From b6ca7e295816702150689aeb181e11ccaad80754 Mon Sep 17 00:00:00 2001 From: "tadeas.zribko" Date: Sat, 4 Apr 2026 13:33:06 +0200 Subject: [PATCH 1/8] refactor: Improve code readability and consistency across various files - Refactor method implementations to use block syntax for better clarity. - Update comments to maintain consistent formatting and improve documentation. - Adjust variable declarations for enhanced readability. - Organize namespaces and remove unnecessary using directives. - Ensure consistent use of access modifiers in interface methods. --- .../Authorization/PermissionPolicyProvider.cs | 17 ++- .../Cache/IOutputCacheInvalidationService.cs | 4 +- .../Cache/OutputCacheInvalidationService.cs | 12 +- .../OutputCacheInvalidationServiceLogs.cs | 4 +- .../Api/Cache/TenantAwareOutputCachePolicy.cs | 14 +- .../ExceptionHandling/ApiExceptionHandler.cs | 19 +-- .../ApiProblemDetailsOptions.cs | 6 +- ...horizationResponsesOperationTransformer.cs | 2 +- .../HealthCheckOpenApiDocumentTransformer.cs | 14 +- .../Api/OpenApi/OpenApiErrorResponseHelper.cs | 18 ++- .../ProblemDetailsOpenApiTransformer.cs | 19 +-- .../Startup/ApplicationBuilderExtensions.cs | 8 +- .../Startup/DatabaseStartupExtensions.cs | 3 +- .../WolverineHandlerChainExtensions.cs | 8 +- .../BackgroundJobs/BackgroundJobsModule.cs | 1 - .../Contracts/ICleanupService.cs | 25 ++-- .../IExternalIntegrationSyncService.cs | 11 +- .../BackgroundJobs/Contracts/IJobQueue.cs | 7 +- .../Contracts/IReindexService.cs | 11 +- .../Domain/BackgroundJobsDbMarker.cs | 9 +- .../Domain/IJobExecutionRepository.cs | 6 +- .../BackgroundJobs/Domain/JobStatus.cs | 5 +- .../GetJobStatus/GetJobStatusRequest.cs | 2 +- .../GetJobStatus/JobStatusResponse.cs | 3 +- .../BackgroundJobs/Features/JobsController.cs | 2 - .../Features/SubmitJob/SubmitJobCommand.cs | 6 +- .../Features/SubmitJob/SubmitJobRequest.cs | 3 +- .../BackgroundJobsInfrastructureLogs.cs | 2 +- .../Persistence/JobExecutionConfiguration.cs | 1 - .../Repositories/JobExecutionRepository.cs | 1 - .../ExternalIntegrationSyncServicePreview.cs | 4 +- .../JobProcessingBackgroundService.cs | 6 +- .../BackgroundJobs/Services/ReindexService.cs | 6 +- .../Services/SoftDeleteCleanupStrategy.cs | 5 +- .../GetFtsIndexNamesProcedure.cs | 11 +- .../GetIndexBloatPercentProcedure.cs | 11 +- .../DragonflyDistributedJobCoordinator.cs | 14 +- .../TickerQ/Jobs/CleanupRecurringJob.cs | 11 +- .../TickerQ/Jobs/ExternalSyncRecurringJob.cs | 10 +- .../TickerQ/Jobs/ReindexRecurringJob.cs | 10 +- .../CleanupRecurringJobRegistration.cs | 4 +- .../ReindexRecurringJobRegistration.cs | 4 +- .../TickerQ/TickerQRecurringJobRegistrar.cs | 4 +- .../TickerQ/TickerQSchedulerDbContext.cs | 8 +- .../TickerQSchedulerDbContextFactory.cs | 5 +- .../BackgroundJobsOptionsValidator.cs | 2 +- .../GetNotificationStreamQuery.cs | 2 - .../SseNotificationItem.cs | 6 +- .../GetNotificationStream/SseStreamRequest.cs | 4 +- .../Contracts/DownloadFileRequest.cs | 6 +- .../Contracts/FileStorageOptions.cs | 8 +- .../Contracts/IFileStorageService.cs | 28 ++-- .../FileStorage/Domain/FileStorageDbMarker.cs | 12 +- .../FileStorage/Errors/DomainErrors.cs | 72 +++++------ .../FileStorage/Errors/ErrorCatalog.cs | 6 - .../FileStorage/Features/FilesController.cs | 11 +- .../Services/LocalFileStorageService.cs | 38 +++--- .../Identity/Common/BootstrapTenantOptions.cs | 3 +- src/Modules/Identity/Common/CorsOptions.cs | 3 +- src/Modules/Identity/Common/DomainErrors.cs | 120 ++++++++++-------- src/Modules/Identity/Common/ErrorCatalog.cs | 2 +- .../Common/IdentityApplicationLogs.cs | 3 +- .../Identity/Common/IdentityDomainErrors.cs | 50 +++++--- .../Identity/Common/KeycloakOptions.cs | 7 +- .../Identity/Common/Security/AuthConstants.cs | 8 +- .../Common/Security/IKeycloakAdminService.cs | 20 ++- .../Common/Security/IRolePermissionMap.cs | 14 +- .../Security/IUserProvisioningService.cs | 10 +- .../Security/StaticRolePermissionMap.cs | 24 ++-- src/Modules/Identity/Entities/AppUser.cs | 31 ++--- src/Modules/Identity/Entities/Tenant.cs | 24 ++-- .../Identity/Entities/TenantInvitation.cs | 26 ++-- src/Modules/Identity/Enums/UserRole.cs | 3 +- .../Features/Bff/DTOs/BffUserResponse.cs | 4 +- .../Tenant/Commands/CreateTenantCommand.cs | 2 - .../Tenant/Commands/DeleteTenantCommand.cs | 2 - .../Tenant/DTOs/CreateTenantRequest.cs | 7 +- .../Features/Tenant/DTOs/TenantResponse.cs | 5 +- .../Tenant/Mappings/TenantMappings.cs | 14 +- .../Tenant/Queries/GetTenantByIdQuery.cs | 6 +- .../Specifications/TenantByIdSpecification.cs | 9 +- .../Specifications/TenantSpecification.cs | 10 +- .../Features/Tenant/TenantSortFields.cs | 4 +- .../CreateTenantRequestValidator.cs | 6 +- .../Validation/TenantFilterValidator.cs | 7 +- .../Commands/CreateTenantInvitationCommand.cs | 4 +- .../Commands/RevokeTenantInvitationCommand.cs | 2 - .../DTOs/AcceptInvitationRequest.cs | 5 +- .../DTOs/CreateTenantInvitationRequest.cs | 4 +- .../DTOs/TenantInvitationResponse.cs | 6 +- .../Mappings/TenantInvitationMappings.cs | 16 ++- .../Queries/GetTenantInvitationsQuery.cs | 6 +- .../TenantInvitationFilterSpecification.cs | 12 +- .../CreateTenantInvitationRequestValidator.cs | 7 +- .../User/Commands/ChangeUserRoleCommand.cs | 2 - .../User/Commands/CreateUserCommand.cs | 3 +- .../User/Commands/DeleteUserCommand.cs | 2 - .../User/Commands/UpdateUserCommand.cs | 2 - .../User/DTOs/ChangeUserRoleRequest.cs | 5 +- .../Features/User/DTOs/CreateUserRequest.cs | 4 +- .../User/DTOs/RequestPasswordResetRequest.cs | 4 +- .../Identity/Features/User/DTOs/UserFilter.cs | 8 +- .../Features/User/Mappings/UserMappings.cs | 13 +- .../Features/User/Queries/GetUserByIdQuery.cs | 6 +- .../Features/User/Queries/GetUsersQuery.cs | 6 +- .../UserByEmailSpecification.cs | 7 +- .../Specifications/UserByIdSpecification.cs | 8 +- .../UserByUsernameSpecification.cs | 6 +- .../User/Specifications/UserFilterCriteria.cs | 7 +- .../Identity/Features/User/UserSortFields.cs | 6 +- .../Features/User/UserValidationHelper.cs | 5 +- .../ChangeUserRoleRequestValidator.cs | 8 +- .../Validation/UpdateUserRequestValidator.cs | 6 +- .../User/Validation/UserFilterValidator.cs | 9 +- .../Identity/Features/V1/BffController.cs | 30 +++-- .../Identity/Features/V1/TenantsController.cs | 13 +- .../CleanupExpiredInvitationsHandler.cs | 9 +- .../Identity/Identity.Domain.GlobalUsings.cs | 1 - src/Modules/Identity/IdentityDbMarker.cs | 7 +- src/Modules/Identity/IdentityModule.cs | 2 +- .../Interfaces/ITenantInvitationRepository.cs | 19 ++- .../Logging/IdentityInfrastructureLogs.cs | 3 +- .../Configurations/AppUserConfiguration.cs | 1 - .../Configurations/TenantConfiguration.cs | 1 - .../TenantInvitationConfiguration.cs | 1 - .../AppUserEntityNormalizationService.cs | 7 +- .../Identity/Persistence/IdentityDbContext.cs | 2 - .../Identity/Repositories/TenantRepository.cs | 36 +++--- .../Identity/Repositories/UserRepository.cs | 25 ++-- .../Security/CookieSessionRefresher.cs | 64 ++++++---- .../Identity/Security/DragonflyTicketStore.cs | 14 +- .../Security/Keycloak/KeycloakAdminService.cs | 26 ++-- .../Keycloak/KeycloakAdminTokenProvider.cs | 30 +++-- .../Security/Keycloak/KeycloakClaimMapper.cs | 8 +- .../Keycloak/KeycloakTokenResponse.cs | 1 - .../Security/Keycloak/KeycloakUrlHelper.cs | 22 ++-- .../Identity/Security/SecureTokenGenerator.cs | 10 +- .../Identity/Security/TenantClaimValidator.cs | 68 +++++----- .../SoftDelete/TenantSoftDeleteCascadeRule.cs | 14 +- src/Modules/Identity/ValueObjects/Email.cs | 37 ++++-- .../Identity/ValueObjects/TenantCode.cs | 23 ++-- .../Notifications/Contracts/EmailMessage.cs | 12 +- .../Notifications/Contracts/IEmailQueue.cs | 11 +- .../Notifications/Contracts/IEmailSender.cs | 12 +- .../Contracts/IEmailTemplateRenderer.cs | 18 +-- .../Contracts/IFailedEmailStore.cs | 18 +-- .../Domain/IFailedEmailRepository.cs | 31 ++--- .../TenantInvitationEmailHandler.cs | 10 +- .../UserRegisteredEmailHandler.cs | 4 +- .../UserRoleChangedEmailHandler.cs | 5 +- .../NotificationsInfrastructureLogs.cs | 2 +- .../Notifications/NotificationsDbMarker.cs | 6 +- .../Notifications/NotificationsModule.cs | 1 - .../NotificationsRuntimeBridge.cs | 4 +- .../Persistence/FailedEmailConfiguration.cs | 7 +- .../Persistence/NotificationsDbContext.cs | 2 - .../Repositories/FailedEmailRepository.cs | 21 +-- .../Services/ChannelEmailQueue.cs | 16 +-- .../Services/EmailRetryRecurringJob.cs | 27 ++-- .../EmailRetryRecurringJobRegistration.cs | 18 +-- .../Services/EmailRetryService.cs | 35 ++--- .../Services/FailedEmailErrorNormalizer.cs | 18 +-- .../Services/FailedEmailStore.cs | 24 ++-- .../Services/FluidEmailTemplateRenderer.cs | 17 ++- .../Services/MailKitEmailSender.cs | 51 ++++---- .../ClaimExpiredFailedEmailsProcedure.cs | 14 +- .../ClaimRetryableFailedEmailsProcedure.cs | 14 +- .../Common/Errors/DomainErrors.cs | 42 +++--- .../Common/Errors/ErrorCatalog.cs | 2 + .../Errors/ProductCatalogDomainErrors.cs | 8 +- .../ProductCatalog/Common/Events/CacheTags.cs | 1 + .../Configurations/CategoryConfiguration.cs | 6 +- .../Entities/ProductData/ProductData.cs | 14 +- .../Entities/ProductData/VideoProductData.cs | 6 +- .../Features/Category/CategoriesController.cs | 2 - .../CreateCategoriesCommand.cs | 5 +- .../CreateCategoriesRequest.cs | 2 +- .../CreateCategories/CreateCategoryRequest.cs | 3 +- .../CategoriesController.DeleteCategories.cs | 2 - .../DeleteCategoriesCommand.cs | 13 +- .../GetCategories/CategorySpecification.cs | 5 +- .../GetCategories/GetCategoriesQuery.cs | 2 +- .../CategoriesController.GetCategoryById.cs | 2 - .../CategoryByIdSpecification.cs | 5 +- .../GetCategoryById/GetCategoryByIdQuery.cs | 5 +- .../CategoriesController.GetCategoryStats.cs | 6 +- .../GetCategoryStats/GetCategoryStatsQuery.cs | 5 +- .../Category/Shared/CategoryResponse.cs | 2 +- .../Category/Shared/CategorySortFields.cs | 7 +- .../CategoriesController.UpdateCategories.cs | 2 - .../UpdateCategoriesCommand.cs | 25 ++-- .../UpdateCategoriesRequest.cs | 5 +- .../UpdateCategoryItemValidator.cs | 4 +- .../UpdateCategories/UpdateCategoryRequest.cs | 2 +- .../CreateProducts/CreateProductRequest.cs | 2 +- .../CreateProductRequestValidator.cs | 3 +- .../CreateProducts/CreateProductsCommand.cs | 9 +- .../CreateProducts/CreateProductsRequest.cs | 2 +- .../ProductsController.CreateProducts.cs | 2 - .../ProductsController.DeleteProducts.cs | 2 - .../GetProductById/GetProductByIdQuery.cs | 2 +- .../ProductByIdSpecification.cs | 3 +- .../ProductByIdWithLinksSpecification.cs | 3 +- .../ProductsController.GetProductById.cs | 2 - .../Product/GetProducts/GetProductsQuery.cs | 2 +- .../GetProducts/ProductFilterCriteria.cs | 11 +- .../GetProducts/ProductFilterValidator.cs | 4 +- .../GetProducts/ProductSpecification.cs | 3 +- .../ProductsController.GetProducts.cs | 2 - .../IdempotentController.Create.cs | 8 +- .../IdempotentCreateCommand.cs | 1 - .../IdempotentCreateRequest.cs | 3 +- .../IdempotentCreateRequestValidator.cs | 6 +- .../Product/PatchProduct/PatchController.cs | 2 - .../PatchProduct/PatchableProductDto.cs | 4 +- .../PatchableProductDtoValidator.cs | 7 +- .../Features/Product/ProductsController.cs | 2 - .../Product/Shared/IProductRequest.cs | 14 +- .../ProductCategoryFacetSpecification.cs | 5 +- .../Shared/ProductCategoryFacetValue.cs | 3 +- .../Product/Shared/ProductMappings.cs | 17 ++- .../Shared/ProductPriceFacetBucketResponse.cs | 3 +- .../Shared/ProductPriceFacetSpecification.cs | 3 +- .../Shared/ProductRequestValidatorBase.cs | 16 ++- .../Shared/ProductSearchFacetsResponse.cs | 2 +- .../Product/Shared/ProductSortFields.cs | 6 +- .../Product/Shared/ProductValidationHelper.cs | 32 ++--- .../Product/Shared/ProductsResponse.cs | 2 +- .../UpdateProductItemValidator.cs | 4 +- .../UpdateProducts/UpdateProductRequest.cs | 3 +- .../UpdateProductRequestValidator.cs | 3 +- .../UpdateProducts/UpdateProductsCommand.cs | 14 +- .../UpdateProducts/UpdateProductsRequest.cs | 4 +- .../CreateImageProductDataCommand.cs | 6 +- .../CreateImageProductDataRequest.cs | 6 +- .../ProductDataController.CreateImage.cs | 2 - .../CreateVideoProductDataRequest.cs | 6 +- .../ProductDataController.CreateVideo.cs | 2 - .../DeleteProductDataCommand.cs | 18 ++- .../ProductDataCascadeDeleteHandler.cs | 2 +- .../ProductDataController.GetAll.cs | 2 - .../ProductDataController.GetById.cs | 2 - .../ProductData/ProductDataController.cs | 1 - .../ProductData/Shared/ProductDataResponse.cs | 10 +- ...roductsForTenantSoftDeleteSpecification.cs | 8 +- .../TenantCascadeDeleteHandler.cs | 9 +- src/Modules/ProductCatalog/GlobalUsings.cs | 1 + .../ProductReviewsByProductDataLoader.cs | 11 +- .../GraphQL/ErrorOrGraphQLExtensions.cs | 20 ++- .../GraphQL/Models/CategoryPageResult.cs | 8 +- .../GraphQL/Models/ProductPageResult.cs | 8 +- .../GraphQL/Models/ProductQueryInput.cs | 10 +- .../GraphQL/Models/ProductReviewQueryInput.cs | 8 +- .../GraphQL/Mutations/ProductMutations.cs | 7 +- .../Mutations/ProductReviewMutations.cs | 8 +- .../GraphQL/Queries/CategoryQueries.cs | 13 +- .../GraphQL/Queries/ProductReviewQueries.cs | 22 ++-- .../GraphQL/Types/ProductReviewType.cs | 8 +- .../GraphQL/Types/ProductType.cs | 14 +- .../GraphQL/Types/ProductTypeResolvers.cs | 19 ++- .../CleanupOrphanedProductDataHandler.cs | 13 +- .../Interfaces/ICategoryRepository.cs | 19 ++- .../Interfaces/IProductDataLinkRepository.cs | 24 ++-- .../Interfaces/IProductDataRepository.cs | 31 +++-- .../Interfaces/IProductRepository.cs | 29 +++-- .../Logging/ProductCatalogApplicationLogs.cs | 4 +- .../ProductCatalogInfrastructureLogs.cs | 3 +- .../Persistence/MongoDbContext.cs | 18 ++- .../Persistence/MongoDbSettings.cs | 5 +- .../Persistence/ProductCatalogDbContext.cs | 6 +- .../ProductCatalog/ProductCatalogDbMarker.cs | 7 +- .../ProductCatalog/ProductCatalogModule.cs | 19 +-- .../Repositories/ProductDataLinkRepository.cs | 20 ++- .../Repositories/ProductDataRepository.cs | 33 +++-- .../Repositories/ProductRepository.cs | 27 ++-- .../ProductSoftDeleteCascadeRule.cs | 15 ++- .../GetProductCategoryStatsProcedure.cs | 24 ++-- .../ProductCatalog/ValueObjects/Price.cs | 27 ++-- .../Reviews/Common/Errors/DomainErrors.cs | 30 +++-- .../Reviews/Domain/ProductReviewMappings.cs | 20 ++- .../Reviews/Domain/ProductReviewResponse.cs | 2 +- .../Reviews/Domain/ProductReviewSortFields.cs | 8 +- src/Modules/Reviews/Domain/Rating.cs | 22 +++- .../CreateProductReviewCommand.cs | 18 ++- .../CreateProductReviewRequestValidator.cs | 5 +- .../DeleteProductReviewCommand.cs | 15 +-- .../GetProductReviewByIdQuery.cs | 3 +- .../GetProductReviewsByProductIdsQuery.cs | 6 +- .../GetProductReviewsQuery.cs | 4 +- .../GetProductReviews/ProductReviewFilter.cs | 6 +- .../ProductReviewFilterCriteria.cs | 8 +- .../ProductReviewFilterValidator.cs | 5 +- .../ProductReviewSpecification.cs | 7 +- .../Features/ProductReviewsController.cs | 34 ++--- .../Persistence/ProductReviewConfiguration.cs | 3 +- .../Reviews/Persistence/ReviewsDbContext.cs | 2 - src/Modules/Reviews/ReviewsDbMarker.cs | 6 +- src/Modules/Reviews/ReviewsModule.cs | 1 - .../Contracts/IWebhookEventHandler.cs | 13 +- .../Contracts/IWebhookPayloadSigner.cs | 11 +- .../Contracts/IWebhookPayloadValidator.cs | 7 +- .../Contracts/IWebhookProcessingQueue.cs | 8 +- .../Webhooks/Contracts/WebhookConstants.cs | 5 +- .../Webhooks/Contracts/WebhookOptions.cs | 7 +- .../Webhooks/Contracts/WebhookPayload.cs | 6 +- .../SendWebhookCallbackHandler.cs | 6 +- .../Logging/WebhooksInfrastructureLogs.cs | 2 +- src/Modules/Webhooks/Security/HmacHelper.cs | 11 +- .../Security/HmacWebhookPayloadSigner.cs | 6 - .../Security/HmacWebhookPayloadValidator.cs | 8 +- .../ValidateWebhookSignatureAttribute.cs | 6 +- .../WebhookSignatureResourceFilter.cs | 8 +- .../Webhooks/Services/ChannelWebhookQueue.cs | 14 +- .../Services/LoggingWebhookEventHandler.cs | 6 - .../OutgoingWebhookBackgroundService.cs | 14 +- src/Modules/Webhooks/WebhooksModule.cs | 2 - .../Application/BackgroundJobs/IQueue.cs | 8 +- .../IRecurringBackgroundJobRegistration.cs | 10 +- .../RecurringBackgroundJobDefinition.cs | 6 +- .../Application/Batch/BatchFailureContext.cs | 26 +++- .../Application/Batch/EntityLookup.cs | 4 +- .../Application/Batch/IBatchRule.cs | 2 +- .../Batch/Rules/FluentValidationBatchRule.cs | 2 +- .../Batch/Rules/MarkMissingByIdBatchRule.cs | 2 +- .../Application/Context/IActorProvider.cs | 6 +- .../Application/Contracts/IDateRangeFilter.cs | 8 +- .../Contracts/IIdempotencyStore.cs | 32 ++--- .../Application/Contracts/ISortableFilter.cs | 8 +- .../Application/DTOs/BatchDeleteRequest.cs | 2 +- .../Application/DTOs/IPagedItems.cs | 6 +- .../Application/DTOs/PaginationFilter.cs | 4 +- .../Application/Errors/ErrorCatalog.cs | 4 +- .../Events/MessageBusExtensions.cs | 4 +- .../Extensions/RepositoryExtensions.cs | 4 +- .../Application/Http/RateLimitPolicies.cs | 2 +- .../Application/Options/AppOptions.cs | 2 +- .../BackgroundJobs/CleanupJobOptions.cs | 4 +- .../BackgroundJobs/EmailRetryJobOptions.cs | 4 +- .../BackgroundJobs/ExternalSyncJobOptions.cs | 2 +- .../BackgroundJobs/ReindexJobOptions.cs | 2 +- .../BackgroundJobs/TickerQSchedulerOptions.cs | 2 +- .../Infrastructure/DragonflyOptions.cs | 4 +- .../Infrastructure/ObservabilityOptions.cs | 12 +- .../TransactionDefaultsOptions.cs | 26 ++-- .../Options/Security/RedactionOptions.cs | 4 +- .../Application/Sorting/SortField.cs | 14 +- .../Application/Sorting/SortFieldMap.cs | 31 +++-- .../Startup/IStartupTaskCoordinator.cs | 10 +- .../Application/Startup/StartupTaskName.cs | 6 +- .../Validation/DataAnnotationsValidator.cs | 29 ++--- .../Validation/DateRangeFilterValidator.cs | 6 +- .../Validation/NotEmptyAttribute.cs | 8 +- .../Validation/PaginationFilterValidator.cs | 4 +- .../Validation/SortableFilterValidator.cs | 4 +- .../Contracts/Api/ControllerExtensions.cs | 6 +- .../Contracts/Api/ErrorOrExtensions.cs | 29 ++--- .../Idempotency/IdempotentAttribute.cs | 4 +- .../Api/RequirePermissionAttribute.cs | 2 +- .../Routing/KebabCaseRouteTokenTransformer.cs | 4 +- .../CleanupOrphanedProductDataCommand.cs | 4 +- .../Contracts/Events/EmailEvents.cs | 6 +- .../Contracts/Events/SoftDeleteEvents.cs | 4 +- .../Contracts/Security/Permission.cs | 72 +++++------ src/SharedKernel/Domain/AuditInfo.cs | 4 +- .../Domain/Contracts/IAuditableEntity.cs | 6 +- .../Contracts/IAuditableTenantEntity.cs | 4 +- src/SharedKernel/Domain/Contracts/IHasId.cs | 4 +- .../Domain/Contracts/ITenantEntity.cs | 6 +- .../Interfaces/IScalarStoredProcedure.cs | 18 +-- .../Domain/Interfaces/IStoredProcedure.cs | 27 ++-- .../Domain/Interfaces/IUnitOfWork.cs | 88 +++++++------ .../Interfaces/IUnitOfWorkOfTContext.cs | 14 +- .../Auditing/IAuditableEntityStateManager.cs | 8 +- .../Services/BoundedChannelQueue.cs | 24 ++-- .../QueueConsumerBackgroundService.cs | 22 +++- .../Configuration/ConfigurationExtensions.cs | 10 +- .../Configuration/ConfigurationSections.cs | 2 +- .../ServiceCollectionOptionsExtensions.cs | 6 +- ...tAuditableEntityConfigurationExtensions.cs | 4 +- .../IEntityNormalizationService.cs | 6 +- .../Health/KeycloakHealthCheck.cs | 8 +- .../Health/KeycloakHealthCheckOptions.cs | 4 +- .../Idempotency/IdempotencyStoreConstants.cs | 5 +- .../Idempotency/InMemoryIdempotencyStore.cs | 29 +++-- .../Logging/LogDataClassifications.cs | 12 +- .../Persistence/ModuleDbContext.cs | 24 ++-- .../Registration/ModuleRegistrationBuilder.cs | 59 ++++----- .../ModuleRegistrationExtensions.cs | 7 +- .../QueueRegistrationExtensions.cs | 6 +- .../Pagination/PagedProjectionBuilder.cs | 6 +- .../Repositories/Pagination/PagedRow.cs | 4 +- .../Repositories/RepositoryBase.cs | 19 ++- .../Resilience/ResilienceDefaults.cs | 2 +- .../SoftDelete/ISoftDeleteCascadeRule.cs | 6 +- .../SoftDelete/ISoftDeleteProcessor.cs | 4 +- .../SoftDelete/SoftDeleteProcessor.cs | 4 +- .../StoredProcedureExecutor.cs | 44 +++++-- .../DbContextCommandTimeoutScope.cs | 10 +- .../DbContextTrackedStateManager.cs | 2 +- .../UnitOfWork/IDbTransactionProvider.cs | 10 +- .../UnitOfWork/ManagedTransactionScope.cs | 7 +- .../Infrastructure/UnitOfWork/UnitOfWork.cs | 30 +++-- .../UnitOfWorkExecutionStrategyFactory.cs | 4 +- .../UnitOfWork/UnitOfWorkForwarder.cs | 28 ++-- .../Jobs/GetJobStatusQueryHandlerTests.cs | 8 +- .../BackgroundJobs/Jobs/JobExecutionTests.cs | 6 +- .../Build/PackageReferencePolicySupport.cs | 67 +++++----- .../Unit/Build/PackageReferencePolicyTests.cs | 2 +- .../Common/ConfigurationExtensionsTests.cs | 21 ++- .../ApiExceptionHandlerTests.cs | 24 ++-- .../RequestContextMiddlewareTests.cs | 12 +- .../Security/StaticRolePermissionMapTests.cs | 8 +- .../Unit/Webhooks/OutgoingWebhookSsrfTests.cs | 8 +- 413 files changed, 2198 insertions(+), 2315 deletions(-) diff --git a/src/APITemplate/Api/Api/Authorization/PermissionPolicyProvider.cs b/src/APITemplate/Api/Api/Authorization/PermissionPolicyProvider.cs index d5e1c48f..db45087d 100644 --- a/src/APITemplate/Api/Api/Authorization/PermissionPolicyProvider.cs +++ b/src/APITemplate/Api/Api/Authorization/PermissionPolicyProvider.cs @@ -9,11 +9,13 @@ namespace APITemplate.Api.Authorization; public sealed class PermissionPolicyProvider : IAuthorizationPolicyProvider { - private readonly DefaultAuthorizationPolicyProvider _fallback; private readonly ConcurrentDictionary _cache = new(); + private readonly DefaultAuthorizationPolicyProvider _fallback; - public PermissionPolicyProvider(IOptions options) => + public PermissionPolicyProvider(IOptions options) + { _fallback = new DefaultAuthorizationPolicyProvider(options); + } public Task GetPolicyAsync(string policyName) { @@ -35,8 +37,13 @@ public PermissionPolicyProvider(IOptions options) => return Task.FromResult(policy); } - public Task GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync(); + public Task GetDefaultPolicyAsync() + { + return _fallback.GetDefaultPolicyAsync(); + } - public Task GetFallbackPolicyAsync() => - _fallback.GetFallbackPolicyAsync(); + public Task GetFallbackPolicyAsync() + { + return _fallback.GetFallbackPolicyAsync(); + } } diff --git a/src/APITemplate/Api/Api/Cache/IOutputCacheInvalidationService.cs b/src/APITemplate/Api/Api/Cache/IOutputCacheInvalidationService.cs index 8fdbcebc..d3ba82f7 100644 --- a/src/APITemplate/Api/Api/Cache/IOutputCacheInvalidationService.cs +++ b/src/APITemplate/Api/Api/Cache/IOutputCacheInvalidationService.cs @@ -2,7 +2,7 @@ namespace APITemplate.Api.Cache; public interface IOutputCacheInvalidationService { - Task EvictAsync(string tag, CancellationToken cancellationToken = default); + public Task EvictAsync(string tag, CancellationToken cancellationToken = default); - Task EvictAsync(IEnumerable tags, CancellationToken cancellationToken = default); + public Task EvictAsync(IEnumerable tags, CancellationToken cancellationToken = default); } diff --git a/src/APITemplate/Api/Api/Cache/OutputCacheInvalidationService.cs b/src/APITemplate/Api/Api/Cache/OutputCacheInvalidationService.cs index 02328538..b3d43d6c 100644 --- a/src/APITemplate/Api/Api/Cache/OutputCacheInvalidationService.cs +++ b/src/APITemplate/Api/Api/Cache/OutputCacheInvalidationService.cs @@ -9,19 +9,23 @@ public sealed class OutputCacheInvalidationService( ILogger logger ) : IOutputCacheInvalidationService { - public Task EvictAsync(string tag, CancellationToken cancellationToken = default) => - EvictAsync([tag], cancellationToken); + public Task EvictAsync(string tag, CancellationToken cancellationToken = default) + { + return EvictAsync([tag], cancellationToken); + } public async Task EvictAsync( IEnumerable tags, CancellationToken cancellationToken = default ) { - var tenantSuffix = tenantProvider.HasTenant ? $"-{tenantProvider.TenantId}" : string.Empty; + string tenantSuffix = tenantProvider.HasTenant + ? $"-{tenantProvider.TenantId}" + : string.Empty; foreach (string tag in tags.Distinct(StringComparer.Ordinal)) { - var targetTag = $"{tag}{tenantSuffix}"; + string targetTag = $"{tag}{tenantSuffix}"; try { await outputCacheStore.EvictByTagAsync(targetTag, cancellationToken); diff --git a/src/APITemplate/Api/Api/Cache/OutputCacheInvalidationServiceLogs.cs b/src/APITemplate/Api/Api/Cache/OutputCacheInvalidationServiceLogs.cs index df47964f..a616ad8a 100644 --- a/src/APITemplate/Api/Api/Cache/OutputCacheInvalidationServiceLogs.cs +++ b/src/APITemplate/Api/Api/Cache/OutputCacheInvalidationServiceLogs.cs @@ -3,8 +3,8 @@ namespace APITemplate.Api.Cache; /// -/// Source-generated logging contract for . -/// Keeps log templates and event identifiers centralized, strongly typed, and allocation-friendly. +/// Source-generated logging contract for . +/// Keeps log templates and event identifiers centralized, strongly typed, and allocation-friendly. /// internal static partial class OutputCacheInvalidationServiceLogs { diff --git a/src/APITemplate/Api/Api/Cache/TenantAwareOutputCachePolicy.cs b/src/APITemplate/Api/Api/Cache/TenantAwareOutputCachePolicy.cs index ba5ef6a1..c10938e6 100644 --- a/src/APITemplate/Api/Api/Cache/TenantAwareOutputCachePolicy.cs +++ b/src/APITemplate/Api/Api/Cache/TenantAwareOutputCachePolicy.cs @@ -15,9 +15,7 @@ CancellationToken cancellationToken !HttpMethods.IsGet(context.HttpContext.Request.Method) && !HttpMethods.IsHead(context.HttpContext.Request.Method) ) - { return ValueTask.CompletedTask; - } string tenantId = context.HttpContext.User.FindFirstValue(AuthConstants.Claims.TenantId) ?? string.Empty; @@ -38,9 +36,7 @@ CancellationToken cancellationToken List originalTags = context.Tags.ToList(); context.Tags.Clear(); foreach (string tag in originalTags) - { context.Tags.Add($"{tag}-{tenantId}"); - } return ValueTask.CompletedTask; } @@ -48,10 +44,16 @@ CancellationToken cancellationToken public ValueTask ServeFromCacheAsync( OutputCacheContext context, CancellationToken cancellationToken - ) => ValueTask.CompletedTask; + ) + { + return ValueTask.CompletedTask; + } public ValueTask ServeResponseAsync( OutputCacheContext context, CancellationToken cancellationToken - ) => ValueTask.CompletedTask; + ) + { + return ValueTask.CompletedTask; + } } diff --git a/src/APITemplate/Api/Api/ExceptionHandling/ApiExceptionHandler.cs b/src/APITemplate/Api/Api/ExceptionHandling/ApiExceptionHandler.cs index 67208cd1..ba3cc5a5 100644 --- a/src/APITemplate/Api/Api/ExceptionHandling/ApiExceptionHandler.cs +++ b/src/APITemplate/Api/Api/ExceptionHandling/ApiExceptionHandler.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; -using SharedKernel.Application.Errors; namespace APITemplate.Api.ExceptionHandling; @@ -38,7 +37,7 @@ CancellationToken cancellationToken } (int statusCode, string? title, string? detail, string? errorCode) = Resolve(exception); - var problemDetails = new ProblemDetails + ProblemDetails problemDetails = new() { Status = statusCode, Title = title, @@ -52,12 +51,14 @@ CancellationToken cancellationToken if (statusCode >= StatusCodes.Status500InternalServerError) _logger.UnhandledException(exception, statusCode, errorCode, context.TraceIdentifier); else + { _logger.HandledApplicationException( exception, statusCode, errorCode, context.TraceIdentifier ); + } context.Response.StatusCode = statusCode; @@ -75,12 +76,14 @@ private static bool IsClientAbortedRequest( HttpContext context, Exception exception, CancellationToken cancellationToken - ) => - exception is OperationCanceledException - && ( - context.RequestAborted.IsCancellationRequested - || cancellationToken.IsCancellationRequested - ); + ) + { + return exception is OperationCanceledException + && ( + context.RequestAborted.IsCancellationRequested + || cancellationToken.IsCancellationRequested + ); + } private static (int StatusCode, string Title, string Detail, string ErrorCode) Resolve( Exception exception diff --git a/src/APITemplate/Api/Api/ExceptionHandling/ApiProblemDetailsOptions.cs b/src/APITemplate/Api/Api/ExceptionHandling/ApiProblemDetailsOptions.cs index 0c19a930..22c9792f 100644 --- a/src/APITemplate/Api/Api/ExceptionHandling/ApiProblemDetailsOptions.cs +++ b/src/APITemplate/Api/Api/ExceptionHandling/ApiProblemDetailsOptions.cs @@ -1,5 +1,3 @@ -using Microsoft.AspNetCore.Mvc; - namespace APITemplate.Api.ExceptionHandling; public static class ApiProblemDetailsOptions @@ -11,8 +9,8 @@ public static void Configure(ProblemDetailsOptions options) IDictionary extensions = context.ProblemDetails.Extensions; extensions["traceId"] = context.HttpContext.TraceIdentifier; - var errorCode = - extensions.TryGetValue("errorCode", out var existingErrorCode) + string errorCode = + extensions.TryGetValue("errorCode", out object? existingErrorCode) && existingErrorCode is string existing ? existing : ErrorCatalog.General.Unknown; diff --git a/src/APITemplate/Api/Api/OpenApi/AuthorizationResponsesOperationTransformer.cs b/src/APITemplate/Api/Api/OpenApi/AuthorizationResponsesOperationTransformer.cs index 36990399..7fd2d2f6 100644 --- a/src/APITemplate/Api/Api/OpenApi/AuthorizationResponsesOperationTransformer.cs +++ b/src/APITemplate/Api/Api/OpenApi/AuthorizationResponsesOperationTransformer.cs @@ -5,7 +5,7 @@ namespace APITemplate.Api.OpenApi; /// -/// Adds 401/403 responses only for operations that require authorization metadata. +/// Adds 401/403 responses only for operations that require authorization metadata. /// public sealed class AuthorizationResponsesOperationTransformer : IOpenApiOperationTransformer { diff --git a/src/APITemplate/Api/Api/OpenApi/HealthCheckOpenApiDocumentTransformer.cs b/src/APITemplate/Api/Api/OpenApi/HealthCheckOpenApiDocumentTransformer.cs index 25aaa238..2fe6b267 100644 --- a/src/APITemplate/Api/Api/OpenApi/HealthCheckOpenApiDocumentTransformer.cs +++ b/src/APITemplate/Api/Api/OpenApi/HealthCheckOpenApiDocumentTransformer.cs @@ -1,17 +1,16 @@ -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi; namespace APITemplate.Api.OpenApi; /// -/// OpenAPI document transformer that manually registers the /health endpoint in the -/// generated specification, since health-check endpoints are not discovered automatically by ASP.NET Core OpenAPI. +/// OpenAPI document transformer that manually registers the /health endpoint in the +/// generated specification, since health-check endpoints are not discovered automatically by ASP.NET Core OpenAPI. /// public sealed class HealthCheckOpenApiDocumentTransformer : IOpenApiDocumentTransformer { /// - /// Adds a GET /health path item with 200 and 503 response descriptions to the document. + /// Adds a GET /health path item with 200 and 503 response descriptions to the document. /// public Task TransformAsync( OpenApiDocument document, @@ -21,15 +20,12 @@ CancellationToken cancellationToken { document.Paths ??= new OpenApiPaths(); - var pathItem = new OpenApiPathItem(); + OpenApiPathItem pathItem = new(); pathItem.AddOperation( HttpMethod.Get, new OpenApiOperation { - Tags = new HashSet - { - new OpenApiTagReference("Health", document, null), - }, + Tags = new HashSet { new("Health", document) }, Summary = "Health check", Description = "Returns the health status of all registered services.", Responses = new OpenApiResponses diff --git a/src/APITemplate/Api/Api/OpenApi/OpenApiErrorResponseHelper.cs b/src/APITemplate/Api/Api/OpenApi/OpenApiErrorResponseHelper.cs index 05baa702..afaff9f6 100644 --- a/src/APITemplate/Api/Api/OpenApi/OpenApiErrorResponseHelper.cs +++ b/src/APITemplate/Api/Api/OpenApi/OpenApiErrorResponseHelper.cs @@ -4,14 +4,14 @@ namespace APITemplate.Api.OpenApi; /// -/// Shared helper for adding RFC 7807 application/problem+json error response entries -/// to OpenAPI operations without duplicating the wiring across multiple transformers. +/// Shared helper for adding RFC 7807 application/problem+json error response entries +/// to OpenAPI operations without duplicating the wiring across multiple transformers. /// internal static class OpenApiErrorResponseHelper { /// - /// Adds an error response for to the operation if one is not already present. - /// Uses the HTTP reason phrase as the description when is not supplied. + /// Adds an error response for to the operation if one is not already present. + /// Uses the HTTP reason phrase as the description when is not supplied. /// internal static void AddErrorResponse( OpenApiOperation operation, @@ -20,10 +20,10 @@ internal static void AddErrorResponse( string? description = null ) { - var statusCodeKey = statusCode.ToString(); + string statusCodeKey = statusCode.ToString(); operation.Responses ??= new OpenApiResponses(); - var resolvedDescription = string.IsNullOrWhiteSpace(description) + string resolvedDescription = string.IsNullOrWhiteSpace(description) ? ReasonPhrases.GetReasonPhrase(statusCode) : description; @@ -40,11 +40,14 @@ out OpenApiMediaType? mediaType ) mediaType.Schema ??= schema; else + { existing.Content["application/problem+json"] = new OpenApiMediaType { Schema = schema, }; + } } + return; } @@ -53,9 +56,10 @@ out OpenApiMediaType? mediaType { response.Content = new Dictionary { - ["application/problem+json"] = new OpenApiMediaType { Schema = schema }, + ["application/problem+json"] = new() { Schema = schema }, }; } + operation.Responses[statusCodeKey] = response; } } diff --git a/src/APITemplate/Api/Api/OpenApi/ProblemDetailsOpenApiTransformer.cs b/src/APITemplate/Api/Api/OpenApi/ProblemDetailsOpenApiTransformer.cs index dc5532cc..b0e94f24 100644 --- a/src/APITemplate/Api/Api/OpenApi/ProblemDetailsOpenApiTransformer.cs +++ b/src/APITemplate/Api/Api/OpenApi/ProblemDetailsOpenApiTransformer.cs @@ -1,19 +1,18 @@ -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi; namespace APITemplate.Api.OpenApi; /// -/// Improved alternative to repeating [ProducesResponseType(typeof(ProblemDetails), 400/404/500)] -/// on every controller action. This transformer adds ProblemDetails responses globally -/// and avoids duplication across individual controllers. +/// Improved alternative to repeating [ProducesResponseType(typeof(ProblemDetails), 400/404/500)] +/// on every controller action. This transformer adds ProblemDetails responses globally +/// and avoids duplication across individual controllers. /// public sealed class ProblemDetailsOpenApiTransformer : IOpenApiDocumentTransformer { /// - /// Registers the shared ApiProblemDetails schema component and attaches standardized - /// error responses (400, 401, 403, 404, 409, 500) to every operation in the document. + /// Registers the shared ApiProblemDetails schema component and attaches standardized + /// error responses (400, 401, 403, 404, 409, 500) to every operation in the document. /// public Task TransformAsync( OpenApiDocument document, @@ -42,12 +41,14 @@ CancellationToken cancellationToken StatusCodes.Status409Conflict, StatusCodes.Status500InternalServerError, ]; - foreach (var statusCode in errorStatusCodes) + foreach (int statusCode in errorStatusCodes) + { OpenApiErrorResponseHelper.AddErrorResponse( operation, statusCode, problemDetailsSchema ); + } } } @@ -55,8 +56,8 @@ CancellationToken cancellationToken } /// - /// Builds the reusable OpenAPI schema for the RFC 7807 ProblemDetails response payload, - /// including the custom traceId, errorCode, and metadata extensions. + /// Builds the reusable OpenAPI schema for the RFC 7807 ProblemDetails response payload, + /// including the custom traceId, errorCode, and metadata extensions. /// private static IOpenApiSchema BuildProblemDetailsSchema() { diff --git a/src/APITemplate/Api/Extensions/Startup/ApplicationBuilderExtensions.cs b/src/APITemplate/Api/Extensions/Startup/ApplicationBuilderExtensions.cs index eadbfa80..8714fc12 100644 --- a/src/APITemplate/Api/Extensions/Startup/ApplicationBuilderExtensions.cs +++ b/src/APITemplate/Api/Extensions/Startup/ApplicationBuilderExtensions.cs @@ -94,9 +94,11 @@ public static WebApplication UseApiDocumentation(this WebApplication app) return app; } - private static bool IsClientAbortedRequest(HttpContext httpContext, Exception? exception) => - exception is OperationCanceledException oce - && oce.CancellationToken == httpContext.RequestAborted; + private static bool IsClientAbortedRequest(HttpContext httpContext, Exception? exception) + { + return exception is OperationCanceledException oce + && oce.CancellationToken == httpContext.RequestAborted; + } private sealed record HostStatusResponse(string Service, string Status); } diff --git a/src/APITemplate/Api/Extensions/Startup/DatabaseStartupExtensions.cs b/src/APITemplate/Api/Extensions/Startup/DatabaseStartupExtensions.cs index 39a5966e..2c5ef79b 100644 --- a/src/APITemplate/Api/Extensions/Startup/DatabaseStartupExtensions.cs +++ b/src/APITemplate/Api/Extensions/Startup/DatabaseStartupExtensions.cs @@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage; +using Npgsql; using ProductCatalog.Persistence; using Reviews.Persistence; @@ -41,7 +42,7 @@ private static async Task EnsureSchemaAsync(IServiceProvider sp) { await creator.CreateTablesAsync(); } - catch (Npgsql.PostgresException ex) when (ex.SqlState == "42P07") + catch (PostgresException ex) when (ex.SqlState == "42P07") { // 42P07 = relation already exists — safe to ignore } diff --git a/src/APITemplate/Api/Extensions/WolverineHandlerChainExtensions.cs b/src/APITemplate/Api/Extensions/WolverineHandlerChainExtensions.cs index b945608d..c6907047 100644 --- a/src/APITemplate/Api/Extensions/WolverineHandlerChainExtensions.cs +++ b/src/APITemplate/Api/Extensions/WolverineHandlerChainExtensions.cs @@ -8,7 +8,9 @@ public static class WolverineHandlerChainExtensions public static bool ShouldApplyErrorOrValidation( this HandlerChain chain, params Assembly[] validatorAssemblies - ) => - validatorAssemblies.Any(chain.MessageType.HasValidatorIn) - && chain.Handlers.Any(handler => handler.Method.ReturnType.IsErrorOrReturnType()); + ) + { + return validatorAssemblies.Any(chain.MessageType.HasValidatorIn) + && chain.Handlers.Any(handler => handler.Method.ReturnType.IsErrorOrReturnType()); + } } diff --git a/src/Modules/BackgroundJobs/BackgroundJobsModule.cs b/src/Modules/BackgroundJobs/BackgroundJobsModule.cs index be03e594..04bc2a6f 100644 --- a/src/Modules/BackgroundJobs/BackgroundJobsModule.cs +++ b/src/Modules/BackgroundJobs/BackgroundJobsModule.cs @@ -1,4 +1,3 @@ -using BackgroundJobs.Features; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; diff --git a/src/Modules/BackgroundJobs/Contracts/ICleanupService.cs b/src/Modules/BackgroundJobs/Contracts/ICleanupService.cs index eb7542ec..c30d14b9 100644 --- a/src/Modules/BackgroundJobs/Contracts/ICleanupService.cs +++ b/src/Modules/BackgroundJobs/Contracts/ICleanupService.cs @@ -1,41 +1,38 @@ namespace BackgroundJobs.Domain; /// -/// Application-layer contract for scheduled data-cleanup operations. -/// Implementations live in the Infrastructure layer and are invoked by recurring background jobs. +/// Application-layer contract for scheduled data-cleanup operations. +/// Implementations live in the Infrastructure layer and are invoked by recurring background jobs. /// public interface ICleanupService { /// - /// Removes expired tenant invitations older than hours, - /// processed in batches of to limit database pressure. + /// Removes expired tenant invitations older than hours, + /// processed in batches of to limit database pressure. /// - Task CleanupExpiredInvitationsAsync( + public Task CleanupExpiredInvitationsAsync( int retentionHours, int batchSize, CancellationToken ct = default ); /// - /// Permanently purges soft-deleted records that exceeded the retention window, - /// processed in batches of . + /// Permanently purges soft-deleted records that exceeded the retention window, + /// processed in batches of . /// - Task CleanupSoftDeletedRecordsAsync( + public Task CleanupSoftDeletedRecordsAsync( int retentionDays, int batchSize, CancellationToken ct = default ); /// - /// Deletes product-data entries that are no longer referenced by any product and have exceeded - /// the retention window, processed in batches of . + /// Deletes product-data entries that are no longer referenced by any product and have exceeded + /// the retention window, processed in batches of . /// - Task CleanupOrphanedProductDataAsync( + public Task CleanupOrphanedProductDataAsync( int retentionDays, int batchSize, CancellationToken ct = default ); } - - - diff --git a/src/Modules/BackgroundJobs/Contracts/IExternalIntegrationSyncService.cs b/src/Modules/BackgroundJobs/Contracts/IExternalIntegrationSyncService.cs index e5f6111d..0459e705 100644 --- a/src/Modules/BackgroundJobs/Contracts/IExternalIntegrationSyncService.cs +++ b/src/Modules/BackgroundJobs/Contracts/IExternalIntegrationSyncService.cs @@ -1,16 +1,13 @@ namespace BackgroundJobs.Domain; /// -/// Application-layer contract for synchronizing data with external third-party integrations. -/// Implementations are provided by the Infrastructure layer and scheduled as recurring background jobs. +/// Application-layer contract for synchronizing data with external third-party integrations. +/// Implementations are provided by the Infrastructure layer and scheduled as recurring background jobs. /// public interface IExternalIntegrationSyncService { /// - /// Pulls changes from external systems and reconciles them with the local data store. + /// Pulls changes from external systems and reconciles them with the local data store. /// - Task SynchronizeAsync(CancellationToken ct = default); + public Task SynchronizeAsync(CancellationToken ct = default); } - - - diff --git a/src/Modules/BackgroundJobs/Contracts/IJobQueue.cs b/src/Modules/BackgroundJobs/Contracts/IJobQueue.cs index 8d6d4486..541c438d 100644 --- a/src/Modules/BackgroundJobs/Contracts/IJobQueue.cs +++ b/src/Modules/BackgroundJobs/Contracts/IJobQueue.cs @@ -1,14 +1,11 @@ namespace BackgroundJobs.Domain; /// -/// Write-side contract for enqueuing generic background job identifiers (as s). +/// 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. +/// Read-side contract for consuming job identifiers from the generic job queue. /// public interface IJobQueueReader : IQueueReader; - - - diff --git a/src/Modules/BackgroundJobs/Contracts/IReindexService.cs b/src/Modules/BackgroundJobs/Contracts/IReindexService.cs index 9686fbdf..d0a7fdfa 100644 --- a/src/Modules/BackgroundJobs/Contracts/IReindexService.cs +++ b/src/Modules/BackgroundJobs/Contracts/IReindexService.cs @@ -1,16 +1,13 @@ namespace BackgroundJobs.Domain; /// -/// Application-layer contract for rebuilding full-text search indexes. -/// Implementations are provided by the Infrastructure layer and scheduled as recurring background jobs. +/// 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. + /// Triggers a full rebuild of the full-text search index for all indexed entities. /// - Task ReindexFullTextSearchAsync(CancellationToken ct = default); + public Task ReindexFullTextSearchAsync(CancellationToken ct = default); } - - - diff --git a/src/Modules/BackgroundJobs/Domain/BackgroundJobsDbMarker.cs b/src/Modules/BackgroundJobs/Domain/BackgroundJobsDbMarker.cs index e5647bdf..9be8f3f4 100644 --- a/src/Modules/BackgroundJobs/Domain/BackgroundJobsDbMarker.cs +++ b/src/Modules/BackgroundJobs/Domain/BackgroundJobsDbMarker.cs @@ -1,11 +1,8 @@ namespace BackgroundJobs.Domain; /// -/// Domain-layer marker type identifying the BackgroundJobs module's persistence boundary. -/// Used as the type parameter for -/// so that handlers can request the correct unit of work without referencing the Infrastructure layer. +/// Domain-layer marker type identifying the BackgroundJobs module's persistence boundary. +/// Used as the type parameter for +/// so that handlers can request the correct unit of work without referencing the Infrastructure layer. /// public abstract class BackgroundJobsDbMarker; - - - diff --git a/src/Modules/BackgroundJobs/Domain/IJobExecutionRepository.cs b/src/Modules/BackgroundJobs/Domain/IJobExecutionRepository.cs index 20c52edf..a20480e4 100644 --- a/src/Modules/BackgroundJobs/Domain/IJobExecutionRepository.cs +++ b/src/Modules/BackgroundJobs/Domain/IJobExecutionRepository.cs @@ -1,9 +1,7 @@ namespace BackgroundJobs.Domain; /// -/// Repository contract for entities, inheriting all generic CRUD operations from . +/// Repository contract for entities, inheriting all generic CRUD operations from +/// . /// public interface IJobExecutionRepository : IRepository; - - - diff --git a/src/Modules/BackgroundJobs/Domain/JobStatus.cs b/src/Modules/BackgroundJobs/Domain/JobStatus.cs index 4b8e32e5..8f1cb8e7 100644 --- a/src/Modules/BackgroundJobs/Domain/JobStatus.cs +++ b/src/Modules/BackgroundJobs/Domain/JobStatus.cs @@ -1,7 +1,7 @@ namespace BackgroundJobs.Domain; /// -/// Represents the execution state of a background . +/// Represents the execution state of a background . /// public enum JobStatus { @@ -17,6 +17,3 @@ public enum JobStatus /// The job terminated with an error and will not be retried automatically. Failed, } - - - diff --git a/src/Modules/BackgroundJobs/Features/GetJobStatus/GetJobStatusRequest.cs b/src/Modules/BackgroundJobs/Features/GetJobStatus/GetJobStatusRequest.cs index 9af581d5..47aff3aa 100644 --- a/src/Modules/BackgroundJobs/Features/GetJobStatus/GetJobStatusRequest.cs +++ b/src/Modules/BackgroundJobs/Features/GetJobStatus/GetJobStatusRequest.cs @@ -1,6 +1,6 @@ namespace BackgroundJobs.Features; /// -/// Carries the unique identifier of the background job whose status is being queried. +/// Carries the unique identifier of the background job whose status is being queried. /// public sealed record GetJobStatusRequest(Guid Id); diff --git a/src/Modules/BackgroundJobs/Features/GetJobStatus/JobStatusResponse.cs b/src/Modules/BackgroundJobs/Features/GetJobStatus/JobStatusResponse.cs index b4d2cd02..8972c5bf 100644 --- a/src/Modules/BackgroundJobs/Features/GetJobStatus/JobStatusResponse.cs +++ b/src/Modules/BackgroundJobs/Features/GetJobStatus/JobStatusResponse.cs @@ -1,7 +1,8 @@ namespace BackgroundJobs.Features; /// -/// Represents the full runtime state of a background job, including progress, result payload, error information, and optional webhook callback URL. +/// 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, diff --git a/src/Modules/BackgroundJobs/Features/JobsController.cs b/src/Modules/BackgroundJobs/Features/JobsController.cs index 9a079a13..9698b378 100644 --- a/src/Modules/BackgroundJobs/Features/JobsController.cs +++ b/src/Modules/BackgroundJobs/Features/JobsController.cs @@ -1,6 +1,4 @@ using Asp.Versioning; -using BackgroundJobs.Features; -using ErrorOr; using Microsoft.AspNetCore.Mvc; using SharedKernel.Contracts.Api; using SharedKernel.Contracts.Security; diff --git a/src/Modules/BackgroundJobs/Features/SubmitJob/SubmitJobCommand.cs b/src/Modules/BackgroundJobs/Features/SubmitJob/SubmitJobCommand.cs index dde90c0c..8c106a08 100644 --- a/src/Modules/BackgroundJobs/Features/SubmitJob/SubmitJobCommand.cs +++ b/src/Modules/BackgroundJobs/Features/SubmitJob/SubmitJobCommand.cs @@ -1,5 +1,3 @@ -using BackgroundJobs.Domain; - namespace BackgroundJobs.Features; public sealed record SubmitJobCommand(SubmitJobRequest Request); @@ -49,8 +47,8 @@ await unitOfWork.ExecuteInTransactionAsync( ); return Error.Failure( - code: SharedKernel.Application.Errors.ErrorCatalog.General.Unknown, - description: "Failed to enqueue job for processing." + ErrorCatalog.General.Unknown, + "Failed to enqueue job for processing." ); } diff --git a/src/Modules/BackgroundJobs/Features/SubmitJob/SubmitJobRequest.cs b/src/Modules/BackgroundJobs/Features/SubmitJob/SubmitJobRequest.cs index a2712dc7..0b76086d 100644 --- a/src/Modules/BackgroundJobs/Features/SubmitJob/SubmitJobRequest.cs +++ b/src/Modules/BackgroundJobs/Features/SubmitJob/SubmitJobRequest.cs @@ -3,7 +3,8 @@ namespace BackgroundJobs.Features; /// -/// Carries the parameters needed to enqueue a new background job, including an optional JSON parameters string and an optional webhook callback URL. +/// 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, diff --git a/src/Modules/BackgroundJobs/Logging/BackgroundJobsInfrastructureLogs.cs b/src/Modules/BackgroundJobs/Logging/BackgroundJobsInfrastructureLogs.cs index a7385e9b..1964b379 100644 --- a/src/Modules/BackgroundJobs/Logging/BackgroundJobsInfrastructureLogs.cs +++ b/src/Modules/BackgroundJobs/Logging/BackgroundJobsInfrastructureLogs.cs @@ -3,7 +3,7 @@ namespace BackgroundJobs.Logging; /// -/// Source-generated logger extension methods for BackgroundJobs infrastructure diagnostics. +/// Source-generated logger extension methods for BackgroundJobs infrastructure diagnostics. /// internal static partial class BackgroundJobsInfrastructureLogs { diff --git a/src/Modules/BackgroundJobs/Persistence/JobExecutionConfiguration.cs b/src/Modules/BackgroundJobs/Persistence/JobExecutionConfiguration.cs index 50e348a7..2cf29d33 100644 --- a/src/Modules/BackgroundJobs/Persistence/JobExecutionConfiguration.cs +++ b/src/Modules/BackgroundJobs/Persistence/JobExecutionConfiguration.cs @@ -1,4 +1,3 @@ -using BackgroundJobs.Domain; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using SharedKernel.Infrastructure.Configurations; diff --git a/src/Modules/BackgroundJobs/Repositories/JobExecutionRepository.cs b/src/Modules/BackgroundJobs/Repositories/JobExecutionRepository.cs index 1f159180..e7457688 100644 --- a/src/Modules/BackgroundJobs/Repositories/JobExecutionRepository.cs +++ b/src/Modules/BackgroundJobs/Repositories/JobExecutionRepository.cs @@ -1,4 +1,3 @@ -using BackgroundJobs.Domain; using BackgroundJobs.Persistence; using SharedKernel.Infrastructure.Repositories; diff --git a/src/Modules/BackgroundJobs/Services/ExternalIntegrationSyncServicePreview.cs b/src/Modules/BackgroundJobs/Services/ExternalIntegrationSyncServicePreview.cs index 1514850a..c38d4e2d 100644 --- a/src/Modules/BackgroundJobs/Services/ExternalIntegrationSyncServicePreview.cs +++ b/src/Modules/BackgroundJobs/Services/ExternalIntegrationSyncServicePreview.cs @@ -4,8 +4,8 @@ namespace BackgroundJobs.Services; /// -/// Placeholder implementation of used until -/// a real provider-specific synchronization workflow is registered. +/// Placeholder implementation of used until +/// a real provider-specific synchronization workflow is registered. /// public sealed class ExternalIntegrationSyncServicePreview : IExternalIntegrationSyncService { diff --git a/src/Modules/BackgroundJobs/Services/JobProcessingBackgroundService.cs b/src/Modules/BackgroundJobs/Services/JobProcessingBackgroundService.cs index 0e2e88cb..a68cb627 100644 --- a/src/Modules/BackgroundJobs/Services/JobProcessingBackgroundService.cs +++ b/src/Modules/BackgroundJobs/Services/JobProcessingBackgroundService.cs @@ -9,8 +9,8 @@ namespace BackgroundJobs.Services; /// -/// Hosted background service that dequeues job IDs from , simulates -/// multi-step processing with progress updates, and dispatches webhook callbacks on completion or failure. +/// Hosted background service that dequeues job IDs from , simulates +/// multi-step processing with progress updates, and dispatches webhook callbacks on completion or failure. /// public sealed class JobProcessingBackgroundService : QueueConsumerBackgroundService { @@ -18,9 +18,9 @@ public sealed class JobProcessingBackgroundService : QueueConsumerBackgroundServ private const int SimulatedStepDelayMs = 200; private const int ProgressPerStep = 20; private const string CompletedResultSummary = "Job completed successfully"; + private readonly ILogger _logger; private readonly IServiceScopeFactory _scopeFactory; - private readonly ILogger _logger; private readonly TimeProvider _timeProvider; public JobProcessingBackgroundService( diff --git a/src/Modules/BackgroundJobs/Services/ReindexService.cs b/src/Modules/BackgroundJobs/Services/ReindexService.cs index caa87d1e..f61d7ac2 100644 --- a/src/Modules/BackgroundJobs/Services/ReindexService.cs +++ b/src/Modules/BackgroundJobs/Services/ReindexService.cs @@ -8,9 +8,9 @@ namespace BackgroundJobs.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. +/// 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 { diff --git a/src/Modules/BackgroundJobs/Services/SoftDeleteCleanupStrategy.cs b/src/Modules/BackgroundJobs/Services/SoftDeleteCleanupStrategy.cs index 6b608d10..dd6838b3 100644 --- a/src/Modules/BackgroundJobs/Services/SoftDeleteCleanupStrategy.cs +++ b/src/Modules/BackgroundJobs/Services/SoftDeleteCleanupStrategy.cs @@ -1,11 +1,10 @@ using Microsoft.EntityFrameworkCore; -using SharedKernel.Domain.Entities.Contracts; namespace BackgroundJobs.Services; /// -/// Generic implementation of that hard-deletes -/// soft-deleted rows in batches using EF Core bulk-delete. +/// Generic implementation of that hard-deletes +/// soft-deleted rows in batches using EF Core bulk-delete. /// public sealed class SoftDeleteCleanupStrategy : ISoftDeleteCleanupStrategy where TEntity : class, ISoftDeletable diff --git a/src/Modules/BackgroundJobs/StoredProcedures/GetFtsIndexNamesProcedure.cs b/src/Modules/BackgroundJobs/StoredProcedures/GetFtsIndexNamesProcedure.cs index 936c118f..2baa32e6 100644 --- a/src/Modules/BackgroundJobs/StoredProcedures/GetFtsIndexNamesProcedure.cs +++ b/src/Modules/BackgroundJobs/StoredProcedures/GetFtsIndexNamesProcedure.cs @@ -1,11 +1,14 @@ namespace BackgroundJobs.StoredProcedures; /// -/// Calls the get_fts_index_names() PostgreSQL function. -/// Returns the names of all full-text search indexes in the public schema -/// (indexes whose definition contains to_tsvector). +/// Calls the get_fts_index_names() PostgreSQL function. +/// Returns the names of all full-text search indexes in the public schema +/// (indexes whose definition contains to_tsvector). /// public sealed record GetFtsIndexNamesProcedure : IScalarStoredProcedure { - public FormattableString ToSql() => $"SELECT * FROM get_fts_index_names()"; + public FormattableString ToSql() + { + return $"SELECT * FROM get_fts_index_names()"; + } } diff --git a/src/Modules/BackgroundJobs/StoredProcedures/GetIndexBloatPercentProcedure.cs b/src/Modules/BackgroundJobs/StoredProcedures/GetIndexBloatPercentProcedure.cs index abc1a1b7..d6b7ff99 100644 --- a/src/Modules/BackgroundJobs/StoredProcedures/GetIndexBloatPercentProcedure.cs +++ b/src/Modules/BackgroundJobs/StoredProcedures/GetIndexBloatPercentProcedure.cs @@ -1,12 +1,15 @@ namespace BackgroundJobs.StoredProcedures; /// -/// Calls the get_index_bloat_percent(p_index_name) PostgreSQL function. -/// Returns the estimated bloat percentage of the given index, calculated from -/// pg_class / pg_stat_user_indexes catalog statistics. +/// Calls the get_index_bloat_percent(p_index_name) PostgreSQL function. +/// Returns the estimated bloat percentage of the given index, calculated from +/// pg_class / pg_stat_user_indexes catalog statistics. /// public sealed record GetIndexBloatPercentProcedure(string IndexName) : IScalarStoredProcedure { - public FormattableString ToSql() => $"SELECT * FROM get_index_bloat_percent({IndexName})"; + public FormattableString ToSql() + { + return $"SELECT * FROM get_index_bloat_percent({IndexName})"; + } } diff --git a/src/Modules/BackgroundJobs/TickerQ/DragonflyDistributedJobCoordinator.cs b/src/Modules/BackgroundJobs/TickerQ/DragonflyDistributedJobCoordinator.cs index 13f3cb1f..fdbd94a0 100644 --- a/src/Modules/BackgroundJobs/TickerQ/DragonflyDistributedJobCoordinator.cs +++ b/src/Modules/BackgroundJobs/TickerQ/DragonflyDistributedJobCoordinator.cs @@ -1,8 +1,6 @@ using BackgroundJobs.Logging; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using SharedKernel.Application.BackgroundJobs; -using SharedKernel.Application.Options.BackgroundJobs; using StackExchange.Redis; namespace BackgroundJobs.TickerQ; @@ -11,6 +9,7 @@ public sealed class DragonflyDistributedJobCoordinator : IDistributedJobCoordina { 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 @@ -19,13 +18,14 @@ public sealed class DragonflyDistributedJobCoordinator : IDistributedJobCoordina 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; + private readonly BackgroundJobsOptions _options; public DragonflyDistributedJobCoordinator( IConnectionMultiplexer connectionMultiplexer, @@ -58,7 +58,7 @@ public async Task ExecuteIfLeaderAsync( lockKey, lockValue, TimeSpan.FromSeconds(LeaseSeconds), - when: When.NotExists + When.NotExists ); if (!acquired) @@ -164,6 +164,8 @@ await database.ScriptEvaluateAsync( } } - private static Task ReleaseAsync(IDatabase database, string key, string value) => - database.ScriptEvaluateAsync(ReleaseLockScript, new { key, value }); + private static Task ReleaseAsync(IDatabase database, string key, string value) + { + return database.ScriptEvaluateAsync(ReleaseLockScript, new { key, value }); + } } diff --git a/src/Modules/BackgroundJobs/TickerQ/Jobs/CleanupRecurringJob.cs b/src/Modules/BackgroundJobs/TickerQ/Jobs/CleanupRecurringJob.cs index d7429017..8af8fc96 100644 --- a/src/Modules/BackgroundJobs/TickerQ/Jobs/CleanupRecurringJob.cs +++ b/src/Modules/BackgroundJobs/TickerQ/Jobs/CleanupRecurringJob.cs @@ -1,9 +1,6 @@ -using BackgroundJobs.Domain; using BackgroundJobs.Logging; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using SharedKernel.Application.BackgroundJobs; -using SharedKernel.Application.Options.BackgroundJobs; using TickerQ.Utilities.Base; namespace BackgroundJobs.TickerQ.Jobs; @@ -12,8 +9,8 @@ public sealed class CleanupRecurringJob { private readonly ICleanupService _cleanupService; private readonly IDistributedJobCoordinator _coordinator; - private readonly CleanupJobOptions _options; private readonly ILogger _logger; + private readonly CleanupJobOptions _options; public CleanupRecurringJob( ICleanupService cleanupService, @@ -29,8 +26,9 @@ ILogger logger } [TickerFunction(TickerQFunctionNames.Cleanup)] - public Task ExecuteAsync(TickerFunctionContext context, CancellationToken ct) => - _coordinator.ExecuteIfLeaderAsync( + public Task ExecuteAsync(TickerFunctionContext context, CancellationToken ct) + { + return _coordinator.ExecuteIfLeaderAsync( TickerQFunctionNames.Cleanup, async token => { @@ -54,4 +52,5 @@ await _cleanupService.CleanupOrphanedProductDataAsync( }, ct ); + } } diff --git a/src/Modules/BackgroundJobs/TickerQ/Jobs/ExternalSyncRecurringJob.cs b/src/Modules/BackgroundJobs/TickerQ/Jobs/ExternalSyncRecurringJob.cs index 8dd6101e..61c55953 100644 --- a/src/Modules/BackgroundJobs/TickerQ/Jobs/ExternalSyncRecurringJob.cs +++ b/src/Modules/BackgroundJobs/TickerQ/Jobs/ExternalSyncRecurringJob.cs @@ -1,16 +1,14 @@ -using BackgroundJobs.Domain; using BackgroundJobs.Logging; using Microsoft.Extensions.Logging; -using SharedKernel.Application.BackgroundJobs; using TickerQ.Utilities.Base; namespace BackgroundJobs.TickerQ.Jobs; public sealed class ExternalSyncRecurringJob { - private readonly IExternalIntegrationSyncService _syncService; private readonly IDistributedJobCoordinator _coordinator; private readonly ILogger _logger; + private readonly IExternalIntegrationSyncService _syncService; public ExternalSyncRecurringJob( IExternalIntegrationSyncService syncService, @@ -24,8 +22,9 @@ ILogger logger } [TickerFunction(TickerQFunctionNames.ExternalSync)] - public Task ExecuteAsync(TickerFunctionContext context, CancellationToken ct) => - _coordinator.ExecuteIfLeaderAsync( + public Task ExecuteAsync(TickerFunctionContext context, CancellationToken ct) + { + return _coordinator.ExecuteIfLeaderAsync( TickerQFunctionNames.ExternalSync, async token => { @@ -34,4 +33,5 @@ public Task ExecuteAsync(TickerFunctionContext context, CancellationToken ct) => }, ct ); + } } diff --git a/src/Modules/BackgroundJobs/TickerQ/Jobs/ReindexRecurringJob.cs b/src/Modules/BackgroundJobs/TickerQ/Jobs/ReindexRecurringJob.cs index 0c6dddc7..f006ad43 100644 --- a/src/Modules/BackgroundJobs/TickerQ/Jobs/ReindexRecurringJob.cs +++ b/src/Modules/BackgroundJobs/TickerQ/Jobs/ReindexRecurringJob.cs @@ -1,16 +1,14 @@ -using BackgroundJobs.Domain; using BackgroundJobs.Logging; using Microsoft.Extensions.Logging; -using SharedKernel.Application.BackgroundJobs; using TickerQ.Utilities.Base; namespace BackgroundJobs.TickerQ.Jobs; public sealed class ReindexRecurringJob { - private readonly IReindexService _reindexService; private readonly IDistributedJobCoordinator _coordinator; private readonly ILogger _logger; + private readonly IReindexService _reindexService; public ReindexRecurringJob( IReindexService reindexService, @@ -24,8 +22,9 @@ ILogger logger } [TickerFunction(TickerQFunctionNames.Reindex)] - public Task ExecuteAsync(TickerFunctionContext context, CancellationToken ct) => - _coordinator.ExecuteIfLeaderAsync( + public Task ExecuteAsync(TickerFunctionContext context, CancellationToken ct) + { + return _coordinator.ExecuteIfLeaderAsync( TickerQFunctionNames.Reindex, async token => { @@ -34,4 +33,5 @@ public Task ExecuteAsync(TickerFunctionContext context, CancellationToken ct) => }, ct ); + } } diff --git a/src/Modules/BackgroundJobs/TickerQ/RecurringJobRegistrations/CleanupRecurringJobRegistration.cs b/src/Modules/BackgroundJobs/TickerQ/RecurringJobRegistrations/CleanupRecurringJobRegistration.cs index 0e7db8d4..cf4b7a35 100644 --- a/src/Modules/BackgroundJobs/TickerQ/RecurringJobRegistrations/CleanupRecurringJobRegistration.cs +++ b/src/Modules/BackgroundJobs/TickerQ/RecurringJobRegistrations/CleanupRecurringJobRegistration.cs @@ -1,7 +1,5 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using SharedKernel.Application.BackgroundJobs; -using SharedKernel.Application.Options.BackgroundJobs; namespace BackgroundJobs.TickerQ.RecurringJobRegistrations; @@ -12,7 +10,7 @@ public RecurringBackgroundJobDefinition Build(IServiceProvider serviceProvider) BackgroundJobsOptions options = serviceProvider .GetRequiredService>() .Value; - return new( + return new RecurringBackgroundJobDefinition( TickerQJobIds.Cleanup, TickerQFunctionNames.Cleanup, options.Cleanup.Cron, diff --git a/src/Modules/BackgroundJobs/TickerQ/RecurringJobRegistrations/ReindexRecurringJobRegistration.cs b/src/Modules/BackgroundJobs/TickerQ/RecurringJobRegistrations/ReindexRecurringJobRegistration.cs index 5e8bb771..1d65abc9 100644 --- a/src/Modules/BackgroundJobs/TickerQ/RecurringJobRegistrations/ReindexRecurringJobRegistration.cs +++ b/src/Modules/BackgroundJobs/TickerQ/RecurringJobRegistrations/ReindexRecurringJobRegistration.cs @@ -1,7 +1,5 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using SharedKernel.Application.BackgroundJobs; -using SharedKernel.Application.Options.BackgroundJobs; namespace BackgroundJobs.TickerQ.RecurringJobRegistrations; @@ -12,7 +10,7 @@ public RecurringBackgroundJobDefinition Build(IServiceProvider serviceProvider) BackgroundJobsOptions options = serviceProvider .GetRequiredService>() .Value; - return new( + return new RecurringBackgroundJobDefinition( TickerQJobIds.Reindex, TickerQFunctionNames.Reindex, options.Reindex.Cron, diff --git a/src/Modules/BackgroundJobs/TickerQ/TickerQRecurringJobRegistrar.cs b/src/Modules/BackgroundJobs/TickerQ/TickerQRecurringJobRegistrar.cs index 30df4ab0..4e0f777d 100644 --- a/src/Modules/BackgroundJobs/TickerQ/TickerQRecurringJobRegistrar.cs +++ b/src/Modules/BackgroundJobs/TickerQ/TickerQRecurringJobRegistrar.cs @@ -1,9 +1,7 @@ using BackgroundJobs.Logging; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using SharedKernel.Application.BackgroundJobs; using TickerQ.Utilities.Entities; namespace BackgroundJobs.TickerQ; @@ -16,10 +14,10 @@ public sealed class TickerQRecurringJobRegistrar private const string UpdatedAtProperty = "UpdatedAt"; private readonly TickerQSchedulerDbContext _dbContext; + private readonly ILogger _logger; private readonly IEnumerable _registrations; private readonly IServiceProvider _serviceProvider; private readonly TimeProvider _timeProvider; - private readonly ILogger _logger; public TickerQRecurringJobRegistrar( TickerQSchedulerDbContext dbContext, diff --git a/src/Modules/BackgroundJobs/TickerQ/TickerQSchedulerDbContext.cs b/src/Modules/BackgroundJobs/TickerQ/TickerQSchedulerDbContext.cs index a47b3903..163b57ab 100644 --- a/src/Modules/BackgroundJobs/TickerQ/TickerQSchedulerDbContext.cs +++ b/src/Modules/BackgroundJobs/TickerQ/TickerQSchedulerDbContext.cs @@ -1,5 +1,5 @@ using Microsoft.EntityFrameworkCore; -using SharedKernel.Application.Options.BackgroundJobs; +using Microsoft.EntityFrameworkCore.Metadata; using TickerQ.EntityFrameworkCore.DbContextFactory; using TickerQ.Utilities.Entities; @@ -15,11 +15,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.HasDefaultSchema(TickerQSchedulerOptions.DefaultSchemaName); base.OnModelCreating(modelBuilder); - foreach ( - Microsoft.EntityFrameworkCore.Metadata.IMutableEntityType entityType in modelBuilder.Model.GetEntityTypes() - ) - { + foreach (IMutableEntityType entityType in modelBuilder.Model.GetEntityTypes()) entityType.SetSchema(TickerQSchedulerOptions.DefaultSchemaName); - } } } diff --git a/src/Modules/BackgroundJobs/TickerQ/TickerQSchedulerDbContextFactory.cs b/src/Modules/BackgroundJobs/TickerQ/TickerQSchedulerDbContextFactory.cs index 1f00e3e9..6f8dd0eb 100644 --- a/src/Modules/BackgroundJobs/TickerQ/TickerQSchedulerDbContextFactory.cs +++ b/src/Modules/BackgroundJobs/TickerQ/TickerQSchedulerDbContextFactory.cs @@ -1,7 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.Extensions.Configuration; -using SharedKernel.Application.Options.BackgroundJobs; namespace BackgroundJobs.TickerQ; @@ -12,8 +11,8 @@ public TickerQSchedulerDbContext CreateDbContext(string[] args) { IConfigurationRoot configuration = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("appsettings.json", optional: true) - .AddJsonFile("appsettings.Development.json", optional: true) + .AddJsonFile("appsettings.json", true) + .AddJsonFile("appsettings.Development.json", true) .AddEnvironmentVariables() .Build(); diff --git a/src/Modules/BackgroundJobs/Validation/BackgroundJobsOptionsValidator.cs b/src/Modules/BackgroundJobs/Validation/BackgroundJobsOptionsValidator.cs index 2d8fc0d1..5d3f44d8 100644 --- a/src/Modules/BackgroundJobs/Validation/BackgroundJobsOptionsValidator.cs +++ b/src/Modules/BackgroundJobs/Validation/BackgroundJobsOptionsValidator.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.Options; using NCrontab; -using SharedKernel.Application.Options.BackgroundJobs; namespace BackgroundJobs.Validation; @@ -104,6 +103,7 @@ private static void ValidateCron(string path, string cron, List failures failures.Add($"{path} is required."); return; } + try { CrontabSchedule.Parse(cron); diff --git a/src/Modules/Chatting/Features/GetNotificationStream/GetNotificationStreamQuery.cs b/src/Modules/Chatting/Features/GetNotificationStream/GetNotificationStreamQuery.cs index 4e22c02e..af39c47f 100644 --- a/src/Modules/Chatting/Features/GetNotificationStream/GetNotificationStreamQuery.cs +++ b/src/Modules/Chatting/Features/GetNotificationStream/GetNotificationStreamQuery.cs @@ -33,5 +33,3 @@ [EnumeratorCancellation] CancellationToken ct } } } - - diff --git a/src/Modules/Chatting/Features/GetNotificationStream/SseNotificationItem.cs b/src/Modules/Chatting/Features/GetNotificationStream/SseNotificationItem.cs index e535500f..98ab4dce 100644 --- a/src/Modules/Chatting/Features/GetNotificationStream/SseNotificationItem.cs +++ b/src/Modules/Chatting/Features/GetNotificationStream/SseNotificationItem.cs @@ -1,9 +1,7 @@ namespace Chatting.Features.GetNotificationStream; /// -/// Represents a single Server-Sent Events (SSE) notification item emitted by the stream, -/// carrying a sequence number, message text, and UTC timestamp. +/// Represents a single Server-Sent Events (SSE) notification item emitted by the stream, +/// carrying a sequence number, message text, and UTC timestamp. /// public sealed record SseNotificationItem(int Sequence, string Message, DateTime TimestampUtc); - - diff --git a/src/Modules/Chatting/Features/GetNotificationStream/SseStreamRequest.cs b/src/Modules/Chatting/Features/GetNotificationStream/SseStreamRequest.cs index c7aa9439..b6103b9f 100644 --- a/src/Modules/Chatting/Features/GetNotificationStream/SseStreamRequest.cs +++ b/src/Modules/Chatting/Features/GetNotificationStream/SseStreamRequest.cs @@ -3,12 +3,10 @@ namespace Chatting.Features.GetNotificationStream; /// -/// Configuration request for the SSE notification stream, specifying how many events should be emitted (1–100). +/// Configuration request for the SSE notification stream, specifying how many events should be emitted (1–100). /// public sealed class SseStreamRequest { [Range(1, 100)] public int Count { get; init; } = 5; } - - diff --git a/src/Modules/FileStorage/Contracts/DownloadFileRequest.cs b/src/Modules/FileStorage/Contracts/DownloadFileRequest.cs index 6b81f861..65c5cf94 100644 --- a/src/Modules/FileStorage/Contracts/DownloadFileRequest.cs +++ b/src/Modules/FileStorage/Contracts/DownloadFileRequest.cs @@ -3,10 +3,6 @@ namespace FileStorage.Contracts; /// -/// Carries the unique identifier of the stored file to be downloaded. +/// Carries the unique identifier of the stored file to be downloaded. /// public sealed record DownloadFileRequest(Guid Id) : IHasId; - - - - diff --git a/src/Modules/FileStorage/Contracts/FileStorageOptions.cs b/src/Modules/FileStorage/Contracts/FileStorageOptions.cs index 48139850..06c3f96b 100644 --- a/src/Modules/FileStorage/Contracts/FileStorageOptions.cs +++ b/src/Modules/FileStorage/Contracts/FileStorageOptions.cs @@ -4,8 +4,8 @@ namespace FileStorage.Contracts; /// -/// Configuration for the local file-storage provider, including the base directory, upload size limit, -/// and allowed file extensions. +/// Configuration for the local file-storage provider, including the base directory, upload size limit, +/// and allowed file extensions. /// public sealed class FileStorageOptions { @@ -24,7 +24,3 @@ public sealed class FileStorageOptions public string[] AllowedExtensions { get; set; } = [".jpg", ".png", ".gif", ".pdf", ".csv", ".txt"]; } - - - - diff --git a/src/Modules/FileStorage/Contracts/IFileStorageService.cs b/src/Modules/FileStorage/Contracts/IFileStorageService.cs index 90ca5ac5..1e2442c1 100644 --- a/src/Modules/FileStorage/Contracts/IFileStorageService.cs +++ b/src/Modules/FileStorage/Contracts/IFileStorageService.cs @@ -1,39 +1,35 @@ namespace FileStorage.Contracts; /// -/// Application-layer abstraction for binary file storage, decoupling handlers from -/// the concrete storage backend (local disk, blob storage, S3, etc.). +/// 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. + /// Persists the contents of under the given + /// and returns a containing the resolved storage path and file size. /// - Task SaveAsync( + public 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. + /// Opens a readable stream for the file at , + /// or returns null if the file does not exist. /// - Task OpenReadAsync(string storagePath, CancellationToken ct = default); + public Task OpenReadAsync(string storagePath, CancellationToken ct = default); /// - /// Permanently removes the file at from the storage backend. + /// Permanently removes the file at from the storage backend. /// - Task DeleteAsync(string storagePath, CancellationToken ct = default); + public Task DeleteAsync(string storagePath, CancellationToken ct = default); } /// -/// Value object returned by describing where -/// the file was stored and how large it is. +/// 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/Modules/FileStorage/Domain/FileStorageDbMarker.cs b/src/Modules/FileStorage/Domain/FileStorageDbMarker.cs index 21e9056f..5275b5e7 100644 --- a/src/Modules/FileStorage/Domain/FileStorageDbMarker.cs +++ b/src/Modules/FileStorage/Domain/FileStorageDbMarker.cs @@ -1,14 +1,8 @@ namespace FileStorage.Domain; /// -/// Domain-layer marker type identifying the FileStorage module's persistence boundary. -/// Used as the type parameter for -/// so that handlers can request the correct unit of work without referencing the Infrastructure layer. +/// Domain-layer marker type identifying the FileStorage module's persistence boundary. +/// Used as the type parameter for +/// so that handlers can request the correct unit of work without referencing the Infrastructure layer. /// public abstract class FileStorageDbMarker; - - - - - - diff --git a/src/Modules/FileStorage/Errors/DomainErrors.cs b/src/Modules/FileStorage/Errors/DomainErrors.cs index 035fd7f8..dfeca816 100644 --- a/src/Modules/FileStorage/Errors/DomainErrors.cs +++ b/src/Modules/FileStorage/Errors/DomainErrors.cs @@ -1,51 +1,49 @@ -using ErrorOr; - namespace FileStorage.Domain; public static class DomainErrors { public static class Files { - public static Error FileNotFound(string fileName) => - Error.NotFound( - code: ErrorCatalog.Files.FileNotFound, - description: $"File '{fileName}' not found." - ); - - public static Error InvalidFileType(string extension) => - Error.Validation( - code: ErrorCatalog.Files.InvalidFileType, - description: $"File type '{extension}' is not allowed." + public static Error FileNotFound(string fileName) + { + return Error.NotFound(ErrorCatalog.Files.FileNotFound, $"File '{fileName}' not found."); + } + + public static Error InvalidFileType(string extension) + { + return Error.Validation( + ErrorCatalog.Files.InvalidFileType, + $"File type '{extension}' is not allowed." ); + } - public static Error FileTooLarge(long maxSize) => - Error.Validation( - code: ErrorCatalog.Files.FileTooLarge, - description: $"File exceeds maximum size of {maxSize} bytes." + public static Error FileTooLarge(long maxSize) + { + return Error.Validation( + ErrorCatalog.Files.FileTooLarge, + $"File exceeds maximum size of {maxSize} bytes." ); - - public static Error InvalidPatchDocument(string message) => - Error.Validation( - code: ErrorCatalog.Files.InvalidPatchDocument, - description: message - ); - - public static Error WebhookInvalidSignature() => - Error.Unauthorized( - code: ErrorCatalog.Files.WebhookInvalidSignature, - description: "Invalid webhook signature." + } + + public static Error InvalidPatchDocument(string message) + { + return Error.Validation(ErrorCatalog.Files.InvalidPatchDocument, message); + } + + public static Error WebhookInvalidSignature() + { + return Error.Unauthorized( + ErrorCatalog.Files.WebhookInvalidSignature, + "Invalid webhook signature." ); + } - public static Error WebhookMissingHeaders() => - Error.Unauthorized( - code: ErrorCatalog.Files.WebhookMissingHeaders, - description: "Required webhook headers are missing." + public static Error WebhookMissingHeaders() + { + return Error.Unauthorized( + ErrorCatalog.Files.WebhookMissingHeaders, + "Required webhook headers are missing." ); + } } } - - - - - - diff --git a/src/Modules/FileStorage/Errors/ErrorCatalog.cs b/src/Modules/FileStorage/Errors/ErrorCatalog.cs index e3173de5..d229e25c 100644 --- a/src/Modules/FileStorage/Errors/ErrorCatalog.cs +++ b/src/Modules/FileStorage/Errors/ErrorCatalog.cs @@ -12,9 +12,3 @@ public static class Files public const string WebhookMissingHeaders = "EXA-0401-WEBHOOK-HDR"; } } - - - - - - diff --git a/src/Modules/FileStorage/Features/FilesController.cs b/src/Modules/FileStorage/Features/FilesController.cs index 6b801ebb..e326e7a1 100644 --- a/src/Modules/FileStorage/Features/FilesController.cs +++ b/src/Modules/FileStorage/Features/FilesController.cs @@ -1,7 +1,4 @@ using Asp.Versioning; -using ErrorOr; -using FileStorage.Contracts; -using FileStorage.Domain; using FileStorage.Features.Download; using FileStorage.Features.Upload; using Microsoft.AspNetCore.Mvc; @@ -17,8 +14,8 @@ namespace FileStorage.Features; public sealed class FilesController(IMessageBus bus) : ApiControllerBase { /// - /// Accepts a multipart form upload, streams the file to local storage via the application - /// layer, and returns a 201 with a Location header pointing to the download endpoint. + /// Accepts a multipart form upload, streams the file to local storage via the application + /// layer, and returns a 201 with a Location header pointing to the download endpoint. /// [HttpPost("upload")] [RequirePermission(Permission.Examples.Upload)] @@ -52,8 +49,8 @@ CancellationToken ct } /// - /// Streams the stored file back to the caller, disposing the underlying stream on error to - /// prevent resource leaks. + /// Streams the stored file back to the caller, disposing the underlying stream on error to + /// prevent resource leaks. /// [HttpGet("{id:guid}/download")] [RequirePermission(Permission.Examples.Download)] diff --git a/src/Modules/FileStorage/Services/LocalFileStorageService.cs b/src/Modules/FileStorage/Services/LocalFileStorageService.cs index ca45f55b..8e877e7f 100644 --- a/src/Modules/FileStorage/Services/LocalFileStorageService.cs +++ b/src/Modules/FileStorage/Services/LocalFileStorageService.cs @@ -4,9 +4,9 @@ namespace FileStorage.Services; /// -/// 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. +/// 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 { @@ -23,8 +23,8 @@ ITenantProvider 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. + /// 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, @@ -32,18 +32,18 @@ public async Task SaveAsync( CancellationToken ct = default ) { - var tenantDir = Path.Combine(_options.BasePath, _tenantProvider.TenantId.ToString()); + string tenantDir = Path.Combine(_options.BasePath, _tenantProvider.TenantId.ToString()); Directory.CreateDirectory(tenantDir); - var safeExtension = Path.GetExtension(Path.GetFileName(fileName)); - var storedFileName = $"{Guid.NewGuid()}{safeExtension}"; - var storagePath = Path.Combine(tenantDir, storedFileName); + string safeExtension = Path.GetExtension(Path.GetFileName(fileName)); + string storedFileName = $"{Guid.NewGuid()}{safeExtension}"; + string storagePath = Path.Combine(tenantDir, storedFileName); ValidatePathWithinBasePath(storagePath); long sizeBytes; await using ( - var output = new FileStream( + FileStream output = new( storagePath, FileMode.Create, FileAccess.Write, @@ -60,7 +60,10 @@ public async Task SaveAsync( return new FileStorageResult(storagePath, sizeBytes); } - /// Opens the file at for reading after path validation; returns if the file does not exist. + /// + /// 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); @@ -80,7 +83,10 @@ public async Task SaveAsync( ); } - /// Deletes the file at after path validation; silently succeeds if the file does not exist. + /// + /// 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); @@ -92,13 +98,13 @@ public Task DeleteAsync(string storagePath, CancellationToken ct = default) } /// - /// Throws if the fully resolved - /// does not reside within the configured base path, preventing path-traversal attacks. + /// Throws if the fully resolved + /// does not reside within the configured base path, preventing path-traversal attacks. /// private void ValidatePathWithinBasePath(string path) { - var fullPath = Path.GetFullPath(path); - var fullBasePath = + string fullPath = Path.GetFullPath(path); + string fullBasePath = Path.GetFullPath(_options.BasePath).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; diff --git a/src/Modules/Identity/Common/BootstrapTenantOptions.cs b/src/Modules/Identity/Common/BootstrapTenantOptions.cs index d7d83d89..b8fbe3fe 100644 --- a/src/Modules/Identity/Common/BootstrapTenantOptions.cs +++ b/src/Modules/Identity/Common/BootstrapTenantOptions.cs @@ -4,7 +4,7 @@ namespace Identity.Options; /// -/// Configuration for the default tenant that is seeded when the application bootstraps for the first time. +/// Configuration for the default tenant that is seeded when the application bootstraps for the first time. /// public sealed class BootstrapTenantOptions { @@ -20,4 +20,3 @@ public sealed class BootstrapTenantOptions [MinLength(1)] public string Name { get; init; } = "Default Tenant"; } - diff --git a/src/Modules/Identity/Common/CorsOptions.cs b/src/Modules/Identity/Common/CorsOptions.cs index 98599ea7..c08f22b2 100644 --- a/src/Modules/Identity/Common/CorsOptions.cs +++ b/src/Modules/Identity/Common/CorsOptions.cs @@ -4,7 +4,7 @@ namespace Identity.Options; /// -/// Configuration for the CORS policy, listing the origins that are permitted to make cross-origin requests. +/// Configuration for the CORS policy, listing the origins that are permitted to make cross-origin requests. /// public sealed class CorsOptions { @@ -12,4 +12,3 @@ public sealed class CorsOptions [Required] public string[] AllowedOrigins { get; init; } = []; } - diff --git a/src/Modules/Identity/Common/DomainErrors.cs b/src/Modules/Identity/Common/DomainErrors.cs index 53e7ae78..b6dd85b1 100644 --- a/src/Modules/Identity/Common/DomainErrors.cs +++ b/src/Modules/Identity/Common/DomainErrors.cs @@ -6,83 +6,103 @@ public static class DomainErrors { public static class Users { - public static Error NotFound(Guid id) => - Error.NotFound( - code: ErrorCatalog.Users.NotFound, - description: $"User with id '{id}' not found." - ); + public static Error NotFound(Guid id) + { + return Error.NotFound(ErrorCatalog.Users.NotFound, $"User with id '{id}' not found."); + } - public static Error EmailAlreadyExists(string email) => - Error.Conflict( - code: ErrorCatalog.Users.EmailAlreadyExists, - description: $"Email '{email}' is already in use." + public static Error EmailAlreadyExists(string email) + { + return Error.Conflict( + ErrorCatalog.Users.EmailAlreadyExists, + $"Email '{email}' is already in use." ); + } - public static Error UsernameAlreadyExists(string username) => - Error.Conflict( - code: ErrorCatalog.Users.UsernameAlreadyExists, - description: $"Username '{username}' is already in use." + public static Error UsernameAlreadyExists(string username) + { + return Error.Conflict( + ErrorCatalog.Users.UsernameAlreadyExists, + $"Username '{username}' is already in use." ); + } } public static class Tenants { - public static Error NotFound(Guid id) => - Error.NotFound( - code: ErrorCatalog.Tenants.NotFound, - description: $"Tenant with id '{id}' not found." + public static Error NotFound(Guid id) + { + return Error.NotFound( + ErrorCatalog.Tenants.NotFound, + $"Tenant with id '{id}' not found." ); + } - public static Error CodeAlreadyExists(string code) => - Error.Conflict( - code: ErrorCatalog.Tenants.CodeAlreadyExists, - description: string.Format(ErrorCatalog.Tenants.CodeAlreadyExistsMessage, code) + public static Error CodeAlreadyExists(string code) + { + return Error.Conflict( + ErrorCatalog.Tenants.CodeAlreadyExists, + string.Format(ErrorCatalog.Tenants.CodeAlreadyExistsMessage, code) ); + } } public static class Invitations { - public static Error NotFound(Guid id) => - Error.NotFound( - code: ErrorCatalog.Invitations.NotFound, - description: $"Invitation with id '{id}' not found." + public static Error NotFound(Guid id) + { + return Error.NotFound( + ErrorCatalog.Invitations.NotFound, + $"Invitation with id '{id}' not found." ); + } - public static Error AlreadyPending(string email) => - Error.Conflict( - code: ErrorCatalog.Invitations.AlreadyPending, - description: $"A pending invitation for '{email}' already exists." + public static Error AlreadyPending(string email) + { + return Error.Conflict( + ErrorCatalog.Invitations.AlreadyPending, + $"A pending invitation for '{email}' already exists." ); + } - public static Error Expired() => - Error.Conflict( - code: ErrorCatalog.Invitations.Expired, - description: ErrorCatalog.Invitations.ExpiredMessage + public static Error Expired() + { + return Error.Conflict( + ErrorCatalog.Invitations.Expired, + ErrorCatalog.Invitations.ExpiredMessage ); + } - public static Error ExpiredCreateNew() => - Error.Conflict( - code: ErrorCatalog.Invitations.Expired, - description: ErrorCatalog.Invitations.ExpiredCreateNewMessage + public static Error ExpiredCreateNew() + { + return Error.Conflict( + ErrorCatalog.Invitations.Expired, + ErrorCatalog.Invitations.ExpiredCreateNewMessage ); + } - public static Error AlreadyAccepted() => - Error.Conflict( - code: ErrorCatalog.Invitations.AlreadyAccepted, - description: ErrorCatalog.Invitations.AlreadyAcceptedMessage + public static Error AlreadyAccepted() + { + return Error.Conflict( + ErrorCatalog.Invitations.AlreadyAccepted, + ErrorCatalog.Invitations.AlreadyAcceptedMessage ); + } - public static Error NotPending() => - Error.Conflict( - code: ErrorCatalog.Invitations.NotPending, - description: ErrorCatalog.Invitations.NotPendingMessage + public static Error NotPending() + { + return Error.Conflict( + ErrorCatalog.Invitations.NotPending, + ErrorCatalog.Invitations.NotPendingMessage ); + } - public static Error NotFoundOrExpired() => - Error.NotFound( - code: ErrorCatalog.Invitations.NotFound, - description: ErrorCatalog.Invitations.NotFoundOrExpiredMessage + public static Error NotFoundOrExpired() + { + return Error.NotFound( + ErrorCatalog.Invitations.NotFound, + ErrorCatalog.Invitations.NotFoundOrExpiredMessage ); + } } } - diff --git a/src/Modules/Identity/Common/ErrorCatalog.cs b/src/Modules/Identity/Common/ErrorCatalog.cs index 30d78c2f..f3ebb190 100644 --- a/src/Modules/Identity/Common/ErrorCatalog.cs +++ b/src/Modules/Identity/Common/ErrorCatalog.cs @@ -28,8 +28,8 @@ public static class Invitations 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/Modules/Identity/Common/IdentityApplicationLogs.cs b/src/Modules/Identity/Common/IdentityApplicationLogs.cs index a4d13e32..a5078296 100644 --- a/src/Modules/Identity/Common/IdentityApplicationLogs.cs +++ b/src/Modules/Identity/Common/IdentityApplicationLogs.cs @@ -4,7 +4,7 @@ namespace Identity.Logging; /// -/// Source-generated logger extension methods for Identity application diagnostics. +/// Source-generated logger extension methods for Identity application diagnostics. /// internal static partial class IdentityApplicationLogs { @@ -55,4 +55,3 @@ public static partial void PasswordResetEmailFailed( Guid userId ); } - diff --git a/src/Modules/Identity/Common/IdentityDomainErrors.cs b/src/Modules/Identity/Common/IdentityDomainErrors.cs index 4bee38b9..7715df53 100644 --- a/src/Modules/Identity/Common/IdentityDomainErrors.cs +++ b/src/Modules/Identity/Common/IdentityDomainErrors.cs @@ -6,31 +6,45 @@ internal static class IdentityDomainErrors { internal static class TenantCodes { - internal static Error Empty() => - Error.Validation("TC-0400-EMPTY", "Tenant code cannot be empty."); - - internal static Error TooLong() => - Error.Validation("TC-0400-LENGTH", "Tenant code cannot exceed 100 characters."); + internal static Error Empty() + { + return Error.Validation("TC-0400-EMPTY", "Tenant code cannot be empty."); + } + + internal static Error TooLong() + { + return Error.Validation("TC-0400-LENGTH", "Tenant code cannot exceed 100 characters."); + } } internal static class Emails { - internal static Error Empty() => - Error.Validation("EMAIL-0400-EMPTY", "Email cannot be empty."); - - internal static Error InvalidFormat() => - Error.Validation("EMAIL-0400-FORMAT", "Invalid email format."); - - internal static Error TooLong() => - Error.Validation("EMAIL-0400-LENGTH", "Email cannot exceed 320 characters."); + internal static Error Empty() + { + return Error.Validation("EMAIL-0400-EMPTY", "Email cannot be empty."); + } + + internal static Error InvalidFormat() + { + return Error.Validation("EMAIL-0400-FORMAT", "Invalid email format."); + } + + internal static Error TooLong() + { + return Error.Validation("EMAIL-0400-LENGTH", "Email cannot exceed 320 characters."); + } } internal static class Invitations { - internal static Error Expired() => Error.Conflict("INV-0410", "Invitation has expired."); - - internal static Error AlreadyAccepted() => - Error.Conflict("INV-0409-ACCEPTED", "Invitation has already been accepted."); + internal static Error Expired() + { + return Error.Conflict("INV-0410", "Invitation has expired."); + } + + internal static Error AlreadyAccepted() + { + return Error.Conflict("INV-0409-ACCEPTED", "Invitation has already been accepted."); + } } } - diff --git a/src/Modules/Identity/Common/KeycloakOptions.cs b/src/Modules/Identity/Common/KeycloakOptions.cs index b3d4f72e..8d40f41e 100644 --- a/src/Modules/Identity/Common/KeycloakOptions.cs +++ b/src/Modules/Identity/Common/KeycloakOptions.cs @@ -5,8 +5,8 @@ namespace Identity.Options; /// -/// Configuration for the Keycloak identity provider, covering realm, server URL, client credentials, -/// and startup readiness-check behaviour. +/// Configuration for the Keycloak identity provider, covering realm, server URL, client credentials, +/// and startup readiness-check behaviour. /// public sealed class KeycloakOptions { @@ -39,7 +39,7 @@ public sealed class KeycloakOptions } /// -/// Client-secret credentials used when authenticating against the Keycloak Admin REST API. +/// Client-secret credentials used when authenticating against the Keycloak Admin REST API. /// public sealed class KeycloakCredentialsOptions { @@ -47,4 +47,3 @@ public sealed class KeycloakCredentialsOptions [ConfigurationKeyName("secret")] public string Secret { get; init; } = string.Empty; } - diff --git a/src/Modules/Identity/Common/Security/AuthConstants.cs b/src/Modules/Identity/Common/Security/AuthConstants.cs index a30d8943..a0fefaec 100644 --- a/src/Modules/Identity/Common/Security/AuthConstants.cs +++ b/src/Modules/Identity/Common/Security/AuthConstants.cs @@ -1,7 +1,7 @@ namespace Identity.Security; /// -/// Shared constants for authentication, OpenID Connect, and OAuth2 token payload names. +/// Shared constants for authentication, OpenID Connect, and OAuth2 token payload names. /// public static class AuthConstants { @@ -80,11 +80,11 @@ public static class Claims } /// - /// Constants for the custom CSRF header contract used by CsrfValidationMiddleware. + /// Constants for the custom CSRF header contract used by CsrfValidationMiddleware. /// /// - /// SPAs retrieve these values at runtime via GET /api/v1/bff/csrf and must send - /// X-CSRF: 1 on every non-safe (mutating) request authenticated with a session cookie. + /// SPAs retrieve these values at runtime via GET /api/v1/bff/csrf and must send + /// X-CSRF: 1 on every non-safe (mutating) request authenticated with a session cookie. /// public static class Csrf { diff --git a/src/Modules/Identity/Common/Security/IKeycloakAdminService.cs b/src/Modules/Identity/Common/Security/IKeycloakAdminService.cs index 3a8c5fab..e390ec38 100644 --- a/src/Modules/Identity/Common/Security/IKeycloakAdminService.cs +++ b/src/Modules/Identity/Common/Security/IKeycloakAdminService.cs @@ -1,20 +1,28 @@ namespace Identity.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. +/// 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); + public 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); + public Task SendPasswordResetEmailAsync(string keycloakUserId, CancellationToken ct = default); /// Enables or disables the specified Keycloak user account. - Task SetUserEnabledAsync(string keycloakUserId, bool enabled, CancellationToken ct = default); + public Task SetUserEnabledAsync( + string keycloakUserId, + bool enabled, + CancellationToken ct = default + ); /// Permanently deletes the specified Keycloak user. - Task DeleteUserAsync(string keycloakUserId, CancellationToken ct = default); + public Task DeleteUserAsync(string keycloakUserId, CancellationToken ct = default); } diff --git a/src/Modules/Identity/Common/Security/IRolePermissionMap.cs b/src/Modules/Identity/Common/Security/IRolePermissionMap.cs index 370e062e..de433afa 100644 --- a/src/Modules/Identity/Common/Security/IRolePermissionMap.cs +++ b/src/Modules/Identity/Common/Security/IRolePermissionMap.cs @@ -1,17 +1,17 @@ namespace Identity.Security; /// -/// Defines the contract for querying which permissions are granted to a given role. -/// Decouples authorization policy evaluation from the concrete permission mapping strategy. +/// 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 the complete set of permission strings granted to . + public IReadOnlySet GetPermissions(string role); /// - /// Returns when has been granted - /// the specified . + /// Returns when has been granted + /// the specified . /// - bool HasPermission(string role, string permission); + public bool HasPermission(string role, string permission); } diff --git a/src/Modules/Identity/Common/Security/IUserProvisioningService.cs b/src/Modules/Identity/Common/Security/IUserProvisioningService.cs index 96dc25d8..4651aba4 100644 --- a/src/Modules/Identity/Common/Security/IUserProvisioningService.cs +++ b/src/Modules/Identity/Common/Security/IUserProvisioningService.cs @@ -1,16 +1,16 @@ namespace Identity.Security; /// -/// Application-layer port that ensures a local record exists for an -/// authenticated Keycloak identity, creating one on first login if necessary. +/// 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. + /// 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( + public Task ProvisionIfNeededAsync( string keycloakUserId, string email, string username, diff --git a/src/Modules/Identity/Common/Security/StaticRolePermissionMap.cs b/src/Modules/Identity/Common/Security/StaticRolePermissionMap.cs index 98be5566..1730d109 100644 --- a/src/Modules/Identity/Common/Security/StaticRolePermissionMap.cs +++ b/src/Modules/Identity/Common/Security/StaticRolePermissionMap.cs @@ -1,9 +1,9 @@ namespace Identity.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. +/// 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 { @@ -13,18 +13,22 @@ public sealed class StaticRolePermissionMap : IRolePermissionMap private static readonly IReadOnlyDictionary> Map = BuildMap(); - public IReadOnlySet GetPermissions(string role) => - Map.TryGetValue(role, out IReadOnlySet? permissions) ? permissions : Empty; + public IReadOnlySet GetPermissions(string role) + { + return Map.TryGetValue(role, out IReadOnlySet? permissions) ? permissions : Empty; + } - public bool HasPermission(string role, string permission) => - GetPermissions(role).Contains(permission); + public bool HasPermission(string role, string permission) + { + return GetPermissions(role).Contains(permission); + } /// - /// Constructs the static role-to-permissions dictionary used for all permission lookups. + /// Constructs the static role-to-permissions dictionary used for all permission lookups. /// private static Dictionary> BuildMap() { - var tenantAdminPermissions = new HashSet(StringComparer.Ordinal) + HashSet tenantAdminPermissions = new(StringComparer.Ordinal) { Permission.Products.Read, Permission.Products.Create, @@ -52,7 +56,7 @@ private static Dictionary> BuildMap() Permission.Examples.Download, }; - var userPermissions = new HashSet(StringComparer.Ordinal) + HashSet userPermissions = new(StringComparer.Ordinal) { Permission.Products.Read, Permission.Categories.Read, diff --git a/src/Modules/Identity/Entities/AppUser.cs b/src/Modules/Identity/Entities/AppUser.cs index 323a9832..634435cf 100644 --- a/src/Modules/Identity/Entities/AppUser.cs +++ b/src/Modules/Identity/Entities/AppUser.cs @@ -3,16 +3,14 @@ namespace Identity.Entities; /// -/// Domain entity representing an application user belonging to a tenant. -/// Tracks identity information, Keycloak linkage, role, and soft-delete state. +/// 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). - /// Setting this property also updates . + /// Original username exactly as entered by the user (preserves casing and formatting). + /// Setting this property also updates . /// public required string Username { @@ -27,14 +25,14 @@ public required string Username } /// - /// Uppercase, trimmed version of the username. - /// Used for fast database indexing, case-insensitive uniqueness checks, and reliable logins. + /// Uppercase, trimmed version of the username. + /// Used for fast database indexing, case-insensitive uniqueness checks, 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). - /// Setting this property also updates . + /// Original email exactly as entered by the user. Required for correct email delivery (RFC compliance). + /// Setting this property also updates . /// public required Email Email { @@ -47,13 +45,13 @@ public required Email Email } /// - /// Uppercase, trimmed version of the email. - /// Used for fast database indexing, case-insensitive uniqueness checks, and reliable logins. + /// Uppercase, trimmed version of the email. + /// Used for fast database indexing, case-insensitive uniqueness checks, 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. + /// The user's subject ID in Keycloak. Nullable — existing users may not have one yet. /// public string? KeycloakUserId { get; set; } @@ -65,8 +63,11 @@ public required Email Email public bool IsDeleted { get; set; } public DateTime? DeletedAtUtc { get; set; } public Guid? DeletedBy { get; set; } + public Guid Id { get; set; } /// Returns the canonical form of a username: trimmed and converted to uppercase invariant. - public static string NormalizeUsername(string username) => username.Trim().ToUpperInvariant(); + public static string NormalizeUsername(string username) + { + return username.Trim().ToUpperInvariant(); + } } - diff --git a/src/Modules/Identity/Entities/Tenant.cs b/src/Modules/Identity/Entities/Tenant.cs index 05ec7509..5eb58ae3 100644 --- a/src/Modules/Identity/Entities/Tenant.cs +++ b/src/Modules/Identity/Entities/Tenant.cs @@ -3,13 +3,11 @@ namespace Identity.Entities; /// -/// Aggregate root representing a tenant (organisation) in the multi-tenant system. -/// All other tenant-scoped entities reference this entity through . +/// 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 TenantCode Code { get; set; } public required string Name @@ -28,18 +26,26 @@ public required string Name public bool IsDeleted { get; set; } public DateTime? DeletedAtUtc { get; set; } public Guid? DeletedBy { get; set; } + public Guid Id { get; set; } - public static Tenant Create(Guid id, TenantCode code, string name) => - new() + public static Tenant Create(Guid id, TenantCode code, string name) + { + return new Tenant { Id = id, TenantId = id, Code = code, Name = name, }; + } - public void Activate() => IsActive = true; + public void Activate() + { + IsActive = true; + } - public void Deactivate() => IsActive = false; + public void Deactivate() + { + IsActive = false; + } } - diff --git a/src/Modules/Identity/Entities/TenantInvitation.cs b/src/Modules/Identity/Entities/TenantInvitation.cs index 0a36f212..2d04da56 100644 --- a/src/Modules/Identity/Entities/TenantInvitation.cs +++ b/src/Modules/Identity/Entities/TenantInvitation.cs @@ -1,17 +1,15 @@ using ErrorOr; -using Identity.Errors; using Identity.ValueObjects; namespace Identity.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 . +/// 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 Email Email { get => field; @@ -32,6 +30,7 @@ private set public bool IsDeleted { get; set; } public DateTime? DeletedAtUtc { get; set; } public Guid? DeletedBy { get; set; } + public Guid Id { get; set; } public static TenantInvitation Create( Email email, @@ -48,8 +47,10 @@ TimeProvider timeProvider return invitation; } - public bool IsExpired(TimeProvider timeProvider) => - ExpiresAtUtc < timeProvider.GetUtcNow().UtcDateTime; + public bool IsExpired(TimeProvider timeProvider) + { + return ExpiresAtUtc < timeProvider.GetUtcNow().UtcDateTime; + } public ErrorOr Accept(TimeProvider timeProvider) { @@ -61,8 +62,13 @@ public ErrorOr Accept(TimeProvider timeProvider) return Result.Success; } - public void Revoke() => Status = InvitationStatus.Revoked; + public void Revoke() + { + Status = InvitationStatus.Revoked; + } - public void RefreshToken(string tokenHash) => TokenHash = tokenHash; + public void RefreshToken(string tokenHash) + { + TokenHash = tokenHash; + } } - diff --git a/src/Modules/Identity/Enums/UserRole.cs b/src/Modules/Identity/Enums/UserRole.cs index 84602bd9..8aed4bfa 100644 --- a/src/Modules/Identity/Enums/UserRole.cs +++ b/src/Modules/Identity/Enums/UserRole.cs @@ -1,7 +1,7 @@ namespace Identity.Enums; /// -/// Defines the authorization role assigned to an . +/// Defines the authorization role assigned to an . /// public enum UserRole { @@ -14,4 +14,3 @@ public enum UserRole /// An administrator with elevated access scoped to a single tenant. TenantAdmin = 2, } - diff --git a/src/Modules/Identity/Features/Bff/DTOs/BffUserResponse.cs b/src/Modules/Identity/Features/Bff/DTOs/BffUserResponse.cs index a670ff42..aa8bcb34 100644 --- a/src/Modules/Identity/Features/Bff/DTOs/BffUserResponse.cs +++ b/src/Modules/Identity/Features/Bff/DTOs/BffUserResponse.cs @@ -1,7 +1,8 @@ namespace Identity.Features.Bff.DTOs; /// -/// Represents the authenticated user's identity and role information returned by the Backend-for-Frontend (BFF) user endpoint. +/// Represents the authenticated user's identity and role information returned by the Backend-for-Frontend (BFF) user +/// endpoint. /// public sealed record BffUserResponse( string? UserId, @@ -10,4 +11,3 @@ public sealed record BffUserResponse( string? TenantId, string[] Roles ); - diff --git a/src/Modules/Identity/Features/Tenant/Commands/CreateTenantCommand.cs b/src/Modules/Identity/Features/Tenant/Commands/CreateTenantCommand.cs index ae1ca070..ebe0c11d 100644 --- a/src/Modules/Identity/Features/Tenant/Commands/CreateTenantCommand.cs +++ b/src/Modules/Identity/Features/Tenant/Commands/CreateTenantCommand.cs @@ -1,6 +1,5 @@ using ErrorOr; using Identity.Features.Tenant.Mappings; -using Identity; using Identity.ValueObjects; using Wolverine; using TenantEntity = Identity.Entities.Tenant; @@ -55,4 +54,3 @@ CancellationToken ct return (tenant.ToResponse(), messages); } } - diff --git a/src/Modules/Identity/Features/Tenant/Commands/DeleteTenantCommand.cs b/src/Modules/Identity/Features/Tenant/Commands/DeleteTenantCommand.cs index 91c19a36..7f280fc3 100644 --- a/src/Modules/Identity/Features/Tenant/Commands/DeleteTenantCommand.cs +++ b/src/Modules/Identity/Features/Tenant/Commands/DeleteTenantCommand.cs @@ -1,5 +1,4 @@ using ErrorOr; -using Identity; using Wolverine; using TenantEntity = Identity.Entities.Tenant; @@ -46,4 +45,3 @@ await unitOfWork.ExecuteInTransactionAsync( return (Result.Success, messages); } } - diff --git a/src/Modules/Identity/Features/Tenant/DTOs/CreateTenantRequest.cs b/src/Modules/Identity/Features/Tenant/DTOs/CreateTenantRequest.cs index b2e79625..da3026be 100644 --- a/src/Modules/Identity/Features/Tenant/DTOs/CreateTenantRequest.cs +++ b/src/Modules/Identity/Features/Tenant/DTOs/CreateTenantRequest.cs @@ -3,10 +3,9 @@ namespace Identity.Features.Tenant.DTOs; /// -/// Represents the request payload for creating a new tenant. +/// Represents the request payload for creating a new tenant. /// public sealed record CreateTenantRequest( - [Required, MaxLength(100)] string Code, - [Required, MaxLength(200)] string Name + [Required] [MaxLength(100)] string Code, + [Required] [MaxLength(200)] string Name ); - diff --git a/src/Modules/Identity/Features/Tenant/DTOs/TenantResponse.cs b/src/Modules/Identity/Features/Tenant/DTOs/TenantResponse.cs index 575c3925..dd66f2e8 100644 --- a/src/Modules/Identity/Features/Tenant/DTOs/TenantResponse.cs +++ b/src/Modules/Identity/Features/Tenant/DTOs/TenantResponse.cs @@ -1,9 +1,7 @@ -using SharedKernel.Domain.Entities.Contracts; - namespace Identity.Features.Tenant.DTOs; /// -/// Read model returned to callers after a tenant query or creation. +/// Read model returned to callers after a tenant query or creation. /// public sealed record TenantResponse( Guid Id, @@ -12,4 +10,3 @@ public sealed record TenantResponse( bool IsActive, DateTime CreatedAtUtc ) : IHasId; - diff --git a/src/Modules/Identity/Features/Tenant/Mappings/TenantMappings.cs b/src/Modules/Identity/Features/Tenant/Mappings/TenantMappings.cs index f0f04c80..b96c02d8 100644 --- a/src/Modules/Identity/Features/Tenant/Mappings/TenantMappings.cs +++ b/src/Modules/Identity/Features/Tenant/Mappings/TenantMappings.cs @@ -1,16 +1,16 @@ using System.Linq.Expressions; -using Identity.Features.Tenant.DTOs; using TenantEntity = Identity.Entities.Tenant; namespace Identity.Features.Tenant.Mappings; /// -/// Provides LINQ-compatible projection expressions and in-process mapping helpers for Tenant entities. +/// 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. + /// 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( @@ -25,8 +25,10 @@ public static class TenantMappings Projection.Compile(); /// - /// Maps a Tenant entity to a using the pre-compiled projection. + /// Maps a Tenant entity to a using the pre-compiled projection. /// - public static TenantResponse ToResponse(this TenantEntity tenant) => CompiledProjection(tenant); + public static TenantResponse ToResponse(this TenantEntity tenant) + { + return CompiledProjection(tenant); + } } - diff --git a/src/Modules/Identity/Features/Tenant/Queries/GetTenantByIdQuery.cs b/src/Modules/Identity/Features/Tenant/Queries/GetTenantByIdQuery.cs index eb845e8f..aba52260 100644 --- a/src/Modules/Identity/Features/Tenant/Queries/GetTenantByIdQuery.cs +++ b/src/Modules/Identity/Features/Tenant/Queries/GetTenantByIdQuery.cs @@ -1,8 +1,5 @@ -using Identity.Features.Tenant.DTOs; -using SharedKernel.Domain.Entities.Contracts; -using Identity.Features.Tenant.Specifications; -using Identity.Interfaces; using ErrorOr; +using Identity.Features.Tenant.Specifications; namespace Identity.Features.Tenant; @@ -26,4 +23,3 @@ CancellationToken ct return result; } } - diff --git a/src/Modules/Identity/Features/Tenant/Specifications/TenantByIdSpecification.cs b/src/Modules/Identity/Features/Tenant/Specifications/TenantByIdSpecification.cs index 040c79d5..49d0694f 100644 --- a/src/Modules/Identity/Features/Tenant/Specifications/TenantByIdSpecification.cs +++ b/src/Modules/Identity/Features/Tenant/Specifications/TenantByIdSpecification.cs @@ -1,21 +1,20 @@ -using Identity.Features.Tenant.DTOs; -using Identity.Features.Tenant.Mappings; using Ardalis.Specification; +using Identity.Features.Tenant.Mappings; using TenantEntity = Identity.Entities.Tenant; namespace Identity.Features.Tenant.Specifications; /// -/// Ardalis specification that fetches a single tenant by ID and projects it to . +/// Ardalis specification that fetches a single tenant by ID and projects it to . /// public sealed class TenantByIdSpecification : Specification { /// - /// Initialises the specification to match the tenant with the given and apply the response projection. + /// Initialises the specification to match the tenant with the given and apply the response + /// projection. /// public TenantByIdSpecification(Guid id) { Query.Where(tenant => tenant.Id == id).AsNoTracking().Select(TenantMappings.Projection); } } - diff --git a/src/Modules/Identity/Features/Tenant/Specifications/TenantSpecification.cs b/src/Modules/Identity/Features/Tenant/Specifications/TenantSpecification.cs index cd2b81c7..e3a11cf4 100644 --- a/src/Modules/Identity/Features/Tenant/Specifications/TenantSpecification.cs +++ b/src/Modules/Identity/Features/Tenant/Specifications/TenantSpecification.cs @@ -1,17 +1,18 @@ -using Identity.Features.Tenant.DTOs; -using Identity.Features.Tenant.Mappings; using Ardalis.Specification; +using Identity.Features.Tenant.Mappings; using TenantEntity = Identity.Entities.Tenant; namespace Identity.Features.Tenant.Specifications; /// -/// Ardalis specification that retrieves a filtered and sorted list of tenants projected to . +/// Ardalis specification that retrieves a filtered and sorted list of tenants projected to +/// . /// public sealed class TenantSpecification : Specification { /// - /// Initialises the specification by applying filter criteria, sort order, and projection from the given . + /// Initialises the specification by applying filter criteria, sort order, and projection from the given + /// . /// public TenantSpecification(TenantFilter filter) { @@ -21,4 +22,3 @@ public TenantSpecification(TenantFilter filter) Query.Select(TenantMappings.Projection); } } - diff --git a/src/Modules/Identity/Features/Tenant/TenantSortFields.cs b/src/Modules/Identity/Features/Tenant/TenantSortFields.cs index 97da50d0..ef717eba 100644 --- a/src/Modules/Identity/Features/Tenant/TenantSortFields.cs +++ b/src/Modules/Identity/Features/Tenant/TenantSortFields.cs @@ -1,10 +1,9 @@ -using SharedKernel.Application.Sorting; using TenantEntity = Identity.Entities.Tenant; namespace Identity.Features.Tenant; /// -/// Defines the sortable fields available for tenant queries and maps them to entity property expressions. +/// Defines the sortable fields available for tenant queries and maps them to entity property expressions. /// public static class TenantSortFields { @@ -18,4 +17,3 @@ public static class TenantSortFields .Add(CreatedAt, t => t.Audit.CreatedAtUtc) .Default(t => t.Audit.CreatedAtUtc); } - diff --git a/src/Modules/Identity/Features/Tenant/Validation/CreateTenantRequestValidator.cs b/src/Modules/Identity/Features/Tenant/Validation/CreateTenantRequestValidator.cs index fa7daddb..0aa5b389 100644 --- a/src/Modules/Identity/Features/Tenant/Validation/CreateTenantRequestValidator.cs +++ b/src/Modules/Identity/Features/Tenant/Validation/CreateTenantRequestValidator.cs @@ -1,10 +1,6 @@ -using Identity.Features.Tenant.DTOs; -using SharedKernel.Application.Validation; - namespace Identity.Features.Tenant.Validation; /// -/// FluentValidation validator for that enforces data-annotation constraints. +/// FluentValidation validator for that enforces data-annotation constraints. /// public sealed class CreateTenantRequestValidator : DataAnnotationsValidator; - diff --git a/src/Modules/Identity/Features/Tenant/Validation/TenantFilterValidator.cs b/src/Modules/Identity/Features/Tenant/Validation/TenantFilterValidator.cs index 33a0fd6f..ad923965 100644 --- a/src/Modules/Identity/Features/Tenant/Validation/TenantFilterValidator.cs +++ b/src/Modules/Identity/Features/Tenant/Validation/TenantFilterValidator.cs @@ -1,16 +1,14 @@ -using Identity.Features.Tenant.DTOs; -using SharedKernel.Application.Validation; using FluentValidation; namespace Identity.Features.Tenant.Validation; /// -/// FluentValidation validator for that composes pagination and sort-field rules. +/// 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. + /// Registers pagination and sortable-field validation rules by including shared sub-validators. /// public TenantFilterValidator() { @@ -18,4 +16,3 @@ public TenantFilterValidator() Include(new SortableFilterValidator(TenantSortFields.Map.AllowedNames)); } } - diff --git a/src/Modules/Identity/Features/TenantInvitation/Commands/CreateTenantInvitationCommand.cs b/src/Modules/Identity/Features/TenantInvitation/Commands/CreateTenantInvitationCommand.cs index d7cf509f..05c03680 100644 --- a/src/Modules/Identity/Features/TenantInvitation/Commands/CreateTenantInvitationCommand.cs +++ b/src/Modules/Identity/Features/TenantInvitation/Commands/CreateTenantInvitationCommand.cs @@ -2,7 +2,6 @@ using Identity.Common.Email; using Identity.Features.TenantInvitation.Mappings; using Identity.Options; -using Identity; using Identity.ValueObjects; using Microsoft.Extensions.Options; using Wolverine; @@ -35,10 +34,12 @@ CancellationToken ct TenantInvitationOptions opts = invitationOptions.Value; if (await invitationRepository.HasPendingInvitationAsync(email.Normalize(), ct)) + { return ( DomainErrors.Invitations.AlreadyPending(command.Request.Email), OutgoingMessagesHelper.Empty ); + } ErrorOr tenantResult = await tenantRepository.GetByIdOrError( tenantProvider.TenantId, @@ -79,4 +80,3 @@ CancellationToken ct return (invitation.ToResponse(), messages); } } - diff --git a/src/Modules/Identity/Features/TenantInvitation/Commands/RevokeTenantInvitationCommand.cs b/src/Modules/Identity/Features/TenantInvitation/Commands/RevokeTenantInvitationCommand.cs index 4c3941db..295de289 100644 --- a/src/Modules/Identity/Features/TenantInvitation/Commands/RevokeTenantInvitationCommand.cs +++ b/src/Modules/Identity/Features/TenantInvitation/Commands/RevokeTenantInvitationCommand.cs @@ -1,5 +1,4 @@ using ErrorOr; -using Identity; using Wolverine; using TenantInvitationEntity = Identity.Entities.TenantInvitation; @@ -35,4 +34,3 @@ await invitationRepository.GetByIdOrError( return (Result.Success, messages); } } - diff --git a/src/Modules/Identity/Features/TenantInvitation/DTOs/AcceptInvitationRequest.cs b/src/Modules/Identity/Features/TenantInvitation/DTOs/AcceptInvitationRequest.cs index dccaf189..915a5546 100644 --- a/src/Modules/Identity/Features/TenantInvitation/DTOs/AcceptInvitationRequest.cs +++ b/src/Modules/Identity/Features/TenantInvitation/DTOs/AcceptInvitationRequest.cs @@ -1,9 +1,6 @@ -using SharedKernel.Application.Validation; - namespace Identity.Features.TenantInvitation.DTOs; /// -/// Represents the request payload for accepting a tenant invitation using a secure token. +/// Represents the request payload for accepting a tenant invitation using a secure token. /// public sealed record AcceptInvitationRequest([NotEmpty] string Token); - diff --git a/src/Modules/Identity/Features/TenantInvitation/DTOs/CreateTenantInvitationRequest.cs b/src/Modules/Identity/Features/TenantInvitation/DTOs/CreateTenantInvitationRequest.cs index 8889a960..9557b4b7 100644 --- a/src/Modules/Identity/Features/TenantInvitation/DTOs/CreateTenantInvitationRequest.cs +++ b/src/Modules/Identity/Features/TenantInvitation/DTOs/CreateTenantInvitationRequest.cs @@ -1,12 +1,10 @@ using System.ComponentModel.DataAnnotations; -using SharedKernel.Application.Validation; namespace Identity.Features.TenantInvitation.DTOs; /// -/// Represents the request payload for inviting a user to the current tenant by email address. +/// 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/Modules/Identity/Features/TenantInvitation/DTOs/TenantInvitationResponse.cs b/src/Modules/Identity/Features/TenantInvitation/DTOs/TenantInvitationResponse.cs index 7fb052e2..958b6461 100644 --- a/src/Modules/Identity/Features/TenantInvitation/DTOs/TenantInvitationResponse.cs +++ b/src/Modules/Identity/Features/TenantInvitation/DTOs/TenantInvitationResponse.cs @@ -1,10 +1,7 @@ -using Identity.Enums; -using SharedKernel.Domain.Entities.Contracts; - namespace Identity.Features.TenantInvitation.DTOs; /// -/// Read model returned to callers for tenant invitation queries. +/// Read model returned to callers for tenant invitation queries. /// public sealed record TenantInvitationResponse( Guid Id, @@ -13,4 +10,3 @@ public sealed record TenantInvitationResponse( DateTime ExpiresAtUtc, DateTime CreatedAtUtc ) : IHasId; - diff --git a/src/Modules/Identity/Features/TenantInvitation/Mappings/TenantInvitationMappings.cs b/src/Modules/Identity/Features/TenantInvitation/Mappings/TenantInvitationMappings.cs index e422b1a7..e9ac7921 100644 --- a/src/Modules/Identity/Features/TenantInvitation/Mappings/TenantInvitationMappings.cs +++ b/src/Modules/Identity/Features/TenantInvitation/Mappings/TenantInvitationMappings.cs @@ -4,12 +4,14 @@ namespace Identity.Features.TenantInvitation.Mappings; /// -/// Provides LINQ-compatible projection expressions and in-process mapping helpers for TenantInvitation entities. +/// 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. + /// Expression tree used by EF Core to project a TenantInvitation entity directly to a + /// in the database query. /// public static readonly Expression< Func @@ -27,9 +29,11 @@ private static readonly Func< > CompiledProjection = Projection.Compile(); /// - /// Maps a TenantInvitation entity to a using the pre-compiled projection. + /// Maps a TenantInvitation entity to a using the pre-compiled + /// projection. /// - public static TenantInvitationResponse ToResponse(this TenantInvitationEntity invitation) => - CompiledProjection(invitation); + public static TenantInvitationResponse ToResponse(this TenantInvitationEntity invitation) + { + return CompiledProjection(invitation); + } } - diff --git a/src/Modules/Identity/Features/TenantInvitation/Queries/GetTenantInvitationsQuery.cs b/src/Modules/Identity/Features/TenantInvitation/Queries/GetTenantInvitationsQuery.cs index 06cab754..203ad7fe 100644 --- a/src/Modules/Identity/Features/TenantInvitation/Queries/GetTenantInvitationsQuery.cs +++ b/src/Modules/Identity/Features/TenantInvitation/Queries/GetTenantInvitationsQuery.cs @@ -1,8 +1,5 @@ -using Identity.Features.TenantInvitation.DTOs; -using Identity.Features.TenantInvitation.Specifications; -using Identity.Interfaces; -using SharedKernel.Domain.Common; using ErrorOr; +using Identity.Features.TenantInvitation.Specifications; namespace Identity.Features.TenantInvitation; @@ -24,4 +21,3 @@ CancellationToken ct ); } } - diff --git a/src/Modules/Identity/Features/TenantInvitation/Specifications/TenantInvitationFilterSpecification.cs b/src/Modules/Identity/Features/TenantInvitation/Specifications/TenantInvitationFilterSpecification.cs index 81fefb5b..9fcf9707 100644 --- a/src/Modules/Identity/Features/TenantInvitation/Specifications/TenantInvitationFilterSpecification.cs +++ b/src/Modules/Identity/Features/TenantInvitation/Specifications/TenantInvitationFilterSpecification.cs @@ -1,19 +1,19 @@ using Ardalis.Specification; using Identity.Features.TenantInvitation.Mappings; -using Identity.Entities; using Identity.ValueObjects; using TenantInvitationEntity = Identity.Entities.TenantInvitation; namespace Identity.Features.TenantInvitation.Specifications; /// -/// Ardalis specification that retrieves a filtered list of tenant invitations projected to . +/// Ardalis specification that retrieves a filtered list of tenant invitations projected to +/// . /// public sealed class TenantInvitationFilterSpecification : Specification { /// - /// Initialises the specification by applying filter criteria, descending creation-date ordering, and projection. + /// Initialises the specification by applying filter criteria, descending creation-date ordering, and projection. /// public TenantInvitationFilterSpecification(TenantInvitationFilter filter) { @@ -25,12 +25,13 @@ public TenantInvitationFilterSpecification(TenantInvitationFilter filter) } /// -/// Internal extension that applies shared criteria to an Ardalis specification builder. +/// Internal extension that applies shared criteria to an Ardalis specification +/// builder. /// internal static class TenantInvitationFilterCriteria { /// - /// Adds optional email (normalised, case-insensitive contains) and status equality predicates to the query. + /// Adds optional email (normalised, case-insensitive contains) and status equality predicates to the query. /// public static void ApplyFilter( this ISpecificationBuilder query, @@ -47,4 +48,3 @@ TenantInvitationFilter filter query.Where(i => i.Status == filter.Status.Value); } } - diff --git a/src/Modules/Identity/Features/TenantInvitation/Validation/CreateTenantInvitationRequestValidator.cs b/src/Modules/Identity/Features/TenantInvitation/Validation/CreateTenantInvitationRequestValidator.cs index a2d05455..48d6cdc9 100644 --- a/src/Modules/Identity/Features/TenantInvitation/Validation/CreateTenantInvitationRequestValidator.cs +++ b/src/Modules/Identity/Features/TenantInvitation/Validation/CreateTenantInvitationRequestValidator.cs @@ -1,11 +1,8 @@ -using Identity.Features.TenantInvitation.DTOs; -using SharedKernel.Application.Validation; - namespace Identity.Features.TenantInvitation.Validation; /// -/// FluentValidation validator for that enforces data-annotation constraints. +/// FluentValidation validator for that enforces data-annotation +/// constraints. /// public sealed class CreateTenantInvitationRequestValidator : DataAnnotationsValidator; - diff --git a/src/Modules/Identity/Features/User/Commands/ChangeUserRoleCommand.cs b/src/Modules/Identity/Features/User/Commands/ChangeUserRoleCommand.cs index fd4feb19..ae1d41cf 100644 --- a/src/Modules/Identity/Features/User/Commands/ChangeUserRoleCommand.cs +++ b/src/Modules/Identity/Features/User/Commands/ChangeUserRoleCommand.cs @@ -1,5 +1,4 @@ using ErrorOr; -using Identity; using Wolverine; namespace Identity.Features.User; @@ -44,4 +43,3 @@ CancellationToken ct return (Result.Success, messages); } } - diff --git a/src/Modules/Identity/Features/User/Commands/CreateUserCommand.cs b/src/Modules/Identity/Features/User/Commands/CreateUserCommand.cs index 202bd632..9d8d0644 100644 --- a/src/Modules/Identity/Features/User/Commands/CreateUserCommand.cs +++ b/src/Modules/Identity/Features/User/Commands/CreateUserCommand.cs @@ -1,7 +1,6 @@ using ErrorOr; using Identity.Features.User.Mappings; using Identity.Logging; -using Identity; using Identity.ValueObjects; using Microsoft.Extensions.Logging; using Wolverine; @@ -76,8 +75,8 @@ CancellationToken ct { logger.CreateUserCompensatingDeleteFailed(compensationEx, keycloakUserId); } + throw; } } } - diff --git a/src/Modules/Identity/Features/User/Commands/DeleteUserCommand.cs b/src/Modules/Identity/Features/User/Commands/DeleteUserCommand.cs index a248a5b4..39b43312 100644 --- a/src/Modules/Identity/Features/User/Commands/DeleteUserCommand.cs +++ b/src/Modules/Identity/Features/User/Commands/DeleteUserCommand.cs @@ -1,6 +1,5 @@ using ErrorOr; using Identity.Logging; -using Identity; using Microsoft.Extensions.Logging; using Wolverine; @@ -47,4 +46,3 @@ CancellationToken ct return (Result.Success, messages); } } - diff --git a/src/Modules/Identity/Features/User/Commands/UpdateUserCommand.cs b/src/Modules/Identity/Features/User/Commands/UpdateUserCommand.cs index d873498a..57f3278e 100644 --- a/src/Modules/Identity/Features/User/Commands/UpdateUserCommand.cs +++ b/src/Modules/Identity/Features/User/Commands/UpdateUserCommand.cs @@ -1,5 +1,4 @@ using ErrorOr; -using Identity; using Identity.ValueObjects; using Wolverine; @@ -65,4 +64,3 @@ await UserValidationHelper.ValidateUsernameUniqueAsync( return (Result.Success, messages); } } - diff --git a/src/Modules/Identity/Features/User/DTOs/ChangeUserRoleRequest.cs b/src/Modules/Identity/Features/User/DTOs/ChangeUserRoleRequest.cs index 1666cb6d..d755fb25 100644 --- a/src/Modules/Identity/Features/User/DTOs/ChangeUserRoleRequest.cs +++ b/src/Modules/Identity/Features/User/DTOs/ChangeUserRoleRequest.cs @@ -1,9 +1,6 @@ -using Identity.Enums; - namespace Identity.Features.User.DTOs; /// -/// Represents the request payload for changing a user's role. +/// Represents the request payload for changing a user's role. /// public sealed record ChangeUserRoleRequest(UserRole Role); - diff --git a/src/Modules/Identity/Features/User/DTOs/CreateUserRequest.cs b/src/Modules/Identity/Features/User/DTOs/CreateUserRequest.cs index 31dfb13a..965ab747 100644 --- a/src/Modules/Identity/Features/User/DTOs/CreateUserRequest.cs +++ b/src/Modules/Identity/Features/User/DTOs/CreateUserRequest.cs @@ -1,13 +1,11 @@ using System.ComponentModel.DataAnnotations; -using SharedKernel.Application.Validation; namespace Identity.Features.User.DTOs; /// -/// Represents the request payload for creating a new user account. +/// 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/Modules/Identity/Features/User/DTOs/RequestPasswordResetRequest.cs b/src/Modules/Identity/Features/User/DTOs/RequestPasswordResetRequest.cs index 5d48bce4..0e273a2d 100644 --- a/src/Modules/Identity/Features/User/DTOs/RequestPasswordResetRequest.cs +++ b/src/Modules/Identity/Features/User/DTOs/RequestPasswordResetRequest.cs @@ -1,12 +1,10 @@ using System.ComponentModel.DataAnnotations; -using SharedKernel.Application.Validation; namespace Identity.Features.User.DTOs; /// -/// Represents the request payload for triggering a Keycloak password-reset email for the given email address. +/// 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/Modules/Identity/Features/User/DTOs/UserFilter.cs b/src/Modules/Identity/Features/User/DTOs/UserFilter.cs index 18c43dc1..673bad09 100644 --- a/src/Modules/Identity/Features/User/DTOs/UserFilter.cs +++ b/src/Modules/Identity/Features/User/DTOs/UserFilter.cs @@ -1,11 +1,8 @@ -using Identity.Enums; -using SharedKernel.Application.Contracts; -using SharedKernel.Application.DTOs; - namespace Identity.Features.User.DTOs; /// -/// Pagination and filtering parameters for querying users, with optional username, email, active-status, role, and sort fields. +/// Pagination and filtering parameters for querying users, with optional username, email, active-status, role, and +/// sort fields. /// public sealed record UserFilter( string? Username = null, @@ -17,4 +14,3 @@ public sealed record UserFilter( int PageNumber = 1, int PageSize = PaginationFilter.DefaultPageSize ) : PaginationFilter(PageNumber, PageSize), ISortableFilter; - diff --git a/src/Modules/Identity/Features/User/Mappings/UserMappings.cs b/src/Modules/Identity/Features/User/Mappings/UserMappings.cs index 4bdb707e..79d2d936 100644 --- a/src/Modules/Identity/Features/User/Mappings/UserMappings.cs +++ b/src/Modules/Identity/Features/User/Mappings/UserMappings.cs @@ -3,12 +3,13 @@ namespace Identity.Features.User.Mappings; /// -/// Provides LINQ-compatible projection expressions and in-process mapping helpers for entities. +/// 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. + /// 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); @@ -16,8 +17,10 @@ public static class UserMappings private static readonly Func CompiledProjection = Projection.Compile(); /// - /// Maps an entity to a using the pre-compiled projection. + /// Maps an entity to a using the pre-compiled projection. /// - public static UserResponse ToResponse(this AppUser user) => CompiledProjection(user); + public static UserResponse ToResponse(this AppUser user) + { + return CompiledProjection(user); + } } - diff --git a/src/Modules/Identity/Features/User/Queries/GetUserByIdQuery.cs b/src/Modules/Identity/Features/User/Queries/GetUserByIdQuery.cs index f378cabf..363957a2 100644 --- a/src/Modules/Identity/Features/User/Queries/GetUserByIdQuery.cs +++ b/src/Modules/Identity/Features/User/Queries/GetUserByIdQuery.cs @@ -1,8 +1,5 @@ -using Identity.Features.User.DTOs; -using SharedKernel.Domain.Entities.Contracts; -using Identity.Features.User.Specifications; -using Identity.Interfaces; using ErrorOr; +using Identity.Features.User.Specifications; namespace Identity.Features.User; @@ -26,4 +23,3 @@ CancellationToken ct return result; } } - diff --git a/src/Modules/Identity/Features/User/Queries/GetUsersQuery.cs b/src/Modules/Identity/Features/User/Queries/GetUsersQuery.cs index 9a9e612e..ab8af384 100644 --- a/src/Modules/Identity/Features/User/Queries/GetUsersQuery.cs +++ b/src/Modules/Identity/Features/User/Queries/GetUsersQuery.cs @@ -1,8 +1,5 @@ -using Identity.Features.User.DTOs; -using Identity.Features.User.Specifications; -using Identity.Interfaces; -using SharedKernel.Domain.Common; using ErrorOr; +using Identity.Features.User.Specifications; namespace Identity.Features.User; @@ -24,4 +21,3 @@ CancellationToken ct ); } } - diff --git a/src/Modules/Identity/Features/User/Specifications/UserByEmailSpecification.cs b/src/Modules/Identity/Features/User/Specifications/UserByEmailSpecification.cs index 6a4d09b4..4413f761 100644 --- a/src/Modules/Identity/Features/User/Specifications/UserByEmailSpecification.cs +++ b/src/Modules/Identity/Features/User/Specifications/UserByEmailSpecification.cs @@ -1,16 +1,16 @@ using Ardalis.Specification; -using Identity.Entities; using Identity.ValueObjects; namespace Identity.Features.User.Specifications; /// -/// Ardalis specification that filters users by a case-insensitive exact email match. +/// 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 . + /// Initialises the specification to match users whose normalised email equals the normalised form of + /// . /// public UserByEmailSpecification(string email) { @@ -18,4 +18,3 @@ public UserByEmailSpecification(string email) Query.Where(u => u.NormalizedEmail == normalizedEmail).AsNoTracking(); } } - diff --git a/src/Modules/Identity/Features/User/Specifications/UserByIdSpecification.cs b/src/Modules/Identity/Features/User/Specifications/UserByIdSpecification.cs index 70ed1d03..9e514c2b 100644 --- a/src/Modules/Identity/Features/User/Specifications/UserByIdSpecification.cs +++ b/src/Modules/Identity/Features/User/Specifications/UserByIdSpecification.cs @@ -1,21 +1,19 @@ using Ardalis.Specification; -using Identity.Features.User.DTOs; using Identity.Features.User.Mappings; -using Identity.Entities; namespace Identity.Features.User.Specifications; /// -/// Ardalis specification that fetches a single user by ID and projects it to . +/// 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. + /// 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).AsNoTracking().Select(UserMappings.Projection); } } - diff --git a/src/Modules/Identity/Features/User/Specifications/UserByUsernameSpecification.cs b/src/Modules/Identity/Features/User/Specifications/UserByUsernameSpecification.cs index ce8da9de..df858768 100644 --- a/src/Modules/Identity/Features/User/Specifications/UserByUsernameSpecification.cs +++ b/src/Modules/Identity/Features/User/Specifications/UserByUsernameSpecification.cs @@ -1,19 +1,17 @@ using Ardalis.Specification; -using Identity.Entities; namespace Identity.Features.User.Specifications; /// -/// Ardalis specification that filters users by their pre-normalised username. +/// 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 . + /// Initialises the specification to match the user with the given . /// public UserByUsernameSpecification(string normalizedUsername) { Query.Where(u => u.NormalizedUsername == normalizedUsername).AsNoTracking(); } } - diff --git a/src/Modules/Identity/Features/User/Specifications/UserFilterCriteria.cs b/src/Modules/Identity/Features/User/Specifications/UserFilterCriteria.cs index 1aec9f5a..ac862d38 100644 --- a/src/Modules/Identity/Features/User/Specifications/UserFilterCriteria.cs +++ b/src/Modules/Identity/Features/User/Specifications/UserFilterCriteria.cs @@ -4,18 +4,18 @@ namespace Identity.Features.User.Specifications; /// -/// Internal extension that applies shared criteria to an Ardalis specification builder. +/// 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. + /// 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)) { - var normalizedUsername = AppUser.NormalizeUsername(filter.Username); + string normalizedUsername = AppUser.NormalizeUsername(filter.Username); query.Where(u => u.NormalizedUsername.Contains(normalizedUsername)); } @@ -32,4 +32,3 @@ internal static void ApplyFilter(this ISpecificationBuilder query, User query.Where(u => u.Role == filter.Role.Value); } } - diff --git a/src/Modules/Identity/Features/User/UserSortFields.cs b/src/Modules/Identity/Features/User/UserSortFields.cs index f900a6ac..6235a413 100644 --- a/src/Modules/Identity/Features/User/UserSortFields.cs +++ b/src/Modules/Identity/Features/User/UserSortFields.cs @@ -1,10 +1,7 @@ -using Identity.Entities; -using SharedKernel.Application.Sorting; - namespace Identity.Features.User; /// -/// Defines the sortable fields available for user queries and maps them to entity property expressions. +/// Defines the sortable fields available for user queries and maps them to entity property expressions. /// public static class UserSortFields { @@ -18,4 +15,3 @@ public static class UserSortFields .Add(CreatedAt, u => u.Audit.CreatedAtUtc) .Default(u => u.Audit.CreatedAtUtc); } - diff --git a/src/Modules/Identity/Features/User/UserValidationHelper.cs b/src/Modules/Identity/Features/User/UserValidationHelper.cs index 1d555499..472eca2a 100644 --- a/src/Modules/Identity/Features/User/UserValidationHelper.cs +++ b/src/Modules/Identity/Features/User/UserValidationHelper.cs @@ -1,5 +1,3 @@ -using Identity.Entities; -using Identity.Interfaces; using ErrorOr; namespace Identity.Features.User; @@ -24,11 +22,10 @@ internal static async Task> ValidateUsernameUniqueAsync( CancellationToken ct ) { - var normalized = AppUser.NormalizeUsername(username); + string normalized = AppUser.NormalizeUsername(username); if (await repository.ExistsByUsernameAsync(normalized, ct)) return DomainErrors.Users.UsernameAlreadyExists(username); return Result.Success; } } - diff --git a/src/Modules/Identity/Features/User/Validation/ChangeUserRoleRequestValidator.cs b/src/Modules/Identity/Features/User/Validation/ChangeUserRoleRequestValidator.cs index 127d76ec..85ab70f5 100644 --- a/src/Modules/Identity/Features/User/Validation/ChangeUserRoleRequestValidator.cs +++ b/src/Modules/Identity/Features/User/Validation/ChangeUserRoleRequestValidator.cs @@ -1,20 +1,18 @@ -using Identity.Features.User.DTOs; -using Identity.Enums; using FluentValidation; namespace Identity.Features.User.Validation; /// -/// FluentValidation validator for that ensures the role value is a valid enum member. +/// 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. + /// 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/Modules/Identity/Features/User/Validation/UpdateUserRequestValidator.cs b/src/Modules/Identity/Features/User/Validation/UpdateUserRequestValidator.cs index 2bf2db27..b5bd1f01 100644 --- a/src/Modules/Identity/Features/User/Validation/UpdateUserRequestValidator.cs +++ b/src/Modules/Identity/Features/User/Validation/UpdateUserRequestValidator.cs @@ -1,10 +1,6 @@ -using Identity.Features.User.DTOs; -using SharedKernel.Application.Validation; - namespace Identity.Features.User.Validation; /// -/// FluentValidation validator for that enforces data-annotation constraints. +/// FluentValidation validator for that enforces data-annotation constraints. /// public sealed class UpdateUserRequestValidator : DataAnnotationsValidator; - diff --git a/src/Modules/Identity/Features/User/Validation/UserFilterValidator.cs b/src/Modules/Identity/Features/User/Validation/UserFilterValidator.cs index 0f22763c..71c97f2f 100644 --- a/src/Modules/Identity/Features/User/Validation/UserFilterValidator.cs +++ b/src/Modules/Identity/Features/User/Validation/UserFilterValidator.cs @@ -1,17 +1,15 @@ -using Identity.Features.User.DTOs; -using Identity.Enums; -using SharedKernel.Application.Validation; using FluentValidation; namespace Identity.Features.User.Validation; /// -/// FluentValidation validator for that composes sort-field rules and validates the optional role enum. +/// 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. + /// Registers sort-field and optional role enum-range validation rules. /// public UserFilterValidator() { @@ -23,4 +21,3 @@ public UserFilterValidator() .WithMessage("Role must be a valid UserRole value."); } } - diff --git a/src/Modules/Identity/Features/V1/BffController.cs b/src/Modules/Identity/Features/V1/BffController.cs index 235fdc6a..abeddaa2 100644 --- a/src/Modules/Identity/Features/V1/BffController.cs +++ b/src/Modules/Identity/Features/V1/BffController.cs @@ -43,25 +43,29 @@ public IActionResult Logout() [HttpGet("csrf")] [AllowAnonymous] - public IActionResult GetCsrf() => - Ok(new - { - headerName = AuthConstants.Csrf.HeaderName, - headerValue = AuthConstants.Csrf.HeaderValue, - }); + public IActionResult GetCsrf() + { + return Ok( + new + { + headerName = AuthConstants.Csrf.HeaderName, + headerValue = AuthConstants.Csrf.HeaderValue, + } + ); + } [HttpGet("user")] public IActionResult GetUser() { - System.Security.Claims.ClaimsPrincipal user = HttpContext.User; + ClaimsPrincipal user = HttpContext.User; BffUserResponse result = new( - UserId: user.FindFirstValue(ClaimTypes.NameIdentifier) ?? user.FindFirstValue(AuthConstants.Claims.Subject), - 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() + user.FindFirstValue(ClaimTypes.NameIdentifier) + ?? user.FindFirstValue(AuthConstants.Claims.Subject), + user.FindFirstValue(ClaimTypes.Name), + user.FindFirstValue(ClaimTypes.Email), + user.FindFirstValue(AuthConstants.Claims.TenantId), + user.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray() ); return Ok(result); } } - diff --git a/src/Modules/Identity/Features/V1/TenantsController.cs b/src/Modules/Identity/Features/V1/TenantsController.cs index a75fc119..d6089e79 100644 --- a/src/Modules/Identity/Features/V1/TenantsController.cs +++ b/src/Modules/Identity/Features/V1/TenantsController.cs @@ -17,10 +17,9 @@ public async Task>> GetAll( CancellationToken ct ) { - ErrorOr> result = await bus.InvokeAsync>>( - new GetTenantsQuery(filter), - ct - ); + ErrorOr> result = await bus.InvokeAsync< + ErrorOr> + >(new GetTenantsQuery(filter), ct); return result.ToActionResult(this); } @@ -38,7 +37,10 @@ public async Task> GetById(Guid id, CancellationTok [HttpPost] [RequirePermission(Permission.Tenants.Create)] - public async Task> Create(CreateTenantRequest request, CancellationToken ct) + public async Task> Create( + CreateTenantRequest request, + CancellationToken ct + ) { ErrorOr result = await bus.InvokeAsync>( new CreateTenantCommand(request), @@ -58,4 +60,3 @@ public async Task Delete(Guid id, CancellationToken ct) return result.ToNoContentResult(this); } } - diff --git a/src/Modules/Identity/Handlers/CleanupExpiredInvitationsHandler.cs b/src/Modules/Identity/Handlers/CleanupExpiredInvitationsHandler.cs index f699e10b..a8653c6e 100644 --- a/src/Modules/Identity/Handlers/CleanupExpiredInvitationsHandler.cs +++ b/src/Modules/Identity/Handlers/CleanupExpiredInvitationsHandler.cs @@ -6,9 +6,9 @@ namespace Identity.Handlers; /// -/// Wolverine handler that processes dispatched by the -/// BackgroundJobs module. Permanently deletes pending tenant invitations whose expiry is older than -/// the configured retention window, using batched bulk deletes for efficiency. +/// Wolverine handler that processes dispatched by the +/// BackgroundJobs module. Permanently deletes pending tenant invitations whose expiry is older than +/// the configured retention window, using batched bulk deletes for efficiency. /// public sealed class CleanupExpiredInvitationsHandler { @@ -37,9 +37,6 @@ CancellationToken ct } while (deleted == command.BatchSize); if (totalDeleted > 0) - { logger.ExpiredInvitationsCleanedUp(totalDeleted); - } } } - diff --git a/src/Modules/Identity/Identity.Domain.GlobalUsings.cs b/src/Modules/Identity/Identity.Domain.GlobalUsings.cs index 4906a5cc..5b994c88 100644 --- a/src/Modules/Identity/Identity.Domain.GlobalUsings.cs +++ b/src/Modules/Identity/Identity.Domain.GlobalUsings.cs @@ -3,4 +3,3 @@ global using SharedKernel.Domain.Entities; global using SharedKernel.Domain.Entities.Contracts; global using SharedKernel.Domain.Interfaces; - diff --git a/src/Modules/Identity/IdentityDbMarker.cs b/src/Modules/Identity/IdentityDbMarker.cs index 28373680..d5c8e781 100644 --- a/src/Modules/Identity/IdentityDbMarker.cs +++ b/src/Modules/Identity/IdentityDbMarker.cs @@ -1,9 +1,8 @@ namespace Identity; /// -/// Domain-layer marker type identifying the Identity module's persistence boundary. -/// Used as the type parameter for -/// so that handlers can request the correct unit of work without referencing the Infrastructure layer. +/// Domain-layer marker type identifying the Identity module's persistence boundary. +/// Used as the type parameter for +/// so that handlers can request the correct unit of work without referencing the Infrastructure layer. /// public abstract class IdentityDbMarker; - diff --git a/src/Modules/Identity/IdentityModule.cs b/src/Modules/Identity/IdentityModule.cs index 2c5a6b04..c5580507 100644 --- a/src/Modules/Identity/IdentityModule.cs +++ b/src/Modules/Identity/IdentityModule.cs @@ -239,7 +239,7 @@ IConfiguration configuration .AddModule(configuration) .ConfigureDbContext(opts => opts.UseNpgsql(connectionString)) .AddDefaultInfrastructure() - .ForwardUnitOfWork() + .ForwardUnitOfWork() .AddRepository() .AddRepository() .AddRepository() diff --git a/src/Modules/Identity/Interfaces/ITenantInvitationRepository.cs b/src/Modules/Identity/Interfaces/ITenantInvitationRepository.cs index d2be8133..765ec524 100644 --- a/src/Modules/Identity/Interfaces/ITenantInvitationRepository.cs +++ b/src/Modules/Identity/Interfaces/ITenantInvitationRepository.cs @@ -1,25 +1,24 @@ -using Identity.Entities; -using SharedKernel.Domain.Interfaces; - namespace Identity.Interfaces; /// -/// Repository contract for entities with invitation-specific lookup operations. +/// Repository contract for entities with invitation-specific lookup operations. /// public interface ITenantInvitationRepository : IRepository { /// - /// Returns the non-revoked invitation that matches the given hashed token, or null if none exists. - /// Expiry and acceptance validation is handled by the domain entity. + /// Returns the non-revoked invitation that matches the given hashed token, or null if none exists. + /// Expiry and acceptance validation is handled by the domain entity. /// - Task GetNonRevokedByTokenHashAsync( + public Task GetNonRevokedByTokenHashAsync( string tokenHash, CancellationToken ct = default ); /// - /// Returns true if there is already a pending invitation for the given normalised email address. + /// Returns true if there is already a pending invitation for the given normalised email address. /// - Task HasPendingInvitationAsync(string normalizedEmail, CancellationToken ct = default); + public Task HasPendingInvitationAsync( + string normalizedEmail, + CancellationToken ct = default + ); } - diff --git a/src/Modules/Identity/Logging/IdentityInfrastructureLogs.cs b/src/Modules/Identity/Logging/IdentityInfrastructureLogs.cs index ad00b886..f08572ac 100644 --- a/src/Modules/Identity/Logging/IdentityInfrastructureLogs.cs +++ b/src/Modules/Identity/Logging/IdentityInfrastructureLogs.cs @@ -4,7 +4,7 @@ namespace Identity.Logging; /// -/// Source-generated logger extension methods for Identity infrastructure diagnostics. +/// Source-generated logger extension methods for Identity infrastructure diagnostics. /// internal static partial class IdentityInfrastructureLogs { @@ -133,4 +133,3 @@ string keycloakUserId )] public static partial void ExpiredInvitationsCleanedUp(this ILogger logger, int count); } - diff --git a/src/Modules/Identity/Persistence/Configurations/AppUserConfiguration.cs b/src/Modules/Identity/Persistence/Configurations/AppUserConfiguration.cs index b0003cd0..3529a4c4 100644 --- a/src/Modules/Identity/Persistence/Configurations/AppUserConfiguration.cs +++ b/src/Modules/Identity/Persistence/Configurations/AppUserConfiguration.cs @@ -48,4 +48,3 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(u => new { u.TenantId, u.NormalizedEmail }).IsUnique(); } } - diff --git a/src/Modules/Identity/Persistence/Configurations/TenantConfiguration.cs b/src/Modules/Identity/Persistence/Configurations/TenantConfiguration.cs index 4e72f2b8..2989d17c 100644 --- a/src/Modules/Identity/Persistence/Configurations/TenantConfiguration.cs +++ b/src/Modules/Identity/Persistence/Configurations/TenantConfiguration.cs @@ -26,4 +26,3 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(t => t.IsActive); } } - diff --git a/src/Modules/Identity/Persistence/Configurations/TenantInvitationConfiguration.cs b/src/Modules/Identity/Persistence/Configurations/TenantInvitationConfiguration.cs index 225b5b0f..b021ea2c 100644 --- a/src/Modules/Identity/Persistence/Configurations/TenantInvitationConfiguration.cs +++ b/src/Modules/Identity/Persistence/Configurations/TenantInvitationConfiguration.cs @@ -44,4 +44,3 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(i => new { i.TenantId, i.NormalizedEmail }); } } - diff --git a/src/Modules/Identity/Persistence/EntityNormalization/AppUserEntityNormalizationService.cs b/src/Modules/Identity/Persistence/EntityNormalization/AppUserEntityNormalizationService.cs index cbc64512..f56e3ae3 100644 --- a/src/Modules/Identity/Persistence/EntityNormalization/AppUserEntityNormalizationService.cs +++ b/src/Modules/Identity/Persistence/EntityNormalization/AppUserEntityNormalizationService.cs @@ -1,10 +1,8 @@ -using SharedKernel.Domain.Entities.Contracts; - namespace Identity.Persistence.EntityNormalization; /// -/// Normalizes fields (username and email) to their canonical -/// casing before the entity is persisted, enabling case-insensitive uniqueness checks. +/// Normalizes fields (username and email) to their canonical +/// casing before the entity is persisted, enabling case-insensitive uniqueness checks. /// public sealed class AppUserEntityNormalizationService : IEntityNormalizationService { @@ -17,4 +15,3 @@ public void Normalize(IAuditableTenantEntity entity) user.NormalizedEmail = user.Email.Normalize(); } } - diff --git a/src/Modules/Identity/Persistence/IdentityDbContext.cs b/src/Modules/Identity/Persistence/IdentityDbContext.cs index 610c7b3e..3835f3e1 100644 --- a/src/Modules/Identity/Persistence/IdentityDbContext.cs +++ b/src/Modules/Identity/Persistence/IdentityDbContext.cs @@ -1,4 +1,3 @@ -using Identity.Persistence.Configurations; using Microsoft.EntityFrameworkCore; namespace Identity.Persistence; @@ -36,4 +35,3 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) ApplyGlobalFilters(modelBuilder); } } - diff --git a/src/Modules/Identity/Repositories/TenantRepository.cs b/src/Modules/Identity/Repositories/TenantRepository.cs index 41db8792..db5054cb 100644 --- a/src/Modules/Identity/Repositories/TenantRepository.cs +++ b/src/Modules/Identity/Repositories/TenantRepository.cs @@ -1,12 +1,11 @@ using Ardalis.Specification; -using Identity.Persistence; using Microsoft.EntityFrameworkCore; namespace Identity.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. +/// 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 { @@ -21,6 +20,22 @@ public TenantRepository(IdentityDbContext dbContext) private IQueryable UnfilteredTenants => _db.Tenants.IgnoreQueryFilters([GlobalQueryFilterNames.Tenant]); + public override async Task GetByIdAsync( + TId id, + CancellationToken cancellationToken = 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); + } + protected override IQueryable ApplySpecification( ISpecification specification, bool evaluateCriteriaOnly = false @@ -39,19 +54,4 @@ ISpecification specification { return SpecificationEvaluator.GetQuery(UnfilteredTenants, specification); } - - public override async Task GetByIdAsync( - TId id, - CancellationToken cancellationToken = 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); - } } - diff --git a/src/Modules/Identity/Repositories/UserRepository.cs b/src/Modules/Identity/Repositories/UserRepository.cs index 42cad3d9..b81a3031 100644 --- a/src/Modules/Identity/Repositories/UserRepository.cs +++ b/src/Modules/Identity/Repositories/UserRepository.cs @@ -1,21 +1,28 @@ using Identity.Features.User.Specifications; -using Identity.Persistence; namespace Identity.Repositories; -/// EF Core repository for with specification-based lookup by email and username. +/// 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 ExistsByEmailAsync(string email, CancellationToken ct = default) + { + return AnyAsync(new UserByEmailSpecification(email), ct); + } - public Task ExistsByUsernameAsync(string normalizedUsername, CancellationToken ct = default) => - AnyAsync(new UserByUsernameSpecification(normalizedUsername), ct); + public Task ExistsByUsernameAsync( + string normalizedUsername, + CancellationToken ct = default + ) + { + return AnyAsync(new UserByUsernameSpecification(normalizedUsername), ct); + } - public Task FindByEmailAsync(string email, CancellationToken ct = default) => - FirstOrDefaultAsync(new UserByEmailSpecification(email), ct); + public Task FindByEmailAsync(string email, CancellationToken ct = default) + { + return FirstOrDefaultAsync(new UserByEmailSpecification(email), ct); + } } - diff --git a/src/Modules/Identity/Security/CookieSessionRefresher.cs b/src/Modules/Identity/Security/CookieSessionRefresher.cs index 27a543dd..e4b804af 100644 --- a/src/Modules/Identity/Security/CookieSessionRefresher.cs +++ b/src/Modules/Identity/Security/CookieSessionRefresher.cs @@ -10,21 +10,24 @@ namespace Identity.Security; /// -/// Provides the cookie authentication principal validation callback used to transparently -/// refresh Keycloak-backed BFF sessions when access tokens are close to expiration. +/// Provides the cookie authentication principal validation callback used to transparently +/// refresh Keycloak-backed BFF sessions when access tokens are close to expiration. /// public static class CookieSessionRefresher { /// - /// Validates an incoming cookie principal and, when appropriate, attempts to refresh - /// the underlying Keycloak session and update the authentication cookie. + /// Validates an incoming cookie principal and, when appropriate, attempts to refresh + /// the underlying Keycloak session and update the authentication cookie. /// public static async Task OnValidatePrincipal(CookieValidatePrincipalContext context) { if (!TryCreateRefreshRequest(context, out RefreshRequest refreshRequest)) return; - KeycloakTokenResponse? tokenResponse = await TryRefreshSessionAsync(context, refreshRequest); + KeycloakTokenResponse? tokenResponse = await TryRefreshSessionAsync( + context, + refreshRequest + ); if (tokenResponse is null) { GetLogger(context).LogWarning("BFF session refresh failed — rejecting principal."); @@ -76,8 +79,8 @@ private static bool IsRefreshRequired( DateTimeOffset expiresAt ) { - BffOptions bffOptions = context.HttpContext.RequestServices - .GetRequiredService>() + BffOptions bffOptions = context + .HttpContext.RequestServices.GetRequiredService>() .Value; return expiresAt - DateTimeOffset.UtcNow @@ -105,8 +108,8 @@ RefreshRequest refreshRequest refreshRequest.KeycloakOptions.Realm ); - HttpClient client = context.HttpContext.RequestServices - .GetRequiredService() + HttpClient client = context + .HttpContext.RequestServices.GetRequiredService() .CreateClient(AuthConstants.HttpClients.KeycloakToken); try @@ -119,10 +122,11 @@ RefreshRequest refreshRequest if (!response.IsSuccessStatusCode) { - GetLogger(context).LogWarning( - "Keycloak token endpoint returned {StatusCode} during BFF refresh.", - (int)response.StatusCode - ); + GetLogger(context) + .LogWarning( + "Keycloak token endpoint returned {StatusCode} during BFF refresh.", + (int)response.StatusCode + ); return null; } @@ -144,15 +148,19 @@ string refreshToken { Dictionary formParams = new() { - [AuthConstants.OAuth2FormParameters.GrantType] = - AuthConstants.OAuth2GrantTypes.RefreshToken, + [AuthConstants.OAuth2FormParameters.GrantType] = AuthConstants + .OAuth2GrantTypes + .RefreshToken, [AuthConstants.OAuth2FormParameters.ClientId] = keycloakOptions.Resource, [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); } @@ -178,16 +186,22 @@ string refreshToken context.ShouldRenew = true; } - private static KeycloakOptions GetKeycloakOptions(CookieValidatePrincipalContext context) => - context.HttpContext.RequestServices - .GetRequiredService>() + private static KeycloakOptions GetKeycloakOptions(CookieValidatePrincipalContext context) + { + return context + .HttpContext.RequestServices.GetRequiredService>() .Value; + } - private static ILogger GetLogger(CookieValidatePrincipalContext context) => - context.HttpContext.RequestServices - .GetRequiredService() + private static ILogger GetLogger(CookieValidatePrincipalContext context) + { + return context + .HttpContext.RequestServices.GetRequiredService() .CreateLogger(nameof(CookieSessionRefresher)); + } - private readonly record struct RefreshRequest(KeycloakOptions KeycloakOptions, string RefreshToken); + private readonly record struct RefreshRequest( + KeycloakOptions KeycloakOptions, + string RefreshToken + ); } - diff --git a/src/Modules/Identity/Security/DragonflyTicketStore.cs b/src/Modules/Identity/Security/DragonflyTicketStore.cs index 7df211e7..a2081aba 100644 --- a/src/Modules/Identity/Security/DragonflyTicketStore.cs +++ b/src/Modules/Identity/Security/DragonflyTicketStore.cs @@ -9,10 +9,10 @@ namespace Identity.Security; /// -/// A ticket store implementation that persists ASP.NET Core authentication tickets -/// into an (typically DragonFly/Redis) under a unique key, -/// allowing the authentication cookie to contain only a small key while the full ticket -/// (claims + properties) is stored in a shared cache reachable by any application instance. +/// A ticket store implementation that persists ASP.NET Core authentication tickets +/// into an (typically DragonFly/Redis) under a unique key, +/// allowing the authentication cookie to contain only a small key while the full ticket +/// (claims + properties) is stored in a shared cache reachable by any application instance. /// public sealed class DragonflyTicketStore : ITicketStore { @@ -66,6 +66,8 @@ public async Task RenewAsync(string key, AuthenticationTicket ticket) } } - public Task RemoveAsync(string key) => _cache.RemoveAsync(key); + public Task RemoveAsync(string key) + { + return _cache.RemoveAsync(key); + } } - diff --git a/src/Modules/Identity/Security/Keycloak/KeycloakAdminService.cs b/src/Modules/Identity/Security/Keycloak/KeycloakAdminService.cs index d7136dc7..b2194396 100644 --- a/src/Modules/Identity/Security/Keycloak/KeycloakAdminService.cs +++ b/src/Modules/Identity/Security/Keycloak/KeycloakAdminService.cs @@ -1,5 +1,6 @@ -using Identity.Options; +using System.Net; using Identity.Logging; +using Identity.Options; using Keycloak.AuthServices.Sdk.Admin; using Keycloak.AuthServices.Sdk.Admin.Models; using Keycloak.AuthServices.Sdk.Admin.Requests.Users; @@ -9,14 +10,14 @@ namespace Identity.Security.Keycloak; /// -/// Keycloak Admin REST API client facade that wraps user lifecycle operations -/// (create, enable/disable, password reset, delete) using the Keycloak SDK. +/// 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; + private readonly string _realm; + private readonly IKeycloakUserClient _userClient; public KeycloakAdminService( IKeycloakUserClient userClient, @@ -30,8 +31,8 @@ ILogger logger } /// - /// Creates a new user in Keycloak and, on a best-effort basis, sends them an - /// email-verify + set-password action email. Returns the new Keycloak user ID. + /// Creates a new user in Keycloak and, on a best-effort basis, sends them an + /// email-verify + set-password action email. Returns the new Keycloak user ID. /// public async Task CreateUserAsync( string username, @@ -39,7 +40,7 @@ public async Task CreateUserAsync( CancellationToken ct = default ) { - var user = new UserRepresentation + UserRepresentation user = new() { Username = username, Email = email, @@ -110,14 +111,14 @@ public async Task SetUserEnabledAsync( CancellationToken ct = default ) { - var patch = new UserRepresentation { Enabled = enabled }; + UserRepresentation patch = new() { Enabled = enabled }; await _userClient.UpdateUserAsync(_realm, keycloakUserId, patch, ct); _logger.KeycloakUserEnabledSet(keycloakUserId, enabled); } /// - /// Deletes the Keycloak user. A 404 response is treated as success to handle idempotent retries. + /// Deletes the Keycloak user. A 404 response is treated as success to handle idempotent retries. /// public async Task DeleteUserAsync(string keycloakUserId, CancellationToken ct = default) { @@ -125,7 +126,7 @@ public async Task DeleteUserAsync(string keycloakUserId, CancellationToken ct = { await _userClient.DeleteUserAsync(_realm, keycloakUserId, ct); } - catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { // Treat 404 as success — the user was already deleted (e.g., retry scenario) _logger.KeycloakUserNotFoundOnDelete(keycloakUserId); @@ -147,11 +148,12 @@ private static string ExtractUserIdFromLocation(HttpResponseMessage response) 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/Modules/Identity/Security/Keycloak/KeycloakAdminTokenProvider.cs b/src/Modules/Identity/Security/Keycloak/KeycloakAdminTokenProvider.cs index a9dd3407..1807571b 100644 --- a/src/Modules/Identity/Security/Keycloak/KeycloakAdminTokenProvider.cs +++ b/src/Modules/Identity/Security/Keycloak/KeycloakAdminTokenProvider.cs @@ -1,15 +1,15 @@ using System.Net.Http.Json; -using Identity.Options; using Identity.Logging; +using Identity.Options; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Identity.Security.Keycloak; /// -/// Singleton service that acquires and caches a Keycloak service-account (client credentials) token. -/// Tokens are kept in memory until they expire; a 30-second safety margin prevents -/// using a token that is about to expire mid-flight. +/// Singleton service that acquires and caches a Keycloak service-account (client credentials) token. +/// Tokens are kept in memory until they expire; a 30-second safety margin prevents +/// using a token that is about to expire mid-flight. /// public sealed class KeycloakAdminTokenProvider : IDisposable { @@ -17,11 +17,11 @@ public sealed class KeycloakAdminTokenProvider : IDisposable private readonly IHttpClientFactory _httpClientFactory; private readonly IOptions _keycloakOptions; + private readonly SemaphoreSlim _lock = new(1, 1); private readonly ILogger _logger; private string? _cachedToken; private DateTimeOffset _tokenExpiresAt = DateTimeOffset.MinValue; - private readonly SemaphoreSlim _lock = new(1, 1); public KeycloakAdminTokenProvider( IHttpClientFactory httpClientFactory, @@ -34,9 +34,14 @@ ILogger logger _logger = logger; } + public void Dispose() + { + _lock.Dispose(); + } + /// - /// Returns a cached service-account access token, refreshing it via the Keycloak token endpoint - /// when it is absent or within the 30-second expiry margin. Thread-safe via . + /// Returns a cached service-account access token, refreshing it via the Keycloak token endpoint + /// when it is absent or within the 30-second expiry margin. Thread-safe via . /// public async Task GetTokenAsync(CancellationToken cancellationToken) { @@ -53,9 +58,11 @@ public async Task GetTokenAsync(CancellationToken cancellationToken) 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 = DateTimeOffset.UtcNow.AddSeconds(response.ExpiresIn); @@ -109,9 +116,8 @@ await response.Content.ReadFromJsonAsync(cancellationToke return token; } - private bool IsTokenValid() => - _cachedToken is not null && DateTimeOffset.UtcNow < _tokenExpiresAt - ExpiryMargin; - - public void Dispose() => _lock.Dispose(); + private bool IsTokenValid() + { + return _cachedToken is not null && DateTimeOffset.UtcNow < _tokenExpiresAt - ExpiryMargin; + } } - diff --git a/src/Modules/Identity/Security/Keycloak/KeycloakClaimMapper.cs b/src/Modules/Identity/Security/Keycloak/KeycloakClaimMapper.cs index d12d7e35..851d3c37 100644 --- a/src/Modules/Identity/Security/Keycloak/KeycloakClaimMapper.cs +++ b/src/Modules/Identity/Security/Keycloak/KeycloakClaimMapper.cs @@ -4,9 +4,9 @@ namespace Identity.Security.Keycloak; /// -/// Maps Keycloak-specific JWT claims into standard .NET claim types expected by -/// ASP.NET Core authorization policies (preferred_username → , -/// realm_access.roles → ). +/// Maps Keycloak-specific JWT claims into standard .NET claim types expected by +/// ASP.NET Core authorization policies (preferred_username → , +/// realm_access.roles → ). /// public static class KeycloakClaimMapper { @@ -39,7 +39,7 @@ private static void MapRealmRoles(ClaimsIdentity identity) foreach (JsonElement role in roles.EnumerateArray()) { - var value = role.GetString(); + string? value = role.GetString(); if (!string.IsNullOrEmpty(value)) identity.AddClaim(new Claim(ClaimTypes.Role, value)); } diff --git a/src/Modules/Identity/Security/Keycloak/KeycloakTokenResponse.cs b/src/Modules/Identity/Security/Keycloak/KeycloakTokenResponse.cs index 5a35106a..b4e303e6 100644 --- a/src/Modules/Identity/Security/Keycloak/KeycloakTokenResponse.cs +++ b/src/Modules/Identity/Security/Keycloak/KeycloakTokenResponse.cs @@ -8,4 +8,3 @@ public sealed record KeycloakTokenResponse( [property: JsonPropertyName("refresh_token")] string? RefreshToken, [property: JsonPropertyName("expires_in")] int ExpiresIn ); - diff --git a/src/Modules/Identity/Security/Keycloak/KeycloakUrlHelper.cs b/src/Modules/Identity/Security/Keycloak/KeycloakUrlHelper.cs index 6c18e67c..a6f03c2a 100644 --- a/src/Modules/Identity/Security/Keycloak/KeycloakUrlHelper.cs +++ b/src/Modules/Identity/Security/Keycloak/KeycloakUrlHelper.cs @@ -1,20 +1,26 @@ namespace Identity.Security.Keycloak; /// -/// Builds well-known Keycloak URL components (authority, discovery, token endpoint) from -/// configured base URL and realm name. +/// Builds well-known Keycloak URL components (authority, discovery, token endpoint) from +/// configured base URL and realm name. /// public static class KeycloakUrlHelper { /// Returns the realm authority URL: {authServerUrl}/realms/{realm}. - public static string BuildAuthority(string authServerUrl, string realm) => - $"{authServerUrl.TrimEnd('/')}/realms/{realm}"; + public static string BuildAuthority(string authServerUrl, string realm) + { + return $"{authServerUrl.TrimEnd('/')}/realms/{realm}"; + } /// Returns the OpenID Connect discovery document URL for the given realm. - public static string BuildDiscoveryUrl(string authServerUrl, string realm) => - $"{BuildAuthority(authServerUrl, realm)}/.well-known/openid-configuration"; + public static string BuildDiscoveryUrl(string authServerUrl, string realm) + { + return $"{BuildAuthority(authServerUrl, realm)}/.well-known/openid-configuration"; + } /// Returns the OAuth2 token endpoint URL for the given realm. - public static string BuildTokenEndpoint(string authServerUrl, string realm) => - $"{BuildAuthority(authServerUrl, realm)}/{AuthConstants.OpenIdConnect.TokenEndpointPath}"; + public static string BuildTokenEndpoint(string authServerUrl, string realm) + { + return $"{BuildAuthority(authServerUrl, realm)}/{AuthConstants.OpenIdConnect.TokenEndpointPath}"; + } } diff --git a/src/Modules/Identity/Security/SecureTokenGenerator.cs b/src/Modules/Identity/Security/SecureTokenGenerator.cs index 2f7e9216..46a0b9aa 100644 --- a/src/Modules/Identity/Security/SecureTokenGenerator.cs +++ b/src/Modules/Identity/Security/SecureTokenGenerator.cs @@ -1,11 +1,12 @@ using System.Security.Cryptography; +using System.Text; using Identity.Common.Email; namespace Identity.Security; /// -/// Generates cryptographically random tokens and produces their SHA-256 hex digest -/// for safe storage in the database. +/// Generates cryptographically random tokens and produces their SHA-256 hex digest +/// for safe storage in the database. /// public sealed class SecureTokenGenerator : ISecureTokenGenerator { @@ -16,12 +17,11 @@ public string GenerateToken() return Convert.ToBase64String(bytes); } - /// Computes and returns the lowercase hex-encoded SHA-256 hash of . + /// Computes and returns the lowercase hex-encoded SHA-256 hash of . public string HashToken(string token) { - byte[] bytes = System.Text.Encoding.UTF8.GetBytes(token); + byte[] bytes = Encoding.UTF8.GetBytes(token); byte[] hash = SHA256.HashData(bytes); return Convert.ToHexStringLower(hash); } } - diff --git a/src/Modules/Identity/Security/TenantClaimValidator.cs b/src/Modules/Identity/Security/TenantClaimValidator.cs index 8afeadc5..86d784bb 100644 --- a/src/Modules/Identity/Security/TenantClaimValidator.cs +++ b/src/Modules/Identity/Security/TenantClaimValidator.cs @@ -9,35 +9,39 @@ namespace Identity.Security; /// -/// Validates tenant-related claims after JWT/OIDC token validation and normalizes -/// Keycloak claims into standard .NET claim types used by authorization policies. +/// 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 { /// - /// JWT Bearer token callback. Maps Keycloak claims, enforces tenant claim presence for user - /// tokens, and provisions the local user record on first login. + /// JWT Bearer token callback. Maps Keycloak claims, enforces tenant claim presence for user + /// tokens, and provisions the local user record on first login. /// - public static Task OnTokenValidated(JwtTokenValidatedContext context) => - ValidateTokenAsync( + public static Task OnTokenValidated(JwtTokenValidatedContext context) + { + return ValidateTokenAsync( context.Principal, context.HttpContext, msg => context.Fail(msg), "JWT Bearer", context.HttpContext.RequestAborted ); + } /// - /// OpenID Connect token callback. Applies the same tenant and claim-mapping rules as JWT Bearer validation. + /// OpenID Connect token callback. Applies the same tenant and claim-mapping rules as JWT Bearer validation. /// - public static Task OnTokenValidated(OidcTokenValidatedContext context) => - ValidateTokenAsync( + public static Task OnTokenValidated(OidcTokenValidatedContext context) + { + return ValidateTokenAsync( context.Principal, context.HttpContext, msg => context.Fail(msg), "OIDC", context.HttpContext.RequestAborted ); + } private static async Task ValidateTokenAsync( ClaimsPrincipal? principal, @@ -57,24 +61,27 @@ CancellationToken ct if (!HasValidTenantClaim(principal) && !isServiceAccount) { - GetLogger(httpContext).LogWarning( - "Missing required {ClaimType} claim on {Scheme} token.", - AuthConstants.Claims.TenantId, - scheme - ); + GetLogger(httpContext) + .LogWarning( + "Missing required {ClaimType} claim on {Scheme} token.", + AuthConstants.Claims.TenantId, + scheme + ); fail($"Missing required {AuthConstants.Claims.TenantId} claim."); } } /// - /// Checks whether the principal has a non-empty GUID value in the tenant_id claim. + /// Checks whether the principal has a non-empty GUID value in the tenant_id claim. /// - public static bool HasValidTenantClaim(ClaimsPrincipal? principal) => - principal?.HasClaim(c => - c.Type == AuthConstants.Claims.TenantId - && Guid.TryParse(c.Value, out Guid tenantId) - && tenantId != Guid.Empty - ) == true; + 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, @@ -86,9 +93,7 @@ CancellationToken ct { string? sub = principal?.FindFirstValue(AuthConstants.Claims.Subject); string? email = principal?.FindFirstValue(ClaimTypes.Email); - string? username = principal?.FindFirstValue( - AuthConstants.Claims.PreferredUsername - ); + string? username = principal?.FindFirstValue(AuthConstants.Claims.PreferredUsername); if ( string.IsNullOrEmpty(sub) @@ -104,10 +109,8 @@ CancellationToken ct } catch (Exception ex) when (ex is not OperationCanceledException) { - GetLogger(httpContext).LogWarning( - ex, - "User provisioning failed during token validation." - ); + GetLogger(httpContext) + .LogWarning(ex, "User provisioning failed during token validation."); } } @@ -121,9 +124,10 @@ private static bool IsServiceAccount(ClaimsPrincipal? principal) ); } - private static ILogger GetLogger(HttpContext httpContext) => - httpContext.RequestServices - .GetRequiredService() + private static ILogger GetLogger(HttpContext httpContext) + { + return httpContext + .RequestServices.GetRequiredService() .CreateLogger(nameof(TenantClaimValidator)); + } } - diff --git a/src/Modules/Identity/SoftDelete/TenantSoftDeleteCascadeRule.cs b/src/Modules/Identity/SoftDelete/TenantSoftDeleteCascadeRule.cs index bac017f4..29ff42c3 100644 --- a/src/Modules/Identity/SoftDelete/TenantSoftDeleteCascadeRule.cs +++ b/src/Modules/Identity/SoftDelete/TenantSoftDeleteCascadeRule.cs @@ -1,17 +1,18 @@ -using Identity.Persistence; using Microsoft.EntityFrameworkCore; -using SharedKernel.Domain.Entities.Contracts; namespace Identity.SoftDelete; /// -/// Soft-delete cascade rule for the Tenant aggregate. -/// Cascades to Users and TenantInvitations within this module. -/// Cross-module cascade (Products, Categories) is handled via . +/// Soft-delete cascade rule for the Tenant aggregate. +/// Cascades to Users and TenantInvitations within this module. +/// Cross-module cascade (Products, Categories) is handled via . /// public sealed class TenantSoftDeleteCascadeRule : ISoftDeleteCascadeRule { - public bool CanHandle(IAuditableTenantEntity entity) => entity is Tenant; + public bool CanHandle(IAuditableTenantEntity entity) + { + return entity is Tenant; + } public async Task> GetDependentsAsync( DbContext dbContext, @@ -49,4 +50,3 @@ await db return dependents; } } - diff --git a/src/Modules/Identity/ValueObjects/Email.cs b/src/Modules/Identity/ValueObjects/Email.cs index 6e083cb3..662f1da8 100644 --- a/src/Modules/Identity/ValueObjects/Email.cs +++ b/src/Modules/Identity/ValueObjects/Email.cs @@ -1,18 +1,22 @@ +using System.Net.Mail; using ErrorOr; -using Identity.Errors; namespace Identity.ValueObjects; /// -/// Value object representing a validated email address. Must be non-empty, syntactically valid, and be at most 320 characters. +/// Value object representing a validated email address. Must be non-empty, syntactically valid, and be at most 320 +/// characters. /// public readonly record struct Email { - public string Value { get; } + private Email(string value) + { + Value = value; + } - private Email(string value) => Value = value; + public string Value { get; } - /// Creates an after trimming and validating the input. + /// Creates an after trimming and validating the input. public static ErrorOr Create(string value) { string trimmed = value?.Trim() ?? string.Empty; @@ -20,7 +24,7 @@ public static ErrorOr Create(string value) if (string.IsNullOrEmpty(trimmed)) return IdentityDomainErrors.Emails.Empty(); - if (!System.Net.Mail.MailAddress.TryCreate(trimmed, out _)) + if (!MailAddress.TryCreate(trimmed, out _)) return IdentityDomainErrors.Emails.InvalidFormat(); if (trimmed.Length > 320) @@ -30,14 +34,25 @@ public static ErrorOr Create(string value) } /// Factory method for EF Core use only. Bypasses validation as values come from persistence. - public static Email FromPersistence(string value) => new(value); + public static Email FromPersistence(string value) + { + return new Email(value); + } /// Returns the canonical form of a raw email string without creating an Email instance. - public static string NormalizeRaw(string value) => value.Trim().ToUpperInvariant(); + public static string NormalizeRaw(string value) + { + return value.Trim().ToUpperInvariant(); + } /// Returns the canonical form of the email address: trimmed and converted to uppercase invariant. - public string Normalize() => Value.ToUpperInvariant(); + public string Normalize() + { + return Value.ToUpperInvariant(); + } - public static implicit operator string(Email email) => email.Value; + public static implicit operator string(Email email) + { + return email.Value; + } } - diff --git a/src/Modules/Identity/ValueObjects/TenantCode.cs b/src/Modules/Identity/ValueObjects/TenantCode.cs index 9fa06f92..3ddd7094 100644 --- a/src/Modules/Identity/ValueObjects/TenantCode.cs +++ b/src/Modules/Identity/ValueObjects/TenantCode.cs @@ -1,18 +1,20 @@ using ErrorOr; -using Identity.Errors; namespace Identity.ValueObjects; /// -/// Value object representing a tenant code. Must be non-empty and at most 100 characters. +/// Value object representing a tenant code. Must be non-empty and at most 100 characters. /// public readonly record struct TenantCode { - public string Value { get; } + private TenantCode(string value) + { + Value = value; + } - private TenantCode(string value) => Value = value; + public string Value { get; } - /// Creates a after trimming and validating the input. + /// Creates a after trimming and validating the input. public static ErrorOr Create(string value) { string trimmed = value?.Trim() ?? string.Empty; @@ -27,8 +29,13 @@ public static ErrorOr Create(string value) } /// Factory method for EF Core use only. Bypasses validation as values come from persistence. - public static TenantCode FromPersistence(string value) => new(value); + public static TenantCode FromPersistence(string value) + { + return new TenantCode(value); + } - public static implicit operator string(TenantCode code) => code.Value; + public static implicit operator string(TenantCode code) + { + return code.Value; + } } - diff --git a/src/Modules/Notifications/Contracts/EmailMessage.cs b/src/Modules/Notifications/Contracts/EmailMessage.cs index 85b755a8..2f9bbc41 100644 --- a/src/Modules/Notifications/Contracts/EmailMessage.cs +++ b/src/Modules/Notifications/Contracts/EmailMessage.cs @@ -1,14 +1,14 @@ namespace Notifications.Contracts; /// -/// Immutable value object representing a single outbound email queued for delivery. -/// Passed through and consumed by the email-sending background service. +/// 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. +/// Optional template name used for logging and dead-letter categorisation. /// /// -/// When true the email retry service will attempt redelivery on failure. +/// When true the email retry service will attempt redelivery on failure. /// public sealed record EmailMessage( string To, @@ -17,7 +17,3 @@ public sealed record EmailMessage( string? TemplateName = null, bool Retryable = false ); - - - - diff --git a/src/Modules/Notifications/Contracts/IEmailQueue.cs b/src/Modules/Notifications/Contracts/IEmailQueue.cs index 5e77b839..fd4984d5 100644 --- a/src/Modules/Notifications/Contracts/IEmailQueue.cs +++ b/src/Modules/Notifications/Contracts/IEmailQueue.cs @@ -1,20 +1,13 @@ -using Notifications.Contracts; -using Notifications.Domain; -using Notifications.Services; using SharedKernel.Application.BackgroundJobs; namespace Notifications.Contracts; /// -/// Write-side contract for enqueuing outbound email messages for asynchronous delivery. +/// 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. +/// Read-side contract for the email background service to consume queued items. /// public interface IEmailQueueReader : IQueueReader; - - - - diff --git a/src/Modules/Notifications/Contracts/IEmailSender.cs b/src/Modules/Notifications/Contracts/IEmailSender.cs index 2d093fa7..7b250a22 100644 --- a/src/Modules/Notifications/Contracts/IEmailSender.cs +++ b/src/Modules/Notifications/Contracts/IEmailSender.cs @@ -1,15 +1,11 @@ namespace Notifications.Contracts; /// -/// Application-layer abstraction for sending emails, decoupling the Application layer from -/// any specific mail provider (SMTP, SendGrid, AWS SES, etc.). +/// 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); + /// Transmits to its recipient via the configured mail provider. + public Task SendAsync(EmailMessage message, CancellationToken ct = default); } - - - - diff --git a/src/Modules/Notifications/Contracts/IEmailTemplateRenderer.cs b/src/Modules/Notifications/Contracts/IEmailTemplateRenderer.cs index a203f353..058282e4 100644 --- a/src/Modules/Notifications/Contracts/IEmailTemplateRenderer.cs +++ b/src/Modules/Notifications/Contracts/IEmailTemplateRenderer.cs @@ -1,18 +1,18 @@ namespace Notifications.Contracts; /// -/// 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.). +/// 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. + /// Renders the template identified by using the supplied + /// and returns the resulting HTML string. /// - Task RenderAsync(string templateName, object model, CancellationToken ct = default); + public Task RenderAsync( + string templateName, + object model, + CancellationToken ct = default + ); } - - - - diff --git a/src/Modules/Notifications/Contracts/IFailedEmailStore.cs b/src/Modules/Notifications/Contracts/IFailedEmailStore.cs index 2b93f3b0..3675be85 100644 --- a/src/Modules/Notifications/Contracts/IFailedEmailStore.cs +++ b/src/Modules/Notifications/Contracts/IFailedEmailStore.cs @@ -1,18 +1,18 @@ namespace Notifications.Contracts; /// -/// Application-layer abstraction for persisting emails that could not be delivered, -/// enabling later inspection, manual retry, or dead-letter analysis. +/// 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. + /// 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); + public Task StoreFailedAsync( + EmailMessage message, + string error, + CancellationToken ct = default + ); } - - - - diff --git a/src/Modules/Notifications/Domain/IFailedEmailRepository.cs b/src/Modules/Notifications/Domain/IFailedEmailRepository.cs index a75ff1b4..d7c22277 100644 --- a/src/Modules/Notifications/Domain/IFailedEmailRepository.cs +++ b/src/Modules/Notifications/Domain/IFailedEmailRepository.cs @@ -1,23 +1,19 @@ -using Notifications.Contracts; -using Notifications.Domain; -using Notifications.Services; - namespace Notifications.Domain; /// -/// Repository contract for records, providing pessimistic-claim operations -/// used by the email retry background service to prevent duplicate processing. +/// 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); + public Task AddAsync(FailedEmail failedEmail, CancellationToken ct = default); /// - /// Atomically claims a batch of unclaimed, retryable emails (those below ) - /// and returns them for processing. + /// Atomically claims a batch of unclaimed, retryable emails (those below ) + /// and returns them for processing. /// - Task> ClaimRetryableBatchAsync( + public Task> ClaimRetryableBatchAsync( int maxRetryAttempts, int batchSize, string claimedBy, @@ -27,10 +23,10 @@ Task> ClaimRetryableBatchAsync( ); /// - /// Atomically claims a batch of emails whose claim lock has expired past , - /// allowing stale claims to be retried. + /// Atomically claims a batch of emails whose claim lock has expired past , + /// allowing stale claims to be retried. /// - Task> ClaimExpiredBatchAsync( + public Task> ClaimExpiredBatchAsync( DateTime cutoff, int batchSize, string claimedBy, @@ -40,13 +36,8 @@ Task> ClaimExpiredBatchAsync( ); /// Persists changes to an existing failed-email record (e.g. retry count increment or dead-letter flag). - Task UpdateAsync(FailedEmail failedEmail, CancellationToken ct = default); + public 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); + public Task DeleteAsync(FailedEmail failedEmail, CancellationToken ct = default); } - - - - - diff --git a/src/Modules/Notifications/Features/SendTenantInvitationEmail/TenantInvitationEmailHandler.cs b/src/Modules/Notifications/Features/SendTenantInvitationEmail/TenantInvitationEmailHandler.cs index 8fcb0d7b..dd18bf74 100644 --- a/src/Modules/Notifications/Features/SendTenantInvitationEmail/TenantInvitationEmailHandler.cs +++ b/src/Modules/Notifications/Features/SendTenantInvitationEmail/TenantInvitationEmailHandler.cs @@ -1,6 +1,4 @@ using Notifications.Contracts; -using Notifications.Domain; -using Notifications.Services; using SharedKernel.Contracts.Events; namespace Notifications.Features; @@ -14,14 +12,14 @@ public static async Task HandleAsync( CancellationToken ct ) { - var html = await templateRenderer.RenderAsync( + string html = await templateRenderer.RenderAsync( EmailTemplateNames.TenantInvitation, new { @event.Email, @event.TenantName, - InvitationUrl = @event.InvitationUrl, - ExpiryHours = @event.ExpiryHours, + @event.InvitationUrl, + @event.ExpiryHours, }, ct ); @@ -32,7 +30,7 @@ await emailQueue.EnqueueAsync( $"You've been invited to {@event.TenantName}", html, EmailTemplateNames.TenantInvitation, - Retryable: true + true ), ct ); diff --git a/src/Modules/Notifications/Features/SendUserRegisteredEmail/UserRegisteredEmailHandler.cs b/src/Modules/Notifications/Features/SendUserRegisteredEmail/UserRegisteredEmailHandler.cs index ebe56f41..d086959c 100644 --- a/src/Modules/Notifications/Features/SendUserRegisteredEmail/UserRegisteredEmailHandler.cs +++ b/src/Modules/Notifications/Features/SendUserRegisteredEmail/UserRegisteredEmailHandler.cs @@ -1,7 +1,5 @@ using Microsoft.Extensions.Options; using Notifications.Contracts; -using Notifications.Domain; -using Notifications.Services; using SharedKernel.Contracts.Events; namespace Notifications.Features; @@ -16,7 +14,7 @@ public static async Task HandleAsync( CancellationToken ct ) { - var html = await templateRenderer.RenderAsync( + string html = await templateRenderer.RenderAsync( EmailTemplateNames.UserRegistration, new { diff --git a/src/Modules/Notifications/Features/SendUserRoleChangedEmail/UserRoleChangedEmailHandler.cs b/src/Modules/Notifications/Features/SendUserRoleChangedEmail/UserRoleChangedEmailHandler.cs index 7d8f06e7..71ba33c5 100644 --- a/src/Modules/Notifications/Features/SendUserRoleChangedEmail/UserRoleChangedEmailHandler.cs +++ b/src/Modules/Notifications/Features/SendUserRoleChangedEmail/UserRoleChangedEmailHandler.cs @@ -1,7 +1,4 @@ -using Microsoft.Extensions.Options; using Notifications.Contracts; -using Notifications.Domain; -using Notifications.Services; using SharedKernel.Contracts.Events; namespace Notifications.Features; @@ -15,7 +12,7 @@ public static async Task HandleAsync( CancellationToken ct ) { - var html = await templateRenderer.RenderAsync( + string html = await templateRenderer.RenderAsync( EmailTemplateNames.UserRoleChanged, new { diff --git a/src/Modules/Notifications/Logging/NotificationsInfrastructureLogs.cs b/src/Modules/Notifications/Logging/NotificationsInfrastructureLogs.cs index a8a86144..556fc43e 100644 --- a/src/Modules/Notifications/Logging/NotificationsInfrastructureLogs.cs +++ b/src/Modules/Notifications/Logging/NotificationsInfrastructureLogs.cs @@ -4,7 +4,7 @@ namespace Notifications.Logging; /// -/// Source-generated logger extension methods for Notifications infrastructure diagnostics. +/// Source-generated logger extension methods for Notifications infrastructure diagnostics. /// internal static partial class NotificationsInfrastructureLogs { diff --git a/src/Modules/Notifications/NotificationsDbMarker.cs b/src/Modules/Notifications/NotificationsDbMarker.cs index 56924226..ebd62870 100644 --- a/src/Modules/Notifications/NotificationsDbMarker.cs +++ b/src/Modules/Notifications/NotificationsDbMarker.cs @@ -1,8 +1,8 @@ namespace Notifications; /// -/// Domain-layer marker type identifying the Notifications module's persistence boundary. -/// Used as the type parameter for -/// so that handlers can request the correct unit of work without referencing the Infrastructure layer. +/// Domain-layer marker type identifying the Notifications module's persistence boundary. +/// Used as the type parameter for +/// so that handlers can request the correct unit of work without referencing the Infrastructure layer. /// public abstract class NotificationsDbMarker; diff --git a/src/Modules/Notifications/NotificationsModule.cs b/src/Modules/Notifications/NotificationsModule.cs index 2f510a4f..db09ab64 100644 --- a/src/Modules/Notifications/NotificationsModule.cs +++ b/src/Modules/Notifications/NotificationsModule.cs @@ -1,4 +1,3 @@ -using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Modules/Notifications/NotificationsRuntimeBridge.cs b/src/Modules/Notifications/NotificationsRuntimeBridge.cs index eaba1ac6..b7b8ef95 100644 --- a/src/Modules/Notifications/NotificationsRuntimeBridge.cs +++ b/src/Modules/Notifications/NotificationsRuntimeBridge.cs @@ -1,13 +1,13 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Notifications; using Notifications.Contracts; using Notifications.Domain; using Notifications.Persistence; using Notifications.Repositories; using Notifications.Services; using Polly; +using Polly.Retry; using SharedKernel.Application.Resilience; using SharedKernel.Infrastructure.Configuration; using SharedKernel.Infrastructure.Registration; @@ -55,7 +55,7 @@ IConfiguration configuration builder => { builder.AddRetry( - new() + new RetryStrategyOptions { MaxRetryAttempts = emailOptions.MaxRetryAttempts, BackoffType = DelayBackoffType.Exponential, diff --git a/src/Modules/Notifications/Persistence/FailedEmailConfiguration.cs b/src/Modules/Notifications/Persistence/FailedEmailConfiguration.cs index 60de0ff7..51fc0b21 100644 --- a/src/Modules/Notifications/Persistence/FailedEmailConfiguration.cs +++ b/src/Modules/Notifications/Persistence/FailedEmailConfiguration.cs @@ -1,12 +1,13 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Notifications.Contracts; using Notifications.Domain; -using Notifications.Services; namespace Notifications.Persistence; -/// EF Core configuration for the entity, with composite indexes optimized for claim-based retry and expiration queries. +/// +/// 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) diff --git a/src/Modules/Notifications/Persistence/NotificationsDbContext.cs b/src/Modules/Notifications/Persistence/NotificationsDbContext.cs index d66d1537..6842744e 100644 --- a/src/Modules/Notifications/Persistence/NotificationsDbContext.cs +++ b/src/Modules/Notifications/Persistence/NotificationsDbContext.cs @@ -1,7 +1,5 @@ using Microsoft.EntityFrameworkCore; -using Notifications.Contracts; using Notifications.Domain; -using Notifications.Services; using SharedKernel.Application.Context; using SharedKernel.Infrastructure.Auditing; using SharedKernel.Infrastructure.EntityNormalization; diff --git a/src/Modules/Notifications/Repositories/FailedEmailRepository.cs b/src/Modules/Notifications/Repositories/FailedEmailRepository.cs index 25bed920..03697a73 100644 --- a/src/Modules/Notifications/Repositories/FailedEmailRepository.cs +++ b/src/Modules/Notifications/Repositories/FailedEmailRepository.cs @@ -1,16 +1,13 @@ -using Microsoft.EntityFrameworkCore; -using Notifications.Contracts; using Notifications.Domain; using Notifications.Persistence; -using Notifications.Services; using Notifications.StoredProcedures; using SharedKernel.Domain.Interfaces; namespace Notifications.Repositories; /// -/// EF Core repository for that coordinates direct context access -/// with stored-procedure-based batch claiming for concurrent retry processing. +/// EF Core repository for that coordinates direct context access +/// with stored-procedure-based batch claiming for concurrent retry processing. /// public sealed class FailedEmailRepository : IFailedEmailRepository { @@ -33,7 +30,10 @@ public Task AddAsync(FailedEmail failedEmail, CancellationToken ct = default) return Task.CompletedTask; } - /// Atomically claims a batch of retryable failed emails via the stored procedure. + /// + /// Atomically claims a batch of retryable failed emails via the + /// stored procedure. + /// public async Task> ClaimRetryableBatchAsync( int maxRetryAttempts, int batchSize, @@ -43,7 +43,7 @@ public async Task> ClaimRetryableBatchAsync( CancellationToken ct = default ) { - var procedure = new ClaimRetryableFailedEmailsProcedure( + ClaimRetryableFailedEmailsProcedure procedure = new( maxRetryAttempts, batchSize, claimedBy, @@ -54,7 +54,10 @@ public async Task> ClaimRetryableBatchAsync( return result.ToList(); } - /// Atomically claims a batch of expired (dead-letter candidate) failed emails via the stored procedure. + /// + /// Atomically claims a batch of expired (dead-letter candidate) failed emails via the + /// stored procedure. + /// public async Task> ClaimExpiredBatchAsync( DateTime cutoff, int batchSize, @@ -64,7 +67,7 @@ public async Task> ClaimExpiredBatchAsync( CancellationToken ct = default ) { - var procedure = new ClaimExpiredFailedEmailsProcedure( + ClaimExpiredFailedEmailsProcedure procedure = new( cutoff, batchSize, claimedBy, diff --git a/src/Modules/Notifications/Services/ChannelEmailQueue.cs b/src/Modules/Notifications/Services/ChannelEmailQueue.cs index e7454ca5..0168bca3 100644 --- a/src/Modules/Notifications/Services/ChannelEmailQueue.cs +++ b/src/Modules/Notifications/Services/ChannelEmailQueue.cs @@ -1,16 +1,15 @@ using Notifications.Contracts; -using Notifications.Domain; -using Notifications.Services; +using SharedKernel.Infrastructure.BackgroundJobs.Services; namespace Notifications.Services; /// -/// Bounded in-process email queue backed by a . -/// Implements both (producer) and (consumer) -/// so that callers and the sending background service remain decoupled. +/// 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 - : SharedKernel.Infrastructure.BackgroundJobs.Services.BoundedChannelQueue, + : BoundedChannelQueue, IEmailQueue, IEmailQueueReader { @@ -19,8 +18,3 @@ public sealed class ChannelEmailQueue public ChannelEmailQueue() : base(DefaultCapacity) { } } - - - - - diff --git a/src/Modules/Notifications/Services/EmailRetryRecurringJob.cs b/src/Modules/Notifications/Services/EmailRetryRecurringJob.cs index f9f279b3..2d922a50 100644 --- a/src/Modules/Notifications/Services/EmailRetryRecurringJob.cs +++ b/src/Modules/Notifications/Services/EmailRetryRecurringJob.cs @@ -1,8 +1,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Notifications.Contracts; -using Notifications.Domain; -using Notifications.Services; using Notifications.Logging; using SharedKernel.Application.BackgroundJobs; using SharedKernel.Application.Options.BackgroundJobs; @@ -11,16 +9,16 @@ namespace Notifications.Services; /// -/// TickerQ recurring job that retries previously failed emails and dead-letters those that have -/// exceeded the configured retry window, delegating to . -/// Execution is gated by to prevent multi-node duplication. +/// TickerQ recurring job that retries previously failed emails and dead-letters those that have +/// exceeded the configured retry window, delegating to . +/// Execution is gated by to prevent multi-node duplication. /// public sealed class EmailRetryRecurringJob { - private readonly IEmailRetryService _emailRetryService; private readonly IDistributedJobCoordinator _coordinator; - private readonly EmailRetryJobOptions _options; + private readonly IEmailRetryService _emailRetryService; private readonly ILogger _logger; + private readonly EmailRetryJobOptions _options; public EmailRetryRecurringJob( IEmailRetryService emailRetryService, @@ -36,12 +34,13 @@ ILogger logger } /// - /// TickerQ entry-point that acquires the distributed leader lease and runs retry and - /// dead-letter operations using settings from . + /// TickerQ entry-point that acquires the distributed leader lease and runs retry and + /// dead-letter operations using settings from . /// [TickerFunction("email-retry-recurring-job")] - public Task ExecuteAsync(TickerFunctionContext context, CancellationToken ct) => - _coordinator.ExecuteIfLeaderAsync( + public Task ExecuteAsync(TickerFunctionContext context, CancellationToken ct) + { + return _coordinator.ExecuteIfLeaderAsync( "email-retry-recurring-job", async token => { @@ -60,9 +59,5 @@ await _emailRetryService.DeadLetterExpiredAsync( }, ct ); + } } - - - - - diff --git a/src/Modules/Notifications/Services/EmailRetryRecurringJobRegistration.cs b/src/Modules/Notifications/Services/EmailRetryRecurringJobRegistration.cs index e5d194dd..beb720f4 100644 --- a/src/Modules/Notifications/Services/EmailRetryRecurringJobRegistration.cs +++ b/src/Modules/Notifications/Services/EmailRetryRecurringJobRegistration.cs @@ -1,13 +1,13 @@ -using SharedKernel.Application.BackgroundJobs; -using SharedKernel.Application.Options.BackgroundJobs; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using SharedKernel.Application.BackgroundJobs; +using SharedKernel.Application.Options.BackgroundJobs; namespace Notifications.Services; /// -/// Provides the for the email-retry recurring job, -/// sourcing schedule and enablement from . +/// Provides the for the email-retry recurring job, +/// sourcing schedule and enablement from . /// public sealed class EmailRetryRecurringJobRegistration : IRecurringBackgroundJobRegistration { @@ -16,9 +16,8 @@ public RecurringBackgroundJobDefinition Build(IServiceProvider serviceProvider) { EmailRetryJobOptions options = serviceProvider .GetRequiredService>() - .Value - .EmailRetry; - return new( + .Value.EmailRetry; + return new RecurringBackgroundJobDefinition( new Guid("31261201-e220-45d0-bd7e-6d662ca1acaf"), "email-retry-recurring-job", options.Cron, @@ -27,8 +26,3 @@ public RecurringBackgroundJobDefinition Build(IServiceProvider serviceProvider) ); } } - - - - - diff --git a/src/Modules/Notifications/Services/EmailRetryService.cs b/src/Modules/Notifications/Services/EmailRetryService.cs index c4b7f1c3..34fab36a 100644 --- a/src/Modules/Notifications/Services/EmailRetryService.cs +++ b/src/Modules/Notifications/Services/EmailRetryService.cs @@ -1,9 +1,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Notifications; using Notifications.Contracts; using Notifications.Domain; -using Notifications.Services; using Notifications.Logging; using Polly; using Polly.Registry; @@ -14,20 +12,20 @@ namespace Notifications.Services; /// -/// Infrastructure implementation of that claims and retries -/// failed emails from the store and moves permanently undeliverable ones to the dead-letter state. -/// Uses optimistic per-record claiming to avoid duplicate processing in multi-instance deployments. +/// Infrastructure implementation of that claims and retries +/// failed emails from the store and moves permanently undeliverable ones to the dead-letter state. +/// Uses optimistic per-record claiming to avoid duplicate processing in multi-instance deployments. /// public sealed class EmailRetryService : IEmailRetryService { private readonly string _claimOwner; + private readonly ILogger _logger; + private readonly EmailRetryJobOptions _options; private readonly IFailedEmailRepository _repository; + private readonly ResiliencePipelineProvider _resiliencePipelineProvider; private readonly IEmailSender _sender; - private readonly IUnitOfWork _unitOfWork; private readonly TimeProvider _timeProvider; - private readonly EmailRetryJobOptions _options; - private readonly ResiliencePipelineProvider _resiliencePipelineProvider; - private readonly ILogger _logger; + private readonly IUnitOfWork _unitOfWork; public EmailRetryService( IFailedEmailRepository repository, @@ -50,9 +48,9 @@ ILogger logger } /// - /// Claims up to retryable failed emails, attempts delivery via - /// the resilience pipeline, and commits progress per-email to prevent duplicate sends on crash. - /// Failures increment RetryCount and release the claim for future attempts. + /// Claims up to retryable failed emails, attempts delivery via + /// the resilience pipeline, and commits progress per-email to prevent duplicate sends on crash. + /// Failures increment RetryCount and release the claim for future attempts. /// public async Task RetryFailedEmailsAsync( int maxRetryAttempts, @@ -78,7 +76,7 @@ public async Task RetryFailedEmailsAsync( { try { - var message = new EmailMessage( + EmailMessage message = new( email.To, email.Subject, email.HtmlBody, @@ -111,8 +109,8 @@ await pipeline.ExecuteAsync( } /// - /// Claims and marks as dead-lettered any failed emails that have been retrying for longer than - /// hours, processing in batches until none remain. + /// Claims and marks as dead-lettered any failed emails that have been retrying for longer than + /// hours, processing in batches until none remain. /// public async Task DeadLetterExpiredAsync( int deadLetterAfterHours, @@ -148,14 +146,7 @@ public async Task DeadLetterExpiredAsync( } if (processed > 0) - { await _unitOfWork.CommitAsync(ct); - } } while (processed == batchSize); } } - - - - - diff --git a/src/Modules/Notifications/Services/FailedEmailErrorNormalizer.cs b/src/Modules/Notifications/Services/FailedEmailErrorNormalizer.cs index f7ba6ef4..0acd2028 100644 --- a/src/Modules/Notifications/Services/FailedEmailErrorNormalizer.cs +++ b/src/Modules/Notifications/Services/FailedEmailErrorNormalizer.cs @@ -1,28 +1,22 @@ -using Notifications.Contracts; using Notifications.Domain; -using Notifications.Services; namespace Notifications.Services; /// -/// Utility that truncates raw exception messages to the maximum length allowed by -/// before persisting them. +/// 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. + /// + /// 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/Modules/Notifications/Services/FailedEmailStore.cs b/src/Modules/Notifications/Services/FailedEmailStore.cs index 5c5dd8f5..1866bc7f 100644 --- a/src/Modules/Notifications/Services/FailedEmailStore.cs +++ b/src/Modules/Notifications/Services/FailedEmailStore.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.Options; using Notifications.Contracts; using Notifications.Domain; -using Notifications.Services; using Notifications.Logging; using SharedKernel.Application.Options.BackgroundJobs; using SharedKernel.Domain.Interfaces; @@ -11,15 +10,15 @@ namespace Notifications.Services; /// -/// Infrastructure implementation of that persists a -/// record when delivery fails, provided the email is marked retryable and the email-retry job is enabled. -/// Uses a new DI scope per call to avoid captive-dependency issues with scoped services. +/// Infrastructure implementation of that persists a +/// record when delivery fails, provided the email is marked retryable and the email-retry job is enabled. +/// 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 bool _enabled; private readonly ILogger _logger; + private readonly IServiceScopeFactory _scopeFactory; public FailedEmailStore( IServiceScopeFactory scopeFactory, @@ -33,9 +32,9 @@ ILogger logger } /// - /// Persists a new for if the message is - /// retryable and the email-retry feature is enabled; silently swallows storage errors to avoid - /// masking the original send failure. + /// Persists a new for if the message is + /// retryable and the email-retry feature is enabled; silently swallows storage errors to avoid + /// masking the original send failure. /// public async Task StoreFailedAsync( EmailMessage message, @@ -44,9 +43,7 @@ public async Task StoreFailedAsync( ) { if (!_enabled || !message.Retryable) - { return; - } try { @@ -56,7 +53,7 @@ public async Task StoreFailedAsync( IUnitOfWork unitOfWork = scope.ServiceProvider.GetRequiredService(); TimeProvider timeProvider = scope.ServiceProvider.GetRequiredService(); - var failedEmail = new FailedEmail + FailedEmail failedEmail = new() { Id = Guid.NewGuid(), To = message.To, @@ -82,8 +79,3 @@ public async Task StoreFailedAsync( } } } - - - - - diff --git a/src/Modules/Notifications/Services/FluidEmailTemplateRenderer.cs b/src/Modules/Notifications/Services/FluidEmailTemplateRenderer.cs index e4b0c9dd..1bad57c4 100644 --- a/src/Modules/Notifications/Services/FluidEmailTemplateRenderer.cs +++ b/src/Modules/Notifications/Services/FluidEmailTemplateRenderer.cs @@ -6,18 +6,19 @@ namespace Notifications.Services; /// -/// Renders Liquid email templates embedded as assembly resources using the Fluid library. -/// Parsed templates are cached in a to avoid -/// repeated parsing across requests. +/// 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(StringComparer.Ordinal); - /// Retrieves (or parses and caches) the named template and renders it against . + /// Retrieves (or parses and caches) the named template and renders it against . public async Task RenderAsync( string templateName, object model, @@ -25,7 +26,7 @@ public async Task RenderAsync( ) { IFluidTemplate template = await GetOrParseTemplateAsync(templateName); - var context = new TemplateContext(model); + TemplateContext context = new(model); return await template.RenderAsync(context); } @@ -65,16 +66,18 @@ private static async Task ParseTemplateAsync(string templateName 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}" ); + } return template; } private static async Task LoadTemplateAsync(string templateName) { - var resourceName = $"{ResourceAssembly.GetName().Name}.{templateName}.liquid"; + string resourceName = $"{ResourceAssembly.GetName().Name}.{templateName}.liquid"; await using Stream stream = ResourceAssembly.GetManifestResourceStream(resourceName) @@ -82,7 +85,7 @@ private static async Task LoadTemplateAsync(string templateName) $"Email template '{templateName}' not found as embedded resource '{resourceName}'." ); - using var reader = new StreamReader(stream); + using StreamReader reader = new(stream); return await reader.ReadToEndAsync(); } } diff --git a/src/Modules/Notifications/Services/MailKitEmailSender.cs b/src/Modules/Notifications/Services/MailKitEmailSender.cs index c33c210e..b589af34 100644 --- a/src/Modules/Notifications/Services/MailKitEmailSender.cs +++ b/src/Modules/Notifications/Services/MailKitEmailSender.cs @@ -3,20 +3,19 @@ using Microsoft.Extensions.Options; using MimeKit; using Notifications.Contracts; -using Notifications.Domain; using Notifications.Logging; namespace Notifications.Services; /// -/// Infrastructure implementation of that delivers email over SMTP -/// using MailKit, with optional authentication and TLS controlled by . +/// Infrastructure implementation of that delivers email over SMTP +/// using MailKit, with optional authentication and TLS controlled by . /// public sealed class MailKitEmailSender : IEmailSender, IAsyncDisposable { - private readonly EmailOptions _options; - private readonly ILogger _logger; private readonly SemaphoreSlim _lock = new(1, 1); + private readonly ILogger _logger; + private readonly EmailOptions _options; private SmtpClient? _client; public MailKitEmailSender(IOptions options, ILogger logger) @@ -25,13 +24,27 @@ public MailKitEmailSender(IOptions options, ILogger - /// Builds a MIME message, connects and optionally authenticates against the configured SMTP server, - /// sends the message, and disconnects cleanly before returning. + /// 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 mimeMessage = new(); mimeMessage.From.Add(new MailboxAddress(_options.SenderName, _options.SenderEmail)); mimeMessage.To.Add(MailboxAddress.Parse(message.To)); mimeMessage.Subject = message.Subject; @@ -53,9 +66,7 @@ await client.ConnectAsync( } if (!string.IsNullOrEmpty(_options.Username) && !client.IsAuthenticated) - { await client.AuthenticateAsync(_options.Username, _options.Password, ct); - } await client.SendAsync(mimeMessage, ct); } @@ -75,16 +86,12 @@ await client.ConnectAsync( private async Task ResetClientAsync() { if (_client is null) - { return; - } try { if (_client.IsConnected) - { - await _client.DisconnectAsync(quit: true, CancellationToken.None); - } + await _client.DisconnectAsync(true, CancellationToken.None); } catch { @@ -96,18 +103,4 @@ private async Task ResetClientAsync() _client = null; } } - - public async ValueTask DisposeAsync() - { - await _lock.WaitAsync(); - try - { - await ResetClientAsync(); - } - finally - { - _lock.Release(); - _lock.Dispose(); - } - } } diff --git a/src/Modules/Notifications/StoredProcedures/ClaimExpiredFailedEmailsProcedure.cs b/src/Modules/Notifications/StoredProcedures/ClaimExpiredFailedEmailsProcedure.cs index 5c7ab1d7..9e2ead49 100644 --- a/src/Modules/Notifications/StoredProcedures/ClaimExpiredFailedEmailsProcedure.cs +++ b/src/Modules/Notifications/StoredProcedures/ClaimExpiredFailedEmailsProcedure.cs @@ -1,14 +1,12 @@ -using Notifications.Contracts; using Notifications.Domain; -using Notifications.Services; using SharedKernel.Domain.Interfaces; namespace Notifications.StoredProcedures; /// -/// Calls the claim_expired_failed_emails(...) PostgreSQL function. -/// Atomically selects and claims a batch of expired failed emails using -/// FOR UPDATE SKIP LOCKED to avoid contention between concurrent workers. +/// Calls the claim_expired_failed_emails(...) PostgreSQL function. +/// Atomically selects and claims a batch of expired failed emails using +/// FOR UPDATE SKIP LOCKED to avoid contention between concurrent workers. /// public sealed record ClaimExpiredFailedEmailsProcedure( DateTime Cutoff, @@ -18,6 +16,8 @@ public sealed record ClaimExpiredFailedEmailsProcedure( DateTime ClaimedUntilUtc ) : IStoredProcedure { - public FormattableString ToSql() => - $"SELECT * FROM claim_expired_failed_emails({Cutoff}, {BatchSize}, {ClaimedBy}, {ClaimedAtUtc}, {ClaimedUntilUtc})"; + public FormattableString ToSql() + { + return $"SELECT * FROM claim_expired_failed_emails({Cutoff}, {BatchSize}, {ClaimedBy}, {ClaimedAtUtc}, {ClaimedUntilUtc})"; + } } diff --git a/src/Modules/Notifications/StoredProcedures/ClaimRetryableFailedEmailsProcedure.cs b/src/Modules/Notifications/StoredProcedures/ClaimRetryableFailedEmailsProcedure.cs index 6151b53c..831584a0 100644 --- a/src/Modules/Notifications/StoredProcedures/ClaimRetryableFailedEmailsProcedure.cs +++ b/src/Modules/Notifications/StoredProcedures/ClaimRetryableFailedEmailsProcedure.cs @@ -1,14 +1,12 @@ -using Notifications.Contracts; using Notifications.Domain; -using Notifications.Services; using SharedKernel.Domain.Interfaces; namespace Notifications.StoredProcedures; /// -/// Calls the claim_retryable_failed_emails(...) PostgreSQL function. -/// Atomically selects and claims a batch of retryable failed emails using -/// FOR UPDATE SKIP LOCKED to avoid contention between concurrent workers. +/// Calls the claim_retryable_failed_emails(...) PostgreSQL function. +/// Atomically selects and claims a batch of retryable failed emails using +/// FOR UPDATE SKIP LOCKED to avoid contention between concurrent workers. /// public sealed record ClaimRetryableFailedEmailsProcedure( int MaxRetryAttempts, @@ -18,6 +16,8 @@ public sealed record ClaimRetryableFailedEmailsProcedure( DateTime ClaimedUntilUtc ) : IStoredProcedure { - public FormattableString ToSql() => - $"SELECT * FROM claim_retryable_failed_emails({MaxRetryAttempts}, {BatchSize}, {ClaimedBy}, {ClaimedAtUtc}, {ClaimedUntilUtc})"; + public FormattableString ToSql() + { + return $"SELECT * FROM claim_retryable_failed_emails({MaxRetryAttempts}, {BatchSize}, {ClaimedBy}, {ClaimedAtUtc}, {ClaimedUntilUtc})"; + } } diff --git a/src/Modules/ProductCatalog/Common/Errors/DomainErrors.cs b/src/Modules/ProductCatalog/Common/Errors/DomainErrors.cs index 13ed219a..fa266f82 100644 --- a/src/Modules/ProductCatalog/Common/Errors/DomainErrors.cs +++ b/src/Modules/ProductCatalog/Common/Errors/DomainErrors.cs @@ -1,42 +1,48 @@ -using ErrorOr; - namespace ProductCatalog.Common.Errors; public static class DomainErrors { public static class Products { - public static Error NotFound(Guid id) => - Error.NotFound( - code: ErrorCatalog.Products.NotFound, - description: string.Format(ErrorCatalog.Products.NotFoundMessage, id) + public static Error NotFound(Guid id) + { + return Error.NotFound( + ErrorCatalog.Products.NotFound, + string.Format(ErrorCatalog.Products.NotFoundMessage, id) ); + } } public static class Patch { - public static Error InvalidPatchDocument(string message) => - Error.Validation( - code: ErrorCatalog.Patch.InvalidDocument, - description: string.Format(ErrorCatalog.Patch.InvalidDocumentMessage, message) + public static Error InvalidPatchDocument(string message) + { + return Error.Validation( + ErrorCatalog.Patch.InvalidDocument, + string.Format(ErrorCatalog.Patch.InvalidDocumentMessage, message) ); + } } public static class ProductData { - public static Error NotFound(Guid id) => - Error.NotFound( - code: ErrorCatalog.ProductData.NotFound, - description: string.Format(ErrorCatalog.ProductData.NotFoundMessage, id) + public static Error NotFound(Guid id) + { + return Error.NotFound( + ErrorCatalog.ProductData.NotFound, + string.Format(ErrorCatalog.ProductData.NotFoundMessage, id) ); + } } public static class Categories { - public static Error NotFound(Guid id) => - Error.NotFound( - code: ErrorCatalog.Categories.NotFound, - description: string.Format(ErrorCatalog.Categories.NotFoundMessage, id) + public static Error NotFound(Guid id) + { + return Error.NotFound( + ErrorCatalog.Categories.NotFound, + string.Format(ErrorCatalog.Categories.NotFoundMessage, id) ); + } } } diff --git a/src/Modules/ProductCatalog/Common/Errors/ErrorCatalog.cs b/src/Modules/ProductCatalog/Common/Errors/ErrorCatalog.cs index b91a3aa4..6e768a6b 100644 --- a/src/Modules/ProductCatalog/Common/Errors/ErrorCatalog.cs +++ b/src/Modules/ProductCatalog/Common/Errors/ErrorCatalog.cs @@ -9,6 +9,7 @@ public static class Products public const string NotFoundMessage = "Product '{0}' not found."; public const string ProductDataNotFound = "PRD-2404"; public const string AlreadyExistsMessage = "Product '{0}' already exists."; + public const string DuplicateIdMessage = "Duplicate product ID '{0}' appears multiple times in the request."; } @@ -32,6 +33,7 @@ public static class Categories public const string NotFound = "CAT-0404"; public const string NotFoundMessage = "Category '{0}' not found."; public const string AlreadyExistsMessage = "Category '{0}' already exists."; + public const string DuplicateIdMessage = "Duplicate category ID '{0}' appears multiple times in the request."; } diff --git a/src/Modules/ProductCatalog/Common/Errors/ProductCatalogDomainErrors.cs b/src/Modules/ProductCatalog/Common/Errors/ProductCatalogDomainErrors.cs index 456a31a8..d5b4e10e 100644 --- a/src/Modules/ProductCatalog/Common/Errors/ProductCatalogDomainErrors.cs +++ b/src/Modules/ProductCatalog/Common/Errors/ProductCatalogDomainErrors.cs @@ -1,12 +1,12 @@ -using ErrorOr; - namespace ProductCatalog.Common.Errors; internal static class ProductCatalogDomainErrors { internal static class Products { - internal static Error NegativePrice() => - Error.Validation("PC-0400", "Price cannot be negative."); + internal static Error NegativePrice() + { + return Error.Validation("PC-0400", "Price cannot be negative."); + } } } diff --git a/src/Modules/ProductCatalog/Common/Events/CacheTags.cs b/src/Modules/ProductCatalog/Common/Events/CacheTags.cs index 0a8ea50c..17a6484f 100644 --- a/src/Modules/ProductCatalog/Common/Events/CacheTags.cs +++ b/src/Modules/ProductCatalog/Common/Events/CacheTags.cs @@ -5,6 +5,7 @@ public static class CacheTags public const string Products = "Products"; public const string Categories = "Categories"; public const string ProductData = "ProductData"; + /// Reviews cache is also invalidated when products are deleted (orphaned reviews). public const string Reviews = "Reviews"; } diff --git a/src/Modules/ProductCatalog/Configurations/CategoryConfiguration.cs b/src/Modules/ProductCatalog/Configurations/CategoryConfiguration.cs index 8aedc9a8..24619794 100644 --- a/src/Modules/ProductCatalog/Configurations/CategoryConfiguration.cs +++ b/src/Modules/ProductCatalog/Configurations/CategoryConfiguration.cs @@ -1,10 +1,9 @@ -using ProductCatalog.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace ProductCatalog.Configurations; -/// EF Core configuration for the entity, including a full-text search GIN index. +/// EF Core configuration for the entity, including a full-text search GIN index. public sealed class CategoryConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) @@ -23,6 +22,3 @@ public void Configure(EntityTypeBuilder builder) .IsTsVectorExpressionIndex("english"); } } - - - diff --git a/src/Modules/ProductCatalog/Entities/ProductData/ProductData.cs b/src/Modules/ProductCatalog/Entities/ProductData/ProductData.cs index 4976543a..400e6595 100644 --- a/src/Modules/ProductCatalog/Entities/ProductData/ProductData.cs +++ b/src/Modules/ProductCatalog/Entities/ProductData/ProductData.cs @@ -3,16 +3,14 @@ namespace ProductCatalog.Entities.ProductData; /// -/// Abstract base document stored in MongoDB that describes rich media associated with products. -/// Serves as the discriminator root for the and subtypes. +/// 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; @@ -26,7 +24,7 @@ public abstract class ProductData : IHasId public DateTime? DeletedAtUtc { get; set; } public Guid? DeletedBy { get; set; } -} - - + [BsonId] + public Guid Id { get; set; } = Guid.NewGuid(); +} diff --git a/src/Modules/ProductCatalog/Entities/ProductData/VideoProductData.cs b/src/Modules/ProductCatalog/Entities/ProductData/VideoProductData.cs index 8853d997..1e0b1073 100644 --- a/src/Modules/ProductCatalog/Entities/ProductData/VideoProductData.cs +++ b/src/Modules/ProductCatalog/Entities/ProductData/VideoProductData.cs @@ -3,7 +3,8 @@ namespace ProductCatalog.Entities.ProductData; /// -/// MongoDB document subtype that represents video media linked to a product, storing video-specific metadata such as duration and resolution. +/// 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 @@ -16,6 +17,3 @@ public sealed class VideoProductData : ProductData public long FileSizeBytes { get; set; } } - - - diff --git a/src/Modules/ProductCatalog/Features/Category/CategoriesController.cs b/src/Modules/ProductCatalog/Features/Category/CategoriesController.cs index b4a17c1a..451828d5 100644 --- a/src/Modules/ProductCatalog/Features/Category/CategoriesController.cs +++ b/src/Modules/ProductCatalog/Features/Category/CategoriesController.cs @@ -1,6 +1,4 @@ using Asp.Versioning; -using Microsoft.AspNetCore.Mvc; -using SharedKernel.Contracts.Api; using Wolverine; namespace ProductCatalog.Features.Category; diff --git a/src/Modules/ProductCatalog/Features/Category/CreateCategories/CreateCategoriesCommand.cs b/src/Modules/ProductCatalog/Features/Category/CreateCategories/CreateCategoriesCommand.cs index ecf7d9a7..007e4d76 100644 --- a/src/Modules/ProductCatalog/Features/Category/CreateCategories/CreateCategoriesCommand.cs +++ b/src/Modules/ProductCatalog/Features/Category/CreateCategories/CreateCategoriesCommand.cs @@ -1,7 +1,4 @@ using ErrorOr; -using ProductCatalog; -using SharedKernel.Application.Batch; -using SharedKernel.Contracts.Events; using Wolverine; using CategoryEntity = ProductCatalog.Entities.Category; @@ -10,7 +7,7 @@ namespace ProductCatalog.Features.Category.CreateCategories; /// 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. +/// Handles by validating all items and persisting in a single transaction. public sealed class CreateCategoriesCommandHandler { public static async Task<( diff --git a/src/Modules/ProductCatalog/Features/Category/CreateCategories/CreateCategoriesRequest.cs b/src/Modules/ProductCatalog/Features/Category/CreateCategories/CreateCategoriesRequest.cs index 11d90d6b..5394c5f4 100644 --- a/src/Modules/ProductCatalog/Features/Category/CreateCategories/CreateCategoriesRequest.cs +++ b/src/Modules/ProductCatalog/Features/Category/CreateCategories/CreateCategoriesRequest.cs @@ -3,7 +3,7 @@ namespace ProductCatalog.Features.Category.CreateCategories; /// -/// Carries a list of category items to be created in a single batch operation; accepts between 1 and 100 items. +/// 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.")] diff --git a/src/Modules/ProductCatalog/Features/Category/CreateCategories/CreateCategoryRequest.cs b/src/Modules/ProductCatalog/Features/Category/CreateCategories/CreateCategoryRequest.cs index 55171cec..4156bb0f 100644 --- a/src/Modules/ProductCatalog/Features/Category/CreateCategories/CreateCategoryRequest.cs +++ b/src/Modules/ProductCatalog/Features/Category/CreateCategories/CreateCategoryRequest.cs @@ -1,10 +1,9 @@ using System.ComponentModel.DataAnnotations; -using SharedKernel.Application.Validation; namespace ProductCatalog.Features.Category.CreateCategories; /// -/// Payload for creating a new category, carrying the name and optional description. +/// Payload for creating a new category, carrying the name and optional description. /// public sealed record CreateCategoryRequest( [NotEmpty(ErrorMessage = "Category name is required.")] diff --git a/src/Modules/ProductCatalog/Features/Category/DeleteCategories/CategoriesController.DeleteCategories.cs b/src/Modules/ProductCatalog/Features/Category/DeleteCategories/CategoriesController.DeleteCategories.cs index 72782df3..a60557c5 100644 --- a/src/Modules/ProductCatalog/Features/Category/DeleteCategories/CategoriesController.DeleteCategories.cs +++ b/src/Modules/ProductCatalog/Features/Category/DeleteCategories/CategoriesController.DeleteCategories.cs @@ -1,8 +1,6 @@ using ErrorOr; using Microsoft.AspNetCore.Mvc; using ProductCatalog.Features.Category.DeleteCategories; -using SharedKernel.Contracts.Api; -using SharedKernel.Contracts.Security; namespace ProductCatalog.Features.Category; diff --git a/src/Modules/ProductCatalog/Features/Category/DeleteCategories/DeleteCategoriesCommand.cs b/src/Modules/ProductCatalog/Features/Category/DeleteCategories/DeleteCategoriesCommand.cs index 26c72b8c..bff18b4f 100644 --- a/src/Modules/ProductCatalog/Features/Category/DeleteCategories/DeleteCategoriesCommand.cs +++ b/src/Modules/ProductCatalog/Features/Category/DeleteCategories/DeleteCategoriesCommand.cs @@ -1,5 +1,4 @@ using ErrorOr; -using ProductCatalog; using Wolverine; namespace ProductCatalog.Features.Category.DeleteCategories; @@ -7,12 +6,12 @@ namespace ProductCatalog.Features.Category.DeleteCategories; /// 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. +/// Handles by loading all categories and deleting in a single transaction. public sealed class DeleteCategoriesCommandHandler { public static async Task<( HandlerContinuation, - IReadOnlyList?, + IReadOnlyList?, OutgoingMessages )> LoadAsync( DeleteCategoriesCommand command, @@ -24,8 +23,10 @@ CancellationToken ct BatchFailureContext context = new(ids); // Load all target categories and mark missing ones as failed - IReadOnlyList categories = - await repository.ListAsync(new CategoriesByIdsSpecification(ids.ToHashSet()), ct); + IReadOnlyList categories = await repository.ListAsync( + new CategoriesByIdsSpecification(ids.ToHashSet()), + ct + ); await context.ApplyRulesAsync( ct, @@ -48,7 +49,7 @@ await context.ApplyRulesAsync( public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( DeleteCategoriesCommand command, - IReadOnlyList categories, + IReadOnlyList categories, ICategoryRepository repository, IProductRepository productRepository, IUnitOfWork unitOfWork, diff --git a/src/Modules/ProductCatalog/Features/Category/GetCategories/CategorySpecification.cs b/src/Modules/ProductCatalog/Features/Category/GetCategories/CategorySpecification.cs index 0a515f99..0b3514f0 100644 --- a/src/Modules/ProductCatalog/Features/Category/GetCategories/CategorySpecification.cs +++ b/src/Modules/ProductCatalog/Features/Category/GetCategories/CategorySpecification.cs @@ -4,11 +4,12 @@ namespace ProductCatalog.Features.Category.GetCategories; /// -/// Ardalis specification for querying a filtered and sorted list of categories projected to . +/// Ardalis specification for querying a filtered and sorted list of categories projected to +/// . /// public sealed class CategorySpecification : Specification { - /// Initialises the specification by applying filter, sort, and projection from . + /// Initialises the specification by applying filter, sort, and projection from . public CategorySpecification(CategoryFilter filter) { Query.ApplyFilter(filter); diff --git a/src/Modules/ProductCatalog/Features/Category/GetCategories/GetCategoriesQuery.cs b/src/Modules/ProductCatalog/Features/Category/GetCategories/GetCategoriesQuery.cs index 53ebe472..91942439 100644 --- a/src/Modules/ProductCatalog/Features/Category/GetCategories/GetCategoriesQuery.cs +++ b/src/Modules/ProductCatalog/Features/Category/GetCategories/GetCategoriesQuery.cs @@ -5,7 +5,7 @@ namespace ProductCatalog.Features.Category.GetCategories; /// Returns a paginated, filtered, and sorted list of categories. public sealed record GetCategoriesQuery(CategoryFilter Filter); -/// Handles . +/// Handles . public sealed class GetCategoriesQueryHandler { public static async Task>> HandleAsync( diff --git a/src/Modules/ProductCatalog/Features/Category/GetCategoryById/CategoriesController.GetCategoryById.cs b/src/Modules/ProductCatalog/Features/Category/GetCategoryById/CategoriesController.GetCategoryById.cs index d4384503..a34523d2 100644 --- a/src/Modules/ProductCatalog/Features/Category/GetCategoryById/CategoriesController.GetCategoryById.cs +++ b/src/Modules/ProductCatalog/Features/Category/GetCategoryById/CategoriesController.GetCategoryById.cs @@ -2,8 +2,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.OutputCaching; using ProductCatalog.Features.Category.GetCategoryById; -using SharedKernel.Contracts.Api; -using SharedKernel.Contracts.Security; namespace ProductCatalog.Features.Category; diff --git a/src/Modules/ProductCatalog/Features/Category/GetCategoryById/CategoryByIdSpecification.cs b/src/Modules/ProductCatalog/Features/Category/GetCategoryById/CategoryByIdSpecification.cs index fc97b7c1..b1994de6 100644 --- a/src/Modules/ProductCatalog/Features/Category/GetCategoryById/CategoryByIdSpecification.cs +++ b/src/Modules/ProductCatalog/Features/Category/GetCategoryById/CategoryByIdSpecification.cs @@ -4,11 +4,12 @@ namespace ProductCatalog.Features.Category.GetCategoryById; /// -/// Ardalis specification that fetches a single category by its identifier, projected directly to . +/// Ardalis specification that fetches a single category by its identifier, projected directly to +/// . /// public sealed class CategoryByIdSpecification : Specification { - /// Initialises the specification for the given . + /// Initialises the specification for the given . public CategoryByIdSpecification(Guid id) { Query diff --git a/src/Modules/ProductCatalog/Features/Category/GetCategoryById/GetCategoryByIdQuery.cs b/src/Modules/ProductCatalog/Features/Category/GetCategoryById/GetCategoryByIdQuery.cs index 6b887d58..c212dad9 100644 --- a/src/Modules/ProductCatalog/Features/Category/GetCategoryById/GetCategoryByIdQuery.cs +++ b/src/Modules/ProductCatalog/Features/Category/GetCategoryById/GetCategoryByIdQuery.cs @@ -1,12 +1,11 @@ using ErrorOr; -using ProductCatalog.Interfaces; namespace ProductCatalog.Features.Category.GetCategoryById; -/// Returns a single category by its unique identifier, or if not found. +/// Returns a single category by its unique identifier, or if not found. public sealed record GetCategoryByIdQuery(Guid Id) : IHasId; -/// Handles . +/// Handles . public sealed class GetCategoryByIdQueryHandler { public static async Task> HandleAsync( diff --git a/src/Modules/ProductCatalog/Features/Category/GetCategoryStats/CategoriesController.GetCategoryStats.cs b/src/Modules/ProductCatalog/Features/Category/GetCategoryStats/CategoriesController.GetCategoryStats.cs index 2decc1ee..3c00a7b9 100644 --- a/src/Modules/ProductCatalog/Features/Category/GetCategoryStats/CategoriesController.GetCategoryStats.cs +++ b/src/Modules/ProductCatalog/Features/Category/GetCategoryStats/CategoriesController.GetCategoryStats.cs @@ -2,16 +2,14 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.OutputCaching; using ProductCatalog.Features.Category.GetCategoryStats; -using SharedKernel.Contracts.Api; -using SharedKernel.Contracts.Security; namespace ProductCatalog.Features.Category; public sealed partial class CategoriesController { /// - /// Returns aggregated statistics for a category via - /// get_product_category_stats(p_category_id) (EF Core FromSql). + /// Returns aggregated statistics for a category via + /// get_product_category_stats(p_category_id) (EF Core FromSql). /// [HttpGet("{id:guid}/stats")] [RequirePermission(Permission.Categories.Read)] diff --git a/src/Modules/ProductCatalog/Features/Category/GetCategoryStats/GetCategoryStatsQuery.cs b/src/Modules/ProductCatalog/Features/Category/GetCategoryStats/GetCategoryStatsQuery.cs index bd269e46..bf17cc65 100644 --- a/src/Modules/ProductCatalog/Features/Category/GetCategoryStats/GetCategoryStatsQuery.cs +++ b/src/Modules/ProductCatalog/Features/Category/GetCategoryStats/GetCategoryStatsQuery.cs @@ -1,13 +1,12 @@ using ErrorOr; -using ProductCatalog.Interfaces; using ProductCategoryStatsEntity = ProductCatalog.Entities.ProductCategoryStats; namespace ProductCatalog.Features.Category.GetCategoryStats; -/// Returns aggregated statistics for a category by its identifier, or if not found. +/// Returns aggregated statistics for a category by its identifier, or if not found. public sealed record GetCategoryStatsQuery(Guid Id) : IHasId; -/// Handles . +/// Handles . public sealed class GetCategoryStatsQueryHandler { public static async Task> HandleAsync( diff --git a/src/Modules/ProductCatalog/Features/Category/Shared/CategoryResponse.cs b/src/Modules/ProductCatalog/Features/Category/Shared/CategoryResponse.cs index ce2b7fc5..1f0884a6 100644 --- a/src/Modules/ProductCatalog/Features/Category/Shared/CategoryResponse.cs +++ b/src/Modules/ProductCatalog/Features/Category/Shared/CategoryResponse.cs @@ -1,7 +1,7 @@ namespace ProductCatalog.Features.Category.Shared; /// -/// Read model returned by category queries, containing the public-facing representation of a category. +/// Read model returned by category queries, containing the public-facing representation of a category. /// public sealed record CategoryResponse( Guid Id, diff --git a/src/Modules/ProductCatalog/Features/Category/Shared/CategorySortFields.cs b/src/Modules/ProductCatalog/Features/Category/Shared/CategorySortFields.cs index 9904ad76..a4db068e 100644 --- a/src/Modules/ProductCatalog/Features/Category/Shared/CategorySortFields.cs +++ b/src/Modules/ProductCatalog/Features/Category/Shared/CategorySortFields.cs @@ -1,10 +1,9 @@ -using SharedKernel.Application.Sorting; using CategoryEntity = ProductCatalog.Entities.Category; namespace ProductCatalog.Features.Category.Shared; /// -/// Defines the allowed sort fields for category queries and maps them to entity expressions. +/// Defines the allowed sort fields for category queries and maps them to entity expressions. /// public static class CategorySortFields { @@ -15,8 +14,8 @@ public static class CategorySortFields public static readonly SortField CreatedAt = new("createdAt"); /// - /// The sort field map used to resolve and apply sorting to category specifications. - /// Defaults to sorting by when no sort field is specified. + /// The sort field map used to resolve and apply sorting to category specifications. + /// Defaults to sorting by when no sort field is specified. /// public static readonly SortFieldMap Map = new SortFieldMap() .Add(Name, c => c.Name) diff --git a/src/Modules/ProductCatalog/Features/Category/UpdateCategories/CategoriesController.UpdateCategories.cs b/src/Modules/ProductCatalog/Features/Category/UpdateCategories/CategoriesController.UpdateCategories.cs index 7d704ba6..d3dc7cbb 100644 --- a/src/Modules/ProductCatalog/Features/Category/UpdateCategories/CategoriesController.UpdateCategories.cs +++ b/src/Modules/ProductCatalog/Features/Category/UpdateCategories/CategoriesController.UpdateCategories.cs @@ -1,8 +1,6 @@ using ErrorOr; using Microsoft.AspNetCore.Mvc; using ProductCatalog.Features.Category.UpdateCategories; -using SharedKernel.Contracts.Api; -using SharedKernel.Contracts.Security; namespace ProductCatalog.Features.Category; diff --git a/src/Modules/ProductCatalog/Features/Category/UpdateCategories/UpdateCategoriesCommand.cs b/src/Modules/ProductCatalog/Features/Category/UpdateCategories/UpdateCategoriesCommand.cs index a1575546..2c11475b 100644 --- a/src/Modules/ProductCatalog/Features/Category/UpdateCategories/UpdateCategoriesCommand.cs +++ b/src/Modules/ProductCatalog/Features/Category/UpdateCategories/UpdateCategoriesCommand.cs @@ -1,8 +1,4 @@ using ErrorOr; -using ProductCatalog; -using SharedKernel.Application.Batch; -using SharedKernel.Application.Batch.Rules; -using SharedKernel.Contracts.Events; using Wolverine; namespace ProductCatalog.Features.Category.UpdateCategories; @@ -10,14 +6,12 @@ namespace ProductCatalog.Features.Category.UpdateCategories; /// 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. +/// +/// Handles by validating all items, loading categories in bulk, and +/// updating in a single transaction. +/// public sealed class UpdateCategoriesCommandHandler { - public sealed record UpdateCategoriesState( - IReadOnlyList Items, - IReadOnlyDictionary CategoryMap - ); - public static async Task<( HandlerContinuation, UpdateCategoriesState?, @@ -34,11 +28,11 @@ CancellationToken ct await context.ApplyRulesAsync(ct, itemValidationRule); // Load all target categories and mark missing ones as failed - var requestedIds = items + HashSet requestedIds = items .Where((_, i) => !context.IsFailed(i)) .Select(item => item.Id) .ToHashSet(); - var categoryMap = ( + Dictionary categoryMap = ( await repository.ListAsync(new CategoriesByIdsSpecification(requestedIds), ct) ).ToDictionary(c => c.Id); @@ -80,7 +74,7 @@ await unitOfWork.ExecuteInTransactionAsync( for (int i = 0; i < state.Items.Count; i++) { UpdateCategoryItem item = state.Items[i]; - ProductCatalog.Entities.Category category = state.CategoryMap[item.Id]; + Entities.Category category = state.CategoryMap[item.Id]; category.Name = item.Name; category.Description = item.Description; @@ -96,4 +90,9 @@ await unitOfWork.ExecuteInTransactionAsync( return (new BatchResponse([], command.Request.Items.Count, 0), messages); } + + public sealed record UpdateCategoriesState( + IReadOnlyList Items, + IReadOnlyDictionary CategoryMap + ); } diff --git a/src/Modules/ProductCatalog/Features/Category/UpdateCategories/UpdateCategoriesRequest.cs b/src/Modules/ProductCatalog/Features/Category/UpdateCategories/UpdateCategoriesRequest.cs index c2377843..9a0ad1c7 100644 --- a/src/Modules/ProductCatalog/Features/Category/UpdateCategories/UpdateCategoriesRequest.cs +++ b/src/Modules/ProductCatalog/Features/Category/UpdateCategories/UpdateCategoriesRequest.cs @@ -1,10 +1,9 @@ using System.ComponentModel.DataAnnotations; -using SharedKernel.Application.Validation; namespace ProductCatalog.Features.Category.UpdateCategories; /// -/// Carries a list of category items to be updated in a single batch operation; accepts between 1 and 100 items. +/// 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.")] @@ -13,7 +12,7 @@ IReadOnlyList Items ); /// -/// Represents a single category within a batch update request, including its ID and replacement data. +/// 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, diff --git a/src/Modules/ProductCatalog/Features/Category/UpdateCategories/UpdateCategoryItemValidator.cs b/src/Modules/ProductCatalog/Features/Category/UpdateCategories/UpdateCategoryItemValidator.cs index 891f1a90..19a6ea36 100644 --- a/src/Modules/ProductCatalog/Features/Category/UpdateCategories/UpdateCategoryItemValidator.cs +++ b/src/Modules/ProductCatalog/Features/Category/UpdateCategories/UpdateCategoryItemValidator.cs @@ -1,8 +1,6 @@ -using SharedKernel.Application.Validation; - namespace ProductCatalog.Features.Category.UpdateCategories; /// -/// FluentValidation validator for that enforces data-annotation constraints. +/// FluentValidation validator for that enforces data-annotation constraints. /// public sealed class UpdateCategoryItemValidator : DataAnnotationsValidator; diff --git a/src/Modules/ProductCatalog/Features/Category/UpdateCategories/UpdateCategoryRequest.cs b/src/Modules/ProductCatalog/Features/Category/UpdateCategories/UpdateCategoryRequest.cs index 3de5fb31..d83f5e60 100644 --- a/src/Modules/ProductCatalog/Features/Category/UpdateCategories/UpdateCategoryRequest.cs +++ b/src/Modules/ProductCatalog/Features/Category/UpdateCategories/UpdateCategoryRequest.cs @@ -1,6 +1,6 @@ namespace ProductCatalog.Features.Category.UpdateCategories; /// -/// Payload for updating an existing category's name and optional description. +/// Payload for updating an existing category's name and optional description. /// public sealed record UpdateCategoryRequest(string Name, string? Description); diff --git a/src/Modules/ProductCatalog/Features/Product/CreateProducts/CreateProductRequest.cs b/src/Modules/ProductCatalog/Features/Product/CreateProducts/CreateProductRequest.cs index 2272fa44..c5a6f61e 100644 --- a/src/Modules/ProductCatalog/Features/Product/CreateProducts/CreateProductRequest.cs +++ b/src/Modules/ProductCatalog/Features/Product/CreateProducts/CreateProductRequest.cs @@ -3,7 +3,7 @@ namespace ProductCatalog.Features.Product.CreateProducts; /// -/// Carries the data required to create a new product, including validation constraints enforced via data annotations. +/// 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.")] diff --git a/src/Modules/ProductCatalog/Features/Product/CreateProducts/CreateProductRequestValidator.cs b/src/Modules/ProductCatalog/Features/Product/CreateProducts/CreateProductRequestValidator.cs index ecd72d17..830940c2 100644 --- a/src/Modules/ProductCatalog/Features/Product/CreateProducts/CreateProductRequestValidator.cs +++ b/src/Modules/ProductCatalog/Features/Product/CreateProducts/CreateProductRequestValidator.cs @@ -1,7 +1,8 @@ namespace ProductCatalog.Features.Product.CreateProducts; /// -/// FluentValidation validator for , inheriting all rules from . +/// FluentValidation validator for , inheriting all rules from +/// . /// public sealed class CreateProductRequestValidator : ProductRequestValidatorBase; diff --git a/src/Modules/ProductCatalog/Features/Product/CreateProducts/CreateProductsCommand.cs b/src/Modules/ProductCatalog/Features/Product/CreateProducts/CreateProductsCommand.cs index 8a91a72a..f0949411 100644 --- a/src/Modules/ProductCatalog/Features/Product/CreateProducts/CreateProductsCommand.cs +++ b/src/Modules/ProductCatalog/Features/Product/CreateProducts/CreateProductsCommand.cs @@ -1,9 +1,5 @@ using ErrorOr; -using ProductCatalog; -using ProductCatalog.Entities; using ProductCatalog.ValueObjects; -using SharedKernel.Application.Batch; -using SharedKernel.Contracts.Events; using Wolverine; using ProductEntity = ProductCatalog.Entities.Product; using ProductRepositoryContract = ProductCatalog.Interfaces.IProductRepository; @@ -13,7 +9,10 @@ namespace ProductCatalog.Features.Product.CreateProducts; /// 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. +/// +/// Handles by validating all items, bulk-validating references, and +/// persisting in a single transaction. +/// public sealed class CreateProductsCommandHandler { public static async Task<( diff --git a/src/Modules/ProductCatalog/Features/Product/CreateProducts/CreateProductsRequest.cs b/src/Modules/ProductCatalog/Features/Product/CreateProducts/CreateProductsRequest.cs index 2f0d2abc..42714dd0 100644 --- a/src/Modules/ProductCatalog/Features/Product/CreateProducts/CreateProductsRequest.cs +++ b/src/Modules/ProductCatalog/Features/Product/CreateProducts/CreateProductsRequest.cs @@ -3,7 +3,7 @@ namespace ProductCatalog.Features.Product.CreateProducts; /// -/// Carries a list of product items to be created in a single batch operation; accepts between 1 and 100 items. +/// 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.")] diff --git a/src/Modules/ProductCatalog/Features/Product/CreateProducts/ProductsController.CreateProducts.cs b/src/Modules/ProductCatalog/Features/Product/CreateProducts/ProductsController.CreateProducts.cs index 3101dd86..857e85d9 100644 --- a/src/Modules/ProductCatalog/Features/Product/CreateProducts/ProductsController.CreateProducts.cs +++ b/src/Modules/ProductCatalog/Features/Product/CreateProducts/ProductsController.CreateProducts.cs @@ -1,8 +1,6 @@ using ErrorOr; using Microsoft.AspNetCore.Mvc; using ProductCatalog.Features.Product.CreateProducts; -using SharedKernel.Contracts.Api; -using SharedKernel.Contracts.Security; namespace ProductCatalog.Features.Product; diff --git a/src/Modules/ProductCatalog/Features/Product/DeleteProducts/ProductsController.DeleteProducts.cs b/src/Modules/ProductCatalog/Features/Product/DeleteProducts/ProductsController.DeleteProducts.cs index af922a55..2917f8f3 100644 --- a/src/Modules/ProductCatalog/Features/Product/DeleteProducts/ProductsController.DeleteProducts.cs +++ b/src/Modules/ProductCatalog/Features/Product/DeleteProducts/ProductsController.DeleteProducts.cs @@ -1,8 +1,6 @@ using ErrorOr; using Microsoft.AspNetCore.Mvc; using ProductCatalog.Features.Product.DeleteProducts; -using SharedKernel.Contracts.Api; -using SharedKernel.Contracts.Security; namespace ProductCatalog.Features.Product; diff --git a/src/Modules/ProductCatalog/Features/Product/GetProductById/GetProductByIdQuery.cs b/src/Modules/ProductCatalog/Features/Product/GetProductById/GetProductByIdQuery.cs index 58ce283f..1d498f00 100644 --- a/src/Modules/ProductCatalog/Features/Product/GetProductById/GetProductByIdQuery.cs +++ b/src/Modules/ProductCatalog/Features/Product/GetProductById/GetProductByIdQuery.cs @@ -6,7 +6,7 @@ namespace ProductCatalog.Features.Product.GetProductById; /// Retrieves a single product by its unique identifier. public sealed record GetProductByIdQuery(Guid Id) : IHasId; -/// Handles by fetching from the product repository. +/// Handles by fetching from the product repository. public sealed class GetProductByIdQueryHandler { public static async Task> HandleAsync( diff --git a/src/Modules/ProductCatalog/Features/Product/GetProductById/ProductByIdSpecification.cs b/src/Modules/ProductCatalog/Features/Product/GetProductById/ProductByIdSpecification.cs index 0f4b4e27..5a439a57 100644 --- a/src/Modules/ProductCatalog/Features/Product/GetProductById/ProductByIdSpecification.cs +++ b/src/Modules/ProductCatalog/Features/Product/GetProductById/ProductByIdSpecification.cs @@ -4,7 +4,8 @@ namespace ProductCatalog.Features.Product.GetProductById; /// -/// Ardalis specification that fetches a single product by its ID and projects it directly to a DTO. +/// Ardalis specification that fetches a single product by its ID and projects it directly to a +/// DTO. /// public sealed class ProductByIdSpecification : Specification { diff --git a/src/Modules/ProductCatalog/Features/Product/GetProductById/ProductByIdWithLinksSpecification.cs b/src/Modules/ProductCatalog/Features/Product/GetProductById/ProductByIdWithLinksSpecification.cs index 37294066..2310531b 100644 --- a/src/Modules/ProductCatalog/Features/Product/GetProductById/ProductByIdWithLinksSpecification.cs +++ b/src/Modules/ProductCatalog/Features/Product/GetProductById/ProductByIdWithLinksSpecification.cs @@ -4,7 +4,8 @@ namespace ProductCatalog.Features.Product.GetProductById; /// -/// Ardalis specification that loads a product by ID and eagerly includes its ProductDataLinks collection, used when link synchronisation or deletion is required. +/// 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 { diff --git a/src/Modules/ProductCatalog/Features/Product/GetProductById/ProductsController.GetProductById.cs b/src/Modules/ProductCatalog/Features/Product/GetProductById/ProductsController.GetProductById.cs index 6ff10e46..592e7604 100644 --- a/src/Modules/ProductCatalog/Features/Product/GetProductById/ProductsController.GetProductById.cs +++ b/src/Modules/ProductCatalog/Features/Product/GetProductById/ProductsController.GetProductById.cs @@ -2,8 +2,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.OutputCaching; using ProductCatalog.Features.Product.GetProductById; -using SharedKernel.Contracts.Api; -using SharedKernel.Contracts.Security; namespace ProductCatalog.Features.Product; diff --git a/src/Modules/ProductCatalog/Features/Product/GetProducts/GetProductsQuery.cs b/src/Modules/ProductCatalog/Features/Product/GetProducts/GetProductsQuery.cs index aa43055d..85f3d88a 100644 --- a/src/Modules/ProductCatalog/Features/Product/GetProducts/GetProductsQuery.cs +++ b/src/Modules/ProductCatalog/Features/Product/GetProducts/GetProductsQuery.cs @@ -6,7 +6,7 @@ namespace ProductCatalog.Features.Product.GetProducts; /// 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. +/// Handles by fetching items, count, and facets from the repository. public sealed class GetProductsQueryHandler { public static async Task> HandleAsync( diff --git a/src/Modules/ProductCatalog/Features/Product/GetProducts/ProductFilterCriteria.cs b/src/Modules/ProductCatalog/Features/Product/GetProducts/ProductFilterCriteria.cs index e9e7944b..1fc577d4 100644 --- a/src/Modules/ProductCatalog/Features/Product/GetProducts/ProductFilterCriteria.cs +++ b/src/Modules/ProductCatalog/Features/Product/GetProducts/ProductFilterCriteria.cs @@ -1,4 +1,3 @@ -using SharedKernel.Application.Search; using Ardalis.Specification; using Microsoft.EntityFrameworkCore; using ProductEntity = ProductCatalog.Entities.Product; @@ -6,12 +5,14 @@ namespace ProductCatalog.Features.Product.GetProducts; /// -/// Internal helper that extends with product-specific filter predicates, centralising all WHERE-clause logic for reuse across multiple 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. + /// 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, @@ -56,14 +57,16 @@ internal static void ApplyFilter( 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. +/// Controls which filter predicates are suppressed when building specifications for facet queries. /// internal sealed record ProductFilterCriteriaOptions( bool IgnoreCategoryIds = false, diff --git a/src/Modules/ProductCatalog/Features/Product/GetProducts/ProductFilterValidator.cs b/src/Modules/ProductCatalog/Features/Product/GetProducts/ProductFilterValidator.cs index 590b1ff4..e4e199f2 100644 --- a/src/Modules/ProductCatalog/Features/Product/GetProducts/ProductFilterValidator.cs +++ b/src/Modules/ProductCatalog/Features/Product/GetProducts/ProductFilterValidator.cs @@ -1,10 +1,10 @@ -using SharedKernel.Application.Validation; using FluentValidation; namespace ProductCatalog.Features.Product.GetProducts; /// -/// FluentValidation validator for ; composes pagination, date-range, sortable-field, and price-range rules including cross-field MinPrice/MaxPrice consistency. +/// FluentValidation validator for ; composes pagination, date-range, sortable-field, and +/// price-range rules including cross-field MinPrice/MaxPrice consistency. /// public sealed class ProductFilterValidator : AbstractValidator { diff --git a/src/Modules/ProductCatalog/Features/Product/GetProducts/ProductSpecification.cs b/src/Modules/ProductCatalog/Features/Product/GetProducts/ProductSpecification.cs index d1ee5f70..a87eb51f 100644 --- a/src/Modules/ProductCatalog/Features/Product/GetProducts/ProductSpecification.cs +++ b/src/Modules/ProductCatalog/Features/Product/GetProducts/ProductSpecification.cs @@ -4,7 +4,8 @@ namespace ProductCatalog.Features.Product.GetProducts; /// -/// Ardalis specification that applies the full product filter, sorting, and projection to produce a list. +/// Ardalis specification that applies the full product filter, sorting, and projection to produce a +/// list. /// public sealed class ProductSpecification : Specification { diff --git a/src/Modules/ProductCatalog/Features/Product/GetProducts/ProductsController.GetProducts.cs b/src/Modules/ProductCatalog/Features/Product/GetProducts/ProductsController.GetProducts.cs index 8692678a..59cfc4a1 100644 --- a/src/Modules/ProductCatalog/Features/Product/GetProducts/ProductsController.GetProducts.cs +++ b/src/Modules/ProductCatalog/Features/Product/GetProducts/ProductsController.GetProducts.cs @@ -2,8 +2,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.OutputCaching; using ProductCatalog.Features.Product.GetProducts; -using SharedKernel.Contracts.Api; -using SharedKernel.Contracts.Security; namespace ProductCatalog.Features.Product; diff --git a/src/Modules/ProductCatalog/Features/Product/IdempotentCreate/IdempotentController.Create.cs b/src/Modules/ProductCatalog/Features/Product/IdempotentCreate/IdempotentController.Create.cs index 24ef3706..c4a60373 100644 --- a/src/Modules/ProductCatalog/Features/Product/IdempotentCreate/IdempotentController.Create.cs +++ b/src/Modules/ProductCatalog/Features/Product/IdempotentCreate/IdempotentController.Create.cs @@ -1,18 +1,14 @@ using ErrorOr; using Microsoft.AspNetCore.Mvc; -using ProductCatalog.Features.Shared.Routing; -using SharedKernel.Contracts.Api; using SharedKernel.Contracts.Api.Filters.Idempotency; -using SharedKernel.Contracts.Security; -using Wolverine; namespace ProductCatalog.Features.Product.IdempotentCreate; public sealed partial class IdempotentController { /// - /// Demonstrates idempotent POST semantics using the - /// filter for duplicate requests. + /// Demonstrates idempotent POST semantics using the + /// filter for duplicate requests. /// [HttpPost] [Idempotent] diff --git a/src/Modules/ProductCatalog/Features/Product/IdempotentCreate/IdempotentCreateCommand.cs b/src/Modules/ProductCatalog/Features/Product/IdempotentCreate/IdempotentCreateCommand.cs index 2f52f1f5..2b5bcf3c 100644 --- a/src/Modules/ProductCatalog/Features/Product/IdempotentCreate/IdempotentCreateCommand.cs +++ b/src/Modules/ProductCatalog/Features/Product/IdempotentCreate/IdempotentCreateCommand.cs @@ -1,5 +1,4 @@ using ErrorOr; -using ProductCatalog; using ProductCatalog.ValueObjects; using IProductRepository = ProductCatalog.Interfaces.IProductRepository; using ProductEntity = ProductCatalog.Entities.Product; diff --git a/src/Modules/ProductCatalog/Features/Product/IdempotentCreate/IdempotentCreateRequest.cs b/src/Modules/ProductCatalog/Features/Product/IdempotentCreate/IdempotentCreateRequest.cs index b7f0393e..9b74ae1d 100644 --- a/src/Modules/ProductCatalog/Features/Product/IdempotentCreate/IdempotentCreateRequest.cs +++ b/src/Modules/ProductCatalog/Features/Product/IdempotentCreate/IdempotentCreateRequest.cs @@ -1,10 +1,9 @@ using System.ComponentModel.DataAnnotations; -using SharedKernel.Application.Validation; namespace ProductCatalog.Features.Product.IdempotentCreate; /// -/// Carries the data for an idempotent resource creation request; demonstrates safe-retry semantics at the API layer. +/// Carries the data for an idempotent resource creation request; demonstrates safe-retry semantics at the API layer. /// public sealed record IdempotentCreateRequest( [NotEmpty] [MaxLength(200)] string Name, diff --git a/src/Modules/ProductCatalog/Features/Product/IdempotentCreate/IdempotentCreateRequestValidator.cs b/src/Modules/ProductCatalog/Features/Product/IdempotentCreate/IdempotentCreateRequestValidator.cs index 231fc227..1b584ac6 100644 --- a/src/Modules/ProductCatalog/Features/Product/IdempotentCreate/IdempotentCreateRequestValidator.cs +++ b/src/Modules/ProductCatalog/Features/Product/IdempotentCreate/IdempotentCreateRequestValidator.cs @@ -1,10 +1,8 @@ -using SharedKernel.Application.Validation; - namespace ProductCatalog.Features.Product.IdempotentCreate; /// -/// FluentValidation validator for that enforces -/// data-annotation constraints such as non-empty name and maximum length. +/// FluentValidation validator for that enforces +/// data-annotation constraints such as non-empty name and maximum length. /// public sealed class IdempotentCreateRequestValidator : DataAnnotationsValidator; diff --git a/src/Modules/ProductCatalog/Features/Product/PatchProduct/PatchController.cs b/src/Modules/ProductCatalog/Features/Product/PatchProduct/PatchController.cs index b07059c6..f840ac24 100644 --- a/src/Modules/ProductCatalog/Features/Product/PatchProduct/PatchController.cs +++ b/src/Modules/ProductCatalog/Features/Product/PatchProduct/PatchController.cs @@ -1,6 +1,4 @@ using Asp.Versioning; -using Microsoft.AspNetCore.Mvc; -using SharedKernel.Contracts.Api; using Wolverine; namespace ProductCatalog.Features.Product.PatchProduct; diff --git a/src/Modules/ProductCatalog/Features/Product/PatchProduct/PatchableProductDto.cs b/src/Modules/ProductCatalog/Features/Product/PatchProduct/PatchableProductDto.cs index ba45bee0..266fd138 100644 --- a/src/Modules/ProductCatalog/Features/Product/PatchProduct/PatchableProductDto.cs +++ b/src/Modules/ProductCatalog/Features/Product/PatchProduct/PatchableProductDto.cs @@ -3,8 +3,8 @@ namespace ProductCatalog.Features.Product.PatchProduct; /// -/// Mutable DTO used as the patch target for JSON Patch operations on a product; declared as a class -/// rather than a record because JSON Patch mutates the object in-place. +/// Mutable DTO used as the patch target for JSON Patch operations on a product; declared as a class +/// rather than a record because JSON Patch mutates the object in-place. /// public sealed class PatchableProductDto { diff --git a/src/Modules/ProductCatalog/Features/Product/PatchProduct/PatchableProductDtoValidator.cs b/src/Modules/ProductCatalog/Features/Product/PatchProduct/PatchableProductDtoValidator.cs index 72af01ad..93d3f2e3 100644 --- a/src/Modules/ProductCatalog/Features/Product/PatchProduct/PatchableProductDtoValidator.cs +++ b/src/Modules/ProductCatalog/Features/Product/PatchProduct/PatchableProductDtoValidator.cs @@ -1,11 +1,8 @@ -using FluentValidation; -using SharedKernel.Application.Validation; - namespace ProductCatalog.Features.Product.PatchProduct; /// -/// FluentValidation validator for the post-patch state; -/// applies data-annotation constraints and the shared description-required-above-price-threshold rule. +/// FluentValidation validator for the post-patch state; +/// applies data-annotation constraints and the shared description-required-above-price-threshold rule. /// public sealed class PatchableProductDtoValidator : DataAnnotationsValidator { diff --git a/src/Modules/ProductCatalog/Features/Product/ProductsController.cs b/src/Modules/ProductCatalog/Features/Product/ProductsController.cs index b2d35c43..9f89a2c1 100644 --- a/src/Modules/ProductCatalog/Features/Product/ProductsController.cs +++ b/src/Modules/ProductCatalog/Features/Product/ProductsController.cs @@ -1,6 +1,4 @@ using Asp.Versioning; -using Microsoft.AspNetCore.Mvc; -using SharedKernel.Contracts.Api; using Wolverine; namespace ProductCatalog.Features.Product; diff --git a/src/Modules/ProductCatalog/Features/Product/Shared/IProductRequest.cs b/src/Modules/ProductCatalog/Features/Product/Shared/IProductRequest.cs index 74270e62..04a97658 100644 --- a/src/Modules/ProductCatalog/Features/Product/Shared/IProductRequest.cs +++ b/src/Modules/ProductCatalog/Features/Product/Shared/IProductRequest.cs @@ -1,14 +1,14 @@ namespace ProductCatalog.Features.Product.Shared; /// -/// Shared contract for create and update product command requests, enabling reuse of -/// FluentValidation rules across both operations without duplicating property declarations. +/// 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; } + public string Name { get; } + public string? Description { get; } + public decimal Price { get; } + public Guid? CategoryId { get; } + public IReadOnlyCollection? ProductDataIds { get; } } diff --git a/src/Modules/ProductCatalog/Features/Product/Shared/ProductCategoryFacetSpecification.cs b/src/Modules/ProductCatalog/Features/Product/Shared/ProductCategoryFacetSpecification.cs index b0707bda..9f5ebdad 100644 --- a/src/Modules/ProductCatalog/Features/Product/Shared/ProductCategoryFacetSpecification.cs +++ b/src/Modules/ProductCatalog/Features/Product/Shared/ProductCategoryFacetSpecification.cs @@ -5,13 +5,14 @@ namespace ProductCatalog.Features.Product.Shared; /// -/// Ardalis specification used for the category facet query; applies all filter criteria except category-ID filtering so that counts reflect the full category distribution. +/// 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.ApplyFilter(filter, new ProductFilterCriteriaOptions(true)); Query.AsNoTracking(); } diff --git a/src/Modules/ProductCatalog/Features/Product/Shared/ProductCategoryFacetValue.cs b/src/Modules/ProductCatalog/Features/Product/Shared/ProductCategoryFacetValue.cs index c6bd2264..c95150d8 100644 --- a/src/Modules/ProductCatalog/Features/Product/Shared/ProductCategoryFacetValue.cs +++ b/src/Modules/ProductCatalog/Features/Product/Shared/ProductCategoryFacetValue.cs @@ -1,6 +1,7 @@ namespace ProductCatalog.Features.Product.Shared; /// -/// Represents a single category bucket in the product search facets, containing the category identity and the number of matching products. +/// 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/Modules/ProductCatalog/Features/Product/Shared/ProductMappings.cs b/src/Modules/ProductCatalog/Features/Product/Shared/ProductMappings.cs index d6b35079..322aa3f1 100644 --- a/src/Modules/ProductCatalog/Features/Product/Shared/ProductMappings.cs +++ b/src/Modules/ProductCatalog/Features/Product/Shared/ProductMappings.cs @@ -4,12 +4,14 @@ namespace ProductCatalog.Features.Product.Shared; /// -/// Provides EF Core-compatible projection expressions and in-memory mapping helpers for converting Product domain entities to DTOs. +/// 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. + /// 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( @@ -25,7 +27,12 @@ public static class ProductMappings 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); + /// + /// Maps a fully-loaded Product entity to a using the pre-compiled + /// projection. + /// + public static ProductResponse ToResponse(this ProductEntity product) + { + return CompiledProjection(product); + } } diff --git a/src/Modules/ProductCatalog/Features/Product/Shared/ProductPriceFacetBucketResponse.cs b/src/Modules/ProductCatalog/Features/Product/Shared/ProductPriceFacetBucketResponse.cs index d3c41ed8..291374be 100644 --- a/src/Modules/ProductCatalog/Features/Product/Shared/ProductPriceFacetBucketResponse.cs +++ b/src/Modules/ProductCatalog/Features/Product/Shared/ProductPriceFacetBucketResponse.cs @@ -1,7 +1,8 @@ namespace ProductCatalog.Features.Product.Shared; /// -/// Represents a single price-range bucket in the product search facets, with a human-readable label and the count of matching products. +/// 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, diff --git a/src/Modules/ProductCatalog/Features/Product/Shared/ProductPriceFacetSpecification.cs b/src/Modules/ProductCatalog/Features/Product/Shared/ProductPriceFacetSpecification.cs index 95e8c5f1..79bdb5ea 100644 --- a/src/Modules/ProductCatalog/Features/Product/Shared/ProductPriceFacetSpecification.cs +++ b/src/Modules/ProductCatalog/Features/Product/Shared/ProductPriceFacetSpecification.cs @@ -5,7 +5,8 @@ namespace ProductCatalog.Features.Product.Shared; /// -/// 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. +/// 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 { diff --git a/src/Modules/ProductCatalog/Features/Product/Shared/ProductRequestValidatorBase.cs b/src/Modules/ProductCatalog/Features/Product/Shared/ProductRequestValidatorBase.cs index 8598f750..d99d5849 100644 --- a/src/Modules/ProductCatalog/Features/Product/Shared/ProductRequestValidatorBase.cs +++ b/src/Modules/ProductCatalog/Features/Product/Shared/ProductRequestValidatorBase.cs @@ -1,32 +1,36 @@ -using SharedKernel.Application.Validation; using FluentValidation; namespace ProductCatalog.Features.Product.Shared; /// -/// Shared FluentValidation extension methods and constants for product-related validation rules. +/// 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 . + /// 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 + ) + { + return 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. +/// 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 diff --git a/src/Modules/ProductCatalog/Features/Product/Shared/ProductSearchFacetsResponse.cs b/src/Modules/ProductCatalog/Features/Product/Shared/ProductSearchFacetsResponse.cs index c8ff31a4..9abab784 100644 --- a/src/Modules/ProductCatalog/Features/Product/Shared/ProductSearchFacetsResponse.cs +++ b/src/Modules/ProductCatalog/Features/Product/Shared/ProductSearchFacetsResponse.cs @@ -1,7 +1,7 @@ namespace ProductCatalog.Features.Product.Shared; /// -/// Aggregates all facet data returned alongside a product search result, enabling client-side filter refinement. +/// Aggregates all facet data returned alongside a product search result, enabling client-side filter refinement. /// public sealed record ProductSearchFacetsResponse( IReadOnlyCollection Categories, diff --git a/src/Modules/ProductCatalog/Features/Product/Shared/ProductSortFields.cs b/src/Modules/ProductCatalog/Features/Product/Shared/ProductSortFields.cs index 66c0987e..fba5f45a 100644 --- a/src/Modules/ProductCatalog/Features/Product/Shared/ProductSortFields.cs +++ b/src/Modules/ProductCatalog/Features/Product/Shared/ProductSortFields.cs @@ -1,10 +1,10 @@ -using SharedKernel.Application.Sorting; using ProductEntity = ProductCatalog.Entities.Product; namespace ProductCatalog.Features.Product.Shared; /// -/// Defines the allowed sort fields for product queries and provides the used by specifications to apply ordering. +/// Defines the allowed sort fields for product queries and provides the used by +/// specifications to apply ordering. /// public static class ProductSortFields { @@ -14,7 +14,7 @@ public static class ProductSortFields public static readonly SortFieldMap Map = new SortFieldMap() .Add(Name, p => p.Name) - .Add(Price, p => (object)p.Price) + .Add(Price, p => p.Price) .Add(CreatedAt, p => p.Audit.CreatedAtUtc) .Default(p => p.Audit.CreatedAtUtc); } diff --git a/src/Modules/ProductCatalog/Features/Product/Shared/ProductValidationHelper.cs b/src/Modules/ProductCatalog/Features/Product/Shared/ProductValidationHelper.cs index 6303306d..34ed8564 100644 --- a/src/Modules/ProductCatalog/Features/Product/Shared/ProductValidationHelper.cs +++ b/src/Modules/ProductCatalog/Features/Product/Shared/ProductValidationHelper.cs @@ -1,13 +1,11 @@ -using SharedKernel.Application.Batch; - namespace ProductCatalog.Features.Product.Shared; /// 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. + /// 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, @@ -39,8 +37,8 @@ CancellationToken ct } /// - /// Checks that all referenced category IDs exist and returns per-item failures for items - /// that reference a missing category. Items in are skipped. + /// 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, @@ -50,7 +48,7 @@ internal static async Task> CheckCategoryReferencesAsync allCategoryIds = items .Where(item => categoryIdSelector(item).HasValue) .Select(item => categoryIdSelector(item)!.Value) .ToHashSet(); @@ -67,9 +65,9 @@ CancellationToken ct if (allCategoryIds.Count == 0) return []; - var failures = new List(); + List failures = new(); - for (var i = 0; i < items.Count; i++) + for (int i = 0; i < items.Count; i++) { if (failedIndices.Contains(i)) continue; @@ -92,8 +90,8 @@ CancellationToken ct } /// - /// Checks that all referenced product-data IDs exist and returns per-item failures for items - /// that reference missing product data. Items in are skipped. + /// 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, @@ -112,18 +110,22 @@ CancellationToken ct if (allProductDataIds.Length == 0) return []; - var existingIds = (await productDataRepository.GetByIdsAsync(allProductDataIds, ct)) + HashSet existingIds = ( + await productDataRepository.GetByIdsAsync(allProductDataIds, ct) + ) .Select(pd => pd.Id) .ToHashSet(); - var missingIds = allProductDataIds.Where(id => !existingIds.Contains(id)).ToHashSet(); + HashSet missingIds = allProductDataIds + .Where(id => !existingIds.Contains(id)) + .ToHashSet(); if (missingIds.Count == 0) return []; - var failures = new List(); + List failures = new(); - for (var i = 0; i < items.Count; i++) + for (int i = 0; i < items.Count; i++) { if (failedIndices.Contains(i)) continue; diff --git a/src/Modules/ProductCatalog/Features/Product/Shared/ProductsResponse.cs b/src/Modules/ProductCatalog/Features/Product/Shared/ProductsResponse.cs index 4a166afe..3e072c71 100644 --- a/src/Modules/ProductCatalog/Features/Product/Shared/ProductsResponse.cs +++ b/src/Modules/ProductCatalog/Features/Product/Shared/ProductsResponse.cs @@ -1,7 +1,7 @@ namespace ProductCatalog.Features.Product.Shared; /// -/// Combines a paged list of products with their associated search facets in a single response envelope. +/// Combines a paged list of products with their associated search facets in a single response envelope. /// public sealed record ProductsResponse( PagedResponse Page, diff --git a/src/Modules/ProductCatalog/Features/Product/UpdateProducts/UpdateProductItemValidator.cs b/src/Modules/ProductCatalog/Features/Product/UpdateProducts/UpdateProductItemValidator.cs index 0514d572..a37e0802 100644 --- a/src/Modules/ProductCatalog/Features/Product/UpdateProducts/UpdateProductItemValidator.cs +++ b/src/Modules/ProductCatalog/Features/Product/UpdateProducts/UpdateProductItemValidator.cs @@ -1,7 +1,7 @@ namespace ProductCatalog.Features.Product.UpdateProducts; /// -/// FluentValidation validator for , reusing the shared -/// product validation rules including the description-required-above-price-threshold rule. +/// 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/Modules/ProductCatalog/Features/Product/UpdateProducts/UpdateProductRequest.cs b/src/Modules/ProductCatalog/Features/Product/UpdateProducts/UpdateProductRequest.cs index 321e4358..97d0110a 100644 --- a/src/Modules/ProductCatalog/Features/Product/UpdateProducts/UpdateProductRequest.cs +++ b/src/Modules/ProductCatalog/Features/Product/UpdateProducts/UpdateProductRequest.cs @@ -3,7 +3,8 @@ namespace ProductCatalog.Features.Product.UpdateProducts; /// -/// Carries the replacement data for an existing product, subject to the same validation constraints as . +/// 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.")] diff --git a/src/Modules/ProductCatalog/Features/Product/UpdateProducts/UpdateProductRequestValidator.cs b/src/Modules/ProductCatalog/Features/Product/UpdateProducts/UpdateProductRequestValidator.cs index b8aeb59a..3e6d1286 100644 --- a/src/Modules/ProductCatalog/Features/Product/UpdateProducts/UpdateProductRequestValidator.cs +++ b/src/Modules/ProductCatalog/Features/Product/UpdateProducts/UpdateProductRequestValidator.cs @@ -1,7 +1,8 @@ namespace ProductCatalog.Features.Product.UpdateProducts; /// -/// FluentValidation validator for , inheriting all rules from . +/// FluentValidation validator for , inheriting all rules from +/// . /// public sealed class UpdateProductRequestValidator : ProductRequestValidatorBase; diff --git a/src/Modules/ProductCatalog/Features/Product/UpdateProducts/UpdateProductsCommand.cs b/src/Modules/ProductCatalog/Features/Product/UpdateProducts/UpdateProductsCommand.cs index 7c29646e..8b526e17 100644 --- a/src/Modules/ProductCatalog/Features/Product/UpdateProducts/UpdateProductsCommand.cs +++ b/src/Modules/ProductCatalog/Features/Product/UpdateProducts/UpdateProductsCommand.cs @@ -1,10 +1,5 @@ using ErrorOr; -using ProductCatalog; -using ProductCatalog.Entities; using ProductCatalog.ValueObjects; -using SharedKernel.Application.Batch; -using SharedKernel.Application.Batch.Rules; -using SharedKernel.Contracts.Events; using Wolverine; using ProductEntity = ProductCatalog.Entities.Product; using ProductRepositoryContract = ProductCatalog.Interfaces.IProductRepository; @@ -14,12 +9,15 @@ namespace ProductCatalog.Features.Product.UpdateProducts; /// 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. +/// +/// 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. + /// 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, diff --git a/src/Modules/ProductCatalog/Features/Product/UpdateProducts/UpdateProductsRequest.cs b/src/Modules/ProductCatalog/Features/Product/UpdateProducts/UpdateProductsRequest.cs index 821ac475..5decbf05 100644 --- a/src/Modules/ProductCatalog/Features/Product/UpdateProducts/UpdateProductsRequest.cs +++ b/src/Modules/ProductCatalog/Features/Product/UpdateProducts/UpdateProductsRequest.cs @@ -3,7 +3,7 @@ namespace ProductCatalog.Features.Product.UpdateProducts; /// -/// Carries a list of product items to be updated in a single batch operation; accepts between 1 and 100 items. +/// 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.")] @@ -12,7 +12,7 @@ IReadOnlyList Items ); /// -/// Represents a single product within a batch update request, including its ID and replacement data. +/// 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, diff --git a/src/Modules/ProductCatalog/Features/ProductData/CreateImageProductData/CreateImageProductDataCommand.cs b/src/Modules/ProductCatalog/Features/ProductData/CreateImageProductData/CreateImageProductDataCommand.cs index ce977d88..8c1ec93b 100644 --- a/src/Modules/ProductCatalog/Features/ProductData/CreateImageProductData/CreateImageProductDataCommand.cs +++ b/src/Modules/ProductCatalog/Features/ProductData/CreateImageProductData/CreateImageProductDataCommand.cs @@ -1,8 +1,4 @@ using ErrorOr; -using ProductCatalog.Entities; -using ProductCatalog.Interfaces; -using SharedKernel.Application.Context; -using SharedKernel.Contracts.Events; using Wolverine; namespace ProductCatalog.Features.ProductData.CreateImageProductData; @@ -19,7 +15,7 @@ public sealed class CreateImageProductDataCommandHandler CancellationToken ct ) { - var entity = new ImageProductData + ImageProductData entity = new() { TenantId = tenantProvider.TenantId, Title = command.Request.Title, diff --git a/src/Modules/ProductCatalog/Features/ProductData/CreateImageProductData/CreateImageProductDataRequest.cs b/src/Modules/ProductCatalog/Features/ProductData/CreateImageProductData/CreateImageProductDataRequest.cs index 0da81366..052eb5ec 100644 --- a/src/Modules/ProductCatalog/Features/ProductData/CreateImageProductData/CreateImageProductDataRequest.cs +++ b/src/Modules/ProductCatalog/Features/ProductData/CreateImageProductData/CreateImageProductDataRequest.cs @@ -1,11 +1,11 @@ using System.ComponentModel.DataAnnotations; -using SharedKernel.Application.Validation; namespace ProductCatalog.Features.ProductData.CreateImageProductData; /// -/// Payload for uploading image product data, including dimensions, format, and file size. -/// Validation constraints are expressed via data annotations and enforced by CreateImageProductDataRequestValidator. +/// Payload for uploading image product data, including dimensions, format, and file size. +/// Validation constraints are expressed via data annotations and enforced by +/// CreateImageProductDataRequestValidator. /// public sealed record CreateImageProductDataRequest( [NotEmpty(ErrorMessage = "Title is required.")] diff --git a/src/Modules/ProductCatalog/Features/ProductData/CreateImageProductData/ProductDataController.CreateImage.cs b/src/Modules/ProductCatalog/Features/ProductData/CreateImageProductData/ProductDataController.CreateImage.cs index 640ee08f..9387ad44 100644 --- a/src/Modules/ProductCatalog/Features/ProductData/CreateImageProductData/ProductDataController.CreateImage.cs +++ b/src/Modules/ProductCatalog/Features/ProductData/CreateImageProductData/ProductDataController.CreateImage.cs @@ -1,8 +1,6 @@ using ErrorOr; using Microsoft.AspNetCore.Mvc; using ProductCatalog.Features.ProductData.CreateImageProductData; -using SharedKernel.Contracts.Api; -using SharedKernel.Contracts.Security; namespace ProductCatalog.Features.ProductData; diff --git a/src/Modules/ProductCatalog/Features/ProductData/CreateVideoProductData/CreateVideoProductDataRequest.cs b/src/Modules/ProductCatalog/Features/ProductData/CreateVideoProductData/CreateVideoProductDataRequest.cs index a1c40a98..ec2c8812 100644 --- a/src/Modules/ProductCatalog/Features/ProductData/CreateVideoProductData/CreateVideoProductDataRequest.cs +++ b/src/Modules/ProductCatalog/Features/ProductData/CreateVideoProductData/CreateVideoProductDataRequest.cs @@ -1,11 +1,11 @@ using System.ComponentModel.DataAnnotations; -using SharedKernel.Application.Validation; namespace ProductCatalog.Features.ProductData.CreateVideoProductData; /// -/// Payload for uploading video product data, including duration, resolution, format, and file size. -/// Validation constraints are expressed via data annotations and enforced by CreateVideoProductDataRequestValidator. +/// Payload for uploading video product data, including duration, resolution, format, and file size. +/// Validation constraints are expressed via data annotations and enforced by +/// CreateVideoProductDataRequestValidator. /// public sealed record CreateVideoProductDataRequest( [NotEmpty(ErrorMessage = "Title is required.")] diff --git a/src/Modules/ProductCatalog/Features/ProductData/CreateVideoProductData/ProductDataController.CreateVideo.cs b/src/Modules/ProductCatalog/Features/ProductData/CreateVideoProductData/ProductDataController.CreateVideo.cs index 9e5230dd..8c3e8db5 100644 --- a/src/Modules/ProductCatalog/Features/ProductData/CreateVideoProductData/ProductDataController.CreateVideo.cs +++ b/src/Modules/ProductCatalog/Features/ProductData/CreateVideoProductData/ProductDataController.CreateVideo.cs @@ -1,8 +1,6 @@ using ErrorOr; using Microsoft.AspNetCore.Mvc; using ProductCatalog.Features.ProductData.CreateVideoProductData; -using SharedKernel.Contracts.Api; -using SharedKernel.Contracts.Security; namespace ProductCatalog.Features.ProductData; diff --git a/src/Modules/ProductCatalog/Features/ProductData/DeleteProductData/DeleteProductDataCommand.cs b/src/Modules/ProductCatalog/Features/ProductData/DeleteProductData/DeleteProductDataCommand.cs index 2e52f5b8..9d353965 100644 --- a/src/Modules/ProductCatalog/Features/ProductData/DeleteProductData/DeleteProductDataCommand.cs +++ b/src/Modules/ProductCatalog/Features/ProductData/DeleteProductData/DeleteProductDataCommand.cs @@ -3,7 +3,6 @@ using Polly; using Polly.Registry; using ProductCatalog.Logging; -using ProductCatalog; using Wolverine; namespace ProductCatalog.Features.ProductData.DeleteProductData; @@ -12,13 +11,6 @@ public sealed record DeleteProductDataCommand(Guid Id) : IHasId; public sealed class DeleteProductDataCommandHandler { - public sealed record DeleteProductDataState( - ProductCatalog.Entities.ProductData.ProductData Data, - Guid TenantId, - Guid ActorId, - DateTime DeletedAtUtc - ); - public static async Task<( HandlerContinuation, DeleteProductDataState?, @@ -34,8 +26,7 @@ CancellationToken ct { Guid tenantId = tenantProvider.TenantId; - ProductCatalog.Entities.ProductData.ProductData? data = - await repository.GetByIdAsync(command.Id, ct); + Entities.ProductData.ProductData? data = await repository.GetByIdAsync(command.Id, ct); if (data is null || data.TenantId != tenantId) { @@ -106,4 +97,11 @@ await repository.SoftDeleteAsync( messages.Add(new CacheInvalidationNotification(CacheTags.Products)); return (Result.Success, messages); } + + public sealed record DeleteProductDataState( + Entities.ProductData.ProductData Data, + Guid TenantId, + Guid ActorId, + DateTime DeletedAtUtc + ); } diff --git a/src/Modules/ProductCatalog/Features/ProductData/DeleteProductData/ProductDataCascadeDeleteHandler.cs b/src/Modules/ProductCatalog/Features/ProductData/DeleteProductData/ProductDataCascadeDeleteHandler.cs index 27d3831c..3f682513 100644 --- a/src/Modules/ProductCatalog/Features/ProductData/DeleteProductData/ProductDataCascadeDeleteHandler.cs +++ b/src/Modules/ProductCatalog/Features/ProductData/DeleteProductData/ProductDataCascadeDeleteHandler.cs @@ -21,7 +21,7 @@ CancellationToken ct try { - var count = await pipeline.ExecuteAsync( + long count = await pipeline.ExecuteAsync( async token => await productDataRepository.SoftDeleteByTenantAsync( @event.TenantId, diff --git a/src/Modules/ProductCatalog/Features/ProductData/GetProductData/ProductDataController.GetAll.cs b/src/Modules/ProductCatalog/Features/ProductData/GetProductData/ProductDataController.GetAll.cs index 9657543d..9ec148b1 100644 --- a/src/Modules/ProductCatalog/Features/ProductData/GetProductData/ProductDataController.GetAll.cs +++ b/src/Modules/ProductCatalog/Features/ProductData/GetProductData/ProductDataController.GetAll.cs @@ -2,8 +2,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.OutputCaching; using ProductCatalog.Features.ProductData.GetProductData; -using SharedKernel.Contracts.Api; -using SharedKernel.Contracts.Security; namespace ProductCatalog.Features.ProductData; diff --git a/src/Modules/ProductCatalog/Features/ProductData/GetProductDataById/ProductDataController.GetById.cs b/src/Modules/ProductCatalog/Features/ProductData/GetProductDataById/ProductDataController.GetById.cs index ddf38539..eccb408a 100644 --- a/src/Modules/ProductCatalog/Features/ProductData/GetProductDataById/ProductDataController.GetById.cs +++ b/src/Modules/ProductCatalog/Features/ProductData/GetProductDataById/ProductDataController.GetById.cs @@ -2,8 +2,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.OutputCaching; using ProductCatalog.Features.ProductData.GetProductDataById; -using SharedKernel.Contracts.Api; -using SharedKernel.Contracts.Security; namespace ProductCatalog.Features.ProductData; diff --git a/src/Modules/ProductCatalog/Features/ProductData/ProductDataController.cs b/src/Modules/ProductCatalog/Features/ProductData/ProductDataController.cs index 7ec53b43..f4feb1bb 100644 --- a/src/Modules/ProductCatalog/Features/ProductData/ProductDataController.cs +++ b/src/Modules/ProductCatalog/Features/ProductData/ProductDataController.cs @@ -1,5 +1,4 @@ using Asp.Versioning; -using SharedKernel.Contracts.Api; using Wolverine; namespace ProductCatalog.Features.ProductData; diff --git a/src/Modules/ProductCatalog/Features/ProductData/Shared/ProductDataResponse.cs b/src/Modules/ProductCatalog/Features/ProductData/Shared/ProductDataResponse.cs index 7110c2d7..5107d13f 100644 --- a/src/Modules/ProductCatalog/Features/ProductData/Shared/ProductDataResponse.cs +++ b/src/Modules/ProductCatalog/Features/ProductData/Shared/ProductDataResponse.cs @@ -3,24 +3,24 @@ namespace ProductCatalog.Features.ProductData.Shared; /// -/// Abstract base read model for product data, serialised as a polymorphic type using the type discriminator. -/// Concrete subtypes add media-specific properties. +/// Abstract base read model for product data, serialised as a polymorphic type using the type discriminator. +/// Concrete subtypes add media-specific properties. /// [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; } + public Guid Id { get; init; } } /// -/// Read model for image product data, extending with pixel dimensions. +/// Read model for image product data, extending with pixel dimensions. /// public sealed record ImageProductDataResponse : ProductDataResponse { @@ -29,7 +29,7 @@ public sealed record ImageProductDataResponse : ProductDataResponse } /// -/// Read model for video product data, extending with duration and resolution. +/// Read model for video product data, extending with duration and resolution. /// public sealed record VideoProductDataResponse : ProductDataResponse { diff --git a/src/Modules/ProductCatalog/Features/TenantCascadeDelete/ProductsForTenantSoftDeleteSpecification.cs b/src/Modules/ProductCatalog/Features/TenantCascadeDelete/ProductsForTenantSoftDeleteSpecification.cs index 66350956..9f9d77ec 100644 --- a/src/Modules/ProductCatalog/Features/TenantCascadeDelete/ProductsForTenantSoftDeleteSpecification.cs +++ b/src/Modules/ProductCatalog/Features/TenantCascadeDelete/ProductsForTenantSoftDeleteSpecification.cs @@ -4,10 +4,10 @@ namespace ProductCatalog.Features.TenantCascadeDelete; /// -/// Loads all non-deleted products (with their data links) for a specific tenant, bypassing -/// global query filters so the spec works correctly in cross-module handlers that run without -/// a tenant context. Including ProductDataLinks ensures the cascade rule can soft-delete -/// them in the same transaction. +/// Loads all non-deleted products (with their data links) for a specific tenant, bypassing +/// global query filters so the spec works correctly in cross-module handlers that run without +/// a tenant context. Including ProductDataLinks ensures the cascade rule can soft-delete +/// them in the same transaction. /// public sealed class ProductsForTenantSoftDeleteSpecification : Specification { diff --git a/src/Modules/ProductCatalog/Features/TenantCascadeDelete/TenantCascadeDeleteHandler.cs b/src/Modules/ProductCatalog/Features/TenantCascadeDelete/TenantCascadeDeleteHandler.cs index 2ac49daf..1c4bda79 100644 --- a/src/Modules/ProductCatalog/Features/TenantCascadeDelete/TenantCascadeDeleteHandler.cs +++ b/src/Modules/ProductCatalog/Features/TenantCascadeDelete/TenantCascadeDeleteHandler.cs @@ -1,4 +1,3 @@ -using ProductCatalog; using Wolverine; using CategoryEntity = ProductCatalog.Entities.Category; using ProductEntity = ProductCatalog.Entities.Product; @@ -6,10 +5,10 @@ namespace ProductCatalog.Features.TenantCascadeDelete; /// -/// Handles by cascading the soft-delete to all -/// non-deleted and records for the tenant. -/// Publishes per product so the Reviews module -/// can cascade to ProductReviews, and invalidates the Products and Categories cache. +/// Handles by cascading the soft-delete to all +/// non-deleted and records for the tenant. +/// Publishes per product so the Reviews module +/// can cascade to ProductReviews, and invalidates the Products and Categories cache. /// public static class TenantCascadeDeleteHandler { diff --git a/src/Modules/ProductCatalog/GlobalUsings.cs b/src/Modules/ProductCatalog/GlobalUsings.cs index 68e9600a..de18a960 100644 --- a/src/Modules/ProductCatalog/GlobalUsings.cs +++ b/src/Modules/ProductCatalog/GlobalUsings.cs @@ -1,5 +1,6 @@ // ── Domain ──────────────────────────────────────────────────────────────────── // ── Application ─────────────────────────────────────────────────────────────── + global using ProductCatalog.Common.Errors; global using ProductCatalog.Common.Events; global using ProductCatalog.Entities; diff --git a/src/Modules/ProductCatalog/GraphQL/DataLoaders/ProductReviewsByProductDataLoader.cs b/src/Modules/ProductCatalog/GraphQL/DataLoaders/ProductReviewsByProductDataLoader.cs index a14d61e5..2565a5dc 100644 --- a/src/Modules/ProductCatalog/GraphQL/DataLoaders/ProductReviewsByProductDataLoader.cs +++ b/src/Modules/ProductCatalog/GraphQL/DataLoaders/ProductReviewsByProductDataLoader.cs @@ -4,9 +4,9 @@ namespace ProductCatalog.GraphQL.DataLoaders; /// -/// Hot Chocolate batch data loader that resolves all reviews for a set of product IDs in a -/// single query, preventing the N+1 problem when the GraphQL schema resolves reviews -/// as a field on ProductType. +/// Hot Chocolate batch data loader that resolves all reviews for a set of product IDs in a +/// single query, preventing the N+1 problem when the GraphQL schema resolves reviews +/// as a field on ProductType. /// public sealed class ProductReviewsByProductDataLoader : BatchDataLoader @@ -24,8 +24,8 @@ public ProductReviewsByProductDataLoader( } /// - /// Fetches all reviews for the supplied in one round-trip - /// and returns a dictionary keyed by product ID. + /// Fetches all reviews for the supplied in one round-trip + /// and returns a dictionary keyed by product ID. /// protected override async Task< IReadOnlyDictionary @@ -38,4 +38,3 @@ protected override async Task< return result.ToGraphQLResult(); } } - diff --git a/src/Modules/ProductCatalog/GraphQL/ErrorOrGraphQLExtensions.cs b/src/Modules/ProductCatalog/GraphQL/ErrorOrGraphQLExtensions.cs index d6698063..0db034a1 100644 --- a/src/Modules/ProductCatalog/GraphQL/ErrorOrGraphQLExtensions.cs +++ b/src/Modules/ProductCatalog/GraphQL/ErrorOrGraphQLExtensions.cs @@ -1,33 +1,32 @@ using ErrorOr; -using HotChocolate; namespace ProductCatalog.GraphQL; /// -/// Extension methods that convert results to GraphQL-compatible -/// responses. Throws on non-NotFound errors, and returns -/// default for NotFound to preserve nullable query semantics. +/// Extension methods that convert results to GraphQL-compatible +/// responses. Throws on non-NotFound errors, and returns +/// default for NotFound to preserve nullable query semantics. /// public static class ErrorOrGraphQLExtensions { /// - /// Unwraps the value on success, or throws on error. + /// Unwraps the value on success, or throws on error. /// public static T ToGraphQLResult(this ErrorOr result) { if (!result.IsError) return result.Value; - ErrorOr.Error firstError = result.FirstError; + Error firstError = result.FirstError; throw new GraphQLException( ErrorBuilder.New().SetMessage(firstError.Description).SetCode(firstError.Code).Build() ); } /// - /// Unwraps the value on success, returns default for NotFound errors - /// (preserving nullable query semantics), or throws - /// for other error types. + /// Unwraps the value on success, returns default for NotFound errors + /// (preserving nullable query semantics), or throws + /// for other error types. /// public static T? ToGraphQLNullableResult(this ErrorOr result) { @@ -37,10 +36,9 @@ public static T ToGraphQLResult(this ErrorOr result) if (result.FirstError.Type == ErrorType.NotFound) return default; - ErrorOr.Error firstError = result.FirstError; + Error firstError = result.FirstError; throw new GraphQLException( ErrorBuilder.New().SetMessage(firstError.Description).SetCode(firstError.Code).Build() ); } } - diff --git a/src/Modules/ProductCatalog/GraphQL/Models/CategoryPageResult.cs b/src/Modules/ProductCatalog/GraphQL/Models/CategoryPageResult.cs index d5c836d2..d3cdc612 100644 --- a/src/Modules/ProductCatalog/GraphQL/Models/CategoryPageResult.cs +++ b/src/Modules/ProductCatalog/GraphQL/Models/CategoryPageResult.cs @@ -1,12 +1,8 @@ namespace ProductCatalog.GraphQL.Models; /// -/// GraphQL return type that wraps a paginated category result set, implementing -/// so the schema exposes consistent paging fields. +/// GraphQL return type that wraps a paginated category result set, implementing +/// so the schema exposes consistent paging fields. /// public sealed record CategoryPageResult(PagedResponse Page) : IPagedItems; - - - - diff --git a/src/Modules/ProductCatalog/GraphQL/Models/ProductPageResult.cs b/src/Modules/ProductCatalog/GraphQL/Models/ProductPageResult.cs index f4e69025..715b0932 100644 --- a/src/Modules/ProductCatalog/GraphQL/Models/ProductPageResult.cs +++ b/src/Modules/ProductCatalog/GraphQL/Models/ProductPageResult.cs @@ -1,14 +1,10 @@ namespace ProductCatalog.GraphQL.Models; /// -/// GraphQL return type that combines a paginated product result set with search facets, -/// implementing both and contracts. +/// GraphQL return type that combines a paginated product result set with search facets, +/// implementing both and contracts. /// public sealed record ProductPageResult( PagedResponse Page, ProductSearchFacetsResponse Facets ) : IPagedItems, IHasFacets; - - - - diff --git a/src/Modules/ProductCatalog/GraphQL/Models/ProductQueryInput.cs b/src/Modules/ProductCatalog/GraphQL/Models/ProductQueryInput.cs index b0681b67..bc5f4945 100644 --- a/src/Modules/ProductCatalog/GraphQL/Models/ProductQueryInput.cs +++ b/src/Modules/ProductCatalog/GraphQL/Models/ProductQueryInput.cs @@ -1,10 +1,8 @@ -using SharedKernel.Application.DTOs; - namespace ProductCatalog.GraphQL.Models; /// -/// GraphQL input type for querying products, supporting full-text search, price and date -/// range filters, category constraints, sorting, and pagination. +/// GraphQL input type for querying products, supporting full-text search, price and date +/// range filters, category constraints, sorting, and pagination. /// public sealed class ProductQueryInput { @@ -21,7 +19,3 @@ public sealed class ProductQueryInput public string? Query { get; init; } public IReadOnlyCollection? CategoryIds { get; init; } } - - - - diff --git a/src/Modules/ProductCatalog/GraphQL/Models/ProductReviewQueryInput.cs b/src/Modules/ProductCatalog/GraphQL/Models/ProductReviewQueryInput.cs index 082aa85e..8b7f0220 100644 --- a/src/Modules/ProductCatalog/GraphQL/Models/ProductReviewQueryInput.cs +++ b/src/Modules/ProductCatalog/GraphQL/Models/ProductReviewQueryInput.cs @@ -1,8 +1,8 @@ namespace ProductCatalog.GraphQL.Models; /// -/// GraphQL input type for querying product reviews, supporting filters by product, -/// user, rating range, date range, sorting, and pagination. +/// GraphQL input type for querying product reviews, supporting filters by product, +/// user, rating range, date range, sorting, and pagination. /// public sealed class ProductReviewQueryInput { @@ -17,7 +17,3 @@ public sealed class ProductReviewQueryInput public int PageNumber { get; init; } = 1; public int PageSize { get; init; } = PaginationFilter.DefaultPageSize; } - - - - diff --git a/src/Modules/ProductCatalog/GraphQL/Mutations/ProductMutations.cs b/src/Modules/ProductCatalog/GraphQL/Mutations/ProductMutations.cs index b2f33b01..24ea85e9 100644 --- a/src/Modules/ProductCatalog/GraphQL/Mutations/ProductMutations.cs +++ b/src/Modules/ProductCatalog/GraphQL/Mutations/ProductMutations.cs @@ -2,14 +2,13 @@ using HotChocolate.Authorization; using ProductCatalog.Features.Product.CreateProducts; using ProductCatalog.Features.Product.DeleteProducts; -using SharedKernel.Contracts.Security; using Wolverine; namespace ProductCatalog.GraphQL.Mutations; /// -/// Hot Chocolate mutation type that exposes product write operations backed by -/// batch CQRS handlers, enforcing per-operation authorization policies. +/// Hot Chocolate mutation type that exposes product write operations backed by +/// batch CQRS handlers, enforcing per-operation authorization policies. /// [Authorize] public class ProductMutations @@ -29,7 +28,7 @@ CancellationToken ct return result.ToGraphQLResult(); } - /// Deletes a single product by ID and returns on success. + /// Deletes a single product by ID and returns on success. [Authorize(Policy = Permission.Products.Delete)] public async Task DeleteProduct(Guid id, [Service] IMessageBus bus, CancellationToken ct) { diff --git a/src/Modules/ProductCatalog/GraphQL/Mutations/ProductReviewMutations.cs b/src/Modules/ProductCatalog/GraphQL/Mutations/ProductReviewMutations.cs index 4cec971f..28c0c687 100644 --- a/src/Modules/ProductCatalog/GraphQL/Mutations/ProductReviewMutations.cs +++ b/src/Modules/ProductCatalog/GraphQL/Mutations/ProductReviewMutations.cs @@ -1,13 +1,12 @@ using ErrorOr; using HotChocolate.Authorization; -using SharedKernel.Contracts.Security; using Wolverine; namespace ProductCatalog.GraphQL.Mutations; /// -/// Hot Chocolate mutation type extension that adds product-review write operations -/// (create and delete) to the root type. +/// Hot Chocolate mutation type extension that adds product-review write operations +/// (create and delete) to the root type. /// [Authorize] [ExtendObjectType(typeof(ProductMutations))] @@ -27,7 +26,7 @@ CancellationToken ct return result.ToGraphQLResult(); } - /// Deletes a product review by its ID and returns on success. + /// Deletes a product review by its ID and returns on success. [Authorize(Policy = Permission.ProductReviews.Delete)] public async Task DeleteProductReview( Guid id, @@ -43,4 +42,3 @@ CancellationToken ct return true; } } - diff --git a/src/Modules/ProductCatalog/GraphQL/Queries/CategoryQueries.cs b/src/Modules/ProductCatalog/GraphQL/Queries/CategoryQueries.cs index ce32c8a7..3fc79bf8 100644 --- a/src/Modules/ProductCatalog/GraphQL/Queries/CategoryQueries.cs +++ b/src/Modules/ProductCatalog/GraphQL/Queries/CategoryQueries.cs @@ -2,22 +2,21 @@ using HotChocolate.Authorization; using ProductCatalog.Features.Category.GetCategories; using ProductCatalog.Features.Category.GetCategoryById; -using ProductCatalog.GraphQL.Models; using Wolverine; namespace ProductCatalog.GraphQL.Queries; /// -/// Hot Chocolate query type extension that adds category queries to the -/// root, providing paginated list and single-item lookup operations. +/// Hot Chocolate query type extension that adds category queries to the +/// root, providing paginated list and single-item lookup operations. /// [Authorize] [ExtendObjectType(typeof(ProductQueries))] public sealed class CategoryQueries { /// - /// Returns a paginated category list, mapping the GraphQL input to the application-layer - /// filter before dispatching via the message bus. + /// Returns a paginated category list, mapping the GraphQL input to the application-layer + /// filter before dispatching via the message bus. /// public async Task GetCategories( CategoryQueryInput? input, @@ -25,7 +24,7 @@ public async Task GetCategories( CancellationToken ct ) { - var filter = new CategoryFilter( + CategoryFilter filter = new( input?.Query, input?.SortBy, input?.SortDirection, @@ -39,7 +38,7 @@ CancellationToken ct return new CategoryPageResult(result.ToGraphQLResult()); } - /// Returns a single category by ID, or if not found. + /// Returns a single category by ID, or if not found. public async Task GetCategoryById( Guid id, [Service] IMessageBus bus, diff --git a/src/Modules/ProductCatalog/GraphQL/Queries/ProductReviewQueries.cs b/src/Modules/ProductCatalog/GraphQL/Queries/ProductReviewQueries.cs index cc74e945..bc1e76a7 100644 --- a/src/Modules/ProductCatalog/GraphQL/Queries/ProductReviewQueries.cs +++ b/src/Modules/ProductCatalog/GraphQL/Queries/ProductReviewQueries.cs @@ -1,22 +1,21 @@ using ErrorOr; using HotChocolate.Authorization; -using ProductCatalog.GraphQL.Models; using Wolverine; namespace ProductCatalog.GraphQL.Queries; /// -/// Hot Chocolate query type extension that adds product-review queries to the -/// root, supporting filtered list, single-item, and -/// per-product lookup operations. +/// Hot Chocolate query type extension that adds product-review queries to the +/// root, supporting filtered list, single-item, and +/// per-product lookup operations. /// [Authorize] [ExtendObjectType(typeof(ProductQueries))] public class ProductReviewQueries { /// - /// Returns a paginated review list, mapping the GraphQL input to the application-layer - /// filter before dispatching via the message bus. + /// Returns a paginated review list, mapping the GraphQL input to the application-layer + /// filter before dispatching via the message bus. /// public async Task GetReviews( ProductReviewQueryInput? input, @@ -24,7 +23,7 @@ public async Task GetReviews( CancellationToken ct ) { - var filter = new ProductReviewFilter( + ProductReviewFilter filter = new( input?.ProductId, input?.UserId, input?.MinRating, @@ -43,7 +42,7 @@ CancellationToken ct return new ProductReviewPageResult(result.ToGraphQLResult()); } - /// Returns a single review by ID, or if not found. + /// Returns a single review by ID, or if not found. public async Task GetReviewById( Guid id, [Service] IMessageBus bus, @@ -65,15 +64,10 @@ public async Task GetReviewsByProductId( CancellationToken ct ) { - var filter = new ProductReviewFilter( - ProductId: productId, - PageNumber: pageNumber, - PageSize: pageSize - ); + ProductReviewFilter filter = new(productId, PageNumber: pageNumber, PageSize: pageSize); ErrorOr> result = await bus.InvokeAsync< ErrorOr> >(new GetProductReviewsQuery(filter), ct); return new ProductReviewPageResult(result.ToGraphQLResult()); } } - diff --git a/src/Modules/ProductCatalog/GraphQL/Types/ProductReviewType.cs b/src/Modules/ProductCatalog/GraphQL/Types/ProductReviewType.cs index 426608f0..a7633536 100644 --- a/src/Modules/ProductCatalog/GraphQL/Types/ProductReviewType.cs +++ b/src/Modules/ProductCatalog/GraphQL/Types/ProductReviewType.cs @@ -1,8 +1,8 @@ namespace ProductCatalog.GraphQL.Types; /// -/// Hot Chocolate object type that maps to the GraphQL schema, -/// annotating each field with descriptions and explicit scalar types. +/// Hot Chocolate object type that maps to the GraphQL schema, +/// annotating each field with descriptions and explicit scalar types. /// public sealed class ProductReviewType : ObjectType { @@ -38,7 +38,3 @@ protected override void Configure(IObjectTypeDescriptor d .Description("The UTC timestamp of when the review was created."); } } - - - - diff --git a/src/Modules/ProductCatalog/GraphQL/Types/ProductType.cs b/src/Modules/ProductCatalog/GraphQL/Types/ProductType.cs index 7c44d298..2c08275c 100644 --- a/src/Modules/ProductCatalog/GraphQL/Types/ProductType.cs +++ b/src/Modules/ProductCatalog/GraphQL/Types/ProductType.cs @@ -1,15 +1,15 @@ namespace ProductCatalog.GraphQL.Types; /// -/// Hot Chocolate object type that maps to the GraphQL schema, -/// including a reviews field resolved via to batch-load -/// associated reviews using the data loader. +/// Hot Chocolate object type that maps to the GraphQL schema, +/// including a reviews field resolved via to batch-load +/// associated reviews using the data loader. /// public sealed class ProductType : ObjectType { /// - /// Configures field descriptions, scalar type mappings, and the batch-loaded reviews - /// resolver for the Product GraphQL type. + /// Configures field descriptions, scalar type mappings, and the batch-loaded reviews + /// resolver for the Product GraphQL type. /// protected override void Configure(IObjectTypeDescriptor descriptor) { @@ -49,7 +49,3 @@ protected override void Configure(IObjectTypeDescriptor descrip .Description("The reviews associated with this product."); } } - - - - diff --git a/src/Modules/ProductCatalog/GraphQL/Types/ProductTypeResolvers.cs b/src/Modules/ProductCatalog/GraphQL/Types/ProductTypeResolvers.cs index b13b9437..e0f20dce 100644 --- a/src/Modules/ProductCatalog/GraphQL/Types/ProductTypeResolvers.cs +++ b/src/Modules/ProductCatalog/GraphQL/Types/ProductTypeResolvers.cs @@ -3,23 +3,22 @@ namespace ProductCatalog.GraphQL.Types; /// -/// Resolver class for the reviews field on . -/// Delegates to to batch-load reviews and -/// returns an empty array when no reviews exist for the product. +/// Resolver class for the reviews field on . +/// Delegates to to batch-load reviews and +/// returns an empty array when no reviews exist for the product. /// public sealed class ProductTypeResolvers { /// - /// Loads reviews for the given via the batch data loader, - /// returning an empty array when the loader yields no result. + /// Loads reviews for the given via the batch data loader, + /// returning an empty array when the loader yields no result. /// public async Task GetReviews( [Parent] ProductResponse product, ProductReviewsByProductDataLoader loader, CancellationToken ct - ) => await loader.LoadAsync(product.Id, ct) ?? Array.Empty(); + ) + { + return await loader.LoadAsync(product.Id, ct) ?? Array.Empty(); + } } - - - - diff --git a/src/Modules/ProductCatalog/Handlers/CleanupOrphanedProductDataHandler.cs b/src/Modules/ProductCatalog/Handlers/CleanupOrphanedProductDataHandler.cs index d2895857..706d1a12 100644 --- a/src/Modules/ProductCatalog/Handlers/CleanupOrphanedProductDataHandler.cs +++ b/src/Modules/ProductCatalog/Handlers/CleanupOrphanedProductDataHandler.cs @@ -8,9 +8,9 @@ namespace ProductCatalog.Handlers; /// -/// Wolverine handler that processes dispatched by the -/// BackgroundJobs module. Identifies MongoDB ProductData documents that have no corresponding -/// ProductDataLink in PostgreSQL and deletes them in paginated batches. +/// Wolverine handler that processes dispatched by the +/// BackgroundJobs module. Identifies MongoDB ProductData documents that have no corresponding +/// ProductDataLink in PostgreSQL and deletes them in paginated batches. /// public sealed class CleanupOrphanedProductDataHandler { @@ -36,9 +36,7 @@ CancellationToken ct ); if (lastSeenId.HasValue) - { pageFilter &= Builders.Filter.Gt(d => d.Id, lastSeenId.Value); - } List page = await mongoCollection .Find(pageFilter) @@ -48,9 +46,7 @@ CancellationToken ct .ToListAsync(ct); if (page.Count == 0) - { break; - } List linkedIds = await dbContext .ProductDataLinks.IgnoreQueryFilters() @@ -76,9 +72,6 @@ CancellationToken ct } if (totalDeleted > 0) - { logger.OrphanedProductDataCleanedUp(totalDeleted); - } } } - diff --git a/src/Modules/ProductCatalog/Interfaces/ICategoryRepository.cs b/src/Modules/ProductCatalog/Interfaces/ICategoryRepository.cs index 20d7c148..eb4ddfa0 100644 --- a/src/Modules/ProductCatalog/Interfaces/ICategoryRepository.cs +++ b/src/Modules/ProductCatalog/Interfaces/ICategoryRepository.cs @@ -1,19 +1,18 @@ -using ProductCatalog.Entities; - namespace ProductCatalog.Interfaces; /// -/// Repository contract for entities, extending the generic repository with category-specific queries. +/// 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, p_tenant_id) PostgreSQL stored procedure - /// and returns aggregated statistics for the given category within the current tenant context. - /// Returns null when no category with the specified ID exists. + /// Calls the get_product_category_stats(p_category_id, p_tenant_id) PostgreSQL stored procedure + /// and returns aggregated statistics for the given category within the current tenant context. + /// Returns null when no category with the specified ID exists. /// - Task GetStatsByIdAsync(Guid categoryId, CancellationToken ct = default); + public Task GetStatsByIdAsync( + Guid categoryId, + CancellationToken ct = default + ); } - - - diff --git a/src/Modules/ProductCatalog/Interfaces/IProductDataLinkRepository.cs b/src/Modules/ProductCatalog/Interfaces/IProductDataLinkRepository.cs index 6c87b172..dcd37d88 100644 --- a/src/Modules/ProductCatalog/Interfaces/IProductDataLinkRepository.cs +++ b/src/Modules/ProductCatalog/Interfaces/IProductDataLinkRepository.cs @@ -1,46 +1,42 @@ -using ProductCatalog.Entities; - namespace ProductCatalog.Interfaces; /// -/// Repository contract for managing join records between relational products and MongoDB product-data documents. +/// 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. + /// Returns all links for the specified product, optionally including soft-deleted records. /// - Task> ListByProductIdAsync( + public 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. + /// Returns links for the specified product IDs in a single query, optionally including soft-deleted records. /// - Task>> ListByProductIdsAsync( + public 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. + /// Returns true if at least one non-deleted link references the given product-data document. /// - Task HasActiveLinksForProductDataAsync( + public Task HasActiveLinksForProductDataAsync( Guid productDataId, CancellationToken ct = default ); /// - /// Soft-deletes all active links that reference the given product-data document. + /// Soft-deletes all active links that reference the given product-data document. /// - Task SoftDeleteActiveLinksForProductDataAsync( + public Task SoftDeleteActiveLinksForProductDataAsync( Guid productDataId, CancellationToken ct = default ); } - - - diff --git a/src/Modules/ProductCatalog/Interfaces/IProductDataRepository.cs b/src/Modules/ProductCatalog/Interfaces/IProductDataRepository.cs index f0f5baa0..3058332f 100644 --- a/src/Modules/ProductCatalog/Interfaces/IProductDataRepository.cs +++ b/src/Modules/ProductCatalog/Interfaces/IProductDataRepository.cs @@ -1,30 +1,31 @@ -using ProductCatalog.Entities; -using ProductCatalog.Entities.ProductData; - namespace ProductCatalog.Interfaces; /// -/// Repository contract for documents stored in MongoDB. -/// Provides CRUD and soft-delete operations scoped to the current tenant. +/// 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); + public 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); + public Task> GetByIdsAsync( + IEnumerable ids, + CancellationToken ct = default + ); /// - /// Returns all non-deleted product-data documents, optionally filtered by discriminator (e.g. "image" or "video"). + /// Returns all non-deleted product-data documents, optionally filtered by discriminator (e.g. + /// "image" or "video"). /// - Task> GetAllAsync(string? type = null, CancellationToken ct = default); + public 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); + public Task CreateAsync(ProductData productData, CancellationToken ct = default); /// Soft-deletes the product-data document with the given ID, recording the actor and timestamp. - Task SoftDeleteAsync( + public Task SoftDeleteAsync( Guid id, Guid actorId, DateTime deletedAtUtc, @@ -32,15 +33,13 @@ Task SoftDeleteAsync( ); /// - /// Soft-deletes all product-data documents belonging to the specified tenant and returns the count of affected documents. + /// Soft-deletes all product-data documents belonging to the specified tenant and returns the count of affected + /// documents. /// - Task SoftDeleteByTenantAsync( + public Task SoftDeleteByTenantAsync( Guid tenantId, Guid actorId, DateTime deletedAtUtc, CancellationToken ct = default ); } - - - diff --git a/src/Modules/ProductCatalog/Interfaces/IProductRepository.cs b/src/Modules/ProductCatalog/Interfaces/IProductRepository.cs index d6d70748..4eed866a 100644 --- a/src/Modules/ProductCatalog/Interfaces/IProductRepository.cs +++ b/src/Modules/ProductCatalog/Interfaces/IProductRepository.cs @@ -5,28 +5,41 @@ namespace ProductCatalog.Interfaces; /// -/// Domain-facing repository contract for products, extending the generic repository with product-specific filtered queries and facet aggregations. +/// 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( + public 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( + /// + /// Returns category facet counts for the current filter, ignoring any active category-ID constraints so all + /// categories remain selectable. + /// + public 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( + /// + /// Returns price-bucket facet counts for the current filter, ignoring any active price-range constraints so all + /// buckets remain selectable. + /// + public Task> GetPriceFacetsAsync( ProductFilter filter, CancellationToken ct = default ); - /// Sets CategoryId to null on all products whose CategoryId is in . - Task ClearCategoryAsync(IReadOnlyCollection categoryIds, CancellationToken ct = default); + /// + /// Sets CategoryId to null on all products whose CategoryId is in + /// . + /// + public Task ClearCategoryAsync( + IReadOnlyCollection categoryIds, + CancellationToken ct = default + ); } diff --git a/src/Modules/ProductCatalog/Logging/ProductCatalogApplicationLogs.cs b/src/Modules/ProductCatalog/Logging/ProductCatalogApplicationLogs.cs index c3864ce3..1d18ce0f 100644 --- a/src/Modules/ProductCatalog/Logging/ProductCatalogApplicationLogs.cs +++ b/src/Modules/ProductCatalog/Logging/ProductCatalogApplicationLogs.cs @@ -1,10 +1,9 @@ using Microsoft.Extensions.Logging; -using SharedKernel.Infrastructure.Logging; namespace ProductCatalog.Logging; /// -/// Source-generated logger extension methods for ProductCatalog application diagnostics. +/// Source-generated logger extension methods for ProductCatalog application diagnostics. /// internal static partial class ProductCatalogApplicationLogs { @@ -44,4 +43,3 @@ public static partial void ProductDataSoftDeleteFailed( Guid tenantId ); } - diff --git a/src/Modules/ProductCatalog/Logging/ProductCatalogInfrastructureLogs.cs b/src/Modules/ProductCatalog/Logging/ProductCatalogInfrastructureLogs.cs index 02233895..c800ac55 100644 --- a/src/Modules/ProductCatalog/Logging/ProductCatalogInfrastructureLogs.cs +++ b/src/Modules/ProductCatalog/Logging/ProductCatalogInfrastructureLogs.cs @@ -3,7 +3,7 @@ namespace ProductCatalog.Logging; /// -/// Source-generated logger extension methods for ProductCatalog infrastructure diagnostics. +/// Source-generated logger extension methods for ProductCatalog infrastructure diagnostics. /// internal static partial class ProductCatalogInfrastructureLogs { @@ -19,4 +19,3 @@ internal static partial class ProductCatalogInfrastructureLogs [LoggerMessage(EventId = 4051, Level = LogLevel.Error, Message = "MongoDB health check failed")] public static partial void MongoDbHealthCheckFailed(this ILogger logger, Exception exception); } - diff --git a/src/Modules/ProductCatalog/Persistence/MongoDbContext.cs b/src/Modules/ProductCatalog/Persistence/MongoDbContext.cs index c3fa9d13..4dc2dc72 100644 --- a/src/Modules/ProductCatalog/Persistence/MongoDbContext.cs +++ b/src/Modules/ProductCatalog/Persistence/MongoDbContext.cs @@ -1,4 +1,3 @@ -using ProductCatalog.Entities; using Microsoft.Extensions.Options; using MongoDB.Bson; using MongoDB.Driver; @@ -7,8 +6,8 @@ namespace ProductCatalog.Persistence; /// -/// Thin wrapper around the MongoDB driver that configures the client with diagnostic -/// activity tracing and exposes typed collection accessors for domain document types. +/// 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 { @@ -16,13 +15,13 @@ public sealed class MongoDbContext public MongoDbContext(IOptions settings) { - var clientSettings = MongoClientSettings.FromConnectionString( + MongoClientSettings? clientSettings = MongoClientSettings.FromConnectionString( settings.Value.ConnectionString ); clientSettings.ServerSelectionTimeout = TimeSpan.FromSeconds(5); clientSettings.ClusterConfigurator = cb => cb.Subscribe(new DiagnosticsActivityEventSubscriber()); - var client = new MongoClient(clientSettings); + MongoClient client = new(clientSettings); _database = client.GetDatabase(settings.Value.DatabaseName); } @@ -30,12 +29,11 @@ public MongoDbContext(IOptions settings) _database.GetCollection("product_data"); /// Sends a ping command to verify that the MongoDB server is reachable. - public Task PingAsync(CancellationToken cancellationToken = default) => - _database.RunCommandAsync( + public Task PingAsync(CancellationToken cancellationToken = default) + { + return _database.RunCommandAsync( new BsonDocument("ping", 1), cancellationToken: cancellationToken ); + } } - - - diff --git a/src/Modules/ProductCatalog/Persistence/MongoDbSettings.cs b/src/Modules/ProductCatalog/Persistence/MongoDbSettings.cs index 3f58e183..8364e640 100644 --- a/src/Modules/ProductCatalog/Persistence/MongoDbSettings.cs +++ b/src/Modules/ProductCatalog/Persistence/MongoDbSettings.cs @@ -1,7 +1,7 @@ namespace ProductCatalog.Persistence; /// -/// Strongly-typed settings for the MongoDB connection, bound from the application configuration. +/// Strongly-typed settings for the MongoDB connection, bound from the application configuration. /// public sealed class MongoDbSettings { @@ -9,6 +9,3 @@ public sealed class MongoDbSettings public string DatabaseName { get; init; } = string.Empty; } - - - diff --git a/src/Modules/ProductCatalog/Persistence/ProductCatalogDbContext.cs b/src/Modules/ProductCatalog/Persistence/ProductCatalogDbContext.cs index b3f51c13..009a6779 100644 --- a/src/Modules/ProductCatalog/Persistence/ProductCatalogDbContext.cs +++ b/src/Modules/ProductCatalog/Persistence/ProductCatalogDbContext.cs @@ -1,9 +1,6 @@ -using ProductCatalog.Configurations; -using SharedKernel.Application.Context; +using Microsoft.EntityFrameworkCore; using SharedKernel.Infrastructure.Auditing; using SharedKernel.Infrastructure.EntityNormalization; -using SharedKernel.Infrastructure.SoftDelete; -using Microsoft.EntityFrameworkCore; namespace ProductCatalog.Persistence; @@ -41,4 +38,3 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) ApplyGlobalFilters(modelBuilder); } } - diff --git a/src/Modules/ProductCatalog/ProductCatalogDbMarker.cs b/src/Modules/ProductCatalog/ProductCatalogDbMarker.cs index 8854a68a..ddfc87ec 100644 --- a/src/Modules/ProductCatalog/ProductCatalogDbMarker.cs +++ b/src/Modules/ProductCatalog/ProductCatalogDbMarker.cs @@ -1,9 +1,8 @@ namespace ProductCatalog; /// -/// Domain-layer marker type identifying the ProductCatalog module's persistence boundary. -/// Used as the type parameter for -/// so that handlers can request the correct unit of work without referencing the Infrastructure layer. +/// Domain-layer marker type identifying the ProductCatalog module's persistence boundary. +/// Used as the type parameter for +/// so that handlers can request the correct unit of work without referencing the Infrastructure layer. /// public abstract class ProductCatalogDbMarker; - diff --git a/src/Modules/ProductCatalog/ProductCatalogModule.cs b/src/Modules/ProductCatalog/ProductCatalogModule.cs index 74f19983..e6fb5f09 100644 --- a/src/Modules/ProductCatalog/ProductCatalogModule.cs +++ b/src/Modules/ProductCatalog/ProductCatalogModule.cs @@ -6,20 +6,15 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Resilience; using Polly; +using Polly.Retry; using ProductCatalog.GraphQL.DataLoaders; using ProductCatalog.GraphQL.Mutations; using ProductCatalog.GraphQL.Queries; using ProductCatalog.GraphQL.Types; -using ProductCatalog.Interfaces; using ProductCatalog.Persistence; using ProductCatalog.Repositories; using ProductCatalog.SoftDelete; -using ProductCatalog.StoredProcedures; -using SharedKernel.Application.Batch; -using SharedKernel.Application.Batch.Rules; -using SharedKernel.Application.Resilience; using SharedKernel.Infrastructure.Configuration; using SharedKernel.Infrastructure.Health; using SharedKernel.Infrastructure.Registration; @@ -42,7 +37,7 @@ IConfiguration configuration .AddModule(configuration) .ConfigureDbContext(options => options.UseNpgsql(connectionString)) .AddDefaultInfrastructure() - .ForwardUnitOfWork() + .ForwardUnitOfWork() .AddStoredProcedureSupport() .AddRepository() .AddRepository() @@ -50,8 +45,8 @@ IConfiguration configuration .AddRepository() .AddCascadeRule(); - services.AddValidatorsFromAssemblyContaining( - filter: registration => !registration.ValidatorType.IsGenericTypeDefinition + services.AddValidatorsFromAssemblyContaining(filter: registration => + !registration.ValidatorType.IsGenericTypeDefinition ); services.AddScoped(typeof(IBatchRule<>), typeof(FluentValidationBatchRule<>)); @@ -61,7 +56,8 @@ IConfiguration configuration services.AddSingleton(); MongoDbSettings mongoSettings = - configuration.GetSection(ConfigurationSections.MongoDB).Get() ?? new(); + configuration.GetSection(ConfigurationSections.MongoDB).Get() + ?? new MongoDbSettings(); if ( !string.IsNullOrWhiteSpace(mongoSettings.ConnectionString) && !string.IsNullOrWhiteSpace(mongoSettings.DatabaseName) @@ -80,7 +76,7 @@ IConfiguration configuration builder => { builder.AddRetry( - new() + new RetryStrategyOptions { MaxRetryAttempts = 3, BackoffType = DelayBackoffType.Exponential, @@ -124,4 +120,3 @@ this IEndpointRouteBuilder endpoints return endpoints; } } - diff --git a/src/Modules/ProductCatalog/Repositories/ProductDataLinkRepository.cs b/src/Modules/ProductCatalog/Repositories/ProductDataLinkRepository.cs index add7cc11..5ae81dfa 100644 --- a/src/Modules/ProductCatalog/Repositories/ProductDataLinkRepository.cs +++ b/src/Modules/ProductCatalog/Repositories/ProductDataLinkRepository.cs @@ -1,14 +1,11 @@ using Microsoft.EntityFrameworkCore; -using ProductCatalog.Entities; -using ProductCatalog.Interfaces; using ProductCatalog.Persistence; -using SharedKernel.Application.Context; namespace ProductCatalog.Repositories; /// -/// EF Core repository for join entities, providing queries -/// that selectively bypass global filters when deleted links must be included. +/// EF Core repository for join entities, providing queries +/// that selectively bypass global filters when deleted links must be included. /// public sealed class ProductDataLinkRepository : IProductDataLinkRepository { @@ -25,7 +22,7 @@ ITenantProvider tenantProvider } /// - /// Returns links for the given product, optionally including soft-deleted entries by bypassing global filters. + /// Returns links for the given product, optionally including soft-deleted entries by bypassing global filters. /// public async Task> ListByProductIdAsync( Guid productId, @@ -76,14 +73,16 @@ public async Task< public Task HasActiveLinksForProductDataAsync( Guid productDataId, CancellationToken ct = default - ) => - _dbContext + ) + { + return _dbContext .ProductDataLinks.AsNoTracking() .AnyAsync(link => link.ProductDataId == productDataId, ct); + } /// - /// Stages removal of all active links for the given product data document so they - /// are soft-deleted when the unit of work commits. + /// Stages removal of all active links for the given product data document so they + /// are soft-deleted when the unit of work commits. /// public async Task SoftDeleteActiveLinksForProductDataAsync( Guid productDataId, @@ -100,4 +99,3 @@ public async Task SoftDeleteActiveLinksForProductDataAsync( _dbContext.ProductDataLinks.RemoveRange(links); } } - diff --git a/src/Modules/ProductCatalog/Repositories/ProductDataRepository.cs b/src/Modules/ProductCatalog/Repositories/ProductDataRepository.cs index 593369a3..eb09bde3 100644 --- a/src/Modules/ProductCatalog/Repositories/ProductDataRepository.cs +++ b/src/Modules/ProductCatalog/Repositories/ProductDataRepository.cs @@ -1,14 +1,11 @@ using MongoDB.Driver; -using ProductCatalog.Entities; -using ProductCatalog.Interfaces; using ProductCatalog.Persistence; -using SharedKernel.Application.Context; namespace ProductCatalog.Repositories; /// -/// MongoDB repository for documents, applying tenant and soft-delete -/// isolation at the query level since MongoDB has no EF Core global filter equivalent. +/// 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 { @@ -21,13 +18,21 @@ public ProductDataRepository(MongoDbContext context, ITenantProvider tenantProvi _tenantProvider = tenantProvider; } - /// Returns a single non-deleted document matching the given ID within the current tenant, or null if not found. - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) => - await _collection + /// + /// Returns a single non-deleted document matching the given ID within the current tenant, or null if not + /// found. + /// + public async Task GetByIdAsync(Guid id, CancellationToken ct = default) + { + return await _collection .Find(x => x.Id == id && x.TenantId == _tenantProvider.TenantId && !x.IsDeleted) .FirstOrDefaultAsync(ct); + } - /// Returns non-deleted documents for the given IDs within the current tenant; deduplicates the ID list before querying. + /// + /// Returns non-deleted documents for the given IDs within the current tenant; deduplicates the ID list before + /// querying. + /// public async Task> GetByIdsAsync( IEnumerable ids, CancellationToken ct = default @@ -49,7 +54,10 @@ public async Task> GetByIdsAsync( .ToListAsync(ct); } - /// Returns all non-deleted documents for the current tenant, optionally filtered by the MongoDB discriminator type. + /// + /// Returns all non-deleted documents for the current tenant, optionally filtered by the MongoDB discriminator + /// type. + /// public async Task> GetAllAsync( string? type = null, CancellationToken ct = default @@ -100,8 +108,8 @@ await _collection.UpdateOneAsync( } /// - /// Soft-deletes all non-deleted documents belonging to the specified tenant in a single - /// UpdateMany operation and returns the count of modified documents. + /// Soft-deletes all non-deleted documents belonging to the specified tenant in a single + /// UpdateMany operation and returns the count of modified documents. /// public async Task SoftDeleteByTenantAsync( Guid tenantId, @@ -128,4 +136,3 @@ public async Task SoftDeleteByTenantAsync( return result.ModifiedCount; } } - diff --git a/src/Modules/ProductCatalog/Repositories/ProductRepository.cs b/src/Modules/ProductCatalog/Repositories/ProductRepository.cs index e1baa1cc..0ef49cd0 100644 --- a/src/Modules/ProductCatalog/Repositories/ProductRepository.cs +++ b/src/Modules/ProductCatalog/Repositories/ProductRepository.cs @@ -7,13 +7,11 @@ namespace ProductCatalog.Repositories; /// -/// EF Core repository for with specification-based listing, -/// count, category facet, and price bucket facet queries. +/// EF Core repository for with specification-based listing, +/// count, category facet, and price bucket facet queries. /// public class ProductRepository : RepositoryBase, ProductApplicationRepository { - private readonly ProductCatalogDbContext _dbContext; - private static readonly IReadOnlyList DefaultPriceBuckets = [ new("0 - 50", 0m, 50m, 0), @@ -23,6 +21,8 @@ public class ProductRepository : RepositoryBase, ProductApplicationRepo new("500+", 500m, null, 0), ]; + private readonly ProductCatalogDbContext _dbContext; + public ProductRepository(ProductCatalogDbContext dbContext) : base(dbContext) { @@ -43,13 +43,16 @@ public async Task>> GetPagedAsync( ); } - /// Returns category facet counts for products matching the filter, ordered by descending count then category name. + /// + /// 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 ) { - var specification = new ProductCategoryFacetSpecification(filter); + ProductCategoryFacetSpecification specification = new(filter); IQueryable query = Ardalis.Specification.EntityFrameworkCore.SpecificationEvaluator.Default.GetQuery( _dbContext.Products.AsQueryable(), @@ -84,7 +87,7 @@ public async Task> GetPriceFacets CancellationToken ct = default ) { - var specification = new ProductPriceFacetSpecification(filter); + ProductPriceFacetSpecification specification = new(filter); IQueryable query = Ardalis.Specification.EntityFrameworkCore.SpecificationEvaluator.Default.GetQuery( _dbContext.Products.AsQueryable(), @@ -110,7 +113,10 @@ public async Task> GetPriceFacets .ToArray(); } - /// Bulk-sets CategoryId to null for all products whose CategoryId is in . + /// + /// Bulk-sets CategoryId to null for all products whose CategoryId is in + /// . + /// public async Task ClearCategoryAsync( IReadOnlyCollection categoryIds, CancellationToken ct = default @@ -134,7 +140,9 @@ private sealed record PriceFacetCounts( int FiveHundredAndAbove ) { - public int[] ToArray() => + public int[] ToArray() + { + return [ ZeroToFifty, FiftyToOneHundred, @@ -142,5 +150,6 @@ public int[] ToArray() => TwoHundredFiftyToFiveHundred, FiveHundredAndAbove, ]; + } } } diff --git a/src/Modules/ProductCatalog/SoftDelete/ProductSoftDeleteCascadeRule.cs b/src/Modules/ProductCatalog/SoftDelete/ProductSoftDeleteCascadeRule.cs index 76ad9d32..e54a033d 100644 --- a/src/Modules/ProductCatalog/SoftDelete/ProductSoftDeleteCascadeRule.cs +++ b/src/Modules/ProductCatalog/SoftDelete/ProductSoftDeleteCascadeRule.cs @@ -5,7 +5,10 @@ namespace ProductCatalog.SoftDelete; public sealed class ProductSoftDeleteCascadeRule : ISoftDeleteCascadeRule { - public bool CanHandle(IAuditableTenantEntity entity) => entity is Product; + public bool CanHandle(IAuditableTenantEntity entity) + { + return entity is Product; + } public async Task> GetDependentsAsync( DbContext dbContext, @@ -13,14 +16,18 @@ public async Task> GetDependentsAsyn CancellationToken cancellationToken = default ) { - if (entity is not Product product || dbContext is not ProductCatalogDbContext productCatalogDbContext) + if ( + entity is not Product product + || dbContext is not ProductCatalogDbContext productCatalogDbContext + ) return []; return await productCatalogDbContext .ProductDataLinks.IgnoreQueryFilters(["SoftDelete", "Tenant"]) - .Where(link => link.ProductId == product.Id && link.TenantId == product.TenantId && !link.IsDeleted) + .Where(link => + link.ProductId == product.Id && link.TenantId == product.TenantId && !link.IsDeleted + ) .Cast() .ToListAsync(cancellationToken); } } - diff --git a/src/Modules/ProductCatalog/StoredProcedures/GetProductCategoryStatsProcedure.cs b/src/Modules/ProductCatalog/StoredProcedures/GetProductCategoryStatsProcedure.cs index 2d1ea72a..931ba86a 100644 --- a/src/Modules/ProductCatalog/StoredProcedures/GetProductCategoryStatsProcedure.cs +++ b/src/Modules/ProductCatalog/StoredProcedures/GetProductCategoryStatsProcedure.cs @@ -1,23 +1,17 @@ -using ProductCatalog.Entities; -using ProductCatalog.Interfaces; - namespace ProductCatalog.StoredProcedures; /// -/// Calls the get_product_category_stats(p_category_id, p_tenant_id) PostgreSQL function. -/// -/// Result columns returned by the function: -/// category_id, category_name, product_count, average_price, total_reviews -/// -/// EF Core maps each column to the corresponding property on -/// by name (case-insensitive). +/// Calls the get_product_category_stats(p_category_id, p_tenant_id) PostgreSQL function. +/// Result columns returned by the function: +/// category_id, category_name, product_count, average_price, total_reviews +/// EF Core maps each column to the corresponding property on +/// by name (case-insensitive). /// public sealed record GetProductCategoryStatsProcedure(Guid CategoryId, Guid TenantId) : IStoredProcedure { - public FormattableString ToSql() => - $"SELECT * FROM get_product_category_stats({CategoryId}, {TenantId})"; + public FormattableString ToSql() + { + return $"SELECT * FROM get_product_category_stats({CategoryId}, {TenantId})"; + } } - - - diff --git a/src/Modules/ProductCatalog/ValueObjects/Price.cs b/src/Modules/ProductCatalog/ValueObjects/Price.cs index 5b3b81de..e35792fb 100644 --- a/src/Modules/ProductCatalog/ValueObjects/Price.cs +++ b/src/Modules/ProductCatalog/ValueObjects/Price.cs @@ -1,18 +1,23 @@ using ErrorOr; -using ProductCatalog.Common.Errors; namespace ProductCatalog.ValueObjects; /// -/// Value object representing a product price. Must be non-negative. +/// Value object representing a product price. Must be non-negative. /// public readonly record struct Price { + private Price(decimal value) + { + Value = value; + } + public decimal Value { get; } - private Price(decimal value) => Value = value; + /// Represents a zero price (e.g. free products). + public static Price Zero => new(0); - /// Creates a after validating that is non-negative. + /// Creates a after validating that is non-negative. public static ErrorOr Create(decimal value) { if (value < 0) @@ -22,11 +27,13 @@ public static ErrorOr Create(decimal value) } /// Factory method for EF Core use only. Bypasses validation as values come from persistence. - public static Price FromPersistence(decimal value) => new(value); - - /// Represents a zero price (e.g. free products). - public static Price Zero => new(0); + public static Price FromPersistence(decimal value) + { + return new Price(value); + } - public static implicit operator decimal(Price price) => price.Value; + public static implicit operator decimal(Price price) + { + return price.Value; + } } - diff --git a/src/Modules/Reviews/Common/Errors/DomainErrors.cs b/src/Modules/Reviews/Common/Errors/DomainErrors.cs index d9c20bc2..e5f8e3d0 100644 --- a/src/Modules/Reviews/Common/Errors/DomainErrors.cs +++ b/src/Modules/Reviews/Common/Errors/DomainErrors.cs @@ -6,22 +6,28 @@ public static class DomainErrors { public static class Reviews { - public static Error NotFound(Guid id) => - Error.NotFound( - code: ErrorCatalog.Reviews.ReviewNotFound, - description: $"Review with id '{id}' not found." + public static Error NotFound(Guid id) + { + return Error.NotFound( + ErrorCatalog.Reviews.ReviewNotFound, + $"Review with id '{id}' not found." ); + } - public static Error ProductNotFoundForReview(Guid productId) => - Error.NotFound( - code: ErrorCatalog.Reviews.ProductNotFoundForReview, - description: $"Product with id '{productId}' not found." + public static Error ProductNotFoundForReview(Guid productId) + { + return Error.NotFound( + ErrorCatalog.Reviews.ProductNotFoundForReview, + $"Product with id '{productId}' not found." ); + } - public static Error ForbiddenOwnReviewsOnly() => - Error.Forbidden( - code: SharedKernel.Application.Errors.ErrorCatalog.Auth.Forbidden, - description: ErrorCatalog.Reviews.ForbiddenOwnReviewsOnlyMessage + public static Error ForbiddenOwnReviewsOnly() + { + return Error.Forbidden( + SharedKernel.Application.Errors.ErrorCatalog.Auth.Forbidden, + ErrorCatalog.Reviews.ForbiddenOwnReviewsOnlyMessage ); + } } } diff --git a/src/Modules/Reviews/Domain/ProductReviewMappings.cs b/src/Modules/Reviews/Domain/ProductReviewMappings.cs index 3d557ef1..11af330b 100644 --- a/src/Modules/Reviews/Domain/ProductReviewMappings.cs +++ b/src/Modules/Reviews/Domain/ProductReviewMappings.cs @@ -4,14 +4,15 @@ namespace Reviews.Domain; /// -/// Provides mapping utilities between product review domain entities and their response DTOs. -/// The compiled projection is shared across specifications and in-memory conversions. +/// 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. + /// 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( @@ -26,7 +27,12 @@ public static class ProductReviewMappings private static readonly Func CompiledProjection = Projection.Compile(); - /// Maps a to a using the compiled projection. - public static ProductReviewResponse ToResponse(this ProductReviewEntity review) => - CompiledProjection(review); + /// + /// Maps a to a using the compiled + /// projection. + /// + public static ProductReviewResponse ToResponse(this ProductReviewEntity review) + { + return CompiledProjection(review); + } } diff --git a/src/Modules/Reviews/Domain/ProductReviewResponse.cs b/src/Modules/Reviews/Domain/ProductReviewResponse.cs index 5d573d54..33389d13 100644 --- a/src/Modules/Reviews/Domain/ProductReviewResponse.cs +++ b/src/Modules/Reviews/Domain/ProductReviewResponse.cs @@ -1,7 +1,7 @@ namespace Reviews.Domain; /// -/// Read model returned by product review queries, representing a single review submitted by a user for a product. +/// Read model returned by product review queries, representing a single review submitted by a user for a product. /// public sealed record ProductReviewResponse( Guid Id, diff --git a/src/Modules/Reviews/Domain/ProductReviewSortFields.cs b/src/Modules/Reviews/Domain/ProductReviewSortFields.cs index 3c3807bc..f65e1f90 100644 --- a/src/Modules/Reviews/Domain/ProductReviewSortFields.cs +++ b/src/Modules/Reviews/Domain/ProductReviewSortFields.cs @@ -3,7 +3,7 @@ namespace Reviews.Domain; /// -/// Defines the allowed sort fields for product review queries and maps them to entity expressions. +/// Defines the allowed sort fields for product review queries and maps them to entity expressions. /// public static class ProductReviewSortFields { @@ -14,12 +14,12 @@ public static class ProductReviewSortFields 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. + /// 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(Rating, r => r.Rating) .Add(CreatedAt, r => r.Audit.CreatedAtUtc) .Default(r => r.Audit.CreatedAtUtc); } diff --git a/src/Modules/Reviews/Domain/Rating.cs b/src/Modules/Reviews/Domain/Rating.cs index 718febf1..44c19de0 100644 --- a/src/Modules/Reviews/Domain/Rating.cs +++ b/src/Modules/Reviews/Domain/Rating.cs @@ -1,18 +1,20 @@ using ErrorOr; -using Reviews.Common.Errors; namespace Reviews.Domain; /// -/// Value object representing a product review rating in the range 1–5 (inclusive). +/// Value object representing a product review rating in the range 1–5 (inclusive). /// public readonly record struct Rating { - public int Value { get; } + private Rating(int value) + { + Value = value; + } - private Rating(int value) => Value = value; + public int Value { get; } - /// Creates a after validating that is between 1 and 5. + /// Creates a after validating that is between 1 and 5. public static ErrorOr Create(int value) { if (value is < 1 or > 5) @@ -22,7 +24,13 @@ public static ErrorOr Create(int value) } /// Factory method for EF Core use only. Bypasses validation as values come from persistence. - public static Rating FromPersistence(int value) => new(value); + public static Rating FromPersistence(int value) + { + return new Rating(value); + } - public static implicit operator int(Rating rating) => rating.Value; + public static implicit operator int(Rating rating) + { + return rating.Value; + } } diff --git a/src/Modules/Reviews/Features/CreateProductReview/CreateProductReviewCommand.cs b/src/Modules/Reviews/Features/CreateProductReview/CreateProductReviewCommand.cs index db916a36..fc8382c4 100644 --- a/src/Modules/Reviews/Features/CreateProductReview/CreateProductReviewCommand.cs +++ b/src/Modules/Reviews/Features/CreateProductReview/CreateProductReviewCommand.cs @@ -1,6 +1,4 @@ using ErrorOr; -using Reviews; -using Reviews.Domain; using SharedKernel.Contracts.Queries.ProductCatalog; using Wolverine; using ProductReviewEntity = Reviews.Domain.ProductReview; @@ -10,16 +8,9 @@ namespace Reviews.Features; /// Creates a new product review for the authenticated user and returns the persisted representation. public sealed record CreateProductReviewCommand(CreateProductReviewRequest Request); -/// Handles . +/// Handles . public sealed class CreateProductReviewCommandHandler { - public sealed record CreateProductReviewState( - Guid ProductId, - Guid UserId, - string? Comment, - Rating Rating - ); - public static async Task<( HandlerContinuation, CreateProductReviewState?, @@ -95,4 +86,11 @@ CancellationToken ct messages.Add(new CacheInvalidationNotification(CacheTags.Categories)); return (review.ToResponse(), messages); } + + public sealed record CreateProductReviewState( + Guid ProductId, + Guid UserId, + string? Comment, + Rating Rating + ); } diff --git a/src/Modules/Reviews/Features/CreateProductReview/CreateProductReviewRequestValidator.cs b/src/Modules/Reviews/Features/CreateProductReview/CreateProductReviewRequestValidator.cs index 73efa939..2f0a6003 100644 --- a/src/Modules/Reviews/Features/CreateProductReview/CreateProductReviewRequestValidator.cs +++ b/src/Modules/Reviews/Features/CreateProductReview/CreateProductReviewRequestValidator.cs @@ -1,9 +1,8 @@ -using SharedKernel.Application.Validation; - namespace Reviews.Features; /// -/// FluentValidation validator for , delegating to data-annotation-based validation rules. +/// FluentValidation validator for , delegating to data-annotation-based +/// validation rules. /// public sealed class CreateProductReviewRequestValidator : DataAnnotationsValidator; diff --git a/src/Modules/Reviews/Features/DeleteProductReview/DeleteProductReviewCommand.cs b/src/Modules/Reviews/Features/DeleteProductReview/DeleteProductReviewCommand.cs index 4ca37590..d6f82720 100644 --- a/src/Modules/Reviews/Features/DeleteProductReview/DeleteProductReviewCommand.cs +++ b/src/Modules/Reviews/Features/DeleteProductReview/DeleteProductReviewCommand.cs @@ -1,5 +1,4 @@ using ErrorOr; -using Reviews; using Wolverine; namespace Reviews.Features; @@ -7,14 +6,10 @@ namespace Reviews.Features; /// Deletes the product review with the given identifier; only the review's author may delete it. public sealed record DeleteProductReviewCommand(Guid Id) : IHasId; -/// Handles . +/// Handles . public sealed class DeleteProductReviewCommandHandler { - public static async Task<( - HandlerContinuation, - Reviews.Domain.ProductReview?, - OutgoingMessages - )> LoadAsync( + public static async Task<(HandlerContinuation, ProductReview?, OutgoingMessages)> LoadAsync( DeleteProductReviewCommand command, IProductReviewRepository reviewRepository, IActorProvider actorProvider, @@ -22,7 +17,7 @@ CancellationToken ct ) { Guid userId = actorProvider.ActorId; - ErrorOr reviewResult = await reviewRepository.GetByIdOrError( + ErrorOr reviewResult = await reviewRepository.GetByIdOrError( command.Id, DomainErrors.Reviews.NotFound(command.Id), ct @@ -34,7 +29,7 @@ CancellationToken ct return (HandlerContinuation.Stop, null, failureMessages); } - Reviews.Domain.ProductReview review = reviewResult.Value; + ProductReview review = reviewResult.Value; if (review.UserId != userId) { @@ -48,7 +43,7 @@ CancellationToken ct public static async Task<(ErrorOr, OutgoingMessages)> HandleAsync( DeleteProductReviewCommand command, - Reviews.Domain.ProductReview review, + ProductReview review, IProductReviewRepository reviewRepository, IUnitOfWork unitOfWork, CancellationToken ct diff --git a/src/Modules/Reviews/Features/GetProductReviewById/GetProductReviewByIdQuery.cs b/src/Modules/Reviews/Features/GetProductReviewById/GetProductReviewByIdQuery.cs index 76ae76c2..3bb93530 100644 --- a/src/Modules/Reviews/Features/GetProductReviewById/GetProductReviewByIdQuery.cs +++ b/src/Modules/Reviews/Features/GetProductReviewById/GetProductReviewByIdQuery.cs @@ -1,12 +1,11 @@ using ErrorOr; -using Reviews.Domain; namespace Reviews.Features; /// Returns a single product review by its unique identifier, or a not-found error if it does not exist. public sealed record GetProductReviewByIdQuery(Guid Id) : IHasId; -/// Handles . +/// Handles . public sealed class GetProductReviewByIdQueryHandler { public static async Task> HandleAsync( diff --git a/src/Modules/Reviews/Features/GetProductReviews/GetProductReviewsByProductIdsQuery.cs b/src/Modules/Reviews/Features/GetProductReviews/GetProductReviewsByProductIdsQuery.cs index 9601c0a6..908e823b 100644 --- a/src/Modules/Reviews/Features/GetProductReviews/GetProductReviewsByProductIdsQuery.cs +++ b/src/Modules/Reviews/Features/GetProductReviews/GetProductReviewsByProductIdsQuery.cs @@ -1,13 +1,11 @@ using ErrorOr; -using Reviews.Domain; -using Reviews.Features; namespace Reviews.Features; /// Returns reviews grouped by product id for a batch of product identifiers. public sealed record GetProductReviewsByProductIdsQuery(IReadOnlyCollection ProductIds); -/// Handles . +/// Handles . public sealed class GetProductReviewsByProductIdsQueryHandler { public static async Task< @@ -19,8 +17,10 @@ CancellationToken ct ) { if (request.ProductIds.Count == 0) + { return (ErrorOr>) new Dictionary(); + } List reviews = await reviewRepository.ListAsync( new ProductReviewByProductIdsSpecification(request.ProductIds), diff --git a/src/Modules/Reviews/Features/GetProductReviews/GetProductReviewsQuery.cs b/src/Modules/Reviews/Features/GetProductReviews/GetProductReviewsQuery.cs index 2ff24ad4..3fdcebae 100644 --- a/src/Modules/Reviews/Features/GetProductReviews/GetProductReviewsQuery.cs +++ b/src/Modules/Reviews/Features/GetProductReviews/GetProductReviewsQuery.cs @@ -1,13 +1,11 @@ using ErrorOr; -using Reviews.Domain; -using Reviews.Features; namespace Reviews.Features; /// Returns a paginated, filtered, and sorted list of product reviews. public sealed record GetProductReviewsQuery(ProductReviewFilter Filter); -/// Handles . +/// Handles . public sealed class GetProductReviewsQueryHandler { public static async Task>> HandleAsync( diff --git a/src/Modules/Reviews/Features/GetProductReviews/ProductReviewFilter.cs b/src/Modules/Reviews/Features/GetProductReviews/ProductReviewFilter.cs index e3bd8492..74d2b6de 100644 --- a/src/Modules/Reviews/Features/GetProductReviews/ProductReviewFilter.cs +++ b/src/Modules/Reviews/Features/GetProductReviews/ProductReviewFilter.cs @@ -1,10 +1,8 @@ -using SharedKernel.Application.Contracts; -using SharedKernel.Application.DTOs; - namespace Reviews.Features; /// -/// Filter parameters for querying product reviews, supporting filtering by product, user, rating range, date range, sorting, and pagination. +/// 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, diff --git a/src/Modules/Reviews/Features/GetProductReviews/ProductReviewFilterCriteria.cs b/src/Modules/Reviews/Features/GetProductReviews/ProductReviewFilterCriteria.cs index 64008507..0de1f2f5 100644 --- a/src/Modules/Reviews/Features/GetProductReviews/ProductReviewFilterCriteria.cs +++ b/src/Modules/Reviews/Features/GetProductReviews/ProductReviewFilterCriteria.cs @@ -4,14 +4,14 @@ namespace Reviews.Features; /// -/// Extension methods that apply criteria to an Ardalis specification builder. -/// Each filter field is applied conditionally, only when a value is present. +/// 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 { /// - /// Appends filter predicates to for each non-null field in , - /// including product id, user id, rating range, and creation date range. + /// Appends filter predicates to for each non-null field in , + /// including product id, user id, rating range, and creation date range. /// internal static void ApplyFilter( this ISpecificationBuilder query, diff --git a/src/Modules/Reviews/Features/GetProductReviews/ProductReviewFilterValidator.cs b/src/Modules/Reviews/Features/GetProductReviews/ProductReviewFilterValidator.cs index 98e0c6e9..c394f729 100644 --- a/src/Modules/Reviews/Features/GetProductReviews/ProductReviewFilterValidator.cs +++ b/src/Modules/Reviews/Features/GetProductReviews/ProductReviewFilterValidator.cs @@ -1,11 +1,10 @@ using FluentValidation; -using SharedKernel.Application.Validation; namespace Reviews.Features; /// -/// FluentValidation validator for . -/// Composes pagination, date-range, sortable, and rating-range validation rules. +/// FluentValidation validator for . +/// Composes pagination, date-range, sortable, and rating-range validation rules. /// public sealed class ProductReviewFilterValidator : AbstractValidator { diff --git a/src/Modules/Reviews/Features/GetProductReviews/ProductReviewSpecification.cs b/src/Modules/Reviews/Features/GetProductReviews/ProductReviewSpecification.cs index d89bda8c..9113e4f7 100644 --- a/src/Modules/Reviews/Features/GetProductReviews/ProductReviewSpecification.cs +++ b/src/Modules/Reviews/Features/GetProductReviews/ProductReviewSpecification.cs @@ -1,17 +1,16 @@ using Ardalis.Specification; -using Reviews.Domain; using ProductReviewEntity = Reviews.Domain.ProductReview; namespace Reviews.Features; /// -/// Ardalis specification for querying a filtered and sorted list of product reviews -/// projected to . +/// 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 . + /// Initialises the specification by applying filter, sort, and projection from . public ProductReviewSpecification(ProductReviewFilter filter) { Query.ApplyFilter(filter); diff --git a/src/Modules/Reviews/Features/ProductReviewsController.cs b/src/Modules/Reviews/Features/ProductReviewsController.cs index c0744876..1bffeed7 100644 --- a/src/Modules/Reviews/Features/ProductReviewsController.cs +++ b/src/Modules/Reviews/Features/ProductReviewsController.cs @@ -17,10 +17,9 @@ public async Task>> GetAll( CancellationToken ct ) { - ErrorOr> result = await bus.InvokeAsync>>( - new GetProductReviewsQuery(filter), - ct - ); + ErrorOr> result = await bus.InvokeAsync< + ErrorOr> + >(new GetProductReviewsQuery(filter), ct); return result.ToActionResult(this); } @@ -29,10 +28,9 @@ CancellationToken ct [OutputCache(PolicyName = CacheTags.Reviews)] public async Task> GetById(Guid id, CancellationToken ct) { - ErrorOr result = await bus.InvokeAsync>( - new GetProductReviewByIdQuery(id), - ct - ); + ErrorOr result = await bus.InvokeAsync< + ErrorOr + >(new GetProductReviewByIdQuery(id), ct); return result.ToActionResult(this); } @@ -44,10 +42,9 @@ public async Task>> GetByProdu CancellationToken ct ) { - ErrorOr> result = await bus.InvokeAsync>>( - new GetProductReviewsByProductIdQuery(productId), - ct - ); + ErrorOr> result = await bus.InvokeAsync< + ErrorOr> + >(new GetProductReviewsByProductIdQuery(productId), ct); return result.ToActionResult(this); } @@ -58,10 +55,9 @@ public async Task> Create( CancellationToken ct ) { - ErrorOr result = await bus.InvokeAsync>( - new CreateProductReviewCommand(request), - ct - ); + ErrorOr result = await bus.InvokeAsync< + ErrorOr + >(new CreateProductReviewCommand(request), ct); return result.ToCreatedResult(this, v => new { id = v.Id, version = this.GetApiVersion() }); } @@ -76,9 +72,3 @@ public async Task Delete(Guid id, CancellationToken ct) return result.ToNoContentResult(this); } } - - - - - - diff --git a/src/Modules/Reviews/Persistence/ProductReviewConfiguration.cs b/src/Modules/Reviews/Persistence/ProductReviewConfiguration.cs index 29a06819..ea7bb13c 100644 --- a/src/Modules/Reviews/Persistence/ProductReviewConfiguration.cs +++ b/src/Modules/Reviews/Persistence/ProductReviewConfiguration.cs @@ -1,10 +1,9 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using Reviews.Domain; namespace Reviews.Persistence; -/// EF Core configuration for the entity. +/// EF Core configuration for the entity. public sealed class ProductReviewConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) diff --git a/src/Modules/Reviews/Persistence/ReviewsDbContext.cs b/src/Modules/Reviews/Persistence/ReviewsDbContext.cs index 820270f7..d42a15b7 100644 --- a/src/Modules/Reviews/Persistence/ReviewsDbContext.cs +++ b/src/Modules/Reviews/Persistence/ReviewsDbContext.cs @@ -1,6 +1,4 @@ using Microsoft.EntityFrameworkCore; -using Reviews.Domain; -using SharedKernel.Application.Context; using SharedKernel.Infrastructure.Auditing; using SharedKernel.Infrastructure.EntityNormalization; using SharedKernel.Infrastructure.SoftDelete; diff --git a/src/Modules/Reviews/ReviewsDbMarker.cs b/src/Modules/Reviews/ReviewsDbMarker.cs index 66a2adf3..abb1ac7d 100644 --- a/src/Modules/Reviews/ReviewsDbMarker.cs +++ b/src/Modules/Reviews/ReviewsDbMarker.cs @@ -1,8 +1,8 @@ namespace Reviews; /// -/// Domain-layer marker type identifying the Reviews module's persistence boundary. -/// Used as the type parameter for -/// so that handlers can request the correct unit of work without referencing the Infrastructure layer. +/// Domain-layer marker type identifying the Reviews module's persistence boundary. +/// Used as the type parameter for +/// so that handlers can request the correct unit of work without referencing the Infrastructure layer. /// public abstract class ReviewsDbMarker; diff --git a/src/Modules/Reviews/ReviewsModule.cs b/src/Modules/Reviews/ReviewsModule.cs index 8c912d98..19ee0e66 100644 --- a/src/Modules/Reviews/ReviewsModule.cs +++ b/src/Modules/Reviews/ReviewsModule.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Reviews.Features; namespace Reviews; diff --git a/src/Modules/Webhooks/Contracts/IWebhookEventHandler.cs b/src/Modules/Webhooks/Contracts/IWebhookEventHandler.cs index a250aff7..763b2736 100644 --- a/src/Modules/Webhooks/Contracts/IWebhookEventHandler.cs +++ b/src/Modules/Webhooks/Contracts/IWebhookEventHandler.cs @@ -1,17 +1,14 @@ namespace Webhooks.Contracts; /// -/// Strategy contract for processing a specific inbound webhook event type. -/// Implementations are discovered by type and selected at runtime based on the they declare. +/// Strategy contract for processing a specific inbound webhook event type. +/// Implementations are discovered by type and selected at runtime based on the they declare. /// public interface IWebhookEventHandler { /// Gets the event-type string this handler is responsible for (e.g. "order.created"). - string EventType { get; } + public string EventType { get; } - /// Processes the inbound for this event type. - Task HandleAsync(WebhookPayload payload, CancellationToken ct = default); + /// Processes the inbound for this event type. + public Task HandleAsync(WebhookPayload payload, CancellationToken ct = default); } - - - diff --git a/src/Modules/Webhooks/Contracts/IWebhookPayloadSigner.cs b/src/Modules/Webhooks/Contracts/IWebhookPayloadSigner.cs index 2a5982d1..f7edf1ee 100644 --- a/src/Modules/Webhooks/Contracts/IWebhookPayloadSigner.cs +++ b/src/Modules/Webhooks/Contracts/IWebhookPayloadSigner.cs @@ -1,18 +1,15 @@ namespace Webhooks.Contracts; /// -/// Application-layer abstraction for signing outgoing webhook payloads so that receivers can -/// verify authenticity. +/// Application-layer abstraction for signing outgoing webhook payloads so that receivers can +/// verify authenticity. /// public interface IWebhookPayloadSigner { - WebhookSignatureResult Sign(string payload); + public WebhookSignatureResult Sign(string payload); } /// -/// Value object containing the computed HMAC signature and the timestamp used as the signing input. +/// Value object containing the computed HMAC signature and the timestamp used as the signing input. /// public sealed record WebhookSignatureResult(string Signature, string Timestamp); - - - diff --git a/src/Modules/Webhooks/Contracts/IWebhookPayloadValidator.cs b/src/Modules/Webhooks/Contracts/IWebhookPayloadValidator.cs index a9861513..d0d76a7c 100644 --- a/src/Modules/Webhooks/Contracts/IWebhookPayloadValidator.cs +++ b/src/Modules/Webhooks/Contracts/IWebhookPayloadValidator.cs @@ -1,12 +1,9 @@ namespace Webhooks.Contracts; /// -/// Application-layer abstraction for verifying the authenticity of inbound webhook payloads. +/// Application-layer abstraction for verifying the authenticity of inbound webhook payloads. /// public interface IWebhookPayloadValidator { - bool IsValid(string payload, string signature, string timestamp); + public bool IsValid(string payload, string signature, string timestamp); } - - - diff --git a/src/Modules/Webhooks/Contracts/IWebhookProcessingQueue.cs b/src/Modules/Webhooks/Contracts/IWebhookProcessingQueue.cs index 52633f64..d6a6a376 100644 --- a/src/Modules/Webhooks/Contracts/IWebhookProcessingQueue.cs +++ b/src/Modules/Webhooks/Contracts/IWebhookProcessingQueue.cs @@ -1,15 +1,13 @@ using SharedKernel.Application.BackgroundJobs; + namespace Webhooks.Contracts; /// -/// Write-side contract for enqueuing inbound webhook payloads awaiting processing. +/// Write-side contract for enqueuing inbound webhook payloads awaiting processing. /// public interface IWebhookProcessingQueue : IQueue; /// -/// Read-side contract for consuming inbound webhook payloads from the processing queue. +/// Read-side contract for consuming inbound webhook payloads from the processing queue. /// public interface IWebhookQueueReader : IQueueReader; - - - diff --git a/src/Modules/Webhooks/Contracts/WebhookConstants.cs b/src/Modules/Webhooks/Contracts/WebhookConstants.cs index d53754a2..56d80b81 100644 --- a/src/Modules/Webhooks/Contracts/WebhookConstants.cs +++ b/src/Modules/Webhooks/Contracts/WebhookConstants.cs @@ -1,7 +1,7 @@ namespace Webhooks.Contracts; /// -/// Centralises header names and HTTP client identifiers used by the webhook infrastructure. +/// Centralises header names and HTTP client identifiers used by the webhook infrastructure. /// public static class WebhookConstants { @@ -10,6 +10,3 @@ public static class WebhookConstants public const string OutgoingHttpClientName = "OutgoingWebhook"; public const string WildcardEventType = "*"; } - - - diff --git a/src/Modules/Webhooks/Contracts/WebhookOptions.cs b/src/Modules/Webhooks/Contracts/WebhookOptions.cs index c9a61ba7..38346ffb 100644 --- a/src/Modules/Webhooks/Contracts/WebhookOptions.cs +++ b/src/Modules/Webhooks/Contracts/WebhookOptions.cs @@ -4,8 +4,8 @@ namespace Webhooks.Contracts; /// -/// Configuration for incoming webhook verification, including the shared HMAC secret -/// and the tolerance window used to reject replayed requests. +/// Configuration for incoming webhook verification, including the shared HMAC secret +/// and the tolerance window used to reject replayed requests. /// public sealed class WebhookOptions { @@ -18,6 +18,3 @@ public sealed class WebhookOptions [Range(0, int.MaxValue)] public int TimestampToleranceSeconds { get; set; } = 300; // 5 minutes } - - - diff --git a/src/Modules/Webhooks/Contracts/WebhookPayload.cs b/src/Modules/Webhooks/Contracts/WebhookPayload.cs index 932ebd9a..ded3fe2d 100644 --- a/src/Modules/Webhooks/Contracts/WebhookPayload.cs +++ b/src/Modules/Webhooks/Contracts/WebhookPayload.cs @@ -3,9 +3,7 @@ namespace Webhooks.Contracts; /// -/// Represents an incoming webhook payload with a discriminated event type, a unique event ID for deduplication, and a raw JSON data element. +/// Represents an incoming webhook payload with a discriminated event type, a unique event ID for deduplication, and a +/// raw JSON data element. /// public sealed record WebhookPayload(string EventType, string EventId, JsonElement Data); - - - diff --git a/src/Modules/Webhooks/Features/SendWebhookCallback/SendWebhookCallbackHandler.cs b/src/Modules/Webhooks/Features/SendWebhookCallback/SendWebhookCallbackHandler.cs index 600ce51f..c2f7e09e 100644 --- a/src/Modules/Webhooks/Features/SendWebhookCallback/SendWebhookCallbackHandler.cs +++ b/src/Modules/Webhooks/Features/SendWebhookCallback/SendWebhookCallbackHandler.cs @@ -1,13 +1,11 @@ using SharedKernel.Contracts.Commands.Webhooks; using Webhooks.Contracts; -using Webhooks.Security; -using Webhooks.Services; namespace Webhooks.Features.SendWebhookCallback; /// -/// Wolverine handler that processes from the BackgroundJobs module -/// by enqueuing the payload into the outgoing webhook queue for delivery. +/// Wolverine handler that processes from the BackgroundJobs module +/// by enqueuing the payload into the outgoing webhook queue for delivery. /// public sealed class SendWebhookCallbackHandler { diff --git a/src/Modules/Webhooks/Logging/WebhooksInfrastructureLogs.cs b/src/Modules/Webhooks/Logging/WebhooksInfrastructureLogs.cs index 343250f5..96231e18 100644 --- a/src/Modules/Webhooks/Logging/WebhooksInfrastructureLogs.cs +++ b/src/Modules/Webhooks/Logging/WebhooksInfrastructureLogs.cs @@ -4,7 +4,7 @@ namespace Webhooks.Logging; /// -/// Source-generated logger extension methods for Webhooks infrastructure diagnostics. +/// Source-generated logger extension methods for Webhooks infrastructure diagnostics. /// internal static partial class WebhooksInfrastructureLogs { diff --git a/src/Modules/Webhooks/Security/HmacHelper.cs b/src/Modules/Webhooks/Security/HmacHelper.cs index e0bbf812..cfee32a3 100644 --- a/src/Modules/Webhooks/Security/HmacHelper.cs +++ b/src/Modules/Webhooks/Security/HmacHelper.cs @@ -4,11 +4,14 @@ namespace Webhooks.Security; /// -/// Internal helper that computes the HMAC-SHA256 signature over a timestamp-prefixed payload. +/// Internal helper that computes the HMAC-SHA256 signature over a timestamp-prefixed payload. /// internal static class HmacHelper { - public static byte[] GetKeyBytes(string secret) => Encoding.UTF8.GetBytes(secret); + public static byte[] GetKeyBytes(string secret) + { + return Encoding.UTF8.GetBytes(secret); + } public static byte[] ComputeHash(byte[] keyBytes, string timestamp, string payload) { @@ -17,7 +20,3 @@ public static byte[] ComputeHash(byte[] keyBytes, string timestamp, string paylo return HMACSHA256.HashData(keyBytes, contentBytes); } } - - - - diff --git a/src/Modules/Webhooks/Security/HmacWebhookPayloadSigner.cs b/src/Modules/Webhooks/Security/HmacWebhookPayloadSigner.cs index b013399c..ff39e9c2 100644 --- a/src/Modules/Webhooks/Security/HmacWebhookPayloadSigner.cs +++ b/src/Modules/Webhooks/Security/HmacWebhookPayloadSigner.cs @@ -1,7 +1,5 @@ using Microsoft.Extensions.Options; using Webhooks.Contracts; -using Webhooks.Services; -using Webhooks.Security; namespace Webhooks.Security; @@ -24,7 +22,3 @@ public WebhookSignatureResult Sign(string payload) return new WebhookSignatureResult(signature, timestamp); } } - - - - diff --git a/src/Modules/Webhooks/Security/HmacWebhookPayloadValidator.cs b/src/Modules/Webhooks/Security/HmacWebhookPayloadValidator.cs index 0a4ca6cb..6dcef6db 100644 --- a/src/Modules/Webhooks/Security/HmacWebhookPayloadValidator.cs +++ b/src/Modules/Webhooks/Security/HmacWebhookPayloadValidator.cs @@ -1,16 +1,14 @@ using System.Security.Cryptography; using Microsoft.Extensions.Options; using Webhooks.Contracts; -using Webhooks.Services; -using Webhooks.Security; namespace Webhooks.Security; public sealed class HmacWebhookPayloadValidator : IWebhookPayloadValidator { private readonly byte[] _keyBytes; - private readonly int _toleranceSeconds; private readonly TimeProvider _timeProvider; + private readonly int _toleranceSeconds; public HmacWebhookPayloadValidator(IOptions options, TimeProvider timeProvider) { @@ -44,7 +42,3 @@ public bool IsValid(string payload, string signature, string timestamp) return CryptographicOperations.FixedTimeEquals(hashBytes, signatureBytes); } } - - - - diff --git a/src/Modules/Webhooks/Security/ValidateWebhookSignatureAttribute.cs b/src/Modules/Webhooks/Security/ValidateWebhookSignatureAttribute.cs index 8695e990..da7d78f9 100644 --- a/src/Modules/Webhooks/Security/ValidateWebhookSignatureAttribute.cs +++ b/src/Modules/Webhooks/Security/ValidateWebhookSignatureAttribute.cs @@ -1,11 +1,7 @@ namespace Webhooks.Security; /// -/// Marks an action method as requiring webhook HMAC signature validation. +/// Marks an action method as requiring webhook HMAC signature validation. /// [AttributeUsage(AttributeTargets.Method)] public sealed class ValidateWebhookSignatureAttribute : Attribute; - - - - diff --git a/src/Modules/Webhooks/Security/WebhookSignatureResourceFilter.cs b/src/Modules/Webhooks/Security/WebhookSignatureResourceFilter.cs index 881359b4..c1f0924f 100644 --- a/src/Modules/Webhooks/Security/WebhookSignatureResourceFilter.cs +++ b/src/Modules/Webhooks/Security/WebhookSignatureResourceFilter.cs @@ -4,15 +4,13 @@ using Microsoft.Extensions.Primitives; using SharedKernel.Application.Errors; using Webhooks.Contracts; -using Webhooks.Services; -using Webhooks.Security; namespace Webhooks.Security; public sealed class WebhookSignatureResourceFilter : IAsyncResourceFilter { - private readonly IWebhookPayloadValidator _validator; private readonly IProblemDetailsService _problemDetailsService; + private readonly IWebhookPayloadValidator _validator; public WebhookSignatureResourceFilter( IWebhookPayloadValidator validator, @@ -91,7 +89,3 @@ private async Task WriteUnauthorizedAsync(ResourceExecutingContext context, stri : new ObjectResult(pd) { StatusCode = StatusCodes.Status401Unauthorized }; } } - - - - diff --git a/src/Modules/Webhooks/Services/ChannelWebhookQueue.cs b/src/Modules/Webhooks/Services/ChannelWebhookQueue.cs index 86c7872a..1e68f6ff 100644 --- a/src/Modules/Webhooks/Services/ChannelWebhookQueue.cs +++ b/src/Modules/Webhooks/Services/ChannelWebhookQueue.cs @@ -1,17 +1,15 @@ using SharedKernel.Infrastructure.BackgroundJobs.Services; using Webhooks.Contracts; -using Webhooks.Services; -using Webhooks.Security; namespace Webhooks.Services; public sealed class ChannelWebhookQueue - : BoundedChannelQueue, IWebhookProcessingQueue, IWebhookQueueReader + : BoundedChannelQueue, + IWebhookProcessingQueue, + IWebhookQueueReader { private const int DefaultCapacity = 500; - public ChannelWebhookQueue() : base(DefaultCapacity) { } -} - - - + public ChannelWebhookQueue() + : base(DefaultCapacity) { } +} diff --git a/src/Modules/Webhooks/Services/LoggingWebhookEventHandler.cs b/src/Modules/Webhooks/Services/LoggingWebhookEventHandler.cs index 3b56a146..f89f1200 100644 --- a/src/Modules/Webhooks/Services/LoggingWebhookEventHandler.cs +++ b/src/Modules/Webhooks/Services/LoggingWebhookEventHandler.cs @@ -1,7 +1,5 @@ using Microsoft.Extensions.Logging; using Webhooks.Contracts; -using Webhooks.Services; -using Webhooks.Security; using Webhooks.Logging; namespace Webhooks.Services; @@ -23,7 +21,3 @@ public Task HandleAsync(WebhookPayload payload, CancellationToken ct = default) return Task.CompletedTask; } } - - - - diff --git a/src/Modules/Webhooks/Services/OutgoingWebhookBackgroundService.cs b/src/Modules/Webhooks/Services/OutgoingWebhookBackgroundService.cs index b0252134..aadce8b3 100644 --- a/src/Modules/Webhooks/Services/OutgoingWebhookBackgroundService.cs +++ b/src/Modules/Webhooks/Services/OutgoingWebhookBackgroundService.cs @@ -5,8 +5,6 @@ using Microsoft.Extensions.Logging; using SharedKernel.Infrastructure.BackgroundJobs.Services; using Webhooks.Contracts; -using Webhooks.Services; -using Webhooks.Security; using Webhooks.Logging; namespace Webhooks.Services; @@ -21,8 +19,8 @@ public sealed class OutgoingWebhookBackgroundService }; private readonly IHttpClientFactory _httpClientFactory; - private readonly IWebhookPayloadSigner _signer; private readonly ILogger _logger; + private readonly IWebhookPayloadSigner _signer; public OutgoingWebhookBackgroundService( IOutgoingWebhookQueueReader queue, @@ -77,24 +75,30 @@ CancellationToken ct private static async Task ValidateCallbackUrlAsync(string callbackUrl, CancellationToken ct) { if (!Uri.TryCreate(callbackUrl, UriKind.Absolute, out Uri? uri)) + { throw new InvalidOperationException( $"Callback URL '{callbackUrl}' is not a valid absolute URI." ); + } if (!AllowedSchemes.Contains(uri.Scheme)) + { throw new InvalidOperationException( $"Callback URL scheme '{uri.Scheme}' is not allowed. Only HTTP and HTTPS are permitted." ); + } IPAddress[] addresses = await Dns.GetHostAddressesAsync(uri.DnsSafeHost, ct); foreach (IPAddress address in addresses) { if (IsProhibitedAddress(address)) + { throw new InvalidOperationException( $"Callback URL '{uri.Host}' resolves to a prohibited address ({address}). " + "Requests to loopback, private, and link-local networks are not allowed." ); + } } } @@ -126,7 +130,3 @@ private static bool IsProhibitedAddress(IPAddress address) return false; } } - - - - diff --git a/src/Modules/Webhooks/WebhooksModule.cs b/src/Modules/Webhooks/WebhooksModule.cs index 70c9b1bc..d00d4b32 100644 --- a/src/Modules/Webhooks/WebhooksModule.cs +++ b/src/Modules/Webhooks/WebhooksModule.cs @@ -2,10 +2,8 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Webhooks.Contracts; using Webhooks.Features; using Webhooks.Security; -using Webhooks.Services; namespace Webhooks; diff --git a/src/SharedKernel/Application/BackgroundJobs/IQueue.cs b/src/SharedKernel/Application/BackgroundJobs/IQueue.cs index 98058fad..0e1c4a5f 100644 --- a/src/SharedKernel/Application/BackgroundJobs/IQueue.cs +++ b/src/SharedKernel/Application/BackgroundJobs/IQueue.cs @@ -1,14 +1,14 @@ namespace SharedKernel.Application.BackgroundJobs; /// -/// 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). +/// 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. + /// Adds to the queue, waiting asynchronously if the queue is full. /// - ValueTask EnqueueAsync(T item, CancellationToken ct = default); + public ValueTask EnqueueAsync(T item, CancellationToken ct = default); } diff --git a/src/SharedKernel/Application/BackgroundJobs/IRecurringBackgroundJobRegistration.cs b/src/SharedKernel/Application/BackgroundJobs/IRecurringBackgroundJobRegistration.cs index b83462b3..23fbab61 100644 --- a/src/SharedKernel/Application/BackgroundJobs/IRecurringBackgroundJobRegistration.cs +++ b/src/SharedKernel/Application/BackgroundJobs/IRecurringBackgroundJobRegistration.cs @@ -1,14 +1,14 @@ namespace SharedKernel.Application.BackgroundJobs; /// -/// A contract implemented per-job to provide the metadata and scheduling intent required -/// by the TickerQ scheduler database. +/// A contract implemented per-job to provide the metadata and scheduling intent required +/// by the TickerQ scheduler database. /// public interface IRecurringBackgroundJobRegistration { /// - /// Constructs the raw struct to be persisted. - /// Typically reads the required interval and enablement from configuration bound elsewhere. + /// Constructs the raw struct to be persisted. + /// Typically reads the required interval and enablement from configuration bound elsewhere. /// - RecurringBackgroundJobDefinition Build(IServiceProvider serviceProvider); + public RecurringBackgroundJobDefinition Build(IServiceProvider serviceProvider); } diff --git a/src/SharedKernel/Application/BackgroundJobs/RecurringBackgroundJobDefinition.cs b/src/SharedKernel/Application/BackgroundJobs/RecurringBackgroundJobDefinition.cs index 55d38f7b..e1dc9a0d 100644 --- a/src/SharedKernel/Application/BackgroundJobs/RecurringBackgroundJobDefinition.cs +++ b/src/SharedKernel/Application/BackgroundJobs/RecurringBackgroundJobDefinition.cs @@ -3,9 +3,9 @@ namespace SharedKernel.Application.BackgroundJobs; /// -/// Immutable descriptor for a recurring background job passed from the Application layer to the -/// Infrastructure scheduler (e.g. Hangfire). Each -/// produces one instance of this record. +/// Immutable descriptor for a recurring background job passed from the Application layer to the +/// Infrastructure scheduler (e.g. Hangfire). 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 (e.g. Hangfire job method name). diff --git a/src/SharedKernel/Application/Batch/BatchFailureContext.cs b/src/SharedKernel/Application/Batch/BatchFailureContext.cs index b48dbf39..19825bc1 100644 --- a/src/SharedKernel/Application/Batch/BatchFailureContext.cs +++ b/src/SharedKernel/Application/Batch/BatchFailureContext.cs @@ -3,14 +3,17 @@ namespace SharedKernel.Application.Batch; /// -/// Holds batch items and collects per-item failures across validation rules. +/// Holds batch items and collects per-item failures across validation rules. /// public sealed class BatchFailureContext { - private readonly List _failures = []; private readonly HashSet _failedIndices = []; + private readonly List _failures = []; - public BatchFailureContext(IReadOnlyList items) => Items = items; + public BatchFailureContext(IReadOnlyList items) + { + Items = items; + } public IReadOnlyList Items { get; } public bool HasFailures => _failures.Count > 0; @@ -22,7 +25,10 @@ public void AddFailure(int index, Guid? id, IReadOnlyList errors) _failedIndices.Add(index); } - public void AddFailure(int index, Guid? id, string error) => AddFailure(index, id, [error]); + public void AddFailure(int index, Guid? id, string error) + { + AddFailure(index, id, [error]); + } public void AddFailures(IEnumerable failures) { @@ -33,13 +39,19 @@ public void AddFailures(IEnumerable failures) } } - public bool IsFailed(int index) => _failedIndices.Contains(index); + public bool IsFailed(int index) + { + return _failedIndices.Contains(index); + } public async Task ApplyRulesAsync(CancellationToken ct, params IBatchRule[] rules) { - for (var i = 0; i < rules.Length; i++) + for (int i = 0; i < rules.Length; i++) await rules[i].ApplyAsync(this, ct); } - public BatchResponse ToFailureResponse() => new(_failures, 0, _failures.Count); + public BatchResponse ToFailureResponse() + { + return new BatchResponse(_failures, 0, _failures.Count); + } } diff --git a/src/SharedKernel/Application/Batch/EntityLookup.cs b/src/SharedKernel/Application/Batch/EntityLookup.cs index b21756f1..a72f650e 100644 --- a/src/SharedKernel/Application/Batch/EntityLookup.cs +++ b/src/SharedKernel/Application/Batch/EntityLookup.cs @@ -1,7 +1,7 @@ namespace SharedKernel.Application.Batch; /// -/// Wraps a dictionary of loaded entities for passing between Wolverine compound-handler -/// LoadAsync and HandleAsync steps with unambiguous type matching. +/// 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/Application/Batch/IBatchRule.cs b/src/SharedKernel/Application/Batch/IBatchRule.cs index 6f8726c2..adfe83c7 100644 --- a/src/SharedKernel/Application/Batch/IBatchRule.cs +++ b/src/SharedKernel/Application/Batch/IBatchRule.cs @@ -2,5 +2,5 @@ namespace SharedKernel.Application.Batch; public interface IBatchRule { - Task ApplyAsync(BatchFailureContext context, CancellationToken ct); + public Task ApplyAsync(BatchFailureContext context, CancellationToken ct); } diff --git a/src/SharedKernel/Application/Batch/Rules/FluentValidationBatchRule.cs b/src/SharedKernel/Application/Batch/Rules/FluentValidationBatchRule.cs index a3ebd055..a1463bd2 100644 --- a/src/SharedKernel/Application/Batch/Rules/FluentValidationBatchRule.cs +++ b/src/SharedKernel/Application/Batch/Rules/FluentValidationBatchRule.cs @@ -11,7 +11,7 @@ public sealed class FluentValidationBatchRule(IValidator validator public async Task ApplyAsync(BatchFailureContext context, CancellationToken ct) { - for (var i = 0; i < context.Items.Count; i++) + for (int i = 0; i < context.Items.Count; i++) { if (context.IsFailed(i)) continue; diff --git a/src/SharedKernel/Application/Batch/Rules/MarkMissingByIdBatchRule.cs b/src/SharedKernel/Application/Batch/Rules/MarkMissingByIdBatchRule.cs index 571c04c5..4d565348 100644 --- a/src/SharedKernel/Application/Batch/Rules/MarkMissingByIdBatchRule.cs +++ b/src/SharedKernel/Application/Batch/Rules/MarkMissingByIdBatchRule.cs @@ -8,7 +8,7 @@ string notFoundMessageTemplate { public Task ApplyAsync(BatchFailureContext context, CancellationToken ct) { - for (var i = 0; i < context.Items.Count; i++) + for (int i = 0; i < context.Items.Count; i++) { if (context.IsFailed(i)) continue; diff --git a/src/SharedKernel/Application/Context/IActorProvider.cs b/src/SharedKernel/Application/Context/IActorProvider.cs index 0f7424c8..38a3e387 100644 --- a/src/SharedKernel/Application/Context/IActorProvider.cs +++ b/src/SharedKernel/Application/Context/IActorProvider.cs @@ -1,11 +1,11 @@ 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. +/// 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; } + public Guid ActorId { get; } } diff --git a/src/SharedKernel/Application/Contracts/IDateRangeFilter.cs b/src/SharedKernel/Application/Contracts/IDateRangeFilter.cs index bd0bfe4e..0efb201a 100644 --- a/src/SharedKernel/Application/Contracts/IDateRangeFilter.cs +++ b/src/SharedKernel/Application/Contracts/IDateRangeFilter.cs @@ -1,14 +1,14 @@ 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. +/// 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; } + public DateTime? CreatedFrom { get; } /// Inclusive upper bound of the creation-date filter; null means no upper bound. - DateTime? CreatedTo { get; } + public DateTime? CreatedTo { get; } } diff --git a/src/SharedKernel/Application/Contracts/IIdempotencyStore.cs b/src/SharedKernel/Application/Contracts/IIdempotencyStore.cs index 893dc86f..b1b0e74e 100644 --- a/src/SharedKernel/Application/Contracts/IIdempotencyStore.cs +++ b/src/SharedKernel/Application/Contracts/IIdempotencyStore.cs @@ -1,29 +1,29 @@ 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. +/// 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. + /// Retrieves a previously cached response entry for , + /// or returns null if no entry exists. /// - Task TryGetAsync(string key, CancellationToken ct = default); + public Task TryGetAsync(string key, CancellationToken ct = default); /// - /// Atomically checks if the key exists and acquires a lock if not. - /// Returns a non-null lock token if the lock was acquired, or null if the key was already present. - /// The token must be passed to to release ownership. + /// Atomically checks if the key exists and acquires a lock if not. + /// Returns a non-null lock token if the lock was acquired, or null if the key was already present. + /// The token must be passed to to release ownership. /// - Task TryAcquireAsync(string key, TimeSpan ttl, CancellationToken ct = default); + public 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. + /// Stores under with the given , + /// replacing the in-flight lock entry so subsequent duplicates receive the cached response. /// - Task SetAsync( + public Task SetAsync( string key, IdempotencyCacheEntry entry, TimeSpan ttl, @@ -31,14 +31,14 @@ Task SetAsync( ); /// - /// Releases the lock for the given key so a retry with the same key can proceed. - /// Only releases if the supplied still matches the stored value. + /// Releases the lock for the given key so a retry with the same key can proceed. + /// Only releases if the supplied still matches the stored value. /// - Task ReleaseAsync(string key, string lockToken, CancellationToken ct = default); + public Task ReleaseAsync(string key, string lockToken, CancellationToken ct = default); } /// -/// Cached HTTP response snapshot stored by the idempotency middleware for replay on duplicate requests. +/// Cached HTTP response snapshot stored by the idempotency middleware for replay on duplicate requests. /// public sealed record IdempotencyCacheEntry( int StatusCode, diff --git a/src/SharedKernel/Application/Contracts/ISortableFilter.cs b/src/SharedKernel/Application/Contracts/ISortableFilter.cs index 49f050ce..e7e66708 100644 --- a/src/SharedKernel/Application/Contracts/ISortableFilter.cs +++ b/src/SharedKernel/Application/Contracts/ISortableFilter.cs @@ -1,14 +1,14 @@ 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. +/// 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; } + public string? SortBy { get; } /// Sort direction, typically "asc" or "desc"; null applies default direction. - string? SortDirection { get; } + public string? SortDirection { get; } } diff --git a/src/SharedKernel/Application/DTOs/BatchDeleteRequest.cs b/src/SharedKernel/Application/DTOs/BatchDeleteRequest.cs index 521a03a0..9c0108bc 100644 --- a/src/SharedKernel/Application/DTOs/BatchDeleteRequest.cs +++ b/src/SharedKernel/Application/DTOs/BatchDeleteRequest.cs @@ -3,7 +3,7 @@ namespace SharedKernel.Application.DTOs; /// -/// Carries a list of entity identifiers to be deleted in a single batch operation; accepts between 1 and 100 IDs. +/// 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.")] diff --git a/src/SharedKernel/Application/DTOs/IPagedItems.cs b/src/SharedKernel/Application/DTOs/IPagedItems.cs index f087e177..20b942eb 100644 --- a/src/SharedKernel/Application/DTOs/IPagedItems.cs +++ b/src/SharedKernel/Application/DTOs/IPagedItems.cs @@ -3,11 +3,11 @@ namespace SharedKernel.Application.DTOs; /// -/// Marks a query response as wrapping a , providing a consistent -/// shape for all paginated query results across the Application layer. +/// 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; } + public PagedResponse Page { get; } } diff --git a/src/SharedKernel/Application/DTOs/PaginationFilter.cs b/src/SharedKernel/Application/DTOs/PaginationFilter.cs index da5c2a80..15aaf0cf 100644 --- a/src/SharedKernel/Application/DTOs/PaginationFilter.cs +++ b/src/SharedKernel/Application/DTOs/PaginationFilter.cs @@ -3,8 +3,8 @@ 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. +/// 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.")] diff --git a/src/SharedKernel/Application/Errors/ErrorCatalog.cs b/src/SharedKernel/Application/Errors/ErrorCatalog.cs index b92a26e2..209dc45e 100644 --- a/src/SharedKernel/Application/Errors/ErrorCatalog.cs +++ b/src/SharedKernel/Application/Errors/ErrorCatalog.cs @@ -1,8 +1,8 @@ namespace SharedKernel.Application.Errors; /// -/// Cross-cutting error codes shared by multiple modules. -/// Module-specific error codes live in each module's own Errors/ErrorCatalog.cs. +/// Cross-cutting error codes shared by multiple modules. +/// Module-specific error codes live in each module's own Errors/ErrorCatalog.cs. /// public static class ErrorCatalog { diff --git a/src/SharedKernel/Application/Events/MessageBusExtensions.cs b/src/SharedKernel/Application/Events/MessageBusExtensions.cs index 1ec73948..a70562af 100644 --- a/src/SharedKernel/Application/Events/MessageBusExtensions.cs +++ b/src/SharedKernel/Application/Events/MessageBusExtensions.cs @@ -3,12 +3,12 @@ namespace SharedKernel.Application.Events; /// -/// Factory for instances when no cascading messages need to be dispatched. +/// Factory for instances when no cascading messages need to be dispatched. /// public static class OutgoingMessagesHelper { /// - /// Returns a new empty for handler paths that do not emit any cascading messages. + /// Returns a new empty for handler paths that do not emit any cascading messages. /// public static OutgoingMessages Empty => new(); } diff --git a/src/SharedKernel/Application/Extensions/RepositoryExtensions.cs b/src/SharedKernel/Application/Extensions/RepositoryExtensions.cs index 8cd547ac..330e4ff1 100644 --- a/src/SharedKernel/Application/Extensions/RepositoryExtensions.cs +++ b/src/SharedKernel/Application/Extensions/RepositoryExtensions.cs @@ -6,8 +6,8 @@ namespace SharedKernel.Application.Extensions; public static class RepositoryExtensions { /// - /// Returns the entity by wrapped in , - /// or the supplied when the entity does not exist. + /// Returns the entity by wrapped in , + /// or the supplied when the entity does not exist. /// public static async Task> GetByIdOrError( this IRepositoryBase repository, diff --git a/src/SharedKernel/Application/Http/RateLimitPolicies.cs b/src/SharedKernel/Application/Http/RateLimitPolicies.cs index 0034fb29..f717f57b 100644 --- a/src/SharedKernel/Application/Http/RateLimitPolicies.cs +++ b/src/SharedKernel/Application/Http/RateLimitPolicies.cs @@ -1,7 +1,7 @@ namespace SharedKernel.Application.Http; /// -/// Centralizes rate-limit policy name constants used across the application and API layers. +/// Centralizes rate-limit policy name constants used across the application and API layers. /// public static class RateLimitPolicies { diff --git a/src/SharedKernel/Application/Options/AppOptions.cs b/src/SharedKernel/Application/Options/AppOptions.cs index df7647d6..7df707e7 100644 --- a/src/SharedKernel/Application/Options/AppOptions.cs +++ b/src/SharedKernel/Application/Options/AppOptions.cs @@ -4,7 +4,7 @@ namespace SharedKernel.Application.Options; /// -/// Top-level application options that apply globally across the service. +/// Top-level application options that apply globally across the service. /// public sealed class AppOptions { diff --git a/src/SharedKernel/Application/Options/BackgroundJobs/CleanupJobOptions.cs b/src/SharedKernel/Application/Options/BackgroundJobs/CleanupJobOptions.cs index 63c43b7f..33e6df4e 100644 --- a/src/SharedKernel/Application/Options/BackgroundJobs/CleanupJobOptions.cs +++ b/src/SharedKernel/Application/Options/BackgroundJobs/CleanupJobOptions.cs @@ -4,8 +4,8 @@ namespace SharedKernel.Application.Options.BackgroundJobs; /// -/// Configuration for the periodic cleanup job that purges expired invitations, soft-deleted records, -/// and orphaned product data according to the configured retention windows. +/// Configuration for the periodic cleanup job that purges expired invitations, soft-deleted records, +/// and orphaned product data according to the configured retention windows. /// public sealed class CleanupJobOptions { diff --git a/src/SharedKernel/Application/Options/BackgroundJobs/EmailRetryJobOptions.cs b/src/SharedKernel/Application/Options/BackgroundJobs/EmailRetryJobOptions.cs index 832b4ed4..28d56fce 100644 --- a/src/SharedKernel/Application/Options/BackgroundJobs/EmailRetryJobOptions.cs +++ b/src/SharedKernel/Application/Options/BackgroundJobs/EmailRetryJobOptions.cs @@ -4,8 +4,8 @@ namespace SharedKernel.Application.Options.BackgroundJobs; /// -/// Configuration for the background job that retries failed outbound email deliveries -/// and moves messages to the dead-letter queue after the maximum retry threshold is exceeded. +/// Configuration for the background job that retries failed outbound email deliveries +/// and moves messages to the dead-letter queue after the maximum retry threshold is exceeded. /// public sealed class EmailRetryJobOptions { diff --git a/src/SharedKernel/Application/Options/BackgroundJobs/ExternalSyncJobOptions.cs b/src/SharedKernel/Application/Options/BackgroundJobs/ExternalSyncJobOptions.cs index 9509fc29..1f949f1a 100644 --- a/src/SharedKernel/Application/Options/BackgroundJobs/ExternalSyncJobOptions.cs +++ b/src/SharedKernel/Application/Options/BackgroundJobs/ExternalSyncJobOptions.cs @@ -4,7 +4,7 @@ namespace SharedKernel.Application.Options.BackgroundJobs; /// -/// Configuration for the scheduled job that synchronises data from external third-party systems. +/// Configuration for the scheduled job that synchronises data from external third-party systems. /// public sealed class ExternalSyncJobOptions { diff --git a/src/SharedKernel/Application/Options/BackgroundJobs/ReindexJobOptions.cs b/src/SharedKernel/Application/Options/BackgroundJobs/ReindexJobOptions.cs index 5413b02d..54d4491c 100644 --- a/src/SharedKernel/Application/Options/BackgroundJobs/ReindexJobOptions.cs +++ b/src/SharedKernel/Application/Options/BackgroundJobs/ReindexJobOptions.cs @@ -4,7 +4,7 @@ namespace SharedKernel.Application.Options.BackgroundJobs; /// -/// Configuration for the scheduled job that rebuilds search indexes on a periodic basis. +/// Configuration for the scheduled job that rebuilds search indexes on a periodic basis. /// public sealed class ReindexJobOptions { diff --git a/src/SharedKernel/Application/Options/BackgroundJobs/TickerQSchedulerOptions.cs b/src/SharedKernel/Application/Options/BackgroundJobs/TickerQSchedulerOptions.cs index 008d7e9c..0d6e474f 100644 --- a/src/SharedKernel/Application/Options/BackgroundJobs/TickerQSchedulerOptions.cs +++ b/src/SharedKernel/Application/Options/BackgroundJobs/TickerQSchedulerOptions.cs @@ -4,7 +4,7 @@ namespace SharedKernel.Application.Options.BackgroundJobs; /// -/// Configuration for the TickerQ scheduler, including distributed coordination and fail-safe behaviour. +/// Configuration for the TickerQ scheduler, including distributed coordination and fail-safe behaviour. /// public sealed class TickerQSchedulerOptions { diff --git a/src/SharedKernel/Application/Options/Infrastructure/DragonflyOptions.cs b/src/SharedKernel/Application/Options/Infrastructure/DragonflyOptions.cs index 70f607a4..d1b188da 100644 --- a/src/SharedKernel/Application/Options/Infrastructure/DragonflyOptions.cs +++ b/src/SharedKernel/Application/Options/Infrastructure/DragonflyOptions.cs @@ -4,8 +4,8 @@ namespace SharedKernel.Application.Options.Infrastructure; /// -/// Configuration for the Dragonfly (Redis-compatible) connection used for distributed caching -/// and background-job coordination. +/// Configuration for the Dragonfly (Redis-compatible) connection used for distributed caching +/// and background-job coordination. /// public sealed class DragonflyOptions { diff --git a/src/SharedKernel/Application/Options/Infrastructure/ObservabilityOptions.cs b/src/SharedKernel/Application/Options/Infrastructure/ObservabilityOptions.cs index 3ec47cff..3cc9649d 100644 --- a/src/SharedKernel/Application/Options/Infrastructure/ObservabilityOptions.cs +++ b/src/SharedKernel/Application/Options/Infrastructure/ObservabilityOptions.cs @@ -5,7 +5,7 @@ namespace SharedKernel.Application.Options.Infrastructure; /// -/// Root configuration object for observability (tracing, metrics, and logging) exporters and endpoints. +/// Root configuration object for observability (tracing, metrics, and logging) exporters and endpoints. /// public sealed class ObservabilityOptions { @@ -26,7 +26,7 @@ public sealed class ObservabilityOptions } /// -/// Endpoint configuration for the OpenTelemetry Protocol (OTLP) exporter. +/// Endpoint configuration for the OpenTelemetry Protocol (OTLP) exporter. /// public sealed class OtlpEndpointOptions { @@ -35,7 +35,7 @@ public sealed class OtlpEndpointOptions } /// -/// Endpoint configuration for the .NET Aspire dashboard exporter. +/// Endpoint configuration for the .NET Aspire dashboard exporter. /// public sealed class AspireEndpointOptions { @@ -44,7 +44,7 @@ public sealed class AspireEndpointOptions } /// -/// Groups the enabled/disabled state for each supported observability exporter. +/// Groups the enabled/disabled state for each supported observability exporter. /// public sealed class ObservabilityExportersOptions { @@ -65,8 +65,8 @@ public sealed class ObservabilityExportersOptions } /// -/// A simple toggle that enables or disables an individual observability exporter. -/// When , the exporter state falls back to the runtime default. +/// A simple toggle that enables or disables an individual observability exporter. +/// When , the exporter state falls back to the runtime default. /// public sealed class ObservabilityExporterToggleOptions { diff --git a/src/SharedKernel/Application/Options/Infrastructure/TransactionDefaultsOptions.cs b/src/SharedKernel/Application/Options/Infrastructure/TransactionDefaultsOptions.cs index 5a96f49c..c85e06a2 100644 --- a/src/SharedKernel/Application/Options/Infrastructure/TransactionDefaultsOptions.cs +++ b/src/SharedKernel/Application/Options/Infrastructure/TransactionDefaultsOptions.cs @@ -6,8 +6,8 @@ namespace SharedKernel.Application.Options.Infrastructure; /// -/// Application-level defaults for database transaction settings that can be overridden per call site. -/// Consumed by infrastructure components to build consistent instances. +/// 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 { @@ -30,24 +30,24 @@ public sealed class TransactionDefaultsOptions public int RetryDelaySeconds { get; set; } = 5; /// - /// Resolves the effective by combining the configured defaults - /// in this instance with the specified . + /// 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 . + /// 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. + /// A new instance containing the resolved transaction settings. /// /// - /// This method is intended to be used by infrastructure and other consumers that require - /// consistent transaction configuration based on application-level defaults plus optional, - /// context-specific overrides. + /// This method is intended to be used by infrastructure and other consumers that require + /// consistent transaction configuration based on application-level defaults plus optional, + /// context-specific overrides. /// public TransactionOptions Resolve(TransactionOptions? overrides) { - var resolved = new TransactionOptions + TransactionOptions resolved = new() { IsolationLevel = overrides?.IsolationLevel ?? IsolationLevel, TimeoutSeconds = overrides?.TimeoutSeconds ?? TimeoutSeconds, @@ -67,8 +67,8 @@ public TransactionOptions Resolve(TransactionOptions? overrides) } /// - /// Throws when the given integer value is negative, - /// enforcing that transaction numeric settings are always non-negative. + /// 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) { diff --git a/src/SharedKernel/Application/Options/Security/RedactionOptions.cs b/src/SharedKernel/Application/Options/Security/RedactionOptions.cs index ee12ed1e..6b9d7588 100644 --- a/src/SharedKernel/Application/Options/Security/RedactionOptions.cs +++ b/src/SharedKernel/Application/Options/Security/RedactionOptions.cs @@ -4,8 +4,8 @@ namespace SharedKernel.Application.Options.Security; /// -/// Configuration for the HMAC-based data redaction feature used to pseudonymise sensitive fields. -/// The signing key is sourced from an environment variable whose name is specified here. +/// Configuration for the HMAC-based data redaction feature used to pseudonymise sensitive fields. +/// The signing key is sourced from an environment variable whose name is specified here. /// public sealed class RedactionOptions { diff --git a/src/SharedKernel/Application/Sorting/SortField.cs b/src/SharedKernel/Application/Sorting/SortField.cs index 1c2ee7a2..f928b7d8 100644 --- a/src/SharedKernel/Application/Sorting/SortField.cs +++ b/src/SharedKernel/Application/Sorting/SortField.cs @@ -1,15 +1,17 @@ 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. +/// 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. + /// 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); + public bool Matches(string? input) + { + return string.Equals(Value, input?.Trim(), StringComparison.OrdinalIgnoreCase); + } } diff --git a/src/SharedKernel/Application/Sorting/SortFieldMap.cs b/src/SharedKernel/Application/Sorting/SortFieldMap.cs index ac731f27..4990ff46 100644 --- a/src/SharedKernel/Application/Sorting/SortFieldMap.cs +++ b/src/SharedKernel/Application/Sorting/SortFieldMap.cs @@ -4,17 +4,12 @@ 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. +/// 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; @@ -22,13 +17,16 @@ private readonly record struct Entry( 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. + /// + /// 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)); + _entries.Add(new Entry(field, keySelector)); return this; } @@ -40,10 +38,10 @@ public SortFieldMap Default(Expression> keySelec } /// - /// 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 . + /// 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, @@ -51,7 +49,7 @@ public void ApplySort( string? sortDirection ) { - var desc = !string.Equals(sortDirection, "asc", StringComparison.OrdinalIgnoreCase); + bool desc = !string.Equals(sortDirection, "asc", StringComparison.OrdinalIgnoreCase); Expression>? key = _entries.FirstOrDefault(e => e.Field.Matches(sortBy)).KeySelector ?? _default; @@ -63,4 +61,9 @@ public void ApplySort( else query.OrderBy(key); } + + private readonly record struct Entry( + SortField Field, + Expression> KeySelector + ); } diff --git a/src/SharedKernel/Application/Startup/IStartupTaskCoordinator.cs b/src/SharedKernel/Application/Startup/IStartupTaskCoordinator.cs index a6506d54..f839a25c 100644 --- a/src/SharedKernel/Application/Startup/IStartupTaskCoordinator.cs +++ b/src/SharedKernel/Application/Startup/IStartupTaskCoordinator.cs @@ -1,16 +1,16 @@ namespace SharedKernel.Application.Startup; /// -/// Coordinates one-time startup tasks across multiple application instances using distributed locking, -/// ensuring that tasks such as database seeding run exactly once even in a scaled-out environment. +/// Coordinates one-time startup tasks across multiple application instances using distributed locking, +/// ensuring that tasks such as database seeding run exactly once even in a scaled-out environment. /// public interface IStartupTaskCoordinator { /// - /// Acquires an exclusive distributed lease for and returns - /// an that releases the lease when disposed. + /// Acquires an exclusive distributed lease for and returns + /// an that releases the lease when disposed. /// - Task AcquireAsync( + public Task AcquireAsync( StartupTaskName startupTask, CancellationToken ct = default ); diff --git a/src/SharedKernel/Application/Startup/StartupTaskName.cs b/src/SharedKernel/Application/Startup/StartupTaskName.cs index 3c3996c9..7f287114 100644 --- a/src/SharedKernel/Application/Startup/StartupTaskName.cs +++ b/src/SharedKernel/Application/Startup/StartupTaskName.cs @@ -1,9 +1,9 @@ namespace SharedKernel.Application.Startup; /// -/// Enumerates the named startup tasks whose distributed execution is coordinated via -/// . Values are numeric identifiers that act as -/// stable distributed lock keys. +/// Enumerates the named startup tasks whose distributed execution is coordinated via +/// . Values are numeric identifiers that act as +/// stable distributed lock keys. /// public enum StartupTaskName : long { diff --git a/src/SharedKernel/Application/Validation/DataAnnotationsValidator.cs b/src/SharedKernel/Application/Validation/DataAnnotationsValidator.cs index 68d41be4..ea82902f 100644 --- a/src/SharedKernel/Application/Validation/DataAnnotationsValidator.cs +++ b/src/SharedKernel/Application/Validation/DataAnnotationsValidator.cs @@ -5,9 +5,9 @@ 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. +/// 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 @@ -18,30 +18,27 @@ protected DataAnnotationsValidator() .Custom( static (model, context) => { - var results = new List(); - Validator.TryValidateObject( - model, - new ValidationContext(model), - results, - validateAllProperties: true - ); + List results = new(); + Validator.TryValidateObject(model, new ValidationContext(model), results, 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. + /// 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, @@ -53,7 +50,7 @@ List results if (constructor is null) return; - var existingMembers = new HashSet(results.SelectMany(r => r.MemberNames)); + HashSet existingMembers = new(results.SelectMany(r => r.MemberNames)); foreach (ParameterInfo parameter in constructor.GetParameters()) { @@ -69,8 +66,8 @@ List results if (property is null) continue; - var value = property.GetValue(model); - var validationContext = new ValidationContext(model) { MemberName = parameter.Name }; + object? value = property.GetValue(model); + ValidationContext validationContext = new(model) { MemberName = parameter.Name }; foreach (ValidationAttribute attribute in validationAttributes) { diff --git a/src/SharedKernel/Application/Validation/DateRangeFilterValidator.cs b/src/SharedKernel/Application/Validation/DateRangeFilterValidator.cs index e44a8c6c..3c1bd933 100644 --- a/src/SharedKernel/Application/Validation/DateRangeFilterValidator.cs +++ b/src/SharedKernel/Application/Validation/DateRangeFilterValidator.cs @@ -4,9 +4,9 @@ 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. +/// 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 diff --git a/src/SharedKernel/Application/Validation/NotEmptyAttribute.cs b/src/SharedKernel/Application/Validation/NotEmptyAttribute.cs index b7139e84..05d91c01 100644 --- a/src/SharedKernel/Application/Validation/NotEmptyAttribute.cs +++ b/src/SharedKernel/Application/Validation/NotEmptyAttribute.cs @@ -3,8 +3,8 @@ namespace SharedKernel.Application.Validation; /// -/// Data annotation attribute that rejects , whitespace strings, and -/// values. Applicable to properties and constructor parameters. +/// 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 @@ -14,16 +14,18 @@ public NotEmptyAttribute() protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) { - var isEmpty = + 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/Application/Validation/PaginationFilterValidator.cs b/src/SharedKernel/Application/Validation/PaginationFilterValidator.cs index 5f0f38f8..ae3a7b34 100644 --- a/src/SharedKernel/Application/Validation/PaginationFilterValidator.cs +++ b/src/SharedKernel/Application/Validation/PaginationFilterValidator.cs @@ -3,7 +3,7 @@ namespace SharedKernel.Application.Validation; /// -/// Validates instances by running all Data Annotation attributes -/// declared on the record's properties and constructor parameters. +/// 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/Application/Validation/SortableFilterValidator.cs b/src/SharedKernel/Application/Validation/SortableFilterValidator.cs index 2ff9a749..b0b86f2b 100644 --- a/src/SharedKernel/Application/Validation/SortableFilterValidator.cs +++ b/src/SharedKernel/Application/Validation/SortableFilterValidator.cs @@ -4,8 +4,8 @@ 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). +/// 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 diff --git a/src/SharedKernel/Contracts/Api/ControllerExtensions.cs b/src/SharedKernel/Contracts/Api/ControllerExtensions.cs index 270e21a2..f1bc9be0 100644 --- a/src/SharedKernel/Contracts/Api/ControllerExtensions.cs +++ b/src/SharedKernel/Contracts/Api/ControllerExtensions.cs @@ -4,8 +4,10 @@ namespace SharedKernel.Contracts.Api; public static class ControllerExtensions { - public static string GetApiVersion(this ControllerBase controller) => - controller.RouteData.Values.TryGetValue("version", out object? version) + public static string GetApiVersion(this ControllerBase controller) + { + return controller.RouteData.Values.TryGetValue("version", out object? version) ? version?.ToString() ?? "1" : "1"; + } } diff --git a/src/SharedKernel/Contracts/Api/ErrorOrExtensions.cs b/src/SharedKernel/Contracts/Api/ErrorOrExtensions.cs index 03f914d9..2003f1e2 100644 --- a/src/SharedKernel/Contracts/Api/ErrorOrExtensions.cs +++ b/src/SharedKernel/Contracts/Api/ErrorOrExtensions.cs @@ -25,11 +25,13 @@ Func routeValuesFactory ) { if (!result.IsError) + { return controller.CreatedAtAction( "GetById", routeValuesFactory(result.Value), result.Value ); + } return ToProblemResult(result.Errors, controller); } @@ -54,13 +56,13 @@ public static IActionResult ToOkResult(this ErrorOr result, ControllerB } /// - /// Returns ProblemDetails for the error case of any result. - /// Use when the success case is handled separately by the caller. + /// 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 - ) => ToProblemDetails(result.Errors, controller); + public static IActionResult ToErrorResult(this ErrorOr result, ControllerBase controller) + { + return ToProblemDetails(result.Errors, controller); + } public static ActionResult ToBatchResult( this ErrorOr result, @@ -73,17 +75,14 @@ ApiControllerBase controller return ToProblemResult(result.Errors, controller); } - private static ActionResult ToProblemResult( - List errors, - ControllerBase controller - ) => ToProblemDetails(errors, controller); + private static ActionResult ToProblemResult(List errors, ControllerBase controller) + { + return ToProblemDetails(errors, controller); + } - private static ObjectResult ToProblemDetails( - List errors, - ControllerBase controller - ) + private static ObjectResult ToProblemDetails(List errors, ControllerBase controller) { - ErrorOr.Error firstError = errors[0]; + Error firstError = errors[0]; int statusCode = firstError.Type switch { ErrorType.Validation => StatusCodes.Status400BadRequest, diff --git a/src/SharedKernel/Contracts/Api/Filters/Idempotency/IdempotentAttribute.cs b/src/SharedKernel/Contracts/Api/Filters/Idempotency/IdempotentAttribute.cs index 0c2caa84..4c47acd3 100644 --- a/src/SharedKernel/Contracts/Api/Filters/Idempotency/IdempotentAttribute.cs +++ b/src/SharedKernel/Contracts/Api/Filters/Idempotency/IdempotentAttribute.cs @@ -1,8 +1,8 @@ namespace SharedKernel.Contracts.Api.Filters.Idempotency; /// -/// Marks an action method as idempotent, enabling the -/// to store and replay responses using the Idempotency-Key request header. +/// 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 diff --git a/src/SharedKernel/Contracts/Api/RequirePermissionAttribute.cs b/src/SharedKernel/Contracts/Api/RequirePermissionAttribute.cs index 122c76d5..30edd16a 100644 --- a/src/SharedKernel/Contracts/Api/RequirePermissionAttribute.cs +++ b/src/SharedKernel/Contracts/Api/RequirePermissionAttribute.cs @@ -6,5 +6,5 @@ namespace SharedKernel.Contracts.Api; public sealed class RequirePermissionAttribute : AuthorizeAttribute { public RequirePermissionAttribute(string permission) - : base(policy: permission) { } + : base(permission) { } } diff --git a/src/SharedKernel/Contracts/Api/Routing/KebabCaseRouteTokenTransformer.cs b/src/SharedKernel/Contracts/Api/Routing/KebabCaseRouteTokenTransformer.cs index 242245c5..30b8658d 100644 --- a/src/SharedKernel/Contracts/Api/Routing/KebabCaseRouteTokenTransformer.cs +++ b/src/SharedKernel/Contracts/Api/Routing/KebabCaseRouteTokenTransformer.cs @@ -4,16 +4,14 @@ namespace SharedKernel.Contracts.Api.Routing; /// -/// Transforms route token values (e.g. [controller]) to kebab-case for outbound URLs and inbound matching. +/// Transforms route token values (e.g. [controller]) to kebab-case for outbound URLs and inbound matching. /// public sealed partial class KebabCaseRouteTokenTransformer : IOutboundParameterTransformer { public string? TransformOutbound(object? value) { if (value is not string s || string.IsNullOrEmpty(s)) - { return null; - } s = InsertHyphenAfterLowercaseOrDigitBeforeUppercase().Replace(s, "$1-$2"); s = InsertHyphenBetweenAcronymAndWord().Replace(s, "$1-$2"); diff --git a/src/SharedKernel/Contracts/Commands/Cleanup/CleanupOrphanedProductDataCommand.cs b/src/SharedKernel/Contracts/Commands/Cleanup/CleanupOrphanedProductDataCommand.cs index 7188355b..2ab0a420 100644 --- a/src/SharedKernel/Contracts/Commands/Cleanup/CleanupOrphanedProductDataCommand.cs +++ b/src/SharedKernel/Contracts/Commands/Cleanup/CleanupOrphanedProductDataCommand.cs @@ -1,7 +1,7 @@ namespace SharedKernel.Contracts.Commands.Cleanup; /// -/// Cross-module command instructing the ProductCatalog module to delete orphaned product data documents. -/// Dispatched by the BackgroundJobs cleanup orchestrator via the message bus. +/// Cross-module command instructing the ProductCatalog module to delete orphaned product data documents. +/// Dispatched by the BackgroundJobs cleanup orchestrator via the message bus. /// public sealed record CleanupOrphanedProductDataCommand(int RetentionDays, int BatchSize); diff --git a/src/SharedKernel/Contracts/Events/EmailEvents.cs b/src/SharedKernel/Contracts/Events/EmailEvents.cs index e36d9946..9b31ad7c 100644 --- a/src/SharedKernel/Contracts/Events/EmailEvents.cs +++ b/src/SharedKernel/Contracts/Events/EmailEvents.cs @@ -1,12 +1,12 @@ namespace SharedKernel.Contracts.Events; /// -/// Published after a new user successfully registers, triggering the welcome email notification. +/// Published after a new user successfully registers, triggering the welcome email notification. /// public sealed record UserRegisteredNotification(Guid UserId, string Email, string Username); /// -/// Published after a tenant invitation is created, triggering the invitation email with the acceptance link. +/// Published after a tenant invitation is created, triggering the invitation email with the acceptance link. /// public sealed record TenantInvitationCreatedNotification( Guid InvitationId, @@ -18,7 +18,7 @@ int ExpiryHours ); /// -/// Published after a user's role is changed, triggering the role-change notification email. +/// Published after a user's role is changed, triggering the role-change notification email. /// public sealed record UserRoleChangedNotification( Guid UserId, diff --git a/src/SharedKernel/Contracts/Events/SoftDeleteEvents.cs b/src/SharedKernel/Contracts/Events/SoftDeleteEvents.cs index ab48d6f0..a6f37e54 100644 --- a/src/SharedKernel/Contracts/Events/SoftDeleteEvents.cs +++ b/src/SharedKernel/Contracts/Events/SoftDeleteEvents.cs @@ -1,8 +1,8 @@ namespace SharedKernel.Contracts.Events; /// -/// Published after a tenant is soft-deleted, allowing downstream handlers to trigger -/// cascading cleanup or audit logging without coupling the delete command to those concerns. +/// Published after a tenant is soft-deleted, allowing downstream handlers to trigger +/// cascading cleanup or audit logging without coupling the delete command to those concerns. /// public sealed record TenantSoftDeletedNotification( Guid TenantId, diff --git a/src/SharedKernel/Contracts/Security/Permission.cs b/src/SharedKernel/Contracts/Security/Permission.cs index 03977a6e..3c9cb1cd 100644 --- a/src/SharedKernel/Contracts/Security/Permission.cs +++ b/src/SharedKernel/Contracts/Security/Permission.cs @@ -3,11 +3,45 @@ namespace SharedKernel.Contracts.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. +/// 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 { + 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; + /// Permissions governing product resource access. public static class Products { @@ -77,38 +111,4 @@ public static class Examples public const string Upload = "Examples.Upload"; public const string Download = "Examples.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/Domain/AuditInfo.cs b/src/SharedKernel/Domain/AuditInfo.cs index d3a8d4c5..8622a6f7 100644 --- a/src/SharedKernel/Domain/AuditInfo.cs +++ b/src/SharedKernel/Domain/AuditInfo.cs @@ -1,8 +1,8 @@ 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. +/// 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 { diff --git a/src/SharedKernel/Domain/Contracts/IAuditableEntity.cs b/src/SharedKernel/Domain/Contracts/IAuditableEntity.cs index 513793e5..c77a6ad9 100644 --- a/src/SharedKernel/Domain/Contracts/IAuditableEntity.cs +++ b/src/SharedKernel/Domain/Contracts/IAuditableEntity.cs @@ -1,10 +1,10 @@ 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. +/// 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; } + public AuditInfo Audit { get; set; } } diff --git a/src/SharedKernel/Domain/Contracts/IAuditableTenantEntity.cs b/src/SharedKernel/Domain/Contracts/IAuditableTenantEntity.cs index 16cdb2a3..13ce3467 100644 --- a/src/SharedKernel/Domain/Contracts/IAuditableTenantEntity.cs +++ b/src/SharedKernel/Domain/Contracts/IAuditableTenantEntity.cs @@ -1,7 +1,7 @@ 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. +/// 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/Domain/Contracts/IHasId.cs b/src/SharedKernel/Domain/Contracts/IHasId.cs index 9185a202..d6f5900e 100644 --- a/src/SharedKernel/Domain/Contracts/IHasId.cs +++ b/src/SharedKernel/Domain/Contracts/IHasId.cs @@ -1,9 +1,9 @@ namespace SharedKernel.Domain.Entities.Contracts; /// -/// Marks a type that carries a unique identity. +/// Marks a type that carries a unique identity. /// public interface IHasId { - Guid Id { get; } + public Guid Id { get; } } diff --git a/src/SharedKernel/Domain/Contracts/ITenantEntity.cs b/src/SharedKernel/Domain/Contracts/ITenantEntity.cs index 5ab7fc28..089383ef 100644 --- a/src/SharedKernel/Domain/Contracts/ITenantEntity.cs +++ b/src/SharedKernel/Domain/Contracts/ITenantEntity.cs @@ -1,10 +1,10 @@ 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. +/// 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; } + public Guid TenantId { get; set; } } diff --git a/src/SharedKernel/Domain/Interfaces/IScalarStoredProcedure.cs b/src/SharedKernel/Domain/Interfaces/IScalarStoredProcedure.cs index 0c7c67c2..22a73668 100644 --- a/src/SharedKernel/Domain/Interfaces/IScalarStoredProcedure.cs +++ b/src/SharedKernel/Domain/Interfaces/IScalarStoredProcedure.cs @@ -1,17 +1,17 @@ namespace SharedKernel.Domain.Interfaces; /// -/// Represents a stored procedure that returns a scalar value (e.g. , -/// , ). -/// -/// Unlike , this interface has no class constraint, -/// allowing value-type results. Executed via Database.SqlQuery<T> instead of -/// Set<T>().FromSqlInterpolated. -/// +/// Represents a stored procedure that returns a scalar value (e.g. , +/// , ). +/// +/// Unlike , this interface has no class constraint, +/// allowing value-type results. Executed via Database.SqlQuery<T> instead of +/// Set<T>().FromSqlInterpolated. +/// /// /// The scalar type returned by the procedure. public interface IScalarStoredProcedure { - /// - FormattableString ToSql(); + /// + public FormattableString ToSql(); } diff --git a/src/SharedKernel/Domain/Interfaces/IStoredProcedure.cs b/src/SharedKernel/Domain/Interfaces/IStoredProcedure.cs index b15d74b2..e691b80e 100644 --- a/src/SharedKernel/Domain/Interfaces/IStoredProcedure.cs +++ b/src/SharedKernel/Domain/Interfaces/IStoredProcedure.cs @@ -1,29 +1,28 @@ namespace SharedKernel.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) -/// -/// Usage example: -/// +/// 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) +/// Usage example: +/// /// var proc = new GetProductCategoryStatsProcedure(categoryId); /// var result = await _executor.QueryFirstAsync(proc, ct); /// /// /// -/// The keyless entity type that EF Core will materialise from the procedure result set. -/// Must be registered with HasNoKey() in the DbContext. +/// 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. + /// 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(); + public FormattableString ToSql(); } diff --git a/src/SharedKernel/Domain/Interfaces/IUnitOfWork.cs b/src/SharedKernel/Domain/Interfaces/IUnitOfWork.cs index 6a7db5e9..e8e0488f 100644 --- a/src/SharedKernel/Domain/Interfaces/IUnitOfWork.cs +++ b/src/SharedKernel/Domain/Interfaces/IUnitOfWork.cs @@ -3,66 +3,72 @@ 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. +/// 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. -/// +/// +/// 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 . + /// 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); + public 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 () => - /// { + /// 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); + /// }, ct); /// - Task ExecuteInTransactionAsync( + public 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. + /// 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( + public Task ExecuteInTransactionAsync( Func> action, CancellationToken ct = default, TransactionOptions? options = null diff --git a/src/SharedKernel/Domain/Interfaces/IUnitOfWorkOfTContext.cs b/src/SharedKernel/Domain/Interfaces/IUnitOfWorkOfTContext.cs index 34b83493..13d2eabe 100644 --- a/src/SharedKernel/Domain/Interfaces/IUnitOfWorkOfTContext.cs +++ b/src/SharedKernel/Domain/Interfaces/IUnitOfWorkOfTContext.cs @@ -1,14 +1,14 @@ namespace SharedKernel.Domain.Interfaces; /// -/// Marker interface that scopes a unit of work to a specific module's persistence boundary. -/// Modules use this to request the correct transactional boundary for their own persistence context. -/// The type parameter serves as a discriminator for DI resolution — it does not need to be a DbContext. -/// Domain layers define a simple marker type; Infrastructure maps it to the real DbContext. +/// Marker interface that scopes a unit of work to a specific module's persistence boundary. +/// Modules use this to request the correct transactional boundary for their own persistence context. +/// The type parameter serves as a discriminator for DI resolution — it does not need to be a DbContext. +/// Domain layers define a simple marker type; Infrastructure maps it to the real DbContext. /// /// -/// Marker type identifying the module's persistence boundary. -/// At the Infrastructure level this is typically a DbContext, but Domain-layer code -/// can use a plain marker class to avoid referencing EF Core directly. +/// Marker type identifying the module's persistence boundary. +/// At the Infrastructure level this is typically a DbContext, but Domain-layer code +/// can use a plain marker class to avoid referencing EF Core directly. /// public interface IUnitOfWork : IUnitOfWork; diff --git a/src/SharedKernel/Infrastructure/Auditing/IAuditableEntityStateManager.cs b/src/SharedKernel/Infrastructure/Auditing/IAuditableEntityStateManager.cs index 7f6ec339..6b7ef946 100644 --- a/src/SharedKernel/Infrastructure/Auditing/IAuditableEntityStateManager.cs +++ b/src/SharedKernel/Infrastructure/Auditing/IAuditableEntityStateManager.cs @@ -4,11 +4,11 @@ namespace SharedKernel.Infrastructure.Auditing; /// -/// Abstracts audit-field stamping for auditable entities tracked by EF Core. +/// Abstracts audit-field stamping for auditable entities tracked by EF Core. /// public interface IAuditableEntityStateManager { - void StampAdded( + public void StampAdded( EntityEntry entry, IAuditableTenantEntity entity, DateTime now, @@ -17,9 +17,9 @@ void StampAdded( Guid currentTenantId ); - void StampModified(IAuditableTenantEntity entity, DateTime now, Guid actor); + public void StampModified(IAuditableTenantEntity entity, DateTime now, Guid actor); - void MarkSoftDeleted( + public void MarkSoftDeleted( EntityEntry entry, IAuditableTenantEntity entity, DateTime now, diff --git a/src/SharedKernel/Infrastructure/BackgroundJobs/Services/BoundedChannelQueue.cs b/src/SharedKernel/Infrastructure/BackgroundJobs/Services/BoundedChannelQueue.cs index 7fe0f5cf..54d208ae 100644 --- a/src/SharedKernel/Infrastructure/BackgroundJobs/Services/BoundedChannelQueue.cs +++ b/src/SharedKernel/Infrastructure/BackgroundJobs/Services/BoundedChannelQueue.cs @@ -1,17 +1,19 @@ using System.Threading.Channels; -using SharedKernel.Application.BackgroundJobs; namespace SharedKernel.Infrastructure.BackgroundJobs.Services; /// -/// A generic bounded channel-based queue. Subclass or instantiate directly for -/// specific queue types (jobs, webhooks, emails, etc.). +/// A generic bounded channel-based queue. Subclass or instantiate directly for +/// specific queue types (jobs, webhooks, emails, etc.). /// public class BoundedChannelQueue { private readonly Channel _channel; - /// Creates a bounded channel with the specified , waiting on enqueue when full and using a single reader. + /// + /// Creates a bounded channel with the specified , waiting on enqueue when full and + /// using a single reader. + /// public BoundedChannelQueue(int capacity) { _channel = Channel.CreateBounded( @@ -24,10 +26,14 @@ public BoundedChannelQueue(int capacity) } /// 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); + public IAsyncEnumerable ReadAllAsync(CancellationToken ct = default) + { + return _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); + /// Writes to the channel, waiting asynchronously if the channel is at capacity. + public ValueTask EnqueueAsync(T item, CancellationToken ct = default) + { + return _channel.Writer.WriteAsync(item, ct); + } } diff --git a/src/SharedKernel/Infrastructure/BackgroundJobs/Services/QueueConsumerBackgroundService.cs b/src/SharedKernel/Infrastructure/BackgroundJobs/Services/QueueConsumerBackgroundService.cs index 0020ed4c..31e5db75 100644 --- a/src/SharedKernel/Infrastructure/BackgroundJobs/Services/QueueConsumerBackgroundService.cs +++ b/src/SharedKernel/Infrastructure/BackgroundJobs/Services/QueueConsumerBackgroundService.cs @@ -4,15 +4,18 @@ namespace SharedKernel.Infrastructure.BackgroundJobs.Services; /// -/// Base that drains an in a -/// continuous async loop, dispatching each item to and routing -/// non-cancellation exceptions to . +/// 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 QueueConsumerBackgroundService(IQueueReader queue) + { + _queue = queue; + } protected sealed override async Task ExecuteAsync(CancellationToken stoppingToken) { @@ -32,7 +35,12 @@ protected sealed override async Task ExecuteAsync(CancellationToken stoppingToke /// 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; + /// + /// Called when throws a non-cancellation exception; default implementation is a + /// no-op. + /// + protected virtual Task HandleErrorAsync(T item, Exception ex, CancellationToken ct) + { + return Task.CompletedTask; + } } diff --git a/src/SharedKernel/Infrastructure/Configuration/ConfigurationExtensions.cs b/src/SharedKernel/Infrastructure/Configuration/ConfigurationExtensions.cs index 8e162e08..ac0698f9 100644 --- a/src/SharedKernel/Infrastructure/Configuration/ConfigurationExtensions.cs +++ b/src/SharedKernel/Infrastructure/Configuration/ConfigurationExtensions.cs @@ -3,21 +3,21 @@ namespace SharedKernel.Infrastructure.Configuration; /// -/// Shared configuration helpers that derive section names from options types by convention. +/// Shared configuration helpers that derive section names from options types by convention. /// public static class ConfigurationExtensions { private const string OptionsSuffix = "Options"; /// - /// Returns the configuration section whose key is derived from - /// by stripping the trailing "Options" suffix. + /// Returns the configuration section whose key is derived from + /// by stripping the trailing "Options" suffix. /// public static IConfigurationSection SectionFor(this IConfiguration configuration) where TOptions : class { - var name = typeof(TOptions).Name; - var sectionName = name.EndsWith(OptionsSuffix, StringComparison.Ordinal) + string name = typeof(TOptions).Name; + string sectionName = name.EndsWith(OptionsSuffix, StringComparison.Ordinal) ? name[..^OptionsSuffix.Length] : name; return configuration.GetSection(sectionName); diff --git a/src/SharedKernel/Infrastructure/Configuration/ConfigurationSections.cs b/src/SharedKernel/Infrastructure/Configuration/ConfigurationSections.cs index 7da81c94..3ccc53a0 100644 --- a/src/SharedKernel/Infrastructure/Configuration/ConfigurationSections.cs +++ b/src/SharedKernel/Infrastructure/Configuration/ConfigurationSections.cs @@ -1,7 +1,7 @@ namespace SharedKernel.Infrastructure.Configuration; /// -/// Configuration keys that cannot be derived from an Options class name by convention. +/// Configuration keys that cannot be derived from an Options class name by convention. /// public static class ConfigurationSections { diff --git a/src/SharedKernel/Infrastructure/Configuration/ServiceCollectionOptionsExtensions.cs b/src/SharedKernel/Infrastructure/Configuration/ServiceCollectionOptionsExtensions.cs index 7659f5a3..7ff646a1 100644 --- a/src/SharedKernel/Infrastructure/Configuration/ServiceCollectionOptionsExtensions.cs +++ b/src/SharedKernel/Infrastructure/Configuration/ServiceCollectionOptionsExtensions.cs @@ -5,13 +5,13 @@ namespace SharedKernel.Infrastructure.Configuration; /// -/// Shared options binding helpers used across host and modules. +/// Shared options binding helpers used across host and modules. /// public static class ServiceCollectionOptionsExtensions { /// - /// Binds to its convention-based configuration section, - /// optionally validates data annotations, and validates eagerly on application start. + /// Binds to its convention-based configuration section, + /// optionally validates data annotations, and validates eagerly on application start. /// public static OptionsBuilder AddValidatedOptions( this IServiceCollection services, diff --git a/src/SharedKernel/Infrastructure/Configurations/TenantAuditableEntityConfigurationExtensions.cs b/src/SharedKernel/Infrastructure/Configurations/TenantAuditableEntityConfigurationExtensions.cs index 11f9f613..402f20db 100644 --- a/src/SharedKernel/Infrastructure/Configurations/TenantAuditableEntityConfigurationExtensions.cs +++ b/src/SharedKernel/Infrastructure/Configurations/TenantAuditableEntityConfigurationExtensions.cs @@ -6,8 +6,8 @@ namespace SharedKernel.Infrastructure.Configurations; /// -/// Applies the standard tenant, audit, soft-delete, and optimistic-concurrency configuration -/// to any entity implementing . +/// Applies the standard tenant, audit, soft-delete, and optimistic-concurrency configuration +/// to any entity implementing . /// public static class TenantAuditableEntityConfigurationExtensions { diff --git a/src/SharedKernel/Infrastructure/EntityNormalization/IEntityNormalizationService.cs b/src/SharedKernel/Infrastructure/EntityNormalization/IEntityNormalizationService.cs index 013cb5e7..c64bbdd8 100644 --- a/src/SharedKernel/Infrastructure/EntityNormalization/IEntityNormalizationService.cs +++ b/src/SharedKernel/Infrastructure/EntityNormalization/IEntityNormalizationService.cs @@ -3,10 +3,10 @@ namespace SharedKernel.Infrastructure.EntityNormalization; /// -/// Defines normalization behavior applied to auditable tenant entities before they are persisted. +/// Defines normalization behavior applied to auditable tenant entities before they are persisted. /// public interface IEntityNormalizationService { - /// Normalizes the relevant fields of in place before persistence. - void Normalize(IAuditableTenantEntity entity); + /// Normalizes the relevant fields of in place before persistence. + public void Normalize(IAuditableTenantEntity entity); } diff --git a/src/SharedKernel/Infrastructure/Health/KeycloakHealthCheck.cs b/src/SharedKernel/Infrastructure/Health/KeycloakHealthCheck.cs index f5d82e0e..96cbb24e 100644 --- a/src/SharedKernel/Infrastructure/Health/KeycloakHealthCheck.cs +++ b/src/SharedKernel/Infrastructure/Health/KeycloakHealthCheck.cs @@ -4,14 +4,14 @@ namespace SharedKernel.Infrastructure.Health; /// -/// Probes the Keycloak OpenID Connect discovery endpoint with a 5-second timeout. +/// Probes the Keycloak OpenID Connect discovery endpoint with a 5-second timeout. /// public sealed class KeycloakHealthCheck : IHealthCheck { private static readonly TimeSpan CheckTimeout = TimeSpan.FromSeconds(5); + private readonly string _discoveryUrl; private readonly IHttpClientFactory _httpClientFactory; - private readonly string _discoveryUrl; public KeycloakHealthCheck( IHttpClientFactory httpClientFactory, @@ -23,8 +23,8 @@ IOptions options } /// - /// Issues an HTTP GET to the Keycloak discovery URL and returns - /// on a 2xx response, or on a non-success status or exception. + /// Issues an HTTP GET to the Keycloak discovery URL and returns + /// on a 2xx response, or on a non-success status or exception. /// public async Task CheckHealthAsync( HealthCheckContext context, diff --git a/src/SharedKernel/Infrastructure/Health/KeycloakHealthCheckOptions.cs b/src/SharedKernel/Infrastructure/Health/KeycloakHealthCheckOptions.cs index d8fda46d..87211d80 100644 --- a/src/SharedKernel/Infrastructure/Health/KeycloakHealthCheckOptions.cs +++ b/src/SharedKernel/Infrastructure/Health/KeycloakHealthCheckOptions.cs @@ -4,8 +4,8 @@ namespace SharedKernel.Infrastructure.Health; /// -/// Options that configure the with the OpenID Connect discovery -/// URL to probe when verifying Keycloak availability. +/// Options that configure the with the OpenID Connect discovery +/// URL to probe when verifying Keycloak availability. /// public sealed class KeycloakHealthCheckOptions { diff --git a/src/SharedKernel/Infrastructure/Idempotency/IdempotencyStoreConstants.cs b/src/SharedKernel/Infrastructure/Idempotency/IdempotencyStoreConstants.cs index ebdd3980..8f39b7e1 100644 --- a/src/SharedKernel/Infrastructure/Idempotency/IdempotencyStoreConstants.cs +++ b/src/SharedKernel/Infrastructure/Idempotency/IdempotencyStoreConstants.cs @@ -1,6 +1,9 @@ namespace SharedKernel.Infrastructure.Idempotency; -/// Shared key-naming constants used by and . +/// +/// Shared key-naming constants used by and +/// . +/// internal static class IdempotencyStoreConstants { /// Suffix appended to an idempotency key to form the corresponding distributed-lock key. diff --git a/src/SharedKernel/Infrastructure/Idempotency/InMemoryIdempotencyStore.cs b/src/SharedKernel/Infrastructure/Idempotency/InMemoryIdempotencyStore.cs index 8ac76e3d..228ed604 100644 --- a/src/SharedKernel/Infrastructure/Idempotency/InMemoryIdempotencyStore.cs +++ b/src/SharedKernel/Infrastructure/Idempotency/InMemoryIdempotencyStore.cs @@ -5,9 +5,9 @@ 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. +/// 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 { @@ -15,6 +15,7 @@ public sealed class InMemoryIdempotencyStore : IIdempotencyStore private readonly ConcurrentDictionary _store = new(); + private readonly TimeProvider _timeProvider; private DateTimeOffset _lastEviction = DateTimeOffset.MinValue; @@ -23,7 +24,10 @@ public InMemoryIdempotencyStore(TimeProvider timeProvider) _timeProvider = timeProvider; } - /// Returns the cached entry for if it exists and has not expired; triggers lazy eviction otherwise. + /// + /// 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 ( @@ -41,7 +45,10 @@ public InMemoryIdempotencyStore(TimeProvider timeProvider) return Task.FromResult(null); } - /// Attempts to insert a lock entry using TryAdd; returns the lock token if acquired, or null otherwise. Atomically checks that no cached result already exists for the key. + /// + /// Attempts to insert a lock entry using TryAdd; returns the lock token if acquired, or null + /// otherwise. Atomically checks that no cached result already exists for the key. + /// public Task TryAcquireAsync(string key, TimeSpan ttl, CancellationToken ct = default) { EvictExpired(); @@ -52,9 +59,7 @@ public InMemoryIdempotencyStore(TimeProvider timeProvider) _store.TryGetValue(key, out (string Value, DateTimeOffset Expiry) existing) && existing.Expiry > now ) - { return Task.FromResult(null); - } string lockKey = key + IdempotencyStoreConstants.LockSuffix; string lockValue = Guid.NewGuid().ToString("N"); @@ -64,7 +69,10 @@ public InMemoryIdempotencyStore(TimeProvider timeProvider) return Task.FromResult(acquired ? lockValue : null); } - /// Serialises and inserts or replaces it in the store with the specified . + /// + /// Serialises and inserts or replaces it in the store with the specified + /// . + /// public Task SetAsync( string key, IdempotencyCacheEntry entry, @@ -78,7 +86,10 @@ public Task SetAsync( return Task.CompletedTask; } - /// Removes the lock entry for only if the supplied still matches, preventing accidental release of expired locks. + /// + /// Removes the lock entry for only if the supplied still + /// matches, preventing accidental release of expired locks. + /// public Task ReleaseAsync(string key, string lockToken, CancellationToken ct = default) { string lockKey = key + IdempotencyStoreConstants.LockSuffix; diff --git a/src/SharedKernel/Infrastructure/Logging/LogDataClassifications.cs b/src/SharedKernel/Infrastructure/Logging/LogDataClassifications.cs index 58feb1ff..00fcced8 100644 --- a/src/SharedKernel/Infrastructure/Logging/LogDataClassifications.cs +++ b/src/SharedKernel/Infrastructure/Logging/LogDataClassifications.cs @@ -3,8 +3,8 @@ namespace SharedKernel.Infrastructure.Logging; /// -/// Defines the project-specific taxonomy used to classify -/// log parameters for compliance-aware redaction in the Microsoft.Extensions.Compliance pipeline. +/// Defines the project-specific taxonomy used to classify +/// log parameters for compliance-aware redaction in the Microsoft.Extensions.Compliance pipeline. /// public static class LogDataClassifications { @@ -21,8 +21,8 @@ public static class LogDataClassifications } /// -/// Marks a log parameter or property as personally identifiable information, causing it to be -/// redacted by the configured classification policy. +/// Marks a log parameter or property as personally identifiable information, causing it to be +/// redacted by the configured classification policy. /// [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] public sealed class PersonalDataAttribute : DataClassificationAttribute @@ -32,8 +32,8 @@ public PersonalDataAttribute() } /// -/// Marks a log parameter or property as sensitive business data, causing it to be -/// redacted by the configured classification policy. +/// Marks a log parameter or property as sensitive business data, causing it to be +/// redacted by the configured classification policy. /// [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] public sealed class SensitiveDataAttribute : DataClassificationAttribute diff --git a/src/SharedKernel/Infrastructure/Persistence/ModuleDbContext.cs b/src/SharedKernel/Infrastructure/Persistence/ModuleDbContext.cs index a8b8f356..d8001a8b 100644 --- a/src/SharedKernel/Infrastructure/Persistence/ModuleDbContext.cs +++ b/src/SharedKernel/Infrastructure/Persistence/ModuleDbContext.cs @@ -11,20 +11,17 @@ namespace SharedKernel.Infrastructure.Persistence; /// -/// Base EF Core context for modules that need multi-tenancy, audit stamping, soft delete, and optimistic concurrency. +/// Base EF Core context for modules that need multi-tenancy, audit stamping, soft delete, and optimistic concurrency. /// public abstract class ModuleDbContext : DbContext { - private readonly ITenantProvider _tenantProvider; private readonly IActorProvider _actorProvider; - private readonly TimeProvider _timeProvider; - private readonly IReadOnlyCollection _softDeleteCascadeRules; private readonly IEntityNormalizationService _entityNormalizationService; private readonly IAuditableEntityStateManager _entityStateManager; + private readonly IReadOnlyCollection _softDeleteCascadeRules; private readonly ISoftDeleteProcessor _softDeleteProcessor; - - protected Guid CurrentTenantId => _tenantProvider.TenantId; - protected bool HasTenant => _tenantProvider.HasTenant; + private readonly ITenantProvider _tenantProvider; + private readonly TimeProvider _timeProvider; protected ModuleDbContext( DbContextOptions options, @@ -47,6 +44,9 @@ ISoftDeleteProcessor softDeleteProcessor _softDeleteProcessor = softDeleteProcessor; } + protected Guid CurrentTenantId => _tenantProvider.TenantId; + protected bool HasTenant => _tenantProvider.HasTenant; + public override int SaveChanges(bool acceptAllChangesOnSuccess) { throw new NotSupportedException( @@ -72,16 +72,10 @@ protected void ApplyGlobalFilters(ModelBuilder modelBuilder) !typeof(ITenantEntity).IsAssignableFrom(entityType.ClrType) || !typeof(ISoftDeletable).IsAssignableFrom(entityType.ClrType) ) - { continue; - } MethodInfo method = typeof(ModuleDbContext) - .GetMethod( - nameof(SetGlobalFilter), - System.Reflection.BindingFlags.Instance - | System.Reflection.BindingFlags.NonPublic - )! + .GetMethod(nameof(SetGlobalFilter), BindingFlags.Instance | BindingFlags.NonPublic)! .MakeGenericMethod(entityType.ClrType); method.Invoke(this, [modelBuilder]); @@ -112,7 +106,7 @@ private async Task ApplyEntityAuditingAsync(CancellationToken cancellationToken) .ToList() ) { - var entity = (IAuditableTenantEntity)entry.Entity; + IAuditableTenantEntity entity = (IAuditableTenantEntity)entry.Entity; switch (entry.State) { case EntityState.Added: diff --git a/src/SharedKernel/Infrastructure/Registration/ModuleRegistrationBuilder.cs b/src/SharedKernel/Infrastructure/Registration/ModuleRegistrationBuilder.cs index d6521c31..0cac56ca 100644 --- a/src/SharedKernel/Infrastructure/Registration/ModuleRegistrationBuilder.cs +++ b/src/SharedKernel/Infrastructure/Registration/ModuleRegistrationBuilder.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using SharedKernel.Application.Options.Infrastructure; +using SharedKernel.Domain.Entities.Contracts; using SharedKernel.Domain.Interfaces; using SharedKernel.Infrastructure.Auditing; using SharedKernel.Infrastructure.Configuration; @@ -16,29 +17,26 @@ namespace SharedKernel.Infrastructure.Registration; /// -/// Fluent registration surface for module infrastructure built on top of . +/// Fluent registration surface for module infrastructure built on top of . /// public sealed class ModuleRegistrationBuilder where TContext : DbContext { - private readonly IServiceCollection _services; - private readonly IConfiguration _configuration; - internal ModuleRegistrationBuilder(IServiceCollection services, IConfiguration configuration) { - _services = services; - _configuration = configuration; + Services = services; + Configuration = configuration; } - public IServiceCollection Services => _services; + public IServiceCollection Services { get; } - public IConfiguration Configuration => _configuration; + public IConfiguration Configuration { get; } public ModuleRegistrationBuilder ConfigureDbContext( Action configure ) { - _services.AddDbContextWithWolverineIntegration(configure); + Services.AddDbContextWithWolverineIntegration(configure); return this; } @@ -46,7 +44,7 @@ public ModuleRegistrationBuilder AddRepository(); + Services.AddScoped(); return this; } @@ -54,14 +52,14 @@ public ModuleRegistrationBuilder AddScoped( where TService : class where TImplementation : class, TService { - _services.AddScoped(); + Services.AddScoped(); return this; } public ModuleRegistrationBuilder AddScoped() where TService : class { - _services.AddScoped(); + Services.AddScoped(); return this; } @@ -69,27 +67,27 @@ public ModuleRegistrationBuilder AddSingleton(); + Services.TryAddSingleton(); return this; } public ModuleRegistrationBuilder AddSingleton() where TService : class { - _services.TryAddSingleton(); + Services.TryAddSingleton(); return this; } public ModuleRegistrationBuilder AddCascadeRule() where TRule : class, ISoftDeleteCascadeRule { - _services.AddScoped(); + Services.AddScoped(); return this; } public ModuleRegistrationBuilder AddStoredProcedureSupport() { - _services.AddScoped(sp => new StoredProcedureExecutor( + Services.AddScoped(sp => new StoredProcedureExecutor( sp.GetRequiredService() )); return this; @@ -97,34 +95,31 @@ public ModuleRegistrationBuilder AddStoredProcedureSupport() public ModuleRegistrationBuilder AddDefaultInfrastructure() { - _services.AddValidatedOptions(_configuration); + Services.AddValidatedOptions(Configuration); - _services.TryAddSingleton(TimeProvider.System); - _services.TryAddSingleton< + Services.TryAddSingleton(TimeProvider.System); + Services.TryAddSingleton< IEntityNormalizationService, PassthroughEntityNormalizationService >(); - _services.TryAddSingleton(); - _services.TryAddSingleton(); + Services.TryAddSingleton(); + Services.TryAddSingleton(); - _services.AddScoped< - IDbTransactionProvider, - EfCoreTransactionProvider - >(); - _services.AddScoped, UnitOfWork>(); + Services.AddScoped, EfCoreTransactionProvider>(); + Services.AddScoped, UnitOfWork>(); return this; } /// - /// Registers a domain-layer marker type as an discriminator - /// that forwards to the real for this module. - /// Handlers use IUnitOfWork<TMarker> to resolve the correct unit of work - /// without referencing the Infrastructure layer directly. + /// Registers a domain-layer marker type as an discriminator + /// that forwards to the real for this module. + /// Handlers use IUnitOfWork<TMarker> to resolve the correct unit of work + /// without referencing the Infrastructure layer directly. /// public ModuleRegistrationBuilder ForwardUnitOfWork() { - _services.AddScoped>(sp => new UnitOfWorkForwarder( + Services.AddScoped>(sp => new UnitOfWorkForwarder( sp.GetRequiredService>() )); return this; @@ -132,6 +127,6 @@ public ModuleRegistrationBuilder ForwardUnitOfWork() private sealed class PassthroughEntityNormalizationService : IEntityNormalizationService { - public void Normalize(Domain.Entities.Contracts.IAuditableTenantEntity entity) { } + public void Normalize(IAuditableTenantEntity entity) { } } } diff --git a/src/SharedKernel/Infrastructure/Registration/ModuleRegistrationExtensions.cs b/src/SharedKernel/Infrastructure/Registration/ModuleRegistrationExtensions.cs index ac452046..70a27ffe 100644 --- a/src/SharedKernel/Infrastructure/Registration/ModuleRegistrationExtensions.cs +++ b/src/SharedKernel/Infrastructure/Registration/ModuleRegistrationExtensions.cs @@ -5,7 +5,7 @@ namespace SharedKernel.Infrastructure.Registration; /// -/// Entry points for configuring module infrastructure. +/// Entry points for configuring module infrastructure. /// public static class ModuleRegistrationExtensions { @@ -13,5 +13,8 @@ public static ModuleRegistrationBuilder AddModule( this IServiceCollection services, IConfiguration configuration ) - where TContext : DbContext => new(services, configuration); + where TContext : DbContext + { + return new ModuleRegistrationBuilder(services, configuration); + } } diff --git a/src/SharedKernel/Infrastructure/Registration/QueueRegistrationExtensions.cs b/src/SharedKernel/Infrastructure/Registration/QueueRegistrationExtensions.cs index 7f5dbd3d..b293c59e 100644 --- a/src/SharedKernel/Infrastructure/Registration/QueueRegistrationExtensions.cs +++ b/src/SharedKernel/Infrastructure/Registration/QueueRegistrationExtensions.cs @@ -4,13 +4,13 @@ namespace SharedKernel.Infrastructure.Registration; /// -/// Shared registration helpers for bounded channel queues plus their hosted consumers. +/// Shared registration helpers for bounded channel queues plus their hosted consumers. /// public static class QueueRegistrationExtensions { /// - /// Registers a singleton queue implementation that is exposed as both producer and reader, - /// then adds the background consumer service. + /// Registers a singleton queue implementation that is exposed as both producer and reader, + /// then adds the background consumer service. /// public static IServiceCollection AddQueueWithConsumer( this IServiceCollection services diff --git a/src/SharedKernel/Infrastructure/Repositories/Pagination/PagedProjectionBuilder.cs b/src/SharedKernel/Infrastructure/Repositories/Pagination/PagedProjectionBuilder.cs index 6184affb..f14344ac 100644 --- a/src/SharedKernel/Infrastructure/Repositories/Pagination/PagedProjectionBuilder.cs +++ b/src/SharedKernel/Infrastructure/Repositories/Pagination/PagedProjectionBuilder.cs @@ -4,9 +4,9 @@ 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. +/// 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 { diff --git a/src/SharedKernel/Infrastructure/Repositories/Pagination/PagedRow.cs b/src/SharedKernel/Infrastructure/Repositories/Pagination/PagedRow.cs index 761ac20b..a2fb70f4 100644 --- a/src/SharedKernel/Infrastructure/Repositories/Pagination/PagedRow.cs +++ b/src/SharedKernel/Infrastructure/Repositories/Pagination/PagedRow.cs @@ -1,7 +1,7 @@ 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 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/Infrastructure/Repositories/RepositoryBase.cs b/src/SharedKernel/Infrastructure/Repositories/RepositoryBase.cs index 3e2fb148..a7ed4469 100644 --- a/src/SharedKernel/Infrastructure/Repositories/RepositoryBase.cs +++ b/src/SharedKernel/Infrastructure/Repositories/RepositoryBase.cs @@ -10,7 +10,7 @@ namespace SharedKernel.Infrastructure.Repositories; /// -/// Generic EF Core repository that stages changes without flushing and provides shared paged-query support. +/// Generic EF Core repository that stages changes without flushing and provides shared paged-query support. /// public abstract class RepositoryBase : Ardalis.Specification.EntityFrameworkCore.RepositoryBase, @@ -28,20 +28,19 @@ public virtual async Task>> GetPagedAsync baseQuery = ApplySpecification((ISpecification)spec); - IQueryable countSource = ApplySpecification( - (ISpecification)spec, - evaluateCriteriaOnly: true - ); + IQueryable countSource = ApplySpecification(spec, true); 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 ); - var skip = (pageNumber - 1) * pageSize; + int skip = (pageNumber - 1) * pageSize; List> results = await baseQuery .Skip(skip) .Take(pageSize) @@ -60,13 +59,13 @@ public virtual async Task>> GetPagedAsync 1) { - var totalCount = await baseQuery.CountAsync(ct); + int totalCount = await baseQuery.CountAsync(ct); if (totalCount > 0) { - var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + int totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); return Error.Validation( - code: ErrorCatalog.General.PageOutOfRange, - description: $"PageNumber {pageNumber} exceeds total pages ({totalPages})." + ErrorCatalog.General.PageOutOfRange, + $"PageNumber {pageNumber} exceeds total pages ({totalPages})." ); } } diff --git a/src/SharedKernel/Infrastructure/Resilience/ResilienceDefaults.cs b/src/SharedKernel/Infrastructure/Resilience/ResilienceDefaults.cs index 1368128c..9ebed198 100644 --- a/src/SharedKernel/Infrastructure/Resilience/ResilienceDefaults.cs +++ b/src/SharedKernel/Infrastructure/Resilience/ResilienceDefaults.cs @@ -1,7 +1,7 @@ namespace SharedKernel.Infrastructure.Resilience; /// -/// Shared retry defaults used when registering resilience pipelines. +/// Shared retry defaults used when registering resilience pipelines. /// public static class ResilienceDefaults { diff --git a/src/SharedKernel/Infrastructure/SoftDelete/ISoftDeleteCascadeRule.cs b/src/SharedKernel/Infrastructure/SoftDelete/ISoftDeleteCascadeRule.cs index b6a51384..0e1cbfe2 100644 --- a/src/SharedKernel/Infrastructure/SoftDelete/ISoftDeleteCascadeRule.cs +++ b/src/SharedKernel/Infrastructure/SoftDelete/ISoftDeleteCascadeRule.cs @@ -4,13 +4,13 @@ namespace SharedKernel.Infrastructure.SoftDelete; /// -/// Defines explicit soft-delete cascade behavior for one aggregate/entity type. +/// Defines explicit soft-delete cascade behavior for one aggregate/entity type. /// public interface ISoftDeleteCascadeRule { - bool CanHandle(IAuditableTenantEntity entity); + public bool CanHandle(IAuditableTenantEntity entity); - Task> GetDependentsAsync( + public Task> GetDependentsAsync( DbContext dbContext, IAuditableTenantEntity entity, CancellationToken cancellationToken = default diff --git a/src/SharedKernel/Infrastructure/SoftDelete/ISoftDeleteProcessor.cs b/src/SharedKernel/Infrastructure/SoftDelete/ISoftDeleteProcessor.cs index 72c6b562..2f13725c 100644 --- a/src/SharedKernel/Infrastructure/SoftDelete/ISoftDeleteProcessor.cs +++ b/src/SharedKernel/Infrastructure/SoftDelete/ISoftDeleteProcessor.cs @@ -5,11 +5,11 @@ namespace SharedKernel.Infrastructure.SoftDelete; /// -/// Orchestrates recursive soft-delete processing for tracked EF Core entities. +/// Orchestrates recursive soft-delete processing for tracked EF Core entities. /// public interface ISoftDeleteProcessor { - Task ProcessAsync( + public Task ProcessAsync( DbContext dbContext, EntityEntry entry, IAuditableTenantEntity entity, diff --git a/src/SharedKernel/Infrastructure/SoftDelete/SoftDeleteProcessor.cs b/src/SharedKernel/Infrastructure/SoftDelete/SoftDeleteProcessor.cs index 1ba766df..022035ae 100644 --- a/src/SharedKernel/Infrastructure/SoftDelete/SoftDeleteProcessor.cs +++ b/src/SharedKernel/Infrastructure/SoftDelete/SoftDeleteProcessor.cs @@ -6,7 +6,7 @@ namespace SharedKernel.Infrastructure.SoftDelete; /// -/// Default implementation that recursively soft-deletes an entity and all dependents surfaced by cascade rules. +/// Default implementation that recursively soft-deletes an entity and all dependents surfaced by cascade rules. /// public class SoftDeleteProcessor : ISoftDeleteProcessor { @@ -27,7 +27,7 @@ public Task ProcessAsync( CancellationToken cancellationToken ) { - var visited = new HashSet(ReferenceEqualityComparer.Instance); + HashSet visited = new(ReferenceEqualityComparer.Instance); return SoftDeleteWithRulesAsync( dbContext, entry, diff --git a/src/SharedKernel/Infrastructure/StoredProcedures/StoredProcedureExecutor.cs b/src/SharedKernel/Infrastructure/StoredProcedures/StoredProcedureExecutor.cs index 8ad3282a..bfe88a28 100644 --- a/src/SharedKernel/Infrastructure/StoredProcedures/StoredProcedureExecutor.cs +++ b/src/SharedKernel/Infrastructure/StoredProcedures/StoredProcedureExecutor.cs @@ -4,38 +4,62 @@ namespace SharedKernel.Infrastructure.StoredProcedures; /// -/// Generic EF Core implementation of backed by a module-specific . +/// Generic EF Core implementation of backed by a module-specific +/// . /// public sealed class StoredProcedureExecutor : IStoredProcedureExecutor { private readonly DbContext _dbContext; - public StoredProcedureExecutor(DbContext dbContext) => _dbContext = dbContext; + public StoredProcedureExecutor(DbContext dbContext) + { + _dbContext = dbContext; + } public Task QueryFirstAsync( IStoredProcedure procedure, CancellationToken ct = default ) - where TResult : class => - _dbContext.Set().FromSqlInterpolated(procedure.ToSql()).FirstOrDefaultAsync(ct); + where TResult : class + { + return _dbContext + .Set() + .FromSqlInterpolated(procedure.ToSql()) + .FirstOrDefaultAsync(ct); + } public async Task> QueryManyAsync( IStoredProcedure procedure, CancellationToken ct = default ) - where TResult : class => - await _dbContext.Set().FromSqlInterpolated(procedure.ToSql()).ToListAsync(ct); + where TResult : class + { + return await _dbContext + .Set() + .FromSqlInterpolated(procedure.ToSql()) + .ToListAsync(ct); + } public async Task ScalarFirstAsync( IScalarStoredProcedure procedure, CancellationToken ct = default - ) => await _dbContext.Database.SqlQuery(procedure.ToSql()).FirstOrDefaultAsync(ct); + ) + { + return await _dbContext + .Database.SqlQuery(procedure.ToSql()) + .FirstOrDefaultAsync(ct); + } public async Task> ScalarManyAsync( IScalarStoredProcedure procedure, CancellationToken ct = default - ) => await _dbContext.Database.SqlQuery(procedure.ToSql()).ToListAsync(ct); + ) + { + return await _dbContext.Database.SqlQuery(procedure.ToSql()).ToListAsync(ct); + } - public Task ExecuteAsync(FormattableString sql, CancellationToken ct = default) => - _dbContext.Database.ExecuteSqlInterpolatedAsync(sql, ct); + public Task ExecuteAsync(FormattableString sql, CancellationToken ct = default) + { + return _dbContext.Database.ExecuteSqlInterpolatedAsync(sql, ct); + } } diff --git a/src/SharedKernel/Infrastructure/UnitOfWork/DbContextCommandTimeoutScope.cs b/src/SharedKernel/Infrastructure/UnitOfWork/DbContextCommandTimeoutScope.cs index 001eb476..e8ad9eaa 100644 --- a/src/SharedKernel/Infrastructure/UnitOfWork/DbContextCommandTimeoutScope.cs +++ b/src/SharedKernel/Infrastructure/UnitOfWork/DbContextCommandTimeoutScope.cs @@ -3,13 +3,13 @@ namespace SharedKernel.Infrastructure.UnitOfWork; /// -/// Temporarily overrides the EF Core command timeout for the duration of a scope. +/// Temporarily overrides the EF Core command timeout for the duration of a scope. /// internal sealed class DbContextCommandTimeoutScope(DbContext dbContext) { public IDisposable Apply(int? timeoutSeconds) { - var previousTimeout = GetCommandTimeoutIfSupported(); + int? previousTimeout = GetCommandTimeoutIfSupported(); SetCommandTimeoutIfSupported(timeoutSeconds); return new Releaser(this, previousTimeout); } @@ -35,8 +35,10 @@ private void SetCommandTimeoutIfSupported(int? timeoutSeconds) catch (Exception ex) when (IsCommandTimeoutNotSupported(ex)) { } } - private static bool IsCommandTimeoutNotSupported(Exception ex) => - ex is InvalidOperationException or NotSupportedException; + private static bool IsCommandTimeoutNotSupported(Exception ex) + { + return ex is InvalidOperationException or NotSupportedException; + } private sealed class Releaser(DbContextCommandTimeoutScope scope, int? previousTimeout) : IDisposable diff --git a/src/SharedKernel/Infrastructure/UnitOfWork/DbContextTrackedStateManager.cs b/src/SharedKernel/Infrastructure/UnitOfWork/DbContextTrackedStateManager.cs index eb482b84..0d240540 100644 --- a/src/SharedKernel/Infrastructure/UnitOfWork/DbContextTrackedStateManager.cs +++ b/src/SharedKernel/Infrastructure/UnitOfWork/DbContextTrackedStateManager.cs @@ -4,7 +4,7 @@ namespace SharedKernel.Infrastructure.UnitOfWork; /// -/// Captures and restores a snapshot of all non-detached EF Core change tracker entries. +/// Captures and restores a snapshot of all non-detached EF Core change tracker entries. /// internal sealed class DbContextTrackedStateManager(DbContext dbContext) { diff --git a/src/SharedKernel/Infrastructure/UnitOfWork/IDbTransactionProvider.cs b/src/SharedKernel/Infrastructure/UnitOfWork/IDbTransactionProvider.cs index a4c477c5..2b756442 100644 --- a/src/SharedKernel/Infrastructure/UnitOfWork/IDbTransactionProvider.cs +++ b/src/SharedKernel/Infrastructure/UnitOfWork/IDbTransactionProvider.cs @@ -6,22 +6,22 @@ namespace SharedKernel.Infrastructure.UnitOfWork; /// -/// Abstracts low-level database transaction management and execution strategy creation. +/// Abstracts low-level database transaction management and execution strategy creation. /// public interface IDbTransactionProvider { - IDbContextTransaction? CurrentTransaction { get; } + public IDbContextTransaction? CurrentTransaction { get; } - Task BeginTransactionAsync( + public Task BeginTransactionAsync( IsolationLevel isolationLevel, CancellationToken ct ); - IExecutionStrategy CreateExecutionStrategy(TransactionOptions options); + public IExecutionStrategy CreateExecutionStrategy(TransactionOptions options); } /// -/// A module-scoped transaction provider interface. +/// A module-scoped transaction provider interface. /// public interface IDbTransactionProvider : IDbTransactionProvider where TContext : DbContext { } diff --git a/src/SharedKernel/Infrastructure/UnitOfWork/ManagedTransactionScope.cs b/src/SharedKernel/Infrastructure/UnitOfWork/ManagedTransactionScope.cs index 1b85cd01..dbbaa856 100644 --- a/src/SharedKernel/Infrastructure/UnitOfWork/ManagedTransactionScope.cs +++ b/src/SharedKernel/Infrastructure/UnitOfWork/ManagedTransactionScope.cs @@ -1,7 +1,7 @@ namespace SharedKernel.Infrastructure.UnitOfWork; /// -/// Tracks the nesting depth of managed transaction scopes opened by the unit of work. +/// Tracks the nesting depth of managed transaction scopes opened by the unit of work. /// internal sealed class ManagedTransactionScope { @@ -15,7 +15,10 @@ public IDisposable Enter() return new Releaser(this); } - private void Exit() => Interlocked.Decrement(ref _depth); + private void Exit() + { + Interlocked.Decrement(ref _depth); + } private sealed class Releaser(ManagedTransactionScope scope) : IDisposable { diff --git a/src/SharedKernel/Infrastructure/UnitOfWork/UnitOfWork.cs b/src/SharedKernel/Infrastructure/UnitOfWork/UnitOfWork.cs index cd210a57..c1fb77ab 100644 --- a/src/SharedKernel/Infrastructure/UnitOfWork/UnitOfWork.cs +++ b/src/SharedKernel/Infrastructure/UnitOfWork/UnitOfWork.cs @@ -9,7 +9,8 @@ namespace SharedKernel.Infrastructure.UnitOfWork; /// -/// Generic EF Core implementation of backed by a module-specific . +/// Generic EF Core implementation of backed by a module-specific +/// . /// public class UnitOfWork : IUnitOfWork where TContext : DbContext @@ -17,15 +18,16 @@ public class UnitOfWork : IUnitOfWork private const string CommitWithinTransactionMessage = "CommitAsync cannot be called inside ExecuteInTransactionAsync. The outermost transaction saves and commits automatically."; + private readonly DbContextCommandTimeoutScope _commandTimeoutScope; + private readonly TContext _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 readonly TransactionDefaultsOptions _transactionDefaults; + private readonly IDbTransactionProvider _transactionProvider; private TransactionOptions? _activeTransactionOptions; + private int _savepointCounter; public UnitOfWork( TContext dbContext, @@ -72,7 +74,8 @@ public async Task ExecuteInTransactionAsync( Func action, CancellationToken ct = default, TransactionOptions? options = null - ) => + ) + { await ExecuteInTransactionAsync( async () => { @@ -82,6 +85,7 @@ await ExecuteInTransactionAsync( ct, options ); + } public async Task ExecuteInTransactionAsync( Func> action, @@ -105,7 +109,7 @@ CancellationToken ct ) { ValidateNestedTransactionOptions(options); - var savepointName = $"uow_sp_{Interlocked.Increment(ref _savepointCounter)}"; + string savepointName = $"uow_sp_{Interlocked.Increment(ref _savepointCounter)}"; IReadOnlyDictionary snapshot = _trackedStateManager.Capture(); @@ -140,8 +144,8 @@ CancellationToken ct TransactionOptions? previousActiveOptions = _activeTransactionOptions; return await strategy.ExecuteAsync( - state: action, - operation: async (_, transactionalAction, cancellationToken) => + action, + async (_, transactionalAction, cancellationToken) => { _activeTransactionOptions = effectiveOptions; using IDisposable timeoutScope = _commandTimeoutScope.Apply( @@ -206,7 +210,7 @@ CancellationToken ct _activeTransactionOptions = previousActiveOptions; } }, - verifySucceeded: null, + null, ct ); } @@ -246,6 +250,8 @@ CancellationToken ct catch (NotSupportedException) { } } - private static bool IsTransactionNotSupported(Exception ex) => - ex is InvalidOperationException or NotSupportedException; + private static bool IsTransactionNotSupported(Exception ex) + { + return ex is InvalidOperationException or NotSupportedException; + } } diff --git a/src/SharedKernel/Infrastructure/UnitOfWork/UnitOfWorkExecutionStrategyFactory.cs b/src/SharedKernel/Infrastructure/UnitOfWork/UnitOfWorkExecutionStrategyFactory.cs index ca2d03c0..b7893e74 100644 --- a/src/SharedKernel/Infrastructure/UnitOfWork/UnitOfWorkExecutionStrategyFactory.cs +++ b/src/SharedKernel/Infrastructure/UnitOfWork/UnitOfWorkExecutionStrategyFactory.cs @@ -6,7 +6,7 @@ namespace SharedKernel.Infrastructure.UnitOfWork; /// -/// Selects the appropriate EF Core execution strategy based on provider type and transaction options. +/// Selects the appropriate EF Core execution strategy based on provider type and transaction options. /// internal static class UnitOfWorkExecutionStrategyFactory { @@ -25,7 +25,7 @@ TransactionOptions effectiveOptions dbContext, effectiveOptions.RetryCount ?? 3, TimeSpan.FromSeconds(effectiveOptions.RetryDelaySeconds ?? 5), - errorCodesToAdd: null + null ); } } diff --git a/src/SharedKernel/Infrastructure/UnitOfWork/UnitOfWorkForwarder.cs b/src/SharedKernel/Infrastructure/UnitOfWork/UnitOfWorkForwarder.cs index e4a4d54e..f30696e6 100644 --- a/src/SharedKernel/Infrastructure/UnitOfWork/UnitOfWorkForwarder.cs +++ b/src/SharedKernel/Infrastructure/UnitOfWork/UnitOfWorkForwarder.cs @@ -4,30 +4,42 @@ namespace SharedKernel.Infrastructure.UnitOfWork; /// -/// Forwards calls to an existing instance. -/// Used to register a Domain-layer marker type as a DI discriminator that resolves to the real -/// backed by the module's EF Core DbContext. +/// Forwards calls to an existing instance. +/// Used to register a Domain-layer marker type as a DI discriminator that resolves to the real +/// backed by the module's EF Core DbContext. /// /// -/// Domain-layer marker type that identifies the module's persistence boundary. +/// Domain-layer marker type that identifies the module's persistence boundary. /// public sealed class UnitOfWorkForwarder : IUnitOfWork { private readonly IUnitOfWork _inner; - public UnitOfWorkForwarder(IUnitOfWork inner) => _inner = inner; + public UnitOfWorkForwarder(IUnitOfWork inner) + { + _inner = inner; + } - public Task CommitAsync(CancellationToken ct = default) => _inner.CommitAsync(ct); + public Task CommitAsync(CancellationToken ct = default) + { + return _inner.CommitAsync(ct); + } public Task ExecuteInTransactionAsync( Func action, CancellationToken ct = default, TransactionOptions? options = null - ) => _inner.ExecuteInTransactionAsync(action, ct, options); + ) + { + return _inner.ExecuteInTransactionAsync(action, ct, options); + } public Task ExecuteInTransactionAsync( Func> action, CancellationToken ct = default, TransactionOptions? options = null - ) => _inner.ExecuteInTransactionAsync(action, ct, options); + ) + { + return _inner.ExecuteInTransactionAsync(action, ct, options); + } } diff --git a/tests/APITemplate.Tests/Unit/BackgroundJobs/Jobs/GetJobStatusQueryHandlerTests.cs b/tests/APITemplate.Tests/Unit/BackgroundJobs/Jobs/GetJobStatusQueryHandlerTests.cs index fe01242e..913d0ac5 100644 --- a/tests/APITemplate.Tests/Unit/BackgroundJobs/Jobs/GetJobStatusQueryHandlerTests.cs +++ b/tests/APITemplate.Tests/Unit/BackgroundJobs/Jobs/GetJobStatusQueryHandlerTests.cs @@ -1,6 +1,6 @@ using BackgroundJobs.Domain; using BackgroundJobs.Features; -using BackgroundJobs.Features; +using ErrorOr; using Moq; using Shouldly; using Xunit; @@ -18,14 +18,14 @@ public async Task HandleAsync_WhenEntityNotFound_ReturnsNotFoundError() Guid id = Guid.NewGuid(); _repository.Setup(r => r.GetByIdAsync(id, ct)).ReturnsAsync((JobExecution?)null); - ErrorOr.ErrorOr result = await GetJobStatusQueryHandler.HandleAsync( + ErrorOr result = await GetJobStatusQueryHandler.HandleAsync( new GetJobStatusQuery(new GetJobStatusRequest(id)), _repository.Object, ct ); result.IsError.ShouldBeTrue(); - result.FirstError.Type.ShouldBe(ErrorOr.ErrorType.NotFound); + result.FirstError.Type.ShouldBe(ErrorType.NotFound); } [Fact] @@ -40,7 +40,7 @@ public async Task HandleAsync_WhenEntityExists_ReturnsMappedResponse() }; _repository.Setup(r => r.GetByIdAsync(entity.Id, ct)).ReturnsAsync(entity); - ErrorOr.ErrorOr result = await GetJobStatusQueryHandler.HandleAsync( + ErrorOr result = await GetJobStatusQueryHandler.HandleAsync( new GetJobStatusQuery(new GetJobStatusRequest(entity.Id)), _repository.Object, ct diff --git a/tests/APITemplate.Tests/Unit/BackgroundJobs/Jobs/JobExecutionTests.cs b/tests/APITemplate.Tests/Unit/BackgroundJobs/Jobs/JobExecutionTests.cs index 27fb051e..c07a6b8a 100644 --- a/tests/APITemplate.Tests/Unit/BackgroundJobs/Jobs/JobExecutionTests.cs +++ b/tests/APITemplate.Tests/Unit/BackgroundJobs/Jobs/JobExecutionTests.cs @@ -7,13 +7,15 @@ namespace APITemplate.Tests.Unit.BackgroundJobs.Jobs; public sealed class JobExecutionTests { - private static JobExecution CreatePendingJob() => - new() + private static JobExecution CreatePendingJob() + { + return new JobExecution { Id = Guid.NewGuid(), JobType = "test-job", SubmittedAtUtc = DateTime.UtcNow, }; + } private static TimeProvider MockTimeAt(DateTime utcNow) { diff --git a/tests/APITemplate.Tests/Unit/Build/PackageReferencePolicySupport.cs b/tests/APITemplate.Tests/Unit/Build/PackageReferencePolicySupport.cs index 657881f4..e38acac3 100644 --- a/tests/APITemplate.Tests/Unit/Build/PackageReferencePolicySupport.cs +++ b/tests/APITemplate.Tests/Unit/Build/PackageReferencePolicySupport.cs @@ -18,7 +18,7 @@ public static PolicyEvaluationResult Evaluate( centralVersions ); - var errors = new List(); + List errors = new(); foreach (IPackagePolicyRule rule in PackagePolicies.All) rule.Validate(resolvedReferences, errors); @@ -70,7 +70,10 @@ reference with { Version = string.IsNullOrWhiteSpace(reference.Version) - && centralVersions.TryGetValue(reference.Include, out var resolvedVersion) + && centralVersions.TryGetValue( + reference.Include, + out string? resolvedVersion + ) ? resolvedVersion : reference.Version, } @@ -82,32 +85,32 @@ reference with internal static class PackagePolicies { public static readonly PrefixVersionRule HealthChecks = new( - Name: "HealthChecks", - Prefix: "AspNetCore.HealthChecks.", - VersionSelector: version => version.Major.ToString() + "HealthChecks", + "AspNetCore.HealthChecks.", + version => version.Major.ToString() ); public static readonly PrefixVersionRule HotChocolate = new( - Name: "HotChocolate", - Prefix: "HotChocolate.", - VersionSelector: version => version.ToString() + "HotChocolate", + "HotChocolate.", + version => version.ToString() ); public static readonly PrefixVersionRule Keycloak = new( - Name: "Keycloak.AuthServices", - Prefix: "Keycloak.AuthServices.", - VersionSelector: version => version.ToString() + "Keycloak.AuthServices", + "Keycloak.AuthServices.", + version => version.ToString() ); public static readonly ExactPairVersionRule Ardalis = new( - Name: "Ardalis.Specification", - FirstPackageId: "Ardalis.Specification", - SecondPackageId: "Ardalis.Specification.EntityFrameworkCore" + "Ardalis.Specification", + "Ardalis.Specification", + "Ardalis.Specification.EntityFrameworkCore" ); public static readonly RequiredPinnedVersionRule Scalar = new( - Name: "Scalar.AspNetCore", - PackageId: "Scalar.AspNetCore" + "Scalar.AspNetCore", + "Scalar.AspNetCore" ); public static IReadOnlyList All { get; } = @@ -116,7 +119,7 @@ internal static class PackagePolicies internal interface IPackagePolicyRule { - void Validate(IReadOnlyList references, List errors); + public void Validate(IReadOnlyList references, List errors); } internal sealed record PrefixVersionRule( @@ -127,7 +130,7 @@ Func VersionSelector { public void Validate(IReadOnlyList references, List errors) { - var family = references + List family = references .Where(reference => reference.Include.StartsWith(Prefix, StringComparison.Ordinal)) .ToList(); @@ -142,7 +145,7 @@ public void Validate(IReadOnlyList references, List er if (parsed.Count == 0) return; - var distinctVersions = parsed + List distinctVersions = parsed .Select(item => VersionSelector(item.Version)) .Distinct(StringComparer.Ordinal) .ToList(); @@ -164,7 +167,7 @@ string SecondPackageId { public void Validate(IReadOnlyList references, List errors) { - var pair = references + List pair = references .Where(reference => reference.Include == FirstPackageId || reference.Include == SecondPackageId ) @@ -188,7 +191,7 @@ public void Validate(IReadOnlyList references, List er if (parsed.Count == 0) return; - var distinctVersions = parsed.Select(item => item.Version).Distinct().ToList(); + List distinctVersions = parsed.Select(item => item.Version).Distinct().ToList(); if (distinctVersions.Count > 1) { @@ -225,7 +228,7 @@ internal static class PackageVersionParsing List errors ) { - var parsed = new List<(PackageReference, Version)>(); + List<(PackageReference, Version)> parsed = new(); foreach (PackageReference reference in references) { if (!Version.TryParse(reference.Version, out Version? version)) @@ -275,12 +278,16 @@ internal static class PackagePolicyTestFiles """; - public static string GetRepoRoot() => - Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..")); + public static string GetRepoRoot() + { + return Path.GetFullPath( + Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..") + ); + } public static string ReadProjectXml(string repoRoot) { - var projectPaths = new[] + string[] projectPaths = new[] { Path.Combine(repoRoot, "src", "APITemplate", "Api", "APITemplate.csproj"), Path.Combine(repoRoot, "src", "SharedKernel", "SharedKernel.csproj"), @@ -289,7 +296,7 @@ public static string ReadProjectXml(string repoRoot) Path.Combine(repoRoot, "src", "Modules", "Reviews", "Reviews.csproj"), }; - var packageReferences = projectPaths + List packageReferences = projectPaths .Select(path => XDocument.Parse(File.ReadAllText(path))) .SelectMany(document => document @@ -299,15 +306,17 @@ public static string ReadProjectXml(string repoRoot) ) .ToList(); - var aggregateDocument = new XDocument( + XDocument aggregateDocument = new( 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) + { + return File.ReadAllText(Path.Combine(repoRoot, "Directory.Packages.props")); + } } internal sealed record PackageReference(string Include, string Version); diff --git a/tests/APITemplate.Tests/Unit/Build/PackageReferencePolicyTests.cs b/tests/APITemplate.Tests/Unit/Build/PackageReferencePolicyTests.cs index 10e1627f..86f0d56a 100644 --- a/tests/APITemplate.Tests/Unit/Build/PackageReferencePolicyTests.cs +++ b/tests/APITemplate.Tests/Unit/Build/PackageReferencePolicyTests.cs @@ -8,7 +8,7 @@ public sealed class PackageReferencePolicyTests [Fact] public void APITemplateCsproj_CompliesWithPackageFamilyVersionPolicy() { - var repoRoot = PackagePolicyTestFiles.GetRepoRoot(); + string repoRoot = PackagePolicyTestFiles.GetRepoRoot(); PolicyEvaluationResult result = PackageReferencePolicy.Evaluate( PackagePolicyTestFiles.ReadProjectXml(repoRoot), PackagePolicyTestFiles.ReadCentralPackageXml(repoRoot) diff --git a/tests/APITemplate.Tests/Unit/Common/ConfigurationExtensionsTests.cs b/tests/APITemplate.Tests/Unit/Common/ConfigurationExtensionsTests.cs index ba9dbdbb..f4e27b47 100644 --- a/tests/APITemplate.Tests/Unit/Common/ConfigurationExtensionsTests.cs +++ b/tests/APITemplate.Tests/Unit/Common/ConfigurationExtensionsTests.cs @@ -7,13 +7,17 @@ namespace APITemplate.Tests.Unit.Common; public sealed class ConfigurationExtensionsTests { - private static IConfiguration BuildConfig(Dictionary values) => - new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + private static IConfiguration BuildConfig(Dictionary values) + { + return new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + } [Fact] public void SectionFor_StripsOptionsSuffix_WhenTypeNameEndsWithOptions() { - IConfiguration config = BuildConfig(new() { ["Email:SmtpHost"] = "smtp.example.com" }); + IConfiguration config = BuildConfig( + new Dictionary { ["Email:SmtpHost"] = "smtp.example.com" } + ); IConfigurationSection section = config.SectionFor(); @@ -25,7 +29,10 @@ public void SectionFor_StripsOptionsSuffix_WhenTypeNameEndsWithOptions() public void SectionFor_UsesTypeName_WhenNoOptionsSuffix() { IConfiguration config = BuildConfig( - new() { ["MongoDbSettings:ConnectionString"] = "mongodb://localhost" } + new Dictionary + { + ["MongoDbSettings:ConnectionString"] = "mongodb://localhost", + } ); IConfigurationSection section = config.SectionFor(); @@ -37,7 +44,9 @@ public void SectionFor_UsesTypeName_WhenNoOptionsSuffix() [Fact] public void SectionFor_ReturnsSectionMatchingStrippedName() { - IConfiguration config = BuildConfig(new() { ["Email:SmtpHost"] = "smtp.example.com" }); + IConfiguration config = BuildConfig( + new Dictionary { ["Email:SmtpHost"] = "smtp.example.com" } + ); IConfigurationSection section = config.SectionFor(); @@ -48,7 +57,7 @@ public void SectionFor_ReturnsSectionMatchingStrippedName() [Fact] public void SectionFor_ReturnsEmptySection_WhenKeyAbsent() { - IConfiguration config = BuildConfig(new()); + IConfiguration config = BuildConfig(new Dictionary()); IConfigurationSection section = config.SectionFor(); diff --git a/tests/APITemplate.Tests/Unit/ExceptionHandling/ApiExceptionHandlerTests.cs b/tests/APITemplate.Tests/Unit/ExceptionHandling/ApiExceptionHandlerTests.cs index d984aa2d..34fc4d6a 100644 --- a/tests/APITemplate.Tests/Unit/ExceptionHandling/ApiExceptionHandlerTests.cs +++ b/tests/APITemplate.Tests/Unit/ExceptionHandling/ApiExceptionHandlerTests.cs @@ -2,7 +2,6 @@ using System.Text.Json; using APITemplate.Api.ExceptionHandling; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -20,15 +19,16 @@ public class ApiExceptionHandlerTests public ApiExceptionHandlerTests() { - var services = new ServiceCollection(); + ServiceCollection services = new(); services.AddOptions(); services.AddProblemDetails(options => { options.CustomizeProblemDetails = context => { IDictionary extensions = context.ProblemDetails.Extensions; - var errorCode = - extensions.TryGetValue("errorCode", out var code) && code is string existingCode + string errorCode = + extensions.TryGetValue("errorCode", out object? code) + && code is string existingCode ? existingCode : ErrorCatalog.General.Unknown; @@ -74,8 +74,8 @@ string expectedErrorCode { DefaultHttpContext context = CreateHttpContext(); - var handler = new ApiExceptionHandler(_loggerMock.Object, _problemDetailsService); - var handled = await handler.TryHandleAsync( + ApiExceptionHandler handler = new(_loggerMock.Object, _problemDetailsService); + bool handled = await handler.TryHandleAsync( context, exception, TestContext.Current.CancellationToken @@ -102,8 +102,8 @@ public async Task TryHandleAsync_WhenGraphQlPath_ReturnsFalse() DefaultHttpContext context = CreateHttpContext(); context.Request.Path = "/graphql"; - var handler = new ApiExceptionHandler(_loggerMock.Object, _problemDetailsService); - var handled = await handler.TryHandleAsync( + ApiExceptionHandler handler = new(_loggerMock.Object, _problemDetailsService); + bool handled = await handler.TryHandleAsync( context, new InvalidOperationException("boom"), TestContext.Current.CancellationToken @@ -115,13 +115,13 @@ public async Task TryHandleAsync_WhenGraphQlPath_ReturnsFalse() [Fact] public async Task TryHandleAsync_WhenRequestIsAborted_ReturnsTrueWithoutProblemDetailsBody() { - using var cts = new CancellationTokenSource(); + using CancellationTokenSource cts = new(); DefaultHttpContext context = CreateHttpContext(); context.RequestAborted = cts.Token; cts.Cancel(); - var handler = new ApiExceptionHandler(_loggerMock.Object, _problemDetailsService); - var handled = await handler.TryHandleAsync( + ApiExceptionHandler handler = new(_loggerMock.Object, _problemDetailsService); + bool handled = await handler.TryHandleAsync( context, new OperationCanceledException(cts.Token), cts.Token @@ -134,7 +134,7 @@ public async Task TryHandleAsync_WhenRequestIsAborted_ReturnsTrueWithoutProblemD private static DefaultHttpContext CreateHttpContext() { - var context = new DefaultHttpContext(); + DefaultHttpContext context = new(); context.Request.Path = "/api/v1/test"; context.Response.Body = new MemoryStream(); return context; diff --git a/tests/APITemplate.Tests/Unit/Middleware/RequestContextMiddlewareTests.cs b/tests/APITemplate.Tests/Unit/Middleware/RequestContextMiddlewareTests.cs index c89b2cb6..b7fca2d3 100644 --- a/tests/APITemplate.Tests/Unit/Middleware/RequestContextMiddlewareTests.cs +++ b/tests/APITemplate.Tests/Unit/Middleware/RequestContextMiddlewareTests.cs @@ -11,10 +11,8 @@ public class RequestContextMiddlewareTests [Fact] public async Task InvokeAsync_WhenHeaderProvided_EchoesCorrelationIdToResponse() { - var middleware = new RequestContextMiddleware(async ctx => - await ctx.Response.WriteAsync("ok") - ); - var context = new DefaultHttpContext(); + RequestContextMiddleware middleware = new(async ctx => await ctx.Response.WriteAsync("ok")); + DefaultHttpContext context = new(); context.Response.Body = new MemoryStream(); context.Request.Headers[RequestContextConstants.Headers.CorrelationId] = "corr-123"; @@ -37,10 +35,8 @@ await ctx.Response.WriteAsync("ok") [Fact] public async Task InvokeAsync_WhenHeaderMissing_UsesTraceIdentifierAsCorrelationId() { - var middleware = new RequestContextMiddleware(async ctx => - await ctx.Response.WriteAsync("ok") - ); - var context = new DefaultHttpContext(); + RequestContextMiddleware middleware = new(async ctx => await ctx.Response.WriteAsync("ok")); + DefaultHttpContext context = new(); context.Response.Body = new MemoryStream(); context.TraceIdentifier = "trace-xyz"; diff --git a/tests/APITemplate.Tests/Unit/Security/StaticRolePermissionMapTests.cs b/tests/APITemplate.Tests/Unit/Security/StaticRolePermissionMapTests.cs index 3067f9bb..44855943 100644 --- a/tests/APITemplate.Tests/Unit/Security/StaticRolePermissionMapTests.cs +++ b/tests/APITemplate.Tests/Unit/Security/StaticRolePermissionMapTests.cs @@ -16,16 +16,14 @@ public void PlatformAdmin_HasAllPermissions() IReadOnlySet permissions = _map.GetPermissions(UserRole.PlatformAdmin.ToString()); permissions.Count.ShouldBe(Permission.All.Count); - foreach (var permission in Permission.All) - { + foreach (string permission in Permission.All) permissions.ShouldContain(permission); - } } [Fact] public void TenantAdmin_HasExpectedPermissions() { - var role = UserRole.TenantAdmin.ToString(); + string role = UserRole.TenantAdmin.ToString(); _map.HasPermission(role, Permission.Products.Read).ShouldBeTrue(); _map.HasPermission(role, Permission.Products.Create).ShouldBeTrue(); @@ -54,7 +52,7 @@ public void TenantAdmin_DoesNotHaveUserWritePermissions(string permission) [Fact] public void User_HasReadOnlyAndReviewCreate() { - var role = UserRole.User.ToString(); + string role = UserRole.User.ToString(); _map.HasPermission(role, Permission.Products.Read).ShouldBeTrue(); _map.HasPermission(role, Permission.Categories.Read).ShouldBeTrue(); diff --git a/tests/APITemplate.Tests/Unit/Webhooks/OutgoingWebhookSsrfTests.cs b/tests/APITemplate.Tests/Unit/Webhooks/OutgoingWebhookSsrfTests.cs index f63c85c4..b3169982 100644 --- a/tests/APITemplate.Tests/Unit/Webhooks/OutgoingWebhookSsrfTests.cs +++ b/tests/APITemplate.Tests/Unit/Webhooks/OutgoingWebhookSsrfTests.cs @@ -1,8 +1,6 @@ using System.Net; using System.Reflection; using Shouldly; -using Webhooks.Contracts; -using Webhooks.Security; using Webhooks.Services; using Xunit; @@ -16,8 +14,10 @@ public sealed class OutgoingWebhookSsrfTests BindingFlags.NonPublic | BindingFlags.Static )!; - private static bool IsProhibited(IPAddress address) => - (bool)IsProhibitedAddressMethod.Invoke(null, [address])!; + private static bool IsProhibited(IPAddress address) + { + return (bool)IsProhibitedAddressMethod.Invoke(null, [address])!; + } [Theory] [InlineData("127.0.0.1")] From 1744bea89d4b1e58bae26dacee3dc92717f369c4 Mon Sep 17 00:00:00 2001 From: "tadeas.zribko" Date: Sat, 4 Apr 2026 13:33:52 +0200 Subject: [PATCH 2/8] refactor: Enhance code readability and consistency across multiple files - Refactor method implementations to use block syntax for clarity. - Update comments for consistent formatting and improved documentation. - Adjust variable declarations for enhanced readability. - Organize namespaces and remove unnecessary using directives. - Ensure consistent use of access modifiers in interface methods. --- .editorconfig | 228 +++++------ APITemplate.slnx | 92 ++--- Directory.Build.targets | 4 +- Directory.Packages.props | 160 ++++---- README.md | 354 ++++++++++++------ TODO.md | 216 ++++++++--- docker-compose.production.yml | 18 +- docker-compose.yml | 18 +- docs/README.md | 41 +- infrastructure/dragonfly/haproxy.cfg | 24 +- .../keycloak/realms/api-template-realm.json | 18 +- .../dashboards/apitemplate-overview.json | 33 +- .../observability/prometheus/prometheus.yml | 8 +- infrastructure/observability/tempo/config.yml | 2 +- src/APITemplate/Api/APITemplate.csproj | 78 ++-- .../Api/Api/Cache/CacheInvalidationHandler.cs | 5 +- .../ApiExceptionHandlerLogs.cs | 4 +- .../Middleware/CsrfValidationMiddleware.cs | 13 +- .../Middleware/RequestContextMiddleware.cs | 14 +- ...BearerSecuritySchemeDocumentTransformer.cs | 8 +- .../ApiServiceCollectionExtensions.cs | 10 +- ...nCompositionServiceCollectionExtensions.cs | 10 - ...bservabilityServiceCollectionExtensions.cs | 22 +- .../Api/Extensions/WolverineTypeExtensions.cs | 4 - .../BackgroundJobs/BackgroundJobs.csproj | 16 +- .../BackgroundJobsRuntimeBridge.cs | 9 +- .../BackgroundJobs/Domain/JobExecution.cs | 19 +- .../BackgroundJobs/Domain/JobMappings.cs | 16 +- .../Persistence/BackgroundJobsDbContext.cs | 2 - .../Services/ChannelJobQueue.cs | 6 +- .../BackgroundJobs/Services/CleanupService.cs | 10 +- .../ExternalSyncRecurringJobRegistration.cs | 4 +- src/Modules/Chatting/Chatting.csproj | 10 +- .../Chatting/Features/SseController.cs | 4 - .../Contracts/FileUploadResponse.cs | 6 +- .../Contracts/UploadFileRequest.cs | 7 +- .../Domain/IStoredFileRepository.cs | 11 +- src/Modules/FileStorage/Domain/StoredFile.cs | 12 +- .../FileStorage/Features/FileUploadRequest.cs | 10 +- src/Modules/FileStorage/FileStorage.csproj | 12 +- src/Modules/Identity/Common/BffOptions.cs | 4 +- src/Modules/Identity/Common/CacheTags.cs | 1 - .../Common/Email/ISecureTokenGenerator.cs | 13 +- .../Identity/Common/SystemIdentityOptions.cs | 6 +- .../Common/TenantInvitationOptions.cs | 5 +- .../Identity/Enums/InvitationStatus.cs | 3 +- .../Features/Tenant/DTOs/TenantFilter.cs | 6 +- .../Tenant/ITenantCodeConflictDetector.cs | 3 +- .../Tenant/Queries/GetTenantsQuery.cs | 6 +- .../Specifications/TenantFilterCriteria.cs | 8 +- .../DTOs/TenantInvitationFilter.cs | 6 +- .../Commands/KeycloakPasswordResetCommand.cs | 2 - .../Features/User/DTOs/UpdateUserRequest.cs | 4 +- .../Features/User/DTOs/UserResponse.cs | 6 +- .../Specifications/UserFilterSpecification.cs | 10 +- .../Validation/CreateUserRequestValidator.cs | 6 +- .../Identity/Features/V1/UsersController.cs | 34 +- src/Modules/Identity/Identity.csproj | 39 +- .../Identity/Interfaces/ITenantRepository.cs | 6 +- .../Identity/Interfaces/IUserRepository.cs | 15 +- .../Persistence/AuthBootstrapSeeder.cs | 24 +- .../PostgresTenantCodeConflictDetector.cs | 22 +- .../TenantInvitationRepository.cs | 16 +- .../Keycloak/KeycloakAdminTokenHandler.cs | 13 +- .../Tenant/UserProvisioningService.cs | 13 +- .../Notifications/Contracts/EmailOptions.cs | 8 +- .../Contracts/EmailTemplateNames.cs | 4 +- .../Contracts/IEmailRetryService.cs | 20 +- .../Notifications/Domain/FailedEmail.cs | 14 +- .../Notifications/Notifications.csproj | 18 +- .../Services/EmailSendingBackgroundService.cs | 28 +- .../Sql/claim_expired_failed_emails_v1_up.sql | 64 ++-- .../claim_retryable_failed_emails_v1_up.sql | 64 ++-- .../ProductCategoryStatsConfiguration.cs | 13 +- .../Configurations/ProductConfiguration.cs | 7 +- .../ProductDataLinkConfiguration.cs | 6 +- .../ProductCatalog/Entities/Category.cs | 10 +- .../ProductCatalog/Entities/Product.cs | 26 +- .../Entities/ProductCategoryStats.cs | 9 +- .../Entities/ProductData/ImageProductData.cs | 6 +- .../Entities/ProductDataLink.cs | 22 +- .../CategoriesController.CreateCategories.cs | 2 - .../CreateCategoryRequestValidator.cs | 4 +- .../CategoriesController.GetCategories.cs | 2 - .../Category/GetCategories/CategoryFilter.cs | 5 +- .../GetCategories/CategoryFilterCriteria.cs | 10 +- .../GetCategories/CategoryFilterValidator.cs | 5 +- .../ProductCategoryStatsResponse.cs | 2 +- .../Shared/CategoriesByIdsSpecification.cs | 2 +- .../Category/Shared/CategoryMappings.cs | 16 +- .../DeleteProducts/DeleteProductsCommand.cs | 20 +- .../Product/GetProducts/ProductFilter.cs | 6 +- .../IdempotentCreate/IdempotentController.cs | 2 - .../IdempotentCreateResponse.cs | 4 +- .../PatchController.PatchProduct.cs | 3 - .../PatchProduct/PatchProductCommand.cs | 9 +- .../Product/Shared/ProductResponse.cs | 2 +- .../ProductsByIdsWithLinksSpecification.cs | 4 +- .../ProductsController.UpdateProducts.cs | 2 - .../CreateImageProductDataRequestValidator.cs | 5 +- .../CreateVideoProductDataCommand.cs | 6 +- .../CreateVideoProductDataRequestValidator.cs | 5 +- .../ProductDataController.Delete.cs | 2 - .../GetProductDataByIdQuery.cs | 1 - .../ProductData/Shared/ProductDataMappings.cs | 36 +- ...egoriesForTenantSoftDeleteSpecification.cs | 4 +- .../GraphQL/Models/CategoryQueryInput.cs | 10 +- .../GraphQL/Models/ProductReviewPageResult.cs | 8 +- .../GraphQL/Queries/ProductQueries.cs | 13 +- .../Persistence/MongoDbHealthCheck.cs | 3 +- .../ProductCatalog/ProductCatalog.csproj | 55 +-- .../Repositories/CategoryRepository.cs | 14 +- .../Reviews/Common/Errors/ErrorCatalog.cs | 1 + .../Common/Errors/ReviewsDomainErrors.cs | 6 +- .../Domain/IProductReviewRepository.cs | 5 +- src/Modules/Reviews/Domain/ProductReview.cs | 8 +- .../CreateProductReviewRequest.cs | 4 +- .../GetProductReviewsByProductIdQuery.cs | 4 +- .../ProductReviewByProductIdSpecification.cs | 7 +- .../ProductReviewByProductIdsSpecification.cs | 7 +- .../Repositories/ProductReviewRepository.cs | 6 +- src/Modules/Reviews/Reviews.csproj | 24 +- src/Modules/Reviews/ReviewsRuntimeBridge.cs | 3 - .../Contracts/IOutgoingWebhookQueue.cs | 8 +- .../Webhooks/Contracts/OutgoingWebhookDTOs.cs | 7 +- .../Webhooks/Features/WebhooksController.cs | 9 +- .../Services/ChannelOutgoingWebhookQueue.cs | 14 +- .../WebhookProcessingBackgroundService.cs | 10 +- src/Modules/Webhooks/Webhooks.csproj | 10 +- .../IDistributedJobCoordinator.cs | 11 +- .../BackgroundJobs/IQueueReader.cs | 10 +- .../Application/Batch/BatchFailureMerge.cs | 6 +- .../Application/Context/ITenantProvider.cs | 12 +- .../Application/DTOs/BatchResponse.cs | 6 +- .../Application/DTOs/IHasFacets.cs | 6 +- .../Application/Errors/DomainErrors.cs | 14 +- .../Http/RequestContextConstants.cs | 18 +- .../Middleware/ErrorOrValidationMiddleware.cs | 22 +- .../BackgroundJobs/BackgroundJobsOptions.cs | 2 +- .../Resilience/ResiliencePipelineKeys.cs | 4 +- .../Application/Search/SearchDefaults.cs | 4 +- .../Contracts/Api/ApiControllerBase.cs | 6 +- .../Idempotency/IdempotencyActionFilter.cs | 24 +- .../Idempotency/IdempotencyConstants.cs | 2 +- .../CleanupExpiredInvitationsCommand.cs | 4 +- .../Webhooks/SendWebhookCallbackCommand.cs | 4 +- .../Contracts/Events/CacheEvents.cs | 4 +- .../Events/ProductSoftDeletedNotification.cs | 4 +- .../ValidateProductExistsQuery.cs | 4 +- src/SharedKernel/Domain/AuditDefaults.cs | 4 +- .../Domain/Contracts/ISoftDeletable.cs | 10 +- .../Domain/Interfaces/IRepository.cs | 14 +- .../Interfaces/IStoredProcedureExecutor.cs | 32 +- .../Domain/Options/TransactionOptions.cs | 25 +- src/SharedKernel/Domain/PagedResponse.cs | 6 +- .../Auditing/AuditableEntityStateManager.cs | 2 +- .../DistributedCacheIdempotencyStore.cs | 25 +- .../Logging/RedactionConfiguration.cs | 10 +- .../TelemetryApiSurfaceResolver.cs | 10 +- .../SoftDelete/ISoftDeleteCleanupStrategy.cs | 8 +- .../UnitOfWork/EfCoreTransactionProvider.cs | 21 +- .../UnitOfWork/UnitOfWorkLogs.cs | 2 +- src/SharedKernel/SharedKernel.csproj | 30 +- .../APITemplate.Tests.csproj | 54 +-- .../Jobs/SubmitJobCommandHandlerTests.cs | 33 +- .../InMemoryIdempotencyStoreTests.cs | 12 +- .../PermissionAuthorizationHandlerTests.cs | 28 +- 167 files changed, 1563 insertions(+), 1453 deletions(-) diff --git a/.editorconfig b/.editorconfig index 5ffa7edd..c85871ca 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,180 +2,180 @@ root = true # ─── All files ──────────────────────────────────────────────────────────────── [*] -indent_style = space -indent_size = 4 -end_of_line = crlf -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true +indent_style = space +indent_size = 4 +end_of_line = crlf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true # ─── C# ─────────────────────────────────────────────────────────────────────── [*.cs] # Namespace style -csharp_style_namespace_declarations = file_scoped:warning +csharp_style_namespace_declarations = file_scoped:warning # Indentation & new-lines (Allman / ReSharper defaults) -csharp_new_line_before_open_brace = all -csharp_new_line_before_else = true -csharp_new_line_before_catch = true -csharp_new_line_before_finally = true -csharp_new_line_before_members_in_object_initializers = true -csharp_new_line_before_members_in_anonymous_types = true -csharp_new_line_between_query_expression_clauses = true +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true # Indentation -csharp_indent_case_contents = true -csharp_indent_switch_labels = true -csharp_indent_labels = flush_left -csharp_indent_block_contents = true -csharp_indent_braces = false -csharp_indent_case_contents_when_block = false +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents_when_block = false # Spacing -csharp_space_after_cast = false -csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true csharp_space_between_method_declaration_parameter_list_parentheses = false -csharp_space_between_method_call_parameter_list_parentheses = false -csharp_space_between_parentheses = false -csharp_space_before_colon_in_inheritance_clause = true -csharp_space_after_colon_in_inheritance_clause = true -csharp_space_around_binary_operators = before_and_after +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after csharp_space_between_method_declaration_empty_parameter_list_parentheses = false -csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_name_and_opening_parenthesis = false csharp_space_between_method_call_empty_parameter_list_parentheses = false # Wrapping -csharp_preserve_single_line_statements = false -csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = false +csharp_preserve_single_line_blocks = true # var vs explicit type — explicit types everywhere (as seen in codebase) -csharp_style_var_for_built_in_types = false:warning -csharp_style_var_when_type_is_apparent = false:suggestion -csharp_style_var_elsewhere = false:warning +csharp_style_var_for_built_in_types = false:warning +csharp_style_var_when_type_is_apparent = false:suggestion +csharp_style_var_elsewhere = false:warning # Braces — omit only for single-line statements (as in codebase) -csharp_prefer_braces = when_multiline:warning +csharp_prefer_braces = when_multiline:warning # Expression-bodied members -csharp_style_expression_bodied_methods = false:silent -csharp_style_expression_bodied_constructors = false:silent -csharp_style_expression_bodied_operators = false:silent -csharp_style_expression_bodied_properties = when_on_single_line:suggestion -csharp_style_expression_bodied_indexers = when_on_single_line:suggestion -csharp_style_expression_bodied_accessors = when_on_single_line:suggestion -csharp_style_expression_bodied_lambdas = true:suggestion -csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = when_on_single_line:suggestion +csharp_style_expression_bodied_indexers = when_on_single_line:suggestion +csharp_style_expression_bodied_accessors = when_on_single_line:suggestion +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = false:silent # Pattern matching -csharp_style_pattern_matching_over_is_with_cast_check = true:warning -csharp_style_pattern_matching_over_as_with_null_check = true:warning -csharp_style_prefer_switch_expression = true:suggestion -csharp_style_prefer_pattern_matching = true:suggestion -csharp_style_prefer_not_pattern = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:warning +csharp_style_pattern_matching_over_as_with_null_check = true:warning +csharp_style_prefer_switch_expression = true:suggestion +csharp_style_prefer_pattern_matching = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion # Null checks -csharp_style_prefer_null_check_over_type_check = true:warning -dotnet_style_null_propagation = true:warning -dotnet_style_coalesce_expression = true:warning -dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning +csharp_style_prefer_null_check_over_type_check = true:warning +dotnet_style_null_propagation = true:warning +dotnet_style_coalesce_expression = true:warning +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning # Modifiers -csharp_prefer_static_local_function = true:suggestion -dotnet_style_require_accessibility_modifiers = always:warning -csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion +csharp_prefer_static_local_function = true:suggestion +dotnet_style_require_accessibility_modifiers = always:warning +csharp_preferred_modifier_order = public, private, protected, internal, file, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, required, volatile, async:suggestion # this. qualification — never -dotnet_style_qualification_for_field = false:warning -dotnet_style_qualification_for_property = false:warning -dotnet_style_qualification_for_method = false:warning -dotnet_style_qualification_for_event = false:warning +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_property = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_event = false:warning # Language keywords vs types -dotnet_style_predefined_type_for_locals_parameters_members = true:warning -dotnet_style_predefined_type_for_member_access = true:warning +dotnet_style_predefined_type_for_locals_parameters_members = true:warning +dotnet_style_predefined_type_for_member_access = true:warning # Object / collection initializers -dotnet_style_object_initializer = true:suggestion -dotnet_style_collection_initializer = true:suggestion -dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion # Tuple / anonymous types -dotnet_style_explicit_tuple_names = true:warning -dotnet_style_prefer_inferred_tuple_names = true:suggestion -dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_explicit_tuple_names = true:warning +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion # Miscellaneous -dotnet_style_prefer_auto_properties = true:warning -dotnet_style_prefer_compound_assignment = true:suggestion -dotnet_style_prefer_simplified_boolean_expressions = true:warning -dotnet_style_prefer_simplified_interpolation = true:suggestion -csharp_style_prefer_local_over_anonymous_function = true:suggestion -csharp_style_prefer_range_operator = true:suggestion -csharp_style_prefer_index_operator = true:suggestion -csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion -csharp_style_prefer_tuple_swap = true:suggestion -csharp_style_prefer_utf8_string_literals = true:suggestion +dotnet_style_prefer_auto_properties = true:warning +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:warning +dotnet_style_prefer_simplified_interpolation = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion # Using directives -dotnet_sort_system_directives_first = true -dotnet_separate_import_directive_groups = false -csharp_using_directive_placement = outside_namespace:warning +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false +csharp_using_directive_placement = outside_namespace:warning # Naming conventions ────────────────────────────────────────────────────────── # Interfaces: IPascalCase -dotnet_naming_rule.interface_should_be_pascal_case.severity = warning -dotnet_naming_rule.interface_should_be_pascal_case.symbols = interface -dotnet_naming_rule.interface_should_be_pascal_case.style = begins_with_i +dotnet_naming_rule.interface_should_be_pascal_case.severity = warning +dotnet_naming_rule.interface_should_be_pascal_case.symbols = interface +dotnet_naming_rule.interface_should_be_pascal_case.style = begins_with_i -dotnet_naming_symbols.interface.applicable_kinds = interface -dotnet_naming_symbols.interface.applicable_accessibilities = public,internal,private,protected,protected_internal,private_protected -dotnet_naming_style.begins_with_i.required_prefix = I -dotnet_naming_style.begins_with_i.capitalization = pascal_case +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.capitalization = pascal_case # Type parameters: TPascalCase -dotnet_naming_rule.type_parameters_should_begin_with_t.severity = warning -dotnet_naming_rule.type_parameters_should_begin_with_t.symbols = type_parameter -dotnet_naming_rule.type_parameters_should_begin_with_t.style = begins_with_t +dotnet_naming_rule.type_parameters_should_begin_with_t.severity = warning +dotnet_naming_rule.type_parameters_should_begin_with_t.symbols = type_parameter +dotnet_naming_rule.type_parameters_should_begin_with_t.style = begins_with_t -dotnet_naming_symbols.type_parameter.applicable_kinds = type_parameter -dotnet_naming_style.begins_with_t.required_prefix = T -dotnet_naming_style.begins_with_t.capitalization = pascal_case +dotnet_naming_symbols.type_parameter.applicable_kinds = type_parameter +dotnet_naming_style.begins_with_t.required_prefix = T +dotnet_naming_style.begins_with_t.capitalization = pascal_case # Types & members: PascalCase -dotnet_naming_rule.types_should_be_pascal_case.severity = warning -dotnet_naming_rule.types_should_be_pascal_case.symbols = types -dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case_style +dotnet_naming_rule.types_should_be_pascal_case.severity = warning +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case_style -dotnet_naming_symbols.types.applicable_kinds = class,struct,interface,enum,delegate -dotnet_naming_style.pascal_case_style.capitalization = pascal_case +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum, delegate +dotnet_naming_style.pascal_case_style.capitalization = pascal_case # Private fields: _camelCase -dotnet_naming_rule.private_fields_should_be_camel_case.severity = warning -dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields -dotnet_naming_rule.private_fields_should_be_camel_case.style = underscore_camel_case +dotnet_naming_rule.private_fields_should_be_camel_case.severity = warning +dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields +dotnet_naming_rule.private_fields_should_be_camel_case.style = underscore_camel_case -dotnet_naming_symbols.private_fields.applicable_kinds = field -dotnet_naming_symbols.private_fields.applicable_accessibilities = private,protected,private_protected -dotnet_naming_style.underscore_camel_case.required_prefix = _ -dotnet_naming_style.underscore_camel_case.capitalization = camel_case +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, private_protected +dotnet_naming_style.underscore_camel_case.required_prefix = _ +dotnet_naming_style.underscore_camel_case.capitalization = camel_case # Constants: PascalCase -dotnet_naming_rule.constants_should_be_pascal_case.severity = warning -dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants -dotnet_naming_rule.constants_should_be_pascal_case.style = pascal_case_style +dotnet_naming_rule.constants_should_be_pascal_case.severity = warning +dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants +dotnet_naming_rule.constants_should_be_pascal_case.style = pascal_case_style -dotnet_naming_symbols.constants.applicable_kinds = field,local -dotnet_naming_symbols.constants.required_modifiers = const +dotnet_naming_symbols.constants.applicable_kinds = field, local +dotnet_naming_symbols.constants.required_modifiers = const # Parameters & locals: camelCase -dotnet_naming_rule.parameters_should_be_camel_case.severity = warning -dotnet_naming_rule.parameters_should_be_camel_case.symbols = parameters -dotnet_naming_rule.parameters_should_be_camel_case.style = camel_case_style +dotnet_naming_rule.parameters_should_be_camel_case.severity = warning +dotnet_naming_rule.parameters_should_be_camel_case.symbols = parameters +dotnet_naming_rule.parameters_should_be_camel_case.style = camel_case_style -dotnet_naming_symbols.parameters.applicable_kinds = parameter,local -dotnet_naming_style.camel_case_style.capitalization = camel_case +dotnet_naming_symbols.parameters.applicable_kinds = parameter, local +dotnet_naming_style.camel_case_style.capitalization = camel_case # ─── XML / CSPROJ ───────────────────────────────────────────────────────────── [*.{xml,csproj,props,targets,resx}] diff --git a/APITemplate.slnx b/APITemplate.slnx index 252da88e..33d45f4c 100644 --- a/APITemplate.slnx +++ b/APITemplate.slnx @@ -1,47 +1,47 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Directory.Build.targets b/Directory.Build.targets index 5a161a05..a6ca4ef4 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -8,11 +8,11 @@ StandardOutputImportance="Low" StandardErrorImportance="High" WorkingDirectory="$(MSBuildThisFileDirectory)" - ContinueOnError="true" /> + ContinueOnError="true"/> + ContinueOnError="true"/> diff --git a/Directory.Packages.props b/Directory.Packages.props index 876fee04..bcf0c597 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,86 +3,86 @@ true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index 7573b00a..e4a385dc 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,17 @@ [![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. +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 | @@ -31,35 +34,59 @@ Step-by-step guides for the most common workflows in this project: ## 🚀 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 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. +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 @@ -243,22 +270,24 @@ classDiagram ## 🛠 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 +* **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. +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 @@ -268,9 +297,9 @@ APITemplate.Domain ← APITemplate.Application ← APITemplate.Infrastructur ### 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.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 | @@ -332,22 +361,29 @@ tests/APITemplate.Tests/ ### 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. +- 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. +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). +> **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 | @@ -357,7 +393,7 @@ All versioned REST resource endpoints sit under the base path `api/v{version}`. ### 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 | @@ -368,7 +404,7 @@ All versioned REST resource endpoints sit under the base path `api/v{version}`. ### 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 | @@ -378,7 +414,7 @@ All versioned REST resource endpoints sit under the base path `api/v{version}`. ### 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 | @@ -388,7 +424,7 @@ All versioned REST resource endpoints sit under the base path `api/v{version}`. ### 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 | @@ -399,7 +435,7 @@ All versioned REST resource endpoints sit under the base path `api/v{version}`. ### 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 | @@ -408,21 +444,27 @@ All versioned REST resource endpoints sit under the base path `api/v{version}`. ## ⚙️ Configuration Reference -All configuration lives in `appsettings.json` (production defaults) and is overridden by `appsettings.Development.json` locally or by environment variables at runtime. +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. +> **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. +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. | @@ -430,13 +472,13 @@ Configuration sections are bound to strongly-typed `IOptions` classes registe ### 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. | @@ -446,7 +488,7 @@ Configuration sections are bound to strongly-typed `IOptions` classes registe ### 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. | @@ -456,14 +498,14 @@ Configuration sections are bound to strongly-typed `IOptions` classes registe ### 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. | @@ -471,7 +513,7 @@ Configuration sections are bound to strongly-typed `IOptions` classes registe ### 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). | @@ -481,7 +523,7 @@ Configuration sections are bound to strongly-typed `IOptions` classes registe ### 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). | @@ -489,26 +531,28 @@ Configuration sections are bound to strongly-typed `IOptions` classes registe ### 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. +> **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). +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 | @@ -517,12 +561,12 @@ Authentication is handled by **Keycloak** using a hybrid approach that supports ### 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`) | +| 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 @@ -546,7 +590,8 @@ Authentication is handled by **Keycloak** using a hybrid approach that supports 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 +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 @@ -561,12 +606,17 @@ TOKEN=$(curl -s -X POST http://localhost:8180/realms/api-template/protocol/openi 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. +> **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*. + +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 }) { @@ -588,6 +638,7 @@ query { ``` **Example GraphQL Mutation:** + ```graphql mutation { createProducts(input: { @@ -609,32 +660,46 @@ mutation { ## 🏆 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. +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`. +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. +`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. +- 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. +- 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 @@ -674,7 +739,8 @@ await _unitOfWork.ExecuteInTransactionAsync(async () => ### 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. +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 @@ -692,13 +758,15 @@ public sealed class ProductSpecification : Specification : AbstractValidator } ``` -Validator classes are auto-discovered via `AddValidatorsFromAssemblyContaining()` — no manual registration needed. +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. +`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 | @@ -731,6 +803,7 @@ Validator classes are auto-discovered via `AddValidatorsFromAssemblyContaining` so integration tests can override them without rebuilding the host. +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: +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. +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. +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 | @@ -824,7 +905,9 @@ 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. +`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 @@ -848,7 +931,7 @@ Only the compiled artefacts from Stage 2 are copied into the slim Stage 3 runtim ### 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 | @@ -859,7 +942,9 @@ Only the compiled artefacts from Stage 2 are copied into the slim Stage 3 runtim ### 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. +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 @@ -876,7 +961,8 @@ Controller / GraphQL Resolver #### 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: +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 @@ -937,7 +1023,9 @@ GraphQL resolvers and DataLoaders follow the same pattern using `[Service] IMess #### 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: +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 @@ -949,7 +1037,9 @@ 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. +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 @@ -966,9 +1056,13 @@ builder.Host.UseWolverine(opts => ``` **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. + +- 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. @@ -976,12 +1070,13 @@ builder.Host.UseWolverine(opts => ## 🗄 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. +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 | | ✅ | @@ -1089,12 +1184,13 @@ 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. +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 | ✅ | | @@ -1139,14 +1235,15 @@ public sealed class VideoProductData : ProductData } ``` -MongoDB stores a `_t` discriminator field automatically, enabling polymorphic queries against the single `product_data` collection. +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 | @@ -1181,27 +1278,32 @@ 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: +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. +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. +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 | @@ -1211,7 +1313,9 @@ The repository maintains an inclusive combination of **Unit Tests** and **Integr ### 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. +`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 @@ -1241,17 +1345,20 @@ 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). + +* [.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: +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 @@ -1274,6 +1381,7 @@ EF Core migrations and MongoDB migrations run automatically at startup — no ma ### 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/TODO.md b/TODO.md index dfab2ccf..73abfb38 100644 --- a/TODO.md +++ b/TODO.md @@ -4,57 +4,99 @@ ### Critical -- [x] **Authorization code duplication** — `PermissionAuthorizationHandler` and `PermissionPolicyProvider` exist in both `APITemplate.Api/Api/Authorization/` and `Identity.Api/Authorization/` with divergent implementations (different constructors, auth schemes, `[SensitiveData]` attributes). Move to SharedKernel or Contracts as the single source of truth. +- [x] **Authorization code duplication** — `PermissionAuthorizationHandler` and `PermissionPolicyProvider` exist in both + `APITemplate.Api/Api/Authorization/` and `Identity.Api/Authorization/` with divergent implementations (different + constructors, auth schemes, `[SensitiveData]` attributes). Move to SharedKernel or Contracts as the single source of + truth. ### High Priority -- [x] **Mixed error handling patterns** — unified on `ErrorOr` return pattern. Exception-based classes (`NotFoundException`, `ConflictException`, `ValidationException`, `AppException` hierarchy) removed. `ApiExceptionHandler` is now a safety net for `DbUpdateConcurrencyException` (409) and unhandled exceptions (500) only. `FluentValidationActionFilter` removed; validation runs through Wolverine middleware. -- [x] **Options classes split between SharedKernel and modules** — `BffOptions`, `KeycloakOptions`, `CorsOptions`, `EmailOptions`, `SystemIdentityOptions` exist in both places. Module-specific options (`BackgroundJobsOptions`, `FileStorageOptions`) are in SharedKernel where they don't belong. Each module should own its options; SharedKernel should contain only truly shared types. -- [ ] **Anemic domain models** — `Tenant`, `StoredFile` and others are pure data containers. Business logic (activate/deactivate, status transitions) leaks into application handlers. Add domain methods and enforce invariants in entity constructors. +- [x] **Mixed error handling patterns** — unified on `ErrorOr` return pattern. Exception-based classes ( + `NotFoundException`, `ConflictException`, `ValidationException`, `AppException` hierarchy) removed. + `ApiExceptionHandler` is now a safety net for `DbUpdateConcurrencyException` (409) and unhandled exceptions (500) + only. `FluentValidationActionFilter` removed; validation runs through Wolverine middleware. +- [x] **Options classes split between SharedKernel and modules** — `BffOptions`, `KeycloakOptions`, `CorsOptions`, + `EmailOptions`, `SystemIdentityOptions` exist in both places. Module-specific options (`BackgroundJobsOptions`, + `FileStorageOptions`) are in SharedKernel where they don't belong. Each module should own its options; SharedKernel + should contain only truly shared types. +- [ ] **Anemic domain models** — `Tenant`, `StoredFile` and others are pure data containers. Business logic ( + activate/deactivate, status transitions) leaks into application handlers. Add domain methods and enforce invariants in + entity constructors. ### Medium Priority -- [ ] **Business logic in handlers** — `CreateProductsCommand` creates entities and relationships directly in the handler. `CreateUserCommand` contains compensating transaction logic (Keycloak + DB rollback). Extract to factory methods on entities and domain services. -- [ ] **Inconsistent logging** — only `ApiExceptionHandlerLogs.cs` and `UnitOfWorkLogs.cs` use source-generated `[LoggerMessage]` with event IDs. All other modules use inline `logger.LogXxx()`. Adopt source-generated logging with a per-module event ID range allocation strategy. -- [x] **Incomplete health checks** — only PostgreSQL and Keycloak are covered. Missing: Redis/Dragonfly, MongoDB (used by ProductCatalog), Wolverine messaging. Add `AddDragonflyHealthCheck()`, `AddMongoDbHealthCheck()` using the existing helper extension pattern. -- [ ] **Soft delete cascade via three mechanisms** — the same business rule (cascade deletes on soft-delete) is implemented via database cascade rules, infrastructure `SoftDeleteProcessor`, and Wolverine event handlers simultaneously. Consolidate to event-driven approach only. -- [ ] **`ClearCategoryAsync` bypasses EF Core change tracker** — `ExecuteUpdateAsync` is a bulk SQL operation that skips the DbContext tracker. If products are tracked in the same session (e.g. loaded during validation), their in-memory `CategoryId` stays non-null while the DB has `null`; a subsequent `SaveChanges` would overwrite the DB back. Verify no tracked products overlap with the bulk update, or invalidate affected entries after the call. -- [ ] **Missing `CategorySoftDeletedNotification`** — category soft-delete (both `DeleteCategoriesCommand` and `TenantCascadeDeleteHandler`) publishes no notification. Product soft-delete publishes `ProductSoftDeletedNotification` which Reviews consumes. Any future module needing to react to category deletion has no hook. Add a `CategorySoftDeletedNotification` and publish it from both delete paths. +- [ ] **Business logic in handlers** — `CreateProductsCommand` creates entities and relationships directly in the + handler. `CreateUserCommand` contains compensating transaction logic (Keycloak + DB rollback). Extract to factory + methods on entities and domain services. +- [ ] **Inconsistent logging** — only `ApiExceptionHandlerLogs.cs` and `UnitOfWorkLogs.cs` use source-generated + `[LoggerMessage]` with event IDs. All other modules use inline `logger.LogXxx()`. Adopt source-generated logging with + a per-module event ID range allocation strategy. +- [x] **Incomplete health checks** — only PostgreSQL and Keycloak are covered. Missing: Redis/Dragonfly, MongoDB (used + by ProductCatalog), Wolverine messaging. Add `AddDragonflyHealthCheck()`, `AddMongoDbHealthCheck()` using the existing + helper extension pattern. +- [ ] **Soft delete cascade via three mechanisms** — the same business rule (cascade deletes on soft-delete) is + implemented via database cascade rules, infrastructure `SoftDeleteProcessor`, and Wolverine event handlers + simultaneously. Consolidate to event-driven approach only. +- [ ] **`ClearCategoryAsync` bypasses EF Core change tracker** — `ExecuteUpdateAsync` is a bulk SQL operation that skips + the DbContext tracker. If products are tracked in the same session (e.g. loaded during validation), their in-memory + `CategoryId` stays non-null while the DB has `null`; a subsequent `SaveChanges` would overwrite the DB back. Verify no + tracked products overlap with the bulk update, or invalidate affected entries after the call. +- [ ] **Missing `CategorySoftDeletedNotification`** — category soft-delete (both `DeleteCategoriesCommand` and + `TenantCascadeDeleteHandler`) publishes no notification. Product soft-delete publishes + `ProductSoftDeletedNotification` which Reviews consumes. Any future module needing to react to category deletion has + no hook. Add a `CategorySoftDeletedNotification` and publish it from both delete paths. ### Low Priority -- [ ] **Aggregate boundary violation** — `Product` entity has `Category? Category` navigation property — a direct reference to another aggregate root. Replace with `CategoryId`-only reference; load via query when needed. -- [ ] **Missing value objects** — `Email` (string with no RFC validation), `Rating` (int with no range enforcement), `Price` (no currency/precision semantics), `TenantCode` (string with implicit format rules) should be strong value objects enforcing their invariants. -- [ ] **Duplicate repository interfaces** — `IProductRepository` is defined in both `ProductCatalog.Domain/Interfaces/` and `ProductCatalog.Application/Features/Product/Repositories/`. Keep one definition in the Domain layer. -- [ ] **Integration test gap — `ProductDataLinks` cascade not verified** — `PostgresTenantSoftDeleteCascadeTests` verifies products and categories are soft-deleted but does not assert `ProductDataLinks` are also soft-deleted in the same cascade. Add assertion to guard against silent regression. -- [ ] **`ProductDataLink` unique constraint** — `Product.SyncProductDataLinks` was previously guarded with `GroupBy().First()` to survive duplicate `ProductDataId` entries; simplified to `ToDictionary()` which throws on duplicates. Verify a unique constraint on `(ProductId, ProductDataId)` exists in the schema; add the migration if missing. +- [ ] **Aggregate boundary violation** — `Product` entity has `Category? Category` navigation property — a direct + reference to another aggregate root. Replace with `CategoryId`-only reference; load via query when needed. +- [ ] **Missing value objects** — `Email` (string with no RFC validation), `Rating` (int with no range enforcement), + `Price` (no currency/precision semantics), `TenantCode` (string with implicit format rules) should be strong value + objects enforcing their invariants. +- [ ] **Duplicate repository interfaces** — `IProductRepository` is defined in both `ProductCatalog.Domain/Interfaces/` + and `ProductCatalog.Application/Features/Product/Repositories/`. Keep one definition in the Domain layer. +- [ ] **Integration test gap — `ProductDataLinks` cascade not verified** — `PostgresTenantSoftDeleteCascadeTests` + verifies products and categories are soft-deleted but does not assert `ProductDataLinks` are also soft-deleted in the + same cascade. Add assertion to guard against silent regression. +- [ ] **`ProductDataLink` unique constraint** — `Product.SyncProductDataLinks` was previously guarded with + `GroupBy().First()` to survive duplicate `ProductDataId` entries; simplified to `ToDictionary()` which throws on + duplicates. Verify a unique constraint on `(ProductId, ProductDataId)` exists in the schema; add the migration if + missing. --- ## Wolverine Outbox & Durable Messaging -- [ ] Enable `UseDurableOutboxOnAllSendingEndpoints()` and `UseDurableInboxOnAllListeners()` for reliable eventual consistency across modules. +- [ ] Enable `UseDurableOutboxOnAllSendingEndpoints()` and `UseDurableInboxOnAllListeners()` for reliable eventual + consistency across modules. - [x] Configure `PersistMessagesWithPostgresql()` for durable message persistence in PostgreSQL. - [ ] Apply `DurabilityMode.Balanced` via shared Wolverine conventions (`ApplySharedConventions()`). -- [x] Migrate handler return types to `(ErrorOr, OutgoingMessages)` tuples for transactional cascade messages instead of manual `bus.PublishAsync()`. -- [ ] Extract `CacheInvalidationCascades` helper (`.ForTag()`, `.ForTags()`, `.None`) to eliminate cache invalidation boilerplate. +- [x] Migrate handler return types to `(ErrorOr, OutgoingMessages)` tuples for transactional cascade messages instead + of manual `bus.PublishAsync()`. +- [ ] Extract `CacheInvalidationCascades` helper (`.ForTag()`, `.ForTags()`, `.None`) to eliminate cache invalidation + boilerplate. ## Wolverine Validation Middleware -- [x] Implement `ErrorOrValidationMiddleware` as Wolverine `Before` middleware — automatic FluentValidation for all commands without manual validation in handlers. -- [x] Add `FluentValidationActionFilter` for MVC controller endpoints (validates action parameters via DI-resolved validators, returns 400 with `ValidationProblemDetails`). +- [x] Implement `ErrorOrValidationMiddleware` as Wolverine `Before` middleware — automatic FluentValidation for all + commands without manual validation in handlers. +- [x] Add `FluentValidationActionFilter` for MVC controller endpoints (validates action parameters via DI-resolved + validators, returns 400 with `ValidationProblemDetails`). ## Integration Events -- [x] Define typed integration event contracts in `Contracts` project (e.g. `ProductCreatedIntegrationEvent`, `TenantDeactivatedIntegrationEvent`). +- [x] Define typed integration event contracts in `Contracts` project (e.g. `ProductCreatedIntegrationEvent`, + `TenantDeactivatedIntegrationEvent`). - [x] Add integration event handlers per module for cross-module cascade operations (soft-delete propagation, cleanup). ## Request Context & Observability Enhancements -- [x] Enhance `RequestContextMiddleware` with tenant ID extraction from claims and Activity tag enrichment for distributed tracing. +- [x] Enhance `RequestContextMiddleware` with tenant ID extraction from claims and Activity tag enrichment for + distributed tracing. - [x] Add `IHttpMetricsTagsFeature` enrichment (api_surface, authenticated) for custom telemetry dimensions. - [x] Return `X-Trace-Id` response header alongside existing `X-Correlation-Id` and `X-Elapsed-Ms`. -- [x] Enhance Serilog request logging with intelligent log levels (499 client abort vs 5xx server error vs 4xx validation). +- [x] Enhance Serilog request logging with intelligent log levels (499 client abort vs 5xx server error vs 4xx + validation). - [x] Enrich Serilog diagnostic context with `RequestHost` and `RequestScheme`. ## Logging Redaction @@ -67,52 +109,65 @@ - [x] Add tenant claim validation in JWT bearer configuration — require valid tenant claim or service account prefix. - [x] Add `KeycloakClaimsPrincipalMapper.MapClaims()` for Keycloak claim transformation. -- [x] Add `AuthorizationResponsesOperationTransformer` for OpenAPI — automatically document 401/403 on `[Authorize]` endpoints. +- [x] Add `AuthorizationResponsesOperationTransformer` for OpenAPI — automatically document 401/403 on `[Authorize]` + endpoints. - [x] Add `BearerSecuritySchemeDocumentTransformer` — dynamic Keycloak OAuth2 authorization code flow in OpenAPI. ## Exception Handling Enhancements -- [x] Enhance `ApiExceptionHandler` with structured error metadata preservation in `ProblemDetails.Extensions["metadata"]`. -- [x] Add error code fallback logic (check `exception.ErrorCode` then `metadata["errorCode"]` then `ErrorCatalog.General.Unknown`). +- [x] Enhance `ApiExceptionHandler` with structured error metadata preservation in + `ProblemDetails.Extensions["metadata"]`. +- [x] Add error code fallback logic (check `exception.ErrorCode` then `metadata["errorCode"]` then + `ErrorCatalog.General.Unknown`). - [x] Differentiate logging by status code (LogError for 5xx, LogWarning for handled exceptions). ## Output Caching Enhancements - [x] Add `TenantAwareOutputCachePolicy` — cache key isolation per tenant to prevent cross-tenant data leaks. -- [x] Expand cache policies to cover all cacheable resources (Tenants, TenantInvitations, Users, Files alongside existing Products, Categories, Reviews, ProductData). +- [x] Expand cache policies to cover all cacheable resources (Tenants, TenantInvitations, Users, Files alongside + existing Products, Categories, Reviews, ProductData). ## Controller Base Enhancements -- [ ] Add helper methods to `ApiControllerBase`: `InvokeToActionResultAsync()`, `InvokeToBatchResultAsync()`, `InvokeToNoContentResultAsync()`, `InvokeToOkResultAsync()`, `InvokeToCreatedResultAsync()`. +- [ ] Add helper methods to `ApiControllerBase`: `InvokeToActionResultAsync()`, `InvokeToBatchResultAsync()`, + `InvokeToNoContentResultAsync()`, `InvokeToOkResultAsync()`, `InvokeToCreatedResultAsync()`. - [ ] Add `ErrorOrHttpExtensions` for minimal API ErrorOr-to-ProblemDetails mapping. ## Configuration Validation -- [x] Implement `AddValidatedOptions()` extension — automatic DataAnnotations validation with early startup failure on invalid configuration. +- [x] Implement `AddValidatedOptions()` extension — automatic DataAnnotations validation with early startup + failure on invalid configuration. ## Idempotency -- [x] Implement `IdempotencyActionFilter` — at-most-once semantics via `Idempotency-Key` header with cached responses, configurable TTL, lock timeouts, and 409 Conflict on concurrent processing. +- [x] Implement `IdempotencyActionFilter` — at-most-once semantics via `Idempotency-Key` header with cached responses, + configurable TTL, lock timeouts, and 409 Conflict on concurrent processing. ## Health Check Helpers -- [x] Extract health check helper extensions: `AddPostgreSqlHealthCheck()`, `AddDragonflyHealthCheck()` with standardized tags and naming. +- [x] Extract health check helper extensions: `AddPostgreSqlHealthCheck()`, `AddDragonflyHealthCheck()` with + standardized tags and naming. ## Infrastructure Generics -- [x] Make `UnitOfWork` generic over `DbContext` instead of hardcoded to `AppDbContext` — enables reuse across per-module contexts. +- [x] Make `UnitOfWork` generic over `DbContext` instead of hardcoded to `AppDbContext` — enables reuse across + per-module contexts. - [x] Make `RepositoryBase` accept generic `DbContext` parameter instead of casting to `AppDbContext`. -- [x] Extract `TenantAuditableDbContext` as abstract reusable base class with `TenantAuditableDbContextDependencies` record for dependency encapsulation. (ModuleDbContext already serves this role) +- [x] Extract `TenantAuditableDbContext` as abstract reusable base class with `TenantAuditableDbContextDependencies` + record for dependency encapsulation. (ModuleDbContext already serves this role) - [ ] Make `IEntityNormalizationService` optional (nullable) in DbContext — not all modules need normalization. -- [ ] Improve `DesignTimeConnectionStringResolver` with dynamic path resolution (walk up directory tree) and environment-specific appsettings loading. +- [ ] Improve `DesignTimeConnectionStringResolver` with dynamic path resolution (walk up directory tree) and + environment-specific appsettings loading. ## Entity Navigation Properties -- [ ] Add explicit bidirectional navigation properties on aggregate roots (e.g. `Tenant.Users`, `AppUser.Tenant`) for better DDD modeling and EF Core relationship configuration. +- [ ] Add explicit bidirectional navigation properties on aggregate roots (e.g. `Tenant.Users`, `AppUser.Tenant`) for + better DDD modeling and EF Core relationship configuration. ## Validation Metrics -- [ ] Add `IValidationMetrics` interface for recording validation failures with telemetry (source, argument type, failure list) — separates observability from application logic. +- [ ] Add `IValidationMetrics` interface for recording validation failures with telemetry (source, argument type, + failure list) — separates observability from application logic. --- @@ -149,12 +204,14 @@ 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 @@ -180,8 +237,10 @@ Implement real-time notifications and chat using ASP.NET Core SignalR. - [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. +- [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 @@ -194,15 +253,17 @@ Implement real-time notifications and chat using ASP.NET Core SignalR. - [ ] 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.) +- [ ] 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 -- [x] Introduce `Result` pattern (e.g. via `OneOf` or custom type) for expected failures instead of exceptions as flow control. +- [x] Introduce `Result` pattern (e.g. via `OneOf` or custom type) for expected failures instead of exceptions as + flow control. - [x] Migrate validation, not-found, and conflict scenarios from exceptions to explicit return types. ## Testing Improvements @@ -210,12 +271,15 @@ Implement real-time notifications and chat using ASP.NET Core SignalR. - [x] Migrate key integration tests from in-memory EF Core to Testcontainers PostgreSQL for realistic database behavior. - [x] Add tests covering PostgreSQL-specific behavior: migrations, `xmin` concurrency tokens, full-text search queries. - [ ] Add infrastructure smoke tests (startup validation, OpenAPI parity across modules). -- [ ] Extract shared test utilities into `Tests.Common` library (`AsyncPoll` for eventual consistency, `TestDatabaseLifecycle`, `TestDataHelper`). -- [ ] Implement abstract `ServiceFactoryBase` for consistent `WebApplicationFactory` configuration across module tests. +- [ ] Extract shared test utilities into `Tests.Common` library (`AsyncPoll` for eventual consistency, + `TestDatabaseLifecycle`, `TestDataHelper`). +- [ ] Implement abstract `ServiceFactoryBase` for consistent `WebApplicationFactory` configuration across + module tests. ## Modularization (Phase 1) -- [x] Split `AppDbContext` into per-module contexts (ProductCatalogDbContext, ReviewsDbContext, IdentityDbContext, etc.). +- [x] Split `AppDbContext` into per-module contexts (ProductCatalogDbContext, ReviewsDbContext, IdentityDbContext, + etc.). - [x] 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. @@ -224,40 +288,70 @@ Implement real-time notifications and chat using ASP.NET Core SignalR. ### 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). +**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. +**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. -**Wolverine Outbox & Handler Tuples** — Enable durable outbox with PostgreSQL persistence and migrate handlers to `(ErrorOr, OutgoingMessages)` return types. Provides transactional message delivery guarantees without external message broker. Foundation for reliable cross-module communication. +**Wolverine Outbox & Handler Tuples** — Enable durable outbox with PostgreSQL persistence and migrate handlers to +`(ErrorOr, OutgoingMessages)` return types. Provides transactional message delivery guarantees without external +message broker. Foundation for reliable cross-module communication. -**Wolverine Validation Middleware** — `ErrorOrValidationMiddleware` eliminates manual FluentValidation calls in every handler. Automatic, consistent validation across all commands with proper ErrorOr integration. Low effort, high consistency impact. +**Wolverine Validation Middleware** — `ErrorOrValidationMiddleware` eliminates manual FluentValidation calls in every +handler. Automatic, consistent validation across all commands with proper ErrorOr integration. Low effort, high +consistency 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 extraction without changing business logic. See `TODO-Architecture.md` for the full plan. +**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 extraction without changing business logic. See `TODO-Architecture.md` for +the full plan. -**Request Context & Observability** — Enhance middleware with tenant tracing, metrics enrichment, and intelligent Serilog log levels. Improves debugging, monitoring, and distributed trace correlation with minimal code changes. +**Request Context & Observability** — Enhance middleware with tenant tracing, metrics enrichment, and intelligent +Serilog log levels. Improves debugging, monitoring, and distributed trace correlation with minimal code changes. -**Exception Handling & Logging Redaction** — Structured error metadata in ProblemDetails, differentiated log levels by status code, and data classification for log redaction (HMAC for sensitive, erase for personal). Security and observability improvement. +**Exception Handling & Logging Redaction** — Structured error metadata in ProblemDetails, differentiated log levels by +status code, and data classification for log redaction (HMAC for sensitive, erase for personal). Security and +observability improvement. -**Authentication Enhancements** — Tenant claim validation in JWT, Keycloak claims mapping, and OpenAPI security transformers. Strengthens multi-tenant security and improves API documentation accuracy. +**Authentication Enhancements** — Tenant claim validation in JWT, Keycloak claims mapping, and OpenAPI security +transformers. Strengthens multi-tenant security and improves API documentation accuracy. -**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. +**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. -**Infrastructure Generics** — Make `UnitOfWork` and `RepositoryBase` generic over `DbContext`. Required for per-module context split (Modularization Phase 1) and eliminates tight coupling to `AppDbContext`. +**Infrastructure Generics** — Make `UnitOfWork` and `RepositoryBase` generic over `DbContext`. Required for per-module +context split (Modularization Phase 1) and eliminates tight coupling to `AppDbContext`. ### Lower Priority -**Controller Base Helpers** — Reduce controller boilerplate with `InvokeToActionResultAsync()` and similar methods. Quality-of-life improvement. +**Controller Base Helpers** — Reduce controller boilerplate with `InvokeToActionResultAsync()` and similar methods. +Quality-of-life improvement. -**Configuration Validation** — `AddValidatedOptions()` catches invalid configuration at startup instead of runtime. Prevents production configuration bugs. +**Configuration Validation** — `AddValidatedOptions()` catches invalid configuration at startup instead of +runtime. Prevents production configuration bugs. -**Output Caching** — Tenant-aware cache policy and expanded coverage. Prevents cross-tenant data leaks and improves cache hit rates. +**Output Caching** — Tenant-aware cache policy and expanded coverage. Prevents cross-tenant data leaks and improves +cache hit rates. -**Idempotency** — `IdempotencyActionFilter` for at-most-once semantics on mutation endpoints. Important for webhook receivers and external API integrations. +**Idempotency** — `IdempotencyActionFilter` for at-most-once semantics on mutation endpoints. Important for webhook +receivers and external API integrations. -**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. +**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 sharing types with frontend clients. +**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 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. +**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/docker-compose.production.yml b/docker-compose.production.yml index 7437d8de..90efabfd 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -23,7 +23,7 @@ services: loki: image: grafana/loki:3.5.5 restart: unless-stopped - command: ["-config.file=/etc/loki/config.yml"] + command: [ "-config.file=/etc/loki/config.yml" ] volumes: - ./infrastructure/observability/loki/config.yml:/etc/loki/config.yml:ro - lokidata:/loki @@ -31,7 +31,7 @@ services: tempo: image: grafana/tempo:2.9.1 restart: unless-stopped - command: ["-config.file=/etc/tempo/config.yml"] + command: [ "-config.file=/etc/tempo/config.yml" ] volumes: - ./infrastructure/observability/tempo/config.yml:/etc/tempo/config.yml:ro - tempodata:/var/tempo @@ -63,7 +63,7 @@ services: volumes: - pgdata:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME}"] + test: [ "CMD-SHELL", "pg_isready -U ${DB_USERNAME}" ] interval: 10s timeout: 5s retries: 5 @@ -74,7 +74,7 @@ services: volumes: - mongodata:/data/db healthcheck: - test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + test: [ "CMD", "mongosh", "--eval", "db.adminCommand('ping')" ] interval: 10s timeout: 5s retries: 5 @@ -89,7 +89,7 @@ services: volumes: - keycloak-pgdata:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${KC_DB_USERNAME}"] + test: [ "CMD-SHELL", "pg_isready -U ${KC_DB_USERNAME}" ] interval: 10s timeout: 5s retries: 5 @@ -112,7 +112,7 @@ services: keycloak-db: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8443"] + test: [ "CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8443" ] interval: 30s timeout: 10s retries: 5 @@ -125,7 +125,7 @@ services: - dragonfly-master-data:/data command: dragonfly --maxmemory 512mb --proactor_threads 2 --requirepass ${DRAGONFLY_PASSWORD:-} healthcheck: - test: ["CMD", "redis-cli", "-a", "${DRAGONFLY_PASSWORD:-}", "ping"] + test: [ "CMD", "redis-cli", "-a", "${DRAGONFLY_PASSWORD:-}", "ping" ] interval: 10s timeout: 5s retries: 5 @@ -140,7 +140,7 @@ services: dragonfly-master: condition: service_healthy healthcheck: - test: ["CMD", "redis-cli", "-a", "${DRAGONFLY_PASSWORD:-}", "ping"] + test: [ "CMD", "redis-cli", "-a", "${DRAGONFLY_PASSWORD:-}", "ping" ] interval: 10s timeout: 5s retries: 5 @@ -156,7 +156,7 @@ services: dragonfly-replica: condition: service_healthy healthcheck: - test: ["CMD", "haproxy", "-c", "-f", "/usr/local/etc/haproxy/haproxy.cfg"] + test: [ "CMD", "haproxy", "-c", "-f", "/usr/local/etc/haproxy/haproxy.cfg" ] interval: 10s timeout: 5s retries: 5 diff --git a/docker-compose.yml b/docker-compose.yml index c6a40ff4..58f5ba9c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,7 +26,7 @@ services: loki: image: grafana/loki:3.5.5 - command: ["-config.file=/etc/loki/config.yml"] + command: [ "-config.file=/etc/loki/config.yml" ] ports: - "3100:3100" volumes: @@ -35,7 +35,7 @@ services: tempo: image: grafana/tempo:2.9.1 - command: ["-config.file=/etc/tempo/config.yml"] + command: [ "-config.file=/etc/tempo/config.yml" ] ports: - "3200:3200" - "4319:4317" @@ -63,7 +63,7 @@ services: aspire-dashboard: image: mcr.microsoft.com/dotnet/aspire-dashboard:9.5 - profiles: ["aspire"] + profiles: [ "aspire" ] ports: - "${ASPIRE_OTLP_GRPC_PORT:-4317}:18889" - "${ASPIRE_OTLP_HTTP_PORT:-4318}:18890" @@ -83,7 +83,7 @@ services: volumes: - pgdata:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] + test: [ "CMD-SHELL", "pg_isready -U postgres" ] interval: 10s timeout: 5s retries: 5 @@ -95,7 +95,7 @@ services: volumes: - mongodata:/data/db healthcheck: - test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + test: [ "CMD", "mongosh", "--eval", "db.adminCommand('ping')" ] interval: 10s timeout: 5s retries: 5 @@ -109,7 +109,7 @@ services: volumes: - keycloak-pgdata:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U keycloak"] + test: [ "CMD-SHELL", "pg_isready -U keycloak" ] interval: 10s timeout: 5s retries: 5 @@ -133,7 +133,7 @@ services: volumes: - ./infrastructure/keycloak/realms:/opt/keycloak/data/import healthcheck: - test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8180"] + test: [ "CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8180" ] interval: 30s timeout: 10s retries: 5 @@ -147,7 +147,7 @@ services: - dragonflydata:/data command: dragonfly --maxmemory 512mb --proactor_threads 2 healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: [ "CMD", "redis-cli", "ping" ] interval: 10s timeout: 5s retries: 5 @@ -162,7 +162,7 @@ services: MP_SMTP_AUTH_ACCEPT_ANY: 1 MP_SMTP_AUTH_ALLOW_INSECURE: 1 healthcheck: - test: ["CMD", "mailpit", "ready-check"] + test: [ "CMD", "mailpit", "ready-check" ] interval: 10s timeout: 5s retries: 5 diff --git a/docs/README.md b/docs/README.md index 94880f79..250d32e8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,23 +1,24 @@ # How-To Guides -Step-by-step workflow guides for this API template. Each guide covers a complete end-to-end example you can follow to extend the project. +Step-by-step workflow guides for this API template. Each guide covers a complete end-to-end example you can follow to +extend the project. -| Guide | What it covers | -|-------|----------------| -| [GraphQL Endpoint](graphql-endpoint.md) | Create a type, query, mutation, and DataLoader with HotChocolate | -| [REST Endpoint](rest-endpoint.md) | Full workflow: entity → DTO → validator → Wolverine handler → controller | -| [Endpoint Showcases](endpoint-showcase.md) | SSE streaming, file upload/download, async jobs, batch, idempotency, JSON Patch, webhooks | -| [EF Core Migration](ef-migration.md) | Add and apply PostgreSQL schema migrations with EF Core | -| [MongoDB Migration](mongodb-migration.md) | Create index and data migrations with Kot.MongoDB.Migrations | -| [Transactions](transactions.md) | Wrap multiple operations in an atomic Unit of Work transaction | -| [Authentication](AUTHENTICATION.md) | JWT login flow, protecting endpoints, and production guidance | -| [Stored Procedures](stored-procedures.md) | Add a PostgreSQL function and call it safely from C# | -| [MongoDB Polymorphism](mongodb-polymorphism.md) | Store multiple document subtypes in a single MongoDB collection | -| [Validation](validation.md) | Add FluentValidation rules, cross-field rules, and shared validators | -| [Specifications](specifications.md) | Write reusable EF Core query specifications with Ardalis.Specification | -| [Scalar & GraphQL UI](scalar-and-graphql-ui.md) | Use the Scalar REST explorer and Nitro GraphQL playground | -| [Testing](testing.md) | Write unit tests (services, validators, repositories) and integration tests | -| [Observability](observability.md) | Run OpenTelemetry locally with Aspire Dashboard or Grafana LGTM | -| [Caching](CACHING.md) | Configure output caching, rate limiting, and DragonFly backing store | -| [Result Pattern](result-pattern.md) | Guidelines for introducing selective `Result` flow in phase 2 | -| [Git Hooks](GIT_HOOKS.md) | Auto-install Husky.Net hooks and format staged C# files with CSharpier | +| Guide | What it covers | +|-------------------------------------------------|-------------------------------------------------------------------------------------------| +| [GraphQL Endpoint](graphql-endpoint.md) | Create a type, query, mutation, and DataLoader with HotChocolate | +| [REST Endpoint](rest-endpoint.md) | Full workflow: entity → DTO → validator → Wolverine handler → controller | +| [Endpoint Showcases](endpoint-showcase.md) | SSE streaming, file upload/download, async jobs, batch, idempotency, JSON Patch, webhooks | +| [EF Core Migration](ef-migration.md) | Add and apply PostgreSQL schema migrations with EF Core | +| [MongoDB Migration](mongodb-migration.md) | Create index and data migrations with Kot.MongoDB.Migrations | +| [Transactions](transactions.md) | Wrap multiple operations in an atomic Unit of Work transaction | +| [Authentication](AUTHENTICATION.md) | JWT login flow, protecting endpoints, and production guidance | +| [Stored Procedures](stored-procedures.md) | Add a PostgreSQL function and call it safely from C# | +| [MongoDB Polymorphism](mongodb-polymorphism.md) | Store multiple document subtypes in a single MongoDB collection | +| [Validation](validation.md) | Add FluentValidation rules, cross-field rules, and shared validators | +| [Specifications](specifications.md) | Write reusable EF Core query specifications with Ardalis.Specification | +| [Scalar & GraphQL UI](scalar-and-graphql-ui.md) | Use the Scalar REST explorer and Nitro GraphQL playground | +| [Testing](testing.md) | Write unit tests (services, validators, repositories) and integration tests | +| [Observability](observability.md) | Run OpenTelemetry locally with Aspire Dashboard or Grafana LGTM | +| [Caching](CACHING.md) | Configure output caching, rate limiting, and DragonFly backing store | +| [Result Pattern](result-pattern.md) | Guidelines for introducing selective `Result` flow in phase 2 | +| [Git Hooks](GIT_HOOKS.md) | Auto-install Husky.Net hooks and format staged C# files with CSharpier | diff --git a/infrastructure/dragonfly/haproxy.cfg b/infrastructure/dragonfly/haproxy.cfg index c744fa95..2d78343c 100644 --- a/infrastructure/dragonfly/haproxy.cfg +++ b/infrastructure/dragonfly/haproxy.cfg @@ -1,19 +1,19 @@ global - maxconn 256 +maxconn 256 defaults - mode tcp - timeout connect 5s - timeout client 30s - timeout server 30s +mode tcp +timeout connect 5s +timeout client 30s +timeout server 30s frontend dragonfly_front - bind *:6379 - default_backend dragonfly_back +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 +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/infrastructure/keycloak/realms/api-template-realm.json b/infrastructure/keycloak/realms/api-template-realm.json index 419a9422..91a5c822 100644 --- a/infrastructure/keycloak/realms/api-template-realm.json +++ b/infrastructure/keycloak/realms/api-template-realm.json @@ -38,8 +38,14 @@ "name": "API Template Scalar UI", "enabled": true, "publicClient": true, - "redirectUris": ["http://localhost:5174/*", "http://localhost:8080/*"], - "webOrigins": ["http://localhost:5174", "http://localhost:8080"], + "redirectUris": [ + "http://localhost:5174/*", + "http://localhost:8080/*" + ], + "webOrigins": [ + "http://localhost:5174", + "http://localhost:8080" + ], "attributes": { "pkce.code.challenge.method": "S256" }, @@ -163,7 +169,9 @@ "firstName": "Admin", "lastName": "User", "attributes": { - "tenant_id": ["00000000-0000-0000-0000-000000000001"] + "tenant_id": [ + "00000000-0000-0000-0000-000000000001" + ] }, "credentials": [ { @@ -172,7 +180,9 @@ "temporary": false } ], - "realmRoles": ["PlatformAdmin"] + "realmRoles": [ + "PlatformAdmin" + ] } ] } diff --git a/infrastructure/observability/grafana/dashboards/apitemplate-overview.json b/infrastructure/observability/grafana/dashboards/apitemplate-overview.json index cadbef61..fad9448b 100644 --- a/infrastructure/observability/grafana/dashboards/apitemplate-overview.json +++ b/infrastructure/observability/grafana/dashboards/apitemplate-overview.json @@ -61,7 +61,9 @@ "justifyMode": "center", "orientation": "auto", "reduceOptions": { - "calcs": ["lastNotNull"], + "calcs": [ + "lastNotNull" + ], "fields": "", "values": false } @@ -117,7 +119,9 @@ "graphMode": "none", "justifyMode": "center", "reduceOptions": { - "calcs": ["lastNotNull"], + "calcs": [ + "lastNotNull" + ], "fields": "", "values": false } @@ -173,7 +177,9 @@ "graphMode": "none", "justifyMode": "center", "reduceOptions": { - "calcs": ["lastNotNull"], + "calcs": [ + "lastNotNull" + ], "fields": "", "values": false } @@ -210,7 +216,9 @@ "graphMode": "area", "justifyMode": "center", "reduceOptions": { - "calcs": ["lastNotNull"], + "calcs": [ + "lastNotNull" + ], "fields": "", "values": false } @@ -335,7 +343,9 @@ "graphMode": "area", "justifyMode": "center", "reduceOptions": { - "calcs": ["lastNotNull"], + "calcs": [ + "lastNotNull" + ], "fields": "", "values": false } @@ -372,7 +382,9 @@ "graphMode": "none", "justifyMode": "center", "reduceOptions": { - "calcs": ["lastNotNull"], + "calcs": [ + "lastNotNull" + ], "fields": "", "values": false } @@ -409,7 +421,9 @@ "graphMode": "none", "justifyMode": "center", "reduceOptions": { - "calcs": ["lastNotNull"], + "calcs": [ + "lastNotNull" + ], "fields": "", "values": false } @@ -449,7 +463,10 @@ "refresh": "10s", "schemaVersion": 41, "style": "dark", - "tags": ["apitemplate", "observability"], + "tags": [ + "apitemplate", + "observability" + ], "templating": { "list": [] }, diff --git a/infrastructure/observability/prometheus/prometheus.yml b/infrastructure/observability/prometheus/prometheus.yml index f3a66c77..4571b931 100644 --- a/infrastructure/observability/prometheus/prometheus.yml +++ b/infrastructure/observability/prometheus/prometheus.yml @@ -8,17 +8,17 @@ rule_files: scrape_configs: - job_name: prometheus static_configs: - - targets: ["prometheus:9090"] + - targets: [ "prometheus:9090" ] - job_name: alloy static_configs: - - targets: ["alloy:12345"] + - targets: [ "alloy:12345" ] - job_name: tempo static_configs: - - targets: ["tempo:3200"] + - targets: [ "tempo:3200" ] - job_name: loki metrics_path: /metrics static_configs: - - targets: ["loki:3100"] + - targets: [ "loki:3100" ] diff --git a/infrastructure/observability/tempo/config.yml b/infrastructure/observability/tempo/config.yml index fdbb4945..7c32e120 100644 --- a/infrastructure/observability/tempo/config.yml +++ b/infrastructure/observability/tempo/config.yml @@ -36,4 +36,4 @@ metrics_generator: overrides: defaults: metrics_generator: - processors: [service-graphs, span-metrics, local-blocks] + processors: [ service-graphs, span-metrics, local-blocks ] diff --git a/src/APITemplate/Api/APITemplate.csproj b/src/APITemplate/Api/APITemplate.csproj index 40e6823c..6cbfa9ff 100644 --- a/src/APITemplate/Api/APITemplate.csproj +++ b/src/APITemplate/Api/APITemplate.csproj @@ -5,50 +5,50 @@ enable - + - - - - - + + + + + - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/APITemplate/Api/Api/Cache/CacheInvalidationHandler.cs b/src/APITemplate/Api/Api/Cache/CacheInvalidationHandler.cs index f91a1f0c..a4c62081 100644 --- a/src/APITemplate/Api/Api/Cache/CacheInvalidationHandler.cs +++ b/src/APITemplate/Api/Api/Cache/CacheInvalidationHandler.cs @@ -8,5 +8,8 @@ public static Task HandleAsync( CacheInvalidationNotification @event, IOutputCacheInvalidationService outputCacheInvalidationService, CancellationToken ct - ) => outputCacheInvalidationService.EvictAsync(@event.CacheTag, ct); + ) + { + return outputCacheInvalidationService.EvictAsync(@event.CacheTag, ct); + } } diff --git a/src/APITemplate/Api/Api/ExceptionHandling/ApiExceptionHandlerLogs.cs b/src/APITemplate/Api/Api/ExceptionHandling/ApiExceptionHandlerLogs.cs index ba944270..9a0aad0a 100644 --- a/src/APITemplate/Api/Api/ExceptionHandling/ApiExceptionHandlerLogs.cs +++ b/src/APITemplate/Api/Api/ExceptionHandling/ApiExceptionHandlerLogs.cs @@ -3,8 +3,8 @@ namespace APITemplate.Api.ExceptionHandling; /// -/// Source-generated logging contract for . -/// Keeps log templates and event identifiers centralized, strongly typed, and allocation-friendly. +/// Source-generated logging contract for . +/// Keeps log templates and event identifiers centralized, strongly typed, and allocation-friendly. /// internal static partial class ApiExceptionHandlerLogs { diff --git a/src/APITemplate/Api/Api/Middleware/CsrfValidationMiddleware.cs b/src/APITemplate/Api/Api/Middleware/CsrfValidationMiddleware.cs index 6fccefd4..9a0b20f8 100644 --- a/src/APITemplate/Api/Api/Middleware/CsrfValidationMiddleware.cs +++ b/src/APITemplate/Api/Api/Middleware/CsrfValidationMiddleware.cs @@ -1,10 +1,13 @@ using Identity.Security; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; namespace APITemplate.Api.Middleware; /// -/// Middleware that enforces CSRF protection for cookie-authenticated requests. +/// Middleware that enforces CSRF protection for cookie-authenticated requests. /// public sealed class CsrfValidationMiddleware( RequestDelegate next, @@ -25,13 +28,13 @@ public async Task InvokeAsync(HttpContext context) if ( context.Request.Headers.TryGetValue( - Microsoft.Net.Http.Headers.HeaderNames.Authorization, - out Microsoft.Extensions.Primitives.StringValues authorizationValues + HeaderNames.Authorization, + out StringValues authorizationValues ) && authorizationValues.Any(static value => !string.IsNullOrEmpty(value) && value.StartsWith( - $"{Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerDefaults.AuthenticationScheme} ", + $"{JwtBearerDefaults.AuthenticationScheme} ", StringComparison.OrdinalIgnoreCase ) ) @@ -62,7 +65,7 @@ out Microsoft.Extensions.Primitives.StringValues authorizationValues if ( context.Request.Headers.TryGetValue( AuthConstants.Csrf.HeaderName, - out Microsoft.Extensions.Primitives.StringValues value + out StringValues value ) && value.Any(v => string.Equals(v, AuthConstants.Csrf.HeaderValue, StringComparison.Ordinal) diff --git a/src/APITemplate/Api/Api/Middleware/RequestContextMiddleware.cs b/src/APITemplate/Api/Api/Middleware/RequestContextMiddleware.cs index b0a4a172..26edc8e3 100644 --- a/src/APITemplate/Api/Api/Middleware/RequestContextMiddleware.cs +++ b/src/APITemplate/Api/Api/Middleware/RequestContextMiddleware.cs @@ -19,9 +19,9 @@ public RequestContextMiddleware(RequestDelegate next) public async Task InvokeAsync(HttpContext context) { - var correlationId = ResolveCorrelationId(context); - var stopwatch = Stopwatch.StartNew(); - var traceId = Activity.Current?.TraceId.ToHexString() ?? context.TraceIdentifier; + string correlationId = ResolveCorrelationId(context); + Stopwatch stopwatch = Stopwatch.StartNew(); + string traceId = Activity.Current?.TraceId.ToHexString() ?? context.TraceIdentifier; string tenantId = context.User.FindFirstValue(AuthConstants.Claims.TenantId) ?? string.Empty; @@ -56,9 +56,7 @@ public async Task InvokeAsync(HttpContext context) tenantId ) ) - { await _next(context); - } } finally { @@ -67,13 +65,13 @@ public async Task InvokeAsync(HttpContext context) if (metricsTagsFeature is not null) { metricsTagsFeature.Tags.Add( - new( + new KeyValuePair( TelemetryTagKeys.ApiSurface, TelemetryApiSurfaceResolver.Resolve(context.Request.Path) ) ); metricsTagsFeature.Tags.Add( - new( + new KeyValuePair( TelemetryTagKeys.Authenticated, context.User.Identity?.IsAuthenticated == true ) @@ -84,7 +82,7 @@ public async Task InvokeAsync(HttpContext context) private static string ResolveCorrelationId(HttpContext context) { - var incoming = context + string incoming = context .Request.Headers[RequestContextConstants.Headers.CorrelationId] .ToString(); return string.IsNullOrWhiteSpace(incoming) ? context.TraceIdentifier : incoming; diff --git a/src/APITemplate/Api/Api/OpenApi/BearerSecuritySchemeDocumentTransformer.cs b/src/APITemplate/Api/Api/OpenApi/BearerSecuritySchemeDocumentTransformer.cs index 11f6b387..91fb2df5 100644 --- a/src/APITemplate/Api/Api/OpenApi/BearerSecuritySchemeDocumentTransformer.cs +++ b/src/APITemplate/Api/Api/OpenApi/BearerSecuritySchemeDocumentTransformer.cs @@ -10,13 +10,13 @@ namespace APITemplate.Api.OpenApi; /// -/// OpenAPI document transformer that registers a Keycloak OAuth2 Authorization Code security scheme -/// and adds a global security requirement so Swagger UI can authenticate against the configured realm. +/// OpenAPI document transformer that registers a Keycloak OAuth2 Authorization Code security scheme +/// and adds a global security requirement so Swagger UI can authenticate against the configured realm. /// public sealed class BearerSecuritySchemeDocumentTransformer : IOpenApiDocumentTransformer { - private readonly IAuthenticationSchemeProvider _schemeProvider; private readonly KeycloakOptions _keycloak; + private readonly IAuthenticationSchemeProvider _schemeProvider; public BearerSecuritySchemeDocumentTransformer( IAuthenticationSchemeProvider schemeProvider, @@ -72,7 +72,7 @@ CancellationToken cancellationToken OpenApiSecurityRequirement requirement = new(); requirement[ - new OpenApiSecuritySchemeReference(AuthConstants.OpenApi.OAuth2Scheme, document, null) + new OpenApiSecuritySchemeReference(AuthConstants.OpenApi.OAuth2Scheme, document) ] = [AuthConstants.Scopes.OpenId]; document.Security ??= []; diff --git a/src/APITemplate/Api/Extensions/ApiServiceCollectionExtensions.cs b/src/APITemplate/Api/Extensions/ApiServiceCollectionExtensions.cs index ed7c0193..48e531dc 100644 --- a/src/APITemplate/Api/Extensions/ApiServiceCollectionExtensions.cs +++ b/src/APITemplate/Api/Extensions/ApiServiceCollectionExtensions.cs @@ -58,7 +58,8 @@ IConfiguration configuration { services.AddValidatedOptions(configuration); DragonflyOptions dragonflyOptions = - configuration.SectionFor().Get() ?? new(); + configuration.SectionFor().Get() + ?? new DragonflyOptions(); if (!string.IsNullOrWhiteSpace(dragonflyOptions.ConnectionString)) { @@ -80,12 +81,10 @@ IConfiguration configuration services .AddHealthChecks() - .AddRedis(dragonflyOptions.ConnectionString, name: HealthCheckNames.Dragonfly); + .AddRedis(dragonflyOptions.ConnectionString, HealthCheckNames.Dragonfly); } else - { services.AddDistributedMemoryCache(); - } return services; } @@ -100,7 +99,8 @@ IConfiguration configuration services.AddScoped(); CachingOptions cachingOptions = - configuration.SectionFor().Get() ?? new(); + configuration.SectionFor().Get() + ?? new CachingOptions(); services.AddOutputCache(options => { diff --git a/src/APITemplate/Api/Extensions/ApplicationCompositionServiceCollectionExtensions.cs b/src/APITemplate/Api/Extensions/ApplicationCompositionServiceCollectionExtensions.cs index 8a39da1b..381d153b 100644 --- a/src/APITemplate/Api/Extensions/ApplicationCompositionServiceCollectionExtensions.cs +++ b/src/APITemplate/Api/Extensions/ApplicationCompositionServiceCollectionExtensions.cs @@ -51,9 +51,7 @@ IConfiguration configuration OnTokenValidated = context => { if (context.Principal?.Identity is ClaimsIdentity identity) - { MapKeycloakClaims(identity); - } return Task.CompletedTask; }, @@ -102,23 +100,17 @@ private static void MapKeycloakClaims(ClaimsIdentity identity) identity.FindFirst(ClaimTypes.Name) is null && identity.FindFirst(AuthConstants.Claims.PreferredUsername) is Claim preferredUsername ) - { identity.AddClaim(new Claim(ClaimTypes.Name, preferredUsername.Value)); - } if (identity.FindFirst(AuthConstants.Claims.RealmAccess) is not Claim realmAccess) - { return; - } using JsonDocument document = JsonDocument.Parse(realmAccess.Value); if ( !document.RootElement.TryGetProperty(AuthConstants.Claims.Roles, out JsonElement roles) || roles.ValueKind != JsonValueKind.Array ) - { return; - } foreach (JsonElement role in roles.EnumerateArray()) { @@ -127,9 +119,7 @@ private static void MapKeycloakClaims(ClaimsIdentity identity) string.IsNullOrWhiteSpace(roleValue) || identity.HasClaim(ClaimTypes.Role, roleValue) ) - { continue; - } identity.AddClaim(new Claim(ClaimTypes.Role, roleValue)); } diff --git a/src/APITemplate/Api/Extensions/ObservabilityServiceCollectionExtensions.cs b/src/APITemplate/Api/Extensions/ObservabilityServiceCollectionExtensions.cs index e37b3372..62707a19 100644 --- a/src/APITemplate/Api/Extensions/ObservabilityServiceCollectionExtensions.cs +++ b/src/APITemplate/Api/Extensions/ObservabilityServiceCollectionExtensions.cs @@ -16,22 +16,24 @@ IHostEnvironment environment services.AddValidatedOptions(configuration); services.AddValidatedOptions(configuration); - AppOptions appOptions = configuration.SectionFor().Get() ?? new(); + AppOptions appOptions = + configuration.SectionFor().Get() ?? new AppOptions(); ObservabilityOptions observabilityOptions = - configuration.SectionFor().Get() ?? new(); + configuration.SectionFor().Get() + ?? new ObservabilityOptions(); - var serviceName = string.IsNullOrWhiteSpace(appOptions.ServiceName) + string serviceName = string.IsNullOrWhiteSpace(appOptions.ServiceName) ? "APITemplate" : appOptions.ServiceName; - var serviceVersion = typeof(Program).Assembly.GetName().Version?.ToString(); - var enableConsoleExporter = + string? serviceVersion = typeof(Program).Assembly.GetName().Version?.ToString(); + bool enableConsoleExporter = observabilityOptions.Exporters.Console.Enabled ?? environment.IsDevelopment(); Uri? otlpEndpoint = ResolveOtlpEndpoint(observabilityOptions, environment); OpenTelemetryBuilder openTelemetryBuilder = services .AddOpenTelemetry() .ConfigureResource(resource => - resource.AddService(serviceName: serviceName, serviceVersion: serviceVersion) + resource.AddService(serviceName, serviceVersion: serviceVersion) ); openTelemetryBuilder.WithTracing(builder => @@ -64,16 +66,14 @@ IHostEnvironment environment IHostEnvironment environment ) { - var explicitOtlpEnabled = observabilityOptions.Exporters.Otlp.Enabled == true; + bool explicitOtlpEnabled = observabilityOptions.Exporters.Otlp.Enabled == true; if ( explicitOtlpEnabled && Uri.TryCreate(observabilityOptions.Otlp.Endpoint, UriKind.Absolute, out Uri? otlpUri) ) - { return otlpUri; - } - var aspireEnabled = + bool aspireEnabled = observabilityOptions.Exporters.Aspire.Enabled ?? environment.IsDevelopment(); if ( aspireEnabled @@ -83,9 +83,7 @@ IHostEnvironment environment out Uri? aspireUri ) ) - { return aspireUri; - } return null; } diff --git a/src/APITemplate/Api/Extensions/WolverineTypeExtensions.cs b/src/APITemplate/Api/Extensions/WolverineTypeExtensions.cs index d780c9fb..21272e48 100644 --- a/src/APITemplate/Api/Extensions/WolverineTypeExtensions.cs +++ b/src/APITemplate/Api/Extensions/WolverineTypeExtensions.cs @@ -9,15 +9,11 @@ internal static class WolverineTypeExtensions internal static bool IsErrorOrReturnType(this Type returnType) { if (!returnType.IsGenericType) - { return false; - } Type genericTypeDefinition = returnType.GetGenericTypeDefinition(); if (genericTypeDefinition == typeof(Task<>) || genericTypeDefinition == typeof(ValueTask<>)) - { return returnType.GetGenericArguments()[0].IsErrorOrReturnType(); - } return genericTypeDefinition == typeof(ErrorOr<>); } diff --git a/src/Modules/BackgroundJobs/BackgroundJobs.csproj b/src/Modules/BackgroundJobs/BackgroundJobs.csproj index 6ea592ef..140f497d 100644 --- a/src/Modules/BackgroundJobs/BackgroundJobs.csproj +++ b/src/Modules/BackgroundJobs/BackgroundJobs.csproj @@ -5,18 +5,18 @@ enable - + - + - - - - - - + + + + + + diff --git a/src/Modules/BackgroundJobs/BackgroundJobsRuntimeBridge.cs b/src/Modules/BackgroundJobs/BackgroundJobsRuntimeBridge.cs index 3571719a..4918b918 100644 --- a/src/Modules/BackgroundJobs/BackgroundJobsRuntimeBridge.cs +++ b/src/Modules/BackgroundJobs/BackgroundJobsRuntimeBridge.cs @@ -1,5 +1,4 @@ using System.Reflection; -using BackgroundJobs.Domain; using BackgroundJobs.Persistence; using BackgroundJobs.Repositories; using BackgroundJobs.Services; @@ -11,10 +10,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using SharedKernel.Application.BackgroundJobs; -using SharedKernel.Application.Options.BackgroundJobs; using SharedKernel.Application.Options.Infrastructure; -using SharedKernel.Domain.Entities.Contracts; using SharedKernel.Infrastructure.Configuration; using SharedKernel.Infrastructure.Registration; using TickerQ.DependencyInjection; @@ -45,10 +41,7 @@ IConfiguration configuration IValidateOptions, BackgroundJobsOptionsValidator >(); - services.AddValidatedOptions( - configuration, - validateDataAnnotations: false - ); + services.AddValidatedOptions(configuration, false); BackgroundJobsOptions options = configuration.SectionFor().Get() ?? new BackgroundJobsOptions(); diff --git a/src/Modules/BackgroundJobs/Domain/JobExecution.cs b/src/Modules/BackgroundJobs/Domain/JobExecution.cs index 43a30617..21ebf87c 100644 --- a/src/Modules/BackgroundJobs/Domain/JobExecution.cs +++ b/src/Modules/BackgroundJobs/Domain/JobExecution.cs @@ -1,12 +1,11 @@ namespace BackgroundJobs.Domain; /// -/// 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. +/// 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; } @@ -22,9 +21,10 @@ public sealed class JobExecution : IAuditableTenantEntity, IHasId public bool IsDeleted { get; set; } public DateTime? DeletedAtUtc { get; set; } public Guid? DeletedBy { get; set; } + public Guid Id { get; set; } /// - /// Transitions the job to and records the start timestamp. + /// Transitions the job to and records the start timestamp. /// public void MarkProcessing(TimeProvider timeProvider) { @@ -33,7 +33,8 @@ public void MarkProcessing(TimeProvider timeProvider) } /// - /// Transitions the job to , sets progress to 100%, stores the optional result payload, and records the completion timestamp. + /// 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) { @@ -44,7 +45,8 @@ public void MarkCompleted(string? resultPayload, TimeProvider timeProvider) } /// - /// Transitions the job to , stores the error message, and records the completion timestamp. + /// Transitions the job to , stores the error message, and records the completion + /// timestamp. /// public void MarkFailed(string errorMessage, TimeProvider timeProvider) { @@ -54,13 +56,10 @@ public void MarkFailed(string errorMessage, TimeProvider timeProvider) } /// - /// Updates the job's progress percentage, clamping the value to the valid range [0, 100]. + /// 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/Modules/BackgroundJobs/Domain/JobMappings.cs b/src/Modules/BackgroundJobs/Domain/JobMappings.cs index 302d5994..53fc601c 100644 --- a/src/Modules/BackgroundJobs/Domain/JobMappings.cs +++ b/src/Modules/BackgroundJobs/Domain/JobMappings.cs @@ -1,13 +1,15 @@ namespace BackgroundJobs.Domain; /// -/// Provides mapping utilities between domain entities and DTOs. +/// Provides mapping utilities between domain entities and +/// DTOs. /// public static class JobMappings { - /// Maps a to a . - public static JobStatusResponse ToResponse(this JobExecution entity) => - new( + /// Maps a to a . + public static JobStatusResponse ToResponse(this JobExecution entity) + { + return new JobStatusResponse( entity.Id, entity.JobType, entity.Status, @@ -20,9 +22,5 @@ public static JobStatusResponse ToResponse(this JobExecution entity) => entity.CompletedAtUtc, entity.CallbackUrl ); + } } - - - - - diff --git a/src/Modules/BackgroundJobs/Persistence/BackgroundJobsDbContext.cs b/src/Modules/BackgroundJobs/Persistence/BackgroundJobsDbContext.cs index ee928ad4..fc5ff534 100644 --- a/src/Modules/BackgroundJobs/Persistence/BackgroundJobsDbContext.cs +++ b/src/Modules/BackgroundJobs/Persistence/BackgroundJobsDbContext.cs @@ -1,10 +1,8 @@ -using BackgroundJobs.Domain; using Microsoft.EntityFrameworkCore; using SharedKernel.Application.Context; using SharedKernel.Infrastructure.Auditing; using SharedKernel.Infrastructure.EntityNormalization; using SharedKernel.Infrastructure.Persistence; -using SharedKernel.Infrastructure.SoftDelete; namespace BackgroundJobs.Persistence; diff --git a/src/Modules/BackgroundJobs/Services/ChannelJobQueue.cs b/src/Modules/BackgroundJobs/Services/ChannelJobQueue.cs index 32901855..9239a1df 100644 --- a/src/Modules/BackgroundJobs/Services/ChannelJobQueue.cs +++ b/src/Modules/BackgroundJobs/Services/ChannelJobQueue.cs @@ -3,9 +3,9 @@ namespace BackgroundJobs.Services; /// -/// 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. +/// 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 { diff --git a/src/Modules/BackgroundJobs/Services/CleanupService.cs b/src/Modules/BackgroundJobs/Services/CleanupService.cs index 050ebc4e..3064ee69 100644 --- a/src/Modules/BackgroundJobs/Services/CleanupService.cs +++ b/src/Modules/BackgroundJobs/Services/CleanupService.cs @@ -6,16 +6,16 @@ namespace BackgroundJobs.Services; /// -/// Infrastructure implementation of that orchestrates -/// scheduled data-hygiene tasks. Cross-module operations (invitations, orphaned product data) -/// are delegated to owning modules via bus commands. Soft-delete cleanup is local. +/// Infrastructure implementation of that orchestrates +/// scheduled data-hygiene tasks. Cross-module operations (invitations, orphaned product data) +/// are delegated to owning modules via bus commands. Soft-delete cleanup is local. /// public sealed class CleanupService : ICleanupService { - private readonly IMessageBus _messageBus; private readonly IEnumerable _cleanupStrategies; - private readonly TimeProvider _timeProvider; private readonly ILogger _logger; + private readonly IMessageBus _messageBus; + private readonly TimeProvider _timeProvider; public CleanupService( IMessageBus messageBus, diff --git a/src/Modules/BackgroundJobs/TickerQ/RecurringJobRegistrations/ExternalSyncRecurringJobRegistration.cs b/src/Modules/BackgroundJobs/TickerQ/RecurringJobRegistrations/ExternalSyncRecurringJobRegistration.cs index 1cb1274b..4c0fd96c 100644 --- a/src/Modules/BackgroundJobs/TickerQ/RecurringJobRegistrations/ExternalSyncRecurringJobRegistration.cs +++ b/src/Modules/BackgroundJobs/TickerQ/RecurringJobRegistrations/ExternalSyncRecurringJobRegistration.cs @@ -1,7 +1,5 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using SharedKernel.Application.BackgroundJobs; -using SharedKernel.Application.Options.BackgroundJobs; namespace BackgroundJobs.TickerQ.RecurringJobRegistrations; @@ -12,7 +10,7 @@ public RecurringBackgroundJobDefinition Build(IServiceProvider serviceProvider) BackgroundJobsOptions options = serviceProvider .GetRequiredService>() .Value; - return new( + return new RecurringBackgroundJobDefinition( TickerQJobIds.ExternalSync, TickerQFunctionNames.ExternalSync, options.ExternalSync.Cron, diff --git a/src/Modules/Chatting/Chatting.csproj b/src/Modules/Chatting/Chatting.csproj index 101cec44..c9c731a6 100644 --- a/src/Modules/Chatting/Chatting.csproj +++ b/src/Modules/Chatting/Chatting.csproj @@ -5,17 +5,17 @@ enable - + - + - + - - + + diff --git a/src/Modules/Chatting/Features/SseController.cs b/src/Modules/Chatting/Features/SseController.cs index 6da6f8b3..8a559698 100644 --- a/src/Modules/Chatting/Features/SseController.cs +++ b/src/Modules/Chatting/Features/SseController.cs @@ -1,7 +1,6 @@ using System.Text.Json; using Asp.Versioning; using Chatting.Features.GetNotificationStream; - using Microsoft.AspNetCore.Mvc; using SharedKernel.Contracts.Api; using SharedKernel.Contracts.Security; @@ -42,6 +41,3 @@ public async Task Stream([FromQuery] SseStreamRequest request, CancellationToken } } } - - - diff --git a/src/Modules/FileStorage/Contracts/FileUploadResponse.cs b/src/Modules/FileStorage/Contracts/FileUploadResponse.cs index 82126b06..40da62a2 100644 --- a/src/Modules/FileStorage/Contracts/FileUploadResponse.cs +++ b/src/Modules/FileStorage/Contracts/FileUploadResponse.cs @@ -3,7 +3,7 @@ namespace FileStorage.Contracts; /// -/// Represents the metadata of a successfully uploaded file as returned to the API consumer. +/// Represents the metadata of a successfully uploaded file as returned to the API consumer. /// public sealed record FileUploadResponse( Guid Id, @@ -13,7 +13,3 @@ public sealed record FileUploadResponse( string? Description, DateTime CreatedAtUtc ) : IHasId; - - - - diff --git a/src/Modules/FileStorage/Contracts/UploadFileRequest.cs b/src/Modules/FileStorage/Contracts/UploadFileRequest.cs index 60ad34e9..b50457cd 100644 --- a/src/Modules/FileStorage/Contracts/UploadFileRequest.cs +++ b/src/Modules/FileStorage/Contracts/UploadFileRequest.cs @@ -1,7 +1,8 @@ namespace FileStorage.Contracts; /// -/// Carries all data needed to store an uploaded file, including the raw stream, original file name, content type, size, and optional description. +/// 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, @@ -10,7 +11,3 @@ public sealed record UploadFileRequest( long SizeBytes, string? Description ); - - - - diff --git a/src/Modules/FileStorage/Domain/IStoredFileRepository.cs b/src/Modules/FileStorage/Domain/IStoredFileRepository.cs index 91df7222..e60605ad 100644 --- a/src/Modules/FileStorage/Domain/IStoredFileRepository.cs +++ b/src/Modules/FileStorage/Domain/IStoredFileRepository.cs @@ -1,14 +1,7 @@ -using SharedKernel.Domain.Interfaces; - namespace FileStorage.Domain; /// -/// Repository contract for entities, inheriting all generic CRUD operations from . +/// Repository contract for entities, inheriting all generic CRUD operations from +/// . /// public interface IStoredFileRepository : IRepository; - - - - - - diff --git a/src/Modules/FileStorage/Domain/StoredFile.cs b/src/Modules/FileStorage/Domain/StoredFile.cs index 030d823e..4b6f9156 100644 --- a/src/Modules/FileStorage/Domain/StoredFile.cs +++ b/src/Modules/FileStorage/Domain/StoredFile.cs @@ -4,12 +4,11 @@ namespace FileStorage.Domain; /// -/// 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. +/// 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; } @@ -20,10 +19,5 @@ public sealed class StoredFile : IAuditableTenantEntity, IHasId public bool IsDeleted { get; set; } public DateTime? DeletedAtUtc { get; set; } public Guid? DeletedBy { get; set; } + public Guid Id { get; set; } } - - - - - - diff --git a/src/Modules/FileStorage/Features/FileUploadRequest.cs b/src/Modules/FileStorage/Features/FileUploadRequest.cs index 65c48cbb..8ccc08d8 100644 --- a/src/Modules/FileStorage/Features/FileUploadRequest.cs +++ b/src/Modules/FileStorage/Features/FileUploadRequest.cs @@ -1,11 +1,11 @@ -using Microsoft.AspNetCore.Http; using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Http; namespace FileStorage.Features; /// -/// Represents the multipart form-data payload for a file upload endpoint, -/// carrying the required file stream and an optional free-text description. +/// Represents the multipart form-data payload for a file upload endpoint, +/// carrying the required file stream and an optional free-text description. /// public sealed class FileUploadRequest { @@ -14,7 +14,3 @@ public sealed class FileUploadRequest public string? Description { get; init; } } - - - - diff --git a/src/Modules/FileStorage/FileStorage.csproj b/src/Modules/FileStorage/FileStorage.csproj index aca272f4..fc996de8 100644 --- a/src/Modules/FileStorage/FileStorage.csproj +++ b/src/Modules/FileStorage/FileStorage.csproj @@ -5,16 +5,16 @@ enable - + - + - - - - + + + + diff --git a/src/Modules/Identity/Common/BffOptions.cs b/src/Modules/Identity/Common/BffOptions.cs index 82c0bc21..c927ca46 100644 --- a/src/Modules/Identity/Common/BffOptions.cs +++ b/src/Modules/Identity/Common/BffOptions.cs @@ -4,8 +4,8 @@ namespace Identity.Options; /// -/// Configuration for the Backend-for-Frontend (BFF) session layer, including cookie settings, -/// requested OIDC scopes, and token refresh thresholds. +/// Configuration for the Backend-for-Frontend (BFF) session layer, including cookie settings, +/// requested OIDC scopes, and token refresh thresholds. /// public sealed class BffOptions { diff --git a/src/Modules/Identity/Common/CacheTags.cs b/src/Modules/Identity/Common/CacheTags.cs index 384ad586..2a4a2e35 100644 --- a/src/Modules/Identity/Common/CacheTags.cs +++ b/src/Modules/Identity/Common/CacheTags.cs @@ -6,4 +6,3 @@ public static class CacheTags public const string TenantInvitations = "TenantInvitations"; public const string Users = "Users"; } - diff --git a/src/Modules/Identity/Common/Email/ISecureTokenGenerator.cs b/src/Modules/Identity/Common/Email/ISecureTokenGenerator.cs index ffb2611e..2b333816 100644 --- a/src/Modules/Identity/Common/Email/ISecureTokenGenerator.cs +++ b/src/Modules/Identity/Common/Email/ISecureTokenGenerator.cs @@ -1,18 +1,17 @@ namespace Identity.Common.Email; /// -/// Application-layer abstraction for generating and hashing cryptographically secure tokens -/// used in email verification flows (e.g. invitation acceptance, password reset). +/// 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(); + public string GenerateToken(); /// - /// Returns a one-way hash of for safe storage in the database, - /// allowing verification without storing the raw token. + /// Returns a one-way hash of for safe storage in the database, + /// allowing verification without storing the raw token. /// - string HashToken(string token); + public string HashToken(string token); } - diff --git a/src/Modules/Identity/Common/SystemIdentityOptions.cs b/src/Modules/Identity/Common/SystemIdentityOptions.cs index 548f65fc..dddc53e1 100644 --- a/src/Modules/Identity/Common/SystemIdentityOptions.cs +++ b/src/Modules/Identity/Common/SystemIdentityOptions.cs @@ -1,12 +1,11 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; -using SharedKernel.Domain.Entities; namespace Identity.Options; /// -/// Configuration that defines the well-known actor identity used when the system performs -/// automated actions without an associated human user. +/// Configuration that defines the well-known actor identity used when the system performs +/// automated actions without an associated human user. /// public sealed class SystemIdentityOptions { @@ -14,4 +13,3 @@ public sealed class SystemIdentityOptions [Required] public Guid DefaultActorId { get; init; } = AuditDefaults.SystemActorId; } - diff --git a/src/Modules/Identity/Common/TenantInvitationOptions.cs b/src/Modules/Identity/Common/TenantInvitationOptions.cs index 8b3ad457..9b1131f0 100644 --- a/src/Modules/Identity/Common/TenantInvitationOptions.cs +++ b/src/Modules/Identity/Common/TenantInvitationOptions.cs @@ -4,8 +4,8 @@ namespace Identity.Options; /// -/// Configuration for tenant invitation flows, including token lifetime and the application base URL -/// used when generating invitation links. +/// Configuration for tenant invitation flows, including token lifetime and the application base URL +/// used when generating invitation links. /// public sealed class TenantInvitationOptions { @@ -18,4 +18,3 @@ public sealed class TenantInvitationOptions [MinLength(1)] public string BaseUrl { get; set; } = string.Empty; } - diff --git a/src/Modules/Identity/Enums/InvitationStatus.cs b/src/Modules/Identity/Enums/InvitationStatus.cs index a2372f98..7b5f481f 100644 --- a/src/Modules/Identity/Enums/InvitationStatus.cs +++ b/src/Modules/Identity/Enums/InvitationStatus.cs @@ -1,7 +1,7 @@ namespace Identity.Enums; /// -/// Represents the lifecycle state of a . +/// Represents the lifecycle state of a . /// public enum InvitationStatus { @@ -17,4 +17,3 @@ public enum InvitationStatus /// The invitation was revoked by a tenant administrator before it could be accepted. Revoked = 3, } - diff --git a/src/Modules/Identity/Features/Tenant/DTOs/TenantFilter.cs b/src/Modules/Identity/Features/Tenant/DTOs/TenantFilter.cs index 58727457..d381c1f7 100644 --- a/src/Modules/Identity/Features/Tenant/DTOs/TenantFilter.cs +++ b/src/Modules/Identity/Features/Tenant/DTOs/TenantFilter.cs @@ -1,10 +1,7 @@ -using SharedKernel.Application.Contracts; -using SharedKernel.Application.DTOs; - namespace Identity.Features.Tenant.DTOs; /// -/// Pagination and filtering parameters for querying tenants, including optional full-text search and sorting. +/// Pagination and filtering parameters for querying tenants, including optional full-text search and sorting. /// public sealed record TenantFilter( string? Query = null, @@ -13,4 +10,3 @@ public sealed record TenantFilter( int PageNumber = 1, int PageSize = PaginationFilter.DefaultPageSize ) : PaginationFilter(PageNumber, PageSize), ISortableFilter; - diff --git a/src/Modules/Identity/Features/Tenant/ITenantCodeConflictDetector.cs b/src/Modules/Identity/Features/Tenant/ITenantCodeConflictDetector.cs index b73b4b8b..053022aa 100644 --- a/src/Modules/Identity/Features/Tenant/ITenantCodeConflictDetector.cs +++ b/src/Modules/Identity/Features/Tenant/ITenantCodeConflictDetector.cs @@ -2,6 +2,5 @@ namespace Identity.Features.Tenant; public interface ITenantCodeConflictDetector { - bool IsCodeConflict(Exception exception); + public bool IsCodeConflict(Exception exception); } - diff --git a/src/Modules/Identity/Features/Tenant/Queries/GetTenantsQuery.cs b/src/Modules/Identity/Features/Tenant/Queries/GetTenantsQuery.cs index 643d5ec0..81af21f7 100644 --- a/src/Modules/Identity/Features/Tenant/Queries/GetTenantsQuery.cs +++ b/src/Modules/Identity/Features/Tenant/Queries/GetTenantsQuery.cs @@ -1,8 +1,5 @@ -using Identity.Features.Tenant.DTOs; -using Identity.Features.Tenant.Specifications; -using Identity.Interfaces; -using SharedKernel.Domain.Common; using ErrorOr; +using Identity.Features.Tenant.Specifications; namespace Identity.Features.Tenant; @@ -24,4 +21,3 @@ CancellationToken ct ); } } - diff --git a/src/Modules/Identity/Features/Tenant/Specifications/TenantFilterCriteria.cs b/src/Modules/Identity/Features/Tenant/Specifications/TenantFilterCriteria.cs index adc8ee5b..0215716b 100644 --- a/src/Modules/Identity/Features/Tenant/Specifications/TenantFilterCriteria.cs +++ b/src/Modules/Identity/Features/Tenant/Specifications/TenantFilterCriteria.cs @@ -1,18 +1,17 @@ using Ardalis.Specification; -using Identity.Features.Tenant.DTOs; using Microsoft.EntityFrameworkCore; -using SharedKernel.Application.Search; using TenantEntity = Identity.Entities.Tenant; namespace Identity.Features.Tenant.Specifications; /// -/// Internal extension that applies shared criteria to an Ardalis specification builder. +/// 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. + /// Adds a PostgreSQL full-text search predicate on Code and Name when + /// is provided. /// internal static void ApplyFilter( this ISpecificationBuilder query, @@ -36,4 +35,3 @@ TenantFilter filter ); } } - diff --git a/src/Modules/Identity/Features/TenantInvitation/DTOs/TenantInvitationFilter.cs b/src/Modules/Identity/Features/TenantInvitation/DTOs/TenantInvitationFilter.cs index eb999069..e16054bf 100644 --- a/src/Modules/Identity/Features/TenantInvitation/DTOs/TenantInvitationFilter.cs +++ b/src/Modules/Identity/Features/TenantInvitation/DTOs/TenantInvitationFilter.cs @@ -1,10 +1,7 @@ -using Identity.Enums; -using SharedKernel.Application.DTOs; - namespace Identity.Features.TenantInvitation.DTOs; /// -/// Pagination and filtering parameters for querying tenant invitations, supporting optional email and status filters. +/// Pagination and filtering parameters for querying tenant invitations, supporting optional email and status filters. /// public sealed record TenantInvitationFilter( string? Email = null, @@ -12,4 +9,3 @@ public sealed record TenantInvitationFilter( int PageNumber = 1, int PageSize = PaginationFilter.DefaultPageSize ) : PaginationFilter(PageNumber, PageSize); - diff --git a/src/Modules/Identity/Features/User/Commands/KeycloakPasswordResetCommand.cs b/src/Modules/Identity/Features/User/Commands/KeycloakPasswordResetCommand.cs index 77a1777a..874b9ebe 100644 --- a/src/Modules/Identity/Features/User/Commands/KeycloakPasswordResetCommand.cs +++ b/src/Modules/Identity/Features/User/Commands/KeycloakPasswordResetCommand.cs @@ -1,5 +1,4 @@ using ErrorOr; -using Identity.Features.User.DTOs; using Identity.Logging; using Microsoft.Extensions.Logging; @@ -34,4 +33,3 @@ CancellationToken ct return Result.Success; } } - diff --git a/src/Modules/Identity/Features/User/DTOs/UpdateUserRequest.cs b/src/Modules/Identity/Features/User/DTOs/UpdateUserRequest.cs index 33b9ae67..261aca61 100644 --- a/src/Modules/Identity/Features/User/DTOs/UpdateUserRequest.cs +++ b/src/Modules/Identity/Features/User/DTOs/UpdateUserRequest.cs @@ -1,13 +1,11 @@ using System.ComponentModel.DataAnnotations; -using SharedKernel.Application.Validation; namespace Identity.Features.User.DTOs; /// -/// Represents the request payload for updating an existing user's username and email. +/// 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/Modules/Identity/Features/User/DTOs/UserResponse.cs b/src/Modules/Identity/Features/User/DTOs/UserResponse.cs index 2797fcd0..43a506bf 100644 --- a/src/Modules/Identity/Features/User/DTOs/UserResponse.cs +++ b/src/Modules/Identity/Features/User/DTOs/UserResponse.cs @@ -1,10 +1,7 @@ -using Identity.Enums; -using SharedKernel.Domain.Entities.Contracts; - namespace Identity.Features.User.DTOs; /// -/// Read model returned to callers after a user query or creation. +/// Read model returned to callers after a user query or creation. /// public sealed record UserResponse( Guid Id, @@ -14,4 +11,3 @@ public sealed record UserResponse( UserRole Role, DateTime CreatedAtUtc ) : IHasId; - diff --git a/src/Modules/Identity/Features/User/Specifications/UserFilterSpecification.cs b/src/Modules/Identity/Features/User/Specifications/UserFilterSpecification.cs index d9bf3eb1..d7260d92 100644 --- a/src/Modules/Identity/Features/User/Specifications/UserFilterSpecification.cs +++ b/src/Modules/Identity/Features/User/Specifications/UserFilterSpecification.cs @@ -1,17 +1,16 @@ -using Identity.Features.User.DTOs; -using Identity.Features.User.Mappings; -using Identity.Entities; using Ardalis.Specification; +using Identity.Features.User.Mappings; namespace Identity.Features.User.Specifications; /// -/// Ardalis specification that retrieves a filtered and sorted list of users projected to . +/// 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 . + /// Initialises the specification by applying filter criteria, sort order, and projection from the given + /// . /// public UserFilterSpecification(UserFilter filter) { @@ -23,4 +22,3 @@ public UserFilterSpecification(UserFilter filter) Query.Select(UserMappings.Projection); } } - diff --git a/src/Modules/Identity/Features/User/Validation/CreateUserRequestValidator.cs b/src/Modules/Identity/Features/User/Validation/CreateUserRequestValidator.cs index c44199e8..45325531 100644 --- a/src/Modules/Identity/Features/User/Validation/CreateUserRequestValidator.cs +++ b/src/Modules/Identity/Features/User/Validation/CreateUserRequestValidator.cs @@ -1,10 +1,6 @@ -using Identity.Features.User.DTOs; -using SharedKernel.Application.Validation; - namespace Identity.Features.User.Validation; /// -/// FluentValidation validator for that enforces data-annotation constraints. +/// FluentValidation validator for that enforces data-annotation constraints. /// public sealed class CreateUserRequestValidator : DataAnnotationsValidator { } - diff --git a/src/Modules/Identity/Features/V1/UsersController.cs b/src/Modules/Identity/Features/V1/UsersController.cs index 1c1b9a71..b8436778 100644 --- a/src/Modules/Identity/Features/V1/UsersController.cs +++ b/src/Modules/Identity/Features/V1/UsersController.cs @@ -20,10 +20,9 @@ public async Task>> GetAll( CancellationToken ct ) { - ErrorOr> result = await bus.InvokeAsync>>( - new GetUsersQuery(filter), - ct - ); + ErrorOr> result = await bus.InvokeAsync< + ErrorOr> + >(new GetUsersQuery(filter), ct); return result.ToActionResult(this); } @@ -59,7 +58,10 @@ public async Task> GetMe(CancellationToken ct) [HttpPost] [RequirePermission(Permission.Users.Create)] - public async Task> Create(CreateUserRequest request, CancellationToken ct) + public async Task> Create( + CreateUserRequest request, + CancellationToken ct + ) { ErrorOr result = await bus.InvokeAsync>( new CreateUserCommand(request), @@ -70,7 +72,11 @@ public async Task> Create(CreateUserRequest request, [HttpPut("{id:guid}")] [RequirePermission(Permission.Users.Update)] - public async Task Update(Guid id, UpdateUserRequest request, CancellationToken ct) + public async Task Update( + Guid id, + UpdateUserRequest request, + CancellationToken ct + ) { ErrorOr result = await bus.InvokeAsync>( new UpdateUserCommand(id, request), @@ -84,7 +90,7 @@ public async Task Update(Guid id, UpdateUserRequest request, Canc public async Task Activate(Guid id, CancellationToken ct) { ErrorOr result = await bus.InvokeAsync>( - new SetUserActiveCommand(id, IsActive: true), + new SetUserActiveCommand(id, true), ct ); return result.ToNoContentResult(this); @@ -95,7 +101,7 @@ public async Task Activate(Guid id, CancellationToken ct) public async Task Deactivate(Guid id, CancellationToken ct) { ErrorOr result = await bus.InvokeAsync>( - new SetUserActiveCommand(id, IsActive: false), + new SetUserActiveCommand(id, false), ct ); return result.ToNoContentResult(this); @@ -103,7 +109,11 @@ public async Task Deactivate(Guid id, CancellationToken ct) [HttpPatch("{id:guid}/role")] [RequirePermission(Permission.Users.Update)] - public async Task ChangeRole(Guid id, ChangeUserRoleRequest request, CancellationToken ct) + public async Task ChangeRole( + Guid id, + ChangeUserRoleRequest request, + CancellationToken ct + ) { ErrorOr result = await bus.InvokeAsync>( new ChangeUserRoleCommand(id, request), @@ -125,7 +135,10 @@ public async Task Delete(Guid id, CancellationToken ct) [HttpPost("password-reset")] [AllowAnonymous] - public async Task RequestPasswordReset(RequestPasswordResetRequest request, CancellationToken ct) + public async Task RequestPasswordReset( + RequestPasswordResetRequest request, + CancellationToken ct + ) { ErrorOr result = await bus.InvokeAsync>( new KeycloakPasswordResetCommand(request), @@ -134,4 +147,3 @@ public async Task RequestPasswordReset(RequestPasswordResetReques return result.ToOkResult(this); } } - diff --git a/src/Modules/Identity/Identity.csproj b/src/Modules/Identity/Identity.csproj index 317d82cb..81547d47 100644 --- a/src/Modules/Identity/Identity.csproj +++ b/src/Modules/Identity/Identity.csproj @@ -5,30 +5,33 @@ enable - + - + - + - - - - - - - - - - - allruntime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/src/Modules/Identity/Interfaces/ITenantRepository.cs b/src/Modules/Identity/Interfaces/ITenantRepository.cs index d9cc85ce..c5d7d33c 100644 --- a/src/Modules/Identity/Interfaces/ITenantRepository.cs +++ b/src/Modules/Identity/Interfaces/ITenantRepository.cs @@ -1,10 +1,6 @@ -using Identity.Entities; -using SharedKernel.Domain.Interfaces; - namespace Identity.Interfaces; /// -/// Repository contract for entities. +/// Repository contract for entities. /// public interface ITenantRepository : IRepository; - diff --git a/src/Modules/Identity/Interfaces/IUserRepository.cs b/src/Modules/Identity/Interfaces/IUserRepository.cs index 528a1d43..f6c29c46 100644 --- a/src/Modules/Identity/Interfaces/IUserRepository.cs +++ b/src/Modules/Identity/Interfaces/IUserRepository.cs @@ -1,20 +1,19 @@ -using Identity.Entities; -using SharedKernel.Domain.Interfaces; - namespace Identity.Interfaces; /// -/// Repository contract for entities with user-specific lookup operations. +/// 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); + public 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); + public 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); + public Task FindByEmailAsync(string email, CancellationToken ct = default); } - diff --git a/src/Modules/Identity/Persistence/AuthBootstrapSeeder.cs b/src/Modules/Identity/Persistence/AuthBootstrapSeeder.cs index 9323dc66..e299f7c6 100644 --- a/src/Modules/Identity/Persistence/AuthBootstrapSeeder.cs +++ b/src/Modules/Identity/Persistence/AuthBootstrapSeeder.cs @@ -1,14 +1,13 @@ using Identity.Options; using Identity.ValueObjects; -using Identity.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; namespace Identity.Persistence; /// -/// Seeds the bootstrap tenant on application startup, creating it if absent or restoring it -/// if it was previously soft-deleted or deactivated. +/// Seeds the bootstrap tenant on application startup, creating it if absent or restoring it +/// if it was previously soft-deleted or deactivated. /// public sealed class AuthBootstrapSeeder { @@ -36,16 +35,20 @@ public async Task SeedAsync(CancellationToken ct = default) await SaveIfChangedAsync(hasChanges, ct); } - private TenantIdentity GetTenantIdentity() => - new(_tenantOptions.Code.Trim(), _tenantOptions.Name.Trim()); + private TenantIdentity GetTenantIdentity() + { + return new TenantIdentity(_tenantOptions.Code.Trim(), _tenantOptions.Name.Trim()); + } - private Task FindTenantAsync(string tenantCode, CancellationToken ct) => - _dbContext + private Task FindTenantAsync(string tenantCode, CancellationToken ct) + { + return _dbContext .Tenants.IgnoreQueryFilters([ GlobalQueryFilterNames.SoftDelete, GlobalQueryFilterNames.Tenant, ]) .FirstOrDefaultAsync(t => t.Code.Value == tenantCode, ct); + } private bool CreateTenant(TenantIdentity tenantIdentity) { @@ -84,9 +87,10 @@ private static bool EnsureTenantIsNotDeleted(Tenant tenant) return true; } - private Task SaveIfChangedAsync(bool hasChanges, CancellationToken ct) => - hasChanges ? _dbContext.SaveChangesAsync(ct) : Task.CompletedTask; + private Task SaveIfChangedAsync(bool hasChanges, CancellationToken ct) + { + return hasChanges ? _dbContext.SaveChangesAsync(ct) : Task.CompletedTask; + } private readonly record struct TenantIdentity(string Code, string Name); } - diff --git a/src/Modules/Identity/Repositories/PostgresTenantCodeConflictDetector.cs b/src/Modules/Identity/Repositories/PostgresTenantCodeConflictDetector.cs index ebc281a9..8f7dea8a 100644 --- a/src/Modules/Identity/Repositories/PostgresTenantCodeConflictDetector.cs +++ b/src/Modules/Identity/Repositories/PostgresTenantCodeConflictDetector.cs @@ -1,4 +1,3 @@ -using Identity.Features.Tenant; using Identity.Persistence.Configurations; using Microsoft.EntityFrameworkCore; using Npgsql; @@ -7,14 +6,15 @@ namespace Identity.Repositories; public sealed class PostgresTenantCodeConflictDetector : ITenantCodeConflictDetector { - public bool IsCodeConflict(Exception exception) => - exception is DbUpdateException dbUpdateException - && dbUpdateException.InnerException is PostgresException postgresException - && postgresException.SqlState == PostgresErrorCodes.UniqueViolation - && string.Equals( - postgresException.ConstraintName, - TenantConfiguration.TenantCodeIndexName, - StringComparison.Ordinal - ); + public bool IsCodeConflict(Exception exception) + { + return exception is DbUpdateException dbUpdateException + && dbUpdateException.InnerException is PostgresException postgresException + && postgresException.SqlState == PostgresErrorCodes.UniqueViolation + && string.Equals( + postgresException.ConstraintName, + TenantConfiguration.TenantCodeIndexName, + StringComparison.Ordinal + ); + } } - diff --git a/src/Modules/Identity/Repositories/TenantInvitationRepository.cs b/src/Modules/Identity/Repositories/TenantInvitationRepository.cs index 646f1bca..97633aa7 100644 --- a/src/Modules/Identity/Repositories/TenantInvitationRepository.cs +++ b/src/Modules/Identity/Repositories/TenantInvitationRepository.cs @@ -1,10 +1,9 @@ -using Identity.Persistence; using Microsoft.EntityFrameworkCore; namespace Identity.Repositories; /// -/// EF Core repository for with token hash and pending-invitation lookup methods. +/// EF Core repository for with token hash and pending-invitation lookup methods. /// public sealed class TenantInvitationRepository : RepositoryBase, @@ -21,21 +20,24 @@ public TenantInvitationRepository(IdentityDbContext dbContext) public Task GetNonRevokedByTokenHashAsync( string tokenHash, CancellationToken ct = default - ) => - _db.TenantInvitations.FirstOrDefaultAsync( + ) + { + return _db.TenantInvitations.FirstOrDefaultAsync( i => i.TokenHash == tokenHash && i.Status != InvitationStatus.Revoked, ct ); + } public Task HasPendingInvitationAsync( string normalizedEmail, CancellationToken ct = default - ) => - _db + ) + { + return _db .TenantInvitations.AsNoTracking() .AnyAsync( i => i.NormalizedEmail == normalizedEmail && i.Status == InvitationStatus.Pending, ct ); + } } - diff --git a/src/Modules/Identity/Security/Keycloak/KeycloakAdminTokenHandler.cs b/src/Modules/Identity/Security/Keycloak/KeycloakAdminTokenHandler.cs index 7cf9a98c..9649fc43 100644 --- a/src/Modules/Identity/Security/Keycloak/KeycloakAdminTokenHandler.cs +++ b/src/Modules/Identity/Security/Keycloak/KeycloakAdminTokenHandler.cs @@ -3,9 +3,9 @@ namespace Identity.Security.Keycloak; /// -/// A transient that attaches a cached Keycloak -/// service-account Bearer token to every outbound admin API request. -/// Token acquisition and caching are delegated to the singleton . +/// A transient that attaches a cached Keycloak +/// service-account Bearer token to every outbound admin API request. +/// Token acquisition and caching are delegated to the singleton . /// public sealed class KeycloakAdminTokenHandler : DelegatingHandler { @@ -17,17 +17,16 @@ public KeycloakAdminTokenHandler(KeycloakAdminTokenProvider tokenProvider) } /// - /// Fetches a valid service-account token from - /// and attaches it as a Bearer Authorization header before forwarding the request. + /// Fetches a valid service-account token from + /// and attaches it as a Bearer Authorization header before forwarding the request. /// protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken ) { - var token = await _tokenProvider.GetTokenAsync(cancellationToken); + string token = await _tokenProvider.GetTokenAsync(cancellationToken); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); return await base.SendAsync(request, cancellationToken); } } - diff --git a/src/Modules/Identity/Security/Tenant/UserProvisioningService.cs b/src/Modules/Identity/Security/Tenant/UserProvisioningService.cs index 344ca419..b704d7f2 100644 --- a/src/Modules/Identity/Security/Tenant/UserProvisioningService.cs +++ b/src/Modules/Identity/Security/Tenant/UserProvisioningService.cs @@ -1,15 +1,15 @@ -using Identity.ValueObjects; using Identity.Logging; +using Identity.ValueObjects; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace Identity.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. +/// 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 { @@ -18,8 +18,8 @@ public sealed class UserProvisioningService : IUserProvisioningService // 2. Both reads use global filter bypass; routing through repositories would require // adding filter-bypass methods to the repository interfaces for a single use case private readonly IdentityDbContext _db; - private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; + private readonly IUnitOfWork _unitOfWork; public UserProvisioningService( IdentityDbContext db, @@ -107,4 +107,3 @@ ILogger logger } } } - diff --git a/src/Modules/Notifications/Contracts/EmailOptions.cs b/src/Modules/Notifications/Contracts/EmailOptions.cs index cac502e9..ceab212c 100644 --- a/src/Modules/Notifications/Contracts/EmailOptions.cs +++ b/src/Modules/Notifications/Contracts/EmailOptions.cs @@ -4,8 +4,8 @@ namespace Notifications.Contracts; /// -/// Configuration for the outbound SMTP email service, including connection settings, sender identity, -/// and retry behaviour. +/// Configuration for the outbound SMTP email service, including connection settings, sender identity, +/// and retry behaviour. /// public sealed class EmailOptions { @@ -50,7 +50,3 @@ public sealed class EmailOptions [Range(1, int.MaxValue)] public int RetryBaseDelaySeconds { get; set; } = 2; } - - - - diff --git a/src/Modules/Notifications/Contracts/EmailTemplateNames.cs b/src/Modules/Notifications/Contracts/EmailTemplateNames.cs index ebd6c0b3..56c01920 100644 --- a/src/Modules/Notifications/Contracts/EmailTemplateNames.cs +++ b/src/Modules/Notifications/Contracts/EmailTemplateNames.cs @@ -1,8 +1,8 @@ namespace Notifications.Contracts; /// -/// Central registry of email template identifiers used by . -/// Centralising these strings prevents magic-string duplication across notification handlers. +/// Central registry of email template identifiers used by . +/// Centralising these strings prevents magic-string duplication across notification handlers. /// public static class EmailTemplateNames { diff --git a/src/Modules/Notifications/Contracts/IEmailRetryService.cs b/src/Modules/Notifications/Contracts/IEmailRetryService.cs index 1d810056..0387cabc 100644 --- a/src/Modules/Notifications/Contracts/IEmailRetryService.cs +++ b/src/Modules/Notifications/Contracts/IEmailRetryService.cs @@ -1,32 +1,28 @@ namespace Notifications.Contracts; /// -/// Application-layer contract for retrying and dead-lettering failed outbound emails. -/// Implementations are driven by recurring background jobs in the Infrastructure layer. +/// Application-layer contract for retrying and dead-lettering failed outbound emails. +/// Implementations are driven by recurring background jobs in the Infrastructure layer. /// public interface IEmailRetryService { /// - /// Re-attempts delivery of previously failed emails up to times, - /// processing up to messages per invocation. + /// Re-attempts delivery of previously failed emails up to times, + /// processing up to messages per invocation. /// - Task RetryFailedEmailsAsync( + public Task RetryFailedEmailsAsync( int maxRetryAttempts, int batchSize, CancellationToken ct = default ); /// - /// Moves emails that have exceeded the age threshold - /// to a dead-letter store, processed in batches of . + /// Moves emails that have exceeded the age threshold + /// to a dead-letter store, processed in batches of . /// - Task DeadLetterExpiredAsync( + public Task DeadLetterExpiredAsync( int deadLetterAfterHours, int batchSize, CancellationToken ct = default ); } - - - - diff --git a/src/Modules/Notifications/Domain/FailedEmail.cs b/src/Modules/Notifications/Domain/FailedEmail.cs index 880d853a..31cf1e82 100644 --- a/src/Modules/Notifications/Domain/FailedEmail.cs +++ b/src/Modules/Notifications/Domain/FailedEmail.cs @@ -3,15 +3,14 @@ namespace Notifications.Domain; /// -/// 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. +/// 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. + /// 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; } @@ -24,9 +23,6 @@ public sealed class FailedEmail : IHasId public string? ClaimedBy { get; set; } public DateTime? ClaimedAtUtc { get; set; } public DateTime? ClaimedUntilUtc { get; set; } -} - - - - + public Guid Id { get; set; } +} diff --git a/src/Modules/Notifications/Notifications.csproj b/src/Modules/Notifications/Notifications.csproj index ee182b85..146a4142 100644 --- a/src/Modules/Notifications/Notifications.csproj +++ b/src/Modules/Notifications/Notifications.csproj @@ -5,21 +5,21 @@ enable - + - + - + - - - - - - + + + + + + diff --git a/src/Modules/Notifications/Services/EmailSendingBackgroundService.cs b/src/Modules/Notifications/Services/EmailSendingBackgroundService.cs index fc21f73e..6dacaa26 100644 --- a/src/Modules/Notifications/Services/EmailSendingBackgroundService.cs +++ b/src/Modules/Notifications/Services/EmailSendingBackgroundService.cs @@ -1,26 +1,24 @@ using Microsoft.Extensions.Logging; using Notifications.Contracts; -using Notifications.Domain; -using Notifications.Services; using Notifications.Logging; using Polly; using Polly.Registry; using SharedKernel.Application.Resilience; +using SharedKernel.Infrastructure.BackgroundJobs.Services; namespace Notifications.Services; /// -/// Hosted background service that drains , sending each -/// through the SMTP resilience pipeline and storing failures -/// via for later retry. +/// Hosted background service that drains , sending each +/// through the SMTP resilience pipeline and storing failures +/// via for later retry. /// -public sealed class EmailSendingBackgroundService - : SharedKernel.Infrastructure.BackgroundJobs.Services.QueueConsumerBackgroundService +public sealed class EmailSendingBackgroundService : QueueConsumerBackgroundService { - private readonly IEmailSender _sender; - private readonly ResiliencePipelineProvider _resiliencePipelineProvider; private readonly IFailedEmailStore _failedEmailStore; private readonly ILogger _logger; + private readonly ResiliencePipelineProvider _resiliencePipelineProvider; + private readonly IEmailSender _sender; public EmailSendingBackgroundService( IEmailQueueReader queue, @@ -37,7 +35,7 @@ ILogger logger _logger = logger; } - /// Executes delivery of through the configured SMTP resilience pipeline. + /// Executes delivery of through the configured SMTP resilience pipeline. protected override async Task ProcessItemAsync(EmailMessage message, CancellationToken ct) { ResiliencePipeline pipeline = _resiliencePipelineProvider.GetPipeline( @@ -53,7 +51,10 @@ await pipeline.ExecuteAsync( ); } - /// Logs the final send failure and delegates to to persist the message for retry. + /// + /// Logs the final send failure and delegates to to persist the message for + /// retry. + /// protected override async Task HandleErrorAsync( EmailMessage message, Exception ex, @@ -65,8 +66,3 @@ CancellationToken ct await _failedEmailStore.StoreFailedAsync(message, ex.Message, ct); } } - - - - - diff --git a/src/Modules/Notifications/StoredProcedures/Sql/claim_expired_failed_emails_v1_up.sql b/src/Modules/Notifications/StoredProcedures/Sql/claim_expired_failed_emails_v1_up.sql index f9d3743f..b6192396 100644 --- a/src/Modules/Notifications/StoredProcedures/Sql/claim_expired_failed_emails_v1_up.sql +++ b/src/Modules/Notifications/StoredProcedures/Sql/claim_expired_failed_emails_v1_up.sql @@ -5,25 +5,25 @@ CREATE FUNCTION claim_expired_failed_emails( p_claimed_at_utc TIMESTAMPTZ, p_claimed_until_utc TIMESTAMPTZ ) -RETURNS TABLE( - "Id" UUID, - "To" TEXT, - "Subject" TEXT, - "HtmlBody" TEXT, - "RetryCount" INT, - "CreatedAtUtc" TIMESTAMPTZ, - "LastAttemptAtUtc" TIMESTAMPTZ, - "LastError" TEXT, - "TemplateName" TEXT, - "IsDeadLettered" BOOLEAN, - "ClaimedBy" TEXT, - "ClaimedAtUtc" TIMESTAMPTZ, - "ClaimedUntilUtc" TIMESTAMPTZ -) -LANGUAGE plpgsql AS $$ + RETURNS TABLE + ( + "Id" UUID, + "To" TEXT, + "Subject" TEXT, + "HtmlBody" TEXT, + "RetryCount" INT, + "CreatedAtUtc" TIMESTAMPTZ, + "LastAttemptAtUtc" TIMESTAMPTZ, + "LastError" TEXT, + "TemplateName" TEXT, + "IsDeadLettered" BOOLEAN, + "ClaimedBy" TEXT, + "ClaimedAtUtc" TIMESTAMPTZ, + "ClaimedUntilUtc" TIMESTAMPTZ + ) + LANGUAGE plpgsql AS $$ BEGIN - RETURN QUERY - WITH claimed AS ( +RETURN QUERY WITH claimed AS ( SELECT fe."Id" FROM "FailedEmails" fe WHERE NOT fe."IsDeadLettered" @@ -33,15 +33,23 @@ BEGIN LIMIT p_batch_size FOR UPDATE SKIP LOCKED ) - UPDATE "FailedEmails" AS failed - SET "ClaimedBy" = p_claimed_by, - "ClaimedAtUtc" = p_claimed_at_utc, - "ClaimedUntilUtc" = p_claimed_until_utc - FROM claimed - WHERE failed."Id" = claimed."Id" - RETURNING failed."Id", failed."To", failed."Subject", failed."HtmlBody", - failed."RetryCount", failed."CreatedAtUtc", failed."LastAttemptAtUtc", - failed."LastError", failed."TemplateName", failed."IsDeadLettered", - failed."ClaimedBy", failed."ClaimedAtUtc", failed."ClaimedUntilUtc"; +UPDATE "FailedEmails" AS failed +SET "ClaimedBy" = p_claimed_by, + "ClaimedAtUtc" = p_claimed_at_utc, + "ClaimedUntilUtc" = p_claimed_until_utc FROM claimed +WHERE failed."Id" = claimed."Id" + RETURNING failed."Id" + , failed."To" + , failed."Subject" + , failed."HtmlBody" + , failed."RetryCount" + , failed."CreatedAtUtc" + , failed."LastAttemptAtUtc" + , failed."LastError" + , failed."TemplateName" + , failed."IsDeadLettered" + , failed."ClaimedBy" + , failed."ClaimedAtUtc" + , failed."ClaimedUntilUtc"; END; $$; diff --git a/src/Modules/Notifications/StoredProcedures/Sql/claim_retryable_failed_emails_v1_up.sql b/src/Modules/Notifications/StoredProcedures/Sql/claim_retryable_failed_emails_v1_up.sql index 15db2fba..d57a5249 100644 --- a/src/Modules/Notifications/StoredProcedures/Sql/claim_retryable_failed_emails_v1_up.sql +++ b/src/Modules/Notifications/StoredProcedures/Sql/claim_retryable_failed_emails_v1_up.sql @@ -5,25 +5,25 @@ CREATE FUNCTION claim_retryable_failed_emails( p_claimed_at_utc TIMESTAMPTZ, p_claimed_until_utc TIMESTAMPTZ ) -RETURNS TABLE( - "Id" UUID, - "To" TEXT, - "Subject" TEXT, - "HtmlBody" TEXT, - "RetryCount" INT, - "CreatedAtUtc" TIMESTAMPTZ, - "LastAttemptAtUtc" TIMESTAMPTZ, - "LastError" TEXT, - "TemplateName" TEXT, - "IsDeadLettered" BOOLEAN, - "ClaimedBy" TEXT, - "ClaimedAtUtc" TIMESTAMPTZ, - "ClaimedUntilUtc" TIMESTAMPTZ -) -LANGUAGE plpgsql AS $$ + RETURNS TABLE + ( + "Id" UUID, + "To" TEXT, + "Subject" TEXT, + "HtmlBody" TEXT, + "RetryCount" INT, + "CreatedAtUtc" TIMESTAMPTZ, + "LastAttemptAtUtc" TIMESTAMPTZ, + "LastError" TEXT, + "TemplateName" TEXT, + "IsDeadLettered" BOOLEAN, + "ClaimedBy" TEXT, + "ClaimedAtUtc" TIMESTAMPTZ, + "ClaimedUntilUtc" TIMESTAMPTZ + ) + LANGUAGE plpgsql AS $$ BEGIN - RETURN QUERY - WITH claimed AS ( +RETURN QUERY WITH claimed AS ( SELECT fe."Id" FROM "FailedEmails" fe WHERE NOT fe."IsDeadLettered" @@ -33,15 +33,23 @@ BEGIN LIMIT p_batch_size FOR UPDATE SKIP LOCKED ) - UPDATE "FailedEmails" AS failed - SET "ClaimedBy" = p_claimed_by, - "ClaimedAtUtc" = p_claimed_at_utc, - "ClaimedUntilUtc" = p_claimed_until_utc - FROM claimed - WHERE failed."Id" = claimed."Id" - RETURNING failed."Id", failed."To", failed."Subject", failed."HtmlBody", - failed."RetryCount", failed."CreatedAtUtc", failed."LastAttemptAtUtc", - failed."LastError", failed."TemplateName", failed."IsDeadLettered", - failed."ClaimedBy", failed."ClaimedAtUtc", failed."ClaimedUntilUtc"; +UPDATE "FailedEmails" AS failed +SET "ClaimedBy" = p_claimed_by, + "ClaimedAtUtc" = p_claimed_at_utc, + "ClaimedUntilUtc" = p_claimed_until_utc FROM claimed +WHERE failed."Id" = claimed."Id" + RETURNING failed."Id" + , failed."To" + , failed."Subject" + , failed."HtmlBody" + , failed."RetryCount" + , failed."CreatedAtUtc" + , failed."LastAttemptAtUtc" + , failed."LastError" + , failed."TemplateName" + , failed."IsDeadLettered" + , failed."ClaimedBy" + , failed."ClaimedAtUtc" + , failed."ClaimedUntilUtc"; END; $$; diff --git a/src/Modules/ProductCatalog/Configurations/ProductCategoryStatsConfiguration.cs b/src/Modules/ProductCatalog/Configurations/ProductCategoryStatsConfiguration.cs index 9f0da3fd..c904d0ee 100644 --- a/src/Modules/ProductCatalog/Configurations/ProductCategoryStatsConfiguration.cs +++ b/src/Modules/ProductCatalog/Configurations/ProductCategoryStatsConfiguration.cs @@ -1,15 +1,15 @@ -using ProductCatalog.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace ProductCatalog.Configurations; /// -/// Registers as a keyless entity. -/// 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. +/// Registers as a keyless entity. +/// 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) { @@ -21,6 +21,3 @@ public void Configure(EntityTypeBuilder builder) builder.ToTable("ProductCategoryStats", t => t.ExcludeFromMigrations()); } } - - - diff --git a/src/Modules/ProductCatalog/Configurations/ProductConfiguration.cs b/src/Modules/ProductCatalog/Configurations/ProductConfiguration.cs index e97b80cf..2f8bf4e0 100644 --- a/src/Modules/ProductCatalog/Configurations/ProductConfiguration.cs +++ b/src/Modules/ProductCatalog/Configurations/ProductConfiguration.cs @@ -1,11 +1,13 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using ProductCatalog.Entities; using ProductCatalog.ValueObjects; namespace ProductCatalog.Configurations; -/// EF Core configuration for the entity, including price precision and a full-text search GIN index. +/// +/// 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) @@ -35,4 +37,3 @@ public void Configure(EntityTypeBuilder builder) .IsTsVectorExpressionIndex("english"); } } - diff --git a/src/Modules/ProductCatalog/Configurations/ProductDataLinkConfiguration.cs b/src/Modules/ProductCatalog/Configurations/ProductDataLinkConfiguration.cs index 82859e2a..9beddd65 100644 --- a/src/Modules/ProductCatalog/Configurations/ProductDataLinkConfiguration.cs +++ b/src/Modules/ProductCatalog/Configurations/ProductDataLinkConfiguration.cs @@ -1,10 +1,9 @@ -using ProductCatalog.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace ProductCatalog.Configurations; -/// EF Core configuration for the join entity with a composite primary key. +/// EF Core configuration for the join entity with a composite primary key. public sealed class ProductDataLinkConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) @@ -26,6 +25,3 @@ public void Configure(EntityTypeBuilder builder) .OnDelete(DeleteBehavior.Restrict); } } - - - diff --git a/src/Modules/ProductCatalog/Entities/Category.cs b/src/Modules/ProductCatalog/Entities/Category.cs index bd1f31a6..97255b54 100644 --- a/src/Modules/ProductCatalog/Entities/Category.cs +++ b/src/Modules/ProductCatalog/Entities/Category.cs @@ -1,13 +1,11 @@ namespace ProductCatalog.Entities; /// -/// Domain entity representing a product category within a tenant. -/// Acts as an aggregate root that groups related 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; @@ -26,7 +24,5 @@ public required string Name public bool IsDeleted { get; set; } public DateTime? DeletedAtUtc { get; set; } public Guid? DeletedBy { get; set; } + public Guid Id { get; set; } } - - - diff --git a/src/Modules/ProductCatalog/Entities/Product.cs b/src/Modules/ProductCatalog/Entities/Product.cs index 3bdddff5..f3d4876b 100644 --- a/src/Modules/ProductCatalog/Entities/Product.cs +++ b/src/Modules/ProductCatalog/Entities/Product.cs @@ -3,14 +3,11 @@ namespace ProductCatalog.Entities; /// -/// Core domain entity representing a product in the catalog. -/// This is the aggregate root - all business rules around products start here. +/// 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 { @@ -29,7 +26,7 @@ public required string Name public Guid? CategoryId { get; set; } - /// Infrastructure-only navigation for query projections. Domain logic must use instead. + /// Infrastructure-only navigation for query projections. Domain logic must use instead. public Category? Category { get; set; } public ICollection ProductDataLinks { get; set; } = []; @@ -40,8 +37,11 @@ public required string Name public DateTime? DeletedAtUtc { get; set; } public Guid? DeletedBy { get; set; } + /// Unique identifier generated when the product is created. + public Guid Id { get; set; } + /// - /// Creates a new with the given fields and optional product-data links. + /// Creates a new with the given fields and optional product-data links. /// public static Product Create( string name, @@ -65,11 +65,12 @@ public static Product Create( foreach (Guid pdId in productDataIds.Distinct()) product.ProductDataLinks.Add(ProductDataLink.Create(id, pdId)); } + return product; } /// - /// Atomically replaces all mutable product fields in a single call, enforcing property-level invariants. + /// Atomically replaces all mutable product fields in a single call, enforcing property-level invariants. /// public void UpdateDetails(string name, string? description, Price price, Guid? categoryId) { @@ -80,8 +81,9 @@ public void UpdateDetails(string name, string? description, Price price, Guid? c } /// - /// Reconciles the product's collection against the desired set of . - /// Removes links not in the target set and creates new links as needed. + /// 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(IEnumerable targetIds) { @@ -111,7 +113,8 @@ public void SyncProductDataLinks(IEnumerable targetIds) } /// - /// Removes all current product data links from the in-memory collection, preparing them for soft-delete by the persistence layer. + /// Removes all current product data links from the in-memory collection, preparing them for soft-delete by the + /// persistence layer. /// public void SoftDeleteProductDataLinks() { @@ -119,4 +122,3 @@ public void SoftDeleteProductDataLinks() ProductDataLinks.Remove(link); } } - diff --git a/src/Modules/ProductCatalog/Entities/ProductCategoryStats.cs b/src/Modules/ProductCatalog/Entities/ProductCategoryStats.cs index 48ccb2fb..8866a45b 100644 --- a/src/Modules/ProductCatalog/Entities/ProductCategoryStats.cs +++ b/src/Modules/ProductCatalog/Entities/ProductCategoryStats.cs @@ -1,9 +1,9 @@ namespace ProductCatalog.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. +/// 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 { @@ -13,6 +13,3 @@ public sealed class ProductCategoryStats public decimal AveragePrice { get; set; } public long TotalReviews { get; set; } } - - - diff --git a/src/Modules/ProductCatalog/Entities/ProductData/ImageProductData.cs b/src/Modules/ProductCatalog/Entities/ProductData/ImageProductData.cs index ee6e1f53..77b10269 100644 --- a/src/Modules/ProductCatalog/Entities/ProductData/ImageProductData.cs +++ b/src/Modules/ProductCatalog/Entities/ProductData/ImageProductData.cs @@ -3,7 +3,8 @@ namespace ProductCatalog.Entities.ProductData; /// -/// MongoDB document subtype that represents image media linked to a product, storing image-specific metadata such as dimensions and format. +/// 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 @@ -16,6 +17,3 @@ public sealed class ImageProductData : ProductData public long FileSizeBytes { get; set; } } - - - diff --git a/src/Modules/ProductCatalog/Entities/ProductDataLink.cs b/src/Modules/ProductCatalog/Entities/ProductDataLink.cs index df9b2b7f..eb322d3c 100644 --- a/src/Modules/ProductCatalog/Entities/ProductDataLink.cs +++ b/src/Modules/ProductCatalog/Entities/ProductDataLink.cs @@ -1,8 +1,9 @@ namespace ProductCatalog.Entities; /// -/// Join entity that associates a with a document stored in MongoDB. -/// Supports soft-delete so that links can be restored without data loss. +/// 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 { @@ -10,6 +11,8 @@ public sealed class ProductDataLink : IAuditableTenantEntity public Guid ProductDataId { get; set; } + public Product Product { get; set; } = null!; + public Guid TenantId { get; set; } public AuditInfo Audit { get; set; } = new(); @@ -20,16 +23,16 @@ public sealed class ProductDataLink : IAuditableTenantEntity 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. + /// 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 }; + public static ProductDataLink Create(Guid productId, Guid productDataId) + { + return new ProductDataLink { ProductId = productId, ProductDataId = productDataId }; + } /// - /// Clears all soft-delete fields, effectively un-deleting this link. + /// Clears all soft-delete fields, effectively un-deleting this link. /// public void Restore() { @@ -38,6 +41,3 @@ public void Restore() DeletedBy = null; } } - - - diff --git a/src/Modules/ProductCatalog/Features/Category/CreateCategories/CategoriesController.CreateCategories.cs b/src/Modules/ProductCatalog/Features/Category/CreateCategories/CategoriesController.CreateCategories.cs index a21c7e68..b2fc4454 100644 --- a/src/Modules/ProductCatalog/Features/Category/CreateCategories/CategoriesController.CreateCategories.cs +++ b/src/Modules/ProductCatalog/Features/Category/CreateCategories/CategoriesController.CreateCategories.cs @@ -1,8 +1,6 @@ using ErrorOr; using Microsoft.AspNetCore.Mvc; using ProductCatalog.Features.Category.CreateCategories; -using SharedKernel.Contracts.Api; -using SharedKernel.Contracts.Security; namespace ProductCatalog.Features.Category; diff --git a/src/Modules/ProductCatalog/Features/Category/CreateCategories/CreateCategoryRequestValidator.cs b/src/Modules/ProductCatalog/Features/Category/CreateCategories/CreateCategoryRequestValidator.cs index 1319e439..bfef3a46 100644 --- a/src/Modules/ProductCatalog/Features/Category/CreateCategories/CreateCategoryRequestValidator.cs +++ b/src/Modules/ProductCatalog/Features/Category/CreateCategories/CreateCategoryRequestValidator.cs @@ -1,9 +1,7 @@ -using SharedKernel.Application.Validation; - namespace ProductCatalog.Features.Category.CreateCategories; /// -/// FluentValidation validator for that enforces data-annotation constraints. +/// FluentValidation validator for that enforces data-annotation constraints. /// public sealed class CreateCategoryRequestValidator : DataAnnotationsValidator; diff --git a/src/Modules/ProductCatalog/Features/Category/GetCategories/CategoriesController.GetCategories.cs b/src/Modules/ProductCatalog/Features/Category/GetCategories/CategoriesController.GetCategories.cs index ca6127e9..76c2e148 100644 --- a/src/Modules/ProductCatalog/Features/Category/GetCategories/CategoriesController.GetCategories.cs +++ b/src/Modules/ProductCatalog/Features/Category/GetCategories/CategoriesController.GetCategories.cs @@ -2,8 +2,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.OutputCaching; using ProductCatalog.Features.Category.GetCategories; -using SharedKernel.Contracts.Api; -using SharedKernel.Contracts.Security; namespace ProductCatalog.Features.Category; diff --git a/src/Modules/ProductCatalog/Features/Category/GetCategories/CategoryFilter.cs b/src/Modules/ProductCatalog/Features/Category/GetCategories/CategoryFilter.cs index ce308095..c1c7add6 100644 --- a/src/Modules/ProductCatalog/Features/Category/GetCategories/CategoryFilter.cs +++ b/src/Modules/ProductCatalog/Features/Category/GetCategories/CategoryFilter.cs @@ -1,10 +1,7 @@ -using SharedKernel.Application.Contracts; -using SharedKernel.Application.DTOs; - namespace ProductCatalog.Features.Category.GetCategories; /// -/// Filter parameters for querying categories, supporting full-text search, sorting, and pagination. +/// Filter parameters for querying categories, supporting full-text search, sorting, and pagination. /// public sealed record CategoryFilter( string? Query = null, diff --git a/src/Modules/ProductCatalog/Features/Category/GetCategories/CategoryFilterCriteria.cs b/src/Modules/ProductCatalog/Features/Category/GetCategories/CategoryFilterCriteria.cs index 5513cf05..2c7a1796 100644 --- a/src/Modules/ProductCatalog/Features/Category/GetCategories/CategoryFilterCriteria.cs +++ b/src/Modules/ProductCatalog/Features/Category/GetCategories/CategoryFilterCriteria.cs @@ -1,4 +1,3 @@ -using SharedKernel.Application.Search; using Ardalis.Specification; using Microsoft.EntityFrameworkCore; using CategoryEntity = ProductCatalog.Entities.Category; @@ -6,14 +5,15 @@ namespace ProductCatalog.Features.Category.GetCategories; /// -/// 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. +/// 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 { /// - /// Appends a full-text search predicate to when is non-empty. - /// Searches across the category name and description columns. + /// Appends a full-text search predicate to when is + /// non-empty. + /// Searches across the category name and description columns. /// internal static void ApplyFilter( this ISpecificationBuilder query, diff --git a/src/Modules/ProductCatalog/Features/Category/GetCategories/CategoryFilterValidator.cs b/src/Modules/ProductCatalog/Features/Category/GetCategories/CategoryFilterValidator.cs index ca829d1f..e11a8e48 100644 --- a/src/Modules/ProductCatalog/Features/Category/GetCategories/CategoryFilterValidator.cs +++ b/src/Modules/ProductCatalog/Features/Category/GetCategories/CategoryFilterValidator.cs @@ -1,11 +1,10 @@ -using SharedKernel.Application.Validation; using FluentValidation; namespace ProductCatalog.Features.Category.GetCategories; /// -/// FluentValidation validator for . -/// Composes pagination and sortable filter validation rules by inclusion. +/// FluentValidation validator for . +/// Composes pagination and sortable filter validation rules by inclusion. /// public sealed class CategoryFilterValidator : AbstractValidator { diff --git a/src/Modules/ProductCatalog/Features/Category/GetCategoryStats/ProductCategoryStatsResponse.cs b/src/Modules/ProductCatalog/Features/Category/GetCategoryStats/ProductCategoryStatsResponse.cs index ab691a60..820083a2 100644 --- a/src/Modules/ProductCatalog/Features/Category/GetCategoryStats/ProductCategoryStatsResponse.cs +++ b/src/Modules/ProductCatalog/Features/Category/GetCategoryStats/ProductCategoryStatsResponse.cs @@ -1,7 +1,7 @@ namespace ProductCatalog.Features.Category.GetCategoryStats; /// -/// Aggregated statistics for a single category, including product count, average price, and total review count. +/// Aggregated statistics for a single category, including product count, average price, and total review count. /// public sealed record ProductCategoryStatsResponse( Guid CategoryId, diff --git a/src/Modules/ProductCatalog/Features/Category/Shared/CategoriesByIdsSpecification.cs b/src/Modules/ProductCatalog/Features/Category/Shared/CategoriesByIdsSpecification.cs index d538a393..81e9a9cd 100644 --- a/src/Modules/ProductCatalog/Features/Category/Shared/CategoriesByIdsSpecification.cs +++ b/src/Modules/ProductCatalog/Features/Category/Shared/CategoriesByIdsSpecification.cs @@ -4,7 +4,7 @@ namespace ProductCatalog.Features.Category.Shared; /// -/// Ardalis specification that loads multiple categories by their IDs, used for batch update and delete operations. +/// Ardalis specification that loads multiple categories by their IDs, used for batch update and delete operations. /// public sealed class CategoriesByIdsSpecification : Specification { diff --git a/src/Modules/ProductCatalog/Features/Category/Shared/CategoryMappings.cs b/src/Modules/ProductCatalog/Features/Category/Shared/CategoryMappings.cs index 6f584ba6..cc03a9b4 100644 --- a/src/Modules/ProductCatalog/Features/Category/Shared/CategoryMappings.cs +++ b/src/Modules/ProductCatalog/Features/Category/Shared/CategoryMappings.cs @@ -4,14 +4,14 @@ namespace ProductCatalog.Features.Category.Shared; /// -/// Provides mapping utilities between category domain entities and their response DTOs. -/// The compiled projection is reused across specifications and in-memory conversions for consistency. +/// Provides mapping utilities between category domain entities and their response DTOs. +/// The compiled projection is reused across specifications and in-memory conversions for consistency. /// public static class CategoryMappings { /// - /// EF Core-compatible expression that projects a to a . - /// Shared with so the same shape is produced by both DB queries and in-memory maps. + /// EF Core-compatible expression that projects a to a . + /// Shared with so the same shape is produced by both DB queries and in-memory maps. /// public static readonly Expression> Projection = category => new CategoryResponse( @@ -24,7 +24,9 @@ public static class CategoryMappings 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 using the compiled projection. + public static CategoryResponse ToResponse(this CategoryEntity category) + { + return CompiledProjection(category); + } } diff --git a/src/Modules/ProductCatalog/Features/Product/DeleteProducts/DeleteProductsCommand.cs b/src/Modules/ProductCatalog/Features/Product/DeleteProducts/DeleteProductsCommand.cs index 1bd06e74..8ee9024e 100644 --- a/src/Modules/ProductCatalog/Features/Product/DeleteProducts/DeleteProductsCommand.cs +++ b/src/Modules/ProductCatalog/Features/Product/DeleteProducts/DeleteProductsCommand.cs @@ -1,5 +1,4 @@ using ErrorOr; -using ProductCatalog; using Wolverine; using ProductRepositoryContract = ProductCatalog.Interfaces.IProductRepository; @@ -8,15 +7,12 @@ namespace ProductCatalog.Features.Product.DeleteProducts; /// 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. +/// +/// Handles by loading all products, soft-deleting links and products in a +/// single transaction. +/// public sealed class DeleteProductsCommandHandler { - public sealed record DeleteProductsState( - IReadOnlyList Products, - Guid ActorId, - DateTime DeletedAtUtc - ); - public static async Task<( HandlerContinuation, DeleteProductsState?, @@ -35,7 +31,7 @@ CancellationToken ct BatchFailureContext context = new(ids); // Load all target products and mark missing ones as failed - IReadOnlyList products = await repository.ListAsync( + IReadOnlyList products = await repository.ListAsync( new ProductsByIdsWithLinksSpecification(ids.ToHashSet()), ct ); @@ -93,4 +89,10 @@ await unitOfWork.ExecuteInTransactionAsync( return (new BatchResponse([], command.Request.Ids.Count, 0), messages); } + + public sealed record DeleteProductsState( + IReadOnlyList Products, + Guid ActorId, + DateTime DeletedAtUtc + ); } diff --git a/src/Modules/ProductCatalog/Features/Product/GetProducts/ProductFilter.cs b/src/Modules/ProductCatalog/Features/Product/GetProducts/ProductFilter.cs index 4b1d84d6..52e88711 100644 --- a/src/Modules/ProductCatalog/Features/Product/GetProducts/ProductFilter.cs +++ b/src/Modules/ProductCatalog/Features/Product/GetProducts/ProductFilter.cs @@ -1,10 +1,8 @@ -using SharedKernel.Application.Contracts; -using SharedKernel.Application.DTOs; - namespace ProductCatalog.Features.Product.GetProducts; /// -/// Encapsulates all criteria available for querying and paging the product list, including text search, price range, date range, category filtering, and sorting. +/// 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, diff --git a/src/Modules/ProductCatalog/Features/Product/IdempotentCreate/IdempotentController.cs b/src/Modules/ProductCatalog/Features/Product/IdempotentCreate/IdempotentController.cs index 7eb9445d..f609adf4 100644 --- a/src/Modules/ProductCatalog/Features/Product/IdempotentCreate/IdempotentController.cs +++ b/src/Modules/ProductCatalog/Features/Product/IdempotentCreate/IdempotentController.cs @@ -1,6 +1,4 @@ using Asp.Versioning; -using Microsoft.AspNetCore.Mvc; -using SharedKernel.Contracts.Api; using Wolverine; namespace ProductCatalog.Features.Product.IdempotentCreate; diff --git a/src/Modules/ProductCatalog/Features/Product/IdempotentCreate/IdempotentCreateResponse.cs b/src/Modules/ProductCatalog/Features/Product/IdempotentCreate/IdempotentCreateResponse.cs index e9a83e5c..90dc51d4 100644 --- a/src/Modules/ProductCatalog/Features/Product/IdempotentCreate/IdempotentCreateResponse.cs +++ b/src/Modules/ProductCatalog/Features/Product/IdempotentCreate/IdempotentCreateResponse.cs @@ -1,9 +1,7 @@ -using SharedKernel.Domain.Entities.Contracts; - namespace ProductCatalog.Features.Product.IdempotentCreate; /// -/// Represents the persisted resource returned after a successful idempotent create operation. +/// Represents the persisted resource returned after a successful idempotent create operation. /// public sealed record IdempotentCreateResponse( Guid Id, diff --git a/src/Modules/ProductCatalog/Features/Product/PatchProduct/PatchController.PatchProduct.cs b/src/Modules/ProductCatalog/Features/Product/PatchProduct/PatchController.PatchProduct.cs index c0fc88d8..797320da 100644 --- a/src/Modules/ProductCatalog/Features/Product/PatchProduct/PatchController.PatchProduct.cs +++ b/src/Modules/ProductCatalog/Features/Product/PatchProduct/PatchController.PatchProduct.cs @@ -1,9 +1,6 @@ using ErrorOr; using Microsoft.AspNetCore.Mvc; -using SharedKernel.Contracts.Api; -using SharedKernel.Contracts.Security; using SystemTextJsonPatch; -using Wolverine; namespace ProductCatalog.Features.Product.PatchProduct; diff --git a/src/Modules/ProductCatalog/Features/Product/PatchProduct/PatchProductCommand.cs b/src/Modules/ProductCatalog/Features/Product/PatchProduct/PatchProductCommand.cs index 8c031e82..7b1f8ce7 100644 --- a/src/Modules/ProductCatalog/Features/Product/PatchProduct/PatchProductCommand.cs +++ b/src/Modules/ProductCatalog/Features/Product/PatchProduct/PatchProductCommand.cs @@ -1,6 +1,6 @@ using ErrorOr; using FluentValidation; -using ProductCatalog; +using FluentValidation.Results; using ProductCatalog.ValueObjects; using SystemTextJsonPatch; using Wolverine; @@ -47,17 +47,16 @@ CancellationToken ct ); } - FluentValidation.Results.ValidationResult validationResult = await validator.ValidateAsync( - dto, - ct - ); + ValidationResult validationResult = await validator.ValidateAsync(dto, ct); if (!validationResult.IsValid) + { return ( DomainErrors.Patch.InvalidPatchDocument( string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)) ), OutgoingMessagesHelper.Empty ); + } ErrorOr priceResult = Price.Create(dto.Price); if (priceResult.IsError) diff --git a/src/Modules/ProductCatalog/Features/Product/Shared/ProductResponse.cs b/src/Modules/ProductCatalog/Features/Product/Shared/ProductResponse.cs index d10f8f27..0540a6a4 100644 --- a/src/Modules/ProductCatalog/Features/Product/Shared/ProductResponse.cs +++ b/src/Modules/ProductCatalog/Features/Product/Shared/ProductResponse.cs @@ -1,7 +1,7 @@ namespace ProductCatalog.Features.Product.Shared; /// -/// Represents a product as returned by the Application layer to API consumers, projected from the domain entity. +/// Represents a product as returned by the Application layer to API consumers, projected from the domain entity. /// public sealed record ProductResponse( Guid Id, diff --git a/src/Modules/ProductCatalog/Features/Product/Shared/ProductsByIdsWithLinksSpecification.cs b/src/Modules/ProductCatalog/Features/Product/Shared/ProductsByIdsWithLinksSpecification.cs index ff572426..dfcbdd89 100644 --- a/src/Modules/ProductCatalog/Features/Product/Shared/ProductsByIdsWithLinksSpecification.cs +++ b/src/Modules/ProductCatalog/Features/Product/Shared/ProductsByIdsWithLinksSpecification.cs @@ -4,8 +4,8 @@ namespace ProductCatalog.Features.Product.Shared; /// -/// Ardalis specification that loads multiple products by their IDs and eagerly includes -/// their ProductDataLinks collections, used for batch update and delete operations. +/// 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 { diff --git a/src/Modules/ProductCatalog/Features/Product/UpdateProducts/ProductsController.UpdateProducts.cs b/src/Modules/ProductCatalog/Features/Product/UpdateProducts/ProductsController.UpdateProducts.cs index c5dca43b..b84f1dbb 100644 --- a/src/Modules/ProductCatalog/Features/Product/UpdateProducts/ProductsController.UpdateProducts.cs +++ b/src/Modules/ProductCatalog/Features/Product/UpdateProducts/ProductsController.UpdateProducts.cs @@ -1,8 +1,6 @@ using ErrorOr; using Microsoft.AspNetCore.Mvc; using ProductCatalog.Features.Product.UpdateProducts; -using SharedKernel.Contracts.Api; -using SharedKernel.Contracts.Security; namespace ProductCatalog.Features.Product; diff --git a/src/Modules/ProductCatalog/Features/ProductData/CreateImageProductData/CreateImageProductDataRequestValidator.cs b/src/Modules/ProductCatalog/Features/ProductData/CreateImageProductData/CreateImageProductDataRequestValidator.cs index 0674007c..77a81286 100644 --- a/src/Modules/ProductCatalog/Features/ProductData/CreateImageProductData/CreateImageProductDataRequestValidator.cs +++ b/src/Modules/ProductCatalog/Features/ProductData/CreateImageProductData/CreateImageProductDataRequestValidator.cs @@ -1,9 +1,8 @@ -using SharedKernel.Application.Validation; - namespace ProductCatalog.Features.ProductData.CreateImageProductData; /// -/// FluentValidation validator for , delegating to data-annotation-based validation rules. +/// FluentValidation validator for , delegating to data-annotation-based +/// validation rules. /// public sealed class CreateImageProductDataRequestValidator : DataAnnotationsValidator; diff --git a/src/Modules/ProductCatalog/Features/ProductData/CreateVideoProductData/CreateVideoProductDataCommand.cs b/src/Modules/ProductCatalog/Features/ProductData/CreateVideoProductData/CreateVideoProductDataCommand.cs index d1728041..ba4e9f29 100644 --- a/src/Modules/ProductCatalog/Features/ProductData/CreateVideoProductData/CreateVideoProductDataCommand.cs +++ b/src/Modules/ProductCatalog/Features/ProductData/CreateVideoProductData/CreateVideoProductDataCommand.cs @@ -1,8 +1,4 @@ using ErrorOr; -using ProductCatalog.Entities; -using ProductCatalog.Interfaces; -using SharedKernel.Application.Context; -using SharedKernel.Contracts.Events; using Wolverine; namespace ProductCatalog.Features.ProductData.CreateVideoProductData; @@ -19,7 +15,7 @@ public sealed class CreateVideoProductDataCommandHandler CancellationToken ct ) { - var entity = new VideoProductData + VideoProductData entity = new() { TenantId = tenantProvider.TenantId, Title = command.Request.Title, diff --git a/src/Modules/ProductCatalog/Features/ProductData/CreateVideoProductData/CreateVideoProductDataRequestValidator.cs b/src/Modules/ProductCatalog/Features/ProductData/CreateVideoProductData/CreateVideoProductDataRequestValidator.cs index 4e7fa9b6..bf4461bc 100644 --- a/src/Modules/ProductCatalog/Features/ProductData/CreateVideoProductData/CreateVideoProductDataRequestValidator.cs +++ b/src/Modules/ProductCatalog/Features/ProductData/CreateVideoProductData/CreateVideoProductDataRequestValidator.cs @@ -1,9 +1,8 @@ -using SharedKernel.Application.Validation; - namespace ProductCatalog.Features.ProductData.CreateVideoProductData; /// -/// FluentValidation validator for , delegating to data-annotation-based validation rules. +/// FluentValidation validator for , delegating to data-annotation-based +/// validation rules. /// public sealed class CreateVideoProductDataRequestValidator : DataAnnotationsValidator; diff --git a/src/Modules/ProductCatalog/Features/ProductData/DeleteProductData/ProductDataController.Delete.cs b/src/Modules/ProductCatalog/Features/ProductData/DeleteProductData/ProductDataController.Delete.cs index 28c0cd2d..69bd66dc 100644 --- a/src/Modules/ProductCatalog/Features/ProductData/DeleteProductData/ProductDataController.Delete.cs +++ b/src/Modules/ProductCatalog/Features/ProductData/DeleteProductData/ProductDataController.Delete.cs @@ -1,8 +1,6 @@ using ErrorOr; using Microsoft.AspNetCore.Mvc; using ProductCatalog.Features.ProductData.DeleteProductData; -using SharedKernel.Contracts.Api; -using SharedKernel.Contracts.Security; namespace ProductCatalog.Features.ProductData; diff --git a/src/Modules/ProductCatalog/Features/ProductData/GetProductDataById/GetProductDataByIdQuery.cs b/src/Modules/ProductCatalog/Features/ProductData/GetProductDataById/GetProductDataByIdQuery.cs index 3bcccd13..b7b70d97 100644 --- a/src/Modules/ProductCatalog/Features/ProductData/GetProductDataById/GetProductDataByIdQuery.cs +++ b/src/Modules/ProductCatalog/Features/ProductData/GetProductDataById/GetProductDataByIdQuery.cs @@ -1,5 +1,4 @@ using ErrorOr; -using SharedKernel.Application.Context; namespace ProductCatalog.Features.ProductData.GetProductDataById; diff --git a/src/Modules/ProductCatalog/Features/ProductData/Shared/ProductDataMappings.cs b/src/Modules/ProductCatalog/Features/ProductData/Shared/ProductDataMappings.cs index 986b5c91..27557e7d 100644 --- a/src/Modules/ProductCatalog/Features/ProductData/Shared/ProductDataMappings.cs +++ b/src/Modules/ProductCatalog/Features/ProductData/Shared/ProductDataMappings.cs @@ -5,17 +5,18 @@ namespace ProductCatalog.Features.ProductData.Shared; /// -/// Provides mapping utilities from product data domain entities to their polymorphic response DTOs. -/// Dispatches to a type-specific mapping method based on the concrete entity type. +/// Provides mapping utilities from product data domain entities to their polymorphic response DTOs. +/// Dispatches to a type-specific mapping method based on the concrete entity type. /// public static class ProductDataMappings { /// - /// Maps a to the appropriate subtype. - /// Throws for unrecognised entity types. + /// Maps a to the appropriate subtype. + /// Throws for unrecognised entity types. /// - public static ProductDataResponse ToResponse(this ProductDataEntity data) => - data switch + public static ProductDataResponse ToResponse(this ProductDataEntity data) + { + return data switch { ImageProductDataEntity image => image.ToImageResponse(), VideoProductDataEntity video => video.ToVideoResponse(), @@ -23,11 +24,13 @@ public static ProductDataResponse ToResponse(this ProductDataEntity data) => $"Unknown ProductData type: {data.GetType().Name}" ), }; + } /// Copies shared fields from the base entity onto an already-populated response record. private static T MapCommon(this ProductDataEntity data, T response, string type) - where T : ProductDataResponse => - response with + where T : ProductDataResponse + { + return response with { Id = data.Id, Title = data.Title, @@ -35,10 +38,12 @@ response with CreatedAt = data.CreatedAt, Type = type, }; + } - /// Maps an to an . - private static ImageProductDataResponse ToImageResponse(this ImageProductDataEntity image) => - image.MapCommon( + /// Maps an to an . + private static ImageProductDataResponse ToImageResponse(this ImageProductDataEntity image) + { + return image.MapCommon( new ImageProductDataResponse { Width = image.Width, @@ -48,10 +53,12 @@ private static ImageProductDataResponse ToImageResponse(this ImageProductDataEnt }, "image" ); + } - /// Maps a to a . - private static VideoProductDataResponse ToVideoResponse(this VideoProductDataEntity video) => - video.MapCommon( + /// Maps a to a . + private static VideoProductDataResponse ToVideoResponse(this VideoProductDataEntity video) + { + return video.MapCommon( new VideoProductDataResponse { DurationSeconds = video.DurationSeconds, @@ -61,4 +68,5 @@ private static VideoProductDataResponse ToVideoResponse(this VideoProductDataEnt }, "video" ); + } } diff --git a/src/Modules/ProductCatalog/Features/TenantCascadeDelete/CategoriesForTenantSoftDeleteSpecification.cs b/src/Modules/ProductCatalog/Features/TenantCascadeDelete/CategoriesForTenantSoftDeleteSpecification.cs index 525dc0cb..47659de9 100644 --- a/src/Modules/ProductCatalog/Features/TenantCascadeDelete/CategoriesForTenantSoftDeleteSpecification.cs +++ b/src/Modules/ProductCatalog/Features/TenantCascadeDelete/CategoriesForTenantSoftDeleteSpecification.cs @@ -4,8 +4,8 @@ namespace ProductCatalog.Features.TenantCascadeDelete; /// -/// Loads all non-deleted categories for a specific tenant, bypassing global query filters -/// so the spec works correctly in cross-module handlers that run without a tenant context. +/// Loads all non-deleted categories for a specific tenant, bypassing global query filters +/// so the spec works correctly in cross-module handlers that run without a tenant context. /// public sealed class CategoriesForTenantSoftDeleteSpecification : Specification { diff --git a/src/Modules/ProductCatalog/GraphQL/Models/CategoryQueryInput.cs b/src/Modules/ProductCatalog/GraphQL/Models/CategoryQueryInput.cs index bff95bf4..b3ca97a6 100644 --- a/src/Modules/ProductCatalog/GraphQL/Models/CategoryQueryInput.cs +++ b/src/Modules/ProductCatalog/GraphQL/Models/CategoryQueryInput.cs @@ -1,10 +1,8 @@ -using SharedKernel.Application.DTOs; - namespace ProductCatalog.GraphQL.Models; /// -/// GraphQL input type for querying categories, providing optional text search, -/// sorting, and pagination parameters. +/// GraphQL input type for querying categories, providing optional text search, +/// sorting, and pagination parameters. /// public sealed class CategoryQueryInput { @@ -14,7 +12,3 @@ public sealed class CategoryQueryInput public int PageNumber { get; init; } = 1; public int PageSize { get; init; } = PaginationFilter.DefaultPageSize; } - - - - diff --git a/src/Modules/ProductCatalog/GraphQL/Models/ProductReviewPageResult.cs b/src/Modules/ProductCatalog/GraphQL/Models/ProductReviewPageResult.cs index 6d5eeca4..f3d33070 100644 --- a/src/Modules/ProductCatalog/GraphQL/Models/ProductReviewPageResult.cs +++ b/src/Modules/ProductCatalog/GraphQL/Models/ProductReviewPageResult.cs @@ -1,12 +1,8 @@ namespace ProductCatalog.GraphQL.Models; /// -/// GraphQL return type that wraps a paginated product-review result set, implementing -/// for consistent schema paging fields. +/// GraphQL return type that wraps a paginated product-review result set, implementing +/// for consistent schema paging fields. /// public sealed record ProductReviewPageResult(PagedResponse Page) : IPagedItems; - - - - diff --git a/src/Modules/ProductCatalog/GraphQL/Queries/ProductQueries.cs b/src/Modules/ProductCatalog/GraphQL/Queries/ProductQueries.cs index de6a3dad..d181f933 100644 --- a/src/Modules/ProductCatalog/GraphQL/Queries/ProductQueries.cs +++ b/src/Modules/ProductCatalog/GraphQL/Queries/ProductQueries.cs @@ -2,21 +2,20 @@ using HotChocolate.Authorization; using ProductCatalog.Features.Product.GetProductById; using ProductCatalog.Features.Product.GetProducts; -using ProductCatalog.GraphQL.Models; using Wolverine; namespace ProductCatalog.GraphQL.Queries; /// -/// Hot Chocolate root query type that exposes product list and single-product lookups, -/// serving as the extension base for and . +/// Hot Chocolate root query type that exposes product list and single-product lookups, +/// serving as the extension base for and . /// [Authorize] public class ProductQueries { /// - /// Returns a paginated product list with search facets, mapping the GraphQL input to the - /// application-layer filter before dispatching via the message bus. + /// Returns a paginated product list with search facets, mapping the GraphQL input to the + /// application-layer filter before dispatching via the message bus. /// public async Task GetProducts( ProductQueryInput? input, @@ -24,7 +23,7 @@ public async Task GetProducts( CancellationToken ct ) { - var filter = new ProductFilter( + ProductFilter filter = new( input?.Name, input?.Description, input?.MinPrice, @@ -47,7 +46,7 @@ CancellationToken ct return new ProductPageResult(page.Page, page.Facets); } - /// Returns a single product by ID, or if not found. + /// Returns a single product by ID, or if not found. public async Task GetProductById( Guid id, [Service] IMessageBus bus, diff --git a/src/Modules/ProductCatalog/Persistence/MongoDbHealthCheck.cs b/src/Modules/ProductCatalog/Persistence/MongoDbHealthCheck.cs index 658820eb..286f8c15 100644 --- a/src/Modules/ProductCatalog/Persistence/MongoDbHealthCheck.cs +++ b/src/Modules/ProductCatalog/Persistence/MongoDbHealthCheck.cs @@ -6,8 +6,8 @@ namespace ProductCatalog.Persistence; public sealed class MongoDbHealthCheck : IHealthCheck { - private readonly MongoDbContext _mongoDbContext; private readonly ILogger _logger; + private readonly MongoDbContext _mongoDbContext; public MongoDbHealthCheck(MongoDbContext mongoDbContext, ILogger logger) { @@ -32,4 +32,3 @@ public async Task CheckHealthAsync( } } } - diff --git a/src/Modules/ProductCatalog/ProductCatalog.csproj b/src/Modules/ProductCatalog/ProductCatalog.csproj index f7b2c53c..e5689725 100644 --- a/src/Modules/ProductCatalog/ProductCatalog.csproj +++ b/src/Modules/ProductCatalog/ProductCatalog.csproj @@ -5,38 +5,41 @@ enable - + - + - - + + - - - - - - - - - - - - - - allruntime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/src/Modules/ProductCatalog/Repositories/CategoryRepository.cs b/src/Modules/ProductCatalog/Repositories/CategoryRepository.cs index 16196841..4c748e0e 100644 --- a/src/Modules/ProductCatalog/Repositories/CategoryRepository.cs +++ b/src/Modules/ProductCatalog/Repositories/CategoryRepository.cs @@ -1,14 +1,11 @@ -using SharedKernel.Application.Context; -using ProductCatalog.Entities; -using ProductCatalog.Interfaces; using ProductCatalog.Persistence; using ProductCatalog.StoredProcedures; namespace ProductCatalog.Repositories; /// -/// EF Core repository for that extends the base repository with -/// stored-procedure-based stats retrieval. +/// EF Core repository for that extends the base repository with +/// stored-procedure-based stats retrieval. /// public sealed class CategoryRepository : RepositoryBase, ICategoryRepository { @@ -27,8 +24,8 @@ ITenantProvider tenantProvider } /// - /// Retrieves aggregate product statistics for the given category via a stored procedure, - /// passing the current tenant ID explicitly to enforce data isolation at the DB level. + /// Retrieves aggregate product statistics for the given category via a stored procedure, + /// passing the current tenant ID explicitly to enforce data isolation at the DB level. /// public Task GetStatsByIdAsync( Guid categoryId, @@ -42,6 +39,3 @@ ITenantProvider tenantProvider ); } } - - - diff --git a/src/Modules/Reviews/Common/Errors/ErrorCatalog.cs b/src/Modules/Reviews/Common/Errors/ErrorCatalog.cs index 479bfd99..67791175 100644 --- a/src/Modules/Reviews/Common/Errors/ErrorCatalog.cs +++ b/src/Modules/Reviews/Common/Errors/ErrorCatalog.cs @@ -6,6 +6,7 @@ public static class Reviews { public const string ProductNotFoundForReview = "REV-2101"; public const string ReviewNotFound = "REV-0404"; + public const string ForbiddenOwnReviewsOnlyMessage = "You can only delete your own reviews."; } diff --git a/src/Modules/Reviews/Common/Errors/ReviewsDomainErrors.cs b/src/Modules/Reviews/Common/Errors/ReviewsDomainErrors.cs index 7407205e..c94cb7c2 100644 --- a/src/Modules/Reviews/Common/Errors/ReviewsDomainErrors.cs +++ b/src/Modules/Reviews/Common/Errors/ReviewsDomainErrors.cs @@ -6,7 +6,9 @@ internal static class ReviewsDomainErrors { internal static class Rating { - internal static Error OutOfRange() => - Error.Validation("REV-0401", "Rating must be between 1 and 5."); + internal static Error OutOfRange() + { + return Error.Validation("REV-0401", "Rating must be between 1 and 5."); + } } } diff --git a/src/Modules/Reviews/Domain/IProductReviewRepository.cs b/src/Modules/Reviews/Domain/IProductReviewRepository.cs index f4a36e86..ee718a4b 100644 --- a/src/Modules/Reviews/Domain/IProductReviewRepository.cs +++ b/src/Modules/Reviews/Domain/IProductReviewRepository.cs @@ -1,8 +1,7 @@ -using Reviews.Domain; - namespace Reviews.Domain; /// -/// Repository contract for entities, inheriting all generic CRUD operations from . +/// Repository contract for entities, inheriting all generic CRUD operations from +/// . /// public interface IProductReviewRepository : IRepository { } diff --git a/src/Modules/Reviews/Domain/ProductReview.cs b/src/Modules/Reviews/Domain/ProductReview.cs index af41921d..13eddea5 100644 --- a/src/Modules/Reviews/Domain/ProductReview.cs +++ b/src/Modules/Reviews/Domain/ProductReview.cs @@ -1,18 +1,15 @@ -using Reviews.Domain; - namespace Reviews.Domain; /// -/// Domain entity representing a user's review of a product, including a 1–5 star rating and an optional comment. +/// 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; } - /// Rating value object enforcing a 1–5 range via . + /// Rating value object enforcing a 1–5 range via . public Rating Rating { get; set; } public Guid TenantId { get; set; } @@ -20,4 +17,5 @@ public sealed class ProductReview : IAuditableTenantEntity, IHasId public bool IsDeleted { get; set; } public DateTime? DeletedAtUtc { get; set; } public Guid? DeletedBy { get; set; } + public Guid Id { get; set; } } diff --git a/src/Modules/Reviews/Features/CreateProductReview/CreateProductReviewRequest.cs b/src/Modules/Reviews/Features/CreateProductReview/CreateProductReviewRequest.cs index ee1a7976..e7121ec0 100644 --- a/src/Modules/Reviews/Features/CreateProductReview/CreateProductReviewRequest.cs +++ b/src/Modules/Reviews/Features/CreateProductReview/CreateProductReviewRequest.cs @@ -1,10 +1,10 @@ using System.ComponentModel.DataAnnotations; -using SharedKernel.Application.Validation; namespace Reviews.Features; /// -/// Payload for submitting a new product review, including the target product, an optional comment, and a 1–5 star rating. +/// 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, diff --git a/src/Modules/Reviews/Features/GetProductReviews/GetProductReviewsByProductIdQuery.cs b/src/Modules/Reviews/Features/GetProductReviews/GetProductReviewsByProductIdQuery.cs index d032f176..3582f309 100644 --- a/src/Modules/Reviews/Features/GetProductReviews/GetProductReviewsByProductIdQuery.cs +++ b/src/Modules/Reviews/Features/GetProductReviews/GetProductReviewsByProductIdQuery.cs @@ -1,13 +1,11 @@ using ErrorOr; -using Reviews.Domain; -using Reviews.Features; namespace Reviews.Features; /// Returns all reviews for a specific product, ordered by creation date descending. public sealed record GetProductReviewsByProductIdQuery(Guid ProductId); -/// Handles . +/// Handles . public sealed class GetProductReviewsByProductIdQueryHandler { public static async Task>> HandleAsync( diff --git a/src/Modules/Reviews/Features/GetProductReviews/ProductReviewByProductIdSpecification.cs b/src/Modules/Reviews/Features/GetProductReviews/ProductReviewByProductIdSpecification.cs index 701a40ce..58c1ee91 100644 --- a/src/Modules/Reviews/Features/GetProductReviews/ProductReviewByProductIdSpecification.cs +++ b/src/Modules/Reviews/Features/GetProductReviews/ProductReviewByProductIdSpecification.cs @@ -1,17 +1,16 @@ using Ardalis.Specification; -using Reviews.Domain; using ProductReviewEntity = Reviews.Domain.ProductReview; namespace Reviews.Features; /// -/// Ardalis specification that retrieves all reviews for a single product, ordered by creation date descending, -/// and projected directly to . +/// Ardalis specification that retrieves all reviews for a single product, ordered by creation date descending, +/// and projected directly to . /// public sealed class ProductReviewByProductIdSpecification : Specification { - /// Initialises the specification for the given . + /// Initialises the specification for the given . public ProductReviewByProductIdSpecification(Guid productId) { Query diff --git a/src/Modules/Reviews/Features/GetProductReviews/ProductReviewByProductIdsSpecification.cs b/src/Modules/Reviews/Features/GetProductReviews/ProductReviewByProductIdsSpecification.cs index 2cc047af..7c56bd3a 100644 --- a/src/Modules/Reviews/Features/GetProductReviews/ProductReviewByProductIdsSpecification.cs +++ b/src/Modules/Reviews/Features/GetProductReviews/ProductReviewByProductIdsSpecification.cs @@ -1,17 +1,16 @@ using Ardalis.Specification; -using Reviews.Domain; using ProductReviewEntity = Reviews.Domain.ProductReview; namespace Reviews.Features; /// -/// Ardalis specification that retrieves reviews for a collection of product ids in a single query, -/// ordered by creation date descending and projected to . +/// 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 ProductReviewByProductIdsSpecification : Specification { - /// Initialises the specification for the given set of . + /// Initialises the specification for the given set of . public ProductReviewByProductIdsSpecification(IReadOnlyCollection productIds) { Query diff --git a/src/Modules/Reviews/Repositories/ProductReviewRepository.cs b/src/Modules/Reviews/Repositories/ProductReviewRepository.cs index 43d61b82..2a4f1415 100644 --- a/src/Modules/Reviews/Repositories/ProductReviewRepository.cs +++ b/src/Modules/Reviews/Repositories/ProductReviewRepository.cs @@ -1,9 +1,11 @@ -using Reviews.Domain; using Reviews.Persistence; namespace Reviews.Repositories; -/// EF Core repository for , inheriting all standard CRUD and specification query support from . +/// +/// EF Core repository for , inheriting all standard CRUD and specification query +/// support from . +/// public sealed class ProductReviewRepository : RepositoryBase, IProductReviewRepository diff --git a/src/Modules/Reviews/Reviews.csproj b/src/Modules/Reviews/Reviews.csproj index 6fd892b6..db4f74cb 100644 --- a/src/Modules/Reviews/Reviews.csproj +++ b/src/Modules/Reviews/Reviews.csproj @@ -6,28 +6,28 @@ - - + + - + - - - - - - + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + diff --git a/src/Modules/Reviews/ReviewsRuntimeBridge.cs b/src/Modules/Reviews/ReviewsRuntimeBridge.cs index 6aee2730..e972b582 100644 --- a/src/Modules/Reviews/ReviewsRuntimeBridge.cs +++ b/src/Modules/Reviews/ReviewsRuntimeBridge.cs @@ -2,9 +2,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Reviews; -using Reviews.Domain; -using Reviews.Features; using Reviews.Persistence; using Reviews.Repositories; using SharedKernel.Infrastructure.Configuration; diff --git a/src/Modules/Webhooks/Contracts/IOutgoingWebhookQueue.cs b/src/Modules/Webhooks/Contracts/IOutgoingWebhookQueue.cs index 1e9c38ce..2a14ab93 100644 --- a/src/Modules/Webhooks/Contracts/IOutgoingWebhookQueue.cs +++ b/src/Modules/Webhooks/Contracts/IOutgoingWebhookQueue.cs @@ -1,15 +1,13 @@ using SharedKernel.Application.BackgroundJobs; + namespace Webhooks.Contracts; /// -/// Write-side contract for enqueuing outgoing webhook dispatch items. +/// Write-side contract for enqueuing outgoing webhook dispatch items. /// public interface IOutgoingWebhookQueue : IQueue; /// -/// Read-side contract for consuming outgoing webhook items from the queue. +/// Read-side contract for consuming outgoing webhook items from the queue. /// public interface IOutgoingWebhookQueueReader : IQueueReader; - - - diff --git a/src/Modules/Webhooks/Contracts/OutgoingWebhookDTOs.cs b/src/Modules/Webhooks/Contracts/OutgoingWebhookDTOs.cs index 617ba9d9..eb26244f 100644 --- a/src/Modules/Webhooks/Contracts/OutgoingWebhookDTOs.cs +++ b/src/Modules/Webhooks/Contracts/OutgoingWebhookDTOs.cs @@ -1,12 +1,12 @@ namespace Webhooks.Contracts; /// -/// Represents a pending outgoing webhook delivery, pairing the destination URL with the pre-serialised JSON payload. +/// Represents a pending outgoing webhook delivery, pairing the destination URL with the pre-serialised JSON payload. /// public sealed record OutgoingWebhookItem(string CallbackUrl, string SerializedPayload); /// -/// The strongly-typed payload delivered to a webhook callback URL upon job completion. +/// The strongly-typed payload delivered to a webhook callback URL upon job completion. /// public sealed record OutgoingJobWebhookPayload( Guid JobId, @@ -16,6 +16,3 @@ public sealed record OutgoingJobWebhookPayload( string? ErrorMessage, DateTime CompletedAtUtc ); - - - diff --git a/src/Modules/Webhooks/Features/WebhooksController.cs b/src/Modules/Webhooks/Features/WebhooksController.cs index db1cd023..ecc78046 100644 --- a/src/Modules/Webhooks/Features/WebhooksController.cs +++ b/src/Modules/Webhooks/Features/WebhooksController.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Mvc; using SharedKernel.Contracts.Api; using Webhooks.Contracts; -using Webhooks.Services; using Webhooks.Security; namespace Webhooks.Features; @@ -13,7 +12,10 @@ public sealed class WebhooksController : ApiControllerBase { private readonly IWebhookProcessingQueue _queue; - public WebhooksController(IWebhookProcessingQueue queue) => _queue = queue; + public WebhooksController(IWebhookProcessingQueue queue) + { + _queue = queue; + } [HttpPost] [AllowAnonymous] @@ -28,6 +30,3 @@ CancellationToken ct return Ok(); } } - - - diff --git a/src/Modules/Webhooks/Services/ChannelOutgoingWebhookQueue.cs b/src/Modules/Webhooks/Services/ChannelOutgoingWebhookQueue.cs index f5dc6c38..dc2ebe5c 100644 --- a/src/Modules/Webhooks/Services/ChannelOutgoingWebhookQueue.cs +++ b/src/Modules/Webhooks/Services/ChannelOutgoingWebhookQueue.cs @@ -1,17 +1,15 @@ using SharedKernel.Infrastructure.BackgroundJobs.Services; using Webhooks.Contracts; -using Webhooks.Services; -using Webhooks.Security; namespace Webhooks.Services; public sealed class ChannelOutgoingWebhookQueue - : BoundedChannelQueue, IOutgoingWebhookQueue, IOutgoingWebhookQueueReader + : BoundedChannelQueue, + IOutgoingWebhookQueue, + IOutgoingWebhookQueueReader { private const int DefaultCapacity = 500; - public ChannelOutgoingWebhookQueue() : base(DefaultCapacity) { } -} - - - + public ChannelOutgoingWebhookQueue() + : base(DefaultCapacity) { } +} diff --git a/src/Modules/Webhooks/Services/WebhookProcessingBackgroundService.cs b/src/Modules/Webhooks/Services/WebhookProcessingBackgroundService.cs index 8b3b6dd8..718bc391 100644 --- a/src/Modules/Webhooks/Services/WebhookProcessingBackgroundService.cs +++ b/src/Modules/Webhooks/Services/WebhookProcessingBackgroundService.cs @@ -2,8 +2,6 @@ using Microsoft.Extensions.Logging; using SharedKernel.Infrastructure.BackgroundJobs.Services; using Webhooks.Contracts; -using Webhooks.Services; -using Webhooks.Security; using Webhooks.Logging; namespace Webhooks.Services; @@ -11,8 +9,8 @@ namespace Webhooks.Services; public sealed class WebhookProcessingBackgroundService : QueueConsumerBackgroundService { - private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; + private readonly IServiceScopeFactory _scopeFactory; public WebhookProcessingBackgroundService( IWebhookQueueReader queue, @@ -46,9 +44,7 @@ protected override async Task ProcessItemAsync(WebhookPayload payload, Cancellat } if (!handled) - { _logger.WebhookNoHandlerRegistered(payload.EventType, payload.EventId); - } } protected override Task HandleErrorAsync( @@ -61,7 +57,3 @@ CancellationToken ct return Task.CompletedTask; } } - - - - diff --git a/src/Modules/Webhooks/Webhooks.csproj b/src/Modules/Webhooks/Webhooks.csproj index ff27188a..63c7d36b 100644 --- a/src/Modules/Webhooks/Webhooks.csproj +++ b/src/Modules/Webhooks/Webhooks.csproj @@ -5,15 +5,15 @@ enable - + - + - - - + + + diff --git a/src/SharedKernel/Application/BackgroundJobs/IDistributedJobCoordinator.cs b/src/SharedKernel/Application/BackgroundJobs/IDistributedJobCoordinator.cs index 6b8121b3..07731efd 100644 --- a/src/SharedKernel/Application/BackgroundJobs/IDistributedJobCoordinator.cs +++ b/src/SharedKernel/Application/BackgroundJobs/IDistributedJobCoordinator.cs @@ -1,16 +1,17 @@ namespace SharedKernel.Application.BackgroundJobs; /// -/// Coordinates execution of background jobs across multiple application instances, -/// ensuring that only the current leader node executes the provided action. +/// Coordinates execution of background jobs across multiple application instances, +/// ensuring that only the current leader node executes the provided action. /// public interface IDistributedJobCoordinator { /// - /// Executes only if the current node holds the leader lease for . - /// If the current node is not the leader, the action is skipped without error. + /// Executes only if the current node holds the leader lease for + /// . + /// If the current node is not the leader, the action is skipped without error. /// - Task ExecuteIfLeaderAsync( + public Task ExecuteIfLeaderAsync( string jobName, Func action, CancellationToken ct = default diff --git a/src/SharedKernel/Application/BackgroundJobs/IQueueReader.cs b/src/SharedKernel/Application/BackgroundJobs/IQueueReader.cs index a316c197..b77af05f 100644 --- a/src/SharedKernel/Application/BackgroundJobs/IQueueReader.cs +++ b/src/SharedKernel/Application/BackgroundJobs/IQueueReader.cs @@ -1,15 +1,15 @@ namespace SharedKernel.Application.BackgroundJobs; /// -/// Generic read-side abstraction for in-process queues, allowing background consumers to drain -/// items without coupling to a specific transport implementation. +/// 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. + /// 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); + public IAsyncEnumerable ReadAllAsync(CancellationToken ct = default); } diff --git a/src/SharedKernel/Application/Batch/BatchFailureMerge.cs b/src/SharedKernel/Application/Batch/BatchFailureMerge.cs index 3f938527..c579b0aa 100644 --- a/src/SharedKernel/Application/Batch/BatchFailureMerge.cs +++ b/src/SharedKernel/Application/Batch/BatchFailureMerge.cs @@ -3,7 +3,7 @@ namespace SharedKernel.Application.Batch; /// -/// Merges per-item batch failures that share the same index (e.g. missing category and missing product data). +/// Merges per-item batch failures that share the same index (e.g. missing category and missing product data). /// public static class BatchFailureMerge { @@ -12,8 +12,8 @@ public static List MergeByIndex( IEnumerable second ) { - var errorsByIndex = new Dictionary>(); - var idByIndex = new Dictionary(); + Dictionary> errorsByIndex = new(); + Dictionary idByIndex = new(); void Accumulate(BatchResultItem item) { diff --git a/src/SharedKernel/Application/Context/ITenantProvider.cs b/src/SharedKernel/Application/Context/ITenantProvider.cs index f4e36555..5c501ac7 100644 --- a/src/SharedKernel/Application/Context/ITenantProvider.cs +++ b/src/SharedKernel/Application/Context/ITenantProvider.cs @@ -1,17 +1,17 @@ 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. +/// 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; } + public Guid TenantId { get; } /// - /// Returns true when the current request is scoped to a tenant; - /// false for system-level or anonymous requests. + /// Returns true when the current request is scoped to a tenant; + /// false for system-level or anonymous requests. /// - bool HasTenant { get; } + public bool HasTenant { get; } } diff --git a/src/SharedKernel/Application/DTOs/BatchResponse.cs b/src/SharedKernel/Application/DTOs/BatchResponse.cs index 9be8d310..65b46f10 100644 --- a/src/SharedKernel/Application/DTOs/BatchResponse.cs +++ b/src/SharedKernel/Application/DTOs/BatchResponse.cs @@ -1,7 +1,7 @@ namespace SharedKernel.Application.DTOs; /// -/// Summarises the outcome of a batch operation, including per-item failure details and aggregate counts. +/// Summarises the outcome of a batch operation, including per-item failure details and aggregate counts. /// public sealed record BatchResponse( IReadOnlyList Failures, @@ -10,7 +10,7 @@ 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. +/// 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/Application/DTOs/IHasFacets.cs b/src/SharedKernel/Application/DTOs/IHasFacets.cs index 0d4a8b62..8f38d3ad 100644 --- a/src/SharedKernel/Application/DTOs/IHasFacets.cs +++ b/src/SharedKernel/Application/DTOs/IHasFacets.cs @@ -1,11 +1,11 @@ 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. +/// 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; } + public TFacets Facets { get; } } diff --git a/src/SharedKernel/Application/Errors/DomainErrors.cs b/src/SharedKernel/Application/Errors/DomainErrors.cs index cfd7de95..0600c852 100644 --- a/src/SharedKernel/Application/Errors/DomainErrors.cs +++ b/src/SharedKernel/Application/Errors/DomainErrors.cs @@ -3,17 +3,19 @@ namespace SharedKernel.Application.Errors; /// -/// Factory methods producing instances for cross-cutting concerns. -/// Module-specific error factories live in each module's own Errors/DomainErrors.cs. +/// Factory methods producing instances for cross-cutting concerns. +/// Module-specific error factories live in each module's own Errors/DomainErrors.cs. /// 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 entityName, Guid id) + { + return Error.NotFound( + ErrorCatalog.General.NotFound, + $"{entityName} with id '{id}' not found." ); + } } } diff --git a/src/SharedKernel/Application/Http/RequestContextConstants.cs b/src/SharedKernel/Application/Http/RequestContextConstants.cs index 3f77c348..5b2f377c 100644 --- a/src/SharedKernel/Application/Http/RequestContextConstants.cs +++ b/src/SharedKernel/Application/Http/RequestContextConstants.cs @@ -1,24 +1,24 @@ namespace SharedKernel.Application.Http; /// -/// Constants for request context headers and log enrichment properties. +/// Constants for request context headers and log enrichment properties. /// public static class RequestContextConstants { public static class Headers { /// - /// Header name used for correlation/trace IDs supplied by the caller. + /// Header name used for correlation/trace IDs supplied by the caller. /// public const string CorrelationId = "X-Correlation-Id"; /// - /// Header name used for the distributed trace ID. + /// Header name used for the distributed trace ID. /// public const string TraceId = "X-Trace-Id"; /// - /// Header name used for the request elapsed time in milliseconds. + /// Header name used for the request elapsed time in milliseconds. /// public const string ElapsedMs = "X-Elapsed-Ms"; } @@ -26,7 +26,7 @@ public static class Headers public static class ContextKeys { /// - /// Key under which the resolved correlation ID is stored in . + /// Key under which the resolved correlation ID is stored in . /// public const string CorrelationId = "CorrelationId"; } @@ -34,22 +34,22 @@ public static class ContextKeys public static class LogProperties { /// - /// Serilog property name for the correlation ID. + /// Serilog property name for the correlation ID. /// public const string CorrelationId = "CorrelationId"; /// - /// Serilog property name for the tenant ID. + /// Serilog property name for the tenant ID. /// public const string TenantId = "TenantId"; /// - /// Serilog property name for the request host. + /// Serilog property name for the request host. /// public const string RequestHost = "RequestHost"; /// - /// Serilog property name for the request scheme. + /// Serilog property name for the request scheme. /// public const string RequestScheme = "RequestScheme"; } diff --git a/src/SharedKernel/Application/Middleware/ErrorOrValidationMiddleware.cs b/src/SharedKernel/Application/Middleware/ErrorOrValidationMiddleware.cs index bc14f467..b56f35fe 100644 --- a/src/SharedKernel/Application/Middleware/ErrorOrValidationMiddleware.cs +++ b/src/SharedKernel/Application/Middleware/ErrorOrValidationMiddleware.cs @@ -7,16 +7,16 @@ 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>. +/// 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. + /// 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, @@ -31,17 +31,17 @@ public static class ErrorOrValidationMiddleware if (validationResult.IsValid) return (HandlerContinuation.Continue, default!); - var errors = validationResult + List errors = validationResult .Errors.Select(e => { - var metadata = new Dictionary { ["propertyName"] = e.PropertyName }; + 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 + ErrorCatalog.General.ValidationFailed, + e.ErrorMessage, + metadata ); }) .ToList(); diff --git a/src/SharedKernel/Application/Options/BackgroundJobs/BackgroundJobsOptions.cs b/src/SharedKernel/Application/Options/BackgroundJobs/BackgroundJobsOptions.cs index e0141e3d..7e43427c 100644 --- a/src/SharedKernel/Application/Options/BackgroundJobs/BackgroundJobsOptions.cs +++ b/src/SharedKernel/Application/Options/BackgroundJobs/BackgroundJobsOptions.cs @@ -5,7 +5,7 @@ namespace SharedKernel.Application.Options.BackgroundJobs; /// -/// Aggregates per-job configuration options for all registered background jobs in the application. +/// Aggregates per-job configuration options for all registered background jobs in the application. /// public sealed class BackgroundJobsOptions { diff --git a/src/SharedKernel/Application/Resilience/ResiliencePipelineKeys.cs b/src/SharedKernel/Application/Resilience/ResiliencePipelineKeys.cs index ec6c2b45..c6f87d36 100644 --- a/src/SharedKernel/Application/Resilience/ResiliencePipelineKeys.cs +++ b/src/SharedKernel/Application/Resilience/ResiliencePipelineKeys.cs @@ -1,8 +1,8 @@ namespace SharedKernel.Application.Resilience; /// -/// String constants that identify the named Polly resilience pipelines registered in the application. -/// Use these keys when resolving a pipeline from ResiliencePipelineProvider. +/// String constants that identify the named Polly resilience pipelines registered in the application. +/// Use these keys when resolving a pipeline from ResiliencePipelineProvider. /// public static class ResiliencePipelineKeys { diff --git a/src/SharedKernel/Application/Search/SearchDefaults.cs b/src/SharedKernel/Application/Search/SearchDefaults.cs index 98228fd9..5cd6db7f 100644 --- a/src/SharedKernel/Application/Search/SearchDefaults.cs +++ b/src/SharedKernel/Application/Search/SearchDefaults.cs @@ -1,12 +1,12 @@ namespace SharedKernel.Application.Search; /// -/// Shared defaults for full-text search across filter specifications. +/// Shared defaults for full-text search across filter specifications. /// public static class SearchDefaults { /// - /// PostgreSQL text search configuration used by all full-text search queries. + /// PostgreSQL text search configuration used by all full-text search queries. /// public const string TextSearchConfiguration = "english"; } diff --git a/src/SharedKernel/Contracts/Api/ApiControllerBase.cs b/src/SharedKernel/Contracts/Api/ApiControllerBase.cs index dc03eb62..7a3f523b 100644 --- a/src/SharedKernel/Contracts/Api/ApiControllerBase.cs +++ b/src/SharedKernel/Contracts/Api/ApiControllerBase.cs @@ -8,6 +8,8 @@ namespace SharedKernel.Contracts.Api; [Route("api/v{version:apiVersion}/[controller]")] public abstract class ApiControllerBase : ControllerBase { - internal ActionResult OkOrUnprocessable(BatchResponse response) => - response.FailureCount > 0 ? UnprocessableEntity(response) : Ok(response); + internal ActionResult OkOrUnprocessable(BatchResponse response) + { + return response.FailureCount > 0 ? UnprocessableEntity(response) : Ok(response); + } } diff --git a/src/SharedKernel/Contracts/Api/Filters/Idempotency/IdempotencyActionFilter.cs b/src/SharedKernel/Contracts/Api/Filters/Idempotency/IdempotencyActionFilter.cs index 825a9cc7..ebc0f03b 100644 --- a/src/SharedKernel/Contracts/Api/Filters/Idempotency/IdempotencyActionFilter.cs +++ b/src/SharedKernel/Contracts/Api/Filters/Idempotency/IdempotencyActionFilter.cs @@ -2,28 +2,26 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; using SharedKernel.Application.Contracts; namespace SharedKernel.Contracts.Api.Filters.Idempotency; /// -/// 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. -/// Implements both (key validation, lock acquisition) and -/// (post-execution caching with accurate headers). +/// 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. +/// Implements both (key validation, lock acquisition) and +/// (post-execution caching with accurate headers). /// public sealed class IdempotencyActionFilter : IAsyncActionFilter, IAsyncResultFilter { private const string ContextKey = "__IdempotencyContext"; + private readonly JsonSerializerOptions _jsonOptions; private readonly IIdempotencyStore _store; - private readonly JsonSerializerOptions _jsonOptions; - public IdempotencyActionFilter( - IIdempotencyStore store, - IOptions jsonOptions - ) + public IdempotencyActionFilter(IIdempotencyStore store, IOptions jsonOptions) { _store = store; _jsonOptions = jsonOptions.Value.JsonSerializerOptions; @@ -47,7 +45,7 @@ ActionExecutionDelegate next if ( !context.HttpContext.Request.Headers.TryGetValue( IdempotencyConstants.HeaderName, - out Microsoft.Extensions.Primitives.StringValues keyValues + out StringValues keyValues ) || string.IsNullOrWhiteSpace(keyValues) ) { @@ -128,9 +126,7 @@ out Microsoft.Extensions.Primitives.StringValues keyValues ); } else - { await _store.ReleaseAsync(key, lockToken, ct); - } } public async Task OnResultExecutionAsync( @@ -154,9 +150,7 @@ ResultExecutionDelegate next string? responseBody = null; if (ctx.Result is ObjectResult objectResult && objectResult.Value is not null) - { responseBody = JsonSerializer.Serialize(objectResult.Value, _jsonOptions); - } string? contentType = context.HttpContext.Response.ContentType; string? locationHeader = context.HttpContext.Response.Headers.Location; diff --git a/src/SharedKernel/Contracts/Api/Filters/Idempotency/IdempotencyConstants.cs b/src/SharedKernel/Contracts/Api/Filters/Idempotency/IdempotencyConstants.cs index 000142eb..0ee69342 100644 --- a/src/SharedKernel/Contracts/Api/Filters/Idempotency/IdempotencyConstants.cs +++ b/src/SharedKernel/Contracts/Api/Filters/Idempotency/IdempotencyConstants.cs @@ -1,7 +1,7 @@ namespace SharedKernel.Contracts.Api.Filters.Idempotency; /// -/// Shared constants for the idempotency feature: header name, key constraints, and default timeouts. +/// Shared constants for the idempotency feature: header name, key constraints, and default timeouts. /// public static class IdempotencyConstants { diff --git a/src/SharedKernel/Contracts/Commands/Cleanup/CleanupExpiredInvitationsCommand.cs b/src/SharedKernel/Contracts/Commands/Cleanup/CleanupExpiredInvitationsCommand.cs index 2a604292..75390cb2 100644 --- a/src/SharedKernel/Contracts/Commands/Cleanup/CleanupExpiredInvitationsCommand.cs +++ b/src/SharedKernel/Contracts/Commands/Cleanup/CleanupExpiredInvitationsCommand.cs @@ -1,7 +1,7 @@ namespace SharedKernel.Contracts.Commands.Cleanup; /// -/// Cross-module command instructing the Identity module to purge expired tenant invitations. -/// Dispatched by the BackgroundJobs cleanup orchestrator via the message bus. +/// Cross-module command instructing the Identity module to purge expired tenant invitations. +/// Dispatched by the BackgroundJobs cleanup orchestrator via the message bus. /// public sealed record CleanupExpiredInvitationsCommand(int RetentionHours, int BatchSize); diff --git a/src/SharedKernel/Contracts/Commands/Webhooks/SendWebhookCallbackCommand.cs b/src/SharedKernel/Contracts/Commands/Webhooks/SendWebhookCallbackCommand.cs index 318e5777..ce2fbc76 100644 --- a/src/SharedKernel/Contracts/Commands/Webhooks/SendWebhookCallbackCommand.cs +++ b/src/SharedKernel/Contracts/Commands/Webhooks/SendWebhookCallbackCommand.cs @@ -1,7 +1,7 @@ namespace SharedKernel.Contracts.Commands.Webhooks; /// -/// Cross-module command instructing the Webhooks module to deliver an outgoing webhook callback. -/// Dispatched by the BackgroundJobs job processor when a job with a callback URL completes. +/// Cross-module command instructing the Webhooks module to deliver an outgoing webhook callback. +/// Dispatched by the BackgroundJobs job processor when a job with a callback URL completes. /// public sealed record SendWebhookCallbackCommand(string CallbackUrl, string SerializedPayload); diff --git a/src/SharedKernel/Contracts/Events/CacheEvents.cs b/src/SharedKernel/Contracts/Events/CacheEvents.cs index 6f3431d3..65f4d287 100644 --- a/src/SharedKernel/Contracts/Events/CacheEvents.cs +++ b/src/SharedKernel/Contracts/Events/CacheEvents.cs @@ -1,7 +1,7 @@ namespace SharedKernel.Contracts.Events; /// -/// Cache invalidation event. Use with a constant -/// to signal that a specific cache region must be evicted. +/// Cache invalidation event. Use with a constant +/// to signal that a specific cache region must be evicted. /// public sealed record CacheInvalidationNotification(string CacheTag); diff --git a/src/SharedKernel/Contracts/Events/ProductSoftDeletedNotification.cs b/src/SharedKernel/Contracts/Events/ProductSoftDeletedNotification.cs index 15c06bec..e99db661 100644 --- a/src/SharedKernel/Contracts/Events/ProductSoftDeletedNotification.cs +++ b/src/SharedKernel/Contracts/Events/ProductSoftDeletedNotification.cs @@ -1,8 +1,8 @@ namespace SharedKernel.Contracts.Events; /// -/// Published after a product is soft-deleted, allowing downstream handlers -/// to trigger cascading cleanup or audit logging across modules (like Reviews or ProductData). +/// Published after a product is soft-deleted, allowing downstream handlers +/// to trigger cascading cleanup or audit logging across modules (like Reviews or ProductData). /// public sealed record ProductSoftDeletedNotification( Guid ProductId, diff --git a/src/SharedKernel/Contracts/Queries/ProductCatalog/ValidateProductExistsQuery.cs b/src/SharedKernel/Contracts/Queries/ProductCatalog/ValidateProductExistsQuery.cs index 880cd351..e3cb361e 100644 --- a/src/SharedKernel/Contracts/Queries/ProductCatalog/ValidateProductExistsQuery.cs +++ b/src/SharedKernel/Contracts/Queries/ProductCatalog/ValidateProductExistsQuery.cs @@ -1,7 +1,7 @@ namespace SharedKernel.Contracts.Queries.ProductCatalog; /// -/// Cross-module query that validates whether a product with the given identifier exists. -/// Handled by the ProductCatalog module. +/// Cross-module query that validates whether a product with the given identifier exists. +/// Handled by the ProductCatalog module. /// public sealed record ValidateProductExistsQuery(Guid ProductId); diff --git a/src/SharedKernel/Domain/AuditDefaults.cs b/src/SharedKernel/Domain/AuditDefaults.cs index d41b8b12..c2cc3ba2 100644 --- a/src/SharedKernel/Domain/AuditDefaults.cs +++ b/src/SharedKernel/Domain/AuditDefaults.cs @@ -1,12 +1,12 @@ namespace SharedKernel.Domain.Entities; /// -/// Provides well-known sentinel values used to populate when no real actor is available. +/// 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. + /// 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/Domain/Contracts/ISoftDeletable.cs b/src/SharedKernel/Domain/Contracts/ISoftDeletable.cs index c962631d..37558073 100644 --- a/src/SharedKernel/Domain/Contracts/ISoftDeletable.cs +++ b/src/SharedKernel/Domain/Contracts/ISoftDeletable.cs @@ -1,12 +1,12 @@ 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. +/// 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; } + public bool IsDeleted { get; set; } + public DateTime? DeletedAtUtc { get; set; } + public Guid? DeletedBy { get; set; } } diff --git a/src/SharedKernel/Domain/Interfaces/IRepository.cs b/src/SharedKernel/Domain/Interfaces/IRepository.cs index a1b8600c..11fe8116 100644 --- a/src/SharedKernel/Domain/Interfaces/IRepository.cs +++ b/src/SharedKernel/Domain/Interfaces/IRepository.cs @@ -5,8 +5,8 @@ namespace SharedKernel.Domain.Interfaces; /// -/// Generic repository abstraction that extends Ardalis , -/// providing a consistent data-access contract for all relational domain entities. +/// Generic repository abstraction that extends Ardalis , +/// providing a consistent data-access contract for all relational domain entities. /// public interface IRepository : IRepositoryBase where T : class @@ -19,12 +19,12 @@ public interface IRepository : IRepositoryBase // 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. - /// Returns when the requested page number exceeds the total available pages. + /// 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. + /// Returns when the requested page number exceeds the total available pages. /// - Task>> GetPagedAsync( + public Task>> GetPagedAsync( ISpecification spec, int pageNumber, int pageSize, diff --git a/src/SharedKernel/Domain/Interfaces/IStoredProcedureExecutor.cs b/src/SharedKernel/Domain/Interfaces/IStoredProcedureExecutor.cs index b26a85e6..e808ba37 100644 --- a/src/SharedKernel/Domain/Interfaces/IStoredProcedureExecutor.cs +++ b/src/SharedKernel/Domain/Interfaces/IStoredProcedureExecutor.cs @@ -1,51 +1,51 @@ namespace SharedKernel.Domain.Interfaces; /// -/// Executes stored procedures and maps the result set to strongly-typed objects. -/// Abstracts the EF Core plumbing (FromSql, DbSet) away from repositories, -/// making the data-access layer easier to test and reason about. +/// Executes stored procedures and maps the result set to strongly-typed objects. +/// Abstracts the EF Core plumbing (FromSql, DbSet) away from repositories, +/// making the data-access layer easier to test and reason about. /// public interface IStoredProcedureExecutor { /// - /// Executes a procedure and returns the first matching row, or null - /// when the result set is empty. + /// Executes a procedure and returns the first matching row, or null + /// when the result set is empty. /// - Task QueryFirstAsync( + public Task QueryFirstAsync( IStoredProcedure procedure, CancellationToken ct = default ) where TResult : class; /// - /// Executes a procedure and returns all rows as a read-only list. + /// Executes a procedure and returns all rows as a read-only list. /// - Task> QueryManyAsync( + public Task> QueryManyAsync( IStoredProcedure procedure, CancellationToken ct = default ) where TResult : class; /// - /// Executes a scalar procedure and returns the first value, or the default of - /// when the result set is empty. + /// Executes a scalar procedure and returns the first value, or the default of + /// when the result set is empty. /// - Task ScalarFirstAsync( + public Task ScalarFirstAsync( IScalarStoredProcedure procedure, CancellationToken ct = default ); /// - /// Executes a scalar procedure and returns all values as a read-only list. + /// Executes a scalar procedure and returns all values as a read-only list. /// - Task> ScalarManyAsync( + public Task> ScalarManyAsync( IScalarStoredProcedure procedure, CancellationToken ct = default ); /// - /// Executes a procedure that performs a write operation (INSERT / UPDATE / DELETE) - /// and returns the number of affected rows. + /// 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); + public Task ExecuteAsync(FormattableString sql, CancellationToken ct = default); } diff --git a/src/SharedKernel/Domain/Options/TransactionOptions.cs b/src/SharedKernel/Domain/Options/TransactionOptions.cs index 1c364975..8c3fc1f3 100644 --- a/src/SharedKernel/Domain/Options/TransactionOptions.cs +++ b/src/SharedKernel/Domain/Options/TransactionOptions.cs @@ -3,8 +3,11 @@ 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. +/// 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 { @@ -15,13 +18,15 @@ public sealed record TransactionOptions public int? RetryDelaySeconds { get; init; } /// - /// Returns true when all properties are null, meaning the record carries no overrides - /// and the configured defaults apply entirely. + /// 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; + public bool IsEmpty() + { + return IsolationLevel is null + && TimeoutSeconds is null + && RetryEnabled is null + && RetryCount is null + && RetryDelaySeconds is null; + } } diff --git a/src/SharedKernel/Domain/PagedResponse.cs b/src/SharedKernel/Domain/PagedResponse.cs index 92df104d..57078d34 100644 --- a/src/SharedKernel/Domain/PagedResponse.cs +++ b/src/SharedKernel/Domain/PagedResponse.cs @@ -1,13 +1,13 @@ 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. +/// 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 . + /// Total number of pages derived from and . public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); /// Returns true when a previous page exists. diff --git a/src/SharedKernel/Infrastructure/Auditing/AuditableEntityStateManager.cs b/src/SharedKernel/Infrastructure/Auditing/AuditableEntityStateManager.cs index 1a67d062..1f602279 100644 --- a/src/SharedKernel/Infrastructure/Auditing/AuditableEntityStateManager.cs +++ b/src/SharedKernel/Infrastructure/Auditing/AuditableEntityStateManager.cs @@ -6,7 +6,7 @@ namespace SharedKernel.Infrastructure.Auditing; /// -/// Default audit state manager that handles add/modify/soft-delete transitions for tenant-auditable entities. +/// Default audit state manager that handles add/modify/soft-delete transitions for tenant-auditable entities. /// public class AuditableEntityStateManager : IAuditableEntityStateManager { diff --git a/src/SharedKernel/Infrastructure/Idempotency/DistributedCacheIdempotencyStore.cs b/src/SharedKernel/Infrastructure/Idempotency/DistributedCacheIdempotencyStore.cs index 1349596c..f9088d1c 100644 --- a/src/SharedKernel/Infrastructure/Idempotency/DistributedCacheIdempotencyStore.cs +++ b/src/SharedKernel/Infrastructure/Idempotency/DistributedCacheIdempotencyStore.cs @@ -5,9 +5,9 @@ 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. +/// 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 { @@ -29,7 +29,10 @@ public DistributedCacheIdempotencyStore(IConnectionMultiplexer connectionMultipl _database = connectionMultiplexer.GetDatabase(); } - /// Returns the cached entry for if it exists in Redis, or if absent or expired. + /// + /// Returns the cached entry for if it exists in Redis, or if + /// absent or expired. + /// public async Task TryGetAsync( string key, CancellationToken ct = default @@ -42,8 +45,8 @@ public DistributedCacheIdempotencyStore(IConnectionMultiplexer connectionMultipl } /// - /// 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. + /// 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, @@ -70,7 +73,10 @@ public DistributedCacheIdempotencyStore(IConnectionMultiplexer connectionMultipl return result.IsNull ? null : lockValue; } - /// Serialises and stores it under in Redis with the specified . + /// + /// Serialises and stores it under in Redis with the specified + /// . + /// public async Task SetAsync( string key, IdempotencyCacheEntry entry, @@ -82,7 +88,10 @@ public async Task SetAsync( 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. + /// + /// 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, string lockToken, CancellationToken ct = default) { string lockKey = KeyPrefix + key + IdempotencyStoreConstants.LockSuffix; diff --git a/src/SharedKernel/Infrastructure/Logging/RedactionConfiguration.cs b/src/SharedKernel/Infrastructure/Logging/RedactionConfiguration.cs index 13aedec3..ef77f8fc 100644 --- a/src/SharedKernel/Infrastructure/Logging/RedactionConfiguration.cs +++ b/src/SharedKernel/Infrastructure/Logging/RedactionConfiguration.cs @@ -3,15 +3,15 @@ namespace SharedKernel.Infrastructure.Logging; /// -/// Provides helper methods for resolving redaction configuration values, centralising the -/// precedence logic (environment variable first, then options, then error) used at startup. +/// Provides helper methods for resolving redaction configuration values, centralising the +/// precedence logic (environment variable first, then options, then error) used at startup. /// public static class RedactionConfiguration { /// - /// Resolves the HMAC key for log redaction by checking the environment variable named in - /// first, then falling back to the inline HmacKey value. - /// Throws if neither source provides a non-empty key. + /// Resolves the HMAC key for log redaction by checking the environment variable named in + /// first, then falling back to the inline HmacKey value. + /// Throws if neither source provides a non-empty key. /// public static string ResolveHmacKey( RedactionOptions options, diff --git a/src/SharedKernel/Infrastructure/Observability/TelemetryApiSurfaceResolver.cs b/src/SharedKernel/Infrastructure/Observability/TelemetryApiSurfaceResolver.cs index f177f32c..789b2fe3 100644 --- a/src/SharedKernel/Infrastructure/Observability/TelemetryApiSurfaceResolver.cs +++ b/src/SharedKernel/Infrastructure/Observability/TelemetryApiSurfaceResolver.cs @@ -3,14 +3,14 @@ namespace SharedKernel.Infrastructure.Observability; /// -/// Maps an HTTP request path to a logical API surface name (e.g., graphql, health, rest) -/// for use as a telemetry tag value. +/// Maps an HTTP request path to a logical API surface name (e.g., graphql, health, rest) +/// for use as a telemetry tag value. /// public static class TelemetryApiSurfaceResolver { /// - /// Returns the surface name for the given request path by matching well-known prefixes; - /// falls back to for all other paths. + /// Returns the surface name for the given request path by matching well-known prefixes; + /// falls back to for all other paths. /// public static string Resolve(PathString path) { @@ -24,9 +24,7 @@ public static string Resolve(PathString path) path.StartsWithSegments(TelemetryPathPrefixes.Scalar) || path.StartsWithSegments(TelemetryPathPrefixes.OpenApi) ) - { return TelemetrySurfaces.Documentation; - } return TelemetrySurfaces.Rest; } diff --git a/src/SharedKernel/Infrastructure/SoftDelete/ISoftDeleteCleanupStrategy.cs b/src/SharedKernel/Infrastructure/SoftDelete/ISoftDeleteCleanupStrategy.cs index c4aa66e3..e9c526a8 100644 --- a/src/SharedKernel/Infrastructure/SoftDelete/ISoftDeleteCleanupStrategy.cs +++ b/src/SharedKernel/Infrastructure/SoftDelete/ISoftDeleteCleanupStrategy.cs @@ -1,12 +1,12 @@ namespace SharedKernel.Infrastructure.SoftDelete; /// -/// Defines a strategy for permanently removing soft-deleted records of a specific entity type -/// that have exceeded the configured retention window. +/// Defines a strategy for permanently removing soft-deleted records of a specific entity type +/// that have exceeded the configured retention window. /// public interface ISoftDeleteCleanupStrategy { - string EntityName { get; } + public string EntityName { get; } - Task CleanupAsync(DateTime cutoff, int batchSize, CancellationToken ct = default); + public Task CleanupAsync(DateTime cutoff, int batchSize, CancellationToken ct = default); } diff --git a/src/SharedKernel/Infrastructure/UnitOfWork/EfCoreTransactionProvider.cs b/src/SharedKernel/Infrastructure/UnitOfWork/EfCoreTransactionProvider.cs index c403b4cb..306e4e8a 100644 --- a/src/SharedKernel/Infrastructure/UnitOfWork/EfCoreTransactionProvider.cs +++ b/src/SharedKernel/Infrastructure/UnitOfWork/EfCoreTransactionProvider.cs @@ -6,27 +6,36 @@ namespace SharedKernel.Infrastructure.UnitOfWork; /// -/// EF Core implementation of backed by a generic . +/// EF Core implementation of backed by a generic . /// public class EfCoreTransactionProvider : IDbTransactionProvider { private readonly DbContext _dbContext; - public EfCoreTransactionProvider(DbContext dbContext) => _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); + ) + { + return _dbContext.Database.BeginTransactionAsync(isolationLevel, ct); + } - public IExecutionStrategy CreateExecutionStrategy(TransactionOptions options) => - UnitOfWorkExecutionStrategyFactory.Create(_dbContext, options); + public IExecutionStrategy CreateExecutionStrategy(TransactionOptions options) + { + return UnitOfWorkExecutionStrategyFactory.Create(_dbContext, options); + } } /// -/// EF Core implementation of backed by a specific module's . +/// EF Core implementation of backed by a specific module's +/// . /// public class EfCoreTransactionProvider : EfCoreTransactionProvider, diff --git a/src/SharedKernel/Infrastructure/UnitOfWork/UnitOfWorkLogs.cs b/src/SharedKernel/Infrastructure/UnitOfWork/UnitOfWorkLogs.cs index de7c96c2..7f518342 100644 --- a/src/SharedKernel/Infrastructure/UnitOfWork/UnitOfWorkLogs.cs +++ b/src/SharedKernel/Infrastructure/UnitOfWork/UnitOfWorkLogs.cs @@ -4,7 +4,7 @@ namespace SharedKernel.Infrastructure.UnitOfWork; /// -/// Source-generated logger extension methods for unit-of-work diagnostics. +/// Source-generated logger extension methods for unit-of-work diagnostics. /// internal static partial class UnitOfWorkLogs { diff --git a/src/SharedKernel/SharedKernel.csproj b/src/SharedKernel/SharedKernel.csproj index bff6ee21..2e233ade 100644 --- a/src/SharedKernel/SharedKernel.csproj +++ b/src/SharedKernel/SharedKernel.csproj @@ -7,27 +7,27 @@ - + - + - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/tests/APITemplate.Tests/APITemplate.Tests.csproj b/tests/APITemplate.Tests/APITemplate.Tests.csproj index f9cd6a6b..7e8bd02b 100644 --- a/tests/APITemplate.Tests/APITemplate.Tests.csproj +++ b/tests/APITemplate.Tests/APITemplate.Tests.csproj @@ -7,42 +7,42 @@ false - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + + - + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - + + + + + diff --git a/tests/APITemplate.Tests/Unit/BackgroundJobs/Jobs/SubmitJobCommandHandlerTests.cs b/tests/APITemplate.Tests/Unit/BackgroundJobs/Jobs/SubmitJobCommandHandlerTests.cs index 10f1b8ac..d4cf98ae 100644 --- a/tests/APITemplate.Tests/Unit/BackgroundJobs/Jobs/SubmitJobCommandHandlerTests.cs +++ b/tests/APITemplate.Tests/Unit/BackgroundJobs/Jobs/SubmitJobCommandHandlerTests.cs @@ -1,9 +1,10 @@ using BackgroundJobs.Domain; -using BackgroundJobs.Domain; -using BackgroundJobs.Features; using BackgroundJobs.Features; +using ErrorOr; using Moq; +using SharedKernel.Application.Errors; using SharedKernel.Domain.Interfaces; +using SharedKernel.Domain.Options; using Shouldly; using Xunit; @@ -11,11 +12,11 @@ namespace APITemplate.Tests.Unit.BackgroundJobs.Jobs; public sealed class SubmitJobCommandHandlerTests { - private readonly Mock _repository = new(); private readonly Mock _jobQueue = new(); - private readonly Mock> _unitOfWork = new(); - private readonly Mock _timeProvider = new(); private readonly DateTime _now = new(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc); + private readonly Mock _repository = new(); + private readonly Mock _timeProvider = new(); + private readonly Mock> _unitOfWork = new(); public SubmitJobCommandHandlerTests() { @@ -25,14 +26,12 @@ public SubmitJobCommandHandlerTests() u.ExecuteInTransactionAsync( It.IsAny>(), It.IsAny(), - It.IsAny() + It.IsAny() ) ) - .Returns< - Func, - CancellationToken, - SharedKernel.Domain.Options.TransactionOptions? - >(async (action, _, _) => await action()); + .Returns, CancellationToken, TransactionOptions?>( + async (action, _, _) => await action() + ); } [Fact] @@ -45,7 +44,7 @@ public async Task HandleAsync_PersistsEntityAndEnqueuesId() .Callback((id, _) => enqueuedId = id) .Returns(ValueTask.CompletedTask); - ErrorOr.ErrorOr result = await SubmitJobCommandHandler.HandleAsync( + ErrorOr result = await SubmitJobCommandHandler.HandleAsync( new SubmitJobCommand(new SubmitJobRequest("data-export")), _repository.Object, _jobQueue.Object, @@ -65,8 +64,8 @@ public async Task HandleAsync_ReturnsPendingResponseWithCorrectFields() CancellationToken ct = TestContext.Current.CancellationToken; _jobQueue.Setup(q => q.EnqueueAsync(It.IsAny(), ct)).Returns(ValueTask.CompletedTask); - ErrorOr.ErrorOr result = await SubmitJobCommandHandler.HandleAsync( - new SubmitJobCommand(new SubmitJobRequest("report-gen", "param1", null)), + ErrorOr result = await SubmitJobCommandHandler.HandleAsync( + new SubmitJobCommand(new SubmitJobRequest("report-gen", "param1")), _repository.Object, _jobQueue.Object, _unitOfWork.Object, @@ -120,7 +119,7 @@ public async Task HandleAsync_WhenEnqueueFails_MarksJobAsFailedAndReturnsError() .Setup(q => q.EnqueueAsync(It.IsAny(), ct)) .ThrowsAsync(new InvalidOperationException("queue unavailable")); - ErrorOr.ErrorOr result = await SubmitJobCommandHandler.HandleAsync( + ErrorOr result = await SubmitJobCommandHandler.HandleAsync( new SubmitJobCommand(new SubmitJobRequest("report-gen")), _repository.Object, _jobQueue.Object, @@ -130,9 +129,7 @@ public async Task HandleAsync_WhenEnqueueFails_MarksJobAsFailedAndReturnsError() ); result.IsError.ShouldBeTrue(); - result.FirstError.Code.ShouldBe( - SharedKernel.Application.Errors.ErrorCatalog.General.Unknown - ); + result.FirstError.Code.ShouldBe(ErrorCatalog.General.Unknown); persisted.ShouldNotBeNull(); persisted!.Status.ShouldBe(JobStatus.Failed); persisted.ErrorMessage.ShouldNotBeNull(); diff --git a/tests/APITemplate.Tests/Unit/Idempotency/InMemoryIdempotencyStoreTests.cs b/tests/APITemplate.Tests/Unit/Idempotency/InMemoryIdempotencyStoreTests.cs index 4a82fc9b..927d3bc8 100644 --- a/tests/APITemplate.Tests/Unit/Idempotency/InMemoryIdempotencyStoreTests.cs +++ b/tests/APITemplate.Tests/Unit/Idempotency/InMemoryIdempotencyStoreTests.cs @@ -143,8 +143,14 @@ private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider { private DateTimeOffset _utcNow = utcNow; - public override DateTimeOffset GetUtcNow() => _utcNow; - - public void Advance(TimeSpan duration) => _utcNow = _utcNow.Add(duration); + public override DateTimeOffset GetUtcNow() + { + return _utcNow; + } + + public void Advance(TimeSpan duration) + { + _utcNow = _utcNow.Add(duration); + } } } diff --git a/tests/APITemplate.Tests/Unit/Security/PermissionAuthorizationHandlerTests.cs b/tests/APITemplate.Tests/Unit/Security/PermissionAuthorizationHandlerTests.cs index 3b43f966..cbc0f012 100644 --- a/tests/APITemplate.Tests/Unit/Security/PermissionAuthorizationHandlerTests.cs +++ b/tests/APITemplate.Tests/Unit/Security/PermissionAuthorizationHandlerTests.cs @@ -11,8 +11,8 @@ namespace APITemplate.Tests.Unit.Security; public class PermissionAuthorizationHandlerTests { - private readonly IRolePermissionMap _rolePermissionMap = new StaticRolePermissionMap(); private readonly PermissionAuthorizationHandler _handler; + private readonly IRolePermissionMap _rolePermissionMap = new StaticRolePermissionMap(); public PermissionAuthorizationHandlerTests() { @@ -22,9 +22,9 @@ public PermissionAuthorizationHandlerTests() [Fact] public async Task UserWithCorrectRole_Succeeds() { - var requirement = new PermissionRequirement(Permission.Products.Read); + PermissionRequirement requirement = new(Permission.Products.Read); ClaimsPrincipal user = CreatePrincipal(UserRole.User); - var context = new AuthorizationHandlerContext([requirement], user, null); + AuthorizationHandlerContext context = new([requirement], user, null); await _handler.HandleAsync(context); @@ -34,9 +34,9 @@ public async Task UserWithCorrectRole_Succeeds() [Fact] public async Task UserWithoutPermission_Fails() { - var requirement = new PermissionRequirement(Permission.Products.Create); + PermissionRequirement requirement = new(Permission.Products.Create); ClaimsPrincipal user = CreatePrincipal(UserRole.User); - var context = new AuthorizationHandlerContext([requirement], user, null); + AuthorizationHandlerContext context = new([requirement], user, null); await _handler.HandleAsync(context); @@ -46,9 +46,9 @@ public async Task UserWithoutPermission_Fails() [Fact] public async Task PlatformAdmin_SucceedsForAnyPermission() { - var requirement = new PermissionRequirement(Permission.Users.Delete); + PermissionRequirement requirement = new(Permission.Users.Delete); ClaimsPrincipal user = CreatePrincipal(UserRole.PlatformAdmin); - var context = new AuthorizationHandlerContext([requirement], user, null); + AuthorizationHandlerContext context = new([requirement], user, null); await _handler.HandleAsync(context); @@ -58,9 +58,9 @@ public async Task PlatformAdmin_SucceedsForAnyPermission() [Fact] public async Task TenantAdmin_SucceedsForProductCreate() { - var requirement = new PermissionRequirement(Permission.Products.Create); + PermissionRequirement requirement = new(Permission.Products.Create); ClaimsPrincipal user = CreatePrincipal(UserRole.TenantAdmin); - var context = new AuthorizationHandlerContext([requirement], user, null); + AuthorizationHandlerContext context = new([requirement], user, null); await _handler.HandleAsync(context); @@ -70,9 +70,9 @@ public async Task TenantAdmin_SucceedsForProductCreate() [Fact] public async Task TenantAdmin_FailsForUserCreate() { - var requirement = new PermissionRequirement(Permission.Users.Create); + PermissionRequirement requirement = new(Permission.Users.Create); ClaimsPrincipal user = CreatePrincipal(UserRole.TenantAdmin); - var context = new AuthorizationHandlerContext([requirement], user, null); + AuthorizationHandlerContext context = new([requirement], user, null); await _handler.HandleAsync(context); @@ -82,9 +82,9 @@ public async Task TenantAdmin_FailsForUserCreate() [Fact] public async Task UnauthenticatedUser_Fails() { - var requirement = new PermissionRequirement(Permission.Products.Read); - var user = new ClaimsPrincipal(new ClaimsIdentity()); - var context = new AuthorizationHandlerContext([requirement], user, null); + PermissionRequirement requirement = new(Permission.Products.Read); + ClaimsPrincipal user = new(new ClaimsIdentity()); + AuthorizationHandlerContext context = new([requirement], user, null); await _handler.HandleAsync(context); From 257537806cb3e690c632338e37d7a3950f153d51 Mon Sep 17 00:00:00 2001 From: "tadeas.zribko" Date: Sat, 4 Apr 2026 13:45:09 +0200 Subject: [PATCH 3/8] feat: Update database configurations for job execution and email handling - Add text column type for Parameters, ResultPayload, and ErrorMessage in JobExecutionConfiguration. - Change HtmlBody property to use text column type in FailedEmailConfiguration. - Introduce max length constraint for CategoryName in ProductCategoryStatsConfiguration. --- .../BackgroundJobs/Persistence/JobExecutionConfiguration.cs | 3 +++ .../Notifications/Persistence/FailedEmailConfiguration.cs | 2 +- .../Configurations/ProductCategoryStatsConfiguration.cs | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Modules/BackgroundJobs/Persistence/JobExecutionConfiguration.cs b/src/Modules/BackgroundJobs/Persistence/JobExecutionConfiguration.cs index 2cf29d33..9a35f23c 100644 --- a/src/Modules/BackgroundJobs/Persistence/JobExecutionConfiguration.cs +++ b/src/Modules/BackgroundJobs/Persistence/JobExecutionConfiguration.cs @@ -20,7 +20,10 @@ public void Configure(EntityTypeBuilder builder) .HasDefaultValue(JobStatus.Pending) .HasSentinel((JobStatus)(-1)); builder.Property(j => j.ProgressPercent).HasDefaultValue(0); + builder.Property(j => j.Parameters).HasColumnType("text"); builder.Property(j => j.CallbackUrl).HasMaxLength(2048); + builder.Property(j => j.ResultPayload).HasColumnType("text"); + builder.Property(j => j.ErrorMessage).HasColumnType("text"); builder.HasIndex(j => j.Status); builder.HasIndex(j => new { j.TenantId, j.SubmittedAtUtc }); diff --git a/src/Modules/Notifications/Persistence/FailedEmailConfiguration.cs b/src/Modules/Notifications/Persistence/FailedEmailConfiguration.cs index 51fc0b21..d87d15df 100644 --- a/src/Modules/Notifications/Persistence/FailedEmailConfiguration.cs +++ b/src/Modules/Notifications/Persistence/FailedEmailConfiguration.cs @@ -16,7 +16,7 @@ public void Configure(EntityTypeBuilder builder) 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.HtmlBody).IsRequired().HasColumnType("text"); builder.Property(e => e.LastError).HasMaxLength(FailedEmail.LastErrorMaxLength); builder.Property(e => e.TemplateName).HasMaxLength(100); diff --git a/src/Modules/ProductCatalog/Configurations/ProductCategoryStatsConfiguration.cs b/src/Modules/ProductCatalog/Configurations/ProductCategoryStatsConfiguration.cs index c904d0ee..a3245509 100644 --- a/src/Modules/ProductCatalog/Configurations/ProductCategoryStatsConfiguration.cs +++ b/src/Modules/ProductCatalog/Configurations/ProductCategoryStatsConfiguration.cs @@ -19,5 +19,7 @@ public void Configure(EntityTypeBuilder builder) // ExcludeFromMigrations tells EF Core to skip this type when generating migrations. // The entity exists only as a materialisation target for FromSql() calls. builder.ToTable("ProductCategoryStats", t => t.ExcludeFromMigrations()); + + builder.Property(s => s.CategoryName).HasMaxLength(100); } } From 7aa264b08739ee7e7395c0d7e33c3f1ec200f35a Mon Sep 17 00:00:00 2001 From: "tadeas.zribko" Date: Sat, 4 Apr 2026 13:45:25 +0200 Subject: [PATCH 4/8] refactor: Update InMemoryIdempotencyStoreTests to include CancellationToken in async method calls - Modify all async method calls in InMemoryIdempotencyStoreTests to accept a CancellationToken parameter. - Ensure consistent usage of CancellationToken across test cases for better control over task cancellation. --- .../InMemoryIdempotencyStoreTests.cs | 50 +++++++++++-------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/tests/APITemplate.Tests/Unit/Idempotency/InMemoryIdempotencyStoreTests.cs b/tests/APITemplate.Tests/Unit/Idempotency/InMemoryIdempotencyStoreTests.cs index 927d3bc8..123c8e5e 100644 --- a/tests/APITemplate.Tests/Unit/Idempotency/InMemoryIdempotencyStoreTests.cs +++ b/tests/APITemplate.Tests/Unit/Idempotency/InMemoryIdempotencyStoreTests.cs @@ -12,10 +12,11 @@ public sealed class InMemoryIdempotencyStoreTests [Fact] public async Task TryAcquireAsync_WhenKeyIsNew_ReturnsLockToken() { + CancellationToken ct = TestContext.Current.CancellationToken; FakeTimeProvider time = new(DateTimeOffset.UtcNow); InMemoryIdempotencyStore store = new(time); - string? token = await store.TryAcquireAsync("key-1", DefaultTtl); + string? token = await store.TryAcquireAsync("key-1", DefaultTtl, ct); token.ShouldNotBeNull(); } @@ -23,11 +24,12 @@ public async Task TryAcquireAsync_WhenKeyIsNew_ReturnsLockToken() [Fact] public async Task TryAcquireAsync_WhenLockAlreadyHeld_ReturnsNull() { + CancellationToken ct = TestContext.Current.CancellationToken; FakeTimeProvider time = new(DateTimeOffset.UtcNow); InMemoryIdempotencyStore store = new(time); - string? first = await store.TryAcquireAsync("key-1", DefaultTtl); - string? second = await store.TryAcquireAsync("key-1", DefaultTtl); + string? first = await store.TryAcquireAsync("key-1", DefaultTtl, ct); + string? second = await store.TryAcquireAsync("key-1", DefaultTtl, ct); first.ShouldNotBeNull(); second.ShouldBeNull(); @@ -36,17 +38,18 @@ public async Task TryAcquireAsync_WhenLockAlreadyHeld_ReturnsNull() [Fact] public async Task TryAcquireAsync_WhenCachedResultExists_ReturnsNull() { + CancellationToken ct = TestContext.Current.CancellationToken; FakeTimeProvider time = new(DateTimeOffset.UtcNow); InMemoryIdempotencyStore store = new(time); - string? token = await store.TryAcquireAsync("key-1", DefaultTtl); + string? token = await store.TryAcquireAsync("key-1", DefaultTtl, ct); token.ShouldNotBeNull(); IdempotencyCacheEntry entry = new(200, """{"id":1}""", "application/json"); - await store.SetAsync("key-1", entry, DefaultTtl); - await store.ReleaseAsync("key-1", token); + await store.SetAsync("key-1", entry, DefaultTtl, ct); + await store.ReleaseAsync("key-1", token!, ct); - string? secondToken = await store.TryAcquireAsync("key-1", DefaultTtl); + string? secondToken = await store.TryAcquireAsync("key-1", DefaultTtl, ct); secondToken.ShouldBeNull(); } @@ -54,19 +57,20 @@ public async Task TryAcquireAsync_WhenCachedResultExists_ReturnsNull() [Fact] public async Task TryAcquireAsync_WhenCachedResultExpired_ReturnsNewToken() { + CancellationToken ct = TestContext.Current.CancellationToken; FakeTimeProvider time = new(DateTimeOffset.UtcNow); InMemoryIdempotencyStore store = new(time); - string? token = await store.TryAcquireAsync("key-1", DefaultTtl); + string? token = await store.TryAcquireAsync("key-1", DefaultTtl, ct); token.ShouldNotBeNull(); IdempotencyCacheEntry entry = new(200, """{"id":1}""", "application/json"); - await store.SetAsync("key-1", entry, TimeSpan.FromSeconds(1)); - await store.ReleaseAsync("key-1", token); + await store.SetAsync("key-1", entry, TimeSpan.FromSeconds(1), ct); + await store.ReleaseAsync("key-1", token!, ct); time.Advance(TimeSpan.FromMinutes(2)); - string? secondToken = await store.TryAcquireAsync("key-1", DefaultTtl); + string? secondToken = await store.TryAcquireAsync("key-1", DefaultTtl, ct); secondToken.ShouldNotBeNull(); } @@ -74,36 +78,39 @@ public async Task TryAcquireAsync_WhenCachedResultExpired_ReturnsNewToken() [Fact] public async Task ReleaseAsync_WithCorrectToken_ReleasesLock() { + CancellationToken ct = TestContext.Current.CancellationToken; FakeTimeProvider time = new(DateTimeOffset.UtcNow); InMemoryIdempotencyStore store = new(time); - string? token = await store.TryAcquireAsync("key-1", DefaultTtl); + string? token = await store.TryAcquireAsync("key-1", DefaultTtl, ct); token.ShouldNotBeNull(); - await store.ReleaseAsync("key-1", token); + await store.ReleaseAsync("key-1", token!, ct); - string? newToken = await store.TryAcquireAsync("key-1", DefaultTtl); + string? newToken = await store.TryAcquireAsync("key-1", DefaultTtl, ct); newToken.ShouldNotBeNull(); } [Fact] public async Task ReleaseAsync_WithWrongToken_DoesNotReleaseLock() { + CancellationToken ct = TestContext.Current.CancellationToken; FakeTimeProvider time = new(DateTimeOffset.UtcNow); InMemoryIdempotencyStore store = new(time); - string? token = await store.TryAcquireAsync("key-1", DefaultTtl); + string? token = await store.TryAcquireAsync("key-1", DefaultTtl, ct); token.ShouldNotBeNull(); - await store.ReleaseAsync("key-1", "wrong-token"); + await store.ReleaseAsync("key-1", "wrong-token", ct); - string? secondToken = await store.TryAcquireAsync("key-1", DefaultTtl); + string? secondToken = await store.TryAcquireAsync("key-1", DefaultTtl, ct); secondToken.ShouldBeNull(); } [Fact] public async Task SetAsync_ThenTryGetAsync_ReturnsCachedEntry() { + CancellationToken ct = TestContext.Current.CancellationToken; FakeTimeProvider time = new(DateTimeOffset.UtcNow); InMemoryIdempotencyStore store = new(time); @@ -113,9 +120,9 @@ public async Task SetAsync_ThenTryGetAsync_ReturnsCachedEntry() "application/json", "/api/items/42" ); - await store.SetAsync("key-1", entry, DefaultTtl); + await store.SetAsync("key-1", entry, DefaultTtl, ct); - IdempotencyCacheEntry? cached = await store.TryGetAsync("key-1"); + IdempotencyCacheEntry? cached = await store.TryGetAsync("key-1", ct); cached.ShouldNotBeNull(); cached.StatusCode.ShouldBe(201); @@ -127,15 +134,16 @@ public async Task SetAsync_ThenTryGetAsync_ReturnsCachedEntry() [Fact] public async Task TryGetAsync_WhenExpired_ReturnsNull() { + CancellationToken ct = TestContext.Current.CancellationToken; FakeTimeProvider time = new(DateTimeOffset.UtcNow); InMemoryIdempotencyStore store = new(time); IdempotencyCacheEntry entry = new(200, "{}", "application/json"); - await store.SetAsync("key-1", entry, TimeSpan.FromSeconds(1)); + await store.SetAsync("key-1", entry, TimeSpan.FromSeconds(1), ct); time.Advance(TimeSpan.FromMinutes(2)); - IdempotencyCacheEntry? cached = await store.TryGetAsync("key-1"); + IdempotencyCacheEntry? cached = await store.TryGetAsync("key-1", ct); cached.ShouldBeNull(); } From a0ce7f0d2f35903b7f06b41bbf9b6b5700b17edb Mon Sep 17 00:00:00 2001 From: "tadeas.zribko" Date: Sat, 4 Apr 2026 13:58:16 +0200 Subject: [PATCH 5/8] feat: Implement authorization and caching mechanisms in API - Add PermissionAuthorizationHandler to manage role-based permissions. - Introduce PermissionPolicyProvider for dynamic authorization policies. - Create PermissionRequirement for defining permission requirements. - Implement caching options and services for efficient data retrieval. - Add middleware for CSRF protection and request context management. - Enhance OpenAPI documentation with error response handling and health check endpoints. --- .../Api/{Api => }/Authorization/PermissionAuthorizationHandler.cs | 0 .../Api/{Api => }/Authorization/PermissionPolicyProvider.cs | 0 .../Api/{Api => }/Authorization/PermissionRequirement.cs | 0 src/APITemplate/Api/{Api => }/Cache/CacheInvalidationHandler.cs | 0 src/APITemplate/Api/{Api => }/Cache/CachingOptions.cs | 0 .../Api/{Api => }/Cache/IOutputCacheInvalidationService.cs | 0 .../Api/{Api => }/Cache/OutputCacheInvalidationService.cs | 0 .../Api/{Api => }/Cache/OutputCacheInvalidationServiceLogs.cs | 0 src/APITemplate/Api/{Api => }/Cache/RedisInstanceNames.cs | 0 .../Api/{Api => }/Cache/TenantAwareOutputCachePolicy.cs | 0 .../Api/{Api => }/ExceptionHandling/ApiExceptionHandler.cs | 0 .../Api/{Api => }/ExceptionHandling/ApiExceptionHandlerLogs.cs | 0 .../Api/{Api => }/ExceptionHandling/ApiProblemDetailsOptions.cs | 0 .../Api/{Api => }/Middleware/CsrfValidationMiddleware.cs | 0 .../Api/{Api => }/Middleware/RequestContextMiddleware.cs | 0 .../OpenApi/AuthorizationResponsesOperationTransformer.cs | 0 .../{Api => }/OpenApi/BearerSecuritySchemeDocumentTransformer.cs | 0 .../{Api => }/OpenApi/HealthCheckOpenApiDocumentTransformer.cs | 0 .../Api/{Api => }/OpenApi/OpenApiErrorResponseHelper.cs | 0 .../Api/{Api => }/OpenApi/ProblemDetailsOpenApiTransformer.cs | 0 src/APITemplate/Api/{Api => }/Security/HttpActorProvider.cs | 0 src/APITemplate/Api/{Api => }/Security/HttpTenantProvider.cs | 0 22 files changed, 0 insertions(+), 0 deletions(-) rename src/APITemplate/Api/{Api => }/Authorization/PermissionAuthorizationHandler.cs (100%) rename src/APITemplate/Api/{Api => }/Authorization/PermissionPolicyProvider.cs (100%) rename src/APITemplate/Api/{Api => }/Authorization/PermissionRequirement.cs (100%) rename src/APITemplate/Api/{Api => }/Cache/CacheInvalidationHandler.cs (100%) rename src/APITemplate/Api/{Api => }/Cache/CachingOptions.cs (100%) rename src/APITemplate/Api/{Api => }/Cache/IOutputCacheInvalidationService.cs (100%) rename src/APITemplate/Api/{Api => }/Cache/OutputCacheInvalidationService.cs (100%) rename src/APITemplate/Api/{Api => }/Cache/OutputCacheInvalidationServiceLogs.cs (100%) rename src/APITemplate/Api/{Api => }/Cache/RedisInstanceNames.cs (100%) rename src/APITemplate/Api/{Api => }/Cache/TenantAwareOutputCachePolicy.cs (100%) rename src/APITemplate/Api/{Api => }/ExceptionHandling/ApiExceptionHandler.cs (100%) rename src/APITemplate/Api/{Api => }/ExceptionHandling/ApiExceptionHandlerLogs.cs (100%) rename src/APITemplate/Api/{Api => }/ExceptionHandling/ApiProblemDetailsOptions.cs (100%) rename src/APITemplate/Api/{Api => }/Middleware/CsrfValidationMiddleware.cs (100%) rename src/APITemplate/Api/{Api => }/Middleware/RequestContextMiddleware.cs (100%) rename src/APITemplate/Api/{Api => }/OpenApi/AuthorizationResponsesOperationTransformer.cs (100%) rename src/APITemplate/Api/{Api => }/OpenApi/BearerSecuritySchemeDocumentTransformer.cs (100%) rename src/APITemplate/Api/{Api => }/OpenApi/HealthCheckOpenApiDocumentTransformer.cs (100%) rename src/APITemplate/Api/{Api => }/OpenApi/OpenApiErrorResponseHelper.cs (100%) rename src/APITemplate/Api/{Api => }/OpenApi/ProblemDetailsOpenApiTransformer.cs (100%) rename src/APITemplate/Api/{Api => }/Security/HttpActorProvider.cs (100%) rename src/APITemplate/Api/{Api => }/Security/HttpTenantProvider.cs (100%) diff --git a/src/APITemplate/Api/Api/Authorization/PermissionAuthorizationHandler.cs b/src/APITemplate/Api/Authorization/PermissionAuthorizationHandler.cs similarity index 100% rename from src/APITemplate/Api/Api/Authorization/PermissionAuthorizationHandler.cs rename to src/APITemplate/Api/Authorization/PermissionAuthorizationHandler.cs diff --git a/src/APITemplate/Api/Api/Authorization/PermissionPolicyProvider.cs b/src/APITemplate/Api/Authorization/PermissionPolicyProvider.cs similarity index 100% rename from src/APITemplate/Api/Api/Authorization/PermissionPolicyProvider.cs rename to src/APITemplate/Api/Authorization/PermissionPolicyProvider.cs diff --git a/src/APITemplate/Api/Api/Authorization/PermissionRequirement.cs b/src/APITemplate/Api/Authorization/PermissionRequirement.cs similarity index 100% rename from src/APITemplate/Api/Api/Authorization/PermissionRequirement.cs rename to src/APITemplate/Api/Authorization/PermissionRequirement.cs diff --git a/src/APITemplate/Api/Api/Cache/CacheInvalidationHandler.cs b/src/APITemplate/Api/Cache/CacheInvalidationHandler.cs similarity index 100% rename from src/APITemplate/Api/Api/Cache/CacheInvalidationHandler.cs rename to src/APITemplate/Api/Cache/CacheInvalidationHandler.cs diff --git a/src/APITemplate/Api/Api/Cache/CachingOptions.cs b/src/APITemplate/Api/Cache/CachingOptions.cs similarity index 100% rename from src/APITemplate/Api/Api/Cache/CachingOptions.cs rename to src/APITemplate/Api/Cache/CachingOptions.cs diff --git a/src/APITemplate/Api/Api/Cache/IOutputCacheInvalidationService.cs b/src/APITemplate/Api/Cache/IOutputCacheInvalidationService.cs similarity index 100% rename from src/APITemplate/Api/Api/Cache/IOutputCacheInvalidationService.cs rename to src/APITemplate/Api/Cache/IOutputCacheInvalidationService.cs diff --git a/src/APITemplate/Api/Api/Cache/OutputCacheInvalidationService.cs b/src/APITemplate/Api/Cache/OutputCacheInvalidationService.cs similarity index 100% rename from src/APITemplate/Api/Api/Cache/OutputCacheInvalidationService.cs rename to src/APITemplate/Api/Cache/OutputCacheInvalidationService.cs diff --git a/src/APITemplate/Api/Api/Cache/OutputCacheInvalidationServiceLogs.cs b/src/APITemplate/Api/Cache/OutputCacheInvalidationServiceLogs.cs similarity index 100% rename from src/APITemplate/Api/Api/Cache/OutputCacheInvalidationServiceLogs.cs rename to src/APITemplate/Api/Cache/OutputCacheInvalidationServiceLogs.cs diff --git a/src/APITemplate/Api/Api/Cache/RedisInstanceNames.cs b/src/APITemplate/Api/Cache/RedisInstanceNames.cs similarity index 100% rename from src/APITemplate/Api/Api/Cache/RedisInstanceNames.cs rename to src/APITemplate/Api/Cache/RedisInstanceNames.cs diff --git a/src/APITemplate/Api/Api/Cache/TenantAwareOutputCachePolicy.cs b/src/APITemplate/Api/Cache/TenantAwareOutputCachePolicy.cs similarity index 100% rename from src/APITemplate/Api/Api/Cache/TenantAwareOutputCachePolicy.cs rename to src/APITemplate/Api/Cache/TenantAwareOutputCachePolicy.cs diff --git a/src/APITemplate/Api/Api/ExceptionHandling/ApiExceptionHandler.cs b/src/APITemplate/Api/ExceptionHandling/ApiExceptionHandler.cs similarity index 100% rename from src/APITemplate/Api/Api/ExceptionHandling/ApiExceptionHandler.cs rename to src/APITemplate/Api/ExceptionHandling/ApiExceptionHandler.cs diff --git a/src/APITemplate/Api/Api/ExceptionHandling/ApiExceptionHandlerLogs.cs b/src/APITemplate/Api/ExceptionHandling/ApiExceptionHandlerLogs.cs similarity index 100% rename from src/APITemplate/Api/Api/ExceptionHandling/ApiExceptionHandlerLogs.cs rename to src/APITemplate/Api/ExceptionHandling/ApiExceptionHandlerLogs.cs diff --git a/src/APITemplate/Api/Api/ExceptionHandling/ApiProblemDetailsOptions.cs b/src/APITemplate/Api/ExceptionHandling/ApiProblemDetailsOptions.cs similarity index 100% rename from src/APITemplate/Api/Api/ExceptionHandling/ApiProblemDetailsOptions.cs rename to src/APITemplate/Api/ExceptionHandling/ApiProblemDetailsOptions.cs diff --git a/src/APITemplate/Api/Api/Middleware/CsrfValidationMiddleware.cs b/src/APITemplate/Api/Middleware/CsrfValidationMiddleware.cs similarity index 100% rename from src/APITemplate/Api/Api/Middleware/CsrfValidationMiddleware.cs rename to src/APITemplate/Api/Middleware/CsrfValidationMiddleware.cs diff --git a/src/APITemplate/Api/Api/Middleware/RequestContextMiddleware.cs b/src/APITemplate/Api/Middleware/RequestContextMiddleware.cs similarity index 100% rename from src/APITemplate/Api/Api/Middleware/RequestContextMiddleware.cs rename to src/APITemplate/Api/Middleware/RequestContextMiddleware.cs diff --git a/src/APITemplate/Api/Api/OpenApi/AuthorizationResponsesOperationTransformer.cs b/src/APITemplate/Api/OpenApi/AuthorizationResponsesOperationTransformer.cs similarity index 100% rename from src/APITemplate/Api/Api/OpenApi/AuthorizationResponsesOperationTransformer.cs rename to src/APITemplate/Api/OpenApi/AuthorizationResponsesOperationTransformer.cs diff --git a/src/APITemplate/Api/Api/OpenApi/BearerSecuritySchemeDocumentTransformer.cs b/src/APITemplate/Api/OpenApi/BearerSecuritySchemeDocumentTransformer.cs similarity index 100% rename from src/APITemplate/Api/Api/OpenApi/BearerSecuritySchemeDocumentTransformer.cs rename to src/APITemplate/Api/OpenApi/BearerSecuritySchemeDocumentTransformer.cs diff --git a/src/APITemplate/Api/Api/OpenApi/HealthCheckOpenApiDocumentTransformer.cs b/src/APITemplate/Api/OpenApi/HealthCheckOpenApiDocumentTransformer.cs similarity index 100% rename from src/APITemplate/Api/Api/OpenApi/HealthCheckOpenApiDocumentTransformer.cs rename to src/APITemplate/Api/OpenApi/HealthCheckOpenApiDocumentTransformer.cs diff --git a/src/APITemplate/Api/Api/OpenApi/OpenApiErrorResponseHelper.cs b/src/APITemplate/Api/OpenApi/OpenApiErrorResponseHelper.cs similarity index 100% rename from src/APITemplate/Api/Api/OpenApi/OpenApiErrorResponseHelper.cs rename to src/APITemplate/Api/OpenApi/OpenApiErrorResponseHelper.cs diff --git a/src/APITemplate/Api/Api/OpenApi/ProblemDetailsOpenApiTransformer.cs b/src/APITemplate/Api/OpenApi/ProblemDetailsOpenApiTransformer.cs similarity index 100% rename from src/APITemplate/Api/Api/OpenApi/ProblemDetailsOpenApiTransformer.cs rename to src/APITemplate/Api/OpenApi/ProblemDetailsOpenApiTransformer.cs diff --git a/src/APITemplate/Api/Api/Security/HttpActorProvider.cs b/src/APITemplate/Api/Security/HttpActorProvider.cs similarity index 100% rename from src/APITemplate/Api/Api/Security/HttpActorProvider.cs rename to src/APITemplate/Api/Security/HttpActorProvider.cs diff --git a/src/APITemplate/Api/Api/Security/HttpTenantProvider.cs b/src/APITemplate/Api/Security/HttpTenantProvider.cs similarity index 100% rename from src/APITemplate/Api/Api/Security/HttpTenantProvider.cs rename to src/APITemplate/Api/Security/HttpTenantProvider.cs From 0cf26ba32ad76ac4c5afd27f480f78ab745ddcdd Mon Sep 17 00:00:00 2001 From: "tadeas.zribko" Date: Sat, 4 Apr 2026 13:59:44 +0200 Subject: [PATCH 6/8] refactor: Update ToCreatedResult method calls for consistency across controllers - Refactor ToCreatedResult method calls in multiple controllers to include action name as a parameter for improved clarity. - Adjust ApiExceptionHandler to remove nullable types for error details, enhancing type safety. --- .../Api/ExceptionHandling/ApiExceptionHandler.cs | 2 +- src/Modules/Identity/Features/V1/TenantsController.cs | 6 +++++- src/Modules/Identity/Features/V1/UsersController.cs | 6 +++++- .../ProductDataController.CreateImage.cs | 6 +++++- .../ProductDataController.CreateVideo.cs | 6 +++++- src/Modules/Reviews/Features/ProductReviewsController.cs | 6 +++++- src/SharedKernel/Contracts/Api/ErrorOrExtensions.cs | 3 ++- 7 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/APITemplate/Api/ExceptionHandling/ApiExceptionHandler.cs b/src/APITemplate/Api/ExceptionHandling/ApiExceptionHandler.cs index ba3cc5a5..3d708878 100644 --- a/src/APITemplate/Api/ExceptionHandling/ApiExceptionHandler.cs +++ b/src/APITemplate/Api/ExceptionHandling/ApiExceptionHandler.cs @@ -36,7 +36,7 @@ CancellationToken cancellationToken return true; } - (int statusCode, string? title, string? detail, string? errorCode) = Resolve(exception); + (int statusCode, string title, string detail, string errorCode) = Resolve(exception); ProblemDetails problemDetails = new() { Status = statusCode, diff --git a/src/Modules/Identity/Features/V1/TenantsController.cs b/src/Modules/Identity/Features/V1/TenantsController.cs index d6089e79..90346420 100644 --- a/src/Modules/Identity/Features/V1/TenantsController.cs +++ b/src/Modules/Identity/Features/V1/TenantsController.cs @@ -46,7 +46,11 @@ CancellationToken ct new CreateTenantCommand(request), ct ); - return result.ToCreatedResult(this, v => new { id = v.Id, version = this.GetApiVersion() }); + return result.ToCreatedResult( + this, + nameof(GetById), + v => new { id = v.Id, version = this.GetApiVersion() } + ); } [HttpDelete("{id:guid}")] diff --git a/src/Modules/Identity/Features/V1/UsersController.cs b/src/Modules/Identity/Features/V1/UsersController.cs index b8436778..897b5a8a 100644 --- a/src/Modules/Identity/Features/V1/UsersController.cs +++ b/src/Modules/Identity/Features/V1/UsersController.cs @@ -67,7 +67,11 @@ CancellationToken ct new CreateUserCommand(request), ct ); - return result.ToCreatedResult(this, v => new { id = v.Id, version = this.GetApiVersion() }); + return result.ToCreatedResult( + this, + nameof(GetById), + v => new { id = v.Id, version = this.GetApiVersion() } + ); } [HttpPut("{id:guid}")] diff --git a/src/Modules/ProductCatalog/Features/ProductData/CreateImageProductData/ProductDataController.CreateImage.cs b/src/Modules/ProductCatalog/Features/ProductData/CreateImageProductData/ProductDataController.CreateImage.cs index 9387ad44..d8596a99 100644 --- a/src/Modules/ProductCatalog/Features/ProductData/CreateImageProductData/ProductDataController.CreateImage.cs +++ b/src/Modules/ProductCatalog/Features/ProductData/CreateImageProductData/ProductDataController.CreateImage.cs @@ -18,6 +18,10 @@ CancellationToken ct new CreateImageProductDataCommand(request), ct ); - return result.ToCreatedResult(this, v => new { id = v.Id, version = this.GetApiVersion() }); + return result.ToCreatedResult( + this, + nameof(GetById), + v => new { id = v.Id, version = this.GetApiVersion() } + ); } } diff --git a/src/Modules/ProductCatalog/Features/ProductData/CreateVideoProductData/ProductDataController.CreateVideo.cs b/src/Modules/ProductCatalog/Features/ProductData/CreateVideoProductData/ProductDataController.CreateVideo.cs index 8c3e8db5..83aae965 100644 --- a/src/Modules/ProductCatalog/Features/ProductData/CreateVideoProductData/ProductDataController.CreateVideo.cs +++ b/src/Modules/ProductCatalog/Features/ProductData/CreateVideoProductData/ProductDataController.CreateVideo.cs @@ -18,6 +18,10 @@ CancellationToken ct new CreateVideoProductDataCommand(request), ct ); - return result.ToCreatedResult(this, v => new { id = v.Id, version = this.GetApiVersion() }); + return result.ToCreatedResult( + this, + nameof(GetById), + v => new { id = v.Id, version = this.GetApiVersion() } + ); } } diff --git a/src/Modules/Reviews/Features/ProductReviewsController.cs b/src/Modules/Reviews/Features/ProductReviewsController.cs index 1bffeed7..16798051 100644 --- a/src/Modules/Reviews/Features/ProductReviewsController.cs +++ b/src/Modules/Reviews/Features/ProductReviewsController.cs @@ -58,7 +58,11 @@ CancellationToken ct ErrorOr result = await bus.InvokeAsync< ErrorOr >(new CreateProductReviewCommand(request), ct); - return result.ToCreatedResult(this, v => new { id = v.Id, version = this.GetApiVersion() }); + return result.ToCreatedResult( + this, + nameof(GetById), + v => new { id = v.Id, version = this.GetApiVersion() } + ); } [HttpDelete("{id:guid}")] diff --git a/src/SharedKernel/Contracts/Api/ErrorOrExtensions.cs b/src/SharedKernel/Contracts/Api/ErrorOrExtensions.cs index 2003f1e2..94d7550a 100644 --- a/src/SharedKernel/Contracts/Api/ErrorOrExtensions.cs +++ b/src/SharedKernel/Contracts/Api/ErrorOrExtensions.cs @@ -21,13 +21,14 @@ ControllerBase controller public static ActionResult ToCreatedResult( this ErrorOr result, ApiControllerBase controller, + string actionName, Func routeValuesFactory ) { if (!result.IsError) { return controller.CreatedAtAction( - "GetById", + actionName, routeValuesFactory(result.Value), result.Value ); From 2667855ce814197a554c1681bb56f44dd30157fc Mon Sep 17 00:00:00 2001 From: "tadeas.zribko" Date: Sat, 4 Apr 2026 14:00:31 +0200 Subject: [PATCH 7/8] chore: Remove legacy API project files and configurations - Deleted the legacy `APITemplate.Api` project files, including README, project configuration, Dockerfile, and various source files. - This cleanup is part of the transition to a new architecture and modular design. --- absolute/README.md | 7 - .../APITemplate.Api/APITemplate.Api.csproj | 99 -- absolute/src/APITemplate.Api/APITemplate.http | 117 -- .../PermissionAuthorizationHandler.cs | 41 - .../Authorization/PermissionPolicyProvider.cs | 41 - .../Authorization/PermissionRequirement.cs | 9 - .../RequirePermissionAttribute.cs | 18 - .../Api/Cache/CacheInvalidationHandler.cs | 15 - .../Api/Cache/CachingOptions.cs | 31 - .../Cache/IOutputCacheInvalidationService.cs | 14 - .../Cache/OutputCacheInvalidationService.cs | 61 - .../Api/Cache/RedisInstanceNames.cs | 7 - .../Api/Cache/TenantAwareOutputCachePolicy.cs | 67 - .../Api/Controllers/ApiControllerBase.cs | 11 - .../Api/Controllers/V1/BffController.cs | 92 -- .../Controllers/V1/CategoriesController.cs | 115 -- .../Controllers/V1/IdempotentController.cs | 43 - .../Api/Controllers/V1/JobsController.cs | 57 - .../Api/Controllers/V1/PatchController.cs | 39 - .../Controllers/V1/ProductDataController.cs | 89 -- .../V1/ProductReviewsController.cs | 92 -- .../Api/Controllers/V1/ProductsController.cs | 94 -- .../Api/Controllers/V1/SseController.cs | 49 - .../V1/TenantInvitationsController.cs | 102 -- .../Api/Controllers/V1/TenantsController.cs | 70 - .../Api/Controllers/V1/UsersController.cs | 168 --- .../Api/Controllers/V1/WebhooksController.cs | 39 - .../Api/ErrorOrMapping/ErrorOrExtensions.cs | 157 --- .../ExceptionHandling/ApiExceptionHandler.cs | 203 --- .../ApiExceptionHandlerLogs.cs | 48 - .../ApiProblemDetailsOptions.cs | 36 - .../Idempotency/IdempotencyActionFilter.cs | 129 -- .../Idempotency/IdempotencyConstants.cs | 12 - .../Idempotency/IdempotentAttribute.cs | 12 - .../FluentValidationActionFilter.cs | 69 - .../ValidateWebhookSignatureAttribute.cs | 8 - .../WebhookSignatureResourceFilter.cs | 71 - .../ProductReviewsByProductDataLoader.cs | 40 - .../Api/GraphQL/ErrorOrGraphQLExtensions.cs | 45 - .../GraphQlExecutionMetricsListener.cs | 110 -- .../Api/GraphQL/Models/CategoryPageResult.cs | 8 - .../Api/GraphQL/Models/CategoryQueryInput.cs | 16 - .../Api/GraphQL/Models/ProductPageResult.cs | 10 - .../Api/GraphQL/Models/ProductQueryInput.cs | 23 - .../GraphQL/Models/ProductReviewPageResult.cs | 8 - .../GraphQL/Models/ProductReviewQueryInput.cs | 19 - .../Api/GraphQL/Mutations/ProductMutations.cs | 59 - .../Mutations/ProductReviewMutations.cs | 46 - .../Api/GraphQL/Queries/CategoryQueries.cs | 54 - .../Api/GraphQL/Queries/ProductQueries.cs | 61 - .../GraphQL/Queries/ProductReviewQueries.cs | 81 -- .../Api/GraphQL/Types/ProductReviewType.cs | 40 - .../Api/GraphQL/Types/ProductType.cs | 51 - .../Api/GraphQL/Types/ProductTypeResolvers.cs | 21 - .../Middleware/CsrfValidationMiddleware.cs | 102 -- .../Middleware/RequestContextMiddleware.cs | 139 -- ...horizationResponsesOperationTransformer.cs | 39 - ...BearerSecuritySchemeDocumentTransformer.cs | 82 -- .../HealthCheckOpenApiDocumentTransformer.cs | 53 - .../Api/OpenApi/OpenApiErrorResponseHelper.cs | 41 - .../ProblemDetailsOpenApiTransformer.cs | 101 -- .../Api/Requests/FileUploadRequest.cs | 15 - absolute/src/APITemplate.Api/Dockerfile | 23 - .../ApiServiceCollectionExtensions.cs | 290 ---- ...thenticationServiceCollectionExtensions.cs | 311 ----- ...ckgroundJobsServiceCollectionExtensions.cs | 179 --- .../Configuration/ConfigurationExtensions.cs | 20 - .../Configuration/ConfigurationSections.cs | 15 - .../Configuration/KeycloakStartupLogs.cs | 47 - .../ServiceCollectionOptionsExtensions.cs | 25 - .../Extensions/ControllerExtensions.cs | 18 - .../EmailServiceCollectionExtensions.cs | 60 - .../GraphQLServiceCollectionExtensions.cs | 45 - ...frastructureServiceCollectionExtensions.cs | 79 -- ...eycloakAdminServiceCollectionExtensions.cs | 67 - ...bservabilityServiceCollectionExtensions.cs | 286 ---- .../PersistenceServiceCollectionExtensions.cs | 151 --- .../Resilience/ResilienceDefaults.cs | 8 - .../Extensions/ServiceCollectionExtensions.cs | 64 - .../Startup/ApplicationBuilderExtensions.cs | 390 ------ .../Extensions/Startup/LoggingExtensions.cs | 103 -- .../WebhookServiceCollectionExtensions.cs | 76 -- .../WolverineHandlerChainExtensions.cs | 22 - .../Extensions/WolverineTypeExtensions.cs | 30 - absolute/src/APITemplate.Api/GlobalUsings.cs | 28 - absolute/src/APITemplate.Api/Program.cs | 82 -- .../Properties/launchSettings.json | 23 - .../appsettings.Production.json | 89 -- absolute/src/APITemplate.Api/appsettings.json | 166 --- .../APITemplate.Application.csproj | 27 - .../Common/BackgroundJobs/ICleanupService.cs | 38 - .../BackgroundJobs/IEmailRetryService.cs | 28 - .../IExternalIntegrationSyncService.cs | 13 - .../Common/BackgroundJobs/IJobQueue.cs | 11 - .../BackgroundJobs/IOutgoingWebhookQueue.cs | 13 - .../Common/BackgroundJobs/IQueue.cs | 14 - .../Common/BackgroundJobs/IQueueReader.cs | 15 - .../IRecurringBackgroundJobRegistration.cs | 16 - .../Common/BackgroundJobs/IReindexService.cs | 13 - .../BackgroundJobs/IWebhookProcessingQueue.cs | 13 - .../RecurringBackgroundJobDefinition.cs | 23 - .../Common/Batch/BatchFailureContext.cs | 43 - .../Common/Batch/BatchFailureMerge.cs | 44 - .../Common/Batch/EntityLookup.cs | 7 - .../Common/Batch/IBatchRule.cs | 6 - .../Batch/Rules/FluentValidationBatchRule.cs | 29 - .../Batch/Rules/MarkMissingByIdBatchRule.cs | 23 - .../Common/Context/IActorProvider.cs | 11 - .../Common/Context/ITenantProvider.cs | 17 - .../Common/Contracts/IDateRangeFilter.cs | 14 - .../Common/Contracts/IFileStorageService.cs | 35 - .../Common/Contracts/IIdempotencyStore.cs | 47 - .../Common/Contracts/IProductRequest.cs | 14 - .../Common/Contracts/ISortableFilter.cs | 14 - .../Common/Contracts/IWebhookEventHandler.cs | 16 - .../Common/Contracts/IWebhookPayloadSigner.cs | 20 - .../Contracts/IWebhookPayloadValidator.cs | 14 - .../Common/DTOs/BatchDeleteRequest.cs | 12 - .../Common/DTOs/BatchResponse.cs | 16 - .../Common/DTOs/IHasFacets.cs | 11 - .../Common/DTOs/IPagedItems.cs | 11 - .../Common/DTOs/PaginationFilter.cs | 20 - .../Common/Email/EmailMessage.cs | 19 - .../Common/Email/EmailTemplateNames.cs | 12 - .../Common/Email/IEmailQueue.cs | 13 - .../Common/Email/IEmailSender.cs | 11 - .../Common/Email/IEmailTemplateRenderer.cs | 14 - .../Common/Email/IFailedEmailStore.cs | 14 - .../Common/Email/ISecureTokenGenerator.cs | 17 - .../Common/Errors/DomainErrors.cs | 191 --- .../Common/Errors/ErrorCatalog.cs | 108 -- .../Common/Events/CacheEvents.cs | 7 - .../Common/Events/CacheTags.cs | 15 - .../Common/Events/EmailEvents.cs | 27 - .../Common/Events/MessageBusExtensions.cs | 30 - .../Common/Events/SoftDeleteEvents.cs | 11 - .../Events/TenantInvitationEmailHandler.cs | 40 - .../Events/UserRegisteredEmailHandler.cs | 38 - .../Events/UserRoleChangedEmailHandler.cs | 35 - .../Common/Extensions/RepositoryExtensions.cs | 37 - .../Common/Http/RateLimitPolicies.cs | 9 - .../Common/Http/RequestContextConstants.cs | 46 - .../Middleware/ErrorOrValidationMiddleware.cs | 50 - .../Common/Options/AppOptions.cs | 9 - .../BackgroundJobs/BackgroundJobsOptions.cs | 13 - .../BackgroundJobs/CleanupJobOptions.cs | 15 - .../BackgroundJobs/EmailRetryJobOptions.cs | 15 - .../BackgroundJobs/ExternalSyncJobOptions.cs | 10 - .../BackgroundJobs/ReindexJobOptions.cs | 10 - .../BackgroundJobs/TickerQSchedulerOptions.cs | 15 - .../Common/Options/BootstrapTenantOptions.cs | 15 - .../Infrastructure/DragonflyOptions.cs | 22 - .../Options/Infrastructure/EmailOptions.cs | 20 - .../Infrastructure/FileStorageOptions.cs | 13 - .../Infrastructure/ObservabilityOptions.cs | 50 - .../TransactionDefaultsOptions.cs | 70 - .../Options/Infrastructure/WebhookOptions.cs | 16 - .../Common/Options/Security/BffOptions.cs | 16 - .../Common/Options/Security/CorsOptions.cs | 9 - .../Options/Security/KeycloakOptions.cs | 41 - .../Options/Security/RateLimitingOptions.cs | 15 - .../Options/Security/RedactionOptions.cs | 18 - .../Options/Security/SystemIdentityOptions.cs | 12 - .../Resilience/ResiliencePipelineKeys.cs | 14 - .../Common/Search/SearchDefaults.cs | 12 - .../Common/Security/AuthConstants.cs | 111 -- .../Common/Security/IKeycloakAdminService.cs | 20 - .../Common/Security/IRolePermissionMap.cs | 17 - .../Security/IUserProvisioningService.cs | 21 - .../Common/Security/Permission.cs | 114 -- .../Security/StaticRolePermissionMap.cs | 75 -- .../Common/Sorting/SortField.cs | 15 - .../Common/Sorting/SortFieldMap.cs | 65 - .../Common/Startup/IStartupTaskCoordinator.cs | 17 - .../Common/Startup/StartupTaskNames.cs | 12 - .../Validation/DataAnnotationsValidator.cs | 82 -- .../Validation/DateRangeFilterValidator.cs | 21 - .../Validation/FluentValidationExtensions.cs | 30 - .../Common/Validation/NotEmptyAttribute.cs | 30 - .../Validation/PaginationFilterValidator.cs | 9 - .../Validation/SortableFilterValidator.cs | 30 - .../Features/Bff/DTOs/BffUserResponse.cs | 12 - .../Features/Category/CategorySortFields.cs | 25 - .../Commands/CreateCategoriesCommand.cs | 57 - .../Commands/DeleteCategoriesCommand.cs | 58 - .../Commands/UpdateCategoriesCommand.cs | 76 -- .../Features/Category/DTOs/CategoryFilter.cs | 15 - .../Category/DTOs/CategoryResponse.cs | 11 - .../Category/DTOs/CreateCategoriesRequest.cs | 12 - .../Category/DTOs/CreateCategoryRequest.cs | 14 - .../DTOs/ProductCategoryStatsResponse.cs | 12 - .../Category/DTOs/UpdateCategoriesRequest.cs | 24 - .../Category/DTOs/UpdateCategoryRequest.cs | 6 - .../Category/Mappings/CategoryMappings.cs | 41 - .../Category/Queries/GetCategoriesQuery.cs | 25 - .../Category/Queries/GetCategoryByIdQuery.cs | 30 - .../Category/Queries/GetCategoryStatsQuery.cs | 27 - .../CategoriesByIdsSpecification.cs | 15 - .../CategoryByIdSpecification.cs | 20 - .../Specifications/CategoryFilterCriteria.cs | 39 - .../Specifications/CategorySpecification.cs | 20 - .../Validation/CategoryFilterValidator.cs | 17 - .../CreateCategoryRequestValidator.cs | 9 - .../Validation/UpdateCategoryItemValidator.cs | 8 - .../Commands/IdempotentCreateCommand.cs | 43 - .../Examples/Commands/PatchProductCommand.cs | 61 - .../Examples/Commands/SubmitJobCommand.cs | 43 - .../Examples/Commands/UploadFileCommand.cs | 71 - .../Examples/DTOs/DownloadFileRequest.cs | 6 - .../Examples/DTOs/FileUploadResponse.cs | 13 - .../Examples/DTOs/GetJobStatusRequest.cs | 6 - .../Examples/DTOs/IdempotentCreateRequest.cs | 12 - .../Examples/DTOs/IdempotentCreateResponse.cs | 11 - .../Examples/DTOs/JobStatusResponse.cs | 20 - .../Examples/DTOs/OutgoingWebhookDTOs.cs | 18 - .../Examples/DTOs/PatchableProductDto.cs | 23 - .../Examples/DTOs/SseNotificationItem.cs | 6 - .../Examples/DTOs/SseStreamRequest.cs | 12 - .../Examples/DTOs/SubmitJobRequest.cs | 13 - .../Examples/DTOs/UploadFileRequest.cs | 12 - .../Examples/DTOs/WebhookConstants.cs | 11 - .../Features/Examples/DTOs/WebhookPayload.cs | 8 - .../Examples/Mappings/JobResponseMapper.cs | 22 - .../Examples/Queries/DownloadFileQuery.cs | 38 - .../Examples/Queries/GetJobStatusQuery.cs | 23 - .../Queries/GetNotificationStreamQuery.cs | 36 - .../IdempotentCreateRequestValidator.cs | 10 - .../PatchableProductDtoValidator.cs | 17 - .../Validation/SubmitJobRequestValidator.cs | 9 - .../Product/Commands/CreateProductsCommand.cs | 83 -- .../Product/Commands/DeleteProductsCommand.cs | 62 - .../Product/Commands/UpdateProductsCommand.cs | 99 -- .../Commands/UpdateProductsValidator.cs | 72 - .../Product/DTOs/CreateProductRequest.cs | 17 - .../Product/DTOs/CreateProductsRequest.cs | 12 - .../Product/DTOs/ProductCategoryFacetValue.cs | 6 - .../Features/Product/DTOs/ProductFilter.cs | 22 - .../DTOs/ProductPriceFacetBucketResponse.cs | 11 - .../Features/Product/DTOs/ProductResponse.cs | 14 - .../DTOs/ProductSearchFacetsResponse.cs | 9 - .../Features/Product/DTOs/ProductsResponse.cs | 9 - .../Product/DTOs/UpdateProductRequest.cs | 17 - .../Product/DTOs/UpdateProductsRequest.cs | 27 - .../Product/Mappings/ProductMappings.cs | 31 - .../Features/Product/ProductSortFields.cs | 20 - .../Product/ProductValidationHelper.cs | 156 --- .../Product/Queries/GetProductByIdQuery.cs | 30 - .../Product/Queries/GetProductsQuery.cs | 26 - .../Repositories/IProductRepository.cs | 27 - .../ProductByIdSpecification.cs | 16 - .../ProductByIdWithLinksSpecification.cs | 15 - .../ProductCategoryFacetSpecification.cs | 17 - .../Specifications/ProductFilterCriteria.cs | 74 -- .../ProductPriceFacetSpecification.cs | 17 - .../Specifications/ProductSpecification.cs | 21 - .../ProductsByIdsWithLinksSpecification.cs | 18 - .../CreateProductRequestValidator.cs | 7 - .../Validation/ProductFilterValidator.cs | 36 - .../Validation/ProductRequestValidatorBase.cs | 38 - .../Validation/UpdateProductItemValidator.cs | 7 - .../UpdateProductRequestValidator.cs | 7 - .../Commands/CreateImageProductDataCommand.cs | 40 - .../Commands/CreateVideoProductDataCommand.cs | 40 - .../Commands/DeleteProductDataCommand.cs | 78 -- .../DTOs/CreateImageProductDataRequest.cs | 29 - .../DTOs/CreateVideoProductDataRequest.cs | 31 - .../ProductData/DTOs/ProductDataResponse.cs | 38 - .../ProductDataCascadeDeleteHandler.cs | 50 - .../Mappings/ProductDataMappings.cs | 64 - .../Queries/GetProductDataByIdQuery.cs | 28 - .../Queries/GetProductDataQuery.cs | 20 - .../CreateImageProductDataRequestValidator.cs | 9 - .../CreateVideoProductDataRequestValidator.cs | 9 - .../Commands/CreateProductReviewCommand.cs | 60 - .../Commands/DeleteProductReviewCommand.cs | 50 - .../DTOs/CreateProductReviewRequest.cs | 13 - .../ProductReview/DTOs/ProductReviewFilter.cs | 20 - .../DTOs/ProductReviewResponse.cs | 13 - .../Mappings/ProductReviewMappings.cs | 32 - .../ProductReview/ProductReviewSortFields.cs | 26 - .../Queries/GetProductReviewByIdQuery.cs | 23 - .../GetProductReviewsByProductIdQuery.cs | 24 - .../GetProductReviewsByProductIdsQuery.cs | 34 - .../Queries/GetProductReviewsQuery.cs | 26 - .../ProductReviewByProductIdSpecification.cs | 22 - .../ProductReviewByProductIdsSpecification.cs | 22 - .../ProductReviewFilterCriteria.cs | 39 - .../ProductReviewSpecification.cs | 23 - .../CreateProductReviewRequestValidator.cs | 9 - .../ProductReviewFilterValidator.cs | 37 - .../Tenant/Commands/CreateTenantCommand.cs | 48 - .../Tenant/Commands/DeleteTenantCommand.cs | 55 - .../Tenant/DTOs/CreateTenantRequest.cs | 11 - .../Features/Tenant/DTOs/TenantFilter.cs | 15 - .../Features/Tenant/DTOs/TenantResponse.cs | 12 - .../Tenant/Mappings/TenantMappings.cs | 31 - .../Tenant/Queries/GetTenantByIdQuery.cs | 29 - .../Tenant/Queries/GetTenantsQuery.cs | 25 - .../Specifications/TenantByIdSpecification.cs | 20 - .../Specifications/TenantFilterCriteria.cs | 38 - .../Specifications/TenantSpecification.cs | 23 - .../Features/Tenant/TenantSortFields.cs | 20 - .../CreateTenantRequestValidator.cs | 9 - .../Validation/TenantFilterValidator.cs | 20 - .../Commands/AcceptTenantInvitationCommand.cs | 46 - .../Commands/CreateTenantInvitationCommand.cs | 82 -- .../Commands/ResendTenantInvitationCommand.cs | 75 -- .../Commands/RevokeTenantInvitationCommand.cs | 39 - .../DTOs/AcceptInvitationRequest.cs | 8 - .../DTOs/CreateTenantInvitationRequest.cs | 11 - .../DTOs/TenantInvitationFilter.cs | 14 - .../DTOs/TenantInvitationResponse.cs | 14 - .../Mappings/TenantInvitationMappings.cs | 35 - .../Queries/GetTenantInvitationsQuery.cs | 25 - .../TenantInvitationFilterSpecification.cs | 49 - .../CreateTenantInvitationRequestValidator.cs | 10 - .../User/Commands/ChangeUserRoleCommand.cs | 54 - .../User/Commands/CreateUserCommand.cs | 93 -- .../User/Commands/DeleteUserCommand.cs | 56 - .../Commands/KeycloakPasswordResetCommand.cs | 41 - .../User/Commands/SetUserActiveCommand.cs | 43 - .../User/Commands/UpdateUserCommand.cs | 65 - .../User/DTOs/ChangeUserRoleRequest.cs | 8 - .../Features/User/DTOs/CreateUserRequest.cs | 12 - .../User/DTOs/RequestPasswordResetRequest.cs | 11 - .../Features/User/DTOs/UpdateUserRequest.cs | 12 - .../Features/User/DTOs/UserFilter.cs | 19 - .../Features/User/DTOs/UserResponse.cs | 15 - .../Features/User/Mappings/UserMappings.cs | 24 - .../Features/User/Queries/GetUserByIdQuery.cs | 28 - .../Features/User/Queries/GetUsersQuery.cs | 24 - .../UserByEmailSpecification.cs | 19 - .../Specifications/UserByIdSpecification.cs | 20 - .../UserByUsernameSpecification.cs | 18 - .../User/Specifications/UserFilterCriteria.cs | 35 - .../Specifications/UserFilterSpecification.cs | 24 - .../Features/User/UserSortFields.cs | 20 - .../Features/User/UserValidationHelper.cs | 34 - .../ChangeUserRoleRequestValidator.cs | 19 - .../Validation/CreateUserRequestValidator.cs | 9 - .../Validation/UpdateUserRequestValidator.cs | 9 - .../User/Validation/UserFilterValidator.cs | 25 - .../GlobalUsings.ApplicationFeatures.cs | 18 - .../APITemplate.Domain.csproj | 12 - .../Common/PagedResponse.cs | 18 - .../APITemplate.Domain/Entities/AppUser.cs | 60 - .../Entities/AuditDefaults.cs | 12 - .../APITemplate.Domain/Entities/AuditInfo.cs | 13 - .../APITemplate.Domain/Entities/Category.cs | 29 - .../Entities/Contracts/IAuditableEntity.cs | 10 - .../Contracts/IAuditableTenantEntity.cs | 7 - .../Entities/Contracts/IHasId.cs | 9 - .../Entities/Contracts/ISoftDeletable.cs | 12 - .../Entities/Contracts/ITenantEntity.cs | 10 - .../Entities/FailedEmail.cs | 25 - .../Entities/JobExecution.cs | 65 - .../APITemplate.Domain/Entities/Product.cs | 97 -- .../Entities/ProductCategoryStats.cs | 15 - .../Entities/ProductData/ImageProductData.cs | 18 - .../Entities/ProductData/ProductData.cs | 29 - .../Entities/ProductData/VideoProductData.cs | 18 - .../Entities/ProductDataLink.cs | 40 - .../Entities/ProductReview.cs | 34 - .../APITemplate.Domain/Entities/StoredFile.cs | 20 - .../src/APITemplate.Domain/Entities/Tenant.cs | 38 - .../Entities/TenantInvitation.cs | 25 - .../Enums/InvitationStatus.cs | 19 - .../src/APITemplate.Domain/Enums/JobStatus.cs | 19 - .../src/APITemplate.Domain/Enums/UserRole.cs | 16 - .../Exceptions/AppException.cs | 25 - .../Exceptions/ConflictException.cs | 14 - .../Exceptions/ForbiddenException.cs | 10 - .../Exceptions/NotFoundException.cs | 16 - .../Exceptions/UnauthorizedException.cs | 10 - .../Exceptions/ValidationException.cs | 14 - .../src/APITemplate.Domain/GlobalUsings.cs | 3 - .../Interfaces/ICategoryRepository.cs | 16 - .../Interfaces/IFailedEmailRepository.cs | 45 - .../Interfaces/IJobExecutionRepository.cs | 8 - .../Interfaces/IProductDataLinkRepository.cs | 43 - .../Interfaces/IProductDataRepository.cs | 42 - .../Interfaces/IProductReviewRepository.cs | 8 - .../Interfaces/IRepository.cs | 36 - .../Interfaces/IStoredFileRepository.cs | 8 - .../Interfaces/IStoredProcedure.cs | 28 - .../Interfaces/IStoredProcedureExecutor.cs | 32 - .../Interfaces/ITenantInvitationRepository.cs | 22 - .../Interfaces/ITenantRepository.cs | 14 - .../Interfaces/IUnitOfWork.cs | 68 - .../Interfaces/IUserRepository.cs | 18 - .../Options/TransactionOptions.cs | 27 - .../APITemplate.Infrastructure.csproj | 72 - .../Services/BoundedChannelQueue.cs | 32 - .../Services/ChannelJobQueue.cs | 16 - .../BackgroundJobs/Services/CleanupService.cs | 170 --- .../Services/EmailRetryService.cs | 165 --- .../ExternalIntegrationSyncServicePreview.cs | 30 - .../Services/ISoftDeleteCleanupStrategy.cs | 17 - .../JobProcessingBackgroundService.cs | 132 -- .../QueueConsumerBackgroundService.cs | 38 - .../BackgroundJobs/Services/ReindexService.cs | 94 -- .../Services/SoftDeleteCleanupStrategy.cs | 52 - .../DragonflyDistributedJobCoordinator.cs | 219 ---- .../IDistributedJobCoordinator.cs | 19 - .../TickerQ/Jobs/CleanupRecurringJob.cs | 68 - .../TickerQ/Jobs/EmailRetryRecurringJob.cs | 63 - .../TickerQ/Jobs/ExternalSyncRecurringJob.cs | 45 - .../TickerQ/Jobs/ReindexRecurringJob.cs | 44 - .../CleanupRecurringJobRegistration.cs | 21 - .../EmailRetryRecurringJobRegistration.cs | 21 - .../ExternalSyncRecurringJobRegistration.cs | 21 - .../ReindexRecurringJobRegistration.cs | 21 - .../TickerQ/TickerQFunctionNames.cs | 13 - .../BackgroundJobs/TickerQ/TickerQJobIds.cs | 13 - .../TickerQ/TickerQRecurringJobRegistrar.cs | 113 -- .../TickerQ/TickerQSchedulerDbContext.cs | 28 - .../TickerQSchedulerDbContextFactory.cs | 36 - .../BackgroundJobsOptionsValidator.cs | 165 --- .../claim_expired_failed_emails_v1_down.sql | 1 - .../claim_expired_failed_emails_v1_up.sql | 47 - .../claim_retryable_failed_emails_v1_down.sql | 1 - .../claim_retryable_failed_emails_v1_up.sql | 47 - .../get_fts_index_names_v1_down.sql | 1 - .../Procedures/get_fts_index_names_v1_up.sql | 8 - .../get_index_bloat_percent_v1_down.sql | 1 - .../get_index_bloat_percent_v1_up.sql | 23 - .../get_product_category_stats_v1_down.sql | 1 - .../get_product_category_stats_v1_up.sql | 24 - .../get_product_category_stats_v2_down.sql | 25 - .../get_product_category_stats_v2_up.sql | 26 - .../Database/SqlResource.cs | 27 - .../Triggers/row_version_triggers_v1_down.sql | 6 - .../Triggers/row_version_triggers_v1_up.sql | 59 - .../Email/ChannelEmailQueue.cs | 20 - .../Email/EmailSendingBackgroundService.cs | 66 - .../Email/FailedEmailErrorNormalizer.cs | 21 - .../Email/FailedEmailStore.cs | 89 -- .../Email/FluidEmailTemplateRenderer.cs | 61 - .../Email/MailKitEmailSender.cs | 52 - .../Email/Templates/tenant-invitation.liquid | 11 - .../Email/Templates/user-registration.liquid | 9 - .../Email/Templates/user-role-changed.liquid | 10 - .../FileStorage/LocalFileStorageService.cs | 110 -- .../GlobalUsings.cs | 18 - .../Health/HealthCheckNames.cs | 9 - .../Health/KeycloakHealthCheck.cs | 53 - .../Health/MongoDbHealthCheck.cs | 39 - .../DistributedCacheIdempotencyStore.cs | 91 -- .../Idempotency/IdempotencyStoreConstants.cs | 8 - .../Idempotency/InMemoryIdempotencyStore.cs | 93 -- .../Logging/ActivityTraceEnricher.cs | 35 - .../Logging/LogDataClassifications.cs | 43 - .../Logging/RedactionConfiguration.cs | 30 - .../20260302153430_AddCategory.Designer.cs | 174 --- .../Migrations/20260302153430_AddCategory.cs | 95 -- ..._AddMultiTenantAuditSoftDelete.Designer.cs | 534 -------- ...304124643_AddMultiTenantAuditSoftDelete.cs | 617 --------- ...56_AddUserRoleForPlatformAdmin.Designer.cs | 644 --------- ...60304174656_AddUserRoleForPlatformAdmin.cs | 30 - ...2_AddNormalizedUsernameForAuth.Designer.cs | 651 --------- ...0304181202_AddNormalizedUsernameForAuth.cs | 76 -- ..._AddPostgresRowVersionTriggers.Designer.cs | 651 --------- ...304185009_AddPostgresRowVersionTriggers.cs | 23 - ...malizedUsernameUniquePerTenant.Designer.cs | 649 --------- ...1_MakeNormalizedUsernameUniquePerTenant.cs | 47 - ...iggersForAppManagedConcurrency.Designer.cs | 644 --------- ...VersionTriggersForAppManagedConcurrency.cs | 23 - ...0260305184129_SyncModelChanges.Designer.cs | 652 --------- .../20260305184129_SyncModelChanges.cs | 85 -- ...06210000_RenameUserRoleTenantUserToUser.cs | 50 - ...224502_SwitchToXminConcurrency.Designer.cs | 657 ---------- .../20260306224502_SwitchToXminConcurrency.cs | 73 -- ...7_ChangeAuditActorFieldsToGuid.Designer.cs | 632 --------- ...0306235337_ChangeAuditActorFieldsToGuid.cs | 199 --- ...0307174126_AddProductDataLinks.Designer.cs | 660 ---------- .../20260307174126_AddProductDataLinks.cs | 45 - ...eteProductDataLinksAndMetadata.Designer.cs | 730 ----------- ...6_SoftDeleteProductDataLinksAndMetadata.cs | 186 --- ...82543_AddFullTextSearchIndexes.Designer.cs | 740 ----------- ...20260308182543_AddFullTextSearchIndexes.cs | 40 - ...812_AddNormalizedEmailForUsers.Designer.cs | 745 ----------- ...260310000812_AddNormalizedEmailForUsers.cs | 62 - ...enantInvitationNormalizedEmail.Designer.cs | 972 -------------- ...0709_AddTenantInvitationNormalizedEmail.cs | 128 -- ...ePasswordHashAddKeycloakUserId.Designer.cs | 975 -------------- ...556_RemovePasswordHashAddKeycloakUserId.cs | 58 - ...8_DropPasswordResetTokensTable.Designer.cs | 866 ------------ ...0315014428_DropPasswordResetTokensTable.cs | 73 -- ...20260316105703_AddFailedEmails.Designer.cs | 909 ------------- .../20260316105703_AddFailedEmails.cs | 65 - ...833_AddFailedEmailTemplateName.Designer.cs | 913 ------------- ...260316144833_AddFailedEmailTemplateName.cs | 28 - ..._AddFailedEmailExpirationIndex.Designer.cs | 915 ------------- ...316202617_AddFailedEmailExpirationIndex.cs | 29 - ...316220129_AddFailedEmailClaims.Designer.cs | 925 ------------- .../20260316220129_AddFailedEmailClaims.cs | 97 -- ...ceduresForReindexAndEmailClaim.Designer.cs | 925 ------------- ...StoredProceduresForReindexAndEmailClaim.cs | 39 - ...620_AddExampleEndpointEntities.Designer.cs | 1158 ---------------- ...260318201620_AddExampleEndpointEntities.cs | 218 ---- ...4_AddCallbackUrlToJobExecution.Designer.cs | 1162 ----------------- ...0318232224_AddCallbackUrlToJobExecution.cs | 28 - .../Migrations/AppDbContextModelSnapshot.cs | 1159 ---------------- .../M001_CreateProductDataIndexes.cs | 39 - .../M002_AddProductDataSoftDeleteIndexes.cs | 36 - ...13820_AddTickerQSchedulerStore.Designer.cs | 240 ---- ...20260316213820_AddTickerQSchedulerStore.cs | 226 ---- .../TickerQSchedulerDbContextModelSnapshot.cs | 237 ---- .../Observability/ApiMetrics.cs | 59 - .../Observability/AuthTelemetry.cs | 108 -- .../Observability/CacheTelemetry.cs | 123 -- .../Observability/ConflictTelemetry.cs | 44 - .../Observability/GraphQlTelemetry.cs | 119 -- .../HealthCheckMetricsPublisher.cs | 54 - .../Observability/HttpRouteResolver.cs | 54 - .../Observability/ObservabilityConventions.cs | 271 ---- .../Observability/StartupTelemetry.cs | 83 -- .../Observability/StoredProcedureTelemetry.cs | 88 -- .../TelemetryApiSurfaceResolver.cs | 33 - .../Observability/ValidationTelemetry.cs | 70 - .../Persistence/AppDbContext.cs | 220 ---- .../Persistence/AppDbContextFactory.cs | 92 -- .../Auditing/AuditableEntityStateManager.cs | 87 -- .../Auditing/IAuditableEntityStateManager.cs | 32 - .../Persistence/AuthBootstrapSeeder.cs | 104 -- .../Configurations/AppUserConfiguration.cs | 59 - .../Configurations/CategoryConfiguration.cs | 31 - .../FailedEmailConfiguration.cs | 47 - .../JobExecutionConfiguration.cs | 44 - .../ProductCategoryStatsConfiguration.cs | 23 - .../Configurations/ProductConfiguration.cs | 39 - .../ProductDataLinkConfiguration.cs | 28 - .../ProductReviewConfiguration.cs | 39 - .../Configurations/StoredFileConfiguration.cs | 32 - ...tAuditableEntityConfigurationExtensions.cs | 75 -- .../Configurations/TenantConfiguration.cs | 24 - .../TenantInvitationConfiguration.cs | 43 - .../DesignTimeConfigurationHelper.cs | 20 - .../Persistence/DesignTimeDefaults.cs | 7 - .../AppUserEntityNormalizationService.cs | 23 - .../IEntityNormalizationService.cs | 13 - .../Persistence/MongoDbContext.cs | 38 - .../Persistence/MongoDbSettings.cs | 11 - .../SoftDelete/ISoftDeleteCascadeRule.cs | 28 - .../SoftDelete/ISoftDeleteProcessor.cs | 25 - .../ProductSoftDeleteCascadeRule.cs | 56 - .../SoftDelete/SoftDeleteProcessor.cs | 82 -- .../SoftDelete/TenantSoftDeleteCascadeRule.cs | 57 - ...tgresAdvisoryLockStartupTaskCoordinator.cs | 119 -- .../DbContextCommandTimeoutScope.cs | 61 - .../DbContextTrackedStateManager.cs | 57 - .../UnitOfWork/EfCoreTransactionProvider.cs | 27 - .../UnitOfWork/IDbTransactionProvider.cs | 24 - .../UnitOfWork/ManagedTransactionScope.cs | 32 - .../Persistence/UnitOfWork/UnitOfWork.cs | 313 ----- .../UnitOfWorkExecutionStrategyFactory.cs | 37 - .../Persistence/UnitOfWork/UnitOfWorkLogs.cs | 109 -- .../Repositories/CategoryRepository.cs | 44 - .../Repositories/FailedEmailRepository.cs | 86 -- .../Repositories/JobExecutionRepository.cs | 13 - .../Pagination/PagedProjectionBuilder.cs | 55 - .../Repositories/Pagination/PagedRow.cs | 7 - .../Repositories/ProductDataLinkRepository.cs | 96 -- .../Repositories/ProductDataRepository.cs | 126 -- .../Repositories/ProductRepository.cs | 123 -- .../Repositories/ProductReviewRepository.cs | 14 - .../Repositories/RepositoryBase.cs | 157 --- .../Repositories/StoredFileRepository.cs | 13 - .../TenantInvitationRepository.cs | 38 - .../Repositories/TenantRepository.cs | 65 - .../Repositories/UserRepository.cs | 24 - .../Security/DragonflyTicketStore.cs | 98 -- .../Security/HttpActorProvider.cs | 43 - .../Security/HttpTenantProvider.cs | 37 - .../Keycloak/CookieSessionRefresher.cs | 199 --- .../Security/Keycloak/KeycloakAdminService.cs | 170 --- .../Keycloak/KeycloakAdminTokenHandler.cs | 32 - .../Keycloak/KeycloakAdminTokenProvider.cs | 114 -- .../Security/Keycloak/KeycloakClaimMapper.cs | 48 - .../Keycloak/KeycloakTokenResponse.cs | 10 - .../Security/Keycloak/KeycloakUrlHelper.cs | 22 - .../Security/SecureTokenGenerator.cs | 26 - .../Security/Tenant/TenantClaimValidator.cs | 166 --- .../Tenant/TenantClaimValidatorLogs.cs | 49 - .../Tenant/UserProvisioningService.cs | 113 -- .../ClaimExpiredFailedEmailsProcedure.cs | 21 - .../ClaimRetryableFailedEmailsProcedure.cs | 21 - .../GetFtsIndexNamesProcedure.cs | 13 - .../GetIndexBloatPercentProcedure.cs | 14 - .../GetProductCategoryStatsProcedure.cs | 20 - .../StoredProcedureExecutor.cs | 54 - .../Webhooks/ChannelOutgoingWebhookQueue.cs | 20 - .../Webhooks/ChannelWebhookQueue.cs | 20 - .../Webhooks/HmacHelper.cs | 21 - .../Webhooks/HmacWebhookPayloadSigner.cs | 32 - .../Webhooks/HmacWebhookPayloadValidator.cs | 55 - .../Webhooks/LoggingWebhookEventHandler.cs | 33 - .../OutgoingWebhookBackgroundService.cs | 69 - .../WebhookProcessingBackgroundService.cs | 78 -- 599 files changed, 45979 deletions(-) delete mode 100644 absolute/README.md delete mode 100644 absolute/src/APITemplate.Api/APITemplate.Api.csproj delete mode 100644 absolute/src/APITemplate.Api/APITemplate.http delete mode 100644 absolute/src/APITemplate.Api/Api/Authorization/PermissionAuthorizationHandler.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Authorization/PermissionPolicyProvider.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Authorization/PermissionRequirement.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Authorization/RequirePermissionAttribute.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Cache/CacheInvalidationHandler.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Cache/CachingOptions.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Cache/IOutputCacheInvalidationService.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Cache/OutputCacheInvalidationService.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Cache/RedisInstanceNames.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Cache/TenantAwareOutputCachePolicy.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Controllers/ApiControllerBase.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Controllers/V1/BffController.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Controllers/V1/CategoriesController.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Controllers/V1/IdempotentController.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Controllers/V1/JobsController.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Controllers/V1/PatchController.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Controllers/V1/ProductDataController.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Controllers/V1/ProductReviewsController.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Controllers/V1/ProductsController.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Controllers/V1/SseController.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Controllers/V1/TenantInvitationsController.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Controllers/V1/TenantsController.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Controllers/V1/UsersController.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Controllers/V1/WebhooksController.cs delete mode 100644 absolute/src/APITemplate.Api/Api/ErrorOrMapping/ErrorOrExtensions.cs delete mode 100644 absolute/src/APITemplate.Api/Api/ExceptionHandling/ApiExceptionHandler.cs delete mode 100644 absolute/src/APITemplate.Api/Api/ExceptionHandling/ApiExceptionHandlerLogs.cs delete mode 100644 absolute/src/APITemplate.Api/Api/ExceptionHandling/ApiProblemDetailsOptions.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Filters/Idempotency/IdempotencyActionFilter.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Filters/Idempotency/IdempotencyConstants.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Filters/Idempotency/IdempotentAttribute.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Filters/Validation/FluentValidationActionFilter.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Filters/Webhooks/ValidateWebhookSignatureAttribute.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Filters/Webhooks/WebhookSignatureResourceFilter.cs delete mode 100644 absolute/src/APITemplate.Api/Api/GraphQL/DataLoaders/ProductReviewsByProductDataLoader.cs delete mode 100644 absolute/src/APITemplate.Api/Api/GraphQL/ErrorOrGraphQLExtensions.cs delete mode 100644 absolute/src/APITemplate.Api/Api/GraphQL/Instrumentation/GraphQlExecutionMetricsListener.cs delete mode 100644 absolute/src/APITemplate.Api/Api/GraphQL/Models/CategoryPageResult.cs delete mode 100644 absolute/src/APITemplate.Api/Api/GraphQL/Models/CategoryQueryInput.cs delete mode 100644 absolute/src/APITemplate.Api/Api/GraphQL/Models/ProductPageResult.cs delete mode 100644 absolute/src/APITemplate.Api/Api/GraphQL/Models/ProductQueryInput.cs delete mode 100644 absolute/src/APITemplate.Api/Api/GraphQL/Models/ProductReviewPageResult.cs delete mode 100644 absolute/src/APITemplate.Api/Api/GraphQL/Models/ProductReviewQueryInput.cs delete mode 100644 absolute/src/APITemplate.Api/Api/GraphQL/Mutations/ProductMutations.cs delete mode 100644 absolute/src/APITemplate.Api/Api/GraphQL/Mutations/ProductReviewMutations.cs delete mode 100644 absolute/src/APITemplate.Api/Api/GraphQL/Queries/CategoryQueries.cs delete mode 100644 absolute/src/APITemplate.Api/Api/GraphQL/Queries/ProductQueries.cs delete mode 100644 absolute/src/APITemplate.Api/Api/GraphQL/Queries/ProductReviewQueries.cs delete mode 100644 absolute/src/APITemplate.Api/Api/GraphQL/Types/ProductReviewType.cs delete mode 100644 absolute/src/APITemplate.Api/Api/GraphQL/Types/ProductType.cs delete mode 100644 absolute/src/APITemplate.Api/Api/GraphQL/Types/ProductTypeResolvers.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Middleware/CsrfValidationMiddleware.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Middleware/RequestContextMiddleware.cs delete mode 100644 absolute/src/APITemplate.Api/Api/OpenApi/AuthorizationResponsesOperationTransformer.cs delete mode 100644 absolute/src/APITemplate.Api/Api/OpenApi/BearerSecuritySchemeDocumentTransformer.cs delete mode 100644 absolute/src/APITemplate.Api/Api/OpenApi/HealthCheckOpenApiDocumentTransformer.cs delete mode 100644 absolute/src/APITemplate.Api/Api/OpenApi/OpenApiErrorResponseHelper.cs delete mode 100644 absolute/src/APITemplate.Api/Api/OpenApi/ProblemDetailsOpenApiTransformer.cs delete mode 100644 absolute/src/APITemplate.Api/Api/Requests/FileUploadRequest.cs delete mode 100644 absolute/src/APITemplate.Api/Dockerfile delete mode 100644 absolute/src/APITemplate.Api/Extensions/ApiServiceCollectionExtensions.cs delete mode 100644 absolute/src/APITemplate.Api/Extensions/AuthenticationServiceCollectionExtensions.cs delete mode 100644 absolute/src/APITemplate.Api/Extensions/BackgroundJobsServiceCollectionExtensions.cs delete mode 100644 absolute/src/APITemplate.Api/Extensions/Configuration/ConfigurationExtensions.cs delete mode 100644 absolute/src/APITemplate.Api/Extensions/Configuration/ConfigurationSections.cs delete mode 100644 absolute/src/APITemplate.Api/Extensions/Configuration/KeycloakStartupLogs.cs delete mode 100644 absolute/src/APITemplate.Api/Extensions/Configuration/ServiceCollectionOptionsExtensions.cs delete mode 100644 absolute/src/APITemplate.Api/Extensions/ControllerExtensions.cs delete mode 100644 absolute/src/APITemplate.Api/Extensions/EmailServiceCollectionExtensions.cs delete mode 100644 absolute/src/APITemplate.Api/Extensions/GraphQLServiceCollectionExtensions.cs delete mode 100644 absolute/src/APITemplate.Api/Extensions/InfrastructureServiceCollectionExtensions.cs delete mode 100644 absolute/src/APITemplate.Api/Extensions/KeycloakAdminServiceCollectionExtensions.cs delete mode 100644 absolute/src/APITemplate.Api/Extensions/ObservabilityServiceCollectionExtensions.cs delete mode 100644 absolute/src/APITemplate.Api/Extensions/PersistenceServiceCollectionExtensions.cs delete mode 100644 absolute/src/APITemplate.Api/Extensions/Resilience/ResilienceDefaults.cs delete mode 100644 absolute/src/APITemplate.Api/Extensions/ServiceCollectionExtensions.cs delete mode 100644 absolute/src/APITemplate.Api/Extensions/Startup/ApplicationBuilderExtensions.cs delete mode 100644 absolute/src/APITemplate.Api/Extensions/Startup/LoggingExtensions.cs delete mode 100644 absolute/src/APITemplate.Api/Extensions/WebhookServiceCollectionExtensions.cs delete mode 100644 absolute/src/APITemplate.Api/Extensions/WolverineHandlerChainExtensions.cs delete mode 100644 absolute/src/APITemplate.Api/Extensions/WolverineTypeExtensions.cs delete mode 100644 absolute/src/APITemplate.Api/GlobalUsings.cs delete mode 100644 absolute/src/APITemplate.Api/Program.cs delete mode 100644 absolute/src/APITemplate.Api/Properties/launchSettings.json delete mode 100644 absolute/src/APITemplate.Api/appsettings.Production.json delete mode 100644 absolute/src/APITemplate.Api/appsettings.json delete mode 100644 absolute/src/APITemplate.Application/APITemplate.Application.csproj delete mode 100644 absolute/src/APITemplate.Application/Common/BackgroundJobs/ICleanupService.cs delete mode 100644 absolute/src/APITemplate.Application/Common/BackgroundJobs/IEmailRetryService.cs delete mode 100644 absolute/src/APITemplate.Application/Common/BackgroundJobs/IExternalIntegrationSyncService.cs delete mode 100644 absolute/src/APITemplate.Application/Common/BackgroundJobs/IJobQueue.cs delete mode 100644 absolute/src/APITemplate.Application/Common/BackgroundJobs/IOutgoingWebhookQueue.cs delete mode 100644 absolute/src/APITemplate.Application/Common/BackgroundJobs/IQueue.cs delete mode 100644 absolute/src/APITemplate.Application/Common/BackgroundJobs/IQueueReader.cs delete mode 100644 absolute/src/APITemplate.Application/Common/BackgroundJobs/IRecurringBackgroundJobRegistration.cs delete mode 100644 absolute/src/APITemplate.Application/Common/BackgroundJobs/IReindexService.cs delete mode 100644 absolute/src/APITemplate.Application/Common/BackgroundJobs/IWebhookProcessingQueue.cs delete mode 100644 absolute/src/APITemplate.Application/Common/BackgroundJobs/RecurringBackgroundJobDefinition.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Batch/BatchFailureContext.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Batch/BatchFailureMerge.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Batch/EntityLookup.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Batch/IBatchRule.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Batch/Rules/FluentValidationBatchRule.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Batch/Rules/MarkMissingByIdBatchRule.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Context/IActorProvider.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Context/ITenantProvider.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Contracts/IDateRangeFilter.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Contracts/IFileStorageService.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Contracts/IIdempotencyStore.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Contracts/IProductRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Contracts/ISortableFilter.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Contracts/IWebhookEventHandler.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Contracts/IWebhookPayloadSigner.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Contracts/IWebhookPayloadValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Common/DTOs/BatchDeleteRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Common/DTOs/BatchResponse.cs delete mode 100644 absolute/src/APITemplate.Application/Common/DTOs/IHasFacets.cs delete mode 100644 absolute/src/APITemplate.Application/Common/DTOs/IPagedItems.cs delete mode 100644 absolute/src/APITemplate.Application/Common/DTOs/PaginationFilter.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Email/EmailMessage.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Email/EmailTemplateNames.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Email/IEmailQueue.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Email/IEmailSender.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Email/IEmailTemplateRenderer.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Email/IFailedEmailStore.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Email/ISecureTokenGenerator.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Errors/DomainErrors.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Errors/ErrorCatalog.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Events/CacheEvents.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Events/CacheTags.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Events/EmailEvents.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Events/MessageBusExtensions.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Events/SoftDeleteEvents.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Events/TenantInvitationEmailHandler.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Events/UserRegisteredEmailHandler.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Events/UserRoleChangedEmailHandler.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Extensions/RepositoryExtensions.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Http/RateLimitPolicies.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Http/RequestContextConstants.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Middleware/ErrorOrValidationMiddleware.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Options/AppOptions.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Options/BackgroundJobs/BackgroundJobsOptions.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Options/BackgroundJobs/CleanupJobOptions.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Options/BackgroundJobs/EmailRetryJobOptions.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Options/BackgroundJobs/ExternalSyncJobOptions.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Options/BackgroundJobs/ReindexJobOptions.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Options/BackgroundJobs/TickerQSchedulerOptions.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Options/BootstrapTenantOptions.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Options/Infrastructure/DragonflyOptions.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Options/Infrastructure/EmailOptions.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Options/Infrastructure/FileStorageOptions.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Options/Infrastructure/ObservabilityOptions.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Options/Infrastructure/TransactionDefaultsOptions.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Options/Infrastructure/WebhookOptions.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Options/Security/BffOptions.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Options/Security/CorsOptions.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Options/Security/KeycloakOptions.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Options/Security/RateLimitingOptions.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Options/Security/RedactionOptions.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Options/Security/SystemIdentityOptions.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Resilience/ResiliencePipelineKeys.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Search/SearchDefaults.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Security/AuthConstants.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Security/IKeycloakAdminService.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Security/IRolePermissionMap.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Security/IUserProvisioningService.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Security/Permission.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Security/StaticRolePermissionMap.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Sorting/SortField.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Sorting/SortFieldMap.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Startup/IStartupTaskCoordinator.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Startup/StartupTaskNames.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Validation/DataAnnotationsValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Validation/DateRangeFilterValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Validation/FluentValidationExtensions.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Validation/NotEmptyAttribute.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Validation/PaginationFilterValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Common/Validation/SortableFilterValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Bff/DTOs/BffUserResponse.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Category/CategorySortFields.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Category/Commands/CreateCategoriesCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Category/Commands/DeleteCategoriesCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Category/Commands/UpdateCategoriesCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Category/DTOs/CategoryFilter.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Category/DTOs/CategoryResponse.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Category/DTOs/CreateCategoriesRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Category/DTOs/CreateCategoryRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Category/DTOs/ProductCategoryStatsResponse.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Category/DTOs/UpdateCategoriesRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Category/DTOs/UpdateCategoryRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Category/Mappings/CategoryMappings.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Category/Queries/GetCategoriesQuery.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Category/Queries/GetCategoryByIdQuery.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Category/Queries/GetCategoryStatsQuery.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Category/Specifications/CategoriesByIdsSpecification.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Category/Specifications/CategoryByIdSpecification.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Category/Specifications/CategoryFilterCriteria.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Category/Specifications/CategorySpecification.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Category/Validation/CategoryFilterValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Category/Validation/CreateCategoryRequestValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Category/Validation/UpdateCategoryItemValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/Commands/IdempotentCreateCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/Commands/PatchProductCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/Commands/SubmitJobCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/Commands/UploadFileCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/DTOs/DownloadFileRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/DTOs/FileUploadResponse.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/DTOs/GetJobStatusRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/DTOs/IdempotentCreateRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/DTOs/IdempotentCreateResponse.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/DTOs/JobStatusResponse.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/DTOs/OutgoingWebhookDTOs.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/DTOs/PatchableProductDto.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/DTOs/SseNotificationItem.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/DTOs/SseStreamRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/DTOs/SubmitJobRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/DTOs/UploadFileRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/DTOs/WebhookConstants.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/DTOs/WebhookPayload.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/Mappings/JobResponseMapper.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/Queries/DownloadFileQuery.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/Queries/GetJobStatusQuery.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/Queries/GetNotificationStreamQuery.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/Validation/IdempotentCreateRequestValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/Validation/PatchableProductDtoValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Examples/Validation/SubmitJobRequestValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/Commands/CreateProductsCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/Commands/DeleteProductsCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/Commands/UpdateProductsCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/Commands/UpdateProductsValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/DTOs/CreateProductRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/DTOs/CreateProductsRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/DTOs/ProductCategoryFacetValue.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/DTOs/ProductFilter.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/DTOs/ProductPriceFacetBucketResponse.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/DTOs/ProductResponse.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/DTOs/ProductSearchFacetsResponse.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/DTOs/ProductsResponse.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/DTOs/UpdateProductRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/DTOs/UpdateProductsRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/Mappings/ProductMappings.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/ProductSortFields.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/ProductValidationHelper.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/Queries/GetProductByIdQuery.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/Queries/GetProductsQuery.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/Repositories/IProductRepository.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/Specifications/ProductByIdSpecification.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/Specifications/ProductByIdWithLinksSpecification.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/Specifications/ProductCategoryFacetSpecification.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/Specifications/ProductFilterCriteria.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/Specifications/ProductPriceFacetSpecification.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/Specifications/ProductSpecification.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/Specifications/ProductsByIdsWithLinksSpecification.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/Validation/CreateProductRequestValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/Validation/ProductFilterValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/Validation/ProductRequestValidatorBase.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/Validation/UpdateProductItemValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Product/Validation/UpdateProductRequestValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductData/Commands/CreateImageProductDataCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductData/Commands/CreateVideoProductDataCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductData/Commands/DeleteProductDataCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductData/DTOs/CreateImageProductDataRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductData/DTOs/CreateVideoProductDataRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductData/DTOs/ProductDataResponse.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductData/Handlers/ProductDataCascadeDeleteHandler.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductData/Mappings/ProductDataMappings.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductData/Queries/GetProductDataByIdQuery.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductData/Queries/GetProductDataQuery.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductData/Validation/CreateImageProductDataRequestValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductData/Validation/CreateVideoProductDataRequestValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductReview/Commands/CreateProductReviewCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductReview/Commands/DeleteProductReviewCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductReview/DTOs/CreateProductReviewRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductReview/DTOs/ProductReviewFilter.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductReview/DTOs/ProductReviewResponse.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductReview/Mappings/ProductReviewMappings.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductReview/ProductReviewSortFields.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewByIdQuery.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewsByProductIdQuery.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewsByProductIdsQuery.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewsQuery.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewByProductIdSpecification.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewByProductIdsSpecification.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewFilterCriteria.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewSpecification.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductReview/Validation/CreateProductReviewRequestValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Features/ProductReview/Validation/ProductReviewFilterValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Tenant/Commands/CreateTenantCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Tenant/Commands/DeleteTenantCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Tenant/DTOs/CreateTenantRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Tenant/DTOs/TenantFilter.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Tenant/DTOs/TenantResponse.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Tenant/Mappings/TenantMappings.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Tenant/Queries/GetTenantByIdQuery.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Tenant/Queries/GetTenantsQuery.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Tenant/Specifications/TenantByIdSpecification.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Tenant/Specifications/TenantFilterCriteria.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Tenant/Specifications/TenantSpecification.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Tenant/TenantSortFields.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Tenant/Validation/CreateTenantRequestValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Features/Tenant/Validation/TenantFilterValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Features/TenantInvitation/Commands/AcceptTenantInvitationCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/TenantInvitation/Commands/CreateTenantInvitationCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/TenantInvitation/Commands/ResendTenantInvitationCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/TenantInvitation/Commands/RevokeTenantInvitationCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/TenantInvitation/DTOs/AcceptInvitationRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Features/TenantInvitation/DTOs/CreateTenantInvitationRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Features/TenantInvitation/DTOs/TenantInvitationFilter.cs delete mode 100644 absolute/src/APITemplate.Application/Features/TenantInvitation/DTOs/TenantInvitationResponse.cs delete mode 100644 absolute/src/APITemplate.Application/Features/TenantInvitation/Mappings/TenantInvitationMappings.cs delete mode 100644 absolute/src/APITemplate.Application/Features/TenantInvitation/Queries/GetTenantInvitationsQuery.cs delete mode 100644 absolute/src/APITemplate.Application/Features/TenantInvitation/Specifications/TenantInvitationFilterSpecification.cs delete mode 100644 absolute/src/APITemplate.Application/Features/TenantInvitation/Validation/CreateTenantInvitationRequestValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/Commands/ChangeUserRoleCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/Commands/CreateUserCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/Commands/DeleteUserCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/Commands/KeycloakPasswordResetCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/Commands/SetUserActiveCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/Commands/UpdateUserCommand.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/DTOs/ChangeUserRoleRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/DTOs/CreateUserRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/DTOs/RequestPasswordResetRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/DTOs/UpdateUserRequest.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/DTOs/UserFilter.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/DTOs/UserResponse.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/Mappings/UserMappings.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/Queries/GetUserByIdQuery.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/Queries/GetUsersQuery.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/Specifications/UserByEmailSpecification.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/Specifications/UserByIdSpecification.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/Specifications/UserByUsernameSpecification.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/Specifications/UserFilterCriteria.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/Specifications/UserFilterSpecification.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/UserSortFields.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/UserValidationHelper.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/Validation/ChangeUserRoleRequestValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/Validation/CreateUserRequestValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/Validation/UpdateUserRequestValidator.cs delete mode 100644 absolute/src/APITemplate.Application/Features/User/Validation/UserFilterValidator.cs delete mode 100644 absolute/src/APITemplate.Application/GlobalUsings.ApplicationFeatures.cs delete mode 100644 absolute/src/APITemplate.Domain/APITemplate.Domain.csproj delete mode 100644 absolute/src/APITemplate.Domain/Common/PagedResponse.cs delete mode 100644 absolute/src/APITemplate.Domain/Entities/AppUser.cs delete mode 100644 absolute/src/APITemplate.Domain/Entities/AuditDefaults.cs delete mode 100644 absolute/src/APITemplate.Domain/Entities/AuditInfo.cs delete mode 100644 absolute/src/APITemplate.Domain/Entities/Category.cs delete mode 100644 absolute/src/APITemplate.Domain/Entities/Contracts/IAuditableEntity.cs delete mode 100644 absolute/src/APITemplate.Domain/Entities/Contracts/IAuditableTenantEntity.cs delete mode 100644 absolute/src/APITemplate.Domain/Entities/Contracts/IHasId.cs delete mode 100644 absolute/src/APITemplate.Domain/Entities/Contracts/ISoftDeletable.cs delete mode 100644 absolute/src/APITemplate.Domain/Entities/Contracts/ITenantEntity.cs delete mode 100644 absolute/src/APITemplate.Domain/Entities/FailedEmail.cs delete mode 100644 absolute/src/APITemplate.Domain/Entities/JobExecution.cs delete mode 100644 absolute/src/APITemplate.Domain/Entities/Product.cs delete mode 100644 absolute/src/APITemplate.Domain/Entities/ProductCategoryStats.cs delete mode 100644 absolute/src/APITemplate.Domain/Entities/ProductData/ImageProductData.cs delete mode 100644 absolute/src/APITemplate.Domain/Entities/ProductData/ProductData.cs delete mode 100644 absolute/src/APITemplate.Domain/Entities/ProductData/VideoProductData.cs delete mode 100644 absolute/src/APITemplate.Domain/Entities/ProductDataLink.cs delete mode 100644 absolute/src/APITemplate.Domain/Entities/ProductReview.cs delete mode 100644 absolute/src/APITemplate.Domain/Entities/StoredFile.cs delete mode 100644 absolute/src/APITemplate.Domain/Entities/Tenant.cs delete mode 100644 absolute/src/APITemplate.Domain/Entities/TenantInvitation.cs delete mode 100644 absolute/src/APITemplate.Domain/Enums/InvitationStatus.cs delete mode 100644 absolute/src/APITemplate.Domain/Enums/JobStatus.cs delete mode 100644 absolute/src/APITemplate.Domain/Enums/UserRole.cs delete mode 100644 absolute/src/APITemplate.Domain/Exceptions/AppException.cs delete mode 100644 absolute/src/APITemplate.Domain/Exceptions/ConflictException.cs delete mode 100644 absolute/src/APITemplate.Domain/Exceptions/ForbiddenException.cs delete mode 100644 absolute/src/APITemplate.Domain/Exceptions/NotFoundException.cs delete mode 100644 absolute/src/APITemplate.Domain/Exceptions/UnauthorizedException.cs delete mode 100644 absolute/src/APITemplate.Domain/Exceptions/ValidationException.cs delete mode 100644 absolute/src/APITemplate.Domain/GlobalUsings.cs delete mode 100644 absolute/src/APITemplate.Domain/Interfaces/ICategoryRepository.cs delete mode 100644 absolute/src/APITemplate.Domain/Interfaces/IFailedEmailRepository.cs delete mode 100644 absolute/src/APITemplate.Domain/Interfaces/IJobExecutionRepository.cs delete mode 100644 absolute/src/APITemplate.Domain/Interfaces/IProductDataLinkRepository.cs delete mode 100644 absolute/src/APITemplate.Domain/Interfaces/IProductDataRepository.cs delete mode 100644 absolute/src/APITemplate.Domain/Interfaces/IProductReviewRepository.cs delete mode 100644 absolute/src/APITemplate.Domain/Interfaces/IRepository.cs delete mode 100644 absolute/src/APITemplate.Domain/Interfaces/IStoredFileRepository.cs delete mode 100644 absolute/src/APITemplate.Domain/Interfaces/IStoredProcedure.cs delete mode 100644 absolute/src/APITemplate.Domain/Interfaces/IStoredProcedureExecutor.cs delete mode 100644 absolute/src/APITemplate.Domain/Interfaces/ITenantInvitationRepository.cs delete mode 100644 absolute/src/APITemplate.Domain/Interfaces/ITenantRepository.cs delete mode 100644 absolute/src/APITemplate.Domain/Interfaces/IUnitOfWork.cs delete mode 100644 absolute/src/APITemplate.Domain/Interfaces/IUserRepository.cs delete mode 100644 absolute/src/APITemplate.Domain/Options/TransactionOptions.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/APITemplate.Infrastructure.csproj delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/BoundedChannelQueue.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/ChannelJobQueue.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/CleanupService.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/EmailRetryService.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/ExternalIntegrationSyncServicePreview.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/ISoftDeleteCleanupStrategy.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/JobProcessingBackgroundService.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/QueueConsumerBackgroundService.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/ReindexService.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/SoftDeleteCleanupStrategy.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Coordination/DragonflyDistributedJobCoordinator.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Coordination/IDistributedJobCoordinator.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/CleanupRecurringJob.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/EmailRetryRecurringJob.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/ExternalSyncRecurringJob.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/ReindexRecurringJob.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/CleanupRecurringJobRegistration.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/EmailRetryRecurringJobRegistration.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/ExternalSyncRecurringJobRegistration.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/ReindexRecurringJobRegistration.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQFunctionNames.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQJobIds.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQRecurringJobRegistrar.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQSchedulerDbContext.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQSchedulerDbContextFactory.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/BackgroundJobs/Validation/BackgroundJobsOptionsValidator.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Database/Procedures/claim_expired_failed_emails_v1_down.sql delete mode 100644 absolute/src/APITemplate.Infrastructure/Database/Procedures/claim_expired_failed_emails_v1_up.sql delete mode 100644 absolute/src/APITemplate.Infrastructure/Database/Procedures/claim_retryable_failed_emails_v1_down.sql delete mode 100644 absolute/src/APITemplate.Infrastructure/Database/Procedures/claim_retryable_failed_emails_v1_up.sql delete mode 100644 absolute/src/APITemplate.Infrastructure/Database/Procedures/get_fts_index_names_v1_down.sql delete mode 100644 absolute/src/APITemplate.Infrastructure/Database/Procedures/get_fts_index_names_v1_up.sql delete mode 100644 absolute/src/APITemplate.Infrastructure/Database/Procedures/get_index_bloat_percent_v1_down.sql delete mode 100644 absolute/src/APITemplate.Infrastructure/Database/Procedures/get_index_bloat_percent_v1_up.sql delete mode 100644 absolute/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v1_down.sql delete mode 100644 absolute/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v1_up.sql delete mode 100644 absolute/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v2_down.sql delete mode 100644 absolute/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v2_up.sql delete mode 100644 absolute/src/APITemplate.Infrastructure/Database/SqlResource.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Database/Triggers/row_version_triggers_v1_down.sql delete mode 100644 absolute/src/APITemplate.Infrastructure/Database/Triggers/row_version_triggers_v1_up.sql delete mode 100644 absolute/src/APITemplate.Infrastructure/Email/ChannelEmailQueue.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Email/EmailSendingBackgroundService.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Email/FailedEmailErrorNormalizer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Email/FailedEmailStore.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Email/FluidEmailTemplateRenderer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Email/MailKitEmailSender.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Email/Templates/tenant-invitation.liquid delete mode 100644 absolute/src/APITemplate.Infrastructure/Email/Templates/user-registration.liquid delete mode 100644 absolute/src/APITemplate.Infrastructure/Email/Templates/user-role-changed.liquid delete mode 100644 absolute/src/APITemplate.Infrastructure/FileStorage/LocalFileStorageService.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/GlobalUsings.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Health/HealthCheckNames.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Health/KeycloakHealthCheck.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Health/MongoDbHealthCheck.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Idempotency/DistributedCacheIdempotencyStore.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Idempotency/IdempotencyStoreConstants.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Logging/ActivityTraceEnricher.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Logging/LogDataClassifications.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Logging/RedactionConfiguration.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260302153430_AddCategory.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260302153430_AddCategory.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260304124643_AddMultiTenantAuditSoftDelete.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260304124643_AddMultiTenantAuditSoftDelete.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260304174656_AddUserRoleForPlatformAdmin.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260304174656_AddUserRoleForPlatformAdmin.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260304181202_AddNormalizedUsernameForAuth.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260304181202_AddNormalizedUsernameForAuth.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260304185009_AddPostgresRowVersionTriggers.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260304185009_AddPostgresRowVersionTriggers.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260304193511_MakeNormalizedUsernameUniquePerTenant.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260304193511_MakeNormalizedUsernameUniquePerTenant.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260304194634_DisablePostgresRowVersionTriggersForAppManagedConcurrency.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260304194634_DisablePostgresRowVersionTriggersForAppManagedConcurrency.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260305184129_SyncModelChanges.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260305184129_SyncModelChanges.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260306210000_RenameUserRoleTenantUserToUser.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260306224502_SwitchToXminConcurrency.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260306224502_SwitchToXminConcurrency.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260306235337_ChangeAuditActorFieldsToGuid.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260306235337_ChangeAuditActorFieldsToGuid.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260307174126_AddProductDataLinks.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260307174126_AddProductDataLinks.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260307191126_SoftDeleteProductDataLinksAndMetadata.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260307191126_SoftDeleteProductDataLinksAndMetadata.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260308182543_AddFullTextSearchIndexes.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260308182543_AddFullTextSearchIndexes.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260310000812_AddNormalizedEmailForUsers.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260310000812_AddNormalizedEmailForUsers.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260315000709_AddTenantInvitationNormalizedEmail.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260315000709_AddTenantInvitationNormalizedEmail.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260315005556_RemovePasswordHashAddKeycloakUserId.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260315005556_RemovePasswordHashAddKeycloakUserId.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260315014428_DropPasswordResetTokensTable.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260315014428_DropPasswordResetTokensTable.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260316105703_AddFailedEmails.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260316105703_AddFailedEmails.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260316144833_AddFailedEmailTemplateName.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260316144833_AddFailedEmailTemplateName.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260316202617_AddFailedEmailExpirationIndex.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260316202617_AddFailedEmailExpirationIndex.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260316220129_AddFailedEmailClaims.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260316220129_AddFailedEmailClaims.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260317100000_AddStoredProceduresForReindexAndEmailClaim.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260317100000_AddStoredProceduresForReindexAndEmailClaim.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260318201620_AddExampleEndpointEntities.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260318201620_AddExampleEndpointEntities.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260318232224_AddCallbackUrlToJobExecution.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/20260318232224_AddCallbackUrlToJobExecution.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/AppDbContextModelSnapshot.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/M001_CreateProductDataIndexes.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/M002_AddProductDataSoftDeleteIndexes.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/TickerQ/20260316213820_AddTickerQSchedulerStore.Designer.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/TickerQ/20260316213820_AddTickerQSchedulerStore.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Migrations/TickerQ/TickerQSchedulerDbContextModelSnapshot.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Observability/ApiMetrics.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Observability/AuthTelemetry.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Observability/CacheTelemetry.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Observability/ConflictTelemetry.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Observability/GraphQlTelemetry.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Observability/HealthCheckMetricsPublisher.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Observability/HttpRouteResolver.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Observability/ObservabilityConventions.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Observability/StartupTelemetry.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Observability/StoredProcedureTelemetry.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Observability/TelemetryApiSurfaceResolver.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Observability/ValidationTelemetry.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/AppDbContext.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/AppDbContextFactory.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/Auditing/AuditableEntityStateManager.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/Auditing/IAuditableEntityStateManager.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/AuthBootstrapSeeder.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/Configurations/AppUserConfiguration.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/Configurations/CategoryConfiguration.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/Configurations/FailedEmailConfiguration.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/Configurations/JobExecutionConfiguration.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/Configurations/ProductCategoryStatsConfiguration.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/Configurations/ProductConfiguration.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/Configurations/ProductDataLinkConfiguration.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/Configurations/ProductReviewConfiguration.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/Configurations/StoredFileConfiguration.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/Configurations/TenantAuditableEntityConfigurationExtensions.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/Configurations/TenantConfiguration.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/Configurations/TenantInvitationConfiguration.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/DesignTimeConfigurationHelper.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/DesignTimeDefaults.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/EntityNormalization/AppUserEntityNormalizationService.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/EntityNormalization/IEntityNormalizationService.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/MongoDbContext.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/MongoDbSettings.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/SoftDelete/ISoftDeleteCascadeRule.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/SoftDelete/ISoftDeleteProcessor.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/SoftDelete/ProductSoftDeleteCascadeRule.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/SoftDelete/SoftDeleteProcessor.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/SoftDelete/TenantSoftDeleteCascadeRule.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/Startup/PostgresAdvisoryLockStartupTaskCoordinator.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/DbContextCommandTimeoutScope.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/DbContextTrackedStateManager.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/EfCoreTransactionProvider.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/IDbTransactionProvider.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/ManagedTransactionScope.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWork.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWorkExecutionStrategyFactory.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWorkLogs.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Repositories/CategoryRepository.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Repositories/FailedEmailRepository.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Repositories/JobExecutionRepository.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Repositories/Pagination/PagedProjectionBuilder.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Repositories/Pagination/PagedRow.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Repositories/ProductDataLinkRepository.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Repositories/ProductDataRepository.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Repositories/ProductRepository.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Repositories/ProductReviewRepository.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Repositories/RepositoryBase.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Repositories/StoredFileRepository.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Repositories/TenantInvitationRepository.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Repositories/TenantRepository.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Repositories/UserRepository.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Security/DragonflyTicketStore.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Security/HttpActorProvider.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Security/HttpTenantProvider.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Security/Keycloak/CookieSessionRefresher.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakAdminService.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakAdminTokenHandler.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakAdminTokenProvider.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakClaimMapper.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakTokenResponse.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakUrlHelper.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Security/SecureTokenGenerator.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Security/Tenant/TenantClaimValidator.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Security/Tenant/TenantClaimValidatorLogs.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Security/Tenant/UserProvisioningService.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/StoredProcedures/ClaimExpiredFailedEmailsProcedure.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/StoredProcedures/ClaimRetryableFailedEmailsProcedure.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/StoredProcedures/GetFtsIndexNamesProcedure.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/StoredProcedures/GetIndexBloatPercentProcedure.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/StoredProcedures/GetProductCategoryStatsProcedure.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/StoredProcedures/StoredProcedureExecutor.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Webhooks/ChannelOutgoingWebhookQueue.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Webhooks/ChannelWebhookQueue.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Webhooks/HmacHelper.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Webhooks/HmacWebhookPayloadSigner.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Webhooks/HmacWebhookPayloadValidator.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Webhooks/LoggingWebhookEventHandler.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Webhooks/OutgoingWebhookBackgroundService.cs delete mode 100644 absolute/src/APITemplate.Infrastructure/Webhooks/WebhookProcessingBackgroundService.cs diff --git a/absolute/README.md b/absolute/README.md deleted file mode 100644 index c44e2c1f..00000000 --- a/absolute/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Absolute Reference - -This folder contains a snapshot of the legacy monolith `src` tree from the `main` branch. - -- `absolute/src` is reference-only. -- Active development continues in the root `src` folder. -- The modular monolith rewrite, including Unit 0 Foundation (`SharedKernel` + `Contracts`), stays in the root `src`. diff --git a/absolute/src/APITemplate.Api/APITemplate.Api.csproj b/absolute/src/APITemplate.Api/APITemplate.Api.csproj deleted file mode 100644 index 0aa18fb4..00000000 --- a/absolute/src/APITemplate.Api/APITemplate.Api.csproj +++ /dev/null @@ -1,99 +0,0 @@ - - - - net10.0 - enable - enable - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/absolute/src/APITemplate.Api/APITemplate.http b/absolute/src/APITemplate.Api/APITemplate.http deleted file mode 100644 index 0461c7d3..00000000 --- a/absolute/src/APITemplate.Api/APITemplate.http +++ /dev/null @@ -1,117 +0,0 @@ -@HostAddress = http://localhost:5174 -@ApiBase = {{HostAddress}}/api/v1 -@ContentType = application/json - -# Set this after login -@Token = YOUR_TOKEN_HERE - -### ===================== -### AUTH -### ===================== - -### Login -# @name login -POST {{ApiBase}}/auth/login -Content-Type: {{ContentType}} - -{ - "username": "admin", - "password": "admin" -} - -### - -### ===================== -### PRODUCTS -### ===================== - -### Get all products -GET {{ApiBase}}/products -Authorization: Bearer {{Token}} -Accept: {{ContentType}} - -### - -### Get product by ID -GET {{ApiBase}}/products/00000000-0000-0000-0000-000000000001 -Authorization: Bearer {{Token}} -Accept: {{ContentType}} - -### - -### Create product -POST {{ApiBase}}/products -Authorization: Bearer {{Token}} -Content-Type: {{ContentType}} - -{ - "name": "Sample Product", - "description": "Product description", - "price": 19.99 -} - -### - -### Update product -PUT {{ApiBase}}/products/00000000-0000-0000-0000-000000000001 -Authorization: Bearer {{Token}} -Content-Type: {{ContentType}} - -{ - "name": "Updated Product", - "description": "Updated description", - "price": 29.99 -} - -### - -### Delete product -DELETE {{ApiBase}}/products/00000000-0000-0000-0000-000000000001 -Authorization: Bearer {{Token}} - -### - -### ===================== -### PRODUCT REVIEWS -### ===================== - -### Get all reviews -GET {{ApiBase}}/productreviews -Authorization: Bearer {{Token}} -Accept: {{ContentType}} - -### - -### Get review by ID -GET {{ApiBase}}/productreviews/00000000-0000-0000-0000-000000000001 -Authorization: Bearer {{Token}} -Accept: {{ContentType}} - -### - -### Get reviews by product -GET {{ApiBase}}/productreviews/by-product/00000000-0000-0000-0000-000000000001 -Authorization: Bearer {{Token}} -Accept: {{ContentType}} - -### - -### Create review -POST {{ApiBase}}/productreviews -Authorization: Bearer {{Token}} -Content-Type: {{ContentType}} - -{ - "productId": "00000000-0000-0000-0000-000000000001", - "userId": "00000000-0000-0000-0000-000000000002", - "comment": "Great product!", - "rating": 5 -} - -### - -### Delete review -DELETE {{ApiBase}}/productreviews/00000000-0000-0000-0000-000000000001 -Authorization: Bearer {{Token}} - -### diff --git a/absolute/src/APITemplate.Api/Api/Authorization/PermissionAuthorizationHandler.cs b/absolute/src/APITemplate.Api/Api/Authorization/PermissionAuthorizationHandler.cs deleted file mode 100644 index 653a5d03..00000000 --- a/absolute/src/APITemplate.Api/Api/Authorization/PermissionAuthorizationHandler.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Security.Claims; -using APITemplate.Application.Common.Security; -using Microsoft.AspNetCore.Authorization; - -namespace APITemplate.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 - ) - { - var roleClaims = context.User.FindAll(ClaimTypes.Role); - - foreach (var roleClaim in roleClaims) - { - if (_rolePermissionMap.HasPermission(roleClaim.Value, requirement.Permission)) - { - context.Succeed(requirement); - break; - } - } - - return Task.CompletedTask; - } -} diff --git a/absolute/src/APITemplate.Api/Api/Authorization/PermissionPolicyProvider.cs b/absolute/src/APITemplate.Api/Api/Authorization/PermissionPolicyProvider.cs deleted file mode 100644 index dcb8b387..00000000 --- a/absolute/src/APITemplate.Api/Api/Authorization/PermissionPolicyProvider.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Collections.Concurrent; -using APITemplate.Application.Common.Security; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Authorization; -using Microsoft.Extensions.Options; - -namespace APITemplate.Api.Authorization; - -public sealed class PermissionPolicyProvider : IAuthorizationPolicyProvider -{ - private readonly DefaultAuthorizationPolicyProvider _fallback; - private readonly ConcurrentDictionary _cache = new(); - - public PermissionPolicyProvider(IOptions options) => - _fallback = new DefaultAuthorizationPolicyProvider(options); - - public Task GetPolicyAsync(string policyName) - { - if (!Permission.All.Contains(policyName)) - return _fallback.GetPolicyAsync(policyName); - - var policy = _cache.GetOrAdd( - policyName, - name => - new AuthorizationPolicyBuilder( - JwtBearerDefaults.AuthenticationScheme, - AuthConstants.BffSchemes.Cookie - ) - .RequireAuthenticatedUser() - .AddRequirements(new PermissionRequirement(name)) - .Build() - ); - - return Task.FromResult(policy); - } - - public Task GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync(); - - public Task GetFallbackPolicyAsync() => - _fallback.GetFallbackPolicyAsync(); -} diff --git a/absolute/src/APITemplate.Api/Api/Authorization/PermissionRequirement.cs b/absolute/src/APITemplate.Api/Api/Authorization/PermissionRequirement.cs deleted file mode 100644 index 91af7682..00000000 --- a/absolute/src/APITemplate.Api/Api/Authorization/PermissionRequirement.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Microsoft.AspNetCore.Authorization; - -namespace APITemplate.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/absolute/src/APITemplate.Api/Api/Authorization/RequirePermissionAttribute.cs b/absolute/src/APITemplate.Api/Api/Authorization/RequirePermissionAttribute.cs deleted file mode 100644 index 147dcecc..00000000 --- a/absolute/src/APITemplate.Api/Api/Authorization/RequirePermissionAttribute.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.AspNetCore.Authorization; - -namespace APITemplate.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/absolute/src/APITemplate.Api/Api/Cache/CacheInvalidationHandler.cs b/absolute/src/APITemplate.Api/Api/Cache/CacheInvalidationHandler.cs deleted file mode 100644 index beb7ff20..00000000 --- a/absolute/src/APITemplate.Api/Api/Cache/CacheInvalidationHandler.cs +++ /dev/null @@ -1,15 +0,0 @@ -using APITemplate.Application.Common.Events; - -namespace APITemplate.Api.Cache; - -/// -/// Handles by evicting tagged output cache entries. -/// -public sealed class CacheInvalidationHandler -{ - public static Task HandleAsync( - CacheInvalidationNotification @event, - IOutputCacheInvalidationService outputCacheInvalidationService, - CancellationToken ct - ) => outputCacheInvalidationService.EvictAsync(@event.CacheTag, ct); -} diff --git a/absolute/src/APITemplate.Api/Api/Cache/CachingOptions.cs b/absolute/src/APITemplate.Api/Api/Cache/CachingOptions.cs deleted file mode 100644 index 53fc9888..00000000 --- a/absolute/src/APITemplate.Api/Api/Cache/CachingOptions.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace APITemplate.Api.Cache; - -/// -/// Strongly-typed options model for configuring per-resource output cache expiration durations. -/// Bound from the Caching configuration section and validated on startup. -/// -public sealed class CachingOptions -{ - [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; -} diff --git a/absolute/src/APITemplate.Api/Api/Cache/IOutputCacheInvalidationService.cs b/absolute/src/APITemplate.Api/Api/Cache/IOutputCacheInvalidationService.cs deleted file mode 100644 index d5bb42e6..00000000 --- a/absolute/src/APITemplate.Api/Api/Cache/IOutputCacheInvalidationService.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace APITemplate.Api.Cache; - -/// -/// Abstraction for evicting output cache entries by tag, allowing the infrastructure -/// implementation to be swapped or mocked independently of the domain/application layers. -/// -public interface IOutputCacheInvalidationService -{ - /// Evicts all output cache entries associated with . - Task EvictAsync(string tag, CancellationToken cancellationToken = default); - - /// Evicts all output cache entries associated with each of the provided . - Task EvictAsync(IEnumerable tags, CancellationToken cancellationToken = default); -} diff --git a/absolute/src/APITemplate.Api/Api/Cache/OutputCacheInvalidationService.cs b/absolute/src/APITemplate.Api/Api/Cache/OutputCacheInvalidationService.cs deleted file mode 100644 index d1d233aa..00000000 --- a/absolute/src/APITemplate.Api/Api/Cache/OutputCacheInvalidationService.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System.Diagnostics; -using APITemplate.Infrastructure.Observability; -using Microsoft.AspNetCore.OutputCaching; -using Microsoft.Extensions.Logging; - -namespace APITemplate.Api.Cache; - -/// -/// Production implementation of that delegates -/// to ASP.NET Core's , with telemetry and per-tag error isolation. -/// -public sealed class OutputCacheInvalidationService : IOutputCacheInvalidationService -{ - private readonly IOutputCacheStore _outputCacheStore; - private readonly ILogger _logger; - - public OutputCacheInvalidationService( - IOutputCacheStore outputCacheStore, - ILogger logger - ) - { - _outputCacheStore = outputCacheStore; - _logger = logger; - } - - /// - public Task EvictAsync(string tag, CancellationToken cancellationToken = default) => - EvictAsync([tag], cancellationToken); - - /// - /// Evicts each distinct tag individually. Errors are logged as warnings and do not abort - /// eviction of remaining tags; is re-thrown. - /// - public async Task EvictAsync( - IEnumerable tags, - CancellationToken cancellationToken = default - ) - { - foreach (var tag in tags.Distinct(StringComparer.Ordinal)) - { - var startedAt = Stopwatch.GetTimestamp(); - using var activity = CacheTelemetry.StartOutputCacheInvalidationActivity(tag); - - try - { - await _outputCacheStore.EvictByTagAsync(tag, cancellationToken); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning( - ex, - "Failed to evict output cache for tag {Tag}. Stale data may be served until expiration.", - tag - ); - continue; - } - - CacheTelemetry.RecordOutputCacheInvalidation(tag, Stopwatch.GetElapsedTime(startedAt)); - } - } -} diff --git a/absolute/src/APITemplate.Api/Api/Cache/RedisInstanceNames.cs b/absolute/src/APITemplate.Api/Api/Cache/RedisInstanceNames.cs deleted file mode 100644 index 275cd291..00000000 --- a/absolute/src/APITemplate.Api/Api/Cache/RedisInstanceNames.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace APITemplate.Api.Cache; - -internal static class RedisInstanceNames -{ - public const string OutputCache = "ApiTemplate:OutputCache:"; - public const string Session = "ApiTemplate:Session:"; -} diff --git a/absolute/src/APITemplate.Api/Api/Cache/TenantAwareOutputCachePolicy.cs b/absolute/src/APITemplate.Api/Api/Cache/TenantAwareOutputCachePolicy.cs deleted file mode 100644 index 83928f2b..00000000 --- a/absolute/src/APITemplate.Api/Api/Cache/TenantAwareOutputCachePolicy.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.Security.Claims; -using APITemplate.Application.Common.Security; -using APITemplate.Infrastructure.Observability; -using Microsoft.AspNetCore.OutputCaching; - -namespace APITemplate.Api.Cache; - -/// -/// Output cache policy that enables caching for authenticated requests and varies the cache key -/// by tenant, preventing cross-tenant data exposure. -/// -/// -/// By default ASP.NET Core Output Cache skips caching when an Authorization header is present. -/// This policy overrides that behaviour and segments the cache per tenant so one tenant's responses -/// are never served to another. -/// -public sealed class TenantAwareOutputCachePolicy : IOutputCachePolicy -{ - /// - /// Enables output caching for GET/HEAD requests and segments the cache key by tenant ID. - /// Non-GET/HEAD requests are skipped without side-effects. - /// - public ValueTask CacheRequestAsync( - OutputCacheContext context, - CancellationToken cancellationToken - ) - { - if ( - !HttpMethods.IsGet(context.HttpContext.Request.Method) - && !HttpMethods.IsHead(context.HttpContext.Request.Method) - ) - return ValueTask.CompletedTask; - - // Explicitly enable caching even when an Authorization header is present. - context.EnableOutputCaching = true; - context.AllowCacheLookup = true; - context.AllowCacheStorage = true; - - // Vary cache key by tenant so each tenant has isolated cache entries. - var tenantId = - context.HttpContext.User.FindFirstValue(AuthConstants.Claims.TenantId) ?? string.Empty; - context.CacheVaryByRules.VaryByValues[AuthConstants.Claims.TenantId] = tenantId; - CacheTelemetry.ConfigureRequest(context); - - return ValueTask.CompletedTask; - } - - /// Records a cache-hit telemetry event when a cached response is served. - public ValueTask ServeFromCacheAsync( - OutputCacheContext context, - CancellationToken cancellationToken - ) - { - CacheTelemetry.RecordCacheHit(context); - return ValueTask.CompletedTask; - } - - /// Records the response outcome telemetry when a fresh response is written to the cache. - public ValueTask ServeResponseAsync( - OutputCacheContext context, - CancellationToken cancellationToken - ) - { - CacheTelemetry.RecordResponseOutcome(context); - return ValueTask.CompletedTask; - } -} diff --git a/absolute/src/APITemplate.Api/Api/Controllers/ApiControllerBase.cs b/absolute/src/APITemplate.Api/Api/Controllers/ApiControllerBase.cs deleted file mode 100644 index 349fc1bf..00000000 --- a/absolute/src/APITemplate.Api/Api/Controllers/ApiControllerBase.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace APITemplate.Api.Controllers; - -[ApiController] -[Route("api/v{version:apiVersion}/[controller]")] -public abstract class ApiControllerBase : ControllerBase -{ - internal ActionResult OkOrUnprocessable(BatchResponse response) => - response.FailureCount > 0 ? UnprocessableEntity(response) : Ok(response); -} diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/BffController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/BffController.cs deleted file mode 100644 index 8427f19d..00000000 --- a/absolute/src/APITemplate.Api/Api/Controllers/V1/BffController.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Security.Claims; -using APITemplate.Api.Controllers; -using APITemplate.Application.Common.Options; -using APITemplate.Application.Common.Security; -using APITemplate.Application.Features.Bff.DTOs; -using Asp.Versioning; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; - -namespace APITemplate.Api.Controllers.V1; - -[ApiVersion(1.0)] -[Authorize(AuthenticationSchemes = AuthConstants.BffSchemes.Cookie)] -/// -/// Presentation-layer controller that exposes Backend-for-Frontend (BFF) endpoints for -/// cookie-based browser clients, including login, logout, CSRF token retrieval, and current-user info. -/// -public sealed class BffController : ApiControllerBase -{ - private readonly BffOptions _bffOptions; - - public BffController(IOptions bffOptions) - { - _bffOptions = bffOptions.Value; - } - - /// - /// Initiates an OIDC authorization-code challenge, redirecting the browser to Keycloak. - /// Falls back to the root path when is not a local URL. - /// - [HttpGet("login")] - [AllowAnonymous] - public IActionResult Login([FromQuery] string? returnUrl = null) - { - var redirectUri = Url.IsLocalUrl(returnUrl) ? returnUrl : "/"; - return Challenge( - new AuthenticationProperties { RedirectUri = redirectUri }, - AuthConstants.BffSchemes.Oidc - ); - } - - /// - /// Signs the user out of both the cookie session and the OIDC provider, then redirects - /// to the configured post-logout URI. - /// - [HttpGet("logout")] - public IActionResult Logout() - { - return SignOut( - new AuthenticationProperties { RedirectUri = _bffOptions.PostLogoutRedirectUri }, - AuthConstants.BffSchemes.Cookie, - AuthConstants.BffSchemes.Oidc - ); - } - - /// - /// Returns the CSRF header name and a static token value that browser clients must include - /// on every state-changing request made with the cookie authentication scheme. - /// - [HttpGet("csrf")] - [AllowAnonymous] - public IActionResult GetCsrf() => - Ok( - new - { - headerName = AuthConstants.Csrf.HeaderName, - headerValue = AuthConstants.Csrf.HeaderValue, - } - ); - - /// - /// Returns the authenticated user's identity claims (id, username, email, tenant, roles) - /// extracted from the current cookie session. - /// - [HttpGet("user")] - public IActionResult GetUser() - { - var user = HttpContext.User; - - var result = new BffUserResponse( - 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/absolute/src/APITemplate.Api/Api/Controllers/V1/CategoriesController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/CategoriesController.cs deleted file mode 100644 index 24da6cf7..00000000 --- a/absolute/src/APITemplate.Api/Api/Controllers/V1/CategoriesController.cs +++ /dev/null @@ -1,115 +0,0 @@ -using APITemplate.Api.Authorization; -using APITemplate.Api.Controllers; -using APITemplate.Api.ErrorOrMapping; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Common.Security; -using Asp.Versioning; -using ErrorOr; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.OutputCaching; -using Wolverine; - -namespace APITemplate.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 -{ - /// - /// Returns a paginated, filterable list of categories from the output cache. - /// - [HttpGet] - [RequirePermission(Permission.Categories.Read)] - [OutputCache(PolicyName = CacheTags.Categories)] - public async Task>> GetAll( - [FromQuery] CategoryFilter filter, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>>( - new GetCategoriesQuery(filter), - ct - ); - return result.ToActionResult(this); - } - - /// Returns a single category by its identifier, or 404 if not found. - [HttpGet("{id:guid}")] - [RequirePermission(Permission.Categories.Read)] - [OutputCache(PolicyName = CacheTags.Categories)] - public async Task> GetById(Guid id, CancellationToken ct) - { - var result = await bus.InvokeAsync>( - new GetCategoryByIdQuery(id), - ct - ); - return result.ToActionResult(this); - } - - /// Creates multiple categories in a single batch operation. - [HttpPost] - [RequirePermission(Permission.Categories.Create)] - public async Task> Create( - CreateCategoriesRequest request, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new CreateCategoriesCommand(request), - ct - ); - return result.ToBatchResult(this); - } - - /// Updates multiple categories in a single batch operation. - [HttpPut] - [RequirePermission(Permission.Categories.Update)] - public async Task> Update( - UpdateCategoriesRequest request, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new UpdateCategoriesCommand(request), - ct - ); - return result.ToBatchResult(this); - } - - /// Soft-deletes multiple categories in a single batch operation. - [HttpDelete] - [RequirePermission(Permission.Categories.Delete)] - public async Task> Delete( - BatchDeleteRequest request, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new DeleteCategoriesCommand(request), - ct - ); - return result.ToBatchResult(this); - } - - /// - /// Returns aggregated statistics for a category by calling the - /// get_product_category_stats(p_category_id) stored procedure via EF Core FromSql. - /// - [HttpGet("{id:guid}/stats")] - [RequirePermission(Permission.Categories.Read)] - [OutputCache(PolicyName = CacheTags.Categories)] - public async Task> GetStats( - Guid id, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new GetCategoryStatsQuery(id), - ct - ); - return result.ToActionResult(this); - } -} diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/IdempotentController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/IdempotentController.cs deleted file mode 100644 index 9fb11b84..00000000 --- a/absolute/src/APITemplate.Api/Api/Controllers/V1/IdempotentController.cs +++ /dev/null @@ -1,43 +0,0 @@ -using APITemplate.Api.Authorization; -using APITemplate.Api.Controllers; -using APITemplate.Api.ErrorOrMapping; -using APITemplate.Api.Filters.Idempotency; -using APITemplate.Application.Common.Security; -using APITemplate.Application.Features.Examples; -using APITemplate.Application.Features.Examples.DTOs; -using Asp.Versioning; -using ErrorOr; -using Microsoft.AspNetCore.Mvc; -using Wolverine; - -namespace APITemplate.Api.Controllers.V1; - -[ApiVersion(1.0)] -/// -/// Presentation-layer controller that demonstrates idempotent POST semantics using the -/// action filter to detect and short-circuit duplicate requests. -/// -public sealed class IdempotentController(IMessageBus bus) : ApiControllerBase -{ - /// - /// Creates a resource idempotently; repeated requests with the same idempotency key - /// return the original response without re-executing the command. - /// - [HttpPost] - [Idempotent] - [RequirePermission(Permission.Examples.Create)] - public async Task> Create( - IdempotentCreateRequest request, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new IdempotentCreateCommand(request), - ct - ); - if (result.IsError) - return result.ToActionResult(this); - - return Created(string.Empty, result.Value); - } -} diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/JobsController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/JobsController.cs deleted file mode 100644 index b06f46d1..00000000 --- a/absolute/src/APITemplate.Api/Api/Controllers/V1/JobsController.cs +++ /dev/null @@ -1,57 +0,0 @@ -using APITemplate.Api.Authorization; -using APITemplate.Api.Controllers; -using APITemplate.Api.ErrorOrMapping; -using APITemplate.Application.Common.Security; -using APITemplate.Application.Features.Examples; -using APITemplate.Application.Features.Examples.DTOs; -using Asp.Versioning; -using ErrorOr; -using Microsoft.AspNetCore.Mvc; -using Wolverine; - -namespace APITemplate.Api.Controllers.V1; - -[ApiVersion(1.0)] -/// -/// Presentation-layer controller that demonstrates 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] - [RequirePermission(Permission.Examples.Execute)] - public async Task Submit(SubmitJobRequest request, CancellationToken ct) - { - var result = await bus.InvokeAsync>( - new SubmitJobCommand(request), - ct - ); - if (result.IsError) - return result.ToErrorResult(this); - - return AcceptedAtAction( - nameof(GetStatus), - new { id = result.Value.Id, version = this.GetApiVersion() }, - result.Value - ); - } - - /// Returns the current execution status of a previously submitted job, or 404 if not found. - [HttpGet("{id:guid}")] - [RequirePermission(Permission.Examples.Read)] - public async Task> GetStatus( - [FromRoute] GetJobStatusRequest request, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new GetJobStatusQuery(request), - ct - ); - return result.ToActionResult(this); - } -} diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/PatchController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/PatchController.cs deleted file mode 100644 index 3682613b..00000000 --- a/absolute/src/APITemplate.Api/Api/Controllers/V1/PatchController.cs +++ /dev/null @@ -1,39 +0,0 @@ -using APITemplate.Api.Authorization; -using APITemplate.Api.Controllers; -using APITemplate.Api.ErrorOrMapping; -using APITemplate.Application.Features.Examples; -using APITemplate.Application.Features.Examples.DTOs; -using Asp.Versioning; -using ErrorOr; -using Microsoft.AspNetCore.Mvc; -using SystemTextJsonPatch; -using Wolverine; - -namespace APITemplate.Api.Controllers.V1; - -[ApiVersion(1.0)] -/// -/// Presentation-layer controller that demonstrates JSON Patch (RFC 6902) support -/// for partial product updates using SystemTextJsonPatch. -/// -public sealed class PatchController(IMessageBus bus) : ApiControllerBase -{ - /// - /// Applies a JSON Patch document to the specified product by passing an apply-delegate - /// to the application layer, which mutates the DTO before persisting. - /// - [HttpPatch("products/{id:guid}")] - [RequirePermission(Permission.Examples.Update)] - public async Task> PatchProduct( - Guid id, - [FromBody] JsonPatchDocument patchDocument, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new PatchProductCommand(id, dto => patchDocument.ApplyTo(dto)), - ct - ); - return result.ToActionResult(this); - } -} diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/ProductDataController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/ProductDataController.cs deleted file mode 100644 index 619b11e8..00000000 --- a/absolute/src/APITemplate.Api/Api/Controllers/V1/ProductDataController.cs +++ /dev/null @@ -1,89 +0,0 @@ -using APITemplate.Api.Authorization; -using APITemplate.Api.Controllers; -using APITemplate.Api.ErrorOrMapping; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Common.Security; -using Asp.Versioning; -using ErrorOr; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.OutputCaching; -using Wolverine; - -namespace APITemplate.Api.Controllers.V1; - -[ApiVersion(1.0)] -[Route("api/v{version:apiVersion}/product-data")] -/// -/// Presentation-layer controller that manages product supplementary data (images and videos) -/// stored in MongoDB, with output-cache integration for read endpoints. -/// -public sealed class ProductDataController(IMessageBus bus) : ApiControllerBase -{ - /// Returns all product data documents, optionally filtered by type. - [HttpGet] - [RequirePermission(Permission.ProductData.Read)] - [OutputCache(PolicyName = CacheTags.ProductData)] - public async Task>> GetAll( - [FromQuery] string? type, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>>( - new GetProductDataQuery(type), - ct - ); - return result.ToActionResult(this); - } - - /// Returns a single product data document by its identifier, or 404 if not found. - [HttpGet("{id:guid}")] - [RequirePermission(Permission.ProductData.Read)] - [OutputCache(PolicyName = CacheTags.ProductData)] - public async Task> GetById(Guid id, CancellationToken ct) - { - var result = await bus.InvokeAsync>( - new GetProductDataByIdQuery(id), - ct - ); - return result.ToActionResult(this); - } - - /// Creates a new image product-data document and returns it with a 201 Location header. - [HttpPost("image")] - [RequirePermission(Permission.ProductData.Create)] - public async Task> CreateImage( - CreateImageProductDataRequest request, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new CreateImageProductDataCommand(request), - ct - ); - return result.ToCreatedResult(this, v => new { id = v.Id, version = this.GetApiVersion() }); - } - - /// Creates a new video product-data document and returns it with a 201 Location header. - [HttpPost("video")] - [RequirePermission(Permission.ProductData.Create)] - public async Task> CreateVideo( - CreateVideoProductDataRequest request, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new CreateVideoProductDataCommand(request), - ct - ); - return result.ToCreatedResult(this, v => new { id = v.Id, version = this.GetApiVersion() }); - } - - /// Deletes a product data document by its identifier. - [HttpDelete("{id:guid}")] - [RequirePermission(Permission.ProductData.Delete)] - public async Task Delete(Guid id, CancellationToken ct) - { - var result = await bus.InvokeAsync>(new DeleteProductDataCommand(id), ct); - return result.ToNoContentResult(this); - } -} diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/ProductReviewsController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/ProductReviewsController.cs deleted file mode 100644 index 791a0a63..00000000 --- a/absolute/src/APITemplate.Api/Api/Controllers/V1/ProductReviewsController.cs +++ /dev/null @@ -1,92 +0,0 @@ -using APITemplate.Api.Authorization; -using APITemplate.Api.Controllers; -using APITemplate.Api.ErrorOrMapping; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Common.Security; -using Asp.Versioning; -using ErrorOr; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.OutputCaching; -using Wolverine; - -namespace APITemplate.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 -{ - /// Returns a paginated, filterable list of product reviews. - [HttpGet] - [RequirePermission(Permission.ProductReviews.Read)] - [OutputCache(PolicyName = CacheTags.Reviews)] - public async Task>> GetAll( - [FromQuery] ProductReviewFilter filter, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>>( - new GetProductReviewsQuery(filter), - ct - ); - return result.ToActionResult(this); - } - - /// Returns a single product review by its identifier, or 404 if not found. - [HttpGet("{id:guid}")] - [RequirePermission(Permission.ProductReviews.Read)] - [OutputCache(PolicyName = CacheTags.Reviews)] - public async Task> GetById(Guid id, CancellationToken ct) - { - var result = await bus.InvokeAsync>( - new GetProductReviewByIdQuery(id), - ct - ); - return result.ToActionResult(this); - } - - /// Returns all reviews for the specified product. - [HttpGet("by-product/{productId:guid}")] - [RequirePermission(Permission.ProductReviews.Read)] - [OutputCache(PolicyName = CacheTags.Reviews)] - public async Task>> GetByProductId( - Guid productId, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>>( - new GetProductReviewsByProductIdQuery(productId), - ct - ); - return result.ToActionResult(this); - } - - /// Creates a new product review and returns it with a 201 Location header. - [HttpPost] - [RequirePermission(Permission.ProductReviews.Create)] - public async Task> Create( - CreateProductReviewRequest request, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new CreateProductReviewCommand(request), - ct - ); - return result.ToCreatedResult(this, v => new { id = v.Id, version = this.GetApiVersion() }); - } - - /// Deletes a product review by its identifier. - [HttpDelete("{id:guid}")] - [RequirePermission(Permission.ProductReviews.Delete)] - public async Task Delete(Guid id, CancellationToken ct) - { - var result = await bus.InvokeAsync>( - new DeleteProductReviewCommand(id), - ct - ); - return result.ToNoContentResult(this); - } -} diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/ProductsController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/ProductsController.cs deleted file mode 100644 index 05a24176..00000000 --- a/absolute/src/APITemplate.Api/Api/Controllers/V1/ProductsController.cs +++ /dev/null @@ -1,94 +0,0 @@ -using APITemplate.Api.Authorization; -using APITemplate.Api.Controllers; -using APITemplate.Api.ErrorOrMapping; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Common.Security; -using Asp.Versioning; -using ErrorOr; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.OutputCaching; -using Wolverine; - -namespace APITemplate.Api.Controllers.V1; - -[ApiVersion(1.0)] -/// -/// Presentation-layer controller that exposes full CRUD endpoints for the product catalog, -/// with permission-based authorization and tenant-aware output caching. -/// -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 async Task> GetAll( - [FromQuery] ProductFilter filter, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new GetProductsQuery(filter), - ct - ); - return result.ToActionResult(this); - } - - /// Returns a single product by its identifier, or 404 if not found. - [HttpGet("{id:guid}")] - [RequirePermission(Permission.Products.Read)] - [OutputCache(PolicyName = CacheTags.Products)] - public async Task> GetById(Guid id, CancellationToken ct) - { - var result = await bus.InvokeAsync>( - new GetProductByIdQuery(id), - ct - ); - return result.ToActionResult(this); - } - - /// Creates multiple products in a single batch operation. - [HttpPost] - [RequirePermission(Permission.Products.Create)] - public async Task> Create( - CreateProductsRequest request, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new CreateProductsCommand(request), - ct - ); - return result.ToBatchResult(this); - } - - /// Updates multiple products in a single batch operation. - [HttpPut] - [RequirePermission(Permission.Products.Update)] - public async Task> Update( - UpdateProductsRequest request, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new UpdateProductsCommand(request), - ct - ); - return result.ToBatchResult(this); - } - - /// Soft-deletes multiple products in a single batch operation. - [HttpDelete] - [RequirePermission(Permission.Products.Delete)] - public async Task> Delete( - BatchDeleteRequest request, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new DeleteProductsCommand(request), - ct - ); - return result.ToBatchResult(this); - } -} diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/SseController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/SseController.cs deleted file mode 100644 index 8e580440..00000000 --- a/absolute/src/APITemplate.Api/Api/Controllers/V1/SseController.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Text.Json; -using APITemplate.Api.Authorization; -using APITemplate.Api.Controllers; -using APITemplate.Application.Features.Examples; -using APITemplate.Application.Features.Examples.DTOs; -using Asp.Versioning; -using Microsoft.AspNetCore.Mvc; -using Wolverine; - -namespace APITemplate.Api.Controllers.V1; - -[ApiVersion(1.0)] -/// -/// Presentation-layer controller that demonstrates Server-Sent Events (SSE) by streaming -/// notifications as newline-delimited JSON over a persistent HTTP connection. -/// -public sealed class SseController(IMessageBus bus) : ApiControllerBase -{ - private const string EventStreamContentType = "text/event-stream"; - private const string NoCacheDirective = "no-cache"; - private const string KeepAliveConnection = "keep-alive"; - private const string SseDataPrefix = "data: "; - - /// - /// Sets SSE response headers and then iterates an async notification stream, writing each - /// item as a data: <json>\n\n frame and flushing immediately for low latency. - /// - [HttpGet("stream")] - [RequirePermission(Permission.Examples.Read)] - public async Task Stream([FromQuery] SseStreamRequest request, CancellationToken ct = default) - { - Response.ContentType = EventStreamContentType; - Response.Headers.CacheControl = NoCacheDirective; - Response.Headers.Connection = KeepAliveConnection; - - var stream = await bus.InvokeAsync>( - new GetNotificationStreamQuery(request), - ct - ); - await using var writer = new StreamWriter(Response.Body, leaveOpen: true); - - await foreach (var item in stream.WithCancellation(ct)) - { - var json = JsonSerializer.Serialize(item, JsonSerializerOptions.Web); - await writer.WriteAsync($"{SseDataPrefix}{json}\n\n"); - await writer.FlushAsync(ct); - } - } -} diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/TenantInvitationsController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/TenantInvitationsController.cs deleted file mode 100644 index 1945258d..00000000 --- a/absolute/src/APITemplate.Api/Api/Controllers/V1/TenantInvitationsController.cs +++ /dev/null @@ -1,102 +0,0 @@ -using APITemplate.Api.Authorization; -using APITemplate.Api.Controllers; -using APITemplate.Api.ErrorOrMapping; -using APITemplate.Application.Common.DTOs; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Common.Security; -using APITemplate.Application.Features.TenantInvitation; -using APITemplate.Application.Features.TenantInvitation.DTOs; -using Asp.Versioning; -using ErrorOr; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.OutputCaching; -using Wolverine; - -namespace APITemplate.Api.Controllers.V1; - -[ApiVersion(1.0)] -[Route("api/v{version:apiVersion}/tenant-invitations")] -/// -/// Presentation-layer controller that manages the lifecycle of tenant invitations, -/// including creation, acceptance via a token link, revocation, and resending. -/// -public sealed class TenantInvitationsController(IMessageBus bus) : ApiControllerBase -{ - /// Returns a paginated list of tenant invitations, optionally filtered. - [HttpGet] - [RequirePermission(Permission.Invitations.Read)] - [OutputCache(PolicyName = CacheTags.TenantInvitations)] - public async Task>> GetAll( - [FromQuery] TenantInvitationFilter filter, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>>( - new GetTenantInvitationsQuery(filter), - ct - ); - return result.ToActionResult(this); - } - - /// Creates a new tenant invitation and sends the invite email. - [HttpPost] - [RequirePermission(Permission.Invitations.Create)] - public async Task> Create( - CreateTenantInvitationRequest request, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new CreateTenantInvitationCommand(request), - ct - ); - if (result.IsError) - return result.ToActionResult(this); - - return CreatedAtAction( - nameof(GetAll), - new { version = this.GetApiVersion() }, - result.Value - ); - } - - /// Accepts a pending invitation using the one-time token from the invite email; allows anonymous callers. - [HttpPost("accept")] - [AllowAnonymous] - public async Task Accept( - [FromBody] AcceptInvitationRequest request, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new AcceptTenantInvitationCommand(request.Token), - ct - ); - return result.ToOkResult(this); - } - - /// Marks an outstanding invitation as revoked so the token can no longer be accepted. - [HttpPatch("{id:guid}/revoke")] - [RequirePermission(Permission.Invitations.Revoke)] - public async Task Revoke(Guid id, CancellationToken ct) - { - var result = await bus.InvokeAsync>( - new RevokeTenantInvitationCommand(id), - ct - ); - return result.ToNoContentResult(this); - } - - /// Re-sends the invitation email for a pending invitation that has not yet been accepted or revoked. - [HttpPost("{id:guid}/resend")] - [RequirePermission(Permission.Invitations.Create)] - public async Task Resend(Guid id, CancellationToken ct) - { - var result = await bus.InvokeAsync>( - new ResendTenantInvitationCommand(id), - ct - ); - return result.ToOkResult(this); - } -} diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/TenantsController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/TenantsController.cs deleted file mode 100644 index c5dbbcb9..00000000 --- a/absolute/src/APITemplate.Api/Api/Controllers/V1/TenantsController.cs +++ /dev/null @@ -1,70 +0,0 @@ -using APITemplate.Api.Authorization; -using APITemplate.Api.Controllers; -using APITemplate.Api.ErrorOrMapping; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Common.Security; -using Asp.Versioning; -using ErrorOr; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.OutputCaching; -using Wolverine; - -namespace APITemplate.Api.Controllers.V1; - -[ApiVersion(1.0)] -/// -/// Presentation-layer controller that exposes CRUD endpoints for tenant management, -/// restricted to platform-level permissions with tenant-isolated output caching. -/// -public sealed class TenantsController(IMessageBus bus) : ApiControllerBase -{ - /// Returns a paginated, filterable list of tenants. - [HttpGet] - [RequirePermission(Permission.Tenants.Read)] - [OutputCache(PolicyName = CacheTags.Tenants)] - public async Task>> GetAll( - [FromQuery] TenantFilter filter, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>>( - new GetTenantsQuery(filter), - ct - ); - return result.ToActionResult(this); - } - - /// Returns a single tenant by its identifier, or 404 if not found. - [HttpGet("{id:guid}")] - [RequirePermission(Permission.Tenants.Read)] - [OutputCache(PolicyName = CacheTags.Tenants)] - public async Task> GetById(Guid id, CancellationToken ct) - { - var result = await bus.InvokeAsync>(new GetTenantByIdQuery(id), ct); - return result.ToActionResult(this); - } - - /// Creates a new tenant and returns it with a 201 Location header. - [HttpPost] - [RequirePermission(Permission.Tenants.Create)] - public async Task> Create( - CreateTenantRequest request, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new CreateTenantCommand(request), - ct - ); - return result.ToCreatedResult(this, v => new { id = v.Id, version = this.GetApiVersion() }); - } - - /// Soft-deletes a tenant and cascades the deletion to its child entities. - [HttpDelete("{id:guid}")] - [RequirePermission(Permission.Tenants.Delete)] - public async Task Delete(Guid id, CancellationToken ct) - { - var result = await bus.InvokeAsync>(new DeleteTenantCommand(id), ct); - return result.ToNoContentResult(this); - } -} diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/UsersController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/UsersController.cs deleted file mode 100644 index 2ecb110f..00000000 --- a/absolute/src/APITemplate.Api/Api/Controllers/V1/UsersController.cs +++ /dev/null @@ -1,168 +0,0 @@ -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using APITemplate.Api.Authorization; -using APITemplate.Api.Controllers; -using APITemplate.Api.ErrorOrMapping; -using APITemplate.Application.Common.DTOs; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Common.Security; -using APITemplate.Application.Features.User.DTOs; -using Asp.Versioning; -using ErrorOr; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.OutputCaching; -using Wolverine; - -namespace APITemplate.Api.Controllers.V1; - -[ApiVersion(1.0)] -/// -/// Presentation-layer controller that exposes user management endpoints including -/// CRUD operations, activation/deactivation, role changes, and self-service password reset. -/// -public sealed class UsersController(IMessageBus bus) : ApiControllerBase -{ - /// Returns a paginated, filterable list of users. - [HttpGet] - [RequirePermission(Permission.Users.Read)] - [OutputCache(PolicyName = CacheTags.Users)] - public async Task>> GetAll( - [FromQuery] UserFilter filter, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>>( - new GetUsersQuery(filter), - ct - ); - return result.ToActionResult(this); - } - - /// Returns a single user by their identifier, or 404 if not found. - [HttpGet("{id:guid}")] - [RequirePermission(Permission.Users.Read)] - [OutputCache(PolicyName = CacheTags.Users)] - public async Task> GetById(Guid id, CancellationToken ct) - { - var result = await bus.InvokeAsync>(new GetUserByIdQuery(id), ct); - return result.ToActionResult(this); - } - - /// - /// Returns the currently authenticated user's profile by resolving their id from the - /// JWT/cookie claims (NameIdentifier, sub, or a custom subject claim). - /// - [HttpGet("me")] - public async Task> GetMe(CancellationToken ct) - { - var userId = - User.FindFirstValue(ClaimTypes.NameIdentifier) - ?? User.FindFirstValue(JwtRegisteredClaimNames.Sub) - ?? User.FindFirstValue(AuthConstants.Claims.Subject); - - if (userId is null || !Guid.TryParse(userId, out var id)) - return Unauthorized(); - - var result = await bus.InvokeAsync>(new GetUserByIdQuery(id), ct); - return result.ToActionResult(this); - } - - /// Creates a new user account and returns it with a 201 Location header. - [HttpPost] - [RequirePermission(Permission.Users.Create)] - public async Task> Create( - CreateUserRequest request, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new CreateUserCommand(request), - ct - ); - return result.ToCreatedResult(this, v => new { id = v.Id, version = this.GetApiVersion() }); - } - - /// Replaces all mutable fields of an existing user. - [HttpPut("{id:guid}")] - [RequirePermission(Permission.Users.Update)] - public async Task Update( - Guid id, - UpdateUserRequest request, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new UpdateUserCommand(id, request), - ct - ); - return result.ToNoContentResult(this); - } - - /// Activates a previously deactivated user account. - [HttpPatch("{id:guid}/activate")] - [RequirePermission(Permission.Users.Update)] - public async Task Activate(Guid id, CancellationToken ct) - { - var result = await bus.InvokeAsync>( - new SetUserActiveCommand(id, IsActive: true), - ct - ); - return result.ToNoContentResult(this); - } - - /// Deactivates an active user account, preventing further logins. - [HttpPatch("{id:guid}/deactivate")] - [RequirePermission(Permission.Users.Update)] - public async Task Deactivate(Guid id, CancellationToken ct) - { - var result = await bus.InvokeAsync>( - new SetUserActiveCommand(id, IsActive: false), - ct - ); - return result.ToNoContentResult(this); - } - - /// Changes the role of an existing user within the current tenant. - [HttpPatch("{id:guid}/role")] - [RequirePermission(Permission.Users.Update)] - public async Task ChangeRole( - Guid id, - ChangeUserRoleRequest request, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new ChangeUserRoleCommand(id, request), - ct - ); - return result.ToNoContentResult(this); - } - - /// Soft-deletes a user account by its identifier. - [HttpDelete("{id:guid}")] - [RequirePermission(Permission.Users.Delete)] - public async Task Delete(Guid id, CancellationToken ct) - { - var result = await bus.InvokeAsync>(new DeleteUserCommand(id), ct); - return result.ToNoContentResult(this); - } - - /// - /// Triggers a Keycloak-initiated password-reset email for the given address; allows - /// anonymous callers so unauthenticated users can recover access. - /// - [HttpPost("password-reset")] - [AllowAnonymous] - public async Task RequestPasswordReset( - RequestPasswordResetRequest request, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new KeycloakPasswordResetCommand(request), - ct - ); - return result.ToOkResult(this); - } -} diff --git a/absolute/src/APITemplate.Api/Api/Controllers/V1/WebhooksController.cs b/absolute/src/APITemplate.Api/Api/Controllers/V1/WebhooksController.cs deleted file mode 100644 index 3cc23954..00000000 --- a/absolute/src/APITemplate.Api/Api/Controllers/V1/WebhooksController.cs +++ /dev/null @@ -1,39 +0,0 @@ -using APITemplate.Api.Controllers; -using APITemplate.Api.Filters.Webhooks; -using APITemplate.Application.Common.BackgroundJobs; -using APITemplate.Application.Features.Examples.DTOs; -using Asp.Versioning; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace APITemplate.Api.Controllers.V1; - -[ApiVersion(1.0)] -/// -/// Presentation-layer controller that receives inbound webhook payloads, validates the -/// HMAC signature via , and enqueues them for -/// asynchronous processing (max 1 MB). -/// -public sealed class WebhooksController : ApiControllerBase -{ - private readonly IWebhookProcessingQueue _queue; - - public WebhooksController(IWebhookProcessingQueue queue) => _queue = queue; - - /// - /// Validates the HMAC signature on the incoming payload and enqueues it for background - /// processing, returning 200 immediately to the sender. - /// - [HttpPost] - [AllowAnonymous] - [ValidateWebhookSignature] - [RequestSizeLimit(1024 * 1024)] // 1 MB max for webhook payloads - public async Task Receive( - [FromBody] WebhookPayload payload, - CancellationToken ct - ) - { - await _queue.EnqueueAsync(payload, ct); - return Ok(); - } -} diff --git a/absolute/src/APITemplate.Api/Api/ErrorOrMapping/ErrorOrExtensions.cs b/absolute/src/APITemplate.Api/Api/ErrorOrMapping/ErrorOrExtensions.cs deleted file mode 100644 index 36a5767a..00000000 --- a/absolute/src/APITemplate.Api/Api/ErrorOrMapping/ErrorOrExtensions.cs +++ /dev/null @@ -1,157 +0,0 @@ -using APITemplate.Api.Controllers; -using ErrorOr; -using Microsoft.AspNetCore.Mvc; - -namespace APITemplate.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 - ) - { - var problemDetails = BuildProblemDetails(errors, controller); - return new ObjectResult(problemDetails) { StatusCode = problemDetails.Status }; - } - - private static ProblemDetails BuildProblemDetails( - List errors, - ControllerBase controller - ) - { - var firstError = errors[0]; - var statusCode = MapToStatusCode(firstError.Type); - var title = MapToTitle(firstError.Type); - var errorCode = firstError.Code; - var detail = firstError.Description; - - if (errors.Count > 1 && firstError.Type == ErrorType.Validation) - detail = string.Join(" ", errors.Select(e => e.Description)); - - var problemDetails = new ProblemDetails - { - Status = statusCode, - Title = title, - Detail = detail, - Instance = controller.HttpContext.Request.Path, - Type = BuildTypeUri(errorCode), - }; - - problemDetails.Extensions["errorCode"] = errorCode; - problemDetails.Extensions["traceId"] = controller.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/absolute/src/APITemplate.Api/Api/ExceptionHandling/ApiExceptionHandler.cs b/absolute/src/APITemplate.Api/Api/ExceptionHandling/ApiExceptionHandler.cs deleted file mode 100644 index a88d4748..00000000 --- a/absolute/src/APITemplate.Api/Api/ExceptionHandling/ApiExceptionHandler.cs +++ /dev/null @@ -1,203 +0,0 @@ -using APITemplate.Domain.Exceptions; -using APITemplate.Infrastructure.Observability; -using Microsoft.AspNetCore.Diagnostics; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; - -namespace APITemplate.Api.ExceptionHandling; - -/// -/// Central REST exception translator for the HTTP pipeline. -/// -/// -/// Converts domain/application exceptions to RFC7807 -/// responses with stable status codes and error codes. GraphQL requests are intentionally -/// bypassed because GraphQL uses a separate error handling pipeline. -/// -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 - ) - { - // GraphQL has its own error format and middleware, so let that pipeline handle GraphQL exceptions. - if (context.Request.Path.StartsWithSegments(TelemetryPathPrefixes.GraphQl)) - return false; - - if (IsClientAbortedRequest(context, exception, cancellationToken)) - { - if (!context.Response.HasStarted) - context.Response.StatusCode = ClientClosedRequestStatusCode; - - return true; - } - - var (statusCode, title, detail, errorCode, metadata) = Resolve(exception); - var 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.UnhandledException(exception, statusCode, errorCode, context.TraceIdentifier); - } - else - { - _logger.HandledApplicationException( - exception, - statusCode, - errorCode, - context.TraceIdentifier - ); - } - - ApiMetrics.RecordHandledException(statusCode, errorCode, exception.GetType().Name); - - ConflictTelemetry.Record(exception, errorCode); - - context.Response.StatusCode = statusCode; - var 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) - { - var (statusCode, title, defaultErrorCode) = MapToHttp(appException); - var 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 var 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/absolute/src/APITemplate.Api/Api/ExceptionHandling/ApiExceptionHandlerLogs.cs b/absolute/src/APITemplate.Api/Api/ExceptionHandling/ApiExceptionHandlerLogs.cs deleted file mode 100644 index 6b77de85..00000000 --- a/absolute/src/APITemplate.Api/Api/ExceptionHandling/ApiExceptionHandlerLogs.cs +++ /dev/null @@ -1,48 +0,0 @@ -using APITemplate.Infrastructure.Logging; - -namespace APITemplate.Api.ExceptionHandling; - -/// -/// Source-generated logging contract for . -/// Keeps log templates and event identifiers centralized, strongly typed, and allocation-friendly. -/// -internal static partial class ApiExceptionHandlerLogs -{ - /// - /// Logs an unhandled server-side exception (typically HTTP 5xx). - /// - /// Target logger instance. - /// Captured exception to attach to the log event. - /// HTTP status code returned to the client. - /// Application error code. Classified as sensitive for redaction. - /// Request trace identifier. Classified as personal for redaction. - [LoggerMessage( - EventId = 1001, - Level = LogLevel.Error, - 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); - - /// - /// Logs a handled application exception (typically HTTP 4xx). - /// - /// Target logger instance. - /// Captured exception to attach to the log event. - /// HTTP status code returned to the client. - /// Application error code. Classified as sensitive for redaction. - /// Request trace identifier. Classified as personal for redaction. - [LoggerMessage( - EventId = 1002, - Level = LogLevel.Warning, - 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); -} diff --git a/absolute/src/APITemplate.Api/Api/ExceptionHandling/ApiProblemDetailsOptions.cs b/absolute/src/APITemplate.Api/Api/ExceptionHandling/ApiProblemDetailsOptions.cs deleted file mode 100644 index 8526fb55..00000000 --- a/absolute/src/APITemplate.Api/Api/ExceptionHandling/ApiProblemDetailsOptions.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace APITemplate.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 => - { - var extensions = context.ProblemDetails.Extensions; - extensions["traceId"] = context.HttpContext.TraceIdentifier; - - // Preserve errorCode set by upstream handlers; only fall back when not provided. - var errorCode = - extensions.TryGetValue("errorCode", out var existingErrorCode) - && existingErrorCode is string existing - ? existing - : ErrorCatalog.General.Unknown; - - extensions["errorCode"] = errorCode; - context.ProblemDetails.Type ??= $"https://api-template.local/errors/{errorCode}"; - }; - } -} diff --git a/absolute/src/APITemplate.Api/Api/Filters/Idempotency/IdempotencyActionFilter.cs b/absolute/src/APITemplate.Api/Api/Filters/Idempotency/IdempotencyActionFilter.cs deleted file mode 100644 index aa5ebab6..00000000 --- a/absolute/src/APITemplate.Api/Api/Filters/Idempotency/IdempotencyActionFilter.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System.Net.Mime; -using System.Text.Json; -using APITemplate.Application.Common.Contracts; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; - -namespace APITemplate.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 - ) - { - var attribute = context - .ActionDescriptor.EndpointMetadata.OfType() - .FirstOrDefault(); - - if (attribute is null) - { - await next(); - return; - } - - if ( - !context.HttpContext.Request.Headers.TryGetValue( - IdempotencyConstants.HeaderName, - out var keyValues - ) || string.IsNullOrWhiteSpace(keyValues) - ) - { - context.Result = new BadRequestObjectResult( - "Idempotency-Key header is required for this endpoint." - ); - return; - } - - var key = keyValues.ToString(); - if (key.Length > IdempotencyConstants.MaxKeyLength) - { - context.Result = new BadRequestObjectResult( - $"Idempotency key must not exceed {IdempotencyConstants.MaxKeyLength} characters." - ); - return; - } - - var resultTtl = TimeSpan.FromHours(attribute.TtlHours); - var lockTimeout = TimeSpan.FromSeconds(attribute.LockTimeoutSeconds); - var ct = context.HttpContext.RequestAborted; - - var 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 - ) - { - var responseBody = objectResult.Value is not null - ? JsonSerializer.Serialize(objectResult.Value, JsonSerializerOptions.Web) - : null; - - string? locationHeader = executedContext.Result switch - { - CreatedResult cr => cr.Location, - _ => null, - }; - - var 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/absolute/src/APITemplate.Api/Api/Filters/Idempotency/IdempotencyConstants.cs b/absolute/src/APITemplate.Api/Api/Filters/Idempotency/IdempotencyConstants.cs deleted file mode 100644 index e846a058..00000000 --- a/absolute/src/APITemplate.Api/Api/Filters/Idempotency/IdempotencyConstants.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Api/Api/Filters/Idempotency/IdempotentAttribute.cs b/absolute/src/APITemplate.Api/Api/Filters/Idempotency/IdempotentAttribute.cs deleted file mode 100644 index 5b5ae760..00000000 --- a/absolute/src/APITemplate.Api/Api/Filters/Idempotency/IdempotentAttribute.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Api/Api/Filters/Validation/FluentValidationActionFilter.cs b/absolute/src/APITemplate.Api/Api/Filters/Validation/FluentValidationActionFilter.cs deleted file mode 100644 index 84c44e5c..00000000 --- a/absolute/src/APITemplate.Api/Api/Filters/Validation/FluentValidationActionFilter.cs +++ /dev/null @@ -1,69 +0,0 @@ -using APITemplate.Infrastructure.Observability; -using FluentValidation; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; - -namespace APITemplate.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 (var argument in context.ActionArguments.Values) - { - if (argument is null) - continue; - - var argumentType = argument.GetType(); - var validatorType = typeof(IValidator<>).MakeGenericType(argumentType); - var validator = _serviceProvider.GetService(validatorType) as IValidator; - - if (validator is null) - continue; - - var validationContext = new ValidationContext(argument); - var result = await validator.ValidateAsync( - validationContext, - context.HttpContext.RequestAborted - ); - - if (result.IsValid) - continue; - - ValidationTelemetry.RecordValidationFailure(context, argumentType, result.Errors); - foreach (var 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/absolute/src/APITemplate.Api/Api/Filters/Webhooks/ValidateWebhookSignatureAttribute.cs b/absolute/src/APITemplate.Api/Api/Filters/Webhooks/ValidateWebhookSignatureAttribute.cs deleted file mode 100644 index 0564ced2..00000000 --- a/absolute/src/APITemplate.Api/Api/Filters/Webhooks/ValidateWebhookSignatureAttribute.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace APITemplate.Api.Filters.Webhooks; - -/// -/// Marks an action method as requiring webhook HMAC signature validation. -/// The actual enforcement is performed by . -/// -[AttributeUsage(AttributeTargets.Method)] -public sealed class ValidateWebhookSignatureAttribute : Attribute; diff --git a/absolute/src/APITemplate.Api/Api/Filters/Webhooks/WebhookSignatureResourceFilter.cs b/absolute/src/APITemplate.Api/Api/Filters/Webhooks/WebhookSignatureResourceFilter.cs deleted file mode 100644 index 9cb02385..00000000 --- a/absolute/src/APITemplate.Api/Api/Filters/Webhooks/WebhookSignatureResourceFilter.cs +++ /dev/null @@ -1,71 +0,0 @@ -using APITemplate.Application.Common.Contracts; -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Features.Examples.DTOs; -using APITemplate.Domain.Exceptions; -using Microsoft.AspNetCore.Mvc.Filters; - -namespace APITemplate.Api.Filters.Webhooks; - -/// -/// Resource filter that validates the HMAC signature of incoming webhook requests for -/// actions decorated with . -/// Reads the raw request body (with buffering enabled) and delegates to . -/// Throws if required headers are absent or the signature is invalid. -/// -public sealed class WebhookSignatureResourceFilter : IAsyncResourceFilter -{ - private readonly IWebhookPayloadValidator _validator; - - public WebhookSignatureResourceFilter(IWebhookPayloadValidator validator) - { - _validator = validator; - } - - /// - /// Validates the webhook signature before the action executes; passes through unchanged - /// if the endpoint does not carry . - /// - public async Task OnResourceExecutionAsync( - ResourceExecutingContext context, - ResourceExecutionDelegate next - ) - { - var hasAttribute = context.ActionDescriptor.EndpointMetadata.Any(m => - m is ValidateWebhookSignatureAttribute - ); - - if (!hasAttribute) - { - await next(); - return; - } - - var request = context.HttpContext.Request; - - if ( - !request.Headers.TryGetValue(WebhookConstants.SignatureHeader, out var signature) - || !request.Headers.TryGetValue(WebhookConstants.TimestampHeader, out var timestamp) - ) - { - throw new UnauthorizedException( - "Missing required webhook signature headers.", - ErrorCatalog.Examples.WebhookMissingHeaders - ); - } - - request.EnableBuffering(); - using var reader = new StreamReader(request.Body, leaveOpen: true); - var body = await reader.ReadToEndAsync(context.HttpContext.RequestAborted); - request.Body.Position = 0; - - if (!_validator.IsValid(body, signature.ToString(), timestamp.ToString())) - { - throw new UnauthorizedException( - "Invalid webhook signature.", - ErrorCatalog.Examples.WebhookInvalidSignature - ); - } - - await next(); - } -} diff --git a/absolute/src/APITemplate.Api/Api/GraphQL/DataLoaders/ProductReviewsByProductDataLoader.cs b/absolute/src/APITemplate.Api/Api/GraphQL/DataLoaders/ProductReviewsByProductDataLoader.cs deleted file mode 100644 index de1bae66..00000000 --- a/absolute/src/APITemplate.Api/Api/GraphQL/DataLoaders/ProductReviewsByProductDataLoader.cs +++ /dev/null @@ -1,40 +0,0 @@ -using ErrorOr; -using Wolverine; - -namespace APITemplate.Api.GraphQL.DataLoaders; - -/// -/// Hot Chocolate batch data loader that resolves all reviews for a set of product IDs in a -/// single query, preventing the N+1 problem when the GraphQL schema resolves reviews -/// as a field on ProductType. -/// -public sealed class ProductReviewsByProductDataLoader - : BatchDataLoader -{ - private readonly IMessageBus _bus; - - public ProductReviewsByProductDataLoader( - IMessageBus bus, - IBatchScheduler batchScheduler, - DataLoaderOptions options = default! - ) - : base(batchScheduler, options) - { - _bus = bus; - } - - /// - /// Fetches all reviews for the supplied in one round-trip - /// and returns a dictionary keyed by product ID. - /// - protected override async Task< - IReadOnlyDictionary - > LoadBatchAsync(IReadOnlyList productIds, CancellationToken ct) - { - var result = await _bus.InvokeAsync< - ErrorOr> - >(new GetProductReviewsByProductIdsQuery(productIds), ct); - - return result.ToGraphQLResult(); - } -} diff --git a/absolute/src/APITemplate.Api/Api/GraphQL/ErrorOrGraphQLExtensions.cs b/absolute/src/APITemplate.Api/Api/GraphQL/ErrorOrGraphQLExtensions.cs deleted file mode 100644 index 2e26f581..00000000 --- a/absolute/src/APITemplate.Api/Api/GraphQL/ErrorOrGraphQLExtensions.cs +++ /dev/null @@ -1,45 +0,0 @@ -using ErrorOr; -using HotChocolate; - -namespace APITemplate.Api.GraphQL; - -/// -/// Extension methods that convert results to GraphQL-compatible -/// responses. Throws on non-NotFound errors, and returns -/// default for NotFound to preserve nullable query semantics. -/// -public static class ErrorOrGraphQLExtensions -{ - /// - /// Unwraps the value on success, or throws on error. - /// - public static T ToGraphQLResult(this ErrorOr result) - { - if (!result.IsError) - return result.Value; - - var firstError = result.FirstError; - throw new GraphQLException( - ErrorBuilder.New().SetMessage(firstError.Description).SetCode(firstError.Code).Build() - ); - } - - /// - /// Unwraps the value on success, returns default for NotFound errors - /// (preserving nullable query semantics), or throws - /// for other error types. - /// - public static T? ToGraphQLNullableResult(this ErrorOr result) - { - if (!result.IsError) - return result.Value; - - if (result.FirstError.Type == ErrorType.NotFound) - return default; - - var firstError = result.FirstError; - throw new GraphQLException( - ErrorBuilder.New().SetMessage(firstError.Description).SetCode(firstError.Code).Build() - ); - } -} diff --git a/absolute/src/APITemplate.Api/Api/GraphQL/Instrumentation/GraphQlExecutionMetricsListener.cs b/absolute/src/APITemplate.Api/Api/GraphQL/Instrumentation/GraphQlExecutionMetricsListener.cs deleted file mode 100644 index 46390601..00000000 --- a/absolute/src/APITemplate.Api/Api/GraphQL/Instrumentation/GraphQlExecutionMetricsListener.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System.Diagnostics; -using APITemplate.Infrastructure.Observability; -using HotChocolate.Execution; -using HotChocolate.Execution.Instrumentation; -using HotChocolate.Resolvers; - -namespace APITemplate.Api.GraphQL.Instrumentation; - -/// -/// Hot Chocolate diagnostic event listener that records OpenTelemetry metrics for every -/// GraphQL request lifecycle event (duration, errors, cache hits/misses, resolver errors, cost). -/// -public sealed class GraphQlExecutionMetricsListener : ExecutionDiagnosticEventListener -{ - /// - /// Starts timing the request and records duration and error status on completion - /// via the returned scope. - /// - public override IDisposable ExecuteRequest(IRequestContext context) - { - var operationType = GetOperationType(context); - var startedAt = Stopwatch.GetTimestamp(); - - return Scope.Create(() => - { - var hasErrors = - context.Result is IOperationResult operationResult - && operationResult.Errors is { Count: > 0 }; - GraphQlTelemetry.RecordRequest( - operationType, - hasErrors, - Stopwatch.GetElapsedTime(startedAt) - ); - }); - } - - /// Records an unhandled request-level exception metric. - public override void RequestError(IRequestContext context, Exception exception) => - GraphQlTelemetry.RecordRequestError(); - - /// Records a GraphQL syntax parse error metric. - public override void SyntaxError(IRequestContext context, IError error) => - GraphQlTelemetry.RecordSyntaxError(); - - /// Records one validation error metric per error in the list. - public override void ValidationErrors(IRequestContext context, IReadOnlyList errors) - { - for (var i = 0; i < errors.Count; i++) - { - GraphQlTelemetry.RecordValidationError(); - } - } - - /// Records a field-level resolver error metric. - public override void ResolverError(IMiddlewareContext context, IError error) => - GraphQlTelemetry.RecordResolverError(); - - /// Records a document cache miss (document parsed and stored). - public override void AddedDocumentToCache(IRequestContext context) => - GraphQlTelemetry.RecordDocumentCacheMiss(); - - /// Records a document cache hit (document retrieved without re-parsing). - public override void RetrievedDocumentFromCache(IRequestContext context) => - GraphQlTelemetry.RecordDocumentCacheHit(); - - /// Records an operation cache miss (operation plan stored). - public override void AddedOperationToCache(IRequestContext context) => - GraphQlTelemetry.RecordOperationCacheMiss(); - - /// Records an operation cache hit (operation plan reused). - public override void RetrievedOperationFromCache(IRequestContext context) => - GraphQlTelemetry.RecordOperationCacheHit(); - - /// Records field and type cost metrics for the completed operation. - public override void OperationCost( - IRequestContext context, - double fieldCost, - double typeCost - ) => GraphQlTelemetry.RecordOperationCost(fieldCost, typeCost); - - private static string GetOperationType(IRequestContext context) => - context.Operation?.Type.ToString().ToLowerInvariant() ?? TelemetryDefaults.Unknown; - - /// - /// Single-use scope that executes a callback exactly once on disposal, guarded by - /// an interlocked flag to prevent double-recording in concurrent scenarios. - /// - private sealed class Scope : IDisposable - { - private readonly Action _onDispose; - private int _disposed; - - private Scope(Action onDispose) - { - _onDispose = onDispose; - } - - /// Creates a new that invokes when disposed. - public static Scope Create(Action onDispose) => new(onDispose); - - /// Invokes the dispose callback exactly once using an interlocked exchange. - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) == 0) - { - _onDispose(); - } - } - } -} diff --git a/absolute/src/APITemplate.Api/Api/GraphQL/Models/CategoryPageResult.cs b/absolute/src/APITemplate.Api/Api/GraphQL/Models/CategoryPageResult.cs deleted file mode 100644 index 1a46aeb3..00000000 --- a/absolute/src/APITemplate.Api/Api/GraphQL/Models/CategoryPageResult.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace APITemplate.Api.GraphQL.Models; - -/// -/// GraphQL return type that wraps a paginated category result set, implementing -/// so the schema exposes consistent paging fields. -/// -public sealed record CategoryPageResult(PagedResponse Page) - : IPagedItems; diff --git a/absolute/src/APITemplate.Api/Api/GraphQL/Models/CategoryQueryInput.cs b/absolute/src/APITemplate.Api/Api/GraphQL/Models/CategoryQueryInput.cs deleted file mode 100644 index 2cdc6e93..00000000 --- a/absolute/src/APITemplate.Api/Api/GraphQL/Models/CategoryQueryInput.cs +++ /dev/null @@ -1,16 +0,0 @@ -using APITemplate.Application.Common.DTOs; - -namespace APITemplate.Api.GraphQL.Models; - -/// -/// GraphQL input type for querying categories, providing optional text search, -/// sorting, and pagination parameters. -/// -public sealed class CategoryQueryInput -{ - public string? Query { get; init; } - public string? SortBy { get; init; } - public string? SortDirection { get; init; } - public int PageNumber { get; init; } = 1; - public int PageSize { get; init; } = PaginationFilter.DefaultPageSize; -} diff --git a/absolute/src/APITemplate.Api/Api/GraphQL/Models/ProductPageResult.cs b/absolute/src/APITemplate.Api/Api/GraphQL/Models/ProductPageResult.cs deleted file mode 100644 index 850c6ca1..00000000 --- a/absolute/src/APITemplate.Api/Api/GraphQL/Models/ProductPageResult.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace APITemplate.Api.GraphQL.Models; - -/// -/// GraphQL return type that combines a paginated product result set with search facets, -/// implementing both and contracts. -/// -public sealed record ProductPageResult( - PagedResponse Page, - ProductSearchFacetsResponse Facets -) : IPagedItems, IHasFacets; diff --git a/absolute/src/APITemplate.Api/Api/GraphQL/Models/ProductQueryInput.cs b/absolute/src/APITemplate.Api/Api/GraphQL/Models/ProductQueryInput.cs deleted file mode 100644 index b72fd731..00000000 --- a/absolute/src/APITemplate.Api/Api/GraphQL/Models/ProductQueryInput.cs +++ /dev/null @@ -1,23 +0,0 @@ -using APITemplate.Application.Common.DTOs; - -namespace APITemplate.Api.GraphQL.Models; - -/// -/// GraphQL input type for querying products, supporting full-text search, price and date -/// range filters, category constraints, sorting, and pagination. -/// -public sealed class ProductQueryInput -{ - public string? Name { get; init; } - public string? Description { get; init; } - public decimal? MinPrice { get; init; } - public decimal? MaxPrice { get; init; } - public DateTime? CreatedFrom { get; init; } - public DateTime? CreatedTo { get; init; } - public string? SortBy { get; init; } - public string? SortDirection { get; init; } - public int PageNumber { get; init; } = 1; - public int PageSize { get; init; } = PaginationFilter.DefaultPageSize; - public string? Query { get; init; } - public IReadOnlyCollection? CategoryIds { get; init; } -} diff --git a/absolute/src/APITemplate.Api/Api/GraphQL/Models/ProductReviewPageResult.cs b/absolute/src/APITemplate.Api/Api/GraphQL/Models/ProductReviewPageResult.cs deleted file mode 100644 index f2331d37..00000000 --- a/absolute/src/APITemplate.Api/Api/GraphQL/Models/ProductReviewPageResult.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace APITemplate.Api.GraphQL.Models; - -/// -/// GraphQL return type that wraps a paginated product-review result set, implementing -/// for consistent schema paging fields. -/// -public sealed record ProductReviewPageResult(PagedResponse Page) - : IPagedItems; diff --git a/absolute/src/APITemplate.Api/Api/GraphQL/Models/ProductReviewQueryInput.cs b/absolute/src/APITemplate.Api/Api/GraphQL/Models/ProductReviewQueryInput.cs deleted file mode 100644 index df0d8505..00000000 --- a/absolute/src/APITemplate.Api/Api/GraphQL/Models/ProductReviewQueryInput.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace APITemplate.Api.GraphQL.Models; - -/// -/// GraphQL input type for querying product reviews, supporting filters by product, -/// user, rating range, date range, sorting, and pagination. -/// -public sealed class ProductReviewQueryInput -{ - public Guid? ProductId { get; init; } - public Guid? UserId { get; init; } - public int? MinRating { get; init; } - public int? MaxRating { get; init; } - public DateTime? CreatedFrom { get; init; } - public DateTime? CreatedTo { get; init; } - public string? SortBy { get; init; } - public string? SortDirection { get; init; } - public int PageNumber { get; init; } = 1; - public int PageSize { get; init; } = PaginationFilter.DefaultPageSize; -} diff --git a/absolute/src/APITemplate.Api/Api/GraphQL/Mutations/ProductMutations.cs b/absolute/src/APITemplate.Api/Api/GraphQL/Mutations/ProductMutations.cs deleted file mode 100644 index 12815064..00000000 --- a/absolute/src/APITemplate.Api/Api/GraphQL/Mutations/ProductMutations.cs +++ /dev/null @@ -1,59 +0,0 @@ -using APITemplate.Application.Common.Security; -using ErrorOr; -using HotChocolate.Authorization; -using Wolverine; - -namespace APITemplate.Api.GraphQL.Mutations; - -/// -/// Hot Chocolate mutation type that exposes product write operations backed by -/// batch CQRS handlers, enforcing per-operation authorization policies. -/// -[Authorize] -public class ProductMutations -{ - /// Creates one or more products and returns a batch outcome. - [Authorize(Policy = Permission.Products.Create)] - public async Task CreateProducts( - CreateProductsRequest input, - [Service] IMessageBus bus, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new CreateProductsCommand(input), - ct - ); - return result.ToGraphQLResult(); - } - - /// Deletes a single product by ID and returns on success. - [Authorize(Policy = Permission.Products.Delete)] - public async Task DeleteProduct(Guid id, [Service] IMessageBus bus, CancellationToken ct) - { - var result = await bus.InvokeAsync>( - new DeleteProductsCommand(new BatchDeleteRequest([id])), - ct - ); - var batch = result.ToGraphQLResult(); - if (batch.FailureCount > 0) - throw new GraphQLException(string.Join("; ", batch.Failures[0].Errors)); - - return true; - } - - /// Deletes one or more products and returns a batch outcome. - [Authorize(Policy = Permission.Products.Delete)] - public async Task DeleteProducts( - BatchDeleteRequest input, - [Service] IMessageBus bus, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new DeleteProductsCommand(input), - ct - ); - return result.ToGraphQLResult(); - } -} diff --git a/absolute/src/APITemplate.Api/Api/GraphQL/Mutations/ProductReviewMutations.cs b/absolute/src/APITemplate.Api/Api/GraphQL/Mutations/ProductReviewMutations.cs deleted file mode 100644 index 0c350c8d..00000000 --- a/absolute/src/APITemplate.Api/Api/GraphQL/Mutations/ProductReviewMutations.cs +++ /dev/null @@ -1,46 +0,0 @@ -using APITemplate.Application.Common.Security; -using ErrorOr; -using HotChocolate.Authorization; -using Wolverine; - -namespace APITemplate.Api.GraphQL.Mutations; - -/// -/// Hot Chocolate mutation type extension that adds product-review write operations -/// (create and delete) to the root type. -/// -[Authorize] -[ExtendObjectType(typeof(ProductMutations))] -public class ProductReviewMutations -{ - /// Creates a new product review and returns the persisted review. - [Authorize(Policy = Permission.ProductReviews.Create)] - public async Task CreateProductReview( - CreateProductReviewRequest input, - [Service] IMessageBus bus, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new CreateProductReviewCommand(input), - ct - ); - return result.ToGraphQLResult(); - } - - /// Deletes a product review by its ID and returns on success. - [Authorize(Policy = Permission.ProductReviews.Delete)] - public async Task DeleteProductReview( - Guid id, - [Service] IMessageBus bus, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new DeleteProductReviewCommand(id), - ct - ); - _ = result.ToGraphQLResult(); - return true; - } -} diff --git a/absolute/src/APITemplate.Api/Api/GraphQL/Queries/CategoryQueries.cs b/absolute/src/APITemplate.Api/Api/GraphQL/Queries/CategoryQueries.cs deleted file mode 100644 index 3c7e4d13..00000000 --- a/absolute/src/APITemplate.Api/Api/GraphQL/Queries/CategoryQueries.cs +++ /dev/null @@ -1,54 +0,0 @@ -using APITemplate.Api.GraphQL.Models; -using ErrorOr; -using HotChocolate.Authorization; -using Wolverine; - -namespace APITemplate.Api.GraphQL.Queries; - -/// -/// Hot Chocolate query type extension that adds category queries to the -/// root, providing paginated list and single-item lookup operations. -/// -[Authorize] -[ExtendObjectType(typeof(ProductQueries))] -public sealed class CategoryQueries -{ - /// - /// Returns a paginated category list, mapping the GraphQL input to the application-layer - /// filter before dispatching via the message bus. - /// - public async Task GetCategories( - CategoryQueryInput? input, - [Service] IMessageBus bus, - CancellationToken ct - ) - { - var filter = new CategoryFilter( - input?.Query, - input?.SortBy, - input?.SortDirection, - input?.PageNumber ?? 1, - input?.PageSize ?? PaginationFilter.DefaultPageSize - ); - - var result = await bus.InvokeAsync>>( - new GetCategoriesQuery(filter), - ct - ); - return new CategoryPageResult(result.ToGraphQLResult()); - } - - /// Returns a single category by ID, or if not found. - public async Task GetCategoryById( - Guid id, - [Service] IMessageBus bus, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new GetCategoryByIdQuery(id), - ct - ); - return result.ToGraphQLNullableResult(); - } -} diff --git a/absolute/src/APITemplate.Api/Api/GraphQL/Queries/ProductQueries.cs b/absolute/src/APITemplate.Api/Api/GraphQL/Queries/ProductQueries.cs deleted file mode 100644 index cb260b57..00000000 --- a/absolute/src/APITemplate.Api/Api/GraphQL/Queries/ProductQueries.cs +++ /dev/null @@ -1,61 +0,0 @@ -using APITemplate.Api.GraphQL.Models; -using ErrorOr; -using HotChocolate.Authorization; -using Wolverine; - -namespace APITemplate.Api.GraphQL.Queries; - -/// -/// Hot Chocolate root query type that exposes product list and single-product lookups, -/// serving as the extension base for and . -/// -[Authorize] -public class ProductQueries -{ - /// - /// Returns a paginated product list with search facets, mapping the GraphQL input to the - /// application-layer filter before dispatching via the message bus. - /// - public async Task GetProducts( - ProductQueryInput? input, - [Service] IMessageBus bus, - CancellationToken ct - ) - { - var filter = new ProductFilter( - input?.Name, - input?.Description, - input?.MinPrice, - input?.MaxPrice, - input?.CreatedFrom, - input?.CreatedTo, - input?.SortBy, - input?.SortDirection, - input?.PageNumber ?? 1, - input?.PageSize ?? PaginationFilter.DefaultPageSize, - input?.Query, - input?.CategoryIds - ); - - var result = await bus.InvokeAsync>( - new GetProductsQuery(filter), - ct - ); - var page = result.ToGraphQLResult(); - return new ProductPageResult(page.Page, page.Facets); - } - - /// Returns a single product by ID, or if not found. - public async Task GetProductById( - Guid id, - [Service] IMessageBus bus, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new GetProductByIdQuery(id), - ct - ); - return result.ToGraphQLNullableResult(); - } -} diff --git a/absolute/src/APITemplate.Api/Api/GraphQL/Queries/ProductReviewQueries.cs b/absolute/src/APITemplate.Api/Api/GraphQL/Queries/ProductReviewQueries.cs deleted file mode 100644 index e5e0933d..00000000 --- a/absolute/src/APITemplate.Api/Api/GraphQL/Queries/ProductReviewQueries.cs +++ /dev/null @@ -1,81 +0,0 @@ -using APITemplate.Api.GraphQL.Models; -using ErrorOr; -using HotChocolate.Authorization; -using Wolverine; - -namespace APITemplate.Api.GraphQL.Queries; - -/// -/// Hot Chocolate query type extension that adds product-review queries to the -/// root, supporting filtered list, single-item, and -/// per-product lookup operations. -/// -[Authorize] -[ExtendObjectType(typeof(ProductQueries))] -public class ProductReviewQueries -{ - /// - /// Returns a paginated review list, mapping the GraphQL input to the application-layer - /// filter before dispatching via the message bus. - /// - public async Task GetReviews( - ProductReviewQueryInput? input, - [Service] IMessageBus bus, - CancellationToken ct - ) - { - var filter = new ProductReviewFilter( - input?.ProductId, - input?.UserId, - input?.MinRating, - input?.MaxRating, - input?.CreatedFrom, - input?.CreatedTo, - input?.SortBy, - input?.SortDirection, - input?.PageNumber ?? 1, - input?.PageSize ?? 20 - ); - - var result = await bus.InvokeAsync>>( - new GetProductReviewsQuery(filter), - ct - ); - return new ProductReviewPageResult(result.ToGraphQLResult()); - } - - /// Returns a single review by ID, or if not found. - public async Task GetReviewById( - Guid id, - [Service] IMessageBus bus, - CancellationToken ct - ) - { - var result = await bus.InvokeAsync>( - new GetProductReviewByIdQuery(id), - ct - ); - return result.ToGraphQLNullableResult(); - } - - /// Returns a paginated list of reviews scoped to a specific product. - public async Task GetReviewsByProductId( - Guid productId, - int pageNumber, - int pageSize, - [Service] IMessageBus bus, - CancellationToken ct - ) - { - var filter = new ProductReviewFilter( - ProductId: productId, - PageNumber: pageNumber, - PageSize: pageSize - ); - var result = await bus.InvokeAsync>>( - new GetProductReviewsQuery(filter), - ct - ); - return new ProductReviewPageResult(result.ToGraphQLResult()); - } -} diff --git a/absolute/src/APITemplate.Api/Api/GraphQL/Types/ProductReviewType.cs b/absolute/src/APITemplate.Api/Api/GraphQL/Types/ProductReviewType.cs deleted file mode 100644 index 3460e094..00000000 --- a/absolute/src/APITemplate.Api/Api/GraphQL/Types/ProductReviewType.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace APITemplate.Api.GraphQL.Types; - -/// -/// Hot Chocolate object type that maps to the GraphQL schema, -/// annotating each field with descriptions and explicit scalar types. -/// -public sealed class ProductReviewType : ObjectType -{ - /// Configures field descriptions and scalar type mappings for the ProductReview GraphQL type. - protected override void Configure(IObjectTypeDescriptor descriptor) - { - descriptor.Description("Represents a review for a product."); - - descriptor - .Field(r => r.Id) - .Type>() - .Description("The unique identifier of the review."); - - descriptor - .Field(r => r.ProductId) - .Type>() - .Description("The identifier of the reviewed product."); - - descriptor - .Field(r => r.UserId) - .Type>() - .Description("The identifier of the user who wrote the review."); - - descriptor - .Field(r => r.Rating) - .Type>() - .Description("Rating from 1 to 5."); - - descriptor.Field(r => r.Comment).Description("The optional review comment."); - - descriptor - .Field(r => r.CreatedAtUtc) - .Description("The UTC timestamp of when the review was created."); - } -} diff --git a/absolute/src/APITemplate.Api/Api/GraphQL/Types/ProductType.cs b/absolute/src/APITemplate.Api/Api/GraphQL/Types/ProductType.cs deleted file mode 100644 index 0f6476fa..00000000 --- a/absolute/src/APITemplate.Api/Api/GraphQL/Types/ProductType.cs +++ /dev/null @@ -1,51 +0,0 @@ -namespace APITemplate.Api.GraphQL.Types; - -/// -/// Hot Chocolate object type that maps to the GraphQL schema, -/// including a reviews field resolved via to batch-load -/// associated reviews using the data loader. -/// -public sealed class ProductType : ObjectType -{ - /// - /// Configures field descriptions, scalar type mappings, and the batch-loaded reviews - /// resolver for the Product GraphQL type. - /// - protected override void Configure(IObjectTypeDescriptor descriptor) - { - descriptor.Description("Represents a product in the catalog."); - - descriptor - .Field(p => p.Id) - .Type>() - .Description("The unique identifier of the product."); - - descriptor - .Field(p => p.Name) - .Type>() - .Description("The name of the product."); - - descriptor - .Field(p => p.Price) - .Type>() - .Description("The price of the product."); - - descriptor - .Field(p => p.Description) - .Description("The optional description of the product."); - - descriptor - .Field(p => p.ProductDataIds) - .Type>>>() - .Description("The ids of related ProductData documents."); - - descriptor - .Field(p => p.CreatedAtUtc) - .Description("The UTC timestamp of when the product was created."); - - descriptor - .Field("reviews") - .ResolveWith(r => r.GetReviews(default!, default!, default)) - .Description("The reviews associated with this product."); - } -} diff --git a/absolute/src/APITemplate.Api/Api/GraphQL/Types/ProductTypeResolvers.cs b/absolute/src/APITemplate.Api/Api/GraphQL/Types/ProductTypeResolvers.cs deleted file mode 100644 index 60f7280d..00000000 --- a/absolute/src/APITemplate.Api/Api/GraphQL/Types/ProductTypeResolvers.cs +++ /dev/null @@ -1,21 +0,0 @@ -using APITemplate.Api.GraphQL.DataLoaders; - -namespace APITemplate.Api.GraphQL.Types; - -/// -/// Resolver class for the reviews field on . -/// Delegates to to batch-load reviews and -/// returns an empty array when no reviews exist for the product. -/// -public sealed class ProductTypeResolvers -{ - /// - /// Loads reviews for the given via the batch data loader, - /// returning an empty array when the loader yields no result. - /// - public async Task GetReviews( - [Parent] ProductResponse product, - ProductReviewsByProductDataLoader loader, - CancellationToken ct - ) => await loader.LoadAsync(product.Id, ct) ?? Array.Empty(); -} diff --git a/absolute/src/APITemplate.Api/Api/Middleware/CsrfValidationMiddleware.cs b/absolute/src/APITemplate.Api/Api/Middleware/CsrfValidationMiddleware.cs deleted file mode 100644 index 8183cda8..00000000 --- a/absolute/src/APITemplate.Api/Api/Middleware/CsrfValidationMiddleware.cs +++ /dev/null @@ -1,102 +0,0 @@ -using APITemplate.Application.Common.Security; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Diagnostics; - -namespace APITemplate.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. - if ( - context.Request.Headers.TryGetValue("Authorization", out var authorizationValues) - && authorizationValues.Any(static value => - !string.IsNullOrEmpty(value) - && value.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. - var isCookieAuthenticated = context.User.Identities.Any(i => - i.AuthenticationType == AuthConstants.BffSchemes.Cookie - ); - - if (!isCookieAuthenticated) - { - var cookieAuthResult = await context.AuthenticateAsync(AuthConstants.BffSchemes.Cookie); - isCookieAuthenticated = cookieAuthResult.Succeeded; - } - - if (!isCookieAuthenticated) - { - await next(context); - return; - } - - // Cookie-authenticated mutating request — require the custom CSRF header. - if ( - context.Request.Headers.TryGetValue(AuthConstants.Csrf.HeaderName, out var value) - && value == AuthConstants.Csrf.HeaderValue - ) - { - await next(context); - return; - } - - // Header missing or wrong value — reject with 403 and RFC 7807 problem details. - context.Response.StatusCode = StatusCodes.Status403Forbidden; - await problemDetailsService.TryWriteAsync( - new ProblemDetailsContext - { - HttpContext = context, - ProblemDetails = - { - Type = "https://tools.ietf.org/html/rfc7231#section-6.5.3", - Title = "Forbidden", - Status = StatusCodes.Status403Forbidden, - Detail = - $"Cookie-authenticated requests must include the '{AuthConstants.Csrf.HeaderName}: {AuthConstants.Csrf.HeaderValue}' header.", - }, - } - ); - } -} diff --git a/absolute/src/APITemplate.Api/Api/Middleware/RequestContextMiddleware.cs b/absolute/src/APITemplate.Api/Api/Middleware/RequestContextMiddleware.cs deleted file mode 100644 index 63060c9d..00000000 --- a/absolute/src/APITemplate.Api/Api/Middleware/RequestContextMiddleware.cs +++ /dev/null @@ -1,139 +0,0 @@ -using System.Diagnostics; -using System.Security.Claims; -using APITemplate.Application.Common.Http; -using APITemplate.Application.Common.Security; -using APITemplate.Infrastructure.Observability; -using Microsoft.AspNetCore.Http.Features; -using Serilog.Context; - -namespace APITemplate.Api.Middleware; - -/// -/// Adds per-request context metadata used by logs and clients. -/// -/// -/// In the current pipeline ordering this middleware runs after -/// app.UseExceptionHandler(), so thrown exceptions are still wrapped by -/// global exception handling while correlation and timing headers are maintained. -/// -public sealed class RequestContextMiddleware -{ - private readonly RequestDelegate _next; - - /// - /// Creates a new . - /// - /// The next middleware in the pipeline. - public RequestContextMiddleware(RequestDelegate next) - { - _next = next; - } - - /// - /// Enriches the request/response with correlation, tracing, timing, and tenant metadata. - /// - /// - /// - /// Ensures a consistent X-Correlation-Id is returned to clients and logged. - /// Emits X-Trace-Id and X-Elapsed-Ms headers for observability. - /// Adds Serilog properties for per-request logging context. - /// Tags prometheus metrics (if enabled) via . - /// - /// - /// The current . - public async Task InvokeAsync(HttpContext context) - { - // Ensure every request has a stable, traceable correlation ID. - var correlationId = ResolveCorrelationId(context); - - // Track elapsed time for the full request pipeline. - var stopwatch = Stopwatch.StartNew(); - - // Prefer OpenTelemetry trace id when available, otherwise use ASP.NET trace id. - var traceId = Activity.Current?.TraceId.ToHexString() ?? context.TraceIdentifier; - - // Capture tenant id from the authenticated user (for logs/telemetry). - var tenantId = context.User.FindFirstValue(AuthConstants.Claims.TenantId); - var effectiveTenantId = !string.IsNullOrWhiteSpace(tenantId) ? tenantId : string.Empty; - - if (!string.IsNullOrWhiteSpace(effectiveTenantId)) - { - // Tag the current activity for distributed tracing. - Activity.Current?.SetTag(TelemetryTagKeys.TenantId, effectiveTenantId); - } - - // Make the correlation ID available to downstream components. - context.Items[RequestContextConstants.ContextKeys.CorrelationId] = correlationId; - - // Emit headers for clients/proxies to consume. - context.Response.Headers[RequestContextConstants.Headers.CorrelationId] = correlationId; - context.Response.Headers[RequestContextConstants.Headers.TraceId] = traceId; - context.Response.Headers[RequestContextConstants.Headers.ElapsedMs] = "0"; - - // Update elapsed-time header when the response is about to be sent. - context.Response.OnStarting(() => - { - context.Response.Headers[RequestContextConstants.Headers.ElapsedMs] = - stopwatch.ElapsedMilliseconds.ToString(); - return Task.CompletedTask; - }); - - try - { - // Enrich Serilog context so all downstream logs in this request include these properties. - using ( - LogContext.PushProperty( - RequestContextConstants.LogProperties.CorrelationId, - correlationId - ) - ) - using ( - LogContext.PushProperty( - RequestContextConstants.LogProperties.TenantId, - effectiveTenantId - ) - ) - { - await _next(context); - } - } - finally - { - // If metrics are enabled, attach tags for the current request. - var metricsTagsFeature = context.Features.Get(); - if (metricsTagsFeature is not null) - { - metricsTagsFeature.Tags.Add( - new( - TelemetryTagKeys.ApiSurface, - TelemetryApiSurfaceResolver.Resolve(context.Request.Path) - ) - ); - metricsTagsFeature.Tags.Add( - new( - TelemetryTagKeys.Authenticated, - context.User.Identity?.IsAuthenticated == true - ) - ); - } - } - } - - /// - /// Resolves a correlation ID for the current request. - /// - /// - /// If the caller supplied X-Correlation-Id, that value is returned. - /// Otherwise, it falls back to ASP.NET Core's . - /// - private static string ResolveCorrelationId(HttpContext context) - { - var incoming = context - .Request.Headers[RequestContextConstants.Headers.CorrelationId] - .ToString(); - if (!string.IsNullOrWhiteSpace(incoming)) - return incoming; - - return context.TraceIdentifier; - } -} diff --git a/absolute/src/APITemplate.Api/Api/OpenApi/AuthorizationResponsesOperationTransformer.cs b/absolute/src/APITemplate.Api/Api/OpenApi/AuthorizationResponsesOperationTransformer.cs deleted file mode 100644 index 8f25bf19..00000000 --- a/absolute/src/APITemplate.Api/Api/OpenApi/AuthorizationResponsesOperationTransformer.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.OpenApi; -using Microsoft.OpenApi; - -namespace APITemplate.Api.OpenApi; - -/// -/// Adds 401/403 responses only for operations that require authorization metadata. -/// This avoids brittle path-based heuristics. -/// -public sealed class AuthorizationResponsesOperationTransformer : IOpenApiOperationTransformer -{ - /// - /// Inspects the endpoint metadata and appends 401/403 response entries when the operation - /// requires authorization and does not allow anonymous access. - /// - public Task TransformAsync( - OpenApiOperation operation, - OpenApiOperationTransformerContext context, - CancellationToken cancellationToken - ) - { - var endpointMetadata = context.Description.ActionDescriptor.EndpointMetadata; - var hasAllowAnonymous = endpointMetadata.OfType().Any(); - var hasAuthorize = endpointMetadata.OfType().Any(); - - if (hasAuthorize && !hasAllowAnonymous) - { - OpenApiErrorResponseHelper.AddErrorResponse( - operation, - StatusCodes.Status401Unauthorized - ); - OpenApiErrorResponseHelper.AddErrorResponse(operation, StatusCodes.Status403Forbidden); - } - - return Task.CompletedTask; - } -} diff --git a/absolute/src/APITemplate.Api/Api/OpenApi/BearerSecuritySchemeDocumentTransformer.cs b/absolute/src/APITemplate.Api/Api/OpenApi/BearerSecuritySchemeDocumentTransformer.cs deleted file mode 100644 index b96c27d9..00000000 --- a/absolute/src/APITemplate.Api/Api/OpenApi/BearerSecuritySchemeDocumentTransformer.cs +++ /dev/null @@ -1,82 +0,0 @@ -using APITemplate.Application.Common.Options; -using APITemplate.Application.Common.Security; -using APITemplate.Infrastructure.Security; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.OpenApi; -using Microsoft.Extensions.Options; -using Microsoft.OpenApi; - -namespace APITemplate.Api.OpenApi; - -/// -/// OpenAPI document transformer that registers a Keycloak OAuth2 Authorization Code security scheme -/// and adds a global security requirement so Swagger UI can authenticate against the configured realm. -/// -public sealed class BearerSecuritySchemeDocumentTransformer : IOpenApiDocumentTransformer -{ - private readonly IAuthenticationSchemeProvider _schemeProvider; - private readonly KeycloakOptions _keycloak; - - public BearerSecuritySchemeDocumentTransformer( - IAuthenticationSchemeProvider schemeProvider, - IOptions keycloakOptions - ) - { - _schemeProvider = schemeProvider; - _keycloak = keycloakOptions.Value; - } - - /// - /// Adds the Keycloak OAuth2 security scheme and a global security requirement to the OpenAPI document, - /// skipping transformation if the JWT Bearer authentication scheme is not registered. - /// - public async Task TransformAsync( - OpenApiDocument document, - OpenApiDocumentTransformerContext context, - CancellationToken cancellationToken - ) - { - var schemes = await _schemeProvider.GetAllSchemesAsync(); - if (!schemes.Any(s => s.Name == JwtBearerDefaults.AuthenticationScheme)) - return; - - var authority = KeycloakUrlHelper.BuildAuthority(_keycloak.AuthServerUrl, _keycloak.Realm); - - var securityScheme = new OpenApiSecurityScheme - { - Type = SecuritySchemeType.OAuth2, - Description = "Keycloak OAuth2 Authorization Code flow", - Flows = new OpenApiOAuthFlows - { - AuthorizationCode = new OpenApiOAuthFlow - { - AuthorizationUrl = new Uri( - $"{authority}/{AuthConstants.OpenIdConnect.AuthorizationEndpointPath}" - ), - TokenUrl = new Uri( - $"{authority}/{AuthConstants.OpenIdConnect.TokenEndpointPath}" - ), - Scopes = new Dictionary - { - [AuthConstants.Scopes.OpenId] = "OpenID Connect", - [AuthConstants.Scopes.Profile] = "User profile", - [AuthConstants.Scopes.Email] = "Email address", - }, - }, - }, - }; - - var components = document.Components ??= new OpenApiComponents(); - components.SecuritySchemes ??= new Dictionary(); - components.SecuritySchemes[AuthConstants.OpenApi.OAuth2Scheme] = securityScheme; - - var requirement = new OpenApiSecurityRequirement(); - requirement[ - new OpenApiSecuritySchemeReference(AuthConstants.OpenApi.OAuth2Scheme, document, null) - ] = [AuthConstants.Scopes.OpenId]; - - document.Security ??= []; - document.Security.Add(requirement); - } -} diff --git a/absolute/src/APITemplate.Api/Api/OpenApi/HealthCheckOpenApiDocumentTransformer.cs b/absolute/src/APITemplate.Api/Api/OpenApi/HealthCheckOpenApiDocumentTransformer.cs deleted file mode 100644 index 25aaa238..00000000 --- a/absolute/src/APITemplate.Api/Api/OpenApi/HealthCheckOpenApiDocumentTransformer.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.OpenApi; -using Microsoft.OpenApi; - -namespace APITemplate.Api.OpenApi; - -/// -/// OpenAPI document transformer that manually registers the /health endpoint in the -/// generated specification, since health-check endpoints are not discovered automatically by ASP.NET Core OpenAPI. -/// -public sealed class HealthCheckOpenApiDocumentTransformer : IOpenApiDocumentTransformer -{ - /// - /// Adds a GET /health path item with 200 and 503 response descriptions to the document. - /// - public Task TransformAsync( - OpenApiDocument document, - OpenApiDocumentTransformerContext context, - CancellationToken cancellationToken - ) - { - document.Paths ??= new OpenApiPaths(); - - var pathItem = new OpenApiPathItem(); - pathItem.AddOperation( - HttpMethod.Get, - new OpenApiOperation - { - Tags = new HashSet - { - new OpenApiTagReference("Health", document, null), - }, - Summary = "Health check", - Description = "Returns the health status of all registered services.", - Responses = new OpenApiResponses - { - [StatusCodes.Status200OK.ToString()] = new OpenApiResponse - { - Description = "Healthy - all services are running", - }, - [StatusCodes.Status503ServiceUnavailable.ToString()] = new OpenApiResponse - { - Description = "Unhealthy - one or more services are down", - }, - }, - } - ); - - document.Paths["/health"] = pathItem; - - return Task.CompletedTask; - } -} diff --git a/absolute/src/APITemplate.Api/Api/OpenApi/OpenApiErrorResponseHelper.cs b/absolute/src/APITemplate.Api/Api/OpenApi/OpenApiErrorResponseHelper.cs deleted file mode 100644 index f1b03675..00000000 --- a/absolute/src/APITemplate.Api/Api/OpenApi/OpenApiErrorResponseHelper.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Microsoft.AspNetCore.WebUtilities; -using Microsoft.OpenApi; - -namespace APITemplate.Api.OpenApi; - -/// -/// Shared helper for adding RFC 7807 application/problem+json error response entries -/// to OpenAPI operations without duplicating the wiring across multiple transformers. -/// -internal static class OpenApiErrorResponseHelper -{ - /// - /// Adds an error response for to the operation if one is not already present. - /// Uses the HTTP reason phrase as the description when is not supplied. - /// - internal static void AddErrorResponse( - OpenApiOperation operation, - int statusCode, - IOpenApiSchema? schema = null, - string? description = null - ) - { - var statusCodeKey = statusCode.ToString(); - operation.Responses ??= new OpenApiResponses(); - if (operation.Responses.ContainsKey(statusCodeKey)) - return; - - var 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/absolute/src/APITemplate.Api/Api/OpenApi/ProblemDetailsOpenApiTransformer.cs b/absolute/src/APITemplate.Api/Api/OpenApi/ProblemDetailsOpenApiTransformer.cs deleted file mode 100644 index 5293246e..00000000 --- a/absolute/src/APITemplate.Api/Api/OpenApi/ProblemDetailsOpenApiTransformer.cs +++ /dev/null @@ -1,101 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.OpenApi; -using Microsoft.OpenApi; - -namespace APITemplate.Api.OpenApi; - -/// -/// Improved alternative to repeating [ProducesResponseType(typeof(ProblemDetails), 400/404/500)] -/// on every controller action. This transformer adds ProblemDetails responses globally -/// and avoids duplication across individual controllers. -/// -public sealed class ProblemDetailsOpenApiTransformer : IOpenApiDocumentTransformer -{ - /// - /// Registers the shared ApiProblemDetails schema component and attaches standardized - /// error responses (400, 401, 403, 404, 409, 500) to every operation in the document. - /// - public Task TransformAsync( - OpenApiDocument document, - OpenApiDocumentTransformerContext context, - CancellationToken cancellationToken - ) - { - document.Components ??= new OpenApiComponents(); - document.Components.Schemas ??= new Dictionary(); - - var problemDetailsSchema = BuildProblemDetailsSchema(); - document.Components.Schemas["ApiProblemDetails"] = problemDetailsSchema; - - foreach (var pathEntry in document.Paths) - { - var path = pathEntry.Value; - if (path.Operations is null) - continue; - - foreach (var operation in path.Operations.Values) - { - int[] errorStatusCodes = - [ - StatusCodes.Status400BadRequest, - StatusCodes.Status401Unauthorized, - StatusCodes.Status403Forbidden, - StatusCodes.Status404NotFound, - StatusCodes.Status409Conflict, - StatusCodes.Status500InternalServerError, - ]; - foreach (var statusCode in errorStatusCodes) - OpenApiErrorResponseHelper.AddErrorResponse( - operation, - statusCode, - problemDetailsSchema - ); - } - } - - return Task.CompletedTask; - } - - /// - /// Builds the reusable OpenAPI schema for the RFC 7807 ProblemDetails response payload, - /// including the custom traceId, errorCode, and metadata extensions. - /// - private static IOpenApiSchema BuildProblemDetailsSchema() - { - return new OpenApiSchema - { - Type = JsonSchemaType.Object, - Description = "Standard RFC 7807 ProblemDetails payload used by REST error responses.", - Properties = new Dictionary - { - ["type"] = new OpenApiSchema - { - Type = JsonSchemaType.String, - Description = "Error documentation URI.", - }, - ["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/absolute/src/APITemplate.Api/Api/Requests/FileUploadRequest.cs b/absolute/src/APITemplate.Api/Api/Requests/FileUploadRequest.cs deleted file mode 100644 index 335627b2..00000000 --- a/absolute/src/APITemplate.Api/Api/Requests/FileUploadRequest.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace APITemplate.Api.Requests; - -/// -/// Represents the multipart form-data payload for a file upload endpoint, -/// carrying the required file stream and an optional free-text description. -/// -public sealed class FileUploadRequest -{ - [Required] - public IFormFile File { get; init; } = null!; - - public string? Description { get; init; } -} diff --git a/absolute/src/APITemplate.Api/Dockerfile b/absolute/src/APITemplate.Api/Dockerfile deleted file mode 100644 index 06262fc8..00000000 --- a/absolute/src/APITemplate.Api/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base -WORKDIR /app -EXPOSE 8080 - -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -WORKDIR /src -COPY ["Directory.Packages.props", "./"] -COPY ["src/APITemplate/Api/APITemplate.csproj", "APITemplate.Api/"] -COPY ["src/APITemplate.Application/APITemplate.Application.csproj", "APITemplate.Application/"] -COPY ["src/APITemplate.Domain/APITemplate.Domain.csproj", "APITemplate.Domain/"] -COPY ["src/APITemplate.Infrastructure/APITemplate.Infrastructure.csproj", "APITemplate.Infrastructure/"] -RUN dotnet restore "APITemplate/Api/APITemplate.csproj" -COPY src/ ./ -WORKDIR "/src/APITemplate/Api" -RUN dotnet build -c Release -o /app/build - -FROM build AS publish -RUN dotnet publish -c Release -o /app/publish /p:UseAppHost=false - -FROM base AS final -WORKDIR /app -COPY --from=publish /app/publish . -ENTRYPOINT ["dotnet", "APITemplate.dll"] diff --git a/absolute/src/APITemplate.Api/Extensions/ApiServiceCollectionExtensions.cs b/absolute/src/APITemplate.Api/Extensions/ApiServiceCollectionExtensions.cs deleted file mode 100644 index 9747d4e7..00000000 --- a/absolute/src/APITemplate.Api/Extensions/ApiServiceCollectionExtensions.cs +++ /dev/null @@ -1,290 +0,0 @@ -using System.Threading.RateLimiting; -using APITemplate.Api.Cache; -using APITemplate.Api.ExceptionHandling; -using APITemplate.Api.Filters.Idempotency; -using APITemplate.Api.Filters.Webhooks; -using APITemplate.Api.OpenApi; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Common.Http; -using APITemplate.Application.Common.Options; -using APITemplate.Infrastructure.Health; -using APITemplate.Infrastructure.Observability; -using Microsoft.AspNetCore.DataProtection; -using Microsoft.AspNetCore.RateLimiting; -using Microsoft.Extensions.Options; -using Serilog; -using StackExchange.Redis; - -namespace APITemplate.Api.Extensions; - -public static class ApiServiceCollectionExtensions -{ - /// - /// Registers core API services (controllers, OpenAPI, ProblemDetails) and - /// exception handling dependencies. - /// - /// - /// This method only registers exception handling services in DI - /// (including ). Runtime exception interception - /// is activated later by calling app.UseExceptionHandler() in the middleware pipeline. - /// - public static IServiceCollection AddApiFoundation( - this IServiceCollection services, - IConfiguration configuration - ) - { - // Controllers are the foundation of the Web API pipeline and must be registered first. - services.AddControllers(options => - { - options.Filters.AddService(); - options.Filters.AddService(); - }); - - services.AddScoped(); - services.AddScoped(); - services.AddIdempotencyStore(); - - services - // Register the exception / ProblemDetails handling infrastructure (RFC 7807 error payloads). - .AddProblemDetailsAndExceptionHandling() - // Register OpenAPI/Swagger generation and documentation transformers. - .AddOpenApiDocumentation() - // Configure per-client rate limiting to protect against abuse. - .AddRateLimiting(configuration) - // Configure output cache storage + data protection (DragonFly/Redis or in-memory fallback). - .AddDragonflyAndDataProtection(configuration) - // Configure output caching policies for controller endpoints. - .AddOutputCaching(configuration); - - return services; - } - - private static IServiceCollection AddProblemDetailsAndExceptionHandling( - this IServiceCollection services - ) - { - // Registers ProblemDetails support (RFC 7807) so errors are returned as structured JSON. - // Configure mapping from exceptions to ProblemDetails types via ApiProblemDetailsOptions. - services.AddProblemDetails(ApiProblemDetailsOptions.Configure); - - // Registers the handler in DI; middleware activation happens in UseApiPipeline via app.UseExceptionHandler(). - services.AddExceptionHandler(); - return services; - } - - private static IServiceCollection AddOpenApiDocumentation(this IServiceCollection services) - { - services.AddOpenApi(options => - { - options.AddDocumentTransformer(); - options.AddDocumentTransformer(); - options.AddDocumentTransformer(); - options.AddOperationTransformer(); - }); - - return services; - } - - private static IServiceCollection AddRateLimiting( - this IServiceCollection services, - IConfiguration configuration - ) - { - services.AddValidatedOptions(configuration); - - // Per-client fixed window rate limiter. Partition key priority: - // 1. JWT username (authenticated users) - // 2. Remote IP address (anonymous users) - // 3. "anonymous" fallback (shared bucket when neither is available) - // IConfigureOptions is used so values are resolved from DI at first request, - // not captured at registration time — this allows tests to override RateLimitingOptions. - services.AddRateLimiter(options => - { - options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; - options.OnRejected = (context, _) => - { - var endpoint = HttpRouteResolver.Resolve(context.HttpContext); - ApiMetrics.RecordRateLimitRejection( - RateLimitPolicies.Fixed, - context.HttpContext.Request.Method, - endpoint - ); - return ValueTask.CompletedTask; - }; - }); - - services.AddSingleton>(sp => - { - var rateLimitOpts = sp.GetRequiredService>().Value; - return new ConfigureOptions(o => - o.AddPolicy( - RateLimitPolicies.Fixed, - httpContext => - RateLimitPartition.GetFixedWindowLimiter( - partitionKey: httpContext.User.Identity?.Name - ?? httpContext.Connection.RemoteIpAddress?.ToString() - ?? "anonymous", - factory: _ => new FixedWindowRateLimiterOptions - { - PermitLimit = rateLimitOpts.PermitLimit, - Window = TimeSpan.FromMinutes(rateLimitOpts.WindowMinutes), - } - ) - ) - ); - }); - - return services; - } - - private static IServiceCollection AddDragonflyAndDataProtection( - this IServiceCollection services, - IConfiguration configuration - ) - { - // Output Cache with optional DragonFly backing store. - // When Dragonfly:ConnectionString is configured, cached responses are stored in DragonFly - // so all application instances share the same cache. Without it, falls back to in-memory. - // Each policy defines an expiration time and a tag used for targeted invalidation - // via IOutputCacheStore.EvictByTagAsync() in controllers after mutations (Create/Update/Delete). - var dragonflySection = configuration.SectionFor(); - var dragonflyConnectionString = dragonflySection.GetValue( - nameof(DragonflyOptions.ConnectionString) - ); - - if (!string.IsNullOrEmpty(dragonflyConnectionString)) - { - services.AddValidatedOptions(configuration); - - var connectTimeoutMs = dragonflySection.GetValue( - nameof(DragonflyOptions.ConnectTimeoutMs), - DragonflyOptions.DefaultConnectTimeoutMs - ); - var syncTimeoutMs = dragonflySection.GetValue( - nameof(DragonflyOptions.SyncTimeoutMs), - DragonflyOptions.DefaultSyncTimeoutMs - ); - - var configOptions = ConfigurationOptions.Parse(dragonflyConnectionString); - configOptions.ConnectTimeout = connectTimeoutMs; - configOptions.SyncTimeout = syncTimeoutMs; - configOptions.AbortOnConnectFail = false; - - // Lazy singleton: the TCP connection is established on first use, not at registration time. - // This keeps startup fast and allows tests to replace Redis services before the - // connection is ever attempted. - var lazyMultiplexer = new Lazy(() => - ConnectionMultiplexer.Connect(configOptions) - ); - services.AddSingleton(_ => lazyMultiplexer.Value); - - services.AddStackExchangeRedisOutputCache(options => - { - options.ConnectionMultiplexerFactory = () => Task.FromResult(lazyMultiplexer.Value); - options.InstanceName = RedisInstanceNames.OutputCache; - }); - - services - .AddDataProtection() - .PersistKeysToStackExchangeRedis( - () => lazyMultiplexer.Value.GetDatabase(), - "DataProtection:Keys" - ); - - services.AddSingleton>(sp => - { - var appOptions = sp.GetRequiredService>().Value; - return new ConfigureOptions(o => - o.ApplicationDiscriminator = appOptions.ServiceName - ); - }); - - services.AddStackExchangeRedisCache(options => - { - options.ConnectionMultiplexerFactory = () => Task.FromResult(lazyMultiplexer.Value); - options.InstanceName = RedisInstanceNames.Session; - }); - - services - .AddHealthChecks() - .AddRedis( - dragonflyConnectionString, - name: HealthCheckNames.Dragonfly, - tags: ["cache"] - ); - } - else - { - Log.Warning( - "Dragonfly:ConnectionString is not configured — using in-memory output cache. " - + "This is not suitable for multi-instance deployments" - ); - services.AddDistributedMemoryCache(); - } - - return services; - } - - private static IServiceCollection AddOutputCaching( - this IServiceCollection services, - IConfiguration configuration - ) - { - // Tenant-aware policy varies cache entries by tenant to prevent cross-tenant data leaks. - services.AddSingleton(); - - // Tag-based invalidation service used by domain event handlers to evict stale entries. - services.AddScoped(); - - // Bind expiration settings from "Caching" section with startup validation. - services.AddValidatedOptions(configuration); - - services.AddOutputCache(); - - // Deferred configuration — resolves CachingOptions from DI at first use, - // same pattern as AddRateLimiting above. - services.AddSingleton< - IConfigureOptions - >(sp => - { - var cachingOptions = sp.GetRequiredService>().Value; - return new ConfigureOptions( - options => - { - // No caching by default — only endpoints with explicit [OutputCache] attributes are cached. - options.AddBasePolicy(builder => builder.NoCache()); - - // Each named policy uses tenant-aware isolation, a configurable expiration, - // and a tag matching the policy name for targeted invalidation. - 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), - ]; - - foreach (var (name, expirationSeconds) in policies) - { - options.AddPolicy( - name, - builder => - builder - .AddPolicy() - .Expire(TimeSpan.FromSeconds(expirationSeconds)) - .Tag(name) - ); - } - } - ); - }); - - return services; - } -} diff --git a/absolute/src/APITemplate.Api/Extensions/AuthenticationServiceCollectionExtensions.cs b/absolute/src/APITemplate.Api/Extensions/AuthenticationServiceCollectionExtensions.cs deleted file mode 100644 index 98e90217..00000000 --- a/absolute/src/APITemplate.Api/Extensions/AuthenticationServiceCollectionExtensions.cs +++ /dev/null @@ -1,311 +0,0 @@ -using APITemplate.Api.Authorization; -using APITemplate.Application.Common.Options; -using APITemplate.Application.Common.Resilience; -using APITemplate.Application.Common.Security; -using APITemplate.Domain.Enums; -using APITemplate.Infrastructure.Health; -using APITemplate.Infrastructure.Observability; -using APITemplate.Infrastructure.Security; -using Keycloak.AuthServices.Authorization; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Authentication.OpenIdConnect; -using Microsoft.AspNetCore.Authorization; -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Protocols.OpenIdConnect; -using Microsoft.IdentityModel.Tokens; -using Polly; - -namespace APITemplate.Api.Extensions; - -/// -/// Presentation-layer extension class that configures all authentication and authorization -/// services including Keycloak JWT, BFF cookie/OIDC flows, CORS, and per-permission policies. -/// -public static class AuthenticationServiceCollectionExtensions -{ - /// - /// Registers and validates CORS, BFF, system-identity, bootstrap-tenant, and Keycloak - /// options from configuration without yet configuring any authentication schemes. - /// - public static IServiceCollection AddAuthenticationOptions( - this IServiceCollection services, - IConfiguration configuration, - IHostEnvironment environment - ) - { - var corsSection = configuration.SectionFor(); - services.AddValidatedOptions(configuration); - - var corsOrigins = (corsSection.Get() ?? new CorsOptions()) - .AllowedOrigins.Where(origin => !string.IsNullOrWhiteSpace(origin)) - .Select(origin => origin.Trim()) - .ToArray(); - - if (corsOrigins?.Length > 0) - { - services.AddCors(options => - { - options.AddDefaultPolicy(policy => - { - policy - .WithOrigins(corsOrigins) - .AllowAnyHeader() - .AllowAnyMethod() - .AllowCredentials(); - }); - }); - } - - services.AddValidatedOptions(configuration); - - services.AddValidatedOptions( - configuration, - validateDataAnnotations: false - ); - - services - .AddValidatedOptions(configuration) - .Validate( - o => !string.IsNullOrWhiteSpace(o.Code) && !string.IsNullOrWhiteSpace(o.Name), - "Bootstrap tenant code/name is required" - ); - - services.AddValidatedOptions(configuration); - - return services; - } - - /// - /// Registers the full hybrid authentication pipeline: JWT bearer for API clients, - /// cookie + OIDC for browser BFF clients, session ticket store, and all per-permission - /// authorization policies mapped from . - /// - public static IServiceCollection AddKeycloakBffAuthentication( - this IServiceCollection services, - IConfiguration configuration, - IHostEnvironment environment - ) - { - var authSettings = BuildAuthSettings(configuration); - - ConfigureAuthenticationSchemes(services, authSettings, environment); - ConfigureCookieSessionStore(services); - ConfigureAuthorization(services, configuration); - ConfigureKeycloakInfrastructure(services, configuration); - - return services; - } - - private static AuthSettings BuildAuthSettings(IConfiguration configuration) - { - var keycloak = - configuration.SectionFor().Get() - ?? throw new InvalidOperationException("Keycloak configuration section is missing."); - var bffOptions = - configuration.SectionFor().Get() ?? new BffOptions(); - var authority = KeycloakUrlHelper.BuildAuthority(keycloak.AuthServerUrl, keycloak.Realm); - return new AuthSettings(keycloak, bffOptions, authority); - } - - private static void ConfigureAuthenticationSchemes( - IServiceCollection services, - AuthSettings settings, - IHostEnvironment environment - ) - { - services - .AddAuthentication(options => - { - options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; - }) - .AddJwtBearer(options => ConfigureJwtBearer(options, settings, environment)) - .AddCookie( - AuthConstants.BffSchemes.Cookie, - options => ConfigureCookie(options, settings, environment) - ) - .AddOpenIdConnect( - AuthConstants.BffSchemes.Oidc, - options => ConfigureOpenIdConnect(options, settings, environment) - ); - } - - private static void ConfigureJwtBearer( - JwtBearerOptions options, - AuthSettings settings, - IHostEnvironment environment - ) - { - var isDevelopment = environment.IsDevelopment(); - - options.Authority = settings.Authority; - options.Audience = settings.Keycloak.Resource; - options.RequireHttpsMetadata = !isDevelopment; - options.TokenValidationParameters = new TokenValidationParameters - { - LogTokenId = isDevelopment, - LogValidationExceptions = isDevelopment, - RequireExpirationTime = true, - RequireSignedTokens = true, - RequireAudience = true, - SaveSigninToken = false, - TryAllDecryptionKeys = true, - TryAllIssuerSigningKeys = true, - ValidateActor = false, - ValidateIssuer = true, - ValidateAudience = true, - ValidateIssuerSigningKey = true, - ValidateLifetime = true, - ValidateTokenReplay = false, - ClockSkew = TimeSpan.FromMinutes(5), - }; - options.Events = new JwtBearerEvents - { - OnTokenValidated = TenantClaimValidator.OnTokenValidated, - }; - } - - private static void ConfigureCookie( - CookieAuthenticationOptions options, - AuthSettings settings, - IHostEnvironment environment - ) - { - options.Cookie.Name = settings.Bff.CookieName; - options.Cookie.HttpOnly = true; - options.Cookie.SameSite = SameSiteMode.Lax; - options.Cookie.SecurePolicy = environment.IsDevelopment() - ? CookieSecurePolicy.SameAsRequest - : CookieSecurePolicy.Always; - options.ExpireTimeSpan = TimeSpan.FromMinutes(settings.Bff.SessionTimeoutMinutes); - options.SlidingExpiration = true; - options.Events.OnRedirectToLogin = RejectUnauthorizedRedirectAsync; - options.Events.OnValidatePrincipal = CookieSessionRefresher.OnValidatePrincipal; - } - - private static void ConfigureOpenIdConnect( - OpenIdConnectOptions options, - AuthSettings settings, - IHostEnvironment environment - ) - { - options.Authority = settings.Authority; - options.RequireHttpsMetadata = !environment.IsDevelopment(); - options.ClientId = settings.Keycloak.Resource; - options.ClientSecret = settings.Keycloak.Credentials.Secret; - options.ResponseType = OpenIdConnectResponseType.Code; - options.SaveTokens = true; - options.SignInScheme = AuthConstants.BffSchemes.Cookie; - - options.Scope.Clear(); - foreach (var scope in settings.Bff.Scopes) - options.Scope.Add(scope); - - options.Events = new OpenIdConnectEvents - { - OnTokenValidated = TenantClaimValidator.OnTokenValidated, - }; - } - - private static void ConfigureCookieSessionStore(IServiceCollection services) - { - services.AddSingleton(); - services - .AddOptions(AuthConstants.BffSchemes.Cookie) - .Configure((opts, store) => opts.SessionStore = store); - } - - private static void ConfigureAuthorization( - IServiceCollection services, - IConfiguration configuration - ) - { - services.AddSingleton(); - services.AddSingleton(); - - services - .AddKeycloakAuthorization(configuration) - .AddAuthorizationBuilder() - .SetFallbackPolicy( - new AuthorizationPolicyBuilder() - .AddAuthenticationSchemes( - JwtBearerDefaults.AuthenticationScheme, - AuthConstants.BffSchemes.Cookie - ) - .RequireAuthenticatedUser() - .Build() - ) - .AddPolicy( - AuthConstants.Policies.PlatformAdmin, - policy => - policy - .AddAuthenticationSchemes( - JwtBearerDefaults.AuthenticationScheme, - AuthConstants.BffSchemes.Cookie - ) - .RequireAuthenticatedUser() - .RequireRole(UserRole.PlatformAdmin.ToString()) - ) - .AddPolicy( - AuthConstants.Policies.TenantAdmin, - policy => - policy - .AddAuthenticationSchemes( - JwtBearerDefaults.AuthenticationScheme, - AuthConstants.BffSchemes.Cookie - ) - .RequireAuthenticatedUser() - .RequireRole( - UserRole.TenantAdmin.ToString(), - UserRole.PlatformAdmin.ToString() - ) - ); - - services.AddSingleton(); - } - - private static void ConfigureKeycloakInfrastructure( - IServiceCollection services, - IConfiguration configuration - ) - { - services.AddHttpClient(nameof(KeycloakHealthCheck)); - services.AddHttpClient(AuthConstants.HttpClients.KeycloakToken); - services - .AddHealthChecks() - .AddCheck(HealthCheckNames.Keycloak, tags: ["identity"]); - - var keycloakOptions = configuration.SectionFor().Get()!; - - services.AddResiliencePipeline( - ResiliencePipelineKeys.KeycloakReadiness, - builder => - { - builder.AddRetry( - new() - { - MaxRetryAttempts = keycloakOptions.ReadinessMaxRetries - 1, - BackoffType = DelayBackoffType.Constant, - Delay = TimeSpan.FromSeconds(2), - ShouldHandle = new PredicateBuilder() - .Handle() - .Handle(), - } - ); - } - ); - } - - private static Task RejectUnauthorizedRedirectAsync( - Microsoft.AspNetCore.Authentication.RedirectContext context - ) - { - AuthTelemetry.RecordUnauthorizedRedirect(); - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - return Task.CompletedTask; - } - - /// Immutable carrier for the Keycloak options and derived authority URL used during scheme configuration. - private sealed record AuthSettings(KeycloakOptions Keycloak, BffOptions Bff, string Authority); -} diff --git a/absolute/src/APITemplate.Api/Extensions/BackgroundJobsServiceCollectionExtensions.cs b/absolute/src/APITemplate.Api/Extensions/BackgroundJobsServiceCollectionExtensions.cs deleted file mode 100644 index bd44d9e3..00000000 --- a/absolute/src/APITemplate.Api/Extensions/BackgroundJobsServiceCollectionExtensions.cs +++ /dev/null @@ -1,179 +0,0 @@ -using APITemplate.Application.Common.BackgroundJobs; -using APITemplate.Application.Common.Options; -using APITemplate.Domain.Entities; -using APITemplate.Domain.Interfaces; -using APITemplate.Infrastructure.BackgroundJobs.Services; -using APITemplate.Infrastructure.BackgroundJobs.TickerQ; -using APITemplate.Infrastructure.BackgroundJobs.TickerQ.Coordination; -using APITemplate.Infrastructure.BackgroundJobs.TickerQ.Jobs; -using APITemplate.Infrastructure.BackgroundJobs.TickerQ.RecurringJobRegistrations; -using APITemplate.Infrastructure.BackgroundJobs.Validation; -using APITemplate.Infrastructure.Repositories; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; -using StackExchange.Redis; -using TickerQ.DependencyInjection; -using TickerQ.EntityFrameworkCore.Customizer; -using TickerQ.EntityFrameworkCore.DependencyInjection; -using TickerQ.Utilities; -using TickerQ.Utilities.Entities; - -namespace APITemplate.Api.Extensions; - -/// -/// Presentation-layer extension class that registers TickerQ-backed recurring background jobs, -/// per-entity soft-delete cleanup strategies, and related infrastructure services. -/// -public static class BackgroundJobsServiceCollectionExtensions -{ - private static readonly Type[] SoftDeleteCleanupOrder = - [ - typeof(ProductDataLink), - typeof(ProductReview), - typeof(Product), - typeof(AppUser), - typeof(TenantInvitation), - typeof(Category), - typeof(Tenant), - ]; - - /// - /// Registers background job services, soft-delete cleanup strategies, and the TickerQ - /// runtime (when enabled), including its EF Core scheduler store and recurring job registrations. - /// - public static IServiceCollection AddBackgroundJobs( - this IServiceCollection services, - IConfiguration configuration - ) - { - services.AddSingleton< - IValidateOptions, - BackgroundJobsOptionsValidator - >(); - services.AddValidatedOptions( - configuration, - validateDataAnnotations: false - ); - var options = - configuration.SectionFor().Get() - ?? new BackgroundJobsOptions(); - - services.AddScoped(); - - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped< - IExternalIntegrationSyncService, - ExternalIntegrationSyncServicePreview - >(); - - RegisterSoftDeleteCleanupStrategies(services); - RegisterTickerQRuntime(services, configuration, options); - - return services; - } - - private static void RegisterTickerQRuntime( - IServiceCollection services, - IConfiguration configuration, - BackgroundJobsOptions options - ) - { - if (!options.TickerQ.Enabled) - { - return; - } - - var dragonflyConnectionString = configuration - .SectionFor() - .GetValue(nameof(DragonflyOptions.ConnectionString)); - - if (string.IsNullOrWhiteSpace(dragonflyConnectionString)) - { - throw new InvalidOperationException( - "Background jobs require Dragonfly:ConnectionString when BackgroundJobs:TickerQ:Enabled is true." - ); - } - - if ( - !string.Equals( - options.TickerQ.CoordinationConnection, - TickerQSchedulerOptions.DefaultCoordinationConnection, - StringComparison.OrdinalIgnoreCase - ) - ) - { - throw new InvalidOperationException( - $"Only '{TickerQSchedulerOptions.DefaultCoordinationConnection}' is supported for BackgroundJobs:TickerQ:CoordinationConnection." - ); - } - - var connectionString = configuration.GetConnectionString( - ConfigurationSections.DefaultConnection - )!; - var schemaName = TickerQSchedulerOptions.DefaultSchemaName; - - services.AddDbContext(dbOptions => - dbOptions.UseNpgsql( - connectionString, - npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory", schemaName) - ) - ); - services.AddScoped(); - services.AddSingleton(); - services.AddScoped< - IRecurringBackgroundJobRegistration, - ExternalSyncRecurringJobRegistration - >(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped< - IRecurringBackgroundJobRegistration, - EmailRetryRecurringJobRegistration - >(); - - services.AddTickerQ(tickerOptions => - { - tickerOptions - .AddOperationalStore(store => - store - .UseApplicationDbContext( - ConfigurationType.IgnoreModelCustomizer - ) - .SetSchema(schemaName) - ) - .ConfigureScheduler(scheduler => - { - scheduler.NodeIdentifier = - $"{options.TickerQ.InstanceNamePrefix}-{Environment.MachineName}-{Environment.ProcessId}"; - scheduler.MaxConcurrency = 1; - }) - .AddTickerQDiscovery([typeof(CleanupRecurringJob).Assembly]); - }); - } - - private static void RegisterSoftDeleteCleanupStrategies(IServiceCollection services) - { - var softDeletableTypes = typeof(ISoftDeletable) - .Assembly.GetTypes() - .Where(t => - t is { IsClass: true, IsAbstract: false } - && typeof(ISoftDeletable).IsAssignableFrom(t) - ) - .OrderBy(GetSoftDeleteCleanupOrder) - .ThenBy(t => t.Name); - - foreach (var entityType in softDeletableTypes) - { - var strategyType = typeof(SoftDeleteCleanupStrategy<>).MakeGenericType(entityType); - services.AddScoped(typeof(ISoftDeleteCleanupStrategy), strategyType); - } - } - - private static int GetSoftDeleteCleanupOrder(Type entityType) - { - var index = Array.IndexOf(SoftDeleteCleanupOrder, entityType); - return index >= 0 ? index : int.MaxValue; - } -} diff --git a/absolute/src/APITemplate.Api/Extensions/Configuration/ConfigurationExtensions.cs b/absolute/src/APITemplate.Api/Extensions/Configuration/ConfigurationExtensions.cs deleted file mode 100644 index 50719b76..00000000 --- a/absolute/src/APITemplate.Api/Extensions/Configuration/ConfigurationExtensions.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace APITemplate.Api.Extensions.Configuration; - -internal static class ConfigurationExtensions -{ - private const string OptionsSuffix = "Options"; - - /// - /// Returns the configuration section whose key is derived from - /// by stripping the trailing "Options" suffix (e.g. EmailOptions"Email"). - /// - public static IConfigurationSection SectionFor(this IConfiguration configuration) - where TOptions : class - { - var name = typeof(TOptions).Name; - var sectionName = name.EndsWith(OptionsSuffix, StringComparison.Ordinal) - ? name[..^OptionsSuffix.Length] - : name; - return configuration.GetSection(sectionName); - } -} diff --git a/absolute/src/APITemplate.Api/Extensions/Configuration/ConfigurationSections.cs b/absolute/src/APITemplate.Api/Extensions/Configuration/ConfigurationSections.cs deleted file mode 100644 index 470abf01..00000000 --- a/absolute/src/APITemplate.Api/Extensions/Configuration/ConfigurationSections.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace APITemplate.Api.Extensions.Configuration; - -/// -/// Configuration keys that cannot be derived from an Options class name by convention. -/// All other section names are resolved automatically via -/// . -/// -internal static class ConfigurationSections -{ - /// GetConnectionString key for the primary PostgreSQL connection. - public const string DefaultConnection = "DefaultConnection"; - - /// MongoDbSettings does not carry the "Options" suffix. - public const string MongoDB = "MongoDB"; -} diff --git a/absolute/src/APITemplate.Api/Extensions/Configuration/KeycloakStartupLogs.cs b/absolute/src/APITemplate.Api/Extensions/Configuration/KeycloakStartupLogs.cs deleted file mode 100644 index 1bf621c2..00000000 --- a/absolute/src/APITemplate.Api/Extensions/Configuration/KeycloakStartupLogs.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace APITemplate.Api.Extensions.Configuration; - -/// -/// Source-generated high-performance logger methods for Keycloak readiness-check events -/// emitted during application startup. -/// -internal static partial class KeycloakStartupLogs -{ - [LoggerMessage( - EventId = 3001, - Level = LogLevel.Warning, - Message = "Keycloak configuration is missing, skipping readiness check" - )] - public static partial void KeycloakConfigMissing(this ILogger logger); - - [LoggerMessage( - EventId = 3002, - Level = LogLevel.Information, - Message = "Keycloak readiness check skipped via configuration" - )] - public static partial void KeycloakReadinessCheckSkipped(this ILogger logger); - - [LoggerMessage( - EventId = 3003, - Level = LogLevel.Information, - Message = "Keycloak is ready at {Url}" - )] - public static partial void KeycloakReady(this ILogger logger, string url); - - [LoggerMessage( - EventId = 3004, - Level = LogLevel.Warning, - Message = "Keycloak not ready, retrying ({Attempt}/{MaxRetries})..." - )] - public static partial void KeycloakRetrying(this ILogger logger, int attempt, int maxRetries); - - [LoggerMessage( - EventId = 3005, - Level = LogLevel.Error, - Message = "Keycloak did not become available after {MaxRetries} retries" - )] - public static partial void KeycloakUnavailable( - this ILogger logger, - Exception exception, - int maxRetries - ); -} diff --git a/absolute/src/APITemplate.Api/Extensions/Configuration/ServiceCollectionOptionsExtensions.cs b/absolute/src/APITemplate.Api/Extensions/Configuration/ServiceCollectionOptionsExtensions.cs deleted file mode 100644 index 1c217d1e..00000000 --- a/absolute/src/APITemplate.Api/Extensions/Configuration/ServiceCollectionOptionsExtensions.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.Extensions.Options; - -namespace APITemplate.Api.Extensions.Configuration; - -internal static class ServiceCollectionOptionsExtensions -{ - /// - /// Binds to its configuration section (via - /// ), optionally validates - /// data annotations, and validates eagerly on application start. - /// - public static OptionsBuilder AddValidatedOptions( - this IServiceCollection services, - IConfiguration configuration, - bool validateDataAnnotations = true - ) - where TOptions : class - { - var builder = services.AddOptions().Bind(configuration.SectionFor()); - if (validateDataAnnotations) - builder.ValidateDataAnnotations(); - builder.ValidateOnStart(); - return builder; - } -} diff --git a/absolute/src/APITemplate.Api/Extensions/ControllerExtensions.cs b/absolute/src/APITemplate.Api/Extensions/ControllerExtensions.cs deleted file mode 100644 index f38d1056..00000000 --- a/absolute/src/APITemplate.Api/Extensions/ControllerExtensions.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Asp.Versioning; -using Microsoft.AspNetCore.Mvc; - -namespace APITemplate.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/absolute/src/APITemplate.Api/Extensions/EmailServiceCollectionExtensions.cs b/absolute/src/APITemplate.Api/Extensions/EmailServiceCollectionExtensions.cs deleted file mode 100644 index 9d618ff7..00000000 --- a/absolute/src/APITemplate.Api/Extensions/EmailServiceCollectionExtensions.cs +++ /dev/null @@ -1,60 +0,0 @@ -using APITemplate.Application.Common.Email; -using APITemplate.Application.Common.Options; -using APITemplate.Application.Common.Resilience; -using APITemplate.Infrastructure.Email; -using APITemplate.Infrastructure.Security; -using Polly; - -namespace APITemplate.Api.Extensions; - -/// -/// Presentation-layer extension class that registers email infrastructure: SMTP sender, -/// Fluid template renderer, channel-based queue with background consumer, and a Polly -/// exponential-backoff resilience pipeline for delivery retries. -/// -public static class EmailServiceCollectionExtensions -{ - /// - /// Registers email services including the MailKit SMTP sender, Fluid template renderer, - /// failed-email store, and a Polly retry pipeline keyed to . - /// - public static IServiceCollection AddEmailServices( - this IServiceCollection services, - IConfiguration configuration - ) - { - var emailSection = configuration.SectionFor(); - var emailOptions = emailSection.Get() ?? new EmailOptions(); - services.Configure(emailSection); - - services.AddQueueWithConsumer< - ChannelEmailQueue, - IEmailQueue, - IEmailQueueReader, - EmailSendingBackgroundService - >(); - services.AddSingleton(); - services.AddSingleton(); - services.AddTransient(); - - services.AddSingleton(); - - services.AddResiliencePipeline( - ResiliencePipelineKeys.SmtpSend, - builder => - { - builder.AddRetry( - new() - { - MaxRetryAttempts = emailOptions.MaxRetryAttempts, - BackoffType = DelayBackoffType.Exponential, - Delay = TimeSpan.FromSeconds(emailOptions.RetryBaseDelaySeconds), - UseJitter = true, - } - ); - } - ); - - return services; - } -} diff --git a/absolute/src/APITemplate.Api/Extensions/GraphQLServiceCollectionExtensions.cs b/absolute/src/APITemplate.Api/Extensions/GraphQLServiceCollectionExtensions.cs deleted file mode 100644 index a6ac5d83..00000000 --- a/absolute/src/APITemplate.Api/Extensions/GraphQLServiceCollectionExtensions.cs +++ /dev/null @@ -1,45 +0,0 @@ -using APITemplate.Api.GraphQL.Instrumentation; - -namespace APITemplate.Api.Extensions; - -/// -/// Presentation-layer extension class that configures the Hot Chocolate GraphQL server, -/// registering query/mutation types, object type mappings, data loaders, authorization, -/// instrumentation, and paging/depth-limit rules. -/// -public static class GraphQLServiceCollectionExtensions -{ - /// - /// Adds the GraphQL server with product and review query/mutation types, object type - /// configurations, the batch data loader, metrics listener, and a max execution depth of 5. - /// - public static IServiceCollection AddGraphQLConfiguration(this IServiceCollection services) - { - services.AddSingleton(); - - services - .AddGraphQLServer() - .AddQueryType() - .AddTypeExtension() - .AddTypeExtension() - .AddMutationType() - .AddTypeExtension() - .AddType() - .AddType() - .AddDataLoader() - .AddAuthorization() - .AddInstrumentation() - .AddDiagnosticEventListener(sp => - sp.GetRequiredService() - ) - .ModifyPagingOptions(o => - { - o.MaxPageSize = PaginationFilter.MaxPageSize; - o.DefaultPageSize = PaginationFilter.DefaultPageSize; - o.IncludeTotalCount = true; - }) - .AddMaxExecutionDepthRule(5); - - return services; - } -} diff --git a/absolute/src/APITemplate.Api/Extensions/InfrastructureServiceCollectionExtensions.cs b/absolute/src/APITemplate.Api/Extensions/InfrastructureServiceCollectionExtensions.cs deleted file mode 100644 index 6eff5c3a..00000000 --- a/absolute/src/APITemplate.Api/Extensions/InfrastructureServiceCollectionExtensions.cs +++ /dev/null @@ -1,79 +0,0 @@ -using APITemplate.Application.Common.BackgroundJobs; -using APITemplate.Application.Common.Contracts; -using APITemplate.Application.Common.Options; -using APITemplate.Infrastructure.BackgroundJobs.Services; -using APITemplate.Infrastructure.FileStorage; -using APITemplate.Infrastructure.Idempotency; -using Microsoft.Extensions.Hosting; -using StackExchange.Redis; - -namespace APITemplate.Api.Extensions; - -/// -/// Presentation-layer extension class that registers cross-cutting infrastructure services -/// such as file storage, idempotency store, job queue, and generic channel-queue helpers. -/// -public static class InfrastructureServiceCollectionExtensions -{ - /// Registers local file storage options and the implementation. - public static IServiceCollection AddFileStorageServices( - this IServiceCollection services, - IConfiguration configuration - ) - { - services.Configure(configuration.SectionFor()); - services.AddScoped(); - return services; - } - - /// - /// Registers the idempotency store as a singleton, using a Redis-backed implementation - /// when a is available, otherwise falling back to - /// an in-memory store suitable for single-instance deployments. - /// - public static IServiceCollection AddIdempotencyStore(this IServiceCollection services) - { - services.AddSingleton(sp => - { - var multiplexer = sp.GetService(); - if (multiplexer is not null) - return new DistributedCacheIdempotencyStore(multiplexer); - - return new InMemoryIdempotencyStore(sp.GetRequiredService()); - }); - - return services; - } - - /// Registers the channel-based job queue and its background processing hosted service. - public static IServiceCollection AddJobServices(this IServiceCollection services) - { - services.AddQueueWithConsumer< - ChannelJobQueue, - IJobQueue, - IJobQueueReader, - JobProcessingBackgroundService - >(); - return services; - } - - /// - /// Registers a single instance as a singleton and exposes it - /// as both the producer interface and the consumer interface - /// , then starts as a hosted service. - /// - public static IServiceCollection AddQueueWithConsumer( - this IServiceCollection services - ) - where TImpl : class, TQueue, TReader - where TQueue : class - where TReader : class - where TService : class, IHostedService - { - services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService()); - services.AddSingleton(sp => sp.GetRequiredService()); - services.AddHostedService(); - return services; - } -} diff --git a/absolute/src/APITemplate.Api/Extensions/KeycloakAdminServiceCollectionExtensions.cs b/absolute/src/APITemplate.Api/Extensions/KeycloakAdminServiceCollectionExtensions.cs deleted file mode 100644 index 3a14f507..00000000 --- a/absolute/src/APITemplate.Api/Extensions/KeycloakAdminServiceCollectionExtensions.cs +++ /dev/null @@ -1,67 +0,0 @@ -using APITemplate.Api.Extensions.Resilience; -using APITemplate.Application.Common.Options; -using APITemplate.Application.Common.Resilience; -using APITemplate.Application.Common.Security; -using APITemplate.Infrastructure.Security; -using Keycloak.AuthServices.Sdk; -using Microsoft.Extensions.Http.Resilience; -using Microsoft.Extensions.Options; -using Polly; - -namespace APITemplate.Api.Extensions; - -/// -/// Presentation-layer extension class that registers the Keycloak Admin HTTP client with -/// machine-to-machine token injection, exponential-backoff resilience, and the -/// scoped service. -/// -public static class KeycloakAdminServiceCollectionExtensions -{ - /// - /// Registers populated from , - /// adds the SDK's named HTTP client with a token-handler delegate and a Polly retry pipeline, - /// and registers as a scoped service. - /// - public static IServiceCollection AddKeycloakAdminService(this IServiceCollection services) - { - // Populate KeycloakAdminClientOptions from IOptions at runtime, - // so validation runs through the IOptions pipeline rather than raw IConfiguration. - services - .AddOptions() - .Configure>( - (adminOpts, keycloakOpts) => - { - adminOpts.AuthServerUrl = keycloakOpts.Value.AuthServerUrl; - adminOpts.Realm = keycloakOpts.Value.Realm; - } - ); - - services.AddSingleton(); - services.AddTransient(); - - // Pass a no-op action so the SDK registers its IKeycloakClient registrations and - // the named HttpClient; the actual option values come from the Configure call above. - services - .AddKeycloakAdminHttpClient(_ => { }) - .AddHttpMessageHandler() - .AddResilienceHandler( - ResiliencePipelineKeys.KeycloakAdmin, - builder => - { - builder.AddRetry( - new HttpRetryStrategyOptions - { - MaxRetryAttempts = ResilienceDefaults.MaxRetryAttempts, - BackoffType = DelayBackoffType.Exponential, - Delay = ResilienceDefaults.ShortDelay, - UseJitter = true, - } - ); - } - ); - - services.AddScoped(); - - return services; - } -} diff --git a/absolute/src/APITemplate.Api/Extensions/ObservabilityServiceCollectionExtensions.cs b/absolute/src/APITemplate.Api/Extensions/ObservabilityServiceCollectionExtensions.cs deleted file mode 100644 index def9bf0c..00000000 --- a/absolute/src/APITemplate.Api/Extensions/ObservabilityServiceCollectionExtensions.cs +++ /dev/null @@ -1,286 +0,0 @@ -using System.Diagnostics; -using System.Reflection; -using System.Runtime.InteropServices; -using APITemplate.Application.Common.Options; -using APITemplate.Infrastructure.Observability; -using HotChocolate.Diagnostics; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Npgsql; -using OpenTelemetry.Metrics; -using OpenTelemetry.Resources; -using OpenTelemetry.Trace; - -namespace APITemplate.Api.Extensions; - -/// -/// Presentation-layer extension class that configures OpenTelemetry tracing, metrics, and -/// OTLP/console exporters, as well as health-check metrics publishing. -/// -public static class ObservabilityServiceCollectionExtensions -{ - /// - /// Registers OpenTelemetry with ASP.NET Core, HttpClient, runtime, GraphQL, Redis, and - /// Npgsql instrumentation; configures custom histogram boundaries and environment-aware - /// OTLP exporters (Aspire in dev, container OTLP otherwise). - /// - public static IServiceCollection AddObservability( - this IServiceCollection services, - IConfiguration configuration, - IHostEnvironment environment - ) - { - services.Configure(configuration.SectionFor()); - services.Configure(configuration.SectionFor()); - services.AddSingleton(); - services.Configure(options => - { - options.Delay = TimeSpan.FromSeconds(15); - options.Period = TimeSpan.FromMinutes(5); - }); - - Activity.DefaultIdFormat = ActivityIdFormat.W3C; - Activity.ForceDefaultIdFormat = true; - - var options = GetObservabilityOptions(configuration); - var appOptions = GetAppOptions(configuration); - var resourceAttributes = BuildResourceAttributes(appOptions, environment); - var enableConsoleExporter = IsConsoleExporterEnabled(options); - var otlpEndpoints = GetEnabledOtlpEndpoints(options, environment); - - var openTelemetryBuilder = services - .AddOpenTelemetry() - .ConfigureResource(resource => resource.AddAttributes(resourceAttributes)); - - openTelemetryBuilder.WithTracing(builder => - { - builder - .AddAspNetCoreInstrumentation(options => - { - options.RecordException = true; - options.Filter = httpContext => - !httpContext.Request.Path.StartsWithSegments(TelemetryPathPrefixes.Health); - options.EnrichWithHttpRequest = (activity, httpRequest) => - { - if ( - TelemetryApiSurfaceResolver.Resolve(httpRequest.Path) - != TelemetrySurfaces.Rest - ) - return; - - var route = HttpRouteResolver.Resolve(httpRequest.HttpContext); - activity.DisplayName = $"{httpRequest.Method} {route}"; - activity.SetTag(TelemetryTagKeys.HttpRoute, route); - }; - }) - .AddHttpClientInstrumentation() - .AddHotChocolateInstrumentation() - .AddRedisInstrumentation() - .AddNpgsql() - .AddSource(ObservabilityConventions.ActivitySourceName) - .AddSource(TelemetryThirdPartySources.MongoDbDriverDiagnosticSources) - .AddSource(TelemetryThirdPartySources.Wolverine); - - ConfigureTracingExporters(builder, otlpEndpoints, enableConsoleExporter); - }); - - openTelemetryBuilder.WithMetrics(builder => - { - builder - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation() - .AddProcessInstrumentation() - .AddMeter( - ObservabilityConventions.MeterName, - ObservabilityConventions.HealthMeterName, - TelemetryMeterNames.AspNetCoreHosting, - TelemetryMeterNames.AspNetCoreServerKestrel, - TelemetryMeterNames.AspNetCoreConnections, - TelemetryMeterNames.AspNetCoreRouting, - TelemetryMeterNames.AspNetCoreDiagnostics, - TelemetryMeterNames.AspNetCoreRateLimiting, - TelemetryMeterNames.AspNetCoreAuthentication, - TelemetryMeterNames.AspNetCoreAuthorization, - TelemetryThirdPartySources.Wolverine - ) - .AddView( - TelemetryInstrumentNames.HttpServerRequestDuration, - new ExplicitBucketHistogramConfiguration - { - Boundaries = TelemetryHistogramBoundaries.HttpRequestDurationSeconds, - } - ) - .AddView( - TelemetryInstrumentNames.HttpClientRequestDuration, - new ExplicitBucketHistogramConfiguration - { - Boundaries = TelemetryHistogramBoundaries.HttpRequestDurationSeconds, - } - ) - .AddView( - TelemetryMetricNames.OutputCacheInvalidationDuration, - new ExplicitBucketHistogramConfiguration - { - Boundaries = TelemetryHistogramBoundaries.CacheOperationDurationMs, - } - ) - .AddView( - TelemetryMetricNames.GraphQlRequestDuration, - new ExplicitBucketHistogramConfiguration - { - Boundaries = TelemetryHistogramBoundaries.GraphQlRequestDurationMs, - } - ); - - ConfigureMetricExporters(builder, otlpEndpoints, enableConsoleExporter); - }); - - return services; - } - - /// - /// Returns the distinct set of OTLP endpoint URLs that are enabled for the current environment, - /// combining Aspire (dev default) and explicit OTLP (container default) endpoints. - /// - internal static IReadOnlyList GetEnabledOtlpEndpoints( - ObservabilityOptions options, - IHostEnvironment environment - ) - { - var endpoints = new List(); - - if (IsAspireExporterEnabled(options, environment)) - { - var aspireEndpoint = string.IsNullOrWhiteSpace(options.Aspire.Endpoint) - ? TelemetryDefaults.AspireOtlpEndpoint - : options.Aspire.Endpoint; - endpoints.Add(aspireEndpoint); - } - - if ( - IsOtlpExporterEnabled(options, environment) - && !string.IsNullOrWhiteSpace(options.Otlp.Endpoint) - ) - { - endpoints.Add(options.Otlp.Endpoint); - } - - return endpoints.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); - } - - /// - /// Returns whether the Aspire OTLP exporter is active: uses the explicit configuration value - /// when set, otherwise defaults to in Development outside a container. - /// - internal static bool IsAspireExporterEnabled( - ObservabilityOptions options, - IHostEnvironment environment - ) => - options.Exporters.Aspire.Enabled - ?? (environment.IsDevelopment() && !IsRunningInContainer()); - - /// - /// Returns whether the generic OTLP exporter is active: uses the explicit configuration value - /// when set, otherwise defaults to when running in a container. - /// - internal static bool IsOtlpExporterEnabled( - ObservabilityOptions options, - IHostEnvironment environment - ) => options.Exporters.Otlp.Enabled ?? IsRunningInContainer(); - - /// Returns whether the console/stdout exporter is enabled; defaults to . - internal static bool IsConsoleExporterEnabled(ObservabilityOptions options) => - options.Exporters.Console.Enabled ?? false; - - private static bool IsRunningInContainer() => - string.Equals( - Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"), - "true", - StringComparison.OrdinalIgnoreCase - ); - - /// Reads and binds from configuration, returning defaults when absent. - internal static ObservabilityOptions GetObservabilityOptions(IConfiguration configuration) => - configuration.SectionFor().Get() ?? new(); - - /// Reads and binds from configuration, returning defaults when absent. - internal static AppOptions GetAppOptions(IConfiguration configuration) => - configuration.SectionFor().Get() ?? new(); - - /// - /// Builds the OpenTelemetry resource attribute dictionary including service name, version, - /// instance ID, host, architecture, OS, and runtime metadata. - /// - internal static Dictionary BuildResourceAttributes( - AppOptions appOptions, - IHostEnvironment environment - ) - { - var serviceName = string.IsNullOrWhiteSpace(appOptions.ServiceName) - ? ObservabilityConventions.ActivitySourceName - : appOptions.ServiceName; - var entryAssembly = Assembly.GetEntryAssembly(); - var assemblyName = entryAssembly?.GetName().Name ?? serviceName; - var version = entryAssembly?.GetName().Version?.ToString() ?? TelemetryDefaults.Unknown; - var machineName = Environment.MachineName; - var processId = Environment.ProcessId; - - return new Dictionary - { - [TelemetryResourceAttributeKeys.AssemblyName] = assemblyName, - [TelemetryResourceAttributeKeys.ServiceName] = serviceName, - [TelemetryResourceAttributeKeys.ServiceNamespace] = serviceName, - [TelemetryResourceAttributeKeys.ServiceVersion] = version, - [TelemetryResourceAttributeKeys.ServiceInstanceId] = $"{machineName}-{processId}", - [TelemetryResourceAttributeKeys.DeploymentEnvironmentName] = - environment.EnvironmentName, - [TelemetryResourceAttributeKeys.HostName] = machineName, - [TelemetryResourceAttributeKeys.HostArchitecture] = - RuntimeInformation.OSArchitecture.ToString(), - [TelemetryResourceAttributeKeys.OsType] = GetOsType(), - [TelemetryResourceAttributeKeys.ProcessPid] = processId, - [TelemetryResourceAttributeKeys.ProcessRuntimeName] = ".NET", - [TelemetryResourceAttributeKeys.ProcessRuntimeVersion] = Environment.Version.ToString(), - }; - } - - private static string GetOsType() => - OperatingSystem.IsWindows() ? "windows" - : OperatingSystem.IsLinux() ? "linux" - : OperatingSystem.IsMacOS() ? "darwin" - : TelemetryDefaults.Unknown; - - private static void ConfigureTracingExporters( - TracerProviderBuilder builder, - IReadOnlyList otlpEndpoints, - bool enableConsoleExporter - ) - { - foreach (var endpoint in otlpEndpoints) - { - builder.AddOtlpExporter(options => options.Endpoint = new Uri(endpoint)); - } - - if (enableConsoleExporter) - { - builder.AddConsoleExporter(); - } - } - - private static void ConfigureMetricExporters( - MeterProviderBuilder builder, - IReadOnlyList otlpEndpoints, - bool enableConsoleExporter - ) - { - foreach (var endpoint in otlpEndpoints) - { - builder.AddOtlpExporter(options => options.Endpoint = new Uri(endpoint)); - } - - if (enableConsoleExporter) - { - builder.AddConsoleExporter(); - } - } -} diff --git a/absolute/src/APITemplate.Api/Extensions/PersistenceServiceCollectionExtensions.cs b/absolute/src/APITemplate.Api/Extensions/PersistenceServiceCollectionExtensions.cs deleted file mode 100644 index 4d10f19a..00000000 --- a/absolute/src/APITemplate.Api/Extensions/PersistenceServiceCollectionExtensions.cs +++ /dev/null @@ -1,151 +0,0 @@ -using APITemplate.Api.Extensions.Resilience; -using APITemplate.Application.Common.Options; -using APITemplate.Application.Common.Resilience; -using APITemplate.Application.Common.Security; -using APITemplate.Application.Common.Startup; -using APITemplate.Application.Features.Product.Repositories; -using APITemplate.Domain.Interfaces; -using APITemplate.Infrastructure.Health; -using APITemplate.Infrastructure.Persistence; -using APITemplate.Infrastructure.Persistence.Auditing; -using APITemplate.Infrastructure.Persistence.EntityNormalization; -using APITemplate.Infrastructure.Persistence.SoftDelete; -using APITemplate.Infrastructure.Persistence.Startup; -using APITemplate.Infrastructure.Repositories; -using APITemplate.Infrastructure.Security; -using APITemplate.Infrastructure.StoredProcedures; -using Kot.MongoDB.Migrations; -using Kot.MongoDB.Migrations.DI; -using Microsoft.EntityFrameworkCore; -using Polly; - -namespace APITemplate.Api.Extensions; - -/// -/// Presentation-layer extension class that registers EF Core (PostgreSQL), MongoDB, and all -/// related repository, auditing, soft-delete, and startup seeding services. -/// -public static class PersistenceServiceCollectionExtensions -{ - /// - /// Configures , registers all repository and infrastructure - /// services, and adds a PostgreSQL health check. - /// - public static IServiceCollection AddPersistence( - this IServiceCollection services, - IConfiguration configuration - ) - { - var connectionString = configuration.GetConnectionString( - ConfigurationSections.DefaultConnection - )!; - - services.Configure( - configuration.SectionFor() - ); - - services.AddDbContext(options => - ConfigurePostgresDbContext(options, connectionString) - ); - - // Repositories (data access) - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - // Infrastructure / persistence helpers - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - // Auditing / normalization / soft delete behavior - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddScoped(); - services.AddScoped(); - - // Application services / initialization - services.AddScoped(); - services.AddScoped(); - - // System services - services.AddSingleton(TimeProvider.System); - - services - .AddHealthChecks() - .AddNpgSql(connectionString, name: HealthCheckNames.PostgreSql, tags: ["database"]); - - return services; - } - - /// - /// Applies the Npgsql provider to the given ; exposed - /// internally so integration tests can reuse the same configuration logic. - /// - internal static void ConfigurePostgresDbContext( - DbContextOptionsBuilder options, - string connectionString - ) - { - options.UseNpgsql(connectionString); - } - - /// - /// Registers the MongoDB context, product-data repository, a Polly retry pipeline for - /// delete operations, the Kot.MongoDB.Migrations migrator, and a MongoDB health check. - /// - public static IServiceCollection AddMongoDB( - this IServiceCollection services, - IConfiguration configuration - ) - { - var mongoSettings = configuration - .GetSection(ConfigurationSections.MongoDB) - .Get()!; - - services.Configure( - configuration.GetSection(ConfigurationSections.MongoDB) - ); - services.AddSingleton(); - services.AddScoped(); - - services.AddResiliencePipeline( - ResiliencePipelineKeys.MongoProductDataDelete, - builder => - { - builder.AddRetry( - new() - { - MaxRetryAttempts = ResilienceDefaults.MaxRetryAttempts, - BackoffType = DelayBackoffType.Exponential, - Delay = ResilienceDefaults.ShortDelay, - UseJitter = true, - } - ); - } - ); - - services.AddMongoMigrations( - mongoSettings.ConnectionString, - new MigrationOptions(mongoSettings.DatabaseName), - config => - config.LoadMigrationsFromAssembly( - typeof(PersistenceServiceCollectionExtensions).Assembly - ) - ); - - services - .AddHealthChecks() - .AddCheck(HealthCheckNames.MongoDb, tags: ["database"]); - - return services; - } -} diff --git a/absolute/src/APITemplate.Api/Extensions/Resilience/ResilienceDefaults.cs b/absolute/src/APITemplate.Api/Extensions/Resilience/ResilienceDefaults.cs deleted file mode 100644 index f694ede0..00000000 --- a/absolute/src/APITemplate.Api/Extensions/Resilience/ResilienceDefaults.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace APITemplate.Api.Extensions.Resilience; - -internal static class ResilienceDefaults -{ - public const int MaxRetryAttempts = 3; - public static readonly TimeSpan ShortDelay = TimeSpan.FromSeconds(1); - public static readonly TimeSpan LongDelay = TimeSpan.FromSeconds(2); -} diff --git a/absolute/src/APITemplate.Api/Extensions/ServiceCollectionExtensions.cs b/absolute/src/APITemplate.Api/Extensions/ServiceCollectionExtensions.cs deleted file mode 100644 index cdb3855a..00000000 --- a/absolute/src/APITemplate.Api/Extensions/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,64 +0,0 @@ -using APITemplate.Application.Common.Context; -using APITemplate.Application.Features.Product.Validation; -using APITemplate.Infrastructure.Security; -using Asp.Versioning; -using FluentValidation; - -namespace APITemplate.Api.Extensions; - -/// -/// Presentation-layer extension class that registers application services (validators, -/// tenant/actor context providers) and API versioning configuration. -/// Wolverine handles handler discovery and validation middleware automatically via UseWolverine(). -/// -public static class ServiceCollectionExtensions -{ - /// - /// Registers application-layer services for dependency injection. - /// - public static IServiceCollection AddApplicationServices(this IServiceCollection services) - { - services.AddHttpContextAccessor(); - - services.AddScoped(); - services.AddScoped(); - - services.AddValidatorsFromAssemblyContaining( - filter: result => !result.ValidatorType.IsGenericTypeDefinition - ); - - return services; - } - - /// - /// Configures URL-segment API versioning (defaulting to v1) and the API explorer used by - /// OpenAPI to group endpoints by version. - /// - public static IServiceCollection AddApiVersioningConfiguration(this IServiceCollection services) - { - // Enable API versioning and configure how clients specify the version. - // This is used both for routing and for generating correct OpenAPI/Swagger docs. - services - .AddApiVersioning(options => - { - // Default to v1 when the client does not specify a version. - options.DefaultApiVersion = new ApiVersion(1, 0); - options.AssumeDefaultVersionWhenUnspecified = true; - - // Include supported/deprecated version headers in responses. - options.ReportApiVersions = true; - - // Read the version from the URL segment (e.g. /api/v1/...). - options.ApiVersionReader = new UrlSegmentApiVersionReader(); - }) - .AddApiExplorer(options => - { - // Format how OpenAPI groups endpoints by version (v1, v2, ...). - options.GroupNameFormat = "'v'VVV"; - // Replace the {version} placeholder in route templates with the actual version. - options.SubstituteApiVersionInUrl = true; - }); - - return services; - } -} diff --git a/absolute/src/APITemplate.Api/Extensions/Startup/ApplicationBuilderExtensions.cs b/absolute/src/APITemplate.Api/Extensions/Startup/ApplicationBuilderExtensions.cs deleted file mode 100644 index b0086c53..00000000 --- a/absolute/src/APITemplate.Api/Extensions/Startup/ApplicationBuilderExtensions.cs +++ /dev/null @@ -1,390 +0,0 @@ -using APITemplate.Api.Middleware; -using APITemplate.Application.Common.Http; -using APITemplate.Application.Common.Options; -using APITemplate.Application.Common.Resilience; -using APITemplate.Application.Common.Security; -using APITemplate.Application.Common.Startup; -using APITemplate.Infrastructure.BackgroundJobs.TickerQ; -using APITemplate.Infrastructure.Observability; -using APITemplate.Infrastructure.Persistence; -using APITemplate.Infrastructure.Security; -using HealthChecks.UI.Client; -using Kot.MongoDB.Migrations; -using Microsoft.AspNetCore.Diagnostics.HealthChecks; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; -using Polly; -using Polly.Registry; -using Scalar.AspNetCore; -using Serilog; -using Serilog.Events; -using TickerQ.DependencyInjection; -using TickerQ.Utilities.Enums; - -namespace APITemplate.Api.Extensions.Startup; - -/// -/// Presentation-layer extension class that provides extension -/// methods for startup orchestration (database migrations, Keycloak readiness, background jobs) -/// and HTTP pipeline configuration. -/// -public static class ApplicationBuilderExtensions -{ - /// - /// Runs relational and MongoDB migrations and seeds the auth bootstrap data under a - /// distributed advisory lock to prevent concurrent runs in multi-instance deployments. - /// - public static async Task UseDatabaseAsync( - this WebApplication app, - CancellationToken ct = default - ) - { - await using var scope = app.Services.CreateAsyncScope(); // Resolve scoped infra services needed only during startup migration. - var coordinator = scope.ServiceProvider.GetRequiredService(); - - await using var startupLease = await coordinator.AcquireAsync( - StartupTaskName.AppBootstrap, - ct - ); - - var dbContext = scope.ServiceProvider.GetRequiredService(); // Resolve EF Core context for relational migrations. - if (dbContext.Database.IsRelational()) - { - using var telemetry = StartupTelemetry.StartRelationalMigration(); - try - { - await dbContext.Database.MigrateAsync(ct); - } - catch (Exception ex) - { - telemetry.Fail(ex); - throw; - } - } - - var seeder = scope.ServiceProvider.GetRequiredService(); - using (var telemetry = StartupTelemetry.StartAuthBootstrapSeed()) - { - try - { - await seeder.SeedAsync(ct); - } - catch (Exception ex) - { - telemetry.Fail(ex); - throw; - } - } - - var mongoContext = scope.ServiceProvider.GetService(); // Mongo context can be missing in tests. - if (mongoContext is not null) - { - var migrator = scope.ServiceProvider.GetRequiredService(); // Resolve Mongo migrator from DI. - using var telemetry = StartupTelemetry.StartMongoMigration(); - try - { - await migrator.MigrateAsync(); - } - catch (Exception ex) - { - telemetry.Fail(ex); - throw; - } - } - } - - /// - /// Migrates the TickerQ scheduler store and syncs recurring job registrations when TickerQ - /// is enabled; exits early with an informational log when disabled or unavailable. - /// - public static async Task UseBackgroundJobsAsync( - this WebApplication app, - CancellationToken ct = default - ) - { - var options = app.Services.GetRequiredService>().Value; - if (!options.TickerQ.Enabled) - { - app.Logger.LogInformation("TickerQ background jobs are disabled."); - return; - } - - await using var scope = app.Services.CreateAsyncScope(); - var registrar = scope.ServiceProvider.GetService(); - if (registrar is null) - { - app.Logger.LogInformation( - "TickerQ background jobs runtime is unavailable in this host; skipping scheduler bootstrap." - ); - return; - } - - var schedulerDbContext = scope.ServiceProvider.GetService(); - if (schedulerDbContext is null) - { - app.Logger.LogInformation( - "TickerQ scheduler store is unavailable in this host; skipping scheduler bootstrap." - ); - return; - } - - var coordinator = scope.ServiceProvider.GetRequiredService(); - await using var startupLease = await coordinator.AcquireAsync( - StartupTaskName.BackgroundJobsBootstrap, - ct - ); - - if (schedulerDbContext.Database.IsRelational()) - { - using var telemetry = StartupTelemetry.StartRelationalMigration(); - try - { - await schedulerDbContext.Database.MigrateAsync(ct); - } - catch (Exception ex) - { - telemetry.Fail(ex); - throw; - } - } - - await registrar.SyncAsync(ct); - - app.UseTickerQ(TickerQStartMode.Immediate); - app.Logger.LogInformation( - "TickerQ background jobs started with schema {SchemaName}.", - TickerQSchedulerOptions.DefaultSchemaName - ); - } - - /// - /// Cross-cutting request context: correlation ID stamping, elapsed-time header, and - /// structured Serilog request logging. Runs early so every downstream log entry is enriched. - /// - public static WebApplication UseRequestContextPipeline(this WebApplication app) - { - app.UseMiddleware(); - app.UseSerilogRequestLogging(options => - { - options.MessageTemplate = - "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms"; - - options.GetLevel = (httpContext, _, exception) => - { - if (IsClientAbortedRequest(httpContext, exception)) - return LogEventLevel.Information; - - if (exception is not null || httpContext.Response.StatusCode >= 500) - return LogEventLevel.Error; - - if (httpContext.Response.StatusCode >= 400) - return LogEventLevel.Warning; - - return LogEventLevel.Information; - }; - - options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => - { - diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value); - diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme); - }; - }); - - return app; - } - - private static bool IsClientAbortedRequest(HttpContext httpContext, Exception? exception) => - exception is OperationCanceledException - && httpContext.RequestAborted.IsCancellationRequested; - - /// - /// Identity and access-control pipeline: CORS preflight handling, token/cookie - /// authentication, CSRF enforcement for BFF cookie sessions, and authorization policy - /// evaluation. Order is fixed — each step depends on the one before it. - /// - public static WebApplication UseSecurityPipeline(this WebApplication app) - { - app.UseCors(); // CORS preflight must precede authentication. - app.UseAuthentication(); // Populate HttpContext.User from JWT / cookie. - app.UseMiddleware(); // Require X-CSRF header for cookie-authenticated mutations. - app.UseAuthorization(); // Enforce endpoint authorization policies. - - return app; - } - - /// - /// Polls the Keycloak OIDC discovery endpoint using a Polly retry pipeline, blocking - /// startup until Keycloak is reachable or the retry budget is exhausted. - /// - public static async Task WaitForKeycloakAsync( - this WebApplication app, - CancellationToken cancellationToken = default - ) - { - var keycloak = app.Services.GetRequiredService>().Value; - - if (string.IsNullOrEmpty(keycloak.AuthServerUrl) || string.IsNullOrEmpty(keycloak.Realm)) - { - app.Logger.KeycloakConfigMissing(); - return; - } - - if (keycloak.SkipReadinessCheck) - { - app.Logger.KeycloakReadinessCheckSkipped(); - return; - } - - var discoveryUrl = KeycloakUrlHelper.BuildDiscoveryUrl( - keycloak.AuthServerUrl, - keycloak.Realm - ); - using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; - - var pipelineProvider = app.Services.GetRequiredService< - ResiliencePipelineProvider - >(); - var pipeline = pipelineProvider.GetPipeline(ResiliencePipelineKeys.KeycloakReadiness); - - try - { - using var telemetry = StartupTelemetry.StartKeycloakReadinessCheck(); - try - { - await pipeline.ExecuteAsync( - async token => - { - var response = await httpClient.GetAsync(discoveryUrl, token); - if (!response.IsSuccessStatusCode) - { - throw new HttpRequestException( - $"Keycloak readiness probe returned HTTP {(int)response.StatusCode} ({response.ReasonPhrase})." - ); - } - }, - cancellationToken - ); - } - catch (Exception ex) - { - telemetry.Fail(ex); - throw; - } - - app.Logger.KeycloakReady(keycloak.AuthServerUrl); - } - catch (HttpRequestException ex) - { - app.Logger.KeycloakUnavailable(ex, keycloak.ReadinessMaxRetries); - throw new InvalidOperationException( - $"Keycloak at {keycloak.AuthServerUrl} did not become available after {keycloak.ReadinessMaxRetries} retries.", - ex - ); - } - catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) - { - app.Logger.KeycloakUnavailable(ex, keycloak.ReadinessMaxRetries); - throw new InvalidOperationException( - $"Keycloak at {keycloak.AuthServerUrl} did not become available after {keycloak.ReadinessMaxRetries} retries.", - ex - ); - } - } - - /// - /// Builds the HTTP middleware pipeline in execution order. - /// - /// - /// Exception handling is intentionally first so downstream middleware and endpoints - /// are wrapped by the global handler. app.UseExceptionHandler() activates - /// handlers previously registered in DI (for example via AddExceptionHandler<T>()). - /// - public static WebApplication UseApiPipeline(this WebApplication app) - { - app.UseExceptionHandler(); // Global exception handling — must be outermost. - app.UseApiDocumentation(); // Scalar / OpenAPI — development only. - app.UseHttpsRedirection(); - app.UseSecurityPipeline(); // CORS → Authentication → CSRF → Authorization. - app.UseRequestContextPipeline(); // Correlation enrichment + structured request logging (after auth for tenant context). - app.UseRateLimiter(); - app.UseOutputCache(); - - return app; - } - - /// Maps REST controllers, the GraphQL endpoint, Nitro UI, and health checks. - public static WebApplication MapApplicationEndpoints(this WebApplication app) - { - app.MapControllers().RequireRateLimiting(RateLimitPolicies.Fixed); - app.MapGraphQL().RequireRateLimiting(RateLimitPolicies.Fixed); - app.MapNitroApp("/graphql/ui"); - app.UseHealthChecks(); - - return app; - } - - /// - /// Mounts the OpenAPI JSON endpoint and the Scalar interactive API reference in Development - /// only, pre-configured with Keycloak PKCE OAuth2 authorization-code flow. - /// - public static WebApplication UseApiDocumentation(this WebApplication app) - { - if (!app.Environment.IsDevelopment()) - return app; // Keep interactive API docs available only in development. - - var keycloak = app.Services.GetRequiredService>().Value; - var appOptions = app.Services.GetRequiredService>().Value; - var authority = KeycloakUrlHelper.BuildAuthority(keycloak.AuthServerUrl, keycloak.Realm); - - app.MapOpenApi().AllowAnonymous(); // Map OpenAPI JSON endpoint. - app.MapScalarApiReference( - "/scalar", - (options, httpContext) => - { - var redirectUri = BuildScalarRedirectUri(httpContext.Request); - - options.WithTitle(appOptions.ServiceName); - options - .AddPreferredSecuritySchemes(AuthConstants.OpenApi.OAuth2Scheme) - .AddAuthorizationCodeFlow( - AuthConstants.OpenApi.OAuth2Scheme, - flow => - { - flow.ClientId = AuthConstants.OpenApi.ScalarClientId; - flow.SelectedScopes = [.. AuthConstants.Scopes.Default]; - flow.AuthorizationUrl = - $"{authority}/{AuthConstants.OpenIdConnect.AuthorizationEndpointPath}"; - flow.TokenUrl = - $"{authority}/{AuthConstants.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}"; - - /// Maps the /health endpoint with a JSON health-check UI response writer, available anonymously. - public static WebApplication UseHealthChecks(this WebApplication app) - { - app.MapHealthChecks( - "/health", - new HealthCheckOptions - { - ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse, - } - ) - .WithTags("Health") - .WithSummary("Health check") - .WithDescription("Returns the health status of all registered services.") - .AllowAnonymous(); - - return app; - } -} diff --git a/absolute/src/APITemplate.Api/Extensions/Startup/LoggingExtensions.cs b/absolute/src/APITemplate.Api/Extensions/Startup/LoggingExtensions.cs deleted file mode 100644 index 12aca4d8..00000000 --- a/absolute/src/APITemplate.Api/Extensions/Startup/LoggingExtensions.cs +++ /dev/null @@ -1,103 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using APITemplate.Infrastructure.Logging; -using Microsoft.Extensions.Compliance.Classification; -using Microsoft.Extensions.Compliance.Redaction; -using Serilog; -using Serilog.Sinks.OpenTelemetry; - -namespace APITemplate.Api.Extensions.Startup; - -/// -/// Presentation-layer extension class that configures Microsoft.Extensions.Compliance -/// redaction (HMAC for sensitive data, erasure for personal data) and Serilog OpenTelemetry sinks. -/// -public static class LoggingExtensions -{ - /// - /// Registers HMAC and erasing redactors for sensitive and personal data classifications - /// and enables log redaction on the host's logging pipeline. - /// - public static WebApplicationBuilder AddApplicationRedaction(this WebApplicationBuilder builder) - { - builder.Services.AddRedaction(redactionBuilder => - { - redactionBuilder.SetRedactor(LogDataClassifications.Personal); - -#pragma warning disable EXTEXP0002 // HMAC redactor API is currently marked experimental in the Microsoft.Extensions.Compliance.Redaction package. - redactionBuilder.SetHmacRedactor( - options => - { - var redactionOptions = - builder.Configuration.SectionFor().Get() - ?? new RedactionOptions(); - Validator.ValidateObject( - redactionOptions, - new ValidationContext(redactionOptions), - validateAllProperties: true - ); - - var hmacKey = RedactionConfiguration.ResolveHmacKey( - redactionOptions, - Environment.GetEnvironmentVariable - ); - - options.KeyId = redactionOptions.KeyId; - options.Key = hmacKey; - }, - new DataClassificationSet(LogDataClassifications.Sensitive) - ); -#pragma warning restore EXTEXP0002 - - redactionBuilder.SetFallbackRedactor(); - }); - - builder.Logging.EnableRedaction(); - - return builder; - } - - /// - /// Attaches Serilog OpenTelemetry sinks for each enabled OTLP endpoint, enriching log - /// events with activity trace/span IDs and OpenTelemetry resource attributes. - /// - public static LoggerConfiguration AddOpenTelemetrySinks( - this LoggerConfiguration loggerConfiguration, - IConfiguration configuration, - IHostEnvironment environment - ) - { - loggerConfiguration.Enrich.FromLogContext().Enrich.With(); - - var options = ObservabilityServiceCollectionExtensions.GetObservabilityOptions( - configuration - ); - - var appOptions = ObservabilityServiceCollectionExtensions.GetAppOptions(configuration); - - var resourceAttributes = ObservabilityServiceCollectionExtensions.BuildResourceAttributes( - appOptions, - environment - ); - var endpoints = ObservabilityServiceCollectionExtensions.GetEnabledOtlpEndpoints( - options, - environment - ); - - foreach (var endpoint in endpoints) - { - loggerConfiguration.WriteTo.OpenTelemetry(otel => - { - otel.Endpoint = endpoint; - otel.Protocol = OtlpProtocol.Grpc; - otel.IncludedData = - IncludedData.MessageTemplateTextAttribute - | IncludedData.SpecRequiredResourceAttributes - | IncludedData.TraceIdField - | IncludedData.SpanIdField; - otel.ResourceAttributes = resourceAttributes; - }); - } - - return loggerConfiguration; - } -} diff --git a/absolute/src/APITemplate.Api/Extensions/WebhookServiceCollectionExtensions.cs b/absolute/src/APITemplate.Api/Extensions/WebhookServiceCollectionExtensions.cs deleted file mode 100644 index 8f992432..00000000 --- a/absolute/src/APITemplate.Api/Extensions/WebhookServiceCollectionExtensions.cs +++ /dev/null @@ -1,76 +0,0 @@ -using APITemplate.Api.Extensions.Resilience; -using APITemplate.Application.Common.BackgroundJobs; -using APITemplate.Application.Common.Contracts; -using APITemplate.Application.Common.Options; -using APITemplate.Application.Common.Resilience; -using APITemplate.Application.Features.Examples.DTOs; -using APITemplate.Infrastructure.Webhooks; -using Microsoft.Extensions.Http.Resilience; -using Polly; - -namespace APITemplate.Api.Extensions; - -/// -/// Presentation-layer extension class that registers both incoming (HMAC-validated, channel-queued) -/// and outgoing (signed, HTTP-delivered) webhook infrastructure services. -/// -public static class WebhookServiceCollectionExtensions -{ - /// - /// Registers , the HMAC payload validator, the inbound channel - /// queue with its background processor, and the logging webhook event handler. - /// - public static IServiceCollection AddIncomingWebhookServices( - this IServiceCollection services, - IConfiguration configuration - ) - { - services.AddValidatedOptions(configuration); - - services.AddSingleton(); - services.AddQueueWithConsumer< - ChannelWebhookQueue, - IWebhookProcessingQueue, - IWebhookQueueReader, - WebhookProcessingBackgroundService - >(); - services.AddScoped(); - return services; - } - - /// - /// Registers the HMAC payload signer, the outbound channel queue with its background delivery - /// service, and an HTTP client with a Polly exponential-backoff retry pipeline for failed deliveries. - /// - public static IServiceCollection AddOutgoingWebhookServices(this IServiceCollection services) - { - services.AddSingleton(); - - services.AddQueueWithConsumer< - ChannelOutgoingWebhookQueue, - IOutgoingWebhookQueue, - IOutgoingWebhookQueueReader, - OutgoingWebhookBackgroundService - >(); - - services - .AddHttpClient(WebhookConstants.OutgoingHttpClientName) - .AddResilienceHandler( - ResiliencePipelineKeys.OutgoingWebhook, - builder => - { - builder.AddRetry( - new HttpRetryStrategyOptions - { - MaxRetryAttempts = ResilienceDefaults.MaxRetryAttempts, - BackoffType = DelayBackoffType.Exponential, - Delay = ResilienceDefaults.LongDelay, - UseJitter = true, - } - ); - } - ); - - return services; - } -} diff --git a/absolute/src/APITemplate.Api/Extensions/WolverineHandlerChainExtensions.cs b/absolute/src/APITemplate.Api/Extensions/WolverineHandlerChainExtensions.cs deleted file mode 100644 index 44863876..00000000 --- a/absolute/src/APITemplate.Api/Extensions/WolverineHandlerChainExtensions.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Reflection; -using Wolverine.Runtime.Handlers; - -namespace APITemplate.Api.Extensions; - -/// -/// Wolverine handler-chain helpers used during bootstrapping to keep Program.cs focused on -/// orchestration rather than reflection-based policy rules. -/// -public static class WolverineHandlerChainExtensions -{ - /// - /// Returns when the chain handles a message with a registered validator - /// and at least one handler returns ErrorOr<T> directly or through Task/ValueTask. - /// - public static bool ShouldApplyErrorOrValidation( - this HandlerChain chain, - Assembly validatorAssembly - ) => - chain.MessageType.HasValidatorIn(validatorAssembly) - && chain.Handlers.Any(h => h.Method.ReturnType.IsErrorOrReturnType()); -} diff --git a/absolute/src/APITemplate.Api/Extensions/WolverineTypeExtensions.cs b/absolute/src/APITemplate.Api/Extensions/WolverineTypeExtensions.cs deleted file mode 100644 index 0945d7c2..00000000 --- a/absolute/src/APITemplate.Api/Extensions/WolverineTypeExtensions.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Reflection; -using ErrorOr; -using FluentValidation; - -namespace APITemplate.Api.Extensions; - -internal static class WolverineTypeExtensions -{ - internal static bool IsErrorOrReturnType(this Type returnType) - { - if (!returnType.IsGenericType) - return false; - - var genericTypeDefinition = returnType.GetGenericTypeDefinition(); - - if (genericTypeDefinition == typeof(Task<>) || genericTypeDefinition == typeof(ValueTask<>)) - return returnType.GetGenericArguments()[0].IsErrorOrReturnType(); - - return genericTypeDefinition == typeof(ErrorOr<>); - } - - internal static bool HasValidatorIn(this Type messageType, Assembly assembly) - { - var validatorType = typeof(IValidator<>).MakeGenericType(messageType); - return assembly - .GetTypes() - .Where(type => !type.IsAbstract && !type.IsGenericTypeDefinition) - .Any(type => validatorType.IsAssignableFrom(type)); - } -} diff --git a/absolute/src/APITemplate.Api/GlobalUsings.cs b/absolute/src/APITemplate.Api/GlobalUsings.cs deleted file mode 100644 index 04791f88..00000000 --- a/absolute/src/APITemplate.Api/GlobalUsings.cs +++ /dev/null @@ -1,28 +0,0 @@ -global using APITemplate.Api.Extensions; -global using APITemplate.Api.Extensions.Configuration; -global using APITemplate.Api.Extensions.Startup; -global using APITemplate.Application.Common.DTOs; -global using APITemplate.Application.Common.Errors; -global using APITemplate.Application.Common.Options; -global using APITemplate.Application.Common.Options.BackgroundJobs; -global using APITemplate.Application.Common.Options.Infrastructure; -global using APITemplate.Application.Common.Options.Security; -global using APITemplate.Application.Common.Security; -global using APITemplate.Application.Features.Bff.DTOs; -global using APITemplate.Application.Features.Category; -global using APITemplate.Application.Features.Category.DTOs; -global using APITemplate.Application.Features.Product; -global using APITemplate.Application.Features.Product.DTOs; -global using APITemplate.Application.Features.ProductData; -global using APITemplate.Application.Features.ProductData.DTOs; -global using APITemplate.Application.Features.ProductReview; -global using APITemplate.Application.Features.ProductReview.DTOs; -global using APITemplate.Application.Features.Tenant; -global using APITemplate.Application.Features.Tenant.DTOs; -global using APITemplate.Application.Features.User; -global using APITemplate.Application.Features.User.DTOs; -global using APITemplate.Domain.Common; -global using APITemplate.Domain.Entities.Contracts; -global using APITemplate.Domain.Entities.ProductData; -global using APITemplate.Infrastructure.Security.Keycloak; -global using APITemplate.Infrastructure.Security.Tenant; diff --git a/absolute/src/APITemplate.Api/Program.cs b/absolute/src/APITemplate.Api/Program.cs deleted file mode 100644 index 022fc997..00000000 --- a/absolute/src/APITemplate.Api/Program.cs +++ /dev/null @@ -1,82 +0,0 @@ -using APITemplate.Application.Common.Middleware; -using JasperFx; -using JasperFx.CodeGeneration; -using Serilog; -using Wolverine; - -try -{ - var builder = WebApplication.CreateBuilder(args); // Build host, configuration, and DI container. - builder.AddApplicationRedaction(); - - builder.Host.UseSerilog( - (context, services, loggerConfiguration) => - { - loggerConfiguration - .ReadFrom.Configuration(context.Configuration) - .ReadFrom.Services(services) - .AddOpenTelemetrySinks(context.Configuration, context.HostingEnvironment); - } - ); - - builder.Services.AddApiFoundation(builder.Configuration); // Registers exception handling services (AddExceptionHandler + ProblemDetails), activated later in UseApiPipeline. - builder.Services.AddObservability(builder.Configuration, builder.Environment); // Register OpenTelemetry tracing/metrics and environment-specific exporters. - builder.Services.AddAuthenticationOptions(builder.Configuration, builder.Environment); - builder.Services.AddPersistence(builder.Configuration); // Register EF Core + repositories + relational health checks. - builder.Services.AddApplicationServices(); // Register application services + validators. - builder.Services.AddEmailServices(builder.Configuration); // Register email sending infrastructure (SMTP, templates, queue, background service). - builder.Services.AddBackgroundJobs(builder.Configuration); // Register TickerQ-backed recurring background jobs (cleanup, reindex, email retry). - builder.Services.AddMongoDB(builder.Configuration); // Register Mongo context/services + Mongo health checks. - builder.Services.AddKeycloakBffAuthentication(builder.Configuration, builder.Environment); // Register Keycloak hybrid JWT + BFF authentication. - builder.Services.AddKeycloakAdminService(); // Register Keycloak Admin API client for user management. - builder.Services.AddApiVersioningConfiguration(); // Register API versioning and explorer metadata. - builder.Services.AddGraphQLConfiguration(); // Register GraphQL schema and server services. - builder.Services.AddFileStorageServices(builder.Configuration); // Register file storage (local FS) for example upload/download. - builder.Services.AddJobServices(); // Register long-running job queue and background processor. - builder.Services.AddIncomingWebhookServices(builder.Configuration); // Register webhook HMAC validation, queue, and background processor. - builder.Services.AddOutgoingWebhookServices(); // Register outgoing webhook queue, signer, and delivery background service. - - builder.Services.CritterStackDefaults(x => - { - x.Production.GeneratedCodeMode = TypeLoadMode.Static; - x.Production.AssertAllPreGeneratedTypesExist = true; - x.Development.GeneratedCodeMode = TypeLoadMode.Dynamic; - }); - - builder.Host.UseWolverine(opts => - { - opts.Durability.Mode = DurabilityMode.Balanced; - opts.Discovery.IncludeAssembly(typeof(CreateProductsCommand).Assembly); - opts.Discovery.IncludeAssembly(typeof(Program).Assembly); - - // Apply ErrorOr validation middleware only to handlers returning ErrorOr. - // Event handlers (returning Task) and non-ErrorOr handlers are not affected. - opts.Policies.AddMiddleware( - typeof(ErrorOrValidationMiddleware), - chain => chain.ShouldApplyErrorOrValidation(typeof(CreateProductsCommand).Assembly) - ); - }); - - var app = builder.Build(); // Materialize the web app from configured services. - app.Logger.LogInformation("Starting APITemplate"); // Startup banner for diagnostics after logging pipeline is ready. - - await app.UseDatabaseAsync(app.Lifetime.ApplicationStopping); // Apply SQL/Mongo migrations before serving traffic. - await app.WaitForKeycloakAsync(app.Lifetime.ApplicationStopping); // Wait for Keycloak to be reachable before serving traffic. - await app.UseBackgroundJobsAsync(app.Lifetime.ApplicationStopping); // Sync and start recurring TickerQ jobs after dependencies are ready. - - app.UseApiPipeline(); // Configure middleware order for request processing. - app.MapApplicationEndpoints(); // Map REST/GraphQL/health endpoints. - - app.Run(); // Start HTTP server and block until shutdown. -} -catch (Exception ex) when (ex is not HostAbortedException) -{ - Console.Error.WriteLine($"Application terminated unexpectedly: {ex}"); - throw; -} - -/// -/// Application entry-point marker class; declared as a partial so integration tests can -/// reference the assembly via WebApplicationFactory<Program>. -/// -public partial class Program; // Used by integration tests via WebApplicationFactory. diff --git a/absolute/src/APITemplate.Api/Properties/launchSettings.json b/absolute/src/APITemplate.Api/Properties/launchSettings.json deleted file mode 100644 index 5cf02897..00000000 --- a/absolute/src/APITemplate.Api/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "http://localhost:5174", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "https://localhost:7289;http://localhost:5174", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/absolute/src/APITemplate.Api/appsettings.Production.json b/absolute/src/APITemplate.Api/appsettings.Production.json deleted file mode 100644 index 69539123..00000000 --- a/absolute/src/APITemplate.Api/appsettings.Production.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "App": { - "ServiceName": "APITemplate" - }, - "SystemIdentity": { - "DefaultActorId": "00000000-0000-0000-0000-000000000000" - }, - "BootstrapTenant": { - "Code": "default", - "Name": "Default Tenant" - }, - "Redaction": { - "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", - "KeyId": 1001 - }, - "Observability": { - "Otlp": { - "Endpoint": "http://alloy:4317" - }, - "Aspire": { - "Endpoint": "" - }, - "Exporters": { - "Aspire": { - "Enabled": false - }, - "Otlp": { - "Enabled": true - }, - "Console": { - "Enabled": false - } - } - }, - "Serilog": { - "Using": [ - "Serilog.Sinks.Console", - "Serilog.Sinks.File", - "Serilog.Expressions", - "Serilog.Formatting.Compact", - "Serilog.Enrichers.Environment", - "Serilog.Enrichers.Thread" - ], - "MinimumLevel": { - "Default": "Information", - "Override": { - "Microsoft": "Warning", - "Microsoft.AspNetCore": "Warning", - "System": "Warning" - } - }, - "Enrich": [ - "FromLogContext", - "WithMachineName", - "WithThreadId" - ], - "Properties": { - "Application": "APITemplate" - }, - "Filter": [ - { - "Name": "ByExcluding", - "Args": { - "expression": "RequestPath like '/health%'" - } - } - ], - "WriteTo": [ - { - "Name": "Console", - "Args": { - "formatter": "Serilog.Formatting.Compact.RenderedCompactJsonFormatter, Serilog.Formatting.Compact" - } - }, - { - "Name": "File", - "Args": { - "path": "logs/apitemplate-.log", - "formatter": "Serilog.Formatting.Compact.RenderedCompactJsonFormatter, Serilog.Formatting.Compact", - "rollingInterval": "Day", - "retainedFileCountLimit": 7, - "fileSizeLimitBytes": 1048576, - "rollOnFileSizeLimit": true, - "shared": true - } - } - ] - } -} \ No newline at end of file diff --git a/absolute/src/APITemplate.Api/appsettings.json b/absolute/src/APITemplate.Api/appsettings.json deleted file mode 100644 index f2efe827..00000000 --- a/absolute/src/APITemplate.Api/appsettings.json +++ /dev/null @@ -1,166 +0,0 @@ -{ - "App": { - "ServiceName": "APITemplate" - }, - "ConnectionStrings": { - "DefaultConnection": "Host=localhost;Port=5432;Database=apitemplate;Username=postgres;Password=postgres" - }, - "MongoDB": { - "ConnectionString": "mongodb://localhost:27017", - "DatabaseName": "apitemplate" - }, - "Dragonfly": { - "ConnectionString": "localhost:6379", - "ConnectTimeoutMs": 5000, - "SyncTimeoutMs": 3000 - }, - "Cors": { - "AllowedOrigins": [ - "http://localhost:3000", - "http://localhost:5173", - "https://localhost:3000", - "https://localhost:5173" - ] - }, - "Keycloak": { - "realm": "", - "auth-server-url": "", - "ssl-required": "external", - "resource": "", - "verify-token-audience": true, - "credentials": { - "secret": "" - }, - "confidential-port": 0 - }, - "Bff": { - "CookieName": ".APITemplate.Auth", - "PostLogoutRedirectUri": "/", - "SessionTimeoutMinutes": 60, - "Scopes": [ - "openid", - "profile", - "email", - "offline_access" - ], - "TokenRefreshThresholdMinutes": 2 - }, - "SystemIdentity": { - "DefaultActorId": "00000000-0000-0000-0000-000000000000" - }, - "RateLimiting": { - "PermitLimit": 100, - "WindowMinutes": 1 - }, - "Caching": { - "ProductsExpirationSeconds": 30, - "CategoriesExpirationSeconds": 60, - "ReviewsExpirationSeconds": 30, - "ProductDataExpirationSeconds": 30, - "TenantsExpirationSeconds": 60, - "TenantInvitationsExpirationSeconds": 30, - "UsersExpirationSeconds": 30 - }, - "TransactionDefaults": { - "IsolationLevel": "ReadCommitted", - "TimeoutSeconds": 30, - "RetryEnabled": true, - "RetryCount": 3, - "RetryDelaySeconds": 5 - }, - "Observability": { - "Otlp": { - "Endpoint": "http://localhost:4317" - }, - "Aspire": { - "Endpoint": "http://localhost:4317" - }, - "Exporters": { - "Aspire": { - "Enabled": null - }, - "Otlp": { - "Enabled": false - }, - "Console": { - "Enabled": false - } - } - }, - "Email": { - "SmtpHost": "localhost", - "SmtpPort": 587, - "UseSsl": true, - "SenderEmail": "noreply@apitemplate.local", - "SenderName": "APITemplate", - "InvitationTokenExpiryHours": 72, - "BaseUrl": "http://localhost:5000", - "MaxRetryAttempts": 3, - "RetryBaseDelaySeconds": 2 - }, - "BackgroundJobs": { - "TickerQ": { - "Enabled": true, - "FailClosed": true, - "InstanceNamePrefix": "APITemplate", - "CoordinationConnection": "Dragonfly" - }, - "ExternalSync": { - "Enabled": false, - "Cron": "0 */12 * * *" - }, - "Cleanup": { - "Enabled": false, - "Cron": "0 * * * *", - "ExpiredInvitationRetentionHours": 168, - "SoftDeleteRetentionDays": 30, - "OrphanedProductDataRetentionDays": 7, - "BatchSize": 100 - }, - "Reindex": { - "Enabled": false, - "Cron": "0 */6 * * *" - }, - "EmailRetry": { - "Enabled": false, - "Cron": "*/15 * * * *", - "MaxRetryAttempts": 5, - "BatchSize": 50, - "DeadLetterAfterHours": 48, - "ClaimLeaseMinutes": 10 - } - }, - "BootstrapTenant": { - "Code": "default", - "Name": "Default Tenant" - }, - "Serilog": { - "Using": [ - "Serilog.Sinks.Console", - "Serilog.Enrichers.Environment", - "Serilog.Enrichers.Thread" - ], - "MinimumLevel": { - "Default": "Information", - "Override": { - "Microsoft": "Warning", - "Microsoft.AspNetCore": "Warning", - "System": "Warning" - } - }, - "Enrich": [ - "FromLogContext", - "WithMachineName", - "WithThreadId" - ], - "Properties": { - "Application": "APITemplate" - }, - "WriteTo": [ - { - "Name": "Console" - } - ] - }, - "AllowedHosts": "*" -} diff --git a/absolute/src/APITemplate.Application/APITemplate.Application.csproj b/absolute/src/APITemplate.Application/APITemplate.Application.csproj deleted file mode 100644 index 592b71b5..00000000 --- a/absolute/src/APITemplate.Application/APITemplate.Application.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - net10.0 - enable - enable - - - - - - - - - - - - - - - - - - - - - - diff --git a/absolute/src/APITemplate.Application/Common/BackgroundJobs/ICleanupService.cs b/absolute/src/APITemplate.Application/Common/BackgroundJobs/ICleanupService.cs deleted file mode 100644 index fdf56aab..00000000 --- a/absolute/src/APITemplate.Application/Common/BackgroundJobs/ICleanupService.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace APITemplate.Application.Common.BackgroundJobs; - -/// -/// Application-layer contract for scheduled data-cleanup operations. -/// Implementations live in the Infrastructure layer and are invoked by recurring background jobs. -/// -public interface ICleanupService -{ - /// - /// Removes expired tenant invitations older than hours, - /// processed in batches of to limit database pressure. - /// - Task CleanupExpiredInvitationsAsync( - int retentionHours, - int batchSize, - CancellationToken ct = default - ); - - /// - /// Permanently purges soft-deleted records that exceeded the retention window, - /// processed in batches of . - /// - Task CleanupSoftDeletedRecordsAsync( - int retentionDays, - int batchSize, - CancellationToken ct = default - ); - - /// - /// Deletes product-data entries that are no longer referenced by any product and have exceeded - /// the retention window, processed in batches of . - /// - Task CleanupOrphanedProductDataAsync( - int retentionDays, - int batchSize, - CancellationToken ct = default - ); -} diff --git a/absolute/src/APITemplate.Application/Common/BackgroundJobs/IEmailRetryService.cs b/absolute/src/APITemplate.Application/Common/BackgroundJobs/IEmailRetryService.cs deleted file mode 100644 index 0c59e8aa..00000000 --- a/absolute/src/APITemplate.Application/Common/BackgroundJobs/IEmailRetryService.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace APITemplate.Application.Common.BackgroundJobs; - -/// -/// Application-layer contract for retrying and dead-lettering failed outbound emails. -/// Implementations are driven by recurring background jobs in the Infrastructure layer. -/// -public interface IEmailRetryService -{ - /// - /// Re-attempts delivery of previously failed emails up to times, - /// processing up to messages per invocation. - /// - Task RetryFailedEmailsAsync( - int maxRetryAttempts, - int batchSize, - CancellationToken ct = default - ); - - /// - /// Moves emails that have exceeded the age threshold - /// to a dead-letter store, processed in batches of . - /// - Task DeadLetterExpiredAsync( - int deadLetterAfterHours, - int batchSize, - CancellationToken ct = default - ); -} diff --git a/absolute/src/APITemplate.Application/Common/BackgroundJobs/IExternalIntegrationSyncService.cs b/absolute/src/APITemplate.Application/Common/BackgroundJobs/IExternalIntegrationSyncService.cs deleted file mode 100644 index 190cc756..00000000 --- a/absolute/src/APITemplate.Application/Common/BackgroundJobs/IExternalIntegrationSyncService.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace APITemplate.Application.Common.BackgroundJobs; - -/// -/// Application-layer contract for synchronizing data with external third-party integrations. -/// Implementations are provided by the Infrastructure layer and scheduled as recurring background jobs. -/// -public interface IExternalIntegrationSyncService -{ - /// - /// Pulls changes from external systems and reconciles them with the local data store. - /// - Task SynchronizeAsync(CancellationToken ct = default); -} diff --git a/absolute/src/APITemplate.Application/Common/BackgroundJobs/IJobQueue.cs b/absolute/src/APITemplate.Application/Common/BackgroundJobs/IJobQueue.cs deleted file mode 100644 index fdc88a17..00000000 --- a/absolute/src/APITemplate.Application/Common/BackgroundJobs/IJobQueue.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace APITemplate.Application.Common.BackgroundJobs; - -/// -/// 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/absolute/src/APITemplate.Application/Common/BackgroundJobs/IOutgoingWebhookQueue.cs b/absolute/src/APITemplate.Application/Common/BackgroundJobs/IOutgoingWebhookQueue.cs deleted file mode 100644 index bc116055..00000000 --- a/absolute/src/APITemplate.Application/Common/BackgroundJobs/IOutgoingWebhookQueue.cs +++ /dev/null @@ -1,13 +0,0 @@ -using APITemplate.Application.Features.Examples.DTOs; - -namespace APITemplate.Application.Common.BackgroundJobs; - -/// -/// Write-side contract for enqueuing outgoing webhook dispatch items. -/// -public interface IOutgoingWebhookQueue : IQueue; - -/// -/// Read-side contract for consuming outgoing webhook items from the queue. -/// -public interface IOutgoingWebhookQueueReader : IQueueReader; diff --git a/absolute/src/APITemplate.Application/Common/BackgroundJobs/IQueue.cs b/absolute/src/APITemplate.Application/Common/BackgroundJobs/IQueue.cs deleted file mode 100644 index fa7a0c81..00000000 --- a/absolute/src/APITemplate.Application/Common/BackgroundJobs/IQueue.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace APITemplate.Application.Common.BackgroundJobs; - -/// -/// 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); -} diff --git a/absolute/src/APITemplate.Application/Common/BackgroundJobs/IQueueReader.cs b/absolute/src/APITemplate.Application/Common/BackgroundJobs/IQueueReader.cs deleted file mode 100644 index 710ccd65..00000000 --- a/absolute/src/APITemplate.Application/Common/BackgroundJobs/IQueueReader.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace APITemplate.Application.Common.BackgroundJobs; - -/// -/// 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/absolute/src/APITemplate.Application/Common/BackgroundJobs/IRecurringBackgroundJobRegistration.cs b/absolute/src/APITemplate.Application/Common/BackgroundJobs/IRecurringBackgroundJobRegistration.cs deleted file mode 100644 index 3aff5e9a..00000000 --- a/absolute/src/APITemplate.Application/Common/BackgroundJobs/IRecurringBackgroundJobRegistration.cs +++ /dev/null @@ -1,16 +0,0 @@ -using APITemplate.Application.Common.Options; - -namespace APITemplate.Application.Common.BackgroundJobs; - -/// -/// 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/absolute/src/APITemplate.Application/Common/BackgroundJobs/IReindexService.cs b/absolute/src/APITemplate.Application/Common/BackgroundJobs/IReindexService.cs deleted file mode 100644 index 2f60ba22..00000000 --- a/absolute/src/APITemplate.Application/Common/BackgroundJobs/IReindexService.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace APITemplate.Application.Common.BackgroundJobs; - -/// -/// 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/absolute/src/APITemplate.Application/Common/BackgroundJobs/IWebhookProcessingQueue.cs b/absolute/src/APITemplate.Application/Common/BackgroundJobs/IWebhookProcessingQueue.cs deleted file mode 100644 index 320a14a0..00000000 --- a/absolute/src/APITemplate.Application/Common/BackgroundJobs/IWebhookProcessingQueue.cs +++ /dev/null @@ -1,13 +0,0 @@ -using APITemplate.Application.Features.Examples.DTOs; - -namespace APITemplate.Application.Common.BackgroundJobs; - -/// -/// Write-side contract for enqueuing inbound webhook payloads awaiting processing. -/// -public interface IWebhookProcessingQueue : IQueue; - -/// -/// Read-side contract for consuming inbound webhook payloads from the processing queue. -/// -public interface IWebhookQueueReader : IQueueReader; diff --git a/absolute/src/APITemplate.Application/Common/BackgroundJobs/RecurringBackgroundJobDefinition.cs b/absolute/src/APITemplate.Application/Common/BackgroundJobs/RecurringBackgroundJobDefinition.cs deleted file mode 100644 index f6f1f3d6..00000000 --- a/absolute/src/APITemplate.Application/Common/BackgroundJobs/RecurringBackgroundJobDefinition.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace APITemplate.Application.Common.BackgroundJobs; - -/// -/// Immutable descriptor for a recurring background job passed from the Application layer to the -/// Infrastructure scheduler (e.g. Hangfire). 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 (e.g. Hangfire job method 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/absolute/src/APITemplate.Application/Common/Batch/BatchFailureContext.cs b/absolute/src/APITemplate.Application/Common/Batch/BatchFailureContext.cs deleted file mode 100644 index 72815418..00000000 --- a/absolute/src/APITemplate.Application/Common/Batch/BatchFailureContext.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace APITemplate.Application.Common.Batch; - -/// -/// Holds batch items and collects per-item failures across validation rules. -/// -internal sealed class BatchFailureContext -{ - private readonly List _failures = []; - private readonly HashSet _failedIndices = []; - - internal BatchFailureContext(IReadOnlyList items) => Items = items; - - internal IReadOnlyList Items { get; } - internal bool HasFailures => _failures.Count > 0; - internal IReadOnlySet FailedIndices => _failedIndices; - - internal void AddFailure(int index, Guid? id, IReadOnlyList errors) - { - _failures.Add(new BatchResultItem(index, id, errors)); - _failedIndices.Add(index); - } - - internal void AddFailure(int index, Guid? id, string error) => AddFailure(index, id, [error]); - - internal void AddFailures(IEnumerable failures) - { - foreach (var failure in failures) - { - _failures.Add(failure); - _failedIndices.Add(failure.Index); - } - } - - internal bool IsFailed(int index) => _failedIndices.Contains(index); - - internal async Task ApplyRulesAsync(CancellationToken ct, params IBatchRule[] rules) - { - for (var i = 0; i < rules.Length; i++) - await rules[i].ApplyAsync(this, ct); - } - - internal BatchResponse ToFailureResponse() => new(_failures, 0, _failures.Count); -} diff --git a/absolute/src/APITemplate.Application/Common/Batch/BatchFailureMerge.cs b/absolute/src/APITemplate.Application/Common/Batch/BatchFailureMerge.cs deleted file mode 100644 index f8400dfe..00000000 --- a/absolute/src/APITemplate.Application/Common/Batch/BatchFailureMerge.cs +++ /dev/null @@ -1,44 +0,0 @@ -using APITemplate.Application.Common.DTOs; - -namespace APITemplate.Application.Common.Batch; - -/// -/// Merges per-item batch failures that share the same index (e.g. missing category and missing product data). -/// -internal static class BatchFailureMerge -{ - internal static List MergeByIndex( - IEnumerable first, - IEnumerable second - ) - { - var errorsByIndex = new Dictionary>(); - var idByIndex = new Dictionary(); - - void Accumulate(BatchResultItem item) - { - if (!errorsByIndex.TryGetValue(item.Index, out var list)) - { - list = []; - errorsByIndex[item.Index] = list; - } - - list.AddRange(item.Errors); - - if (!idByIndex.TryGetValue(item.Index, out var existingId)) - idByIndex[item.Index] = item.Id; - else if (existingId is null && item.Id is not null) - idByIndex[item.Index] = item.Id; - } - - foreach (var x in first) - Accumulate(x); - foreach (var 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/absolute/src/APITemplate.Application/Common/Batch/EntityLookup.cs b/absolute/src/APITemplate.Application/Common/Batch/EntityLookup.cs deleted file mode 100644 index 23d83087..00000000 --- a/absolute/src/APITemplate.Application/Common/Batch/EntityLookup.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace APITemplate.Application.Common.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/absolute/src/APITemplate.Application/Common/Batch/IBatchRule.cs b/absolute/src/APITemplate.Application/Common/Batch/IBatchRule.cs deleted file mode 100644 index ae12bb2e..00000000 --- a/absolute/src/APITemplate.Application/Common/Batch/IBatchRule.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace APITemplate.Application.Common.Batch; - -internal interface IBatchRule -{ - Task ApplyAsync(BatchFailureContext context, CancellationToken ct); -} diff --git a/absolute/src/APITemplate.Application/Common/Batch/Rules/FluentValidationBatchRule.cs b/absolute/src/APITemplate.Application/Common/Batch/Rules/FluentValidationBatchRule.cs deleted file mode 100644 index 8ef414ce..00000000 --- a/absolute/src/APITemplate.Application/Common/Batch/Rules/FluentValidationBatchRule.cs +++ /dev/null @@ -1,29 +0,0 @@ -using FluentValidation; - -namespace APITemplate.Application.Common.Batch.Rules; - -internal sealed class FluentValidationBatchRule(IValidator validator) - : IBatchRule -{ - private readonly IValidator _validator = validator; - - public async Task ApplyAsync(BatchFailureContext context, CancellationToken ct) - { - for (var i = 0; i < context.Items.Count; i++) - { - if (context.IsFailed(i)) - continue; - - var 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/absolute/src/APITemplate.Application/Common/Batch/Rules/MarkMissingByIdBatchRule.cs b/absolute/src/APITemplate.Application/Common/Batch/Rules/MarkMissingByIdBatchRule.cs deleted file mode 100644 index 89bdf552..00000000 --- a/absolute/src/APITemplate.Application/Common/Batch/Rules/MarkMissingByIdBatchRule.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace APITemplate.Application.Common.Batch.Rules; - -internal sealed class MarkMissingByIdBatchRule( - Func idSelector, - IReadOnlySet foundIds, - string notFoundMessageTemplate -) : IBatchRule -{ - public Task ApplyAsync(BatchFailureContext context, CancellationToken ct) - { - for (var 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/absolute/src/APITemplate.Application/Common/Context/IActorProvider.cs b/absolute/src/APITemplate.Application/Common/Context/IActorProvider.cs deleted file mode 100644 index da626e65..00000000 --- a/absolute/src/APITemplate.Application/Common/Context/IActorProvider.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace APITemplate.Application.Common.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/absolute/src/APITemplate.Application/Common/Context/ITenantProvider.cs b/absolute/src/APITemplate.Application/Common/Context/ITenantProvider.cs deleted file mode 100644 index 60378105..00000000 --- a/absolute/src/APITemplate.Application/Common/Context/ITenantProvider.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace APITemplate.Application.Common.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/absolute/src/APITemplate.Application/Common/Contracts/IDateRangeFilter.cs b/absolute/src/APITemplate.Application/Common/Contracts/IDateRangeFilter.cs deleted file mode 100644 index f4ed4729..00000000 --- a/absolute/src/APITemplate.Application/Common/Contracts/IDateRangeFilter.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace APITemplate.Application.Common.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/absolute/src/APITemplate.Application/Common/Contracts/IFileStorageService.cs b/absolute/src/APITemplate.Application/Common/Contracts/IFileStorageService.cs deleted file mode 100644 index e3606810..00000000 --- a/absolute/src/APITemplate.Application/Common/Contracts/IFileStorageService.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Application/Common/Contracts/IIdempotencyStore.cs b/absolute/src/APITemplate.Application/Common/Contracts/IIdempotencyStore.cs deleted file mode 100644 index 3db01107..00000000 --- a/absolute/src/APITemplate.Application/Common/Contracts/IIdempotencyStore.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace APITemplate.Application.Common.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/absolute/src/APITemplate.Application/Common/Contracts/IProductRequest.cs b/absolute/src/APITemplate.Application/Common/Contracts/IProductRequest.cs deleted file mode 100644 index 2eb35274..00000000 --- a/absolute/src/APITemplate.Application/Common/Contracts/IProductRequest.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace APITemplate.Application.Common.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/absolute/src/APITemplate.Application/Common/Contracts/ISortableFilter.cs b/absolute/src/APITemplate.Application/Common/Contracts/ISortableFilter.cs deleted file mode 100644 index b32b58cd..00000000 --- a/absolute/src/APITemplate.Application/Common/Contracts/ISortableFilter.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace APITemplate.Application.Common.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/absolute/src/APITemplate.Application/Common/Contracts/IWebhookEventHandler.cs b/absolute/src/APITemplate.Application/Common/Contracts/IWebhookEventHandler.cs deleted file mode 100644 index 3038a937..00000000 --- a/absolute/src/APITemplate.Application/Common/Contracts/IWebhookEventHandler.cs +++ /dev/null @@ -1,16 +0,0 @@ -using APITemplate.Application.Features.Examples.DTOs; - -namespace APITemplate.Application.Common.Contracts; - -/// -/// Strategy contract for processing a specific inbound webhook event type. -/// Implementations are discovered by type and selected at runtime based on the they declare. -/// -public interface IWebhookEventHandler -{ - /// Gets the event-type string this handler is responsible for (e.g. "order.created"). - string EventType { get; } - - /// Processes the inbound for this event type. - Task HandleAsync(WebhookPayload payload, CancellationToken ct = default); -} diff --git a/absolute/src/APITemplate.Application/Common/Contracts/IWebhookPayloadSigner.cs b/absolute/src/APITemplate.Application/Common/Contracts/IWebhookPayloadSigner.cs deleted file mode 100644 index acd3d9b7..00000000 --- a/absolute/src/APITemplate.Application/Common/Contracts/IWebhookPayloadSigner.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace APITemplate.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 - /// and returns them as a . - /// - WebhookSignatureResult Sign(string payload); -} - -/// -/// 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/absolute/src/APITemplate.Application/Common/Contracts/IWebhookPayloadValidator.cs b/absolute/src/APITemplate.Application/Common/Contracts/IWebhookPayloadValidator.cs deleted file mode 100644 index 747c97bf..00000000 --- a/absolute/src/APITemplate.Application/Common/Contracts/IWebhookPayloadValidator.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace APITemplate.Application.Common.Contracts; - -/// -/// Application-layer abstraction for verifying the authenticity of inbound webhook payloads -/// by validating their HMAC signature against the shared secret. -/// -public interface IWebhookPayloadValidator -{ - /// - /// Returns true when the computed HMAC of and - /// matches ; false otherwise. - /// - bool IsValid(string payload, string signature, string timestamp); -} diff --git a/absolute/src/APITemplate.Application/Common/DTOs/BatchDeleteRequest.cs b/absolute/src/APITemplate.Application/Common/DTOs/BatchDeleteRequest.cs deleted file mode 100644 index 0433c5c4..00000000 --- a/absolute/src/APITemplate.Application/Common/DTOs/BatchDeleteRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace APITemplate.Application.Common.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/absolute/src/APITemplate.Application/Common/DTOs/BatchResponse.cs b/absolute/src/APITemplate.Application/Common/DTOs/BatchResponse.cs deleted file mode 100644 index ac3973bc..00000000 --- a/absolute/src/APITemplate.Application/Common/DTOs/BatchResponse.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace APITemplate.Application.Common.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/absolute/src/APITemplate.Application/Common/DTOs/IHasFacets.cs b/absolute/src/APITemplate.Application/Common/DTOs/IHasFacets.cs deleted file mode 100644 index e00b9d9e..00000000 --- a/absolute/src/APITemplate.Application/Common/DTOs/IHasFacets.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace APITemplate.Application.Common.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/absolute/src/APITemplate.Application/Common/DTOs/IPagedItems.cs b/absolute/src/APITemplate.Application/Common/DTOs/IPagedItems.cs deleted file mode 100644 index da3b3742..00000000 --- a/absolute/src/APITemplate.Application/Common/DTOs/IPagedItems.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace APITemplate.Application.Common.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/absolute/src/APITemplate.Application/Common/DTOs/PaginationFilter.cs b/absolute/src/APITemplate.Application/Common/DTOs/PaginationFilter.cs deleted file mode 100644 index 543e573a..00000000 --- a/absolute/src/APITemplate.Application/Common/DTOs/PaginationFilter.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace APITemplate.Application.Common.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/absolute/src/APITemplate.Application/Common/Email/EmailMessage.cs b/absolute/src/APITemplate.Application/Common/Email/EmailMessage.cs deleted file mode 100644 index ef5eeeae..00000000 --- a/absolute/src/APITemplate.Application/Common/Email/EmailMessage.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace APITemplate.Application.Common.Email; - -/// -/// 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/absolute/src/APITemplate.Application/Common/Email/EmailTemplateNames.cs b/absolute/src/APITemplate.Application/Common/Email/EmailTemplateNames.cs deleted file mode 100644 index baffb801..00000000 --- a/absolute/src/APITemplate.Application/Common/Email/EmailTemplateNames.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace APITemplate.Application.Common.Email; - -/// -/// 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/absolute/src/APITemplate.Application/Common/Email/IEmailQueue.cs b/absolute/src/APITemplate.Application/Common/Email/IEmailQueue.cs deleted file mode 100644 index 3dc7c817..00000000 --- a/absolute/src/APITemplate.Application/Common/Email/IEmailQueue.cs +++ /dev/null @@ -1,13 +0,0 @@ -using APITemplate.Application.Common.BackgroundJobs; - -namespace APITemplate.Application.Common.Email; - -/// -/// 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/absolute/src/APITemplate.Application/Common/Email/IEmailSender.cs b/absolute/src/APITemplate.Application/Common/Email/IEmailSender.cs deleted file mode 100644 index a3ad8adf..00000000 --- a/absolute/src/APITemplate.Application/Common/Email/IEmailSender.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace APITemplate.Application.Common.Email; - -/// -/// 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/absolute/src/APITemplate.Application/Common/Email/IEmailTemplateRenderer.cs b/absolute/src/APITemplate.Application/Common/Email/IEmailTemplateRenderer.cs deleted file mode 100644 index da84449b..00000000 --- a/absolute/src/APITemplate.Application/Common/Email/IEmailTemplateRenderer.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace APITemplate.Application.Common.Email; - -/// -/// 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/absolute/src/APITemplate.Application/Common/Email/IFailedEmailStore.cs b/absolute/src/APITemplate.Application/Common/Email/IFailedEmailStore.cs deleted file mode 100644 index 1bb53967..00000000 --- a/absolute/src/APITemplate.Application/Common/Email/IFailedEmailStore.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace APITemplate.Application.Common.Email; - -/// -/// 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/absolute/src/APITemplate.Application/Common/Email/ISecureTokenGenerator.cs b/absolute/src/APITemplate.Application/Common/Email/ISecureTokenGenerator.cs deleted file mode 100644 index db68dcaf..00000000 --- a/absolute/src/APITemplate.Application/Common/Email/ISecureTokenGenerator.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace APITemplate.Application.Common.Email; - -/// -/// 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/absolute/src/APITemplate.Application/Common/Errors/DomainErrors.cs b/absolute/src/APITemplate.Application/Common/Errors/DomainErrors.cs deleted file mode 100644 index 7a7edbce..00000000 --- a/absolute/src/APITemplate.Application/Common/Errors/DomainErrors.cs +++ /dev/null @@ -1,191 +0,0 @@ -using ErrorOr; - -namespace APITemplate.Application.Common.Errors; - -/// -/// Factory methods producing instances that mirror the -/// codes. Each method sets the appropriate -/// so the presentation layer can map them to HTTP status codes without domain knowledge. -/// -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 class Auth - { - public static Error ForbiddenOwnReviewsOnly() => - Error.Forbidden( - code: ErrorCatalog.Auth.Forbidden, - description: ErrorCatalog.Auth.ForbiddenOwnReviewsOnly - ); - } - - public static class Products - { - public static Error NotFound(Guid id) => - Error.NotFound( - code: ErrorCatalog.Products.NotFound, - description: string.Format(ErrorCatalog.Products.NotFoundMessage, id) - ); - } - - public static class ProductData - { - public static Error NotFound(Guid id) => - Error.NotFound( - code: ErrorCatalog.ProductData.NotFound, - description: string.Format(ErrorCatalog.ProductData.NotFoundMessage, id) - ); - } - - public static class Categories - { - public static Error NotFound(Guid id) => - Error.NotFound( - code: ErrorCatalog.Categories.NotFound, - description: string.Format(ErrorCatalog.Categories.NotFoundMessage, id) - ); - } - - public static class Reviews - { - public static Error NotFound(Guid id) => - Error.NotFound( - code: ErrorCatalog.Reviews.ReviewNotFound, - description: $"Review with id '{id}' not found." - ); - - public static Error ProductNotFoundForReview(Guid productId) => - Error.NotFound( - code: ErrorCatalog.Reviews.ProductNotFoundForReview, - description: $"Product with id '{productId}' not found." - ); - } - - public static class Users - { - public static Error NotFound(Guid id) => - Error.NotFound( - code: ErrorCatalog.Users.NotFound, - description: $"User with id '{id}' not found." - ); - - public static Error EmailAlreadyExists(string email) => - Error.Conflict( - code: ErrorCatalog.Users.EmailAlreadyExists, - description: $"Email '{email}' is already in use." - ); - - public static Error UsernameAlreadyExists(string username) => - Error.Conflict( - code: ErrorCatalog.Users.UsernameAlreadyExists, - description: $"Username '{username}' is already in use." - ); - } - - public static class Tenants - { - public static Error NotFound(Guid id) => - Error.NotFound( - code: ErrorCatalog.Tenants.NotFound, - description: $"Tenant with id '{id}' not found." - ); - - public static Error CodeAlreadyExists(string code) => - Error.Conflict( - code: ErrorCatalog.Tenants.CodeAlreadyExists, - description: string.Format(ErrorCatalog.Tenants.CodeAlreadyExistsMessage, code) - ); - } - - public static class Invitations - { - public static Error NotFound(Guid id) => - Error.NotFound( - code: ErrorCatalog.Invitations.NotFound, - description: $"Invitation with id '{id}' not found." - ); - - public static Error AlreadyPending(string email) => - Error.Conflict( - code: ErrorCatalog.Invitations.AlreadyPending, - description: $"A pending invitation for '{email}' already exists." - ); - - public static Error Expired() => - Error.Conflict( - code: ErrorCatalog.Invitations.Expired, - description: ErrorCatalog.Invitations.ExpiredMessage - ); - - public static Error ExpiredCreateNew() => - Error.Conflict( - code: ErrorCatalog.Invitations.Expired, - description: ErrorCatalog.Invitations.ExpiredCreateNewMessage - ); - - public static Error AlreadyAccepted() => - Error.Conflict( - code: ErrorCatalog.Invitations.AlreadyAccepted, - description: ErrorCatalog.Invitations.AlreadyAcceptedMessage - ); - - public static Error NotPending() => - Error.Conflict( - code: ErrorCatalog.Invitations.NotPending, - description: ErrorCatalog.Invitations.NotPendingMessage - ); - - public static Error NotFoundOrExpired() => - Error.NotFound( - code: ErrorCatalog.Invitations.NotFound, - description: ErrorCatalog.Invitations.NotFoundOrExpiredMessage - ); - } - - public static class Examples - { - public static Error FileNotFound(string fileName) => - Error.NotFound( - code: ErrorCatalog.Examples.FileNotFound, - description: $"File '{fileName}' not found." - ); - - public static Error InvalidFileType(string extension) => - Error.Validation( - code: ErrorCatalog.Examples.InvalidFileType, - description: $"File type '{extension}' is not allowed." - ); - - public static Error FileTooLarge(long maxSize) => - Error.Validation( - code: ErrorCatalog.Examples.FileTooLarge, - description: $"File exceeds maximum size of {maxSize} bytes." - ); - - public static Error InvalidPatchDocument(string message) => - Error.Validation( - code: ErrorCatalog.Examples.InvalidPatchDocument, - description: message - ); - - public static Error WebhookInvalidSignature() => - Error.Unauthorized( - code: ErrorCatalog.Examples.WebhookInvalidSignature, - description: "Invalid webhook signature." - ); - - public static Error WebhookMissingHeaders() => - Error.Unauthorized( - code: ErrorCatalog.Examples.WebhookMissingHeaders, - description: "Required webhook headers are missing." - ); - } -} diff --git a/absolute/src/APITemplate.Application/Common/Errors/ErrorCatalog.cs b/absolute/src/APITemplate.Application/Common/Errors/ErrorCatalog.cs deleted file mode 100644 index db389e53..00000000 --- a/absolute/src/APITemplate.Application/Common/Errors/ErrorCatalog.cs +++ /dev/null @@ -1,108 +0,0 @@ -namespace APITemplate.Application.Common.Errors; - -/// -/// Central catalog of structured error codes used throughout the Application and Presentation layers. -/// Organising codes here prevents duplication and makes it easy to cross-reference codes in API documentation. -/// -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 ForbiddenOwnReviewsOnly = "You can only delete your own reviews."; - } - - /// Error codes specific to the Products domain. - public static class Products - { - public const string EntityName = "Product"; - public const string NotFound = "PRD-0404"; - public const string NotFoundMessage = "Product '{0}' not found."; - public const string ProductDataNotFound = "PRD-2404"; - public const string AlreadyExistsMessage = "Product '{0}' already exists."; - public const string DuplicateIdMessage = - "Duplicate product ID '{0}' appears multiple times in the request."; - } - - /// Error codes specific to the ProductData domain. - public static class ProductData - { - public const string NotFound = "PDT-0404"; - public const string NotFoundMessage = "Product data not found: {0}"; - public const string InUse = "PDT-0409"; - } - - /// Error codes specific to the Categories domain. - public static class Categories - { - public const string EntityName = "Category"; - public const string NotFound = "CAT-0404"; - public const string NotFoundMessage = "Category '{0}' not found."; - public const string AlreadyExistsMessage = "Category '{0}' already exists."; - public const string DuplicateIdMessage = - "Duplicate category ID '{0}' appears multiple times in the request."; - } - - /// Error codes specific to the Reviews domain. - public static class Reviews - { - public const string ProductNotFoundForReview = "REV-2101"; - public const string ReviewNotFound = "REV-0404"; - } - - /// 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."; - } - - /// Error codes used by the example/showcase feature endpoints. - public static class Examples - { - public const string FileNotFound = "EXA-0404-FILE"; - public const string InvalidFileType = "EXA-0400-FILE"; - public const string FileTooLarge = "EXA-0400-SIZE"; - public const string InvalidPatchDocument = "EXA-0400-PATCH"; - public const string WebhookInvalidSignature = "EXA-0401-WEBHOOK"; - public const string WebhookMissingHeaders = "EXA-0401-WEBHOOK-HDR"; - } -} diff --git a/absolute/src/APITemplate.Application/Common/Events/CacheEvents.cs b/absolute/src/APITemplate.Application/Common/Events/CacheEvents.cs deleted file mode 100644 index e8279424..00000000 --- a/absolute/src/APITemplate.Application/Common/Events/CacheEvents.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace APITemplate.Application.Common.Events; - -/// -/// Cache invalidation event. Use with a constant -/// to signal that a specific cache region must be evicted. -/// -public sealed record CacheInvalidationNotification(string CacheTag); diff --git a/absolute/src/APITemplate.Application/Common/Events/CacheTags.cs b/absolute/src/APITemplate.Application/Common/Events/CacheTags.cs deleted file mode 100644 index f4abe312..00000000 --- a/absolute/src/APITemplate.Application/Common/Events/CacheTags.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace APITemplate.Application.Common.Events; - -/// -/// Centralizes cache tag and policy name constants used across the application and API layers. -/// -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"; -} diff --git a/absolute/src/APITemplate.Application/Common/Events/EmailEvents.cs b/absolute/src/APITemplate.Application/Common/Events/EmailEvents.cs deleted file mode 100644 index 83d7c0eb..00000000 --- a/absolute/src/APITemplate.Application/Common/Events/EmailEvents.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace APITemplate.Application.Common.Events; - -/// -/// Published after a new user successfully registers, triggering the welcome email notification. -/// -public sealed record UserRegisteredNotification(Guid UserId, string Email, string Username); - -/// -/// Published after a tenant invitation is created, triggering the invitation email with the acceptance link. -/// -public sealed record TenantInvitationCreatedNotification( - Guid InvitationId, - string Email, - string TenantName, - string Token -); - -/// -/// Published after a user's role is changed, triggering the role-change notification email. -/// -public sealed record UserRoleChangedNotification( - Guid UserId, - string Email, - string Username, - string OldRole, - string NewRole -); diff --git a/absolute/src/APITemplate.Application/Common/Events/MessageBusExtensions.cs b/absolute/src/APITemplate.Application/Common/Events/MessageBusExtensions.cs deleted file mode 100644 index a1f77b5a..00000000 --- a/absolute/src/APITemplate.Application/Common/Events/MessageBusExtensions.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.Extensions.Logging; -using Wolverine; - -namespace APITemplate.Application.Common.Events; - -/// -/// Extension methods for providing safe (fire-and-forget) publishing. -/// -public static class MessageBusExtensions -{ - /// - /// Publishes a message, swallowing any non-cancellation exception and logging it as a warning. - /// Use for notification events whose failure must not break the main command flow. - /// - public static async Task PublishSafeAsync( - this IMessageBus bus, - TEvent @event, - ILogger logger - ) - { - try - { - await bus.PublishAsync(@event); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - logger.LogWarning(ex, "Failed to publish {EventType}.", typeof(TEvent).Name); - } - } -} diff --git a/absolute/src/APITemplate.Application/Common/Events/SoftDeleteEvents.cs b/absolute/src/APITemplate.Application/Common/Events/SoftDeleteEvents.cs deleted file mode 100644 index a1607fd5..00000000 --- a/absolute/src/APITemplate.Application/Common/Events/SoftDeleteEvents.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace APITemplate.Application.Common.Events; - -/// -/// Published after a tenant is soft-deleted, allowing downstream handlers to trigger -/// cascading cleanup or audit logging without coupling the delete command to those concerns. -/// -public sealed record TenantSoftDeletedNotification( - Guid TenantId, - Guid ActorId, - DateTime DeletedAtUtc -); diff --git a/absolute/src/APITemplate.Application/Common/Events/TenantInvitationEmailHandler.cs b/absolute/src/APITemplate.Application/Common/Events/TenantInvitationEmailHandler.cs deleted file mode 100644 index 72fef209..00000000 --- a/absolute/src/APITemplate.Application/Common/Events/TenantInvitationEmailHandler.cs +++ /dev/null @@ -1,40 +0,0 @@ -using APITemplate.Application.Common.Email; -using APITemplate.Application.Common.Options; -using Microsoft.Extensions.Options; - -namespace APITemplate.Application.Common.Events; - -public sealed class TenantInvitationEmailHandler -{ - public static async Task HandleAsync( - TenantInvitationCreatedNotification @event, - IEmailTemplateRenderer templateRenderer, - IEmailQueue emailQueue, - IOptions options, - CancellationToken ct - ) - { - var html = await templateRenderer.RenderAsync( - EmailTemplateNames.TenantInvitation, - new - { - @event.Email, - @event.TenantName, - InvitationUrl = $"{options.Value.BaseUrl}/invitations/accept?token={@event.Token}", - ExpiryHours = options.Value.InvitationTokenExpiryHours, - }, - ct - ); - - await emailQueue.EnqueueAsync( - new EmailMessage( - @event.Email, - $"You've been invited to {@event.TenantName}", - html, - EmailTemplateNames.TenantInvitation, - Retryable: true - ), - ct - ); - } -} diff --git a/absolute/src/APITemplate.Application/Common/Events/UserRegisteredEmailHandler.cs b/absolute/src/APITemplate.Application/Common/Events/UserRegisteredEmailHandler.cs deleted file mode 100644 index 33ad0a02..00000000 --- a/absolute/src/APITemplate.Application/Common/Events/UserRegisteredEmailHandler.cs +++ /dev/null @@ -1,38 +0,0 @@ -using APITemplate.Application.Common.Email; -using APITemplate.Application.Common.Options; -using Microsoft.Extensions.Options; - -namespace APITemplate.Application.Common.Events; - -public sealed class UserRegisteredEmailHandler -{ - public static async Task HandleAsync( - UserRegisteredNotification @event, - IEmailTemplateRenderer templateRenderer, - IEmailQueue emailQueue, - IOptions options, - CancellationToken ct - ) - { - var html = await templateRenderer.RenderAsync( - EmailTemplateNames.UserRegistration, - new - { - @event.Username, - @event.Email, - LoginUrl = $"{options.Value.BaseUrl}/login", - }, - ct - ); - - await emailQueue.EnqueueAsync( - new EmailMessage( - @event.Email, - "Welcome to the platform!", - html, - EmailTemplateNames.UserRegistration - ), - ct - ); - } -} diff --git a/absolute/src/APITemplate.Application/Common/Events/UserRoleChangedEmailHandler.cs b/absolute/src/APITemplate.Application/Common/Events/UserRoleChangedEmailHandler.cs deleted file mode 100644 index 83eb01e8..00000000 --- a/absolute/src/APITemplate.Application/Common/Events/UserRoleChangedEmailHandler.cs +++ /dev/null @@ -1,35 +0,0 @@ -using APITemplate.Application.Common.Email; - -namespace APITemplate.Application.Common.Events; - -public sealed class UserRoleChangedEmailHandler -{ - public static async Task HandleAsync( - UserRoleChangedNotification @event, - IEmailTemplateRenderer templateRenderer, - IEmailQueue emailQueue, - CancellationToken ct - ) - { - var html = await templateRenderer.RenderAsync( - EmailTemplateNames.UserRoleChanged, - new - { - @event.Username, - @event.OldRole, - @event.NewRole, - }, - ct - ); - - await emailQueue.EnqueueAsync( - new EmailMessage( - @event.Email, - "Your role has been updated", - html, - EmailTemplateNames.UserRoleChanged - ), - ct - ); - } -} diff --git a/absolute/src/APITemplate.Application/Common/Extensions/RepositoryExtensions.cs b/absolute/src/APITemplate.Application/Common/Extensions/RepositoryExtensions.cs deleted file mode 100644 index 6b55cb68..00000000 --- a/absolute/src/APITemplate.Application/Common/Extensions/RepositoryExtensions.cs +++ /dev/null @@ -1,37 +0,0 @@ -using APITemplate.Domain.Exceptions; -using Ardalis.Specification; -using ErrorOr; - -namespace APITemplate.Application.Common.Extensions; - -public static class RepositoryExtensions -{ - [Obsolete("Use GetByIdOrError with ErrorOr pattern instead.")] - public static async Task GetByIdOrThrowAsync( - this IRepositoryBase repository, - Guid id, - string errorCode, - CancellationToken ct = default - ) - where T : class - { - return await repository.GetByIdAsync(id, ct) - ?? throw new NotFoundException(typeof(T).Name, id, errorCode); - } - - /// - /// 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 - { - var entity = await repository.GetByIdAsync(id, ct); - return entity is null ? notFoundError : entity; - } -} diff --git a/absolute/src/APITemplate.Application/Common/Http/RateLimitPolicies.cs b/absolute/src/APITemplate.Application/Common/Http/RateLimitPolicies.cs deleted file mode 100644 index 282ec6c1..00000000 --- a/absolute/src/APITemplate.Application/Common/Http/RateLimitPolicies.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace APITemplate.Application.Common.Http; - -/// -/// Centralizes rate-limit policy name constants used across the application and API layers. -/// -public static class RateLimitPolicies -{ - public const string Fixed = "fixed"; -} diff --git a/absolute/src/APITemplate.Application/Common/Http/RequestContextConstants.cs b/absolute/src/APITemplate.Application/Common/Http/RequestContextConstants.cs deleted file mode 100644 index d39f9cf9..00000000 --- a/absolute/src/APITemplate.Application/Common/Http/RequestContextConstants.cs +++ /dev/null @@ -1,46 +0,0 @@ -namespace APITemplate.Application.Common.Http; - -/// -/// Constants for request context headers and log enrichment properties. -/// -public static class RequestContextConstants -{ - public static class Headers - { - /// - /// Header name used for correlation/trace IDs supplied by the caller. - /// - public const string CorrelationId = "X-Correlation-Id"; - - /// - /// Header name used for the distributed trace ID. - /// - public const string TraceId = "X-Trace-Id"; - - /// - /// Header name used for the request elapsed time in milliseconds. - /// - public const string ElapsedMs = "X-Elapsed-Ms"; - } - - public static class ContextKeys - { - /// - /// Key under which the resolved correlation ID is stored in . - /// - public const string CorrelationId = "CorrelationId"; - } - - public static class LogProperties - { - /// - /// Serilog property name for the correlation ID. - /// - public const string CorrelationId = "CorrelationId"; - - /// - /// Serilog property name for the tenant ID. - /// - public const string TenantId = "TenantId"; - } -} diff --git a/absolute/src/APITemplate.Application/Common/Middleware/ErrorOrValidationMiddleware.cs b/absolute/src/APITemplate.Application/Common/Middleware/ErrorOrValidationMiddleware.cs deleted file mode 100644 index c7631aa5..00000000 --- a/absolute/src/APITemplate.Application/Common/Middleware/ErrorOrValidationMiddleware.cs +++ /dev/null @@ -1,50 +0,0 @@ -using APITemplate.Application.Common.Errors; -using ErrorOr; -using FluentValidation; -using Wolverine; - -namespace APITemplate.Application.Common.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!); - - var validationResult = await validator.ValidateAsync(message, ct); - - if (validationResult.IsValid) - return (HandlerContinuation.Continue, default!); - - var errors = validationResult - .Errors.Select(e => - { - var metadata = new Dictionary { ["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/absolute/src/APITemplate.Application/Common/Options/AppOptions.cs b/absolute/src/APITemplate.Application/Common/Options/AppOptions.cs deleted file mode 100644 index a8f65c3e..00000000 --- a/absolute/src/APITemplate.Application/Common/Options/AppOptions.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace APITemplate.Application.Common.Options; - -/// -/// Top-level application options that apply globally across the service. -/// -public sealed class AppOptions -{ - public string ServiceName { get; init; } = "APITemplate"; -} diff --git a/absolute/src/APITemplate.Application/Common/Options/BackgroundJobs/BackgroundJobsOptions.cs b/absolute/src/APITemplate.Application/Common/Options/BackgroundJobs/BackgroundJobsOptions.cs deleted file mode 100644 index 5a1caa54..00000000 --- a/absolute/src/APITemplate.Application/Common/Options/BackgroundJobs/BackgroundJobsOptions.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace APITemplate.Application.Common.Options.BackgroundJobs; - -/// -/// Aggregates per-job configuration options for all registered background jobs in the application. -/// -public sealed class BackgroundJobsOptions -{ - public TickerQSchedulerOptions TickerQ { get; set; } = new(); - public ExternalSyncJobOptions ExternalSync { get; set; } = new(); - public CleanupJobOptions Cleanup { get; set; } = new(); - public ReindexJobOptions Reindex { get; set; } = new(); - public EmailRetryJobOptions EmailRetry { get; set; } = new(); -} diff --git a/absolute/src/APITemplate.Application/Common/Options/BackgroundJobs/CleanupJobOptions.cs b/absolute/src/APITemplate.Application/Common/Options/BackgroundJobs/CleanupJobOptions.cs deleted file mode 100644 index 44026af8..00000000 --- a/absolute/src/APITemplate.Application/Common/Options/BackgroundJobs/CleanupJobOptions.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace APITemplate.Application.Common.Options.BackgroundJobs; - -/// -/// Configuration for the periodic cleanup job that purges expired invitations, soft-deleted records, -/// and orphaned product data according to the configured retention windows. -/// -public sealed class CleanupJobOptions -{ - public bool Enabled { get; set; } - public string Cron { get; set; } = "0 * * * *"; - public int ExpiredInvitationRetentionHours { get; set; } = 168; - public int SoftDeleteRetentionDays { get; set; } = 30; - public int OrphanedProductDataRetentionDays { get; set; } = 7; - public int BatchSize { get; set; } = 100; -} diff --git a/absolute/src/APITemplate.Application/Common/Options/BackgroundJobs/EmailRetryJobOptions.cs b/absolute/src/APITemplate.Application/Common/Options/BackgroundJobs/EmailRetryJobOptions.cs deleted file mode 100644 index 685fb299..00000000 --- a/absolute/src/APITemplate.Application/Common/Options/BackgroundJobs/EmailRetryJobOptions.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace APITemplate.Application.Common.Options.BackgroundJobs; - -/// -/// Configuration for the background job that retries failed outbound email deliveries -/// and moves messages to the dead-letter queue after the maximum retry threshold is exceeded. -/// -public sealed class EmailRetryJobOptions -{ - public bool Enabled { get; set; } - public string Cron { get; set; } = "*/15 * * * *"; - public int MaxRetryAttempts { get; set; } = 5; - public int BatchSize { get; set; } = 50; - public int DeadLetterAfterHours { get; set; } = 48; - public int ClaimLeaseMinutes { get; set; } = 10; -} diff --git a/absolute/src/APITemplate.Application/Common/Options/BackgroundJobs/ExternalSyncJobOptions.cs b/absolute/src/APITemplate.Application/Common/Options/BackgroundJobs/ExternalSyncJobOptions.cs deleted file mode 100644 index 574ed430..00000000 --- a/absolute/src/APITemplate.Application/Common/Options/BackgroundJobs/ExternalSyncJobOptions.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace APITemplate.Application.Common.Options.BackgroundJobs; - -/// -/// Configuration for the scheduled job that synchronises data from external third-party systems. -/// -public sealed class ExternalSyncJobOptions -{ - public bool Enabled { get; set; } - public string Cron { get; set; } = "0 */12 * * *"; -} diff --git a/absolute/src/APITemplate.Application/Common/Options/BackgroundJobs/ReindexJobOptions.cs b/absolute/src/APITemplate.Application/Common/Options/BackgroundJobs/ReindexJobOptions.cs deleted file mode 100644 index 16602c27..00000000 --- a/absolute/src/APITemplate.Application/Common/Options/BackgroundJobs/ReindexJobOptions.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace APITemplate.Application.Common.Options.BackgroundJobs; - -/// -/// Configuration for the scheduled job that rebuilds search indexes on a periodic basis. -/// -public sealed class ReindexJobOptions -{ - public bool Enabled { get; set; } - public string Cron { get; set; } = "0 */6 * * *"; -} diff --git a/absolute/src/APITemplate.Application/Common/Options/BackgroundJobs/TickerQSchedulerOptions.cs b/absolute/src/APITemplate.Application/Common/Options/BackgroundJobs/TickerQSchedulerOptions.cs deleted file mode 100644 index 6b06c530..00000000 --- a/absolute/src/APITemplate.Application/Common/Options/BackgroundJobs/TickerQSchedulerOptions.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace APITemplate.Application.Common.Options.BackgroundJobs; - -/// -/// 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; set; } - public bool FailClosed { get; set; } = true; - public string InstanceNamePrefix { get; set; } = "APITemplate"; - public string CoordinationConnection { get; set; } = DefaultCoordinationConnection; -} diff --git a/absolute/src/APITemplate.Application/Common/Options/BootstrapTenantOptions.cs b/absolute/src/APITemplate.Application/Common/Options/BootstrapTenantOptions.cs deleted file mode 100644 index db896471..00000000 --- a/absolute/src/APITemplate.Application/Common/Options/BootstrapTenantOptions.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace APITemplate.Application.Common.Options; - -/// -/// Configuration for the default tenant that is seeded when the application bootstraps for the first time. -/// -public sealed class BootstrapTenantOptions -{ - [Required] - public string Code { get; init; } = "default"; - - [Required] - public string Name { get; init; } = "Default Tenant"; -} diff --git a/absolute/src/APITemplate.Application/Common/Options/Infrastructure/DragonflyOptions.cs b/absolute/src/APITemplate.Application/Common/Options/Infrastructure/DragonflyOptions.cs deleted file mode 100644 index 3245c82e..00000000 --- a/absolute/src/APITemplate.Application/Common/Options/Infrastructure/DragonflyOptions.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace APITemplate.Application.Common.Options.Infrastructure; - -/// -/// Configuration for the Dragonfly (Redis-compatible) connection used for distributed caching -/// and background-job coordination. -/// -public sealed class DragonflyOptions -{ - public const int DefaultConnectTimeoutMs = 5000; - public const int DefaultSyncTimeoutMs = 3000; - - [Required] - public string ConnectionString { get; init; } = string.Empty; - - [Range(1, int.MaxValue)] - public int ConnectTimeoutMs { get; init; } = DefaultConnectTimeoutMs; - - [Range(1, int.MaxValue)] - public int SyncTimeoutMs { get; init; } = DefaultSyncTimeoutMs; -} diff --git a/absolute/src/APITemplate.Application/Common/Options/Infrastructure/EmailOptions.cs b/absolute/src/APITemplate.Application/Common/Options/Infrastructure/EmailOptions.cs deleted file mode 100644 index aa9322f1..00000000 --- a/absolute/src/APITemplate.Application/Common/Options/Infrastructure/EmailOptions.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace APITemplate.Application.Common.Options.Infrastructure; - -/// -/// Configuration for the outbound SMTP email service, including connection settings, sender identity, -/// and retry behaviour. -/// -public sealed class EmailOptions -{ - public string SmtpHost { get; set; } = "localhost"; - public int SmtpPort { get; set; } = 587; - public bool UseSsl { get; set; } = true; - public string SenderEmail { get; set; } = string.Empty; - public string SenderName { get; set; } = string.Empty; - public string? Username { get; set; } - public string? Password { get; set; } - public int InvitationTokenExpiryHours { get; set; } = 72; - public string BaseUrl { get; set; } = string.Empty; - public int MaxRetryAttempts { get; set; } = 3; - public int RetryBaseDelaySeconds { get; set; } = 2; -} diff --git a/absolute/src/APITemplate.Application/Common/Options/Infrastructure/FileStorageOptions.cs b/absolute/src/APITemplate.Application/Common/Options/Infrastructure/FileStorageOptions.cs deleted file mode 100644 index f762caec..00000000 --- a/absolute/src/APITemplate.Application/Common/Options/Infrastructure/FileStorageOptions.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace APITemplate.Application.Common.Options.Infrastructure; - -/// -/// Configuration for the local file-storage provider, including the base directory, upload size limit, -/// and allowed file extensions. -/// -public sealed class FileStorageOptions -{ - public string BasePath { get; set; } = Path.Combine(Path.GetTempPath(), "api-template-files"); - public long MaxFileSizeBytes { get; set; } = 10 * 1024 * 1024; // 10 MB - public string[] AllowedExtensions { get; set; } = - [".jpg", ".png", ".gif", ".pdf", ".csv", ".txt"]; -} diff --git a/absolute/src/APITemplate.Application/Common/Options/Infrastructure/ObservabilityOptions.cs b/absolute/src/APITemplate.Application/Common/Options/Infrastructure/ObservabilityOptions.cs deleted file mode 100644 index 89639bc3..00000000 --- a/absolute/src/APITemplate.Application/Common/Options/Infrastructure/ObservabilityOptions.cs +++ /dev/null @@ -1,50 +0,0 @@ -namespace APITemplate.Application.Common.Options.Infrastructure; - -/// -/// Root configuration object for observability (tracing, metrics, and logging) exporters and endpoints. -/// -public sealed class ObservabilityOptions -{ - public OtlpEndpointOptions Otlp { get; init; } = new(); - - public AspireEndpointOptions Aspire { get; init; } = new(); - - public ObservabilityExportersOptions Exporters { get; init; } = new(); -} - -/// -/// Endpoint configuration for the OpenTelemetry Protocol (OTLP) exporter. -/// -public sealed class OtlpEndpointOptions -{ - public string Endpoint { get; init; } = string.Empty; -} - -/// -/// Endpoint configuration for the .NET Aspire dashboard exporter. -/// -public sealed class AspireEndpointOptions -{ - public string Endpoint { get; init; } = string.Empty; -} - -/// -/// Groups the enabled/disabled state for each supported observability exporter. -/// -public sealed class ObservabilityExportersOptions -{ - public ObservabilityExporterToggleOptions Aspire { get; init; } = new(); - - public ObservabilityExporterToggleOptions Otlp { get; init; } = new(); - - public ObservabilityExporterToggleOptions Console { get; init; } = new(); -} - -/// -/// A simple toggle that enables or disables an individual observability exporter. -/// When , the exporter state falls back to the runtime default. -/// -public sealed class ObservabilityExporterToggleOptions -{ - public bool? Enabled { get; init; } -} diff --git a/absolute/src/APITemplate.Application/Common/Options/Infrastructure/TransactionDefaultsOptions.cs b/absolute/src/APITemplate.Application/Common/Options/Infrastructure/TransactionDefaultsOptions.cs deleted file mode 100644 index f1fafb18..00000000 --- a/absolute/src/APITemplate.Application/Common/Options/Infrastructure/TransactionDefaultsOptions.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Data; -using APITemplate.Domain.Options; - -namespace APITemplate.Application.Common.Options.Infrastructure; - -/// -/// 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 IsolationLevel IsolationLevel { get; set; } = IsolationLevel.ReadCommitted; - public int TimeoutSeconds { get; set; } = 30; - public bool RetryEnabled { get; set; } = true; - public int RetryCount { get; set; } = 3; - 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. - /// - /// - /// This method is intended to be used by infrastructure and other consumers that require - /// consistent transaction configuration based on application-level defaults plus optional, - /// context-specific overrides. - /// - public TransactionOptions Resolve(TransactionOptions? overrides) - { - var resolved = new TransactionOptions - { - 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/absolute/src/APITemplate.Application/Common/Options/Infrastructure/WebhookOptions.cs b/absolute/src/APITemplate.Application/Common/Options/Infrastructure/WebhookOptions.cs deleted file mode 100644 index 3cb585fc..00000000 --- a/absolute/src/APITemplate.Application/Common/Options/Infrastructure/WebhookOptions.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace APITemplate.Application.Common.Options.Infrastructure; - -/// -/// Configuration for incoming webhook verification, including the shared HMAC secret -/// and the tolerance window used to reject replayed requests. -/// -public sealed class WebhookOptions -{ - [Required] - [MinLength(16, ErrorMessage = "Webhook secret must be at least 16 characters.")] - public string Secret { get; set; } = string.Empty; - - public int TimestampToleranceSeconds { get; set; } = 300; // 5 minutes -} diff --git a/absolute/src/APITemplate.Application/Common/Options/Security/BffOptions.cs b/absolute/src/APITemplate.Application/Common/Options/Security/BffOptions.cs deleted file mode 100644 index 4ec7a02e..00000000 --- a/absolute/src/APITemplate.Application/Common/Options/Security/BffOptions.cs +++ /dev/null @@ -1,16 +0,0 @@ -using APITemplate.Application.Common.Security; - -namespace APITemplate.Application.Common.Options.Security; - -/// -/// Configuration for the Backend-for-Frontend (BFF) session layer, including cookie settings, -/// requested OIDC scopes, and token refresh thresholds. -/// -public sealed class BffOptions -{ - public string CookieName { get; init; } = ".APITemplate.Auth"; - public string PostLogoutRedirectUri { get; init; } = "/"; - public int SessionTimeoutMinutes { get; init; } = 60; - public string[] Scopes { get; init; } = [.. AuthConstants.Scopes.Default]; - public int TokenRefreshThresholdMinutes { get; init; } = 2; -} diff --git a/absolute/src/APITemplate.Application/Common/Options/Security/CorsOptions.cs b/absolute/src/APITemplate.Application/Common/Options/Security/CorsOptions.cs deleted file mode 100644 index 478ec2f1..00000000 --- a/absolute/src/APITemplate.Application/Common/Options/Security/CorsOptions.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace APITemplate.Application.Common.Options.Security; - -/// -/// Configuration for the CORS policy, listing the origins that are permitted to make cross-origin requests. -/// -public sealed class CorsOptions -{ - public string[] AllowedOrigins { get; init; } = []; -} diff --git a/absolute/src/APITemplate.Application/Common/Options/Security/KeycloakOptions.cs b/absolute/src/APITemplate.Application/Common/Options/Security/KeycloakOptions.cs deleted file mode 100644 index 4cf26673..00000000 --- a/absolute/src/APITemplate.Application/Common/Options/Security/KeycloakOptions.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Microsoft.Extensions.Configuration; - -namespace APITemplate.Application.Common.Options.Security; - -/// -/// Configuration for the Keycloak identity provider, covering realm, server URL, client credentials, -/// and startup readiness-check behaviour. -/// -public sealed class KeycloakOptions -{ - [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; - - [ConfigurationKeyName("credentials")] - public KeycloakCredentialsOptions Credentials { get; init; } = new(); -} - -/// -/// Client-secret credentials used when authenticating against the Keycloak Admin REST API. -/// -public sealed class KeycloakCredentialsOptions -{ - [ConfigurationKeyName("secret")] - public string Secret { get; init; } = string.Empty; -} diff --git a/absolute/src/APITemplate.Application/Common/Options/Security/RateLimitingOptions.cs b/absolute/src/APITemplate.Application/Common/Options/Security/RateLimitingOptions.cs deleted file mode 100644 index 4b52bf5f..00000000 --- a/absolute/src/APITemplate.Application/Common/Options/Security/RateLimitingOptions.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace APITemplate.Application.Common.Options.Security; - -/// -/// Configuration for the sliding-window rate-limiting policy applied to inbound API requests. -/// -public sealed class RateLimitingOptions -{ - [Range(1, int.MaxValue)] - public int PermitLimit { get; set; } = 100; - - [Range(1, int.MaxValue)] - public int WindowMinutes { get; set; } = 1; -} diff --git a/absolute/src/APITemplate.Application/Common/Options/Security/RedactionOptions.cs b/absolute/src/APITemplate.Application/Common/Options/Security/RedactionOptions.cs deleted file mode 100644 index 5f591ad7..00000000 --- a/absolute/src/APITemplate.Application/Common/Options/Security/RedactionOptions.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace APITemplate.Application.Common.Options.Security; - -/// -/// Configuration for the HMAC-based data redaction feature used to pseudonymise sensitive fields. -/// The signing key is sourced from an environment variable whose name is specified here. -/// -public sealed class RedactionOptions -{ - [Required] - public string HmacKeyEnvironmentVariable { get; init; } = "APITEMPLATE_REDACTION_HMAC_KEY"; - - public string HmacKey { get; init; } = string.Empty; - - [Range(1, int.MaxValue)] - public int KeyId { get; init; } = 1001; -} diff --git a/absolute/src/APITemplate.Application/Common/Options/Security/SystemIdentityOptions.cs b/absolute/src/APITemplate.Application/Common/Options/Security/SystemIdentityOptions.cs deleted file mode 100644 index cad9fac7..00000000 --- a/absolute/src/APITemplate.Application/Common/Options/Security/SystemIdentityOptions.cs +++ /dev/null @@ -1,12 +0,0 @@ -using APITemplate.Domain.Entities; - -namespace APITemplate.Application.Common.Options.Security; - -/// -/// 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 Guid DefaultActorId { get; init; } = AuditDefaults.SystemActorId; -} diff --git a/absolute/src/APITemplate.Application/Common/Resilience/ResiliencePipelineKeys.cs b/absolute/src/APITemplate.Application/Common/Resilience/ResiliencePipelineKeys.cs deleted file mode 100644 index 28fdfd86..00000000 --- a/absolute/src/APITemplate.Application/Common/Resilience/ResiliencePipelineKeys.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace APITemplate.Application.Common.Resilience; - -/// -/// String constants that identify the named Polly resilience pipelines registered in the application. -/// Use these keys when resolving a pipeline from ResiliencePipelineProvider. -/// -public static class ResiliencePipelineKeys -{ - public const string MongoProductDataDelete = "mongo-productdata-delete"; - public const string SmtpSend = "smtp-send"; - public const string KeycloakAdmin = "keycloak-admin"; - public const string KeycloakReadiness = "keycloak-readiness"; - public const string OutgoingWebhook = "outgoing-webhook"; -} diff --git a/absolute/src/APITemplate.Application/Common/Search/SearchDefaults.cs b/absolute/src/APITemplate.Application/Common/Search/SearchDefaults.cs deleted file mode 100644 index 66b2168c..00000000 --- a/absolute/src/APITemplate.Application/Common/Search/SearchDefaults.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace APITemplate.Application.Common.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/absolute/src/APITemplate.Application/Common/Security/AuthConstants.cs b/absolute/src/APITemplate.Application/Common/Security/AuthConstants.cs deleted file mode 100644 index 0aa41409..00000000 --- a/absolute/src/APITemplate.Application/Common/Security/AuthConstants.cs +++ /dev/null @@ -1,111 +0,0 @@ -namespace APITemplate.Application.Common.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 = "sub"; - 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 = "tenant_id"; - } - - /// - /// Constants for the custom CSRF header contract used by CsrfValidationMiddleware. - /// - /// - /// SPAs retrieve these values at runtime via GET /api/v1/bff/csrf and must send - /// X-CSRF: 1 on every non-safe (mutating) request authenticated with a session cookie. - /// - 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/absolute/src/APITemplate.Application/Common/Security/IKeycloakAdminService.cs b/absolute/src/APITemplate.Application/Common/Security/IKeycloakAdminService.cs deleted file mode 100644 index f733710d..00000000 --- a/absolute/src/APITemplate.Application/Common/Security/IKeycloakAdminService.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace APITemplate.Application.Common.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/absolute/src/APITemplate.Application/Common/Security/IRolePermissionMap.cs b/absolute/src/APITemplate.Application/Common/Security/IRolePermissionMap.cs deleted file mode 100644 index 2c27cc74..00000000 --- a/absolute/src/APITemplate.Application/Common/Security/IRolePermissionMap.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace APITemplate.Application.Common.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/absolute/src/APITemplate.Application/Common/Security/IUserProvisioningService.cs b/absolute/src/APITemplate.Application/Common/Security/IUserProvisioningService.cs deleted file mode 100644 index 8491d863..00000000 --- a/absolute/src/APITemplate.Application/Common/Security/IUserProvisioningService.cs +++ /dev/null @@ -1,21 +0,0 @@ -using APITemplate.Domain.Entities; - -namespace APITemplate.Application.Common.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/absolute/src/APITemplate.Application/Common/Security/Permission.cs b/absolute/src/APITemplate.Application/Common/Security/Permission.cs deleted file mode 100644 index ca14aaed..00000000 --- a/absolute/src/APITemplate.Application/Common/Security/Permission.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System.Reflection; - -namespace APITemplate.Application.Common.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"; - } - - private static readonly Lazy> LazyAll = new(() => - { - var permissions = new HashSet(StringComparer.Ordinal); - foreach ( - var nestedType in typeof(Permission).GetNestedTypes( - BindingFlags.Public | BindingFlags.Static - ) - ) - { - foreach ( - var 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/absolute/src/APITemplate.Application/Common/Security/StaticRolePermissionMap.cs b/absolute/src/APITemplate.Application/Common/Security/StaticRolePermissionMap.cs deleted file mode 100644 index 44281d0e..00000000 --- a/absolute/src/APITemplate.Application/Common/Security/StaticRolePermissionMap.cs +++ /dev/null @@ -1,75 +0,0 @@ -using APITemplate.Domain.Enums; - -namespace APITemplate.Application.Common.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 var 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() - { - var tenantAdminPermissions = new HashSet(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, - }; - - var userPermissions = new HashSet(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/absolute/src/APITemplate.Application/Common/Sorting/SortField.cs b/absolute/src/APITemplate.Application/Common/Sorting/SortField.cs deleted file mode 100644 index 367816cd..00000000 --- a/absolute/src/APITemplate.Application/Common/Sorting/SortField.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace APITemplate.Application.Common.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/absolute/src/APITemplate.Application/Common/Sorting/SortFieldMap.cs b/absolute/src/APITemplate.Application/Common/Sorting/SortFieldMap.cs deleted file mode 100644 index b74ce5e8..00000000 --- a/absolute/src/APITemplate.Application/Common/Sorting/SortFieldMap.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Linq.Expressions; -using Ardalis.Specification; - -namespace APITemplate.Application.Common.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 - ) - { - var desc = !string.Equals(sortDirection, "asc", StringComparison.OrdinalIgnoreCase); - var 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/absolute/src/APITemplate.Application/Common/Startup/IStartupTaskCoordinator.cs b/absolute/src/APITemplate.Application/Common/Startup/IStartupTaskCoordinator.cs deleted file mode 100644 index 8d073072..00000000 --- a/absolute/src/APITemplate.Application/Common/Startup/IStartupTaskCoordinator.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace APITemplate.Application.Common.Startup; - -/// -/// Coordinates one-time startup tasks across multiple application instances using distributed locking, -/// ensuring that tasks such as database seeding run exactly once even in a scaled-out environment. -/// -public interface IStartupTaskCoordinator -{ - /// - /// Acquires an exclusive distributed lease for and returns - /// an that releases the lease when disposed. - /// - Task AcquireAsync( - StartupTaskName startupTask, - CancellationToken ct = default - ); -} diff --git a/absolute/src/APITemplate.Application/Common/Startup/StartupTaskNames.cs b/absolute/src/APITemplate.Application/Common/Startup/StartupTaskNames.cs deleted file mode 100644 index 4cd9a0b9..00000000 --- a/absolute/src/APITemplate.Application/Common/Startup/StartupTaskNames.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace APITemplate.Application.Common.Startup; - -/// -/// Enumerates the named startup tasks whose distributed execution is coordinated via -/// . Values are numeric identifiers that act as -/// stable distributed lock keys. -/// -public enum StartupTaskName : long -{ - AppBootstrap = 2026031801, - BackgroundJobsBootstrap = 2026031802, -} diff --git a/absolute/src/APITemplate.Application/Common/Validation/DataAnnotationsValidator.cs b/absolute/src/APITemplate.Application/Common/Validation/DataAnnotationsValidator.cs deleted file mode 100644 index 346aed49..00000000 --- a/absolute/src/APITemplate.Application/Common/Validation/DataAnnotationsValidator.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Reflection; -using FluentValidation; - -namespace APITemplate.Application.Common.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) => - { - var results = new List(); - 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 (var 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 - ) - { - var type = model.GetType(); - var constructor = type.GetConstructors().FirstOrDefault(); - if (constructor is null) - return; - - var existingMembers = new HashSet(results.SelectMany(r => r.MemberNames)); - - foreach (var parameter in constructor.GetParameters()) - { - if (existingMembers.Contains(parameter.Name ?? string.Empty)) - continue; - - var validationAttributes = parameter.GetCustomAttributes(); - var property = type.GetProperty( - parameter.Name!, - BindingFlags.Public | BindingFlags.Instance - ); - if (property is null) - continue; - - var value = property.GetValue(model); - var validationContext = new ValidationContext(model) { MemberName = parameter.Name }; - - foreach (var attribute in validationAttributes) - { - var result = attribute.GetValidationResult(value, validationContext); - if (result != ValidationResult.Success && result is not null) - results.Add(result); - } - } - } -} diff --git a/absolute/src/APITemplate.Application/Common/Validation/DateRangeFilterValidator.cs b/absolute/src/APITemplate.Application/Common/Validation/DateRangeFilterValidator.cs deleted file mode 100644 index 33935d99..00000000 --- a/absolute/src/APITemplate.Application/Common/Validation/DateRangeFilterValidator.cs +++ /dev/null @@ -1,21 +0,0 @@ -using APITemplate.Application.Common.Contracts; -using FluentValidation; - -namespace APITemplate.Application.Common.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/absolute/src/APITemplate.Application/Common/Validation/FluentValidationExtensions.cs b/absolute/src/APITemplate.Application/Common/Validation/FluentValidationExtensions.cs deleted file mode 100644 index 676dbb8c..00000000 --- a/absolute/src/APITemplate.Application/Common/Validation/FluentValidationExtensions.cs +++ /dev/null @@ -1,30 +0,0 @@ -using APITemplate.Application.Common.Errors; -using FluentValidation; - -namespace APITemplate.Application.Common.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 - ) - { - var result = await validator.ValidateAsync(instance, ct); - if (!result.IsValid) - throw new Domain.Exceptions.ValidationException( - string.Join("; ", result.Errors.Select(e => e.ErrorMessage)), - errorCode ?? ErrorCatalog.General.ValidationFailed - ); - } -} diff --git a/absolute/src/APITemplate.Application/Common/Validation/NotEmptyAttribute.cs b/absolute/src/APITemplate.Application/Common/Validation/NotEmptyAttribute.cs deleted file mode 100644 index 2abeaf29..00000000 --- a/absolute/src/APITemplate.Application/Common/Validation/NotEmptyAttribute.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace APITemplate.Application.Common.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) - { - var 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/absolute/src/APITemplate.Application/Common/Validation/PaginationFilterValidator.cs b/absolute/src/APITemplate.Application/Common/Validation/PaginationFilterValidator.cs deleted file mode 100644 index 4526f5b7..00000000 --- a/absolute/src/APITemplate.Application/Common/Validation/PaginationFilterValidator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using APITemplate.Application.Common.DTOs; - -namespace APITemplate.Application.Common.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/absolute/src/APITemplate.Application/Common/Validation/SortableFilterValidator.cs b/absolute/src/APITemplate.Application/Common/Validation/SortableFilterValidator.cs deleted file mode 100644 index 933ea8bf..00000000 --- a/absolute/src/APITemplate.Application/Common/Validation/SortableFilterValidator.cs +++ /dev/null @@ -1,30 +0,0 @@ -using APITemplate.Application.Common.Contracts; -using FluentValidation; - -namespace APITemplate.Application.Common.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/absolute/src/APITemplate.Application/Features/Bff/DTOs/BffUserResponse.cs b/absolute/src/APITemplate.Application/Features/Bff/DTOs/BffUserResponse.cs deleted file mode 100644 index 61480d31..00000000 --- a/absolute/src/APITemplate.Application/Features/Bff/DTOs/BffUserResponse.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Category/CategorySortFields.cs b/absolute/src/APITemplate.Application/Features/Category/CategorySortFields.cs deleted file mode 100644 index 3dec35d8..00000000 --- a/absolute/src/APITemplate.Application/Features/Category/CategorySortFields.cs +++ /dev/null @@ -1,25 +0,0 @@ -using APITemplate.Application.Common.Sorting; -using CategoryEntity = APITemplate.Domain.Entities.Category; - -namespace APITemplate.Application.Features.Category; - -/// -/// Defines the allowed sort fields for category queries and maps them to entity expressions. -/// -public static class CategorySortFields -{ - /// Sort by category name. - public static readonly SortField Name = new("name"); - - /// Sort by creation timestamp. - public static readonly SortField CreatedAt = new("createdAt"); - - /// - /// The sort field map used to resolve and apply sorting to category specifications. - /// Defaults to sorting by when no sort field is specified. - /// - 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/absolute/src/APITemplate.Application/Features/Category/Commands/CreateCategoriesCommand.cs b/absolute/src/APITemplate.Application/Features/Category/Commands/CreateCategoriesCommand.cs deleted file mode 100644 index 710f8c9b..00000000 --- a/absolute/src/APITemplate.Application/Features/Category/Commands/CreateCategoriesCommand.cs +++ /dev/null @@ -1,57 +0,0 @@ -using APITemplate.Application.Common.Batch; -using APITemplate.Application.Common.Batch.Rules; -using APITemplate.Application.Common.Events; -using ErrorOr; -using FluentValidation; -using Wolverine; -using CategoryEntity = APITemplate.Domain.Entities.Category; - -namespace APITemplate.Application.Features.Category; - -/// 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> HandleAsync( - CreateCategoriesCommand command, - ICategoryRepository repository, - IUnitOfWork unitOfWork, - IMessageBus bus, - IValidator itemValidator, - CancellationToken ct - ) - { - var items = command.Request.Items; - var context = new BatchFailureContext(items); - - await context.ApplyRulesAsync( - ct, - new FluentValidationBatchRule(itemValidator) - ); - - if (context.HasFailures) - return context.ToFailureResponse(); - - var 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 - ); - - await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.Categories)); - return new BatchResponse([], items.Count, 0); - } -} diff --git a/absolute/src/APITemplate.Application/Features/Category/Commands/DeleteCategoriesCommand.cs b/absolute/src/APITemplate.Application/Features/Category/Commands/DeleteCategoriesCommand.cs deleted file mode 100644 index 430ee223..00000000 --- a/absolute/src/APITemplate.Application/Features/Category/Commands/DeleteCategoriesCommand.cs +++ /dev/null @@ -1,58 +0,0 @@ -using APITemplate.Application.Common.Batch; -using APITemplate.Application.Common.Batch.Rules; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Features.Category.Specifications; -using ErrorOr; -using Wolverine; - -namespace APITemplate.Application.Features.Category; - -/// 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> HandleAsync( - DeleteCategoriesCommand command, - ICategoryRepository repository, - IUnitOfWork unitOfWork, - IMessageBus bus, - CancellationToken ct - ) - { - var ids = command.Request.Ids; - var context = new BatchFailureContext(ids); - - // Load all target categories and mark missing ones as failed - var 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(); - - // Remove categories in a single transaction - await unitOfWork.ExecuteInTransactionAsync( - async () => - { - await repository.DeleteRangeAsync(categories, ct); - }, - ct - ); - - await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.Categories)); - - return new BatchResponse([], ids.Count, 0); - } -} diff --git a/absolute/src/APITemplate.Application/Features/Category/Commands/UpdateCategoriesCommand.cs b/absolute/src/APITemplate.Application/Features/Category/Commands/UpdateCategoriesCommand.cs deleted file mode 100644 index 31fbb193..00000000 --- a/absolute/src/APITemplate.Application/Features/Category/Commands/UpdateCategoriesCommand.cs +++ /dev/null @@ -1,76 +0,0 @@ -using APITemplate.Application.Common.Batch; -using APITemplate.Application.Common.Batch.Rules; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Features.Category.Specifications; -using ErrorOr; -using FluentValidation; -using Wolverine; - -namespace APITemplate.Application.Features.Category; - -/// 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> HandleAsync( - UpdateCategoriesCommand command, - ICategoryRepository repository, - IUnitOfWork unitOfWork, - IMessageBus bus, - IValidator itemValidator, - CancellationToken ct - ) - { - var items = command.Request.Items; - var context = new BatchFailureContext(items); - await context.ApplyRulesAsync( - ct, - new FluentValidationBatchRule(itemValidator) - ); - - // Load all target categories and mark missing ones as failed - var requestedIds = items - .Where((_, i) => !context.IsFailed(i)) - .Select(item => item.Id) - .ToHashSet(); - var 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(); - - // Apply changes in a single transaction - await unitOfWork.ExecuteInTransactionAsync( - async () => - { - for (var i = 0; i < items.Count; i++) - { - var item = items[i]; - var category = categoryMap[item.Id]; - - category.Name = item.Name; - category.Description = item.Description; - - await repository.UpdateAsync(category, ct); - } - }, - ct - ); - - await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.Categories)); - - return new BatchResponse([], items.Count, 0); - } -} diff --git a/absolute/src/APITemplate.Application/Features/Category/DTOs/CategoryFilter.cs b/absolute/src/APITemplate.Application/Features/Category/DTOs/CategoryFilter.cs deleted file mode 100644 index 83ef6514..00000000 --- a/absolute/src/APITemplate.Application/Features/Category/DTOs/CategoryFilter.cs +++ /dev/null @@ -1,15 +0,0 @@ -using APITemplate.Application.Common.Contracts; -using APITemplate.Application.Common.DTOs; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Category/DTOs/CategoryResponse.cs b/absolute/src/APITemplate.Application/Features/Category/DTOs/CategoryResponse.cs deleted file mode 100644 index 985b674c..00000000 --- a/absolute/src/APITemplate.Application/Features/Category/DTOs/CategoryResponse.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Category/DTOs/CreateCategoriesRequest.cs b/absolute/src/APITemplate.Application/Features/Category/DTOs/CreateCategoriesRequest.cs deleted file mode 100644 index 0c6a1eea..00000000 --- a/absolute/src/APITemplate.Application/Features/Category/DTOs/CreateCategoriesRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Category/DTOs/CreateCategoryRequest.cs b/absolute/src/APITemplate.Application/Features/Category/DTOs/CreateCategoryRequest.cs deleted file mode 100644 index 71891b43..00000000 --- a/absolute/src/APITemplate.Application/Features/Category/DTOs/CreateCategoryRequest.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using APITemplate.Application.Common.Validation; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Category/DTOs/ProductCategoryStatsResponse.cs b/absolute/src/APITemplate.Application/Features/Category/DTOs/ProductCategoryStatsResponse.cs deleted file mode 100644 index 118718f8..00000000 --- a/absolute/src/APITemplate.Application/Features/Category/DTOs/ProductCategoryStatsResponse.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Category/DTOs/UpdateCategoriesRequest.cs b/absolute/src/APITemplate.Application/Features/Category/DTOs/UpdateCategoriesRequest.cs deleted file mode 100644 index 8887c6f0..00000000 --- a/absolute/src/APITemplate.Application/Features/Category/DTOs/UpdateCategoriesRequest.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using APITemplate.Application.Common.Validation; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Category/DTOs/UpdateCategoryRequest.cs b/absolute/src/APITemplate.Application/Features/Category/DTOs/UpdateCategoryRequest.cs deleted file mode 100644 index c40686a7..00000000 --- a/absolute/src/APITemplate.Application/Features/Category/DTOs/UpdateCategoryRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Category/Mappings/CategoryMappings.cs b/absolute/src/APITemplate.Application/Features/Category/Mappings/CategoryMappings.cs deleted file mode 100644 index 1867e632..00000000 --- a/absolute/src/APITemplate.Application/Features/Category/Mappings/CategoryMappings.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Linq.Expressions; -using CategoryEntity = APITemplate.Domain.Entities.Category; -using ProductCategoryStatsEntity = APITemplate.Domain.Entities.ProductCategoryStats; - -namespace APITemplate.Application.Features.Category.Mappings; - -/// -/// Provides mapping utilities between category domain entities and their response DTOs. -/// The compiled projection is reused across specifications and in-memory conversions for consistency. -/// -public static class CategoryMappings -{ - /// - /// EF Core-compatible expression that projects a to a . - /// Shared with so the same shape is produced by both DB queries and in-memory maps. - /// - 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/absolute/src/APITemplate.Application/Features/Category/Queries/GetCategoriesQuery.cs b/absolute/src/APITemplate.Application/Features/Category/Queries/GetCategoriesQuery.cs deleted file mode 100644 index 8ddf5b5c..00000000 --- a/absolute/src/APITemplate.Application/Features/Category/Queries/GetCategoriesQuery.cs +++ /dev/null @@ -1,25 +0,0 @@ -using APITemplate.Application.Features.Category.Specifications; -using ErrorOr; - -namespace APITemplate.Application.Features.Category; - -/// 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/absolute/src/APITemplate.Application/Features/Category/Queries/GetCategoryByIdQuery.cs b/absolute/src/APITemplate.Application/Features/Category/Queries/GetCategoryByIdQuery.cs deleted file mode 100644 index 6775934f..00000000 --- a/absolute/src/APITemplate.Application/Features/Category/Queries/GetCategoryByIdQuery.cs +++ /dev/null @@ -1,30 +0,0 @@ -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Features.Category.Specifications; -using APITemplate.Domain.Interfaces; -using ErrorOr; - -namespace APITemplate.Application.Features.Category; - -/// 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 - ) - { - var result = await repository.FirstOrDefaultAsync( - new CategoryByIdSpecification(request.Id), - ct - ); - - if (result is null) - return DomainErrors.Categories.NotFound(request.Id); - - return result; - } -} diff --git a/absolute/src/APITemplate.Application/Features/Category/Queries/GetCategoryStatsQuery.cs b/absolute/src/APITemplate.Application/Features/Category/Queries/GetCategoryStatsQuery.cs deleted file mode 100644 index 7d22bd07..00000000 --- a/absolute/src/APITemplate.Application/Features/Category/Queries/GetCategoryStatsQuery.cs +++ /dev/null @@ -1,27 +0,0 @@ -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Features.Category.Mappings; -using APITemplate.Domain.Interfaces; -using ErrorOr; - -namespace APITemplate.Application.Features.Category; - -/// 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 - ) - { - var stats = await repository.GetStatsByIdAsync(request.Id, ct); - - if (stats is null) - return DomainErrors.Categories.NotFound(request.Id); - - return stats.ToResponse(); - } -} diff --git a/absolute/src/APITemplate.Application/Features/Category/Specifications/CategoriesByIdsSpecification.cs b/absolute/src/APITemplate.Application/Features/Category/Specifications/CategoriesByIdsSpecification.cs deleted file mode 100644 index e4df1b99..00000000 --- a/absolute/src/APITemplate.Application/Features/Category/Specifications/CategoriesByIdsSpecification.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Ardalis.Specification; -using CategoryEntity = APITemplate.Domain.Entities.Category; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Category/Specifications/CategoryByIdSpecification.cs b/absolute/src/APITemplate.Application/Features/Category/Specifications/CategoryByIdSpecification.cs deleted file mode 100644 index 64f20e5b..00000000 --- a/absolute/src/APITemplate.Application/Features/Category/Specifications/CategoryByIdSpecification.cs +++ /dev/null @@ -1,20 +0,0 @@ -using APITemplate.Application.Features.Category.Mappings; -using Ardalis.Specification; -using CategoryEntity = APITemplate.Domain.Entities.Category; - -namespace APITemplate.Application.Features.Category.Specifications; - -/// -/// Ardalis specification that fetches a single category by its identifier, projected directly to . -/// -public sealed class CategoryByIdSpecification : Specification -{ - /// Initialises the specification for the given . - public CategoryByIdSpecification(Guid id) - { - Query - .Where(category => category.Id == id) - .AsNoTracking() - .Select(CategoryMappings.Projection); - } -} diff --git a/absolute/src/APITemplate.Application/Features/Category/Specifications/CategoryFilterCriteria.cs b/absolute/src/APITemplate.Application/Features/Category/Specifications/CategoryFilterCriteria.cs deleted file mode 100644 index 095115c1..00000000 --- a/absolute/src/APITemplate.Application/Features/Category/Specifications/CategoryFilterCriteria.cs +++ /dev/null @@ -1,39 +0,0 @@ -using APITemplate.Application.Common.Search; -using Ardalis.Specification; -using Microsoft.EntityFrameworkCore; -using CategoryEntity = APITemplate.Domain.Entities.Category; - -namespace APITemplate.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 -{ - /// - /// Appends a full-text search predicate to when is non-empty. - /// Searches across the category name and description columns. - /// - 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/absolute/src/APITemplate.Application/Features/Category/Specifications/CategorySpecification.cs b/absolute/src/APITemplate.Application/Features/Category/Specifications/CategorySpecification.cs deleted file mode 100644 index 93c6c50c..00000000 --- a/absolute/src/APITemplate.Application/Features/Category/Specifications/CategorySpecification.cs +++ /dev/null @@ -1,20 +0,0 @@ -using APITemplate.Application.Features.Category.Mappings; -using Ardalis.Specification; -using CategoryEntity = APITemplate.Domain.Entities.Category; - -namespace APITemplate.Application.Features.Category.Specifications; - -/// -/// Ardalis specification for querying a filtered and sorted list of categories projected to . -/// -public sealed class CategorySpecification : Specification -{ - /// Initialises the specification by applying filter, sort, and projection from . - public CategorySpecification(CategoryFilter filter) - { - Query.ApplyFilter(filter); - Query.AsNoTracking(); - CategorySortFields.Map.ApplySort(Query, filter.SortBy, filter.SortDirection); - Query.Select(CategoryMappings.Projection); - } -} diff --git a/absolute/src/APITemplate.Application/Features/Category/Validation/CategoryFilterValidator.cs b/absolute/src/APITemplate.Application/Features/Category/Validation/CategoryFilterValidator.cs deleted file mode 100644 index 601e01d0..00000000 --- a/absolute/src/APITemplate.Application/Features/Category/Validation/CategoryFilterValidator.cs +++ /dev/null @@ -1,17 +0,0 @@ -using APITemplate.Application.Common.Validation; -using FluentValidation; - -namespace APITemplate.Application.Features.Category.Validation; - -/// -/// FluentValidation validator for . -/// Composes pagination and sortable filter validation rules by inclusion. -/// -public sealed class CategoryFilterValidator : AbstractValidator -{ - public CategoryFilterValidator() - { - Include(new PaginationFilterValidator()); - Include(new SortableFilterValidator(CategorySortFields.Map.AllowedNames)); - } -} diff --git a/absolute/src/APITemplate.Application/Features/Category/Validation/CreateCategoryRequestValidator.cs b/absolute/src/APITemplate.Application/Features/Category/Validation/CreateCategoryRequestValidator.cs deleted file mode 100644 index 050238dd..00000000 --- a/absolute/src/APITemplate.Application/Features/Category/Validation/CreateCategoryRequestValidator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using APITemplate.Application.Common.Validation; - -namespace APITemplate.Application.Features.Category.Validation; - -/// -/// FluentValidation validator for that enforces data-annotation constraints. -/// -public sealed class CreateCategoryRequestValidator - : DataAnnotationsValidator; diff --git a/absolute/src/APITemplate.Application/Features/Category/Validation/UpdateCategoryItemValidator.cs b/absolute/src/APITemplate.Application/Features/Category/Validation/UpdateCategoryItemValidator.cs deleted file mode 100644 index 06ef06ef..00000000 --- a/absolute/src/APITemplate.Application/Features/Category/Validation/UpdateCategoryItemValidator.cs +++ /dev/null @@ -1,8 +0,0 @@ -using APITemplate.Application.Common.Validation; - -namespace APITemplate.Application.Features.Category.Validation; - -/// -/// FluentValidation validator for that enforces data-annotation constraints. -/// -public sealed class UpdateCategoryItemValidator : DataAnnotationsValidator; diff --git a/absolute/src/APITemplate.Application/Features/Examples/Commands/IdempotentCreateCommand.cs b/absolute/src/APITemplate.Application/Features/Examples/Commands/IdempotentCreateCommand.cs deleted file mode 100644 index 2674cab6..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/Commands/IdempotentCreateCommand.cs +++ /dev/null @@ -1,43 +0,0 @@ -using APITemplate.Application.Features.Examples.DTOs; -using APITemplate.Application.Features.Product.Repositories; -using APITemplate.Domain.Interfaces; -using ErrorOr; -using ProductEntity = APITemplate.Domain.Entities.Product; - -namespace APITemplate.Application.Features.Examples; - -public sealed record IdempotentCreateCommand(IdempotentCreateRequest Request); - -public sealed class IdempotentCreateCommandHandler -{ - public static async Task> HandleAsync( - IdempotentCreateCommand command, - IProductRepository repository, - IUnitOfWork unitOfWork, - CancellationToken ct - ) - { - var entity = new ProductEntity - { - Id = Guid.NewGuid(), - Name = command.Request.Name, - Description = command.Request.Description, - Price = 0, - }; - - await unitOfWork.ExecuteInTransactionAsync( - async () => - { - await repository.AddAsync(entity, ct); - }, - ct - ); - - return new IdempotentCreateResponse( - entity.Id, - entity.Name, - entity.Description, - entity.Audit.CreatedAtUtc - ); - } -} diff --git a/absolute/src/APITemplate.Application/Features/Examples/Commands/PatchProductCommand.cs b/absolute/src/APITemplate.Application/Features/Examples/Commands/PatchProductCommand.cs deleted file mode 100644 index c20b02b9..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/Commands/PatchProductCommand.cs +++ /dev/null @@ -1,61 +0,0 @@ -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Features.Examples.DTOs; -using APITemplate.Application.Features.Product; -using APITemplate.Application.Features.Product.Mappings; -using APITemplate.Application.Features.Product.Repositories; -using APITemplate.Domain.Interfaces; -using ErrorOr; -using FluentValidation; -using Wolverine; - -namespace APITemplate.Application.Features.Examples; - -public sealed record PatchProductCommand(Guid Id, Action ApplyPatch) : IHasId; - -public sealed class PatchProductCommandHandler -{ - public static async Task> HandleAsync( - PatchProductCommand command, - IProductRepository repository, - IUnitOfWork unitOfWork, - IValidator validator, - IMessageBus bus, - CancellationToken ct - ) - { - var product = await repository.GetByIdAsync(command.Id, ct); - if (product is null) - return DomainErrors.Products.NotFound(command.Id); - - var dto = new PatchableProductDto - { - Name = product.Name, - Description = product.Description, - Price = product.Price, - CategoryId = product.CategoryId, - }; - - command.ApplyPatch(dto); - - var validationResult = await validator.ValidateAsync(dto, ct); - if (!validationResult.IsValid) - return DomainErrors.Examples.InvalidPatchDocument( - string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)) - ); - - product.UpdateDetails(dto.Name, dto.Description, dto.Price, dto.CategoryId); - - await unitOfWork.ExecuteInTransactionAsync( - async () => - { - await repository.UpdateAsync(product, ct); - }, - ct - ); - - await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.Products)); - - return product.ToResponse(); - } -} diff --git a/absolute/src/APITemplate.Application/Features/Examples/Commands/SubmitJobCommand.cs b/absolute/src/APITemplate.Application/Features/Examples/Commands/SubmitJobCommand.cs deleted file mode 100644 index f9bdbff3..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/Commands/SubmitJobCommand.cs +++ /dev/null @@ -1,43 +0,0 @@ -using APITemplate.Application.Common.BackgroundJobs; -using APITemplate.Application.Features.Examples.DTOs; -using APITemplate.Domain.Entities; -using APITemplate.Domain.Interfaces; -using ErrorOr; - -namespace APITemplate.Application.Features.Examples; - -public sealed record SubmitJobCommand(SubmitJobRequest Request); - -public sealed class SubmitJobCommandHandler -{ - public static async Task> HandleAsync( - SubmitJobCommand command, - IJobExecutionRepository repository, - IJobQueue jobQueue, - IUnitOfWork unitOfWork, - TimeProvider timeProvider, - CancellationToken ct - ) - { - var entity = new JobExecution - { - Id = Guid.NewGuid(), - JobType = command.Request.JobType, - Parameters = command.Request.Parameters, - CallbackUrl = command.Request.CallbackUrl, - SubmittedAtUtc = timeProvider.GetUtcNow().UtcDateTime, - }; - - await unitOfWork.ExecuteInTransactionAsync( - async () => - { - await repository.AddAsync(entity, ct); - }, - ct - ); - - await jobQueue.EnqueueAsync(entity.Id, ct); - - return JobResponseMapper.MapToResponse(entity); - } -} diff --git a/absolute/src/APITemplate.Application/Features/Examples/Commands/UploadFileCommand.cs b/absolute/src/APITemplate.Application/Features/Examples/Commands/UploadFileCommand.cs deleted file mode 100644 index 9b2e09c1..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/Commands/UploadFileCommand.cs +++ /dev/null @@ -1,71 +0,0 @@ -using APITemplate.Application.Common.Contracts; -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Common.Options; -using APITemplate.Application.Features.Examples.DTOs; -using APITemplate.Domain.Entities; -using APITemplate.Domain.Interfaces; -using ErrorOr; -using Microsoft.Extensions.Options; - -namespace APITemplate.Application.Features.Examples; - -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 - ) - { - var req = command.Request; - var opts = options.Value; - var extension = Path.GetExtension(req.FileName)?.ToLowerInvariant(); - if (string.IsNullOrEmpty(extension) || !opts.AllowedExtensions.Contains(extension)) - return DomainErrors.Examples.InvalidFileType(extension ?? "none"); - - if (req.SizeBytes > opts.MaxFileSizeBytes) - return DomainErrors.Examples.FileTooLarge(opts.MaxFileSizeBytes); - - var storageResult = await storage.SaveAsync(req.FileStream, req.FileName, ct); - - try - { - var 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/absolute/src/APITemplate.Application/Features/Examples/DTOs/DownloadFileRequest.cs b/absolute/src/APITemplate.Application/Features/Examples/DTOs/DownloadFileRequest.cs deleted file mode 100644 index 61692cf6..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/DTOs/DownloadFileRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace APITemplate.Application.Features.Examples.DTOs; - -/// -/// Carries the unique identifier of the stored file to be downloaded. -/// -public sealed record DownloadFileRequest(Guid Id) : IHasId; diff --git a/absolute/src/APITemplate.Application/Features/Examples/DTOs/FileUploadResponse.cs b/absolute/src/APITemplate.Application/Features/Examples/DTOs/FileUploadResponse.cs deleted file mode 100644 index 70311a2a..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/DTOs/FileUploadResponse.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace APITemplate.Application.Features.Examples.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/absolute/src/APITemplate.Application/Features/Examples/DTOs/GetJobStatusRequest.cs b/absolute/src/APITemplate.Application/Features/Examples/DTOs/GetJobStatusRequest.cs deleted file mode 100644 index b9c59c56..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/DTOs/GetJobStatusRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace APITemplate.Application.Features.Examples.DTOs; - -/// -/// Carries the unique identifier of the background job whose status is being queried. -/// -public sealed record GetJobStatusRequest(Guid Id) : IHasId; diff --git a/absolute/src/APITemplate.Application/Features/Examples/DTOs/IdempotentCreateRequest.cs b/absolute/src/APITemplate.Application/Features/Examples/DTOs/IdempotentCreateRequest.cs deleted file mode 100644 index f137ac04..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/DTOs/IdempotentCreateRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using APITemplate.Application.Common.Validation; - -namespace APITemplate.Application.Features.Examples.DTOs; - -/// -/// Carries the data for an idempotent resource creation request; demonstrates safe-retry semantics at the API layer. -/// -public sealed record IdempotentCreateRequest( - [NotEmpty] [MaxLength(200)] string Name, - string? Description -); diff --git a/absolute/src/APITemplate.Application/Features/Examples/DTOs/IdempotentCreateResponse.cs b/absolute/src/APITemplate.Application/Features/Examples/DTOs/IdempotentCreateResponse.cs deleted file mode 100644 index 5ffbd2fa..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/DTOs/IdempotentCreateResponse.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace APITemplate.Application.Features.Examples.DTOs; - -/// -/// Represents the persisted resource returned after a successful idempotent create operation. -/// -public sealed record IdempotentCreateResponse( - Guid Id, - string Name, - string? Description, - DateTime CreatedAtUtc -) : IHasId; diff --git a/absolute/src/APITemplate.Application/Features/Examples/DTOs/JobStatusResponse.cs b/absolute/src/APITemplate.Application/Features/Examples/DTOs/JobStatusResponse.cs deleted file mode 100644 index c2025bde..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/DTOs/JobStatusResponse.cs +++ /dev/null @@ -1,20 +0,0 @@ -using APITemplate.Domain.Enums; - -namespace APITemplate.Application.Features.Examples.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/absolute/src/APITemplate.Application/Features/Examples/DTOs/OutgoingWebhookDTOs.cs b/absolute/src/APITemplate.Application/Features/Examples/DTOs/OutgoingWebhookDTOs.cs deleted file mode 100644 index 8739a643..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/DTOs/OutgoingWebhookDTOs.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace APITemplate.Application.Features.Examples.DTOs; - -/// -/// Represents a pending outgoing webhook delivery, pairing the destination URL with the pre-serialised JSON payload. -/// -public sealed record OutgoingWebhookItem(string CallbackUrl, string SerializedPayload); - -/// -/// The strongly-typed payload delivered to a webhook callback URL upon job completion, carrying final status, result, and error information. -/// -public sealed record OutgoingJobWebhookPayload( - Guid JobId, - string JobType, - string Status, - string? ResultPayload, - string? ErrorMessage, - DateTime CompletedAtUtc -); diff --git a/absolute/src/APITemplate.Application/Features/Examples/DTOs/PatchableProductDto.cs b/absolute/src/APITemplate.Application/Features/Examples/DTOs/PatchableProductDto.cs deleted file mode 100644 index 02669d84..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/DTOs/PatchableProductDto.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using APITemplate.Application.Common.Validation; - -namespace APITemplate.Application.Features.Examples.DTOs; - -/// -/// Mutable DTO used as the patch target for JSON Patch operations on a product; declared as a class rather than a record because JSON Patch mutates the object in-place. -/// -// Mutable class (not record) — required because JsonPatch mutates in-place -public sealed class PatchableProductDto -{ - [NotEmpty(ErrorMessage = "Name is required.")] - [MaxLength(200)] - public string Name { get; set; } = string.Empty; - - [MaxLength(1000)] - public string? Description { get; set; } - - [Range(0.01, double.MaxValue, ErrorMessage = "Price must be positive.")] - public decimal Price { get; set; } - - public Guid? CategoryId { get; set; } -} diff --git a/absolute/src/APITemplate.Application/Features/Examples/DTOs/SseNotificationItem.cs b/absolute/src/APITemplate.Application/Features/Examples/DTOs/SseNotificationItem.cs deleted file mode 100644 index 3490c281..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/DTOs/SseNotificationItem.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace APITemplate.Application.Features.Examples.DTOs; - -/// -/// Represents a single Server-Sent Events (SSE) notification item emitted by the stream, carrying a sequence number, message text, and UTC timestamp. -/// -public sealed record SseNotificationItem(int Sequence, string Message, DateTime TimestampUtc); diff --git a/absolute/src/APITemplate.Application/Features/Examples/DTOs/SseStreamRequest.cs b/absolute/src/APITemplate.Application/Features/Examples/DTOs/SseStreamRequest.cs deleted file mode 100644 index 5bb54b12..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/DTOs/SseStreamRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace APITemplate.Application.Features.Examples.DTOs; - -/// -/// Configuration request for the SSE notification stream, specifying how many events should be emitted (1–100). -/// -public sealed class SseStreamRequest -{ - [Range(1, 100)] - public int Count { get; init; } = 5; -} diff --git a/absolute/src/APITemplate.Application/Features/Examples/DTOs/SubmitJobRequest.cs b/absolute/src/APITemplate.Application/Features/Examples/DTOs/SubmitJobRequest.cs deleted file mode 100644 index af041f8b..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/DTOs/SubmitJobRequest.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using APITemplate.Application.Common.Validation; - -namespace APITemplate.Application.Features.Examples.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/absolute/src/APITemplate.Application/Features/Examples/DTOs/UploadFileRequest.cs b/absolute/src/APITemplate.Application/Features/Examples/DTOs/UploadFileRequest.cs deleted file mode 100644 index 2bf02777..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/DTOs/UploadFileRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace APITemplate.Application.Features.Examples.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/absolute/src/APITemplate.Application/Features/Examples/DTOs/WebhookConstants.cs b/absolute/src/APITemplate.Application/Features/Examples/DTOs/WebhookConstants.cs deleted file mode 100644 index 503b47f3..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/DTOs/WebhookConstants.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace APITemplate.Application.Features.Examples.DTOs; - -/// -/// Centralises header names and HTTP client identifiers used by the outgoing webhook 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/absolute/src/APITemplate.Application/Features/Examples/DTOs/WebhookPayload.cs b/absolute/src/APITemplate.Application/Features/Examples/DTOs/WebhookPayload.cs deleted file mode 100644 index 4c8fc760..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/DTOs/WebhookPayload.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Text.Json; - -namespace APITemplate.Application.Features.Examples.DTOs; - -/// -/// Represents an incoming webhook payload with a discriminated event type, a unique event ID for deduplication, and a raw JSON data element. -/// -public sealed record WebhookPayload(string EventType, string EventId, JsonElement Data); diff --git a/absolute/src/APITemplate.Application/Features/Examples/Mappings/JobResponseMapper.cs b/absolute/src/APITemplate.Application/Features/Examples/Mappings/JobResponseMapper.cs deleted file mode 100644 index 57daf9cf..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/Mappings/JobResponseMapper.cs +++ /dev/null @@ -1,22 +0,0 @@ -using APITemplate.Application.Features.Examples.DTOs; -using APITemplate.Domain.Entities; - -namespace APITemplate.Application.Features.Examples; - -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/absolute/src/APITemplate.Application/Features/Examples/Queries/DownloadFileQuery.cs b/absolute/src/APITemplate.Application/Features/Examples/Queries/DownloadFileQuery.cs deleted file mode 100644 index ae70e2e4..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/Queries/DownloadFileQuery.cs +++ /dev/null @@ -1,38 +0,0 @@ -using APITemplate.Application.Common.Contracts; -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Common.Extensions; -using APITemplate.Application.Features.Examples.DTOs; -using APITemplate.Domain.Interfaces; -using ErrorOr; - -namespace APITemplate.Application.Features.Examples; - -public sealed record DownloadFileQuery(DownloadFileRequest Request); - -public sealed record FileDownloadResult(Stream FileStream, string ContentType, string FileName); - -public sealed class DownloadFileQueryHandler -{ - public static async Task> HandleAsync( - DownloadFileQuery query, - IStoredFileRepository repository, - IFileStorageService storage, - CancellationToken ct - ) - { - var entityResult = await repository.GetByIdOrError( - query.Request.Id, - DomainErrors.Examples.FileNotFound(query.Request.Id.ToString()), - ct - ); - if (entityResult.IsError) - return entityResult.Errors; - var entity = entityResult.Value; - - var stream = await storage.OpenReadAsync(entity.StoragePath, ct); - if (stream is null) - return DomainErrors.Examples.FileNotFound(entity.OriginalFileName); - - return new FileDownloadResult(stream, entity.ContentType, entity.OriginalFileName); - } -} diff --git a/absolute/src/APITemplate.Application/Features/Examples/Queries/GetJobStatusQuery.cs b/absolute/src/APITemplate.Application/Features/Examples/Queries/GetJobStatusQuery.cs deleted file mode 100644 index c3e11fc1..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/Queries/GetJobStatusQuery.cs +++ /dev/null @@ -1,23 +0,0 @@ -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Features.Examples.DTOs; -using APITemplate.Domain.Interfaces; -using ErrorOr; - -namespace APITemplate.Application.Features.Examples; - -public sealed record GetJobStatusQuery(GetJobStatusRequest Request); - -public sealed class GetJobStatusQueryHandler -{ - public static async Task> HandleAsync( - GetJobStatusQuery query, - IJobExecutionRepository repository, - CancellationToken ct - ) - { - var 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/absolute/src/APITemplate.Application/Features/Examples/Queries/GetNotificationStreamQuery.cs b/absolute/src/APITemplate.Application/Features/Examples/Queries/GetNotificationStreamQuery.cs deleted file mode 100644 index c56add7d..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/Queries/GetNotificationStreamQuery.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Runtime.CompilerServices; -using APITemplate.Application.Features.Examples.DTOs; - -namespace APITemplate.Application.Features.Examples; - -public sealed record GetNotificationStreamQuery(SseStreamRequest Request); - -public sealed class GetNotificationStreamQueryHandler -{ - public static Task> HandleAsync( - GetNotificationStreamQuery request, - TimeProvider timeProvider, - CancellationToken ct - ) - { - return Task.FromResult(StreamNotifications(request.Request.Count, timeProvider, ct)); - } - - private static async IAsyncEnumerable StreamNotifications( - int count, - TimeProvider timeProvider, - [EnumeratorCancellation] CancellationToken ct - ) - { - for (var i = 1; i <= count; i++) - { - ct.ThrowIfCancellationRequested(); - await Task.Delay(500, ct); - yield return new SseNotificationItem( - i, - $"Event {i} of {count}", - timeProvider.GetUtcNow().UtcDateTime - ); - } - } -} diff --git a/absolute/src/APITemplate.Application/Features/Examples/Validation/IdempotentCreateRequestValidator.cs b/absolute/src/APITemplate.Application/Features/Examples/Validation/IdempotentCreateRequestValidator.cs deleted file mode 100644 index 610ca046..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/Validation/IdempotentCreateRequestValidator.cs +++ /dev/null @@ -1,10 +0,0 @@ -using APITemplate.Application.Common.Validation; -using APITemplate.Application.Features.Examples.DTOs; - -namespace APITemplate.Application.Features.Examples.Validation; - -/// -/// FluentValidation validator for that enforces data-annotation constraints such as non-empty name and maximum length. -/// -public sealed class IdempotentCreateRequestValidator - : DataAnnotationsValidator; diff --git a/absolute/src/APITemplate.Application/Features/Examples/Validation/PatchableProductDtoValidator.cs b/absolute/src/APITemplate.Application/Features/Examples/Validation/PatchableProductDtoValidator.cs deleted file mode 100644 index c136db42..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/Validation/PatchableProductDtoValidator.cs +++ /dev/null @@ -1,17 +0,0 @@ -using APITemplate.Application.Common.Validation; -using APITemplate.Application.Features.Examples.DTOs; -using APITemplate.Application.Features.Product.Validation; -using FluentValidation; - -namespace APITemplate.Application.Features.Examples.Validation; - -/// -/// FluentValidation validator for the post-patch state; applies data-annotation constraints and the shared description-required-above-price-threshold rule. -/// -public sealed class PatchableProductDtoValidator : DataAnnotationsValidator -{ - public PatchableProductDtoValidator() - { - RuleFor(x => x.Description).RequiredAbovePriceThreshold(x => x.Price); - } -} diff --git a/absolute/src/APITemplate.Application/Features/Examples/Validation/SubmitJobRequestValidator.cs b/absolute/src/APITemplate.Application/Features/Examples/Validation/SubmitJobRequestValidator.cs deleted file mode 100644 index 1917ded2..00000000 --- a/absolute/src/APITemplate.Application/Features/Examples/Validation/SubmitJobRequestValidator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using APITemplate.Application.Common.Validation; -using APITemplate.Application.Features.Examples.DTOs; - -namespace APITemplate.Application.Features.Examples.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/absolute/src/APITemplate.Application/Features/Product/Commands/CreateProductsCommand.cs b/absolute/src/APITemplate.Application/Features/Product/Commands/CreateProductsCommand.cs deleted file mode 100644 index a078d252..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/Commands/CreateProductsCommand.cs +++ /dev/null @@ -1,83 +0,0 @@ -using APITemplate.Application.Common.Batch; -using APITemplate.Application.Common.Batch.Rules; -using APITemplate.Application.Common.Events; -using APITemplate.Domain.Entities; -using ErrorOr; -using FluentValidation; -using Wolverine; -using ProductEntity = APITemplate.Domain.Entities.Product; - -namespace APITemplate.Application.Features.Product; - -/// 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> HandleAsync( - CreateProductsCommand command, - IProductRepository repository, - ICategoryRepository categoryRepository, - IProductDataRepository productDataRepository, - IUnitOfWork unitOfWork, - IMessageBus bus, - IValidator itemValidator, - CancellationToken ct - ) - { - var items = command.Request.Items; - var context = new BatchFailureContext(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(); - - // Build entities and persist in a single transaction - var entities = items - .Select(item => - { - var 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 - ); - - await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.Products)); - return new BatchResponse([], items.Count, 0); - } -} diff --git a/absolute/src/APITemplate.Application/Features/Product/Commands/DeleteProductsCommand.cs b/absolute/src/APITemplate.Application/Features/Product/Commands/DeleteProductsCommand.cs deleted file mode 100644 index 8bbcc959..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/Commands/DeleteProductsCommand.cs +++ /dev/null @@ -1,62 +0,0 @@ -using APITemplate.Application.Common.Batch; -using APITemplate.Application.Common.Batch.Rules; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Features.Product.Specifications; -using ErrorOr; -using Wolverine; - -namespace APITemplate.Application.Features.Product; - -/// 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> HandleAsync( - DeleteProductsCommand command, - IProductRepository repository, - IUnitOfWork unitOfWork, - IMessageBus bus, - CancellationToken ct - ) - { - var ids = command.Request.Ids; - var context = new BatchFailureContext(ids); - - // Load all target products and mark missing ones as failed - var 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(); - - // Soft-delete product-data links and remove products in a single transaction - await unitOfWork.ExecuteInTransactionAsync( - async () => - { - foreach (var product in products) - product.SoftDeleteProductDataLinks(); - - await repository.DeleteRangeAsync(products, ct); - }, - ct - ); - - await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.Products)); - await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.Reviews)); - - return new BatchResponse([], ids.Count, 0); - } -} diff --git a/absolute/src/APITemplate.Application/Features/Product/Commands/UpdateProductsCommand.cs b/absolute/src/APITemplate.Application/Features/Product/Commands/UpdateProductsCommand.cs deleted file mode 100644 index 3b3fed47..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/Commands/UpdateProductsCommand.cs +++ /dev/null @@ -1,99 +0,0 @@ -using APITemplate.Application.Common.Batch; -using APITemplate.Application.Common.Events; -using APITemplate.Domain.Entities; -using ErrorOr; -using FluentValidation; -using Wolverine; -using ProductEntity = APITemplate.Domain.Entities.Product; - -namespace APITemplate.Application.Features.Product; - -/// 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> HandleAsync( - UpdateProductsCommand command, - EntityLookup lookup, - IProductRepository repository, - IUnitOfWork unitOfWork, - IMessageBus bus, - 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 - ); - - await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.Products)); - return new BatchResponse([], items.Count, 0); - } -} diff --git a/absolute/src/APITemplate.Application/Features/Product/Commands/UpdateProductsValidator.cs b/absolute/src/APITemplate.Application/Features/Product/Commands/UpdateProductsValidator.cs deleted file mode 100644 index e781903e..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/Commands/UpdateProductsValidator.cs +++ /dev/null @@ -1,72 +0,0 @@ -using APITemplate.Application.Common.Batch; -using APITemplate.Application.Common.Batch.Rules; -using APITemplate.Application.Features.Product.Specifications; -using FluentValidation; -using ProductEntity = APITemplate.Domain.Entities.Product; - -namespace APITemplate.Application.Features.Product; - -/// -/// 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/absolute/src/APITemplate.Application/Features/Product/DTOs/CreateProductRequest.cs b/absolute/src/APITemplate.Application/Features/Product/DTOs/CreateProductRequest.cs deleted file mode 100644 index 43021395..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/DTOs/CreateProductRequest.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using APITemplate.Application.Common.Validation; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Product/DTOs/CreateProductsRequest.cs b/absolute/src/APITemplate.Application/Features/Product/DTOs/CreateProductsRequest.cs deleted file mode 100644 index 39b99ba7..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/DTOs/CreateProductsRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Product/DTOs/ProductCategoryFacetValue.cs b/absolute/src/APITemplate.Application/Features/Product/DTOs/ProductCategoryFacetValue.cs deleted file mode 100644 index 6a2d4cd7..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/DTOs/ProductCategoryFacetValue.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Product/DTOs/ProductFilter.cs b/absolute/src/APITemplate.Application/Features/Product/DTOs/ProductFilter.cs deleted file mode 100644 index 1b1856bd..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/DTOs/ProductFilter.cs +++ /dev/null @@ -1,22 +0,0 @@ -using APITemplate.Application.Common.Contracts; -using APITemplate.Application.Common.DTOs; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Product/DTOs/ProductPriceFacetBucketResponse.cs b/absolute/src/APITemplate.Application/Features/Product/DTOs/ProductPriceFacetBucketResponse.cs deleted file mode 100644 index bce0cf10..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/DTOs/ProductPriceFacetBucketResponse.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace APITemplate.Application.Features.Product.DTOs; - -/// -/// 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/absolute/src/APITemplate.Application/Features/Product/DTOs/ProductResponse.cs b/absolute/src/APITemplate.Application/Features/Product/DTOs/ProductResponse.cs deleted file mode 100644 index 17eb03fd..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/DTOs/ProductResponse.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Product/DTOs/ProductSearchFacetsResponse.cs b/absolute/src/APITemplate.Application/Features/Product/DTOs/ProductSearchFacetsResponse.cs deleted file mode 100644 index 53251cb9..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/DTOs/ProductSearchFacetsResponse.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Product/DTOs/ProductsResponse.cs b/absolute/src/APITemplate.Application/Features/Product/DTOs/ProductsResponse.cs deleted file mode 100644 index 4ee80d63..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/DTOs/ProductsResponse.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Product/DTOs/UpdateProductRequest.cs b/absolute/src/APITemplate.Application/Features/Product/DTOs/UpdateProductRequest.cs deleted file mode 100644 index 6b6176e4..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/DTOs/UpdateProductRequest.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using APITemplate.Application.Common.Validation; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Product/DTOs/UpdateProductsRequest.cs b/absolute/src/APITemplate.Application/Features/Product/DTOs/UpdateProductsRequest.cs deleted file mode 100644 index 08ac1165..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/DTOs/UpdateProductsRequest.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using APITemplate.Application.Common.Validation; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Product/Mappings/ProductMappings.cs b/absolute/src/APITemplate.Application/Features/Product/Mappings/ProductMappings.cs deleted file mode 100644 index 5069c16d..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/Mappings/ProductMappings.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Linq.Expressions; -using ProductEntity = APITemplate.Domain.Entities.Product; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Product/ProductSortFields.cs b/absolute/src/APITemplate.Application/Features/Product/ProductSortFields.cs deleted file mode 100644 index a1ec6aa6..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/ProductSortFields.cs +++ /dev/null @@ -1,20 +0,0 @@ -using APITemplate.Application.Common.Sorting; -using ProductEntity = APITemplate.Domain.Entities.Product; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Product/ProductValidationHelper.cs b/absolute/src/APITemplate.Application/Features/Product/ProductValidationHelper.cs deleted file mode 100644 index bf7701a7..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/ProductValidationHelper.cs +++ /dev/null @@ -1,156 +0,0 @@ -using APITemplate.Application.Common.Batch; - -namespace APITemplate.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 : 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 - ) - { - var allCategoryIds = items - .Where(item => categoryIdSelector(item).HasValue) - .Select(item => categoryIdSelector(item)!.Value) - .ToHashSet(); - - if (allCategoryIds.Count == 0) - return []; - - var existing = await categoryRepository.ListAsync( - new Category.Specifications.CategoriesByIdsSpecification(allCategoryIds), - ct - ); - allCategoryIds.ExceptWith(existing.Select(c => c.Id)); - - if (allCategoryIds.Count == 0) - return []; - - var failures = new List(); - - for (var i = 0; i < items.Count; i++) - { - if (failedIndices.Contains(i)) - continue; - - var 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 - ) - { - var allProductDataIds = items - .Where(item => productDataIdsSelector(item) is { Count: > 0 }) - .SelectMany(item => productDataIdsSelector(item)!) - .Distinct() - .ToArray(); - - if (allProductDataIds.Length == 0) - return []; - - var existingIds = (await productDataRepository.GetByIdsAsync(allProductDataIds, ct)) - .Select(pd => pd.Id) - .ToHashSet(); - - var missingIds = allProductDataIds.Where(id => !existingIds.Contains(id)).ToHashSet(); - - if (missingIds.Count == 0) - return []; - - var failures = new List(); - - for (var i = 0; i < items.Count; i++) - { - if (failedIndices.Contains(i)) - continue; - - var pdIds = productDataIdsSelector(items[i]); - if (pdIds is not { Count: > 0 }) - continue; - - var 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/absolute/src/APITemplate.Application/Features/Product/Queries/GetProductByIdQuery.cs b/absolute/src/APITemplate.Application/Features/Product/Queries/GetProductByIdQuery.cs deleted file mode 100644 index cef57aa1..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/Queries/GetProductByIdQuery.cs +++ /dev/null @@ -1,30 +0,0 @@ -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Features.Product.Repositories; -using APITemplate.Application.Features.Product.Specifications; -using ErrorOr; - -namespace APITemplate.Application.Features.Product; - -/// 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 - ) - { - var result = await repository.FirstOrDefaultAsync( - new ProductByIdSpecification(request.Id), - ct - ); - - if (result is null) - return DomainErrors.Products.NotFound(request.Id); - - return result; - } -} diff --git a/absolute/src/APITemplate.Application/Features/Product/Queries/GetProductsQuery.cs b/absolute/src/APITemplate.Application/Features/Product/Queries/GetProductsQuery.cs deleted file mode 100644 index 8ba60488..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/Queries/GetProductsQuery.cs +++ /dev/null @@ -1,26 +0,0 @@ -using ErrorOr; - -namespace APITemplate.Application.Features.Product; - -/// 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 - ) - { - var page = await repository.GetPagedAsync(request.Filter, ct); - var categoryFacets = await repository.GetCategoryFacetsAsync(request.Filter, ct); - var priceFacets = await repository.GetPriceFacetsAsync(request.Filter, ct); - - return new ProductsResponse( - page, - new ProductSearchFacetsResponse(categoryFacets, priceFacets) - ); - } -} diff --git a/absolute/src/APITemplate.Application/Features/Product/Repositories/IProductRepository.cs b/absolute/src/APITemplate.Application/Features/Product/Repositories/IProductRepository.cs deleted file mode 100644 index 67a5147f..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/Repositories/IProductRepository.cs +++ /dev/null @@ -1,27 +0,0 @@ -using ProductEntity = APITemplate.Domain.Entities.Product; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Product/Specifications/ProductByIdSpecification.cs b/absolute/src/APITemplate.Application/Features/Product/Specifications/ProductByIdSpecification.cs deleted file mode 100644 index 8789f5a0..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/Specifications/ProductByIdSpecification.cs +++ /dev/null @@ -1,16 +0,0 @@ -using APITemplate.Application.Features.Product.Mappings; -using Ardalis.Specification; -using ProductEntity = APITemplate.Domain.Entities.Product; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Product/Specifications/ProductByIdWithLinksSpecification.cs b/absolute/src/APITemplate.Application/Features/Product/Specifications/ProductByIdWithLinksSpecification.cs deleted file mode 100644 index 86613014..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/Specifications/ProductByIdWithLinksSpecification.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Ardalis.Specification; -using ProductEntity = APITemplate.Domain.Entities.Product; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Product/Specifications/ProductCategoryFacetSpecification.cs b/absolute/src/APITemplate.Application/Features/Product/Specifications/ProductCategoryFacetSpecification.cs deleted file mode 100644 index 704ca4da..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/Specifications/ProductCategoryFacetSpecification.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Ardalis.Specification; -using ProductEntity = APITemplate.Domain.Entities.Product; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Product/Specifications/ProductFilterCriteria.cs b/absolute/src/APITemplate.Application/Features/Product/Specifications/ProductFilterCriteria.cs deleted file mode 100644 index c3eb2f68..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/Specifications/ProductFilterCriteria.cs +++ /dev/null @@ -1,74 +0,0 @@ -using APITemplate.Application.Common.Search; -using Ardalis.Specification; -using Microsoft.EntityFrameworkCore; -using ProductEntity = APITemplate.Domain.Entities.Product; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Product/Specifications/ProductPriceFacetSpecification.cs b/absolute/src/APITemplate.Application/Features/Product/Specifications/ProductPriceFacetSpecification.cs deleted file mode 100644 index bda0d8aa..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/Specifications/ProductPriceFacetSpecification.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Ardalis.Specification; -using ProductEntity = APITemplate.Domain.Entities.Product; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Product/Specifications/ProductSpecification.cs b/absolute/src/APITemplate.Application/Features/Product/Specifications/ProductSpecification.cs deleted file mode 100644 index 96b3d225..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/Specifications/ProductSpecification.cs +++ /dev/null @@ -1,21 +0,0 @@ -using APITemplate.Application.Features.Product.Mappings; -using Ardalis.Specification; -using ProductEntity = APITemplate.Domain.Entities.Product; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Product/Specifications/ProductsByIdsWithLinksSpecification.cs b/absolute/src/APITemplate.Application/Features/Product/Specifications/ProductsByIdsWithLinksSpecification.cs deleted file mode 100644 index b4debef4..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/Specifications/ProductsByIdsWithLinksSpecification.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Ardalis.Specification; -using ProductEntity = APITemplate.Domain.Entities.Product; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Product/Validation/CreateProductRequestValidator.cs b/absolute/src/APITemplate.Application/Features/Product/Validation/CreateProductRequestValidator.cs deleted file mode 100644 index 31f5ce65..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/Validation/CreateProductRequestValidator.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace APITemplate.Application.Features.Product.Validation; - -/// -/// FluentValidation validator for , inheriting all rules from . -/// -public sealed class CreateProductRequestValidator - : ProductRequestValidatorBase; diff --git a/absolute/src/APITemplate.Application/Features/Product/Validation/ProductFilterValidator.cs b/absolute/src/APITemplate.Application/Features/Product/Validation/ProductFilterValidator.cs deleted file mode 100644 index 49865708..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/Validation/ProductFilterValidator.cs +++ /dev/null @@ -1,36 +0,0 @@ -using APITemplate.Application.Common.Validation; -using FluentValidation; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Product/Validation/ProductRequestValidatorBase.cs b/absolute/src/APITemplate.Application/Features/Product/Validation/ProductRequestValidatorBase.cs deleted file mode 100644 index 301cd904..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/Validation/ProductRequestValidatorBase.cs +++ /dev/null @@ -1,38 +0,0 @@ -using APITemplate.Application.Common.Validation; -using FluentValidation; - -namespace APITemplate.Application.Features.Product.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/absolute/src/APITemplate.Application/Features/Product/Validation/UpdateProductItemValidator.cs b/absolute/src/APITemplate.Application/Features/Product/Validation/UpdateProductItemValidator.cs deleted file mode 100644 index c56dfbea..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/Validation/UpdateProductItemValidator.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Product/Validation/UpdateProductRequestValidator.cs b/absolute/src/APITemplate.Application/Features/Product/Validation/UpdateProductRequestValidator.cs deleted file mode 100644 index 015be957..00000000 --- a/absolute/src/APITemplate.Application/Features/Product/Validation/UpdateProductRequestValidator.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace APITemplate.Application.Features.Product.Validation; - -/// -/// FluentValidation validator for , inheriting all rules from . -/// -public sealed class UpdateProductRequestValidator - : ProductRequestValidatorBase; diff --git a/absolute/src/APITemplate.Application/Features/ProductData/Commands/CreateImageProductDataCommand.cs b/absolute/src/APITemplate.Application/Features/ProductData/Commands/CreateImageProductDataCommand.cs deleted file mode 100644 index 6b8c2188..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductData/Commands/CreateImageProductDataCommand.cs +++ /dev/null @@ -1,40 +0,0 @@ -using APITemplate.Application.Common.Context; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Features.ProductData.Mappings; -using APITemplate.Domain.Entities; -using APITemplate.Domain.Interfaces; -using ErrorOr; -using Wolverine; - -namespace APITemplate.Application.Features.ProductData; - -public sealed record CreateImageProductDataCommand(CreateImageProductDataRequest Request); - -public sealed class CreateImageProductDataCommandHandler -{ - public static async Task> HandleAsync( - CreateImageProductDataCommand command, - IProductDataRepository repository, - ITenantProvider tenantProvider, - IMessageBus bus, - TimeProvider timeProvider, - CancellationToken ct - ) - { - var entity = new ImageProductData - { - 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, - }; - - var created = await repository.CreateAsync(entity, ct); - await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.ProductData)); - return created.ToResponse(); - } -} diff --git a/absolute/src/APITemplate.Application/Features/ProductData/Commands/CreateVideoProductDataCommand.cs b/absolute/src/APITemplate.Application/Features/ProductData/Commands/CreateVideoProductDataCommand.cs deleted file mode 100644 index eeef14b5..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductData/Commands/CreateVideoProductDataCommand.cs +++ /dev/null @@ -1,40 +0,0 @@ -using APITemplate.Application.Common.Context; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Features.ProductData.Mappings; -using APITemplate.Domain.Entities; -using APITemplate.Domain.Interfaces; -using ErrorOr; -using Wolverine; - -namespace APITemplate.Application.Features.ProductData; - -public sealed record CreateVideoProductDataCommand(CreateVideoProductDataRequest Request); - -public sealed class CreateVideoProductDataCommandHandler -{ - public static async Task> HandleAsync( - CreateVideoProductDataCommand command, - IProductDataRepository repository, - ITenantProvider tenantProvider, - IMessageBus bus, - TimeProvider timeProvider, - CancellationToken ct - ) - { - var entity = new VideoProductData - { - 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, - }; - - var created = await repository.CreateAsync(entity, ct); - await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.ProductData)); - return created.ToResponse(); - } -} diff --git a/absolute/src/APITemplate.Application/Features/ProductData/Commands/DeleteProductDataCommand.cs b/absolute/src/APITemplate.Application/Features/ProductData/Commands/DeleteProductDataCommand.cs deleted file mode 100644 index 650c9310..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductData/Commands/DeleteProductDataCommand.cs +++ /dev/null @@ -1,78 +0,0 @@ -using APITemplate.Application.Common.Context; -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Common.Resilience; -using APITemplate.Domain.Interfaces; -using ErrorOr; -using Microsoft.Extensions.Logging; -using Polly.Registry; -using Wolverine; - -namespace APITemplate.Application.Features.ProductData; - -public sealed record DeleteProductDataCommand(Guid Id) : IHasId; - -public sealed class DeleteProductDataCommandHandler -{ - public static async Task> HandleAsync( - DeleteProductDataCommand command, - IProductDataRepository repository, - IProductDataLinkRepository productDataLinkRepository, - ITenantProvider tenantProvider, - IActorProvider actorProvider, - IUnitOfWork unitOfWork, - IMessageBus bus, - TimeProvider timeProvider, - ResiliencePipelineProvider resiliencePipelineProvider, - ILogger logger, - CancellationToken ct - ) - { - var tenantId = tenantProvider.TenantId; - - var data = await repository.GetByIdAsync(command.Id, ct); - - if (data is null || data.TenantId != tenantId) - return DomainErrors.ProductData.NotFound(command.Id); - - var deletedAtUtc = timeProvider.GetUtcNow().UtcDateTime; - var actorId = actorProvider.ActorId; - - await unitOfWork.ExecuteInTransactionAsync( - async () => - { - await productDataLinkRepository.SoftDeleteActiveLinksForProductDataAsync( - command.Id, - ct - ); - }, - ct - ); - - var 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; - } - - await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.ProductData)); - return Result.Success; - } -} diff --git a/absolute/src/APITemplate.Application/Features/ProductData/DTOs/CreateImageProductDataRequest.cs b/absolute/src/APITemplate.Application/Features/ProductData/DTOs/CreateImageProductDataRequest.cs deleted file mode 100644 index a4e67e83..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductData/DTOs/CreateImageProductDataRequest.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using APITemplate.Application.Common.Validation; - -namespace APITemplate.Application.Features.ProductData.DTOs; - -/// -/// Payload for uploading image product data, including dimensions, format, and file size. -/// Validation constraints are expressed via data annotations and enforced by CreateImageProductDataRequestValidator. -/// -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/absolute/src/APITemplate.Application/Features/ProductData/DTOs/CreateVideoProductDataRequest.cs b/absolute/src/APITemplate.Application/Features/ProductData/DTOs/CreateVideoProductDataRequest.cs deleted file mode 100644 index 81f3b8ba..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductData/DTOs/CreateVideoProductDataRequest.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using APITemplate.Application.Common.Validation; - -namespace APITemplate.Application.Features.ProductData.DTOs; - -/// -/// Payload for uploading video product data, including duration, resolution, format, and file size. -/// Validation constraints are expressed via data annotations and enforced by CreateVideoProductDataRequestValidator. -/// -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/absolute/src/APITemplate.Application/Features/ProductData/DTOs/ProductDataResponse.cs b/absolute/src/APITemplate.Application/Features/ProductData/DTOs/ProductDataResponse.cs deleted file mode 100644 index 59468a17..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductData/DTOs/ProductDataResponse.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Text.Json.Serialization; - -namespace APITemplate.Application.Features.ProductData.DTOs; - -/// -/// Abstract base read model for product data, serialised as a polymorphic type using the type discriminator. -/// Concrete subtypes add media-specific properties. -/// -[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/absolute/src/APITemplate.Application/Features/ProductData/Handlers/ProductDataCascadeDeleteHandler.cs b/absolute/src/APITemplate.Application/Features/ProductData/Handlers/ProductDataCascadeDeleteHandler.cs deleted file mode 100644 index 6c60643e..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductData/Handlers/ProductDataCascadeDeleteHandler.cs +++ /dev/null @@ -1,50 +0,0 @@ -using APITemplate.Application.Common.Events; -using APITemplate.Application.Common.Resilience; -using Microsoft.Extensions.Logging; -using Polly.Registry; - -namespace APITemplate.Application.Features.ProductData.Handlers; - -public sealed class ProductDataCascadeDeleteHandler -{ - public static async Task HandleAsync( - TenantSoftDeletedNotification @event, - IProductDataRepository productDataRepository, - ResiliencePipelineProvider resiliencePipelineProvider, - ILogger logger, - CancellationToken ct - ) - { - var pipeline = resiliencePipelineProvider.GetPipeline( - ResiliencePipelineKeys.MongoProductDataDelete - ); - - try - { - var count = await pipeline.ExecuteAsync( - async token => - await productDataRepository.SoftDeleteByTenantAsync( - @event.TenantId, - @event.ActorId, - @event.DeletedAtUtc, - token - ), - ct - ); - - logger.LogInformation( - "Cascade soft-deleted {Count} ProductData documents for tenant {TenantId}.", - count, - @event.TenantId - ); - } - catch (Exception ex) - { - logger.LogError( - ex, - "Failed to cascade soft-delete ProductData documents for tenant {TenantId}. EF entities are already soft-deleted.", - @event.TenantId - ); - } - } -} diff --git a/absolute/src/APITemplate.Application/Features/ProductData/Mappings/ProductDataMappings.cs b/absolute/src/APITemplate.Application/Features/ProductData/Mappings/ProductDataMappings.cs deleted file mode 100644 index e06fe616..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductData/Mappings/ProductDataMappings.cs +++ /dev/null @@ -1,64 +0,0 @@ -using ImageProductDataEntity = APITemplate.Domain.Entities.ProductData.ImageProductData; -using ProductDataEntity = APITemplate.Domain.Entities.ProductData.ProductData; -using VideoProductDataEntity = APITemplate.Domain.Entities.ProductData.VideoProductData; - -namespace APITemplate.Application.Features.ProductData.Mappings; - -/// -/// Provides mapping utilities from product data domain entities to their polymorphic response DTOs. -/// Dispatches to a type-specific mapping method based on the concrete entity type. -/// -public static class ProductDataMappings -{ - /// - /// Maps a to the appropriate subtype. - /// Throws for unrecognised entity types. - /// - 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}" - ), - }; - - /// Copies shared fields from the base entity onto an already-populated response record. - 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, - }; - - /// Maps an to an . - private static ImageProductDataResponse ToImageResponse(this ImageProductDataEntity image) => - image.MapCommon( - new ImageProductDataResponse - { - Width = image.Width, - Height = image.Height, - Format = image.Format, - FileSizeBytes = image.FileSizeBytes, - }, - "image" - ); - - /// Maps a to a . - 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/absolute/src/APITemplate.Application/Features/ProductData/Queries/GetProductDataByIdQuery.cs b/absolute/src/APITemplate.Application/Features/ProductData/Queries/GetProductDataByIdQuery.cs deleted file mode 100644 index 87cc70c7..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductData/Queries/GetProductDataByIdQuery.cs +++ /dev/null @@ -1,28 +0,0 @@ -using APITemplate.Application.Common.Context; -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Features.ProductData.Mappings; -using APITemplate.Domain.Interfaces; -using ErrorOr; - -namespace APITemplate.Application.Features.ProductData; - -public sealed record GetProductDataByIdQuery(Guid Id) : IHasId; - -public sealed class GetProductDataByIdQueryHandler -{ - public static async Task> HandleAsync( - GetProductDataByIdQuery request, - IProductDataRepository repository, - ITenantProvider tenantProvider, - CancellationToken ct - ) - { - var tenantId = tenantProvider.TenantId; - var 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/absolute/src/APITemplate.Application/Features/ProductData/Queries/GetProductDataQuery.cs b/absolute/src/APITemplate.Application/Features/ProductData/Queries/GetProductDataQuery.cs deleted file mode 100644 index 5661e86b..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductData/Queries/GetProductDataQuery.cs +++ /dev/null @@ -1,20 +0,0 @@ -using APITemplate.Application.Features.ProductData.Mappings; -using APITemplate.Domain.Interfaces; -using ErrorOr; - -namespace APITemplate.Application.Features.ProductData; - -public sealed record GetProductDataQuery(string? Type); - -public sealed class GetProductDataQueryHandler -{ - public static async Task>> HandleAsync( - GetProductDataQuery request, - IProductDataRepository repository, - CancellationToken ct - ) - { - var items = await repository.GetAllAsync(request.Type, ct); - return items.Select(item => item.ToResponse()).ToList(); - } -} diff --git a/absolute/src/APITemplate.Application/Features/ProductData/Validation/CreateImageProductDataRequestValidator.cs b/absolute/src/APITemplate.Application/Features/ProductData/Validation/CreateImageProductDataRequestValidator.cs deleted file mode 100644 index b8261bbd..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductData/Validation/CreateImageProductDataRequestValidator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using APITemplate.Application.Common.Validation; - -namespace APITemplate.Application.Features.ProductData.Validation; - -/// -/// FluentValidation validator for , delegating to data-annotation-based validation rules. -/// -public sealed class CreateImageProductDataRequestValidator - : DataAnnotationsValidator; diff --git a/absolute/src/APITemplate.Application/Features/ProductData/Validation/CreateVideoProductDataRequestValidator.cs b/absolute/src/APITemplate.Application/Features/ProductData/Validation/CreateVideoProductDataRequestValidator.cs deleted file mode 100644 index 26658f94..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductData/Validation/CreateVideoProductDataRequestValidator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using APITemplate.Application.Common.Validation; - -namespace APITemplate.Application.Features.ProductData.Validation; - -/// -/// FluentValidation validator for , delegating to data-annotation-based validation rules. -/// -public sealed class CreateVideoProductDataRequestValidator - : DataAnnotationsValidator; diff --git a/absolute/src/APITemplate.Application/Features/ProductReview/Commands/CreateProductReviewCommand.cs b/absolute/src/APITemplate.Application/Features/ProductReview/Commands/CreateProductReviewCommand.cs deleted file mode 100644 index ef92dcef..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductReview/Commands/CreateProductReviewCommand.cs +++ /dev/null @@ -1,60 +0,0 @@ -using APITemplate.Application.Common.Context; -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Common.Extensions; -using APITemplate.Application.Features.Product.Repositories; -using APITemplate.Application.Features.ProductReview.Mappings; -using APITemplate.Domain.Interfaces; -using ErrorOr; -using Wolverine; -using ProductReviewEntity = APITemplate.Domain.Entities.ProductReview; - -namespace APITemplate.Application.Features.ProductReview; - -/// 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> HandleAsync( - CreateProductReviewCommand command, - IProductReviewRepository reviewRepository, - IProductRepository productRepository, - IUnitOfWork unitOfWork, - IActorProvider actorProvider, - IMessageBus bus, - CancellationToken ct - ) - { - var userId = actorProvider.ActorId; - var productResult = await productRepository.GetByIdOrError( - command.Request.ProductId, - DomainErrors.Reviews.ProductNotFoundForReview(command.Request.ProductId), - ct - ); - if (productResult.IsError) - return productResult.Errors; - - var review = await unitOfWork.ExecuteInTransactionAsync( - async () => - { - var entity = new ProductReviewEntity - { - 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 CacheInvalidationNotification(CacheTags.Reviews)); - return review.ToResponse(); - } -} diff --git a/absolute/src/APITemplate.Application/Features/ProductReview/Commands/DeleteProductReviewCommand.cs b/absolute/src/APITemplate.Application/Features/ProductReview/Commands/DeleteProductReviewCommand.cs deleted file mode 100644 index 69a5f9a0..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductReview/Commands/DeleteProductReviewCommand.cs +++ /dev/null @@ -1,50 +0,0 @@ -using APITemplate.Application.Common.Context; -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Common.Extensions; -using APITemplate.Domain.Interfaces; -using ErrorOr; -using Wolverine; - -namespace APITemplate.Application.Features.ProductReview; - -/// 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> HandleAsync( - DeleteProductReviewCommand command, - IProductReviewRepository reviewRepository, - IUnitOfWork unitOfWork, - IActorProvider actorProvider, - IMessageBus bus, - CancellationToken ct - ) - { - var userId = actorProvider.ActorId; - var reviewResult = await reviewRepository.GetByIdOrError( - command.Id, - DomainErrors.Reviews.NotFound(command.Id), - ct - ); - if (reviewResult.IsError) - return reviewResult.Errors; - var review = reviewResult.Value; - - if (review.UserId != userId) - return DomainErrors.Auth.ForbiddenOwnReviewsOnly(); - - await unitOfWork.ExecuteInTransactionAsync( - async () => - { - await reviewRepository.DeleteAsync(review, ct); - }, - ct - ); - - await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.Reviews)); - return Result.Success; - } -} diff --git a/absolute/src/APITemplate.Application/Features/ProductReview/DTOs/CreateProductReviewRequest.cs b/absolute/src/APITemplate.Application/Features/ProductReview/DTOs/CreateProductReviewRequest.cs deleted file mode 100644 index af9f211a..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductReview/DTOs/CreateProductReviewRequest.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using APITemplate.Application.Common.Validation; - -namespace APITemplate.Application.Features.ProductReview.DTOs; - -/// -/// 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 -); diff --git a/absolute/src/APITemplate.Application/Features/ProductReview/DTOs/ProductReviewFilter.cs b/absolute/src/APITemplate.Application/Features/ProductReview/DTOs/ProductReviewFilter.cs deleted file mode 100644 index 5e424fdc..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductReview/DTOs/ProductReviewFilter.cs +++ /dev/null @@ -1,20 +0,0 @@ -using APITemplate.Application.Common.Contracts; -using APITemplate.Application.Common.DTOs; - -namespace APITemplate.Application.Features.ProductReview.DTOs; - -/// -/// 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; diff --git a/absolute/src/APITemplate.Application/Features/ProductReview/DTOs/ProductReviewResponse.cs b/absolute/src/APITemplate.Application/Features/ProductReview/DTOs/ProductReviewResponse.cs deleted file mode 100644 index 25d32a37..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductReview/DTOs/ProductReviewResponse.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace APITemplate.Application.Features.ProductReview.DTOs; - -/// -/// 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/absolute/src/APITemplate.Application/Features/ProductReview/Mappings/ProductReviewMappings.cs b/absolute/src/APITemplate.Application/Features/ProductReview/Mappings/ProductReviewMappings.cs deleted file mode 100644 index aeab6a5c..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductReview/Mappings/ProductReviewMappings.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Linq.Expressions; -using ProductReviewEntity = APITemplate.Domain.Entities.ProductReview; - -namespace APITemplate.Application.Features.ProductReview.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/absolute/src/APITemplate.Application/Features/ProductReview/ProductReviewSortFields.cs b/absolute/src/APITemplate.Application/Features/ProductReview/ProductReviewSortFields.cs deleted file mode 100644 index 02e99adb..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductReview/ProductReviewSortFields.cs +++ /dev/null @@ -1,26 +0,0 @@ -using APITemplate.Application.Common.Sorting; -using ProductReviewEntity = APITemplate.Domain.Entities.ProductReview; - -namespace APITemplate.Application.Features.ProductReview; - -/// -/// 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/absolute/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewByIdQuery.cs b/absolute/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewByIdQuery.cs deleted file mode 100644 index dfce77ee..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewByIdQuery.cs +++ /dev/null @@ -1,23 +0,0 @@ -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Features.ProductReview.Mappings; -using APITemplate.Domain.Interfaces; -using ErrorOr; - -namespace APITemplate.Application.Features.ProductReview; - -/// Returns a single product review by its unique identifier, or if not found. -public sealed record GetProductReviewByIdQuery(Guid Id) : IHasId; - -/// Handles . -public sealed class GetProductReviewByIdQueryHandler -{ - public static async Task> HandleAsync( - GetProductReviewByIdQuery request, - IProductReviewRepository reviewRepository, - CancellationToken ct - ) - { - var item = await reviewRepository.GetByIdAsync(request.Id, ct); - return item is null ? DomainErrors.Reviews.NotFound(request.Id) : item.ToResponse(); - } -} diff --git a/absolute/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewsByProductIdQuery.cs b/absolute/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewsByProductIdQuery.cs deleted file mode 100644 index 60d4dd89..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewsByProductIdQuery.cs +++ /dev/null @@ -1,24 +0,0 @@ -using APITemplate.Application.Features.ProductReview.Specifications; -using APITemplate.Domain.Interfaces; -using ErrorOr; - -namespace APITemplate.Application.Features.ProductReview; - -/// 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 ProductReviewByProductIdSpecification(request.ProductId), - ct - ); - } -} diff --git a/absolute/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewsByProductIdsQuery.cs b/absolute/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewsByProductIdsQuery.cs deleted file mode 100644 index e865c523..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewsByProductIdsQuery.cs +++ /dev/null @@ -1,34 +0,0 @@ -using APITemplate.Application.Features.ProductReview.Specifications; -using APITemplate.Domain.Interfaces; -using ErrorOr; - -namespace APITemplate.Application.Features.ProductReview; - -/// Returns reviews grouped by product id for a batch of product identifiers. -public sealed record GetProductReviewsByProductIdsQuery(IReadOnlyCollection ProductIds); - -/// Handles . -public sealed class GetProductReviewsByProductIdsQueryHandler -{ - public static async Task< - ErrorOr> - > HandleAsync( - GetProductReviewsByProductIdsQuery request, - IProductReviewRepository reviewRepository, - CancellationToken ct - ) - { - if (request.ProductIds.Count == 0) - return (ErrorOr>) - new Dictionary(); - - var reviews = await reviewRepository.ListAsync( - new ProductReviewByProductIdsSpecification(request.ProductIds), - ct - ); - var lookup = reviews.ToLookup(review => review.ProductId); - - return (ErrorOr>) - request.ProductIds.Distinct().ToDictionary(id => id, id => lookup[id].ToArray()); - } -} diff --git a/absolute/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewsQuery.cs b/absolute/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewsQuery.cs deleted file mode 100644 index 7f2f2197..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductReview/Queries/GetProductReviewsQuery.cs +++ /dev/null @@ -1,26 +0,0 @@ -using APITemplate.Application.Features.ProductReview.Specifications; -using APITemplate.Domain.Interfaces; -using ErrorOr; - -namespace APITemplate.Application.Features.ProductReview; - -/// 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/absolute/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewByProductIdSpecification.cs b/absolute/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewByProductIdSpecification.cs deleted file mode 100644 index 2ba9be7b..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewByProductIdSpecification.cs +++ /dev/null @@ -1,22 +0,0 @@ -using APITemplate.Application.Features.ProductReview.Mappings; -using Ardalis.Specification; -using ProductReviewEntity = APITemplate.Domain.Entities.ProductReview; - -namespace APITemplate.Application.Features.ProductReview.Specifications; - -/// -/// Ardalis specification that retrieves all reviews for a single product, ordered by creation date descending, -/// and projected directly to . -/// -public sealed class ProductReviewByProductIdSpecification - : Specification -{ - /// Initialises the specification for the given . - public ProductReviewByProductIdSpecification(Guid productId) - { - Query - .Where(r => r.ProductId == productId) - .OrderByDescending(r => r.Audit.CreatedAtUtc) - .Select(ProductReviewMappings.Projection); - } -} diff --git a/absolute/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewByProductIdsSpecification.cs b/absolute/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewByProductIdsSpecification.cs deleted file mode 100644 index 18c37771..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewByProductIdsSpecification.cs +++ /dev/null @@ -1,22 +0,0 @@ -using APITemplate.Application.Features.ProductReview.Mappings; -using Ardalis.Specification; -using ProductReviewEntity = APITemplate.Domain.Entities.ProductReview; - -namespace APITemplate.Application.Features.ProductReview.Specifications; - -/// -/// 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 ProductReviewByProductIdsSpecification - : Specification -{ - /// Initialises the specification for the given set of . - public ProductReviewByProductIdsSpecification(IReadOnlyCollection productIds) - { - Query - .Where(r => productIds.Contains(r.ProductId)) - .OrderByDescending(r => r.Audit.CreatedAtUtc) - .Select(ProductReviewMappings.Projection); - } -} diff --git a/absolute/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewFilterCriteria.cs b/absolute/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewFilterCriteria.cs deleted file mode 100644 index e9f6dff6..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewFilterCriteria.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Ardalis.Specification; -using ProductReviewEntity = APITemplate.Domain.Entities.ProductReview; - -namespace APITemplate.Application.Features.ProductReview.Specifications; - -/// -/// 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 -{ - /// - /// Appends filter predicates to for each non-null field in , - /// including product id, user id, rating range, and creation date range. - /// - 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/absolute/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewSpecification.cs b/absolute/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewSpecification.cs deleted file mode 100644 index 7498bfe1..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductReview/Specifications/ProductReviewSpecification.cs +++ /dev/null @@ -1,23 +0,0 @@ -using APITemplate.Application.Features.ProductReview.Mappings; -using Ardalis.Specification; -using ProductReviewEntity = APITemplate.Domain.Entities.ProductReview; - -namespace APITemplate.Application.Features.ProductReview.Specifications; - -/// -/// 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); - } -} diff --git a/absolute/src/APITemplate.Application/Features/ProductReview/Validation/CreateProductReviewRequestValidator.cs b/absolute/src/APITemplate.Application/Features/ProductReview/Validation/CreateProductReviewRequestValidator.cs deleted file mode 100644 index 6df9bb44..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductReview/Validation/CreateProductReviewRequestValidator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using APITemplate.Application.Common.Validation; - -namespace APITemplate.Application.Features.ProductReview.Validation; - -/// -/// FluentValidation validator for , delegating to data-annotation-based validation rules. -/// -public sealed class CreateProductReviewRequestValidator - : DataAnnotationsValidator; diff --git a/absolute/src/APITemplate.Application/Features/ProductReview/Validation/ProductReviewFilterValidator.cs b/absolute/src/APITemplate.Application/Features/ProductReview/Validation/ProductReviewFilterValidator.cs deleted file mode 100644 index e1349481..00000000 --- a/absolute/src/APITemplate.Application/Features/ProductReview/Validation/ProductReviewFilterValidator.cs +++ /dev/null @@ -1,37 +0,0 @@ -using APITemplate.Application.Common.Validation; -using FluentValidation; - -namespace APITemplate.Application.Features.ProductReview.Validation; - -/// -/// 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/absolute/src/APITemplate.Application/Features/Tenant/Commands/CreateTenantCommand.cs b/absolute/src/APITemplate.Application/Features/Tenant/Commands/CreateTenantCommand.cs deleted file mode 100644 index 795cc1b9..00000000 --- a/absolute/src/APITemplate.Application/Features/Tenant/Commands/CreateTenantCommand.cs +++ /dev/null @@ -1,48 +0,0 @@ -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Features.Tenant.DTOs; -using APITemplate.Application.Features.Tenant.Mappings; -using APITemplate.Domain.Interfaces; -using ErrorOr; -using Wolverine; -using TenantEntity = APITemplate.Domain.Entities.Tenant; - -namespace APITemplate.Application.Features.Tenant; - -public sealed record CreateTenantCommand(CreateTenantRequest Request); - -public sealed class CreateTenantCommandHandler -{ - public static async Task> HandleAsync( - CreateTenantCommand command, - ITenantRepository repository, - IUnitOfWork unitOfWork, - IMessageBus bus, - CancellationToken ct - ) - { - if (await repository.CodeExistsAsync(command.Request.Code, ct)) - return DomainErrors.Tenants.CodeAlreadyExists(command.Request.Code); - - var tenant = await unitOfWork.ExecuteInTransactionAsync( - async () => - { - var id = Guid.NewGuid(); - var entity = new TenantEntity - { - Id = id, - TenantId = id, - Code = command.Request.Code, - Name = command.Request.Name, - }; - - await repository.AddAsync(entity, ct); - return entity; - }, - ct - ); - - await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.Tenants)); - return tenant.ToResponse(); - } -} diff --git a/absolute/src/APITemplate.Application/Features/Tenant/Commands/DeleteTenantCommand.cs b/absolute/src/APITemplate.Application/Features/Tenant/Commands/DeleteTenantCommand.cs deleted file mode 100644 index 456a1126..00000000 --- a/absolute/src/APITemplate.Application/Features/Tenant/Commands/DeleteTenantCommand.cs +++ /dev/null @@ -1,55 +0,0 @@ -using APITemplate.Application.Common.Context; -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Common.Extensions; -using APITemplate.Domain.Interfaces; -using ErrorOr; -using Microsoft.Extensions.Logging; -using Wolverine; - -namespace APITemplate.Application.Features.Tenant; - -public sealed record DeleteTenantCommand(Guid Id) : IHasId; - -public sealed class DeleteTenantCommandHandler -{ - public static async Task> HandleAsync( - DeleteTenantCommand command, - ITenantRepository repository, - IUnitOfWork unitOfWork, - IMessageBus bus, - IActorProvider actorProvider, - TimeProvider timeProvider, - ILogger logger, - CancellationToken ct - ) - { - var tenantResult = await repository.GetByIdOrError( - command.Id, - DomainErrors.Tenants.NotFound(command.Id), - ct - ); - if (tenantResult.IsError) - return tenantResult.Errors; - - await unitOfWork.ExecuteInTransactionAsync( - async () => - { - await repository.DeleteAsync(tenantResult.Value, ct); - }, - ct - ); - - await bus.PublishSafeAsync( - new TenantSoftDeletedNotification( - command.Id, - actorProvider.ActorId, - timeProvider.GetUtcNow().UtcDateTime - ), - logger - ); - - await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.Tenants)); - return Result.Success; - } -} diff --git a/absolute/src/APITemplate.Application/Features/Tenant/DTOs/CreateTenantRequest.cs b/absolute/src/APITemplate.Application/Features/Tenant/DTOs/CreateTenantRequest.cs deleted file mode 100644 index 479588d9..00000000 --- a/absolute/src/APITemplate.Application/Features/Tenant/DTOs/CreateTenantRequest.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Tenant/DTOs/TenantFilter.cs b/absolute/src/APITemplate.Application/Features/Tenant/DTOs/TenantFilter.cs deleted file mode 100644 index 3d314ab5..00000000 --- a/absolute/src/APITemplate.Application/Features/Tenant/DTOs/TenantFilter.cs +++ /dev/null @@ -1,15 +0,0 @@ -using APITemplate.Application.Common.Contracts; -using APITemplate.Application.Common.DTOs; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Tenant/DTOs/TenantResponse.cs b/absolute/src/APITemplate.Application/Features/Tenant/DTOs/TenantResponse.cs deleted file mode 100644 index 9241f49f..00000000 --- a/absolute/src/APITemplate.Application/Features/Tenant/DTOs/TenantResponse.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Tenant/Mappings/TenantMappings.cs b/absolute/src/APITemplate.Application/Features/Tenant/Mappings/TenantMappings.cs deleted file mode 100644 index ad194ae7..00000000 --- a/absolute/src/APITemplate.Application/Features/Tenant/Mappings/TenantMappings.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Linq.Expressions; -using APITemplate.Application.Features.Tenant.DTOs; -using TenantEntity = APITemplate.Domain.Entities.Tenant; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Tenant/Queries/GetTenantByIdQuery.cs b/absolute/src/APITemplate.Application/Features/Tenant/Queries/GetTenantByIdQuery.cs deleted file mode 100644 index c47d17ec..00000000 --- a/absolute/src/APITemplate.Application/Features/Tenant/Queries/GetTenantByIdQuery.cs +++ /dev/null @@ -1,29 +0,0 @@ -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Features.Tenant.DTOs; -using APITemplate.Application.Features.Tenant.Specifications; -using APITemplate.Domain.Entities.Contracts; -using APITemplate.Domain.Interfaces; -using ErrorOr; - -namespace APITemplate.Application.Features.Tenant; - -public sealed record GetTenantByIdQuery(Guid Id) : IHasId; - -public sealed class GetTenantByIdQueryHandler -{ - public static async Task> HandleAsync( - GetTenantByIdQuery request, - ITenantRepository repository, - CancellationToken ct - ) - { - var result = await repository.FirstOrDefaultAsync( - new TenantByIdSpecification(request.Id), - ct - ); - if (result is null) - return DomainErrors.Tenants.NotFound(request.Id); - - return result; - } -} diff --git a/absolute/src/APITemplate.Application/Features/Tenant/Queries/GetTenantsQuery.cs b/absolute/src/APITemplate.Application/Features/Tenant/Queries/GetTenantsQuery.cs deleted file mode 100644 index 08316223..00000000 --- a/absolute/src/APITemplate.Application/Features/Tenant/Queries/GetTenantsQuery.cs +++ /dev/null @@ -1,25 +0,0 @@ -using APITemplate.Application.Features.Tenant.DTOs; -using APITemplate.Application.Features.Tenant.Specifications; -using APITemplate.Domain.Interfaces; -using ErrorOr; - -namespace APITemplate.Application.Features.Tenant; - -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/absolute/src/APITemplate.Application/Features/Tenant/Specifications/TenantByIdSpecification.cs b/absolute/src/APITemplate.Application/Features/Tenant/Specifications/TenantByIdSpecification.cs deleted file mode 100644 index cd11cce8..00000000 --- a/absolute/src/APITemplate.Application/Features/Tenant/Specifications/TenantByIdSpecification.cs +++ /dev/null @@ -1,20 +0,0 @@ -using APITemplate.Application.Features.Tenant.DTOs; -using APITemplate.Application.Features.Tenant.Mappings; -using Ardalis.Specification; -using TenantEntity = APITemplate.Domain.Entities.Tenant; - -namespace APITemplate.Application.Features.Tenant.Specifications; - -/// -/// Ardalis specification that fetches a single tenant by ID and projects it to . -/// -public sealed class TenantByIdSpecification : Specification -{ - /// - /// Initialises the specification to match the tenant with the given and apply the response projection. - /// - public TenantByIdSpecification(Guid id) - { - Query.Where(tenant => tenant.Id == id).AsNoTracking().Select(TenantMappings.Projection); - } -} diff --git a/absolute/src/APITemplate.Application/Features/Tenant/Specifications/TenantFilterCriteria.cs b/absolute/src/APITemplate.Application/Features/Tenant/Specifications/TenantFilterCriteria.cs deleted file mode 100644 index fe5be69b..00000000 --- a/absolute/src/APITemplate.Application/Features/Tenant/Specifications/TenantFilterCriteria.cs +++ /dev/null @@ -1,38 +0,0 @@ -using APITemplate.Application.Common.Search; -using APITemplate.Application.Features.Tenant.DTOs; -using Ardalis.Specification; -using Microsoft.EntityFrameworkCore; -using TenantEntity = APITemplate.Domain.Entities.Tenant; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Tenant/Specifications/TenantSpecification.cs b/absolute/src/APITemplate.Application/Features/Tenant/Specifications/TenantSpecification.cs deleted file mode 100644 index 09f40a33..00000000 --- a/absolute/src/APITemplate.Application/Features/Tenant/Specifications/TenantSpecification.cs +++ /dev/null @@ -1,23 +0,0 @@ -using APITemplate.Application.Features.Tenant.DTOs; -using APITemplate.Application.Features.Tenant.Mappings; -using Ardalis.Specification; -using TenantEntity = APITemplate.Domain.Entities.Tenant; - -namespace APITemplate.Application.Features.Tenant.Specifications; - -/// -/// Ardalis specification that retrieves a filtered and sorted list of tenants projected to . -/// -public sealed class TenantSpecification : Specification -{ - /// - /// Initialises the specification by applying filter criteria, sort order, and projection from the given . - /// - public TenantSpecification(TenantFilter filter) - { - Query.ApplyFilter(filter); - Query.AsNoTracking(); - TenantSortFields.Map.ApplySort(Query, filter.SortBy, filter.SortDirection); - Query.Select(TenantMappings.Projection); - } -} diff --git a/absolute/src/APITemplate.Application/Features/Tenant/TenantSortFields.cs b/absolute/src/APITemplate.Application/Features/Tenant/TenantSortFields.cs deleted file mode 100644 index 266143bc..00000000 --- a/absolute/src/APITemplate.Application/Features/Tenant/TenantSortFields.cs +++ /dev/null @@ -1,20 +0,0 @@ -using APITemplate.Application.Common.Sorting; -using TenantEntity = APITemplate.Domain.Entities.Tenant; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/Tenant/Validation/CreateTenantRequestValidator.cs b/absolute/src/APITemplate.Application/Features/Tenant/Validation/CreateTenantRequestValidator.cs deleted file mode 100644 index 26052588..00000000 --- a/absolute/src/APITemplate.Application/Features/Tenant/Validation/CreateTenantRequestValidator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using APITemplate.Application.Common.Validation; -using APITemplate.Application.Features.Tenant.DTOs; - -namespace APITemplate.Application.Features.Tenant.Validation; - -/// -/// FluentValidation validator for that enforces data-annotation constraints. -/// -public sealed class CreateTenantRequestValidator : DataAnnotationsValidator; diff --git a/absolute/src/APITemplate.Application/Features/Tenant/Validation/TenantFilterValidator.cs b/absolute/src/APITemplate.Application/Features/Tenant/Validation/TenantFilterValidator.cs deleted file mode 100644 index 9776ceda..00000000 --- a/absolute/src/APITemplate.Application/Features/Tenant/Validation/TenantFilterValidator.cs +++ /dev/null @@ -1,20 +0,0 @@ -using APITemplate.Application.Common.Validation; -using APITemplate.Application.Features.Tenant.DTOs; -using FluentValidation; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/TenantInvitation/Commands/AcceptTenantInvitationCommand.cs b/absolute/src/APITemplate.Application/Features/TenantInvitation/Commands/AcceptTenantInvitationCommand.cs deleted file mode 100644 index dc589cd6..00000000 --- a/absolute/src/APITemplate.Application/Features/TenantInvitation/Commands/AcceptTenantInvitationCommand.cs +++ /dev/null @@ -1,46 +0,0 @@ -using APITemplate.Application.Common.Email; -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Common.Events; -using APITemplate.Domain.Enums; -using APITemplate.Domain.Interfaces; -using ErrorOr; -using Wolverine; - -namespace APITemplate.Application.Features.TenantInvitation; - -public sealed record AcceptTenantInvitationCommand(string Token); - -public sealed class AcceptTenantInvitationCommandHandler -{ - public static async Task> HandleAsync( - AcceptTenantInvitationCommand command, - ITenantInvitationRepository invitationRepository, - IUnitOfWork unitOfWork, - ISecureTokenGenerator tokenGenerator, - IMessageBus bus, - TimeProvider timeProvider, - CancellationToken ct - ) - { - var tokenHash = tokenGenerator.HashToken(command.Token); - var invitation = await invitationRepository.GetValidByTokenHashAsync(tokenHash, ct); - - if (invitation is null) - return DomainErrors.Invitations.NotFoundOrExpired(); - - var 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); - - await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.TenantInvitations)); - return Result.Success; - } -} diff --git a/absolute/src/APITemplate.Application/Features/TenantInvitation/Commands/CreateTenantInvitationCommand.cs b/absolute/src/APITemplate.Application/Features/TenantInvitation/Commands/CreateTenantInvitationCommand.cs deleted file mode 100644 index b2c38289..00000000 --- a/absolute/src/APITemplate.Application/Features/TenantInvitation/Commands/CreateTenantInvitationCommand.cs +++ /dev/null @@ -1,82 +0,0 @@ -using APITemplate.Application.Common.Context; -using APITemplate.Application.Common.Email; -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Common.Extensions; -using APITemplate.Application.Common.Options; -using APITemplate.Application.Features.TenantInvitation.DTOs; -using APITemplate.Application.Features.TenantInvitation.Mappings; -using APITemplate.Domain.Entities; -using APITemplate.Domain.Interfaces; -using ErrorOr; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Wolverine; -using TenantInvitationEntity = APITemplate.Domain.Entities.TenantInvitation; - -namespace APITemplate.Application.Features.TenantInvitation; - -public sealed record CreateTenantInvitationCommand(CreateTenantInvitationRequest Request); - -public sealed class CreateTenantInvitationCommandHandler -{ - public static async Task> HandleAsync( - CreateTenantInvitationCommand command, - ITenantInvitationRepository invitationRepository, - ITenantRepository tenantRepository, - IUnitOfWork unitOfWork, - ISecureTokenGenerator tokenGenerator, - IMessageBus bus, - ITenantProvider tenantProvider, - TimeProvider timeProvider, - IOptions emailOptions, - ILogger logger, - CancellationToken ct - ) - { - var emailOpts = emailOptions.Value; - var normalizedEmail = AppUser.NormalizeEmail(command.Request.Email); - - if (await invitationRepository.HasPendingInvitationAsync(normalizedEmail, ct)) - return DomainErrors.Invitations.AlreadyPending(command.Request.Email); - - var tenantResult = await tenantRepository.GetByIdOrError( - tenantProvider.TenantId, - DomainErrors.Tenants.NotFound(tenantProvider.TenantId), - ct - ); - if (tenantResult.IsError) - return tenantResult.Errors; - var tenant = tenantResult.Value; - - var rawToken = tokenGenerator.GenerateToken(); - var tokenHash = tokenGenerator.HashToken(rawToken); - - var invitation = new TenantInvitationEntity - { - Id = Guid.NewGuid(), - Email = command.Request.Email.Trim(), - NormalizedEmail = normalizedEmail, - TokenHash = tokenHash, - ExpiresAtUtc = timeProvider - .GetUtcNow() - .UtcDateTime.AddHours(emailOpts.InvitationTokenExpiryHours), - }; - - await invitationRepository.AddAsync(invitation, ct); - await unitOfWork.CommitAsync(ct); - - await bus.PublishSafeAsync( - new TenantInvitationCreatedNotification( - invitation.Id, - invitation.Email, - tenant.Name, - rawToken - ), - logger - ); - - await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.TenantInvitations)); - return invitation.ToResponse(); - } -} diff --git a/absolute/src/APITemplate.Application/Features/TenantInvitation/Commands/ResendTenantInvitationCommand.cs b/absolute/src/APITemplate.Application/Features/TenantInvitation/Commands/ResendTenantInvitationCommand.cs deleted file mode 100644 index e4b62438..00000000 --- a/absolute/src/APITemplate.Application/Features/TenantInvitation/Commands/ResendTenantInvitationCommand.cs +++ /dev/null @@ -1,75 +0,0 @@ -using APITemplate.Application.Common.Context; -using APITemplate.Application.Common.Email; -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Common.Extensions; -using APITemplate.Domain.Enums; -using APITemplate.Domain.Interfaces; -using ErrorOr; -using Microsoft.Extensions.Logging; -using Wolverine; - -namespace APITemplate.Application.Features.TenantInvitation; - -public sealed record ResendTenantInvitationCommand(Guid InvitationId); - -public sealed class ResendTenantInvitationCommandHandler -{ - public static async Task> HandleAsync( - ResendTenantInvitationCommand command, - ITenantInvitationRepository invitationRepository, - ITenantRepository tenantRepository, - IUnitOfWork unitOfWork, - ISecureTokenGenerator tokenGenerator, - IMessageBus bus, - ITenantProvider tenantProvider, - TimeProvider timeProvider, - ILogger logger, - CancellationToken ct - ) - { - var invitationResult = await invitationRepository.GetByIdOrError( - command.InvitationId, - DomainErrors.Invitations.NotFound(command.InvitationId), - ct - ); - if (invitationResult.IsError) - return invitationResult.Errors; - var invitation = invitationResult.Value; - - if (invitation.Status != InvitationStatus.Pending) - return DomainErrors.Invitations.NotPending(); - - var now = timeProvider.GetUtcNow().UtcDateTime; - if (invitation.ExpiresAtUtc < now) - return DomainErrors.Invitations.ExpiredCreateNew(); - - var tenantResult = await tenantRepository.GetByIdOrError( - tenantProvider.TenantId, - DomainErrors.Tenants.NotFound(tenantProvider.TenantId), - ct - ); - if (tenantResult.IsError) - return tenantResult.Errors; - var tenant = tenantResult.Value; - - var rawToken = tokenGenerator.GenerateToken(); - invitation.TokenHash = tokenGenerator.HashToken(rawToken); - - await invitationRepository.UpdateAsync(invitation, ct); - await unitOfWork.CommitAsync(ct); - - await bus.PublishSafeAsync( - new TenantInvitationCreatedNotification( - invitation.Id, - invitation.Email, - tenant.Name, - rawToken - ), - logger - ); - - await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.TenantInvitations)); - return Result.Success; - } -} diff --git a/absolute/src/APITemplate.Application/Features/TenantInvitation/Commands/RevokeTenantInvitationCommand.cs b/absolute/src/APITemplate.Application/Features/TenantInvitation/Commands/RevokeTenantInvitationCommand.cs deleted file mode 100644 index 315cbe0b..00000000 --- a/absolute/src/APITemplate.Application/Features/TenantInvitation/Commands/RevokeTenantInvitationCommand.cs +++ /dev/null @@ -1,39 +0,0 @@ -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Common.Extensions; -using APITemplate.Domain.Enums; -using APITemplate.Domain.Interfaces; -using ErrorOr; -using Wolverine; - -namespace APITemplate.Application.Features.TenantInvitation; - -public sealed record RevokeTenantInvitationCommand(Guid InvitationId); - -public sealed class RevokeTenantInvitationCommandHandler -{ - public static async Task> HandleAsync( - RevokeTenantInvitationCommand command, - ITenantInvitationRepository invitationRepository, - IUnitOfWork unitOfWork, - IMessageBus bus, - CancellationToken ct - ) - { - var invitationResult = await invitationRepository.GetByIdOrError( - command.InvitationId, - DomainErrors.Invitations.NotFound(command.InvitationId), - ct - ); - if (invitationResult.IsError) - return invitationResult.Errors; - var invitation = invitationResult.Value; - - invitation.Status = InvitationStatus.Revoked; - await invitationRepository.UpdateAsync(invitation, ct); - await unitOfWork.CommitAsync(ct); - - await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.TenantInvitations)); - return Result.Success; - } -} diff --git a/absolute/src/APITemplate.Application/Features/TenantInvitation/DTOs/AcceptInvitationRequest.cs b/absolute/src/APITemplate.Application/Features/TenantInvitation/DTOs/AcceptInvitationRequest.cs deleted file mode 100644 index facf6952..00000000 --- a/absolute/src/APITemplate.Application/Features/TenantInvitation/DTOs/AcceptInvitationRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -using APITemplate.Application.Common.Validation; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/TenantInvitation/DTOs/CreateTenantInvitationRequest.cs b/absolute/src/APITemplate.Application/Features/TenantInvitation/DTOs/CreateTenantInvitationRequest.cs deleted file mode 100644 index 53c0fba3..00000000 --- a/absolute/src/APITemplate.Application/Features/TenantInvitation/DTOs/CreateTenantInvitationRequest.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using APITemplate.Application.Common.Validation; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/TenantInvitation/DTOs/TenantInvitationFilter.cs b/absolute/src/APITemplate.Application/Features/TenantInvitation/DTOs/TenantInvitationFilter.cs deleted file mode 100644 index 1a497afe..00000000 --- a/absolute/src/APITemplate.Application/Features/TenantInvitation/DTOs/TenantInvitationFilter.cs +++ /dev/null @@ -1,14 +0,0 @@ -using APITemplate.Application.Common.DTOs; -using APITemplate.Domain.Enums; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/TenantInvitation/DTOs/TenantInvitationResponse.cs b/absolute/src/APITemplate.Application/Features/TenantInvitation/DTOs/TenantInvitationResponse.cs deleted file mode 100644 index ab10c1d5..00000000 --- a/absolute/src/APITemplate.Application/Features/TenantInvitation/DTOs/TenantInvitationResponse.cs +++ /dev/null @@ -1,14 +0,0 @@ -using APITemplate.Domain.Enums; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/TenantInvitation/Mappings/TenantInvitationMappings.cs b/absolute/src/APITemplate.Application/Features/TenantInvitation/Mappings/TenantInvitationMappings.cs deleted file mode 100644 index b555e040..00000000 --- a/absolute/src/APITemplate.Application/Features/TenantInvitation/Mappings/TenantInvitationMappings.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Linq.Expressions; -using APITemplate.Application.Features.TenantInvitation.DTOs; -using TenantInvitationEntity = APITemplate.Domain.Entities.TenantInvitation; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/TenantInvitation/Queries/GetTenantInvitationsQuery.cs b/absolute/src/APITemplate.Application/Features/TenantInvitation/Queries/GetTenantInvitationsQuery.cs deleted file mode 100644 index a8733b2e..00000000 --- a/absolute/src/APITemplate.Application/Features/TenantInvitation/Queries/GetTenantInvitationsQuery.cs +++ /dev/null @@ -1,25 +0,0 @@ -using APITemplate.Application.Features.TenantInvitation.DTOs; -using APITemplate.Application.Features.TenantInvitation.Specifications; -using APITemplate.Domain.Interfaces; -using ErrorOr; - -namespace APITemplate.Application.Features.TenantInvitation; - -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/absolute/src/APITemplate.Application/Features/TenantInvitation/Specifications/TenantInvitationFilterSpecification.cs b/absolute/src/APITemplate.Application/Features/TenantInvitation/Specifications/TenantInvitationFilterSpecification.cs deleted file mode 100644 index 96e1e6d2..00000000 --- a/absolute/src/APITemplate.Application/Features/TenantInvitation/Specifications/TenantInvitationFilterSpecification.cs +++ /dev/null @@ -1,49 +0,0 @@ -using APITemplate.Application.Features.TenantInvitation.DTOs; -using APITemplate.Application.Features.TenantInvitation.Mappings; -using APITemplate.Domain.Entities; -using Ardalis.Specification; -using TenantInvitationEntity = APITemplate.Domain.Entities.TenantInvitation; - -namespace APITemplate.Application.Features.TenantInvitation.Specifications; - -/// -/// Ardalis specification that retrieves a filtered list of tenant invitations projected to . -/// -public sealed class TenantInvitationFilterSpecification - : Specification -{ - /// - /// Initialises the specification by applying filter criteria, descending creation-date ordering, and projection. - /// - 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 -{ - /// - /// Adds optional email (normalised, case-insensitive contains) and status equality predicates to the query. - /// - public static void ApplyFilter( - this ISpecificationBuilder query, - TenantInvitationFilter filter - ) - { - if (!string.IsNullOrWhiteSpace(filter.Email)) - { - var normalized = AppUser.NormalizeEmail(filter.Email); - query.Where(i => i.Email.ToUpper().Contains(normalized)); - } - - if (filter.Status.HasValue) - query.Where(i => i.Status == filter.Status.Value); - } -} diff --git a/absolute/src/APITemplate.Application/Features/TenantInvitation/Validation/CreateTenantInvitationRequestValidator.cs b/absolute/src/APITemplate.Application/Features/TenantInvitation/Validation/CreateTenantInvitationRequestValidator.cs deleted file mode 100644 index 86943c7a..00000000 --- a/absolute/src/APITemplate.Application/Features/TenantInvitation/Validation/CreateTenantInvitationRequestValidator.cs +++ /dev/null @@ -1,10 +0,0 @@ -using APITemplate.Application.Common.Validation; -using APITemplate.Application.Features.TenantInvitation.DTOs; - -namespace APITemplate.Application.Features.TenantInvitation.Validation; - -/// -/// FluentValidation validator for that enforces data-annotation constraints. -/// -public sealed class CreateTenantInvitationRequestValidator - : DataAnnotationsValidator; diff --git a/absolute/src/APITemplate.Application/Features/User/Commands/ChangeUserRoleCommand.cs b/absolute/src/APITemplate.Application/Features/User/Commands/ChangeUserRoleCommand.cs deleted file mode 100644 index b644e675..00000000 --- a/absolute/src/APITemplate.Application/Features/User/Commands/ChangeUserRoleCommand.cs +++ /dev/null @@ -1,54 +0,0 @@ -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Common.Extensions; -using APITemplate.Application.Features.User.DTOs; -using APITemplate.Domain.Interfaces; -using ErrorOr; -using Microsoft.Extensions.Logging; -using Wolverine; - -namespace APITemplate.Application.Features.User; - -public sealed record ChangeUserRoleCommand(Guid Id, ChangeUserRoleRequest Request) : IHasId; - -public sealed class ChangeUserRoleCommandHandler -{ - public static async Task> HandleAsync( - ChangeUserRoleCommand command, - IUserRepository repository, - IUnitOfWork unitOfWork, - IMessageBus bus, - ILogger logger, - CancellationToken ct - ) - { - var userResult = await repository.GetByIdOrError( - command.Id, - DomainErrors.Users.NotFound(command.Id), - ct - ); - if (userResult.IsError) - return userResult.Errors; - var user = userResult.Value; - - var oldRole = user.Role.ToString(); - - user.Role = command.Request.Role; - await repository.UpdateAsync(user, ct); - await unitOfWork.CommitAsync(ct); - - await bus.PublishSafeAsync( - new UserRoleChangedNotification( - user.Id, - user.Email, - user.Username, - oldRole, - command.Request.Role.ToString() - ), - logger - ); - - await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.Users)); - return Result.Success; - } -} diff --git a/absolute/src/APITemplate.Application/Features/User/Commands/CreateUserCommand.cs b/absolute/src/APITemplate.Application/Features/User/Commands/CreateUserCommand.cs deleted file mode 100644 index 689f09ef..00000000 --- a/absolute/src/APITemplate.Application/Features/User/Commands/CreateUserCommand.cs +++ /dev/null @@ -1,93 +0,0 @@ -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Common.Security; -using APITemplate.Application.Features.User.DTOs; -using APITemplate.Application.Features.User.Mappings; -using APITemplate.Domain.Entities; -using APITemplate.Domain.Interfaces; -using ErrorOr; -using Microsoft.Extensions.Logging; -using Wolverine; - -namespace APITemplate.Application.Features.User; - -public sealed record CreateUserCommand(CreateUserRequest Request); - -public sealed class CreateUserCommandHandler -{ - public static async Task> HandleAsync( - CreateUserCommand command, - IUserRepository repository, - IUnitOfWork unitOfWork, - IMessageBus bus, - ILogger logger, - IKeycloakAdminService keycloakAdmin, - CancellationToken ct - ) - { - var emailResult = await UserValidationHelper.ValidateEmailUniqueAsync( - repository, - command.Request.Email, - ct - ); - if (emailResult.IsError) - return emailResult.Errors; - - var usernameResult = await UserValidationHelper.ValidateUsernameUniqueAsync( - repository, - command.Request.Username, - ct - ); - if (usernameResult.IsError) - return usernameResult.Errors; - - var keycloakUserId = await keycloakAdmin.CreateUserAsync( - command.Request.Username, - command.Request.Email, - ct - ); - - try - { - var user = new AppUser - { - Id = Guid.NewGuid(), - Username = command.Request.Username, - Email = command.Request.Email, - KeycloakUserId = keycloakUserId, - }; - - await repository.AddAsync(user, ct); - await unitOfWork.CommitAsync(ct); - - await bus.PublishSafeAsync( - new UserRegisteredNotification(user.Id, user.Email, user.Username), - logger - ); - - await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.Users)); - return user.ToResponse(); - } - 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/absolute/src/APITemplate.Application/Features/User/Commands/DeleteUserCommand.cs b/absolute/src/APITemplate.Application/Features/User/Commands/DeleteUserCommand.cs deleted file mode 100644 index 0e3c1bd8..00000000 --- a/absolute/src/APITemplate.Application/Features/User/Commands/DeleteUserCommand.cs +++ /dev/null @@ -1,56 +0,0 @@ -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Common.Extensions; -using APITemplate.Application.Common.Security; -using APITemplate.Domain.Interfaces; -using ErrorOr; -using Microsoft.Extensions.Logging; -using Wolverine; - -namespace APITemplate.Application.Features.User; - -public sealed record DeleteUserCommand(Guid Id) : IHasId; - -public sealed class DeleteUserCommandHandler -{ - public static async Task> HandleAsync( - DeleteUserCommand command, - IUserRepository repository, - IUnitOfWork unitOfWork, - IMessageBus bus, - IKeycloakAdminService keycloakAdmin, - ILogger logger, - CancellationToken ct - ) - { - var userResult = await repository.GetByIdOrError( - command.Id, - DomainErrors.Users.NotFound(command.Id), - ct - ); - if (userResult.IsError) - return userResult.Errors; - var 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; - } - - await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.Users)); - return Result.Success; - } -} diff --git a/absolute/src/APITemplate.Application/Features/User/Commands/KeycloakPasswordResetCommand.cs b/absolute/src/APITemplate.Application/Features/User/Commands/KeycloakPasswordResetCommand.cs deleted file mode 100644 index 1ae560cf..00000000 --- a/absolute/src/APITemplate.Application/Features/User/Commands/KeycloakPasswordResetCommand.cs +++ /dev/null @@ -1,41 +0,0 @@ -using APITemplate.Application.Common.Security; -using APITemplate.Application.Features.User.DTOs; -using APITemplate.Domain.Interfaces; -using ErrorOr; -using Microsoft.Extensions.Logging; - -namespace APITemplate.Application.Features.User; - -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 - ) - { - var 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/absolute/src/APITemplate.Application/Features/User/Commands/SetUserActiveCommand.cs b/absolute/src/APITemplate.Application/Features/User/Commands/SetUserActiveCommand.cs deleted file mode 100644 index fd13eb46..00000000 --- a/absolute/src/APITemplate.Application/Features/User/Commands/SetUserActiveCommand.cs +++ /dev/null @@ -1,43 +0,0 @@ -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Common.Extensions; -using APITemplate.Application.Common.Security; -using APITemplate.Domain.Interfaces; -using ErrorOr; -using Wolverine; - -namespace APITemplate.Application.Features.User; - -public sealed record SetUserActiveCommand(Guid Id, bool IsActive) : IHasId; - -public sealed class SetUserActiveCommandHandler -{ - public static async Task> HandleAsync( - SetUserActiveCommand command, - IUserRepository repository, - IUnitOfWork unitOfWork, - IMessageBus bus, - IKeycloakAdminService keycloakAdmin, - CancellationToken ct - ) - { - var userResult = await repository.GetByIdOrError( - command.Id, - DomainErrors.Users.NotFound(command.Id), - ct - ); - if (userResult.IsError) - return userResult.Errors; - var 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); - - await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.Users)); - return Result.Success; - } -} diff --git a/absolute/src/APITemplate.Application/Features/User/Commands/UpdateUserCommand.cs b/absolute/src/APITemplate.Application/Features/User/Commands/UpdateUserCommand.cs deleted file mode 100644 index f5a3890b..00000000 --- a/absolute/src/APITemplate.Application/Features/User/Commands/UpdateUserCommand.cs +++ /dev/null @@ -1,65 +0,0 @@ -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Common.Events; -using APITemplate.Application.Common.Extensions; -using APITemplate.Application.Features.User.DTOs; -using APITemplate.Domain.Entities; -using APITemplate.Domain.Interfaces; -using ErrorOr; -using Wolverine; - -namespace APITemplate.Application.Features.User; - -public sealed record UpdateUserCommand(Guid Id, UpdateUserRequest Request) : IHasId; - -public sealed class UpdateUserCommandHandler -{ - public static async Task> HandleAsync( - UpdateUserCommand command, - IUserRepository repository, - IUnitOfWork unitOfWork, - IMessageBus bus, - CancellationToken ct - ) - { - var userResult = await repository.GetByIdOrError( - command.Id, - DomainErrors.Users.NotFound(command.Id), - ct - ); - if (userResult.IsError) - return userResult.Errors; - var user = userResult.Value; - - if (!string.Equals(user.Email, command.Request.Email, StringComparison.OrdinalIgnoreCase)) - { - var emailResult = await UserValidationHelper.ValidateEmailUniqueAsync( - repository, - command.Request.Email, - ct - ); - if (emailResult.IsError) - return emailResult.Errors; - } - - var normalizedNew = AppUser.NormalizeUsername(command.Request.Username); - if (!string.Equals(user.NormalizedUsername, normalizedNew, StringComparison.Ordinal)) - { - var usernameResult = await UserValidationHelper.ValidateUsernameUniqueAsync( - repository, - command.Request.Username, - ct - ); - if (usernameResult.IsError) - return usernameResult.Errors; - } - - user.Username = command.Request.Username; - user.Email = command.Request.Email; - - await repository.UpdateAsync(user, ct); - await unitOfWork.CommitAsync(ct); - - await bus.PublishAsync(new CacheInvalidationNotification(CacheTags.Users)); - return Result.Success; - } -} diff --git a/absolute/src/APITemplate.Application/Features/User/DTOs/ChangeUserRoleRequest.cs b/absolute/src/APITemplate.Application/Features/User/DTOs/ChangeUserRoleRequest.cs deleted file mode 100644 index 463990b6..00000000 --- a/absolute/src/APITemplate.Application/Features/User/DTOs/ChangeUserRoleRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -using APITemplate.Domain.Enums; - -namespace APITemplate.Application.Features.User.DTOs; - -/// -/// Represents the request payload for changing a user's role. -/// -public sealed record ChangeUserRoleRequest(UserRole Role); diff --git a/absolute/src/APITemplate.Application/Features/User/DTOs/CreateUserRequest.cs b/absolute/src/APITemplate.Application/Features/User/DTOs/CreateUserRequest.cs deleted file mode 100644 index b5b08a1d..00000000 --- a/absolute/src/APITemplate.Application/Features/User/DTOs/CreateUserRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using APITemplate.Application.Common.Validation; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/User/DTOs/RequestPasswordResetRequest.cs b/absolute/src/APITemplate.Application/Features/User/DTOs/RequestPasswordResetRequest.cs deleted file mode 100644 index 40b088fe..00000000 --- a/absolute/src/APITemplate.Application/Features/User/DTOs/RequestPasswordResetRequest.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using APITemplate.Application.Common.Validation; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/User/DTOs/UpdateUserRequest.cs b/absolute/src/APITemplate.Application/Features/User/DTOs/UpdateUserRequest.cs deleted file mode 100644 index d2c77acc..00000000 --- a/absolute/src/APITemplate.Application/Features/User/DTOs/UpdateUserRequest.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using APITemplate.Application.Common.Validation; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/User/DTOs/UserFilter.cs b/absolute/src/APITemplate.Application/Features/User/DTOs/UserFilter.cs deleted file mode 100644 index 91df4ddc..00000000 --- a/absolute/src/APITemplate.Application/Features/User/DTOs/UserFilter.cs +++ /dev/null @@ -1,19 +0,0 @@ -using APITemplate.Application.Common.Contracts; -using APITemplate.Application.Common.DTOs; -using APITemplate.Domain.Enums; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/User/DTOs/UserResponse.cs b/absolute/src/APITemplate.Application/Features/User/DTOs/UserResponse.cs deleted file mode 100644 index f6512a54..00000000 --- a/absolute/src/APITemplate.Application/Features/User/DTOs/UserResponse.cs +++ /dev/null @@ -1,15 +0,0 @@ -using APITemplate.Domain.Enums; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/User/Mappings/UserMappings.cs b/absolute/src/APITemplate.Application/Features/User/Mappings/UserMappings.cs deleted file mode 100644 index 13d53922..00000000 --- a/absolute/src/APITemplate.Application/Features/User/Mappings/UserMappings.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Linq.Expressions; -using APITemplate.Application.Features.User.DTOs; -using APITemplate.Domain.Entities; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/User/Queries/GetUserByIdQuery.cs b/absolute/src/APITemplate.Application/Features/User/Queries/GetUserByIdQuery.cs deleted file mode 100644 index d224826a..00000000 --- a/absolute/src/APITemplate.Application/Features/User/Queries/GetUserByIdQuery.cs +++ /dev/null @@ -1,28 +0,0 @@ -using APITemplate.Application.Common.Errors; -using APITemplate.Application.Features.User.Specifications; -using APITemplate.Domain.Entities.Contracts; -using APITemplate.Domain.Interfaces; -using ErrorOr; - -namespace APITemplate.Application.Features.User; - -public sealed record GetUserByIdQuery(Guid Id) : IHasId; - -public sealed class GetUserByIdQueryHandler -{ - public static async Task> HandleAsync( - GetUserByIdQuery request, - IUserRepository repository, - CancellationToken ct - ) - { - var result = await repository.FirstOrDefaultAsync( - new UserByIdSpecification(request.Id), - ct - ); - if (result is null) - return DomainErrors.Users.NotFound(request.Id); - - return result; - } -} diff --git a/absolute/src/APITemplate.Application/Features/User/Queries/GetUsersQuery.cs b/absolute/src/APITemplate.Application/Features/User/Queries/GetUsersQuery.cs deleted file mode 100644 index 505011d1..00000000 --- a/absolute/src/APITemplate.Application/Features/User/Queries/GetUsersQuery.cs +++ /dev/null @@ -1,24 +0,0 @@ -using APITemplate.Application.Features.User.Specifications; -using APITemplate.Domain.Interfaces; -using ErrorOr; - -namespace APITemplate.Application.Features.User; - -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/absolute/src/APITemplate.Application/Features/User/Specifications/UserByEmailSpecification.cs b/absolute/src/APITemplate.Application/Features/User/Specifications/UserByEmailSpecification.cs deleted file mode 100644 index 24430eac..00000000 --- a/absolute/src/APITemplate.Application/Features/User/Specifications/UserByEmailSpecification.cs +++ /dev/null @@ -1,19 +0,0 @@ -using APITemplate.Domain.Entities; -using Ardalis.Specification; - -namespace APITemplate.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) - { - var normalizedEmail = email.Trim().ToUpperInvariant(); - Query.Where(u => u.Email.ToUpper() == normalizedEmail); - } -} diff --git a/absolute/src/APITemplate.Application/Features/User/Specifications/UserByIdSpecification.cs b/absolute/src/APITemplate.Application/Features/User/Specifications/UserByIdSpecification.cs deleted file mode 100644 index 41d6bf46..00000000 --- a/absolute/src/APITemplate.Application/Features/User/Specifications/UserByIdSpecification.cs +++ /dev/null @@ -1,20 +0,0 @@ -using APITemplate.Application.Features.User.DTOs; -using APITemplate.Application.Features.User.Mappings; -using APITemplate.Domain.Entities; -using Ardalis.Specification; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/User/Specifications/UserByUsernameSpecification.cs b/absolute/src/APITemplate.Application/Features/User/Specifications/UserByUsernameSpecification.cs deleted file mode 100644 index fe4c47c8..00000000 --- a/absolute/src/APITemplate.Application/Features/User/Specifications/UserByUsernameSpecification.cs +++ /dev/null @@ -1,18 +0,0 @@ -using APITemplate.Domain.Entities; -using Ardalis.Specification; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/User/Specifications/UserFilterCriteria.cs b/absolute/src/APITemplate.Application/Features/User/Specifications/UserFilterCriteria.cs deleted file mode 100644 index 85b7de93..00000000 --- a/absolute/src/APITemplate.Application/Features/User/Specifications/UserFilterCriteria.cs +++ /dev/null @@ -1,35 +0,0 @@ -using APITemplate.Application.Features.User.DTOs; -using APITemplate.Domain.Entities; -using Ardalis.Specification; - -namespace APITemplate.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)) - { - var normalizedUsername = AppUser.NormalizeUsername(filter.Username); - query.Where(u => u.NormalizedUsername.Contains(normalizedUsername)); - } - - if (!string.IsNullOrWhiteSpace(filter.Email)) - { - var normalizedEmail = filter.Email.Trim().ToUpperInvariant(); - query.Where(u => u.Email.ToUpper() == 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/absolute/src/APITemplate.Application/Features/User/Specifications/UserFilterSpecification.cs b/absolute/src/APITemplate.Application/Features/User/Specifications/UserFilterSpecification.cs deleted file mode 100644 index 71ea6606..00000000 --- a/absolute/src/APITemplate.Application/Features/User/Specifications/UserFilterSpecification.cs +++ /dev/null @@ -1,24 +0,0 @@ -using APITemplate.Application.Features.User.Mappings; -using APITemplate.Domain.Entities; -using Ardalis.Specification; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/User/UserSortFields.cs b/absolute/src/APITemplate.Application/Features/User/UserSortFields.cs deleted file mode 100644 index 102f9e50..00000000 --- a/absolute/src/APITemplate.Application/Features/User/UserSortFields.cs +++ /dev/null @@ -1,20 +0,0 @@ -using APITemplate.Application.Common.Sorting; -using APITemplate.Domain.Entities; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/User/UserValidationHelper.cs b/absolute/src/APITemplate.Application/Features/User/UserValidationHelper.cs deleted file mode 100644 index 29c494e4..00000000 --- a/absolute/src/APITemplate.Application/Features/User/UserValidationHelper.cs +++ /dev/null @@ -1,34 +0,0 @@ -using APITemplate.Application.Common.Errors; -using APITemplate.Domain.Entities; -using APITemplate.Domain.Interfaces; -using ErrorOr; - -namespace APITemplate.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 - ) - { - var normalized = AppUser.NormalizeUsername(username); - if (await repository.ExistsByUsernameAsync(normalized, ct)) - return DomainErrors.Users.UsernameAlreadyExists(username); - - return Result.Success; - } -} diff --git a/absolute/src/APITemplate.Application/Features/User/Validation/ChangeUserRoleRequestValidator.cs b/absolute/src/APITemplate.Application/Features/User/Validation/ChangeUserRoleRequestValidator.cs deleted file mode 100644 index 4e52ecea..00000000 --- a/absolute/src/APITemplate.Application/Features/User/Validation/ChangeUserRoleRequestValidator.cs +++ /dev/null @@ -1,19 +0,0 @@ -using APITemplate.Application.Features.User.DTOs; -using APITemplate.Domain.Enums; -using FluentValidation; - -namespace APITemplate.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/absolute/src/APITemplate.Application/Features/User/Validation/CreateUserRequestValidator.cs b/absolute/src/APITemplate.Application/Features/User/Validation/CreateUserRequestValidator.cs deleted file mode 100644 index a600f93d..00000000 --- a/absolute/src/APITemplate.Application/Features/User/Validation/CreateUserRequestValidator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using APITemplate.Application.Common.Validation; -using APITemplate.Application.Features.User.DTOs; - -namespace APITemplate.Application.Features.User.Validation; - -/// -/// FluentValidation validator for that enforces data-annotation constraints. -/// -public sealed class CreateUserRequestValidator : DataAnnotationsValidator { } diff --git a/absolute/src/APITemplate.Application/Features/User/Validation/UpdateUserRequestValidator.cs b/absolute/src/APITemplate.Application/Features/User/Validation/UpdateUserRequestValidator.cs deleted file mode 100644 index 8d105bef..00000000 --- a/absolute/src/APITemplate.Application/Features/User/Validation/UpdateUserRequestValidator.cs +++ /dev/null @@ -1,9 +0,0 @@ -using APITemplate.Application.Common.Validation; -using APITemplate.Application.Features.User.DTOs; - -namespace APITemplate.Application.Features.User.Validation; - -/// -/// FluentValidation validator for that enforces data-annotation constraints. -/// -public sealed class UpdateUserRequestValidator : DataAnnotationsValidator; diff --git a/absolute/src/APITemplate.Application/Features/User/Validation/UserFilterValidator.cs b/absolute/src/APITemplate.Application/Features/User/Validation/UserFilterValidator.cs deleted file mode 100644 index 5b3808fc..00000000 --- a/absolute/src/APITemplate.Application/Features/User/Validation/UserFilterValidator.cs +++ /dev/null @@ -1,25 +0,0 @@ -using APITemplate.Application.Common.Validation; -using APITemplate.Application.Features.User.DTOs; -using APITemplate.Domain.Enums; -using FluentValidation; - -namespace APITemplate.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/absolute/src/APITemplate.Application/GlobalUsings.ApplicationFeatures.cs b/absolute/src/APITemplate.Application/GlobalUsings.ApplicationFeatures.cs deleted file mode 100644 index 33233c07..00000000 --- a/absolute/src/APITemplate.Application/GlobalUsings.ApplicationFeatures.cs +++ /dev/null @@ -1,18 +0,0 @@ -global using APITemplate.Application.Common.Contracts; -global using APITemplate.Application.Common.DTOs; -global using APITemplate.Application.Common.Errors; -global using APITemplate.Application.Common.Options; -global using APITemplate.Application.Common.Options.BackgroundJobs; -global using APITemplate.Application.Common.Options.Infrastructure; -global using APITemplate.Application.Common.Options.Security; -global using APITemplate.Application.Common.Security; -global using APITemplate.Application.Features.Category.DTOs; -global using APITemplate.Application.Features.Product.DTOs; -global using APITemplate.Application.Features.Product.Repositories; -global using APITemplate.Application.Features.ProductData.DTOs; -global using APITemplate.Application.Features.ProductReview.DTOs; -global using APITemplate.Application.Features.User.DTOs; -global using APITemplate.Domain.Common; -global using APITemplate.Domain.Entities.Contracts; -global using APITemplate.Domain.Entities.ProductData; -global using APITemplate.Domain.Interfaces; diff --git a/absolute/src/APITemplate.Domain/APITemplate.Domain.csproj b/absolute/src/APITemplate.Domain/APITemplate.Domain.csproj deleted file mode 100644 index 4d310464..00000000 --- a/absolute/src/APITemplate.Domain/APITemplate.Domain.csproj +++ /dev/null @@ -1,12 +0,0 @@ - - - net10.0 - enable - enable - - - - - - - diff --git a/absolute/src/APITemplate.Domain/Common/PagedResponse.cs b/absolute/src/APITemplate.Domain/Common/PagedResponse.cs deleted file mode 100644 index b72f83c7..00000000 --- a/absolute/src/APITemplate.Domain/Common/PagedResponse.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Domain/Entities/AppUser.cs b/absolute/src/APITemplate.Domain/Entities/AppUser.cs deleted file mode 100644 index 139e6862..00000000 --- a/absolute/src/APITemplate.Domain/Entities/AppUser.cs +++ /dev/null @@ -1,60 +0,0 @@ -using APITemplate.Domain.Enums; - -namespace APITemplate.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/absolute/src/APITemplate.Domain/Entities/AuditDefaults.cs b/absolute/src/APITemplate.Domain/Entities/AuditDefaults.cs deleted file mode 100644 index 36c89aef..00000000 --- a/absolute/src/APITemplate.Domain/Entities/AuditDefaults.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Domain/Entities/AuditInfo.cs b/absolute/src/APITemplate.Domain/Entities/AuditInfo.cs deleted file mode 100644 index d1630fc0..00000000 --- a/absolute/src/APITemplate.Domain/Entities/AuditInfo.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Domain/Entities/Category.cs b/absolute/src/APITemplate.Domain/Entities/Category.cs deleted file mode 100644 index 127036df..00000000 --- a/absolute/src/APITemplate.Domain/Entities/Category.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Domain/Entities/Contracts/IAuditableEntity.cs b/absolute/src/APITemplate.Domain/Entities/Contracts/IAuditableEntity.cs deleted file mode 100644 index ad3c9965..00000000 --- a/absolute/src/APITemplate.Domain/Entities/Contracts/IAuditableEntity.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Domain/Entities/Contracts/IAuditableTenantEntity.cs b/absolute/src/APITemplate.Domain/Entities/Contracts/IAuditableTenantEntity.cs deleted file mode 100644 index 045da868..00000000 --- a/absolute/src/APITemplate.Domain/Entities/Contracts/IAuditableTenantEntity.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Domain/Entities/Contracts/IHasId.cs b/absolute/src/APITemplate.Domain/Entities/Contracts/IHasId.cs deleted file mode 100644 index 5898b767..00000000 --- a/absolute/src/APITemplate.Domain/Entities/Contracts/IHasId.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace APITemplate.Domain.Entities.Contracts; - -/// -/// Marks a type that carries a unique identity. -/// -public interface IHasId -{ - Guid Id { get; } -} diff --git a/absolute/src/APITemplate.Domain/Entities/Contracts/ISoftDeletable.cs b/absolute/src/APITemplate.Domain/Entities/Contracts/ISoftDeletable.cs deleted file mode 100644 index 77a78d66..00000000 --- a/absolute/src/APITemplate.Domain/Entities/Contracts/ISoftDeletable.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Domain/Entities/Contracts/ITenantEntity.cs b/absolute/src/APITemplate.Domain/Entities/Contracts/ITenantEntity.cs deleted file mode 100644 index 708c3f94..00000000 --- a/absolute/src/APITemplate.Domain/Entities/Contracts/ITenantEntity.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Domain/Entities/FailedEmail.cs b/absolute/src/APITemplate.Domain/Entities/FailedEmail.cs deleted file mode 100644 index 661ce432..00000000 --- a/absolute/src/APITemplate.Domain/Entities/FailedEmail.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Domain/Entities/JobExecution.cs b/absolute/src/APITemplate.Domain/Entities/JobExecution.cs deleted file mode 100644 index 7ffd9999..00000000 --- a/absolute/src/APITemplate.Domain/Entities/JobExecution.cs +++ /dev/null @@ -1,65 +0,0 @@ -using APITemplate.Domain.Enums; - -namespace APITemplate.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/absolute/src/APITemplate.Domain/Entities/Product.cs b/absolute/src/APITemplate.Domain/Entities/Product.cs deleted file mode 100644 index e208d32e..00000000 --- a/absolute/src/APITemplate.Domain/Entities/Product.cs +++ /dev/null @@ -1,97 +0,0 @@ -namespace APITemplate.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 ICollection Reviews { 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 ( - var link in ProductDataLinks - .Where(link => !targetIds.Contains(link.ProductDataId)) - .ToArray() - ) - ProductDataLinks.Remove(link); - - foreach (var 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 (var link in ProductDataLinks.ToArray()) - ProductDataLinks.Remove(link); - } -} diff --git a/absolute/src/APITemplate.Domain/Entities/ProductCategoryStats.cs b/absolute/src/APITemplate.Domain/Entities/ProductCategoryStats.cs deleted file mode 100644 index 7203cd49..00000000 --- a/absolute/src/APITemplate.Domain/Entities/ProductCategoryStats.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Domain/Entities/ProductData/ImageProductData.cs b/absolute/src/APITemplate.Domain/Entities/ProductData/ImageProductData.cs deleted file mode 100644 index f0a49ead..00000000 --- a/absolute/src/APITemplate.Domain/Entities/ProductData/ImageProductData.cs +++ /dev/null @@ -1,18 +0,0 @@ -using MongoDB.Bson.Serialization.Attributes; - -namespace APITemplate.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/absolute/src/APITemplate.Domain/Entities/ProductData/ProductData.cs b/absolute/src/APITemplate.Domain/Entities/ProductData/ProductData.cs deleted file mode 100644 index a89295e4..00000000 --- a/absolute/src/APITemplate.Domain/Entities/ProductData/ProductData.cs +++ /dev/null @@ -1,29 +0,0 @@ -using MongoDB.Bson.Serialization.Attributes; - -namespace APITemplate.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/absolute/src/APITemplate.Domain/Entities/ProductData/VideoProductData.cs b/absolute/src/APITemplate.Domain/Entities/ProductData/VideoProductData.cs deleted file mode 100644 index 920f4139..00000000 --- a/absolute/src/APITemplate.Domain/Entities/ProductData/VideoProductData.cs +++ /dev/null @@ -1,18 +0,0 @@ -using MongoDB.Bson.Serialization.Attributes; - -namespace APITemplate.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/absolute/src/APITemplate.Domain/Entities/ProductDataLink.cs b/absolute/src/APITemplate.Domain/Entities/ProductDataLink.cs deleted file mode 100644 index 90dc3e89..00000000 --- a/absolute/src/APITemplate.Domain/Entities/ProductDataLink.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Domain/Entities/ProductReview.cs b/absolute/src/APITemplate.Domain/Entities/ProductReview.cs deleted file mode 100644 index 900a339b..00000000 --- a/absolute/src/APITemplate.Domain/Entities/ProductReview.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace APITemplate.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 Product Product { get; set; } = null!; - public AppUser User { 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/absolute/src/APITemplate.Domain/Entities/StoredFile.cs b/absolute/src/APITemplate.Domain/Entities/StoredFile.cs deleted file mode 100644 index 99f54b40..00000000 --- a/absolute/src/APITemplate.Domain/Entities/StoredFile.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Domain/Entities/Tenant.cs b/absolute/src/APITemplate.Domain/Entities/Tenant.cs deleted file mode 100644 index ae2d0191..00000000 --- a/absolute/src/APITemplate.Domain/Entities/Tenant.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Domain/Entities/TenantInvitation.cs b/absolute/src/APITemplate.Domain/Entities/TenantInvitation.cs deleted file mode 100644 index f416252d..00000000 --- a/absolute/src/APITemplate.Domain/Entities/TenantInvitation.cs +++ /dev/null @@ -1,25 +0,0 @@ -using APITemplate.Domain.Enums; - -namespace APITemplate.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/absolute/src/APITemplate.Domain/Enums/InvitationStatus.cs b/absolute/src/APITemplate.Domain/Enums/InvitationStatus.cs deleted file mode 100644 index 6d1bbb66..00000000 --- a/absolute/src/APITemplate.Domain/Enums/InvitationStatus.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Domain/Enums/JobStatus.cs b/absolute/src/APITemplate.Domain/Enums/JobStatus.cs deleted file mode 100644 index d0ef507b..00000000 --- a/absolute/src/APITemplate.Domain/Enums/JobStatus.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Domain/Enums/UserRole.cs b/absolute/src/APITemplate.Domain/Enums/UserRole.cs deleted file mode 100644 index 5b47e3cf..00000000 --- a/absolute/src/APITemplate.Domain/Enums/UserRole.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Domain/Exceptions/AppException.cs b/absolute/src/APITemplate.Domain/Exceptions/AppException.cs deleted file mode 100644 index 99d9490c..00000000 --- a/absolute/src/APITemplate.Domain/Exceptions/AppException.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Domain/Exceptions/ConflictException.cs b/absolute/src/APITemplate.Domain/Exceptions/ConflictException.cs deleted file mode 100644 index 25323bc0..00000000 --- a/absolute/src/APITemplate.Domain/Exceptions/ConflictException.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Domain/Exceptions/ForbiddenException.cs b/absolute/src/APITemplate.Domain/Exceptions/ForbiddenException.cs deleted file mode 100644 index 7006a770..00000000 --- a/absolute/src/APITemplate.Domain/Exceptions/ForbiddenException.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Domain/Exceptions/NotFoundException.cs b/absolute/src/APITemplate.Domain/Exceptions/NotFoundException.cs deleted file mode 100644 index 9b14acfa..00000000 --- a/absolute/src/APITemplate.Domain/Exceptions/NotFoundException.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Domain/Exceptions/UnauthorizedException.cs b/absolute/src/APITemplate.Domain/Exceptions/UnauthorizedException.cs deleted file mode 100644 index 85292e37..00000000 --- a/absolute/src/APITemplate.Domain/Exceptions/UnauthorizedException.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Domain/Exceptions/ValidationException.cs b/absolute/src/APITemplate.Domain/Exceptions/ValidationException.cs deleted file mode 100644 index ebe21522..00000000 --- a/absolute/src/APITemplate.Domain/Exceptions/ValidationException.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Domain/GlobalUsings.cs b/absolute/src/APITemplate.Domain/GlobalUsings.cs deleted file mode 100644 index 56393ae3..00000000 --- a/absolute/src/APITemplate.Domain/GlobalUsings.cs +++ /dev/null @@ -1,3 +0,0 @@ -global using APITemplate.Domain.Common; -global using APITemplate.Domain.Entities.Contracts; -global using APITemplate.Domain.Entities.ProductData; diff --git a/absolute/src/APITemplate.Domain/Interfaces/ICategoryRepository.cs b/absolute/src/APITemplate.Domain/Interfaces/ICategoryRepository.cs deleted file mode 100644 index 88ae78c8..00000000 --- a/absolute/src/APITemplate.Domain/Interfaces/ICategoryRepository.cs +++ /dev/null @@ -1,16 +0,0 @@ -using APITemplate.Domain.Entities; - -namespace APITemplate.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/absolute/src/APITemplate.Domain/Interfaces/IFailedEmailRepository.cs b/absolute/src/APITemplate.Domain/Interfaces/IFailedEmailRepository.cs deleted file mode 100644 index f9798332..00000000 --- a/absolute/src/APITemplate.Domain/Interfaces/IFailedEmailRepository.cs +++ /dev/null @@ -1,45 +0,0 @@ -using APITemplate.Domain.Entities; - -namespace APITemplate.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); - - /// - /// 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); -} diff --git a/absolute/src/APITemplate.Domain/Interfaces/IJobExecutionRepository.cs b/absolute/src/APITemplate.Domain/Interfaces/IJobExecutionRepository.cs deleted file mode 100644 index f4b43301..00000000 --- a/absolute/src/APITemplate.Domain/Interfaces/IJobExecutionRepository.cs +++ /dev/null @@ -1,8 +0,0 @@ -using APITemplate.Domain.Entities; - -namespace APITemplate.Domain.Interfaces; - -/// -/// Repository contract for entities, inheriting all generic CRUD operations from . -/// -public interface IJobExecutionRepository : IRepository; diff --git a/absolute/src/APITemplate.Domain/Interfaces/IProductDataLinkRepository.cs b/absolute/src/APITemplate.Domain/Interfaces/IProductDataLinkRepository.cs deleted file mode 100644 index 61709ae6..00000000 --- a/absolute/src/APITemplate.Domain/Interfaces/IProductDataLinkRepository.cs +++ /dev/null @@ -1,43 +0,0 @@ -using APITemplate.Domain.Entities; - -namespace APITemplate.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/absolute/src/APITemplate.Domain/Interfaces/IProductDataRepository.cs b/absolute/src/APITemplate.Domain/Interfaces/IProductDataRepository.cs deleted file mode 100644 index d68f355b..00000000 --- a/absolute/src/APITemplate.Domain/Interfaces/IProductDataRepository.cs +++ /dev/null @@ -1,42 +0,0 @@ -using APITemplate.Domain.Entities; - -namespace APITemplate.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/absolute/src/APITemplate.Domain/Interfaces/IProductReviewRepository.cs b/absolute/src/APITemplate.Domain/Interfaces/IProductReviewRepository.cs deleted file mode 100644 index 349c1dbc..00000000 --- a/absolute/src/APITemplate.Domain/Interfaces/IProductReviewRepository.cs +++ /dev/null @@ -1,8 +0,0 @@ -using APITemplate.Domain.Entities; - -namespace APITemplate.Domain.Interfaces; - -/// -/// Repository contract for entities, inheriting all generic CRUD operations from . -/// -public interface IProductReviewRepository : IRepository { } diff --git a/absolute/src/APITemplate.Domain/Interfaces/IRepository.cs b/absolute/src/APITemplate.Domain/Interfaces/IRepository.cs deleted file mode 100644 index e5737595..00000000 --- a/absolute/src/APITemplate.Domain/Interfaces/IRepository.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Ardalis.Specification; - -namespace APITemplate.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/absolute/src/APITemplate.Domain/Interfaces/IStoredFileRepository.cs b/absolute/src/APITemplate.Domain/Interfaces/IStoredFileRepository.cs deleted file mode 100644 index ac6d7acc..00000000 --- a/absolute/src/APITemplate.Domain/Interfaces/IStoredFileRepository.cs +++ /dev/null @@ -1,8 +0,0 @@ -using APITemplate.Domain.Entities; - -namespace APITemplate.Domain.Interfaces; - -/// -/// Repository contract for entities, inheriting all generic CRUD operations from . -/// -public interface IStoredFileRepository : IRepository; diff --git a/absolute/src/APITemplate.Domain/Interfaces/IStoredProcedure.cs b/absolute/src/APITemplate.Domain/Interfaces/IStoredProcedure.cs deleted file mode 100644 index 10cd5123..00000000 --- a/absolute/src/APITemplate.Domain/Interfaces/IStoredProcedure.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace APITemplate.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) -/// -/// Usage example: -/// -/// var proc = new GetProductCategoryStatsProcedure(categoryId); -/// var result = await _executor.QueryFirstAsync(proc, ct); -/// -/// -/// -/// 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/absolute/src/APITemplate.Domain/Interfaces/IStoredProcedureExecutor.cs b/absolute/src/APITemplate.Domain/Interfaces/IStoredProcedureExecutor.cs deleted file mode 100644 index 7fdf224b..00000000 --- a/absolute/src/APITemplate.Domain/Interfaces/IStoredProcedureExecutor.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace APITemplate.Domain.Interfaces; - -/// -/// Executes stored procedures and maps the result set to strongly-typed objects. -/// Abstracts the EF Core plumbing (FromSql, DbSet) away from repositories, -/// making the data-access layer easier to test and reason about. -/// -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/absolute/src/APITemplate.Domain/Interfaces/ITenantInvitationRepository.cs b/absolute/src/APITemplate.Domain/Interfaces/ITenantInvitationRepository.cs deleted file mode 100644 index 22c626a5..00000000 --- a/absolute/src/APITemplate.Domain/Interfaces/ITenantInvitationRepository.cs +++ /dev/null @@ -1,22 +0,0 @@ -using APITemplate.Domain.Entities; - -namespace APITemplate.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/absolute/src/APITemplate.Domain/Interfaces/ITenantRepository.cs b/absolute/src/APITemplate.Domain/Interfaces/ITenantRepository.cs deleted file mode 100644 index 9f5ad918..00000000 --- a/absolute/src/APITemplate.Domain/Interfaces/ITenantRepository.cs +++ /dev/null @@ -1,14 +0,0 @@ -using APITemplate.Domain.Entities; - -namespace APITemplate.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/absolute/src/APITemplate.Domain/Interfaces/IUnitOfWork.cs b/absolute/src/APITemplate.Domain/Interfaces/IUnitOfWork.cs deleted file mode 100644 index c3c52f1d..00000000 --- a/absolute/src/APITemplate.Domain/Interfaces/IUnitOfWork.cs +++ /dev/null @@ -1,68 +0,0 @@ -using APITemplate.Domain.Options; - -namespace APITemplate.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/absolute/src/APITemplate.Domain/Interfaces/IUserRepository.cs b/absolute/src/APITemplate.Domain/Interfaces/IUserRepository.cs deleted file mode 100644 index 1bee6c47..00000000 --- a/absolute/src/APITemplate.Domain/Interfaces/IUserRepository.cs +++ /dev/null @@ -1,18 +0,0 @@ -using APITemplate.Domain.Entities; - -namespace APITemplate.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/absolute/src/APITemplate.Domain/Options/TransactionOptions.cs b/absolute/src/APITemplate.Domain/Options/TransactionOptions.cs deleted file mode 100644 index 38f1b859..00000000 --- a/absolute/src/APITemplate.Domain/Options/TransactionOptions.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Data; - -namespace APITemplate.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/absolute/src/APITemplate.Infrastructure/APITemplate.Infrastructure.csproj b/absolute/src/APITemplate.Infrastructure/APITemplate.Infrastructure.csproj deleted file mode 100644 index 9e667f27..00000000 --- a/absolute/src/APITemplate.Infrastructure/APITemplate.Infrastructure.csproj +++ /dev/null @@ -1,72 +0,0 @@ - - - net10.0 - enable - enable - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/BoundedChannelQueue.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/BoundedChannelQueue.cs deleted file mode 100644 index 7d287ce9..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/BoundedChannelQueue.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Threading.Channels; - -namespace APITemplate.Infrastructure.BackgroundJobs.Services; - -/// -/// A generic bounded channel-based queue. Subclass or instantiate directly for -/// specific queue types (jobs, webhooks, emails, etc.). -/// -public class BoundedChannelQueue -{ - 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/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/ChannelJobQueue.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/ChannelJobQueue.cs deleted file mode 100644 index d840cee7..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/ChannelJobQueue.cs +++ /dev/null @@ -1,16 +0,0 @@ -using APITemplate.Application.Common.BackgroundJobs; - -namespace APITemplate.Infrastructure.BackgroundJobs.Services; - -/// -/// 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/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/CleanupService.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/CleanupService.cs deleted file mode 100644 index 057b1346..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/CleanupService.cs +++ /dev/null @@ -1,170 +0,0 @@ -using APITemplate.Application.Common.BackgroundJobs; -using APITemplate.Domain.Entities; -using APITemplate.Domain.Enums; -using APITemplate.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using MongoDB.Driver; - -namespace APITemplate.Infrastructure.BackgroundJobs.Services; - -/// -/// Infrastructure implementation of that performs -/// scheduled data-hygiene tasks: expired invitations, soft-deleted records, and orphaned MongoDB documents. -/// -public sealed class CleanupService : ICleanupService -{ - private readonly AppDbContext _dbContext; - private readonly MongoDbContext? _mongoDbContext; - private readonly IEnumerable _cleanupStrategies; - private readonly TimeProvider _timeProvider; - private readonly ILogger _logger; - - public CleanupService( - AppDbContext dbContext, - IEnumerable cleanupStrategies, - TimeProvider timeProvider, - ILogger logger, - MongoDbContext? mongoDbContext = null - ) - { - _dbContext = dbContext; - _cleanupStrategies = cleanupStrategies; - _timeProvider = timeProvider; - _mongoDbContext = mongoDbContext; - _logger = logger; - } - - /// - /// Permanently deletes pending tenant invitations whose expiry timestamp is older than - /// hours, processed in batches of . - /// - public async Task CleanupExpiredInvitationsAsync( - int retentionHours, - int batchSize, - CancellationToken ct = default - ) - { - var cutoff = _timeProvider.GetUtcNow().UtcDateTime.AddHours(-retentionHours); - int totalDeleted = 0; - int deleted; - - do - { - deleted = await _dbContext - .TenantInvitations.IgnoreQueryFilters() - .Where(i => i.Status == InvitationStatus.Pending && i.ExpiresAtUtc < cutoff) - .OrderBy(i => i.ExpiresAtUtc) - .Take(batchSize) - .ExecuteDeleteAsync(ct); - - totalDeleted += deleted; - } while (deleted == batchSize); - - if (totalDeleted > 0) - { - _logger.LogInformation("Cleaned up {Count} expired invitations.", totalDeleted); - } - } - - /// - /// Delegates to every registered to hard-delete - /// soft-deleted records older than days. - /// - public async Task CleanupSoftDeletedRecordsAsync( - int retentionDays, - int batchSize, - CancellationToken ct = default - ) - { - var cutoff = _timeProvider.GetUtcNow().UtcDateTime.AddDays(-retentionDays); - - foreach (var strategy in _cleanupStrategies) - { - var deleted = await strategy.CleanupAsync(cutoff, batchSize, ct); - - if (deleted > 0) - { - _logger.LogInformation( - "Cleaned up {Count} soft-deleted records from {Entity}.", - deleted, - strategy.EntityName - ); - } - } - } - - /// - /// Safety net for orphaned MongoDB ProductData documents that are no longer linked - /// from any ProductDataLink in PostgreSQL. Under normal operation, cascade rules - /// (ProductSoftDeleteCascadeRule, ProductDataCascadeDeleteHandler) handle cleanup. - /// Orphans may appear after transaction failures, manual DB edits, or cascade bugs. - /// - public async Task CleanupOrphanedProductDataAsync( - int retentionDays, - int batchSize, - CancellationToken ct = default - ) - { - if (_mongoDbContext is null) - { - _logger.LogDebug( - "MongoDbContext not available, skipping orphaned product data cleanup." - ); - return; - } - - var mongoCollection = _mongoDbContext.ProductData; - var cutoff = _timeProvider.GetUtcNow().UtcDateTime.AddDays(-retentionDays); - var totalDeleted = 0; - Guid? lastSeenId = null; - - while (true) - { - var pageFilter = Builders.Filter.Lt(d => d.CreatedAt, cutoff); - if (lastSeenId.HasValue) - { - pageFilter &= Builders.Filter.Gt(d => d.Id, lastSeenId.Value); - } - - var page = await mongoCollection - .Find(pageFilter) - .SortBy(d => d.Id) - .Limit(batchSize) - .Project(d => d.Id) - .ToListAsync(ct); - - if (page.Count == 0) - { - break; - } - - var linkedIds = await _dbContext - .ProductDataLinks.IgnoreQueryFilters() - .Where(l => page.Contains(l.ProductDataId)) - .Select(l => l.ProductDataId) - .Distinct() - .ToListAsync(ct); - - var linkedIdSet = linkedIds.ToHashSet(); - var orphanedIds = page.Where(id => !linkedIdSet.Contains(id)).ToArray(); - - if (orphanedIds.Length > 0) - { - var deleteFilter = Builders.Filter.In(d => d.Id, orphanedIds); - await mongoCollection.DeleteManyAsync(deleteFilter, ct); - totalDeleted += orphanedIds.Length; - } - - lastSeenId = page[^1]; - } - - if (totalDeleted > 0) - { - _logger.LogInformation( - "Cleaned up {Count} orphaned product data documents.", - totalDeleted - ); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/EmailRetryService.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/EmailRetryService.cs deleted file mode 100644 index 5d8c280a..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/EmailRetryService.cs +++ /dev/null @@ -1,165 +0,0 @@ -using APITemplate.Application.Common.BackgroundJobs; -using APITemplate.Application.Common.Email; -using APITemplate.Application.Common.Options; -using APITemplate.Application.Common.Resilience; -using APITemplate.Domain.Interfaces; -using APITemplate.Infrastructure.Email; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Polly.Registry; - -namespace APITemplate.Infrastructure.BackgroundJobs.Services; - -/// -/// Infrastructure implementation of that claims and retries -/// failed emails from the store and moves permanently undeliverable ones to the dead-letter state. -/// Uses optimistic per-record claiming to avoid duplicate processing in multi-instance deployments. -/// -public sealed class EmailRetryService : IEmailRetryService -{ - private readonly string _claimOwner; - private readonly IFailedEmailRepository _repository; - private readonly IEmailSender _sender; - private readonly IUnitOfWork _unitOfWork; - private readonly TimeProvider _timeProvider; - private readonly EmailRetryJobOptions _options; - private readonly ResiliencePipelineProvider _resiliencePipelineProvider; - private readonly ILogger _logger; - - public EmailRetryService( - IFailedEmailRepository repository, - IEmailSender sender, - IUnitOfWork unitOfWork, - TimeProvider timeProvider, - IOptions options, - ResiliencePipelineProvider resiliencePipelineProvider, - ILogger logger - ) - { - _claimOwner = $"{Environment.MachineName}:{Environment.ProcessId}"; - _repository = repository; - _sender = sender; - _unitOfWork = unitOfWork; - _timeProvider = timeProvider; - _options = options.Value.EmailRetry; - _resiliencePipelineProvider = resiliencePipelineProvider; - _logger = logger; - } - - /// - /// Claims up to retryable failed emails, attempts delivery via - /// the resilience pipeline, and commits progress per-email to prevent duplicate sends on crash. - /// Failures increment RetryCount and release the claim for future attempts. - /// - public async Task RetryFailedEmailsAsync( - int maxRetryAttempts, - int batchSize, - CancellationToken ct = default - ) - { - var pipeline = _resiliencePipelineProvider.GetPipeline(ResiliencePipelineKeys.SmtpSend); - var claimedAtUtc = _timeProvider.GetUtcNow().UtcDateTime; - var claimUntilUtc = claimedAtUtc.AddMinutes(_options.ClaimLeaseMinutes); - var emails = await _repository.ClaimRetryableBatchAsync( - maxRetryAttempts, - batchSize, - _claimOwner, - claimedAtUtc, - claimUntilUtc, - ct - ); - - foreach (var email in emails) - { - try - { - var message = new EmailMessage( - email.To, - email.Subject, - email.HtmlBody, - email.TemplateName - ); - await pipeline.ExecuteAsync( - async token => await _sender.SendAsync(message, token), - ct - ); - - await _repository.DeleteAsync(email, ct); - _logger.LogInformation( - "Successfully retried email to {Recipient} (attempt {Attempt}).", - email.To, - email.RetryCount + 1 - ); - } - catch (Exception ex) - { - email.RetryCount++; - email.LastAttemptAtUtc = _timeProvider.GetUtcNow().UtcDateTime; - email.LastError = FailedEmailErrorNormalizer.Normalize(ex.Message); - email.ClaimedBy = null; - email.ClaimedAtUtc = null; - email.ClaimedUntilUtc = null; - await _repository.UpdateAsync(email, ct); - - _logger.LogWarning( - ex, - "Retry attempt {Attempt} failed for email to {Recipient}.", - email.RetryCount, - email.To - ); - } - - // Commit after each email to ensure durable progress — avoids duplicate sends on crash - await _unitOfWork.CommitAsync(ct); - } - } - - /// - /// Claims and marks as dead-lettered any failed emails that have been retrying for longer than - /// hours, processing in batches until none remain. - /// - public async Task DeadLetterExpiredAsync( - int deadLetterAfterHours, - int batchSize, - CancellationToken ct = default - ) - { - var cutoff = _timeProvider.GetUtcNow().UtcDateTime.AddHours(-deadLetterAfterHours); - int processed; - - do - { - var claimedAtUtc = _timeProvider.GetUtcNow().UtcDateTime; - var expired = await _repository.ClaimExpiredBatchAsync( - cutoff, - batchSize, - _claimOwner, - claimedAtUtc, - claimedAtUtc.AddMinutes(_options.ClaimLeaseMinutes), - ct - ); - processed = expired.Count; - - foreach (var email in expired) - { - email.IsDeadLettered = true; - email.ClaimedBy = null; - email.ClaimedAtUtc = null; - email.ClaimedUntilUtc = null; - await _repository.UpdateAsync(email, ct); - - _logger.LogWarning( - "Dead-lettered email to {Recipient} with subject '{Subject}' after {Hours}h.", - email.To, - email.Subject, - deadLetterAfterHours - ); - } - - if (processed > 0) - { - await _unitOfWork.CommitAsync(ct); - } - } while (processed == batchSize); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/ExternalIntegrationSyncServicePreview.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/ExternalIntegrationSyncServicePreview.cs deleted file mode 100644 index 0bb26c53..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/ExternalIntegrationSyncServicePreview.cs +++ /dev/null @@ -1,30 +0,0 @@ -using APITemplate.Application.Common.BackgroundJobs; -using Microsoft.Extensions.Logging; - -namespace APITemplate.Infrastructure.BackgroundJobs.Services; - -/// -/// Placeholder implementation of used until -/// a real provider-specific synchronization workflow is registered. -/// Logs a warning and returns immediately without performing any work. -/// -public sealed class ExternalIntegrationSyncServicePreview : IExternalIntegrationSyncService -{ - private readonly ILogger _logger; - - public ExternalIntegrationSyncServicePreview( - ILogger logger - ) - { - _logger = logger; - } - - /// Logs a notice that no sync workflow is registered and completes without error. - public Task SynchronizeAsync(CancellationToken ct = default) - { - _logger.LogInformation( - "External integration synchronization job executed, but no provider-specific synchronization workflow is registered yet." - ); - return Task.CompletedTask; - } -} diff --git a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/ISoftDeleteCleanupStrategy.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/ISoftDeleteCleanupStrategy.cs deleted file mode 100644 index c1ba10be..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/ISoftDeleteCleanupStrategy.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace APITemplate.Infrastructure.BackgroundJobs.Services; - -/// -/// Strategy abstraction for purging soft-deleted records of a specific entity type. -/// Implementations are discovered and invoked by during the scheduled cleanup job. -/// -public interface ISoftDeleteCleanupStrategy -{ - /// Gets the name of the entity type this strategy handles, used for logging. - string EntityName { get; } - - /// - /// Permanently deletes soft-deleted records older than in batches. - /// Returns the total number of rows deleted. - /// - Task CleanupAsync(DateTime cutoff, int batchSize, CancellationToken ct = default); -} diff --git a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/JobProcessingBackgroundService.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/JobProcessingBackgroundService.cs deleted file mode 100644 index 01b8708e..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/JobProcessingBackgroundService.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System.Text.Json; -using APITemplate.Application.Common.BackgroundJobs; -using APITemplate.Application.Features.Examples.DTOs; -using APITemplate.Domain.Entities; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace APITemplate.Infrastructure.BackgroundJobs.Services; - -/// -/// Hosted background service that dequeues job IDs from , simulates -/// multi-step processing with progress updates, and dispatches webhook callbacks on completion or failure. -/// 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 IOutgoingWebhookQueue _outgoingWebhookQueue; - private readonly ILogger _logger; - private readonly TimeProvider _timeProvider; - - public JobProcessingBackgroundService( - IJobQueueReader queue, - IServiceScopeFactory scopeFactory, - IOutgoingWebhookQueue outgoingWebhookQueue, - ILogger logger, - TimeProvider timeProvider - ) - : base(queue) - { - _scopeFactory = scopeFactory; - _outgoingWebhookQueue = outgoingWebhookQueue; - _logger = logger; - _timeProvider = timeProvider; - } - - /// - /// Marks the job as processing, simulates five incremental progress steps, marks it - /// completed with a result payload, and enqueues a webhook callback if a callback URL is configured. - /// - protected override async Task ProcessItemAsync(Guid jobId, CancellationToken ct) - { - await using var scope = _scopeFactory.CreateAsyncScope(); - var repo = scope.ServiceProvider.GetRequiredService(); - var uow = scope.ServiceProvider.GetRequiredService(); - - var job = await repo.GetByIdAsync(jobId, ct); - if (job is null) - return; - - job.MarkProcessing(_timeProvider); - await uow.CommitAsync(ct); - - for (var 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); - - await EnqueueCallbackAsync(job, ct); - } - - /// Logs the error and attempts to persist the failed state and enqueue a failure callback 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 var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( - ct, - timeoutCts.Token - ); - var token = linkedCts.Token; - - await using var scope = _scopeFactory.CreateAsyncScope(); - var repo = scope.ServiceProvider.GetRequiredService(); - var uow = scope.ServiceProvider.GetRequiredService(); - - var job = await repo.GetByIdAsync(jobId, token); - if (job is not null) - { - job.MarkFailed(errorMessage, _timeProvider); - await uow.CommitAsync(token); - - await EnqueueCallbackAsync(job, token); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to mark job {JobId} as failed", jobId); - } - } - - /// Serialises the job outcome into an and pushes it to the outgoing webhook queue when a callback URL is present. - private async Task EnqueueCallbackAsync(JobExecution job, CancellationToken ct) - { - if (job.CallbackUrl is null) - return; - - var payload = new OutgoingJobWebhookPayload( - job.Id, - job.JobType, - job.Status.ToString(), - job.ResultPayload, - job.ErrorMessage, - job.CompletedAtUtc ?? _timeProvider.GetUtcNow().UtcDateTime - ); - - var serialized = JsonSerializer.Serialize(payload, JsonSerializerOptions.Web); - var item = new OutgoingWebhookItem(job.CallbackUrl, serialized); - - await _outgoingWebhookQueue.EnqueueAsync(item, ct); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/QueueConsumerBackgroundService.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/QueueConsumerBackgroundService.cs deleted file mode 100644 index ac09d9a7..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/QueueConsumerBackgroundService.cs +++ /dev/null @@ -1,38 +0,0 @@ -using APITemplate.Application.Common.BackgroundJobs; -using Microsoft.Extensions.Hosting; - -namespace APITemplate.Infrastructure.BackgroundJobs.Services; - -/// -/// 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 (var 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/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/ReindexService.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/ReindexService.cs deleted file mode 100644 index 3e776794..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/ReindexService.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System.Text.RegularExpressions; -using APITemplate.Application.Common.BackgroundJobs; -using APITemplate.Infrastructure.Persistence; -using APITemplate.Infrastructure.StoredProcedures; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -namespace APITemplate.Infrastructure.BackgroundJobs.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 AppDbContext _dbContext; - private readonly ILogger _logger; - - public ReindexService(AppDbContext 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) - { - var procedure = new GetFtsIndexNamesProcedure(); - var ftsIndexes = await _dbContext - .Database.SqlQuery(procedure.ToSql()) - .ToListAsync(ct); - - foreach (var index in ftsIndexes) - { - if (!ValidIndexNameRegex().IsMatch(index)) - { - _logger.LogWarning("Skipping invalid FTS index name: {IndexName}.", index); - continue; - } - - var 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) - { - var procedure = new GetIndexBloatPercentProcedure(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/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/SoftDeleteCleanupStrategy.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/SoftDeleteCleanupStrategy.cs deleted file mode 100644 index 2f17b350..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Services/SoftDeleteCleanupStrategy.cs +++ /dev/null @@ -1,52 +0,0 @@ -using APITemplate.Domain.Entities; -using APITemplate.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; - -namespace APITemplate.Infrastructure.BackgroundJobs.Services; - -/// -/// Generic implementation of that hard-deletes -/// soft-deleted rows in batches using EF Core bulk-delete. -/// -public sealed class SoftDeleteCleanupStrategy : ISoftDeleteCleanupStrategy - where TEntity : class, ISoftDeletable -{ - private readonly AppDbContext _dbContext; - - public SoftDeleteCleanupStrategy(AppDbContext dbContext) - { - _dbContext = dbContext; - } - - /// - public string EntityName => typeof(TEntity).Name; - - /// - /// Iterates in batches, deleting records where IsDeleted is true and - /// DeletedAtUtc precedes , until no full batch remains. - /// - public async Task CleanupAsync( - DateTime cutoff, - int batchSize, - CancellationToken ct = default - ) - { - int totalDeleted = 0; - int deleted; - - do - { - deleted = await _dbContext - .Set() - .IgnoreQueryFilters() - .Where(e => e.IsDeleted && e.DeletedAtUtc < cutoff) - .OrderBy(e => e.DeletedAtUtc) - .Take(batchSize) - .ExecuteDeleteAsync(ct); - - totalDeleted += deleted; - } while (deleted == batchSize); - - return totalDeleted; - } -} diff --git a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Coordination/DragonflyDistributedJobCoordinator.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Coordination/DragonflyDistributedJobCoordinator.cs deleted file mode 100644 index 4694928b..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Coordination/DragonflyDistributedJobCoordinator.cs +++ /dev/null @@ -1,219 +0,0 @@ -using APITemplate.Application.Common.Options; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using StackExchange.Redis; - -namespace APITemplate.Infrastructure.BackgroundJobs.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 - ) - { - var database = RequireCoordination(jobName); - if (database is null) - { - await action(ct); - return; - } - - var lockKey = $"TickerQ:Leader:{_options.TickerQ.InstanceNamePrefix}:{jobName}"; - var lockValue = $"{Environment.MachineName}:{Environment.ProcessId}:{Guid.NewGuid():N}"; - - var 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 var executionCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - var 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 var timer = new PeriodicTimer( - TimeSpan.FromSeconds(LeaseSeconds / LeaseRenewalDivider) - ); - while (await timer.WaitForNextTickAsync(executionCts.Token)) - { - var 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/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Coordination/IDistributedJobCoordinator.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Coordination/IDistributedJobCoordinator.cs deleted file mode 100644 index 5d32cc90..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Coordination/IDistributedJobCoordinator.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace APITemplate.Infrastructure.BackgroundJobs.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/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/CleanupRecurringJob.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/CleanupRecurringJob.cs deleted file mode 100644 index 64f66790..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/CleanupRecurringJob.cs +++ /dev/null @@ -1,68 +0,0 @@ -using APITemplate.Application.Common.BackgroundJobs; -using APITemplate.Application.Common.Options; -using APITemplate.Infrastructure.BackgroundJobs.TickerQ.Coordination; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using TickerQ.Utilities.Base; - -namespace APITemplate.Infrastructure.BackgroundJobs.TickerQ.Jobs; - -/// -/// TickerQ recurring job that orchestrates all data-hygiene cleanup tasks (expired invitations, -/// soft-deleted records, orphaned MongoDB documents) 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 three 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.CleanupExpiredInvitationsAsync( - _options.ExpiredInvitationRetentionHours, - _options.BatchSize, - token - ); - await _cleanupService.CleanupSoftDeletedRecordsAsync( - _options.SoftDeleteRetentionDays, - _options.BatchSize, - token - ); - await _cleanupService.CleanupOrphanedProductDataAsync( - _options.OrphanedProductDataRetentionDays, - _options.BatchSize, - token - ); - }, - ct - ); -} diff --git a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/EmailRetryRecurringJob.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/EmailRetryRecurringJob.cs deleted file mode 100644 index 5654d478..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/EmailRetryRecurringJob.cs +++ /dev/null @@ -1,63 +0,0 @@ -using APITemplate.Application.Common.BackgroundJobs; -using APITemplate.Application.Common.Options; -using APITemplate.Infrastructure.BackgroundJobs.TickerQ.Coordination; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using TickerQ.Utilities.Base; - -namespace APITemplate.Infrastructure.BackgroundJobs.TickerQ.Jobs; - -/// -/// TickerQ recurring job that retries previously failed emails and dead-letters those that have -/// exceeded the configured retry window, delegating to . -/// Execution is gated by to prevent multi-node duplication. -/// -public sealed class EmailRetryRecurringJob -{ - private readonly IEmailRetryService _emailRetryService; - private readonly IDistributedJobCoordinator _coordinator; - private readonly EmailRetryJobOptions _options; - private readonly ILogger _logger; - - public EmailRetryRecurringJob( - IEmailRetryService emailRetryService, - IDistributedJobCoordinator coordinator, - IOptions options, - ILogger logger - ) - { - _emailRetryService = emailRetryService; - _coordinator = coordinator; - _options = options.Value.EmailRetry; - _logger = logger; - } - - /// - /// TickerQ entry-point that acquires the distributed leader lease and runs retry and - /// dead-letter operations using settings from . - /// - [TickerFunction(TickerQFunctionNames.EmailRetry)] - public Task ExecuteAsync(TickerFunctionContext context, CancellationToken ct) => - _coordinator.ExecuteIfLeaderAsync( - TickerQFunctionNames.EmailRetry, - async token => - { - _logger.LogInformation( - "Executing email retry recurring job for ticker {TickerId}.", - context.Id - ); - - await _emailRetryService.RetryFailedEmailsAsync( - _options.MaxRetryAttempts, - _options.BatchSize, - token - ); - await _emailRetryService.DeadLetterExpiredAsync( - _options.DeadLetterAfterHours, - _options.BatchSize, - token - ); - }, - ct - ); -} diff --git a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/ExternalSyncRecurringJob.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/ExternalSyncRecurringJob.cs deleted file mode 100644 index 536b82d0..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/ExternalSyncRecurringJob.cs +++ /dev/null @@ -1,45 +0,0 @@ -using APITemplate.Application.Common.BackgroundJobs; -using APITemplate.Infrastructure.BackgroundJobs.TickerQ.Coordination; -using Microsoft.Extensions.Logging; -using TickerQ.Utilities.Base; - -namespace APITemplate.Infrastructure.BackgroundJobs.TickerQ.Jobs; - -/// -/// TickerQ recurring job that triggers synchronization with configured external integrations -/// through . -/// Execution is gated by to prevent multi-node duplication. -/// -public sealed class ExternalSyncRecurringJob -{ - private readonly IExternalIntegrationSyncService _syncService; - private readonly IDistributedJobCoordinator _coordinator; - private readonly ILogger _logger; - - public ExternalSyncRecurringJob( - IExternalIntegrationSyncService syncService, - IDistributedJobCoordinator coordinator, - ILogger logger - ) - { - _syncService = syncService; - _coordinator = coordinator; - _logger = logger; - } - - /// TickerQ entry-point that acquires the distributed leader lease and invokes the sync service. - [TickerFunction(TickerQFunctionNames.ExternalSync)] - public Task ExecuteAsync(TickerFunctionContext context, CancellationToken ct) => - _coordinator.ExecuteIfLeaderAsync( - TickerQFunctionNames.ExternalSync, - async token => - { - _logger.LogInformation( - "Executing external integration sync recurring job for ticker {TickerId}.", - context.Id - ); - await _syncService.SynchronizeAsync(token); - }, - ct - ); -} diff --git a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/ReindexRecurringJob.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/ReindexRecurringJob.cs deleted file mode 100644 index 08e14347..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/Jobs/ReindexRecurringJob.cs +++ /dev/null @@ -1,44 +0,0 @@ -using APITemplate.Application.Common.BackgroundJobs; -using APITemplate.Infrastructure.BackgroundJobs.TickerQ.Coordination; -using Microsoft.Extensions.Logging; -using TickerQ.Utilities.Base; - -namespace APITemplate.Infrastructure.BackgroundJobs.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/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/CleanupRecurringJobRegistration.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/CleanupRecurringJobRegistration.cs deleted file mode 100644 index 76a153e0..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/CleanupRecurringJobRegistration.cs +++ /dev/null @@ -1,21 +0,0 @@ -using APITemplate.Application.Common.BackgroundJobs; -using APITemplate.Application.Common.Options; - -namespace APITemplate.Infrastructure.BackgroundJobs.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 invitation, soft-delete, and orphaned ProductData cleanup." - ); -} diff --git a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/EmailRetryRecurringJobRegistration.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/EmailRetryRecurringJobRegistration.cs deleted file mode 100644 index b5465980..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/EmailRetryRecurringJobRegistration.cs +++ /dev/null @@ -1,21 +0,0 @@ -using APITemplate.Application.Common.BackgroundJobs; -using APITemplate.Application.Common.Options; - -namespace APITemplate.Infrastructure.BackgroundJobs.TickerQ.RecurringJobRegistrations; - -/// -/// Provides the for the email-retry recurring job, -/// sourcing schedule and enablement from . -/// -public sealed class EmailRetryRecurringJobRegistration : IRecurringBackgroundJobRegistration -{ - /// Builds the email-retry job definition from the supplied options. - public RecurringBackgroundJobDefinition Build(BackgroundJobsOptions options) => - new( - TickerQJobIds.EmailRetry, - TickerQFunctionNames.EmailRetry, - options.EmailRetry.Cron, - options.EmailRetry.Enabled, - "Retries failed emails and dead-letters expired retry records." - ); -} diff --git a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/ExternalSyncRecurringJobRegistration.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/ExternalSyncRecurringJobRegistration.cs deleted file mode 100644 index bb29ed51..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/ExternalSyncRecurringJobRegistration.cs +++ /dev/null @@ -1,21 +0,0 @@ -using APITemplate.Application.Common.BackgroundJobs; -using APITemplate.Application.Common.Options; - -namespace APITemplate.Infrastructure.BackgroundJobs.TickerQ.RecurringJobRegistrations; - -/// -/// Provides the for the external-sync recurring job, -/// sourcing schedule and enablement from . -/// -public sealed class ExternalSyncRecurringJobRegistration : IRecurringBackgroundJobRegistration -{ - /// Builds the external-sync job definition from the supplied options. - public RecurringBackgroundJobDefinition Build(BackgroundJobsOptions options) => - new( - TickerQJobIds.ExternalSync, - TickerQFunctionNames.ExternalSync, - options.ExternalSync.Cron, - options.ExternalSync.Enabled, - "Runs periodic synchronization for configured external integrations." - ); -} diff --git a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/ReindexRecurringJobRegistration.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/ReindexRecurringJobRegistration.cs deleted file mode 100644 index 2fd2a632..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/RecurringJobRegistrations/ReindexRecurringJobRegistration.cs +++ /dev/null @@ -1,21 +0,0 @@ -using APITemplate.Application.Common.BackgroundJobs; -using APITemplate.Application.Common.Options; - -namespace APITemplate.Infrastructure.BackgroundJobs.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/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQFunctionNames.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQFunctionNames.cs deleted file mode 100644 index 6d5b3ce5..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQFunctionNames.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace APITemplate.Infrastructure.BackgroundJobs.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 ExternalSync = "external-sync-recurring-job"; - public const string Cleanup = "cleanup-recurring-job"; - public const string Reindex = "reindex-recurring-job"; - public const string EmailRetry = "email-retry-recurring-job"; -} diff --git a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQJobIds.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQJobIds.cs deleted file mode 100644 index 3712b627..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQJobIds.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace APITemplate.Infrastructure.BackgroundJobs.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 ExternalSync = new("d3870105-2cdb-4d6c-a2a6-3843bd459018"); - public static readonly Guid Cleanup = new("4bc6790c-c877-43ed-8a32-85d5fa2dad95"); - public static readonly Guid Reindex = new("9cf4e6ef-a2dd-4ff7-8968-174a6236a59f"); - public static readonly Guid EmailRetry = new("31261201-e220-45d0-bd7e-6d662ca1acaf"); -} diff --git a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQRecurringJobRegistrar.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQRecurringJobRegistrar.cs deleted file mode 100644 index 0f213a5c..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQRecurringJobRegistrar.cs +++ /dev/null @@ -1,113 +0,0 @@ -using APITemplate.Application.Common.BackgroundJobs; -using APITemplate.Application.Common.Options; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using TickerQ.Utilities.Entities; - -namespace APITemplate.Infrastructure.BackgroundJobs.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 = "APITemplate: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) - { - var now = _timeProvider.GetUtcNow().UtcDateTime; - var definitions = _registrations.Select(x => x.Build(_options)).ToList(); - var tickersById = (await _dbContext.Set().ToListAsync(ct)).ToDictionary( - x => x.Id - ); - - foreach (var definition in definitions) - { - if (!tickersById.TryGetValue(definition.Id, out var existing)) - { - var entity = new CronTickerEntity - { - 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); - var 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/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQSchedulerDbContext.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQSchedulerDbContext.cs deleted file mode 100644 index eae177ae..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQSchedulerDbContext.cs +++ /dev/null @@ -1,28 +0,0 @@ -using APITemplate.Application.Common.Options; -using Microsoft.EntityFrameworkCore; -using TickerQ.EntityFrameworkCore.DbContextFactory; -using TickerQ.Utilities.Entities; - -namespace APITemplate.Infrastructure.BackgroundJobs.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 (var entityType in modelBuilder.Model.GetEntityTypes()) - { - entityType.SetSchema(TickerQSchedulerOptions.DefaultSchemaName); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQSchedulerDbContextFactory.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQSchedulerDbContextFactory.cs deleted file mode 100644 index c581d210..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/TickerQ/TickerQSchedulerDbContextFactory.cs +++ /dev/null @@ -1,36 +0,0 @@ -using APITemplate.Application.Common.Options; -using APITemplate.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Design; - -namespace APITemplate.Infrastructure.BackgroundJobs.TickerQ; - -/// -/// Design-time factory for , enabling EF Core CLI -/// migration commands (dotnet ef migrations add) without a running host. -/// Connection string is resolved from appsettings.json, appsettings.Development.json, -/// and environment variables, falling back to a local development default. -/// -public sealed class TickerQSchedulerDbContextFactory - : IDesignTimeDbContextFactory -{ - /// Creates a configured for tooling use. - public TickerQSchedulerDbContext CreateDbContext(string[] args) - { - var configuration = DesignTimeConfigurationHelper.BuildConfiguration(); - var connectionString = DesignTimeConfigurationHelper.GetConnectionString(configuration); - - var options = new DbContextOptionsBuilder() - .UseNpgsql( - connectionString, - npgsql => - npgsql.MigrationsHistoryTable( - "__EFMigrationsHistory", - TickerQSchedulerOptions.DefaultSchemaName - ) - ) - .Options; - - return new TickerQSchedulerDbContext(options); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Validation/BackgroundJobsOptionsValidator.cs b/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Validation/BackgroundJobsOptionsValidator.cs deleted file mode 100644 index 8e433ff2..00000000 --- a/absolute/src/APITemplate.Infrastructure/BackgroundJobs/Validation/BackgroundJobsOptionsValidator.cs +++ /dev/null @@ -1,165 +0,0 @@ -using APITemplate.Application.Common.Options; -using Microsoft.Extensions.Options; -using NCrontab; - -namespace APITemplate.Infrastructure.BackgroundJobs.Validation; - -/// -/// Validates at startup, ensuring all enabled jobs have -/// well-formed cron expressions and positive numeric settings before the application starts accepting traffic. -/// -public sealed class BackgroundJobsOptionsValidator : IValidateOptions -{ - /// - /// Validates all sub-option groups; returns a combined failure result if any setting is invalid, - /// or if all are well-formed. - /// - public ValidateOptionsResult Validate(string? name, BackgroundJobsOptions options) - { - ArgumentNullException.ThrowIfNull(options); - - List failures = []; - - ValidateTickerQ(options.TickerQ, failures); - ValidateCleanup(options.Cleanup, failures); - ValidateReindex(options.Reindex, failures); - ValidateExternalSync(options.ExternalSync, failures); - ValidateEmailRetry(options.EmailRetry, failures); - - return failures.Count == 0 - ? ValidateOptionsResult.Success - : ValidateOptionsResult.Fail(failures); - } - - private static void ValidateTickerQ(TickerQSchedulerOptions options, List failures) - { - if (!options.Enabled) - { - return; - } - - if (string.IsNullOrWhiteSpace(options.InstanceNamePrefix)) - { - failures.Add("BackgroundJobs:TickerQ:InstanceNamePrefix is required."); - } - - if ( - !string.Equals( - options.CoordinationConnection, - TickerQSchedulerOptions.DefaultCoordinationConnection, - StringComparison.OrdinalIgnoreCase - ) - ) - { - failures.Add( - $"BackgroundJobs:TickerQ:CoordinationConnection must be '{TickerQSchedulerOptions.DefaultCoordinationConnection}'." - ); - } - } - - private static void ValidateCleanup(CleanupJobOptions options, List failures) - { - if (!options.Enabled) - { - return; - } - - ValidateCron("BackgroundJobs:Cleanup:Cron", options.Cron, failures); - ValidatePositive("BackgroundJobs:Cleanup:BatchSize", options.BatchSize, failures); - ValidateNonNegative( - "BackgroundJobs:Cleanup:ExpiredInvitationRetentionHours", - options.ExpiredInvitationRetentionHours, - failures - ); - ValidateNonNegative( - "BackgroundJobs:Cleanup:SoftDeleteRetentionDays", - options.SoftDeleteRetentionDays, - failures - ); - ValidateNonNegative( - "BackgroundJobs:Cleanup:OrphanedProductDataRetentionDays", - options.OrphanedProductDataRetentionDays, - failures - ); - } - - private static void ValidateReindex(ReindexJobOptions options, List failures) - { - if (!options.Enabled) - { - return; - } - - ValidateCron("BackgroundJobs:Reindex:Cron", options.Cron, failures); - } - - private static void ValidateExternalSync(ExternalSyncJobOptions options, List failures) - { - if (!options.Enabled) - { - return; - } - - ValidateCron("BackgroundJobs:ExternalSync:Cron", options.Cron, failures); - } - - private static void ValidateEmailRetry(EmailRetryJobOptions options, List failures) - { - if (!options.Enabled) - { - return; - } - - ValidateCron("BackgroundJobs:EmailRetry:Cron", options.Cron, failures); - ValidatePositive("BackgroundJobs:EmailRetry:BatchSize", options.BatchSize, failures); - ValidatePositive( - "BackgroundJobs:EmailRetry:MaxRetryAttempts", - options.MaxRetryAttempts, - failures - ); - ValidatePositive( - "BackgroundJobs:EmailRetry:ClaimLeaseMinutes", - options.ClaimLeaseMinutes, - failures - ); - ValidateNonNegative( - "BackgroundJobs:EmailRetry:DeadLetterAfterHours", - options.DeadLetterAfterHours, - failures - ); - } - - private static void ValidateCron(string path, string cron, List failures) - { - if (string.IsNullOrWhiteSpace(cron)) - { - failures.Add($"{path} is required."); - return; - } - - try - { - CrontabSchedule.Parse(cron); - } - catch (CrontabException) - { - failures.Add($"{path} must be a valid 5-part CRON expression."); - } - } - - private static void ValidatePositive(string path, int value, List failures) - { - if (value <= 0) - { - failures.Add($"{path} must be greater than 0."); - } - } - - private static void ValidateNonNegative(string path, int value, List failures) - { - if (value < 0) - { - failures.Add($"{path} must be greater than or equal to 0."); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Database/Procedures/claim_expired_failed_emails_v1_down.sql b/absolute/src/APITemplate.Infrastructure/Database/Procedures/claim_expired_failed_emails_v1_down.sql deleted file mode 100644 index 62e9c782..00000000 --- a/absolute/src/APITemplate.Infrastructure/Database/Procedures/claim_expired_failed_emails_v1_down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP FUNCTION IF EXISTS claim_expired_failed_emails(TIMESTAMPTZ, INT, TEXT, TIMESTAMPTZ, TIMESTAMPTZ); diff --git a/absolute/src/APITemplate.Infrastructure/Database/Procedures/claim_expired_failed_emails_v1_up.sql b/absolute/src/APITemplate.Infrastructure/Database/Procedures/claim_expired_failed_emails_v1_up.sql deleted file mode 100644 index f9d3743f..00000000 --- a/absolute/src/APITemplate.Infrastructure/Database/Procedures/claim_expired_failed_emails_v1_up.sql +++ /dev/null @@ -1,47 +0,0 @@ -CREATE FUNCTION claim_expired_failed_emails( - p_cutoff TIMESTAMPTZ, - p_batch_size INT, - p_claimed_by TEXT, - p_claimed_at_utc TIMESTAMPTZ, - p_claimed_until_utc TIMESTAMPTZ -) -RETURNS TABLE( - "Id" UUID, - "To" TEXT, - "Subject" TEXT, - "HtmlBody" TEXT, - "RetryCount" INT, - "CreatedAtUtc" TIMESTAMPTZ, - "LastAttemptAtUtc" TIMESTAMPTZ, - "LastError" TEXT, - "TemplateName" TEXT, - "IsDeadLettered" BOOLEAN, - "ClaimedBy" TEXT, - "ClaimedAtUtc" TIMESTAMPTZ, - "ClaimedUntilUtc" TIMESTAMPTZ -) -LANGUAGE plpgsql AS $$ -BEGIN - RETURN QUERY - WITH claimed AS ( - SELECT fe."Id" - FROM "FailedEmails" fe - WHERE NOT fe."IsDeadLettered" - AND fe."CreatedAtUtc" < p_cutoff - AND (fe."ClaimedUntilUtc" IS NULL OR fe."ClaimedUntilUtc" < p_claimed_at_utc) - ORDER BY fe."CreatedAtUtc" - LIMIT p_batch_size - FOR UPDATE SKIP LOCKED - ) - UPDATE "FailedEmails" AS failed - SET "ClaimedBy" = p_claimed_by, - "ClaimedAtUtc" = p_claimed_at_utc, - "ClaimedUntilUtc" = p_claimed_until_utc - FROM claimed - WHERE failed."Id" = claimed."Id" - RETURNING failed."Id", failed."To", failed."Subject", failed."HtmlBody", - failed."RetryCount", failed."CreatedAtUtc", failed."LastAttemptAtUtc", - failed."LastError", failed."TemplateName", failed."IsDeadLettered", - failed."ClaimedBy", failed."ClaimedAtUtc", failed."ClaimedUntilUtc"; -END; -$$; diff --git a/absolute/src/APITemplate.Infrastructure/Database/Procedures/claim_retryable_failed_emails_v1_down.sql b/absolute/src/APITemplate.Infrastructure/Database/Procedures/claim_retryable_failed_emails_v1_down.sql deleted file mode 100644 index 3dba5d31..00000000 --- a/absolute/src/APITemplate.Infrastructure/Database/Procedures/claim_retryable_failed_emails_v1_down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP FUNCTION IF EXISTS claim_retryable_failed_emails(INT, INT, TEXT, TIMESTAMPTZ, TIMESTAMPTZ); diff --git a/absolute/src/APITemplate.Infrastructure/Database/Procedures/claim_retryable_failed_emails_v1_up.sql b/absolute/src/APITemplate.Infrastructure/Database/Procedures/claim_retryable_failed_emails_v1_up.sql deleted file mode 100644 index 15db2fba..00000000 --- a/absolute/src/APITemplate.Infrastructure/Database/Procedures/claim_retryable_failed_emails_v1_up.sql +++ /dev/null @@ -1,47 +0,0 @@ -CREATE FUNCTION claim_retryable_failed_emails( - p_max_retry_attempts INT, - p_batch_size INT, - p_claimed_by TEXT, - p_claimed_at_utc TIMESTAMPTZ, - p_claimed_until_utc TIMESTAMPTZ -) -RETURNS TABLE( - "Id" UUID, - "To" TEXT, - "Subject" TEXT, - "HtmlBody" TEXT, - "RetryCount" INT, - "CreatedAtUtc" TIMESTAMPTZ, - "LastAttemptAtUtc" TIMESTAMPTZ, - "LastError" TEXT, - "TemplateName" TEXT, - "IsDeadLettered" BOOLEAN, - "ClaimedBy" TEXT, - "ClaimedAtUtc" TIMESTAMPTZ, - "ClaimedUntilUtc" TIMESTAMPTZ -) -LANGUAGE plpgsql AS $$ -BEGIN - RETURN QUERY - WITH claimed AS ( - SELECT fe."Id" - FROM "FailedEmails" fe - WHERE NOT fe."IsDeadLettered" - AND fe."RetryCount" < p_max_retry_attempts - AND (fe."ClaimedUntilUtc" IS NULL OR fe."ClaimedUntilUtc" < p_claimed_at_utc) - ORDER BY COALESCE(fe."LastAttemptAtUtc", fe."CreatedAtUtc") - LIMIT p_batch_size - FOR UPDATE SKIP LOCKED - ) - UPDATE "FailedEmails" AS failed - SET "ClaimedBy" = p_claimed_by, - "ClaimedAtUtc" = p_claimed_at_utc, - "ClaimedUntilUtc" = p_claimed_until_utc - FROM claimed - WHERE failed."Id" = claimed."Id" - RETURNING failed."Id", failed."To", failed."Subject", failed."HtmlBody", - failed."RetryCount", failed."CreatedAtUtc", failed."LastAttemptAtUtc", - failed."LastError", failed."TemplateName", failed."IsDeadLettered", - failed."ClaimedBy", failed."ClaimedAtUtc", failed."ClaimedUntilUtc"; -END; -$$; diff --git a/absolute/src/APITemplate.Infrastructure/Database/Procedures/get_fts_index_names_v1_down.sql b/absolute/src/APITemplate.Infrastructure/Database/Procedures/get_fts_index_names_v1_down.sql deleted file mode 100644 index c9065a8d..00000000 --- a/absolute/src/APITemplate.Infrastructure/Database/Procedures/get_fts_index_names_v1_down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP FUNCTION IF EXISTS get_fts_index_names(); diff --git a/absolute/src/APITemplate.Infrastructure/Database/Procedures/get_fts_index_names_v1_up.sql b/absolute/src/APITemplate.Infrastructure/Database/Procedures/get_fts_index_names_v1_up.sql deleted file mode 100644 index dd433bb2..00000000 --- a/absolute/src/APITemplate.Infrastructure/Database/Procedures/get_fts_index_names_v1_up.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE FUNCTION get_fts_index_names() -RETURNS TABLE("Value" TEXT) -LANGUAGE sql STABLE AS $$ - SELECT indexname::TEXT AS "Value" - FROM pg_indexes - WHERE schemaname = 'public' - AND indexdef LIKE '%to_tsvector%'; -$$; diff --git a/absolute/src/APITemplate.Infrastructure/Database/Procedures/get_index_bloat_percent_v1_down.sql b/absolute/src/APITemplate.Infrastructure/Database/Procedures/get_index_bloat_percent_v1_down.sql deleted file mode 100644 index 09ab783f..00000000 --- a/absolute/src/APITemplate.Infrastructure/Database/Procedures/get_index_bloat_percent_v1_down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP FUNCTION IF EXISTS get_index_bloat_percent(TEXT); diff --git a/absolute/src/APITemplate.Infrastructure/Database/Procedures/get_index_bloat_percent_v1_up.sql b/absolute/src/APITemplate.Infrastructure/Database/Procedures/get_index_bloat_percent_v1_up.sql deleted file mode 100644 index 975340c2..00000000 --- a/absolute/src/APITemplate.Infrastructure/Database/Procedures/get_index_bloat_percent_v1_up.sql +++ /dev/null @@ -1,23 +0,0 @@ -CREATE FUNCTION get_index_bloat_percent(p_index_name TEXT) -RETURNS TABLE("Value" DOUBLE PRECISION) -LANGUAGE sql STABLE AS $$ - SELECT CASE - WHEN pg_relation_size(c.oid) = 0 THEN 0 - ELSE GREATEST(0, - 100.0 * (1.0 - ( - (s.n_live_tup::float * COALESCE(NULLIF(avg_w.avg_width, 0), 32) / 0.9) - / NULLIF(pg_relation_size(c.oid)::float, 0) - )) - ) - END AS "Value" - FROM pg_class c - JOIN pg_stat_user_indexes si ON si.indexrelid = c.oid - JOIN pg_stat_user_tables s ON s.relid = si.relid - CROSS JOIN LATERAL ( - SELECT COALESCE(SUM(ps.avg_width), 32)::int AS avg_width - FROM pg_stats ps - WHERE ps.schemaname = 'public' - AND ps.tablename = s.relname - ) avg_w - WHERE c.relname = p_index_name; -$$; diff --git a/absolute/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v1_down.sql b/absolute/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v1_down.sql deleted file mode 100644 index 7717173b..00000000 --- a/absolute/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v1_down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP FUNCTION IF EXISTS get_product_category_stats(UUID); diff --git a/absolute/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v1_up.sql b/absolute/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v1_up.sql deleted file mode 100644 index d15dcdc3..00000000 --- a/absolute/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v1_up.sql +++ /dev/null @@ -1,24 +0,0 @@ -CREATE FUNCTION get_product_category_stats(p_category_id UUID) -RETURNS TABLE( - "CategoryId" UUID, - "CategoryName" TEXT, - "ProductCount" BIGINT, - "AveragePrice" NUMERIC, - "TotalReviews" BIGINT -) -LANGUAGE plpgsql AS $$ -BEGIN - RETURN QUERY - SELECT - c."Id" AS "CategoryId", - c."Name"::TEXT AS "CategoryName", - COUNT(DISTINCT p."Id") AS "ProductCount", - COALESCE(AVG(p."Price"), 0) AS "AveragePrice", - COUNT(pr."Id") AS "TotalReviews" - 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; -$$; diff --git a/absolute/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v2_down.sql b/absolute/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v2_down.sql deleted file mode 100644 index 8c1f8474..00000000 --- a/absolute/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v2_down.sql +++ /dev/null @@ -1,25 +0,0 @@ -DROP FUNCTION IF EXISTS get_product_category_stats(UUID, UUID); -CREATE FUNCTION get_product_category_stats(p_category_id UUID) -RETURNS TABLE( - "CategoryId" UUID, - "CategoryName" TEXT, - "ProductCount" BIGINT, - "AveragePrice" NUMERIC, - "TotalReviews" BIGINT -) -LANGUAGE plpgsql AS $$ -BEGIN - RETURN QUERY - SELECT - c."Id" AS "CategoryId", - c."Name"::TEXT AS "CategoryName", - COUNT(DISTINCT p."Id") AS "ProductCount", - COALESCE(AVG(p."Price"), 0) AS "AveragePrice", - COUNT(pr."Id") AS "TotalReviews" - 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; -$$; diff --git a/absolute/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v2_up.sql b/absolute/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v2_up.sql deleted file mode 100644 index 87f996fc..00000000 --- a/absolute/src/APITemplate.Infrastructure/Database/Procedures/get_product_category_stats_v2_up.sql +++ /dev/null @@ -1,26 +0,0 @@ -CREATE FUNCTION get_product_category_stats(p_category_id UUID, p_tenant_id UUID) -RETURNS TABLE( - "CategoryId" UUID, - "CategoryName" TEXT, - "ProductCount" BIGINT, - "AveragePrice" NUMERIC, - "TotalReviews" BIGINT -) -LANGUAGE plpgsql AS $$ -BEGIN - RETURN QUERY - SELECT - c."Id" AS "CategoryId", - c."Name"::TEXT AS "CategoryName", - COUNT(DISTINCT p."Id") AS "ProductCount", - COALESCE(AVG(p."Price"), 0) AS "AveragePrice", - COUNT(pr."Id") AS "TotalReviews" - FROM "Categories" c - LEFT JOIN "Products" p ON p."CategoryId" = c."Id" AND p."TenantId" = p_tenant_id AND NOT p."IsDeleted" - LEFT JOIN "ProductReviews" pr ON pr."ProductId" = p."Id" AND pr."TenantId" = p_tenant_id AND NOT pr."IsDeleted" - WHERE c."Id" = p_category_id - AND c."TenantId" = p_tenant_id - AND NOT c."IsDeleted" - GROUP BY c."Id", c."Name"; -END; -$$; diff --git a/absolute/src/APITemplate.Infrastructure/Database/SqlResource.cs b/absolute/src/APITemplate.Infrastructure/Database/SqlResource.cs deleted file mode 100644 index c087b983..00000000 --- a/absolute/src/APITemplate.Infrastructure/Database/SqlResource.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Reflection; - -namespace APITemplate.Infrastructure.Database; - -/// -/// Loads embedded SQL files from Infrastructure/Database/**. -/// SQL files are compiled into the assembly as embedded resources, -/// so they work correctly after publish without relying on the file system. -/// -public static class SqlResource -{ - private const string Namespace = "APITemplate.Infrastructure.Database"; - - public static string Load(string relativeResourcePath) - { - var normalizedPath = relativeResourcePath - .Replace('\\', '.') - .Replace('/', '.'); - - var resourceName = $"{Namespace}.{normalizedPath}"; - 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/absolute/src/APITemplate.Infrastructure/Database/Triggers/row_version_triggers_v1_down.sql b/absolute/src/APITemplate.Infrastructure/Database/Triggers/row_version_triggers_v1_down.sql deleted file mode 100644 index b02fb45d..00000000 --- a/absolute/src/APITemplate.Infrastructure/Database/Triggers/row_version_triggers_v1_down.sql +++ /dev/null @@ -1,6 +0,0 @@ -DROP TRIGGER IF EXISTS trg_set_row_version_users ON "Users"; -DROP TRIGGER IF EXISTS trg_set_row_version_tenants ON "Tenants"; -DROP TRIGGER IF EXISTS trg_set_row_version_product_reviews ON "ProductReviews"; -DROP TRIGGER IF EXISTS trg_set_row_version_products ON "Products"; -DROP TRIGGER IF EXISTS trg_set_row_version_categories ON "Categories"; -DROP FUNCTION IF EXISTS set_row_version(); diff --git a/absolute/src/APITemplate.Infrastructure/Database/Triggers/row_version_triggers_v1_up.sql b/absolute/src/APITemplate.Infrastructure/Database/Triggers/row_version_triggers_v1_up.sql deleted file mode 100644 index 60092e65..00000000 --- a/absolute/src/APITemplate.Infrastructure/Database/Triggers/row_version_triggers_v1_up.sql +++ /dev/null @@ -1,59 +0,0 @@ -CREATE OR REPLACE FUNCTION set_row_version() -RETURNS trigger -LANGUAGE plpgsql -AS $$ -BEGIN - NEW."RowVersion" := decode(md5(random()::text || clock_timestamp()::text || pg_current_xact_id()::text), 'hex'); - RETURN NEW; -END; -$$; - -UPDATE "Categories" -SET "RowVersion" = decode(md5(random()::text || clock_timestamp()::text || pg_current_xact_id()::text), 'hex') -WHERE "RowVersion" IS NULL; - -UPDATE "Products" -SET "RowVersion" = decode(md5(random()::text || clock_timestamp()::text || pg_current_xact_id()::text), 'hex') -WHERE "RowVersion" IS NULL; - -UPDATE "ProductReviews" -SET "RowVersion" = decode(md5(random()::text || clock_timestamp()::text || pg_current_xact_id()::text), 'hex') -WHERE "RowVersion" IS NULL; - -UPDATE "Tenants" -SET "RowVersion" = decode(md5(random()::text || clock_timestamp()::text || pg_current_xact_id()::text), 'hex') -WHERE "RowVersion" IS NULL; - -UPDATE "Users" -SET "RowVersion" = decode(md5(random()::text || clock_timestamp()::text || pg_current_xact_id()::text), 'hex') -WHERE "RowVersion" IS NULL; - -DROP TRIGGER IF EXISTS trg_set_row_version_categories ON "Categories"; -CREATE TRIGGER trg_set_row_version_categories -BEFORE INSERT OR UPDATE ON "Categories" -FOR EACH ROW -EXECUTE FUNCTION set_row_version(); - -DROP TRIGGER IF EXISTS trg_set_row_version_products ON "Products"; -CREATE TRIGGER trg_set_row_version_products -BEFORE INSERT OR UPDATE ON "Products" -FOR EACH ROW -EXECUTE FUNCTION set_row_version(); - -DROP TRIGGER IF EXISTS trg_set_row_version_product_reviews ON "ProductReviews"; -CREATE TRIGGER trg_set_row_version_product_reviews -BEFORE INSERT OR UPDATE ON "ProductReviews" -FOR EACH ROW -EXECUTE FUNCTION set_row_version(); - -DROP TRIGGER IF EXISTS trg_set_row_version_tenants ON "Tenants"; -CREATE TRIGGER trg_set_row_version_tenants -BEFORE INSERT OR UPDATE ON "Tenants" -FOR EACH ROW -EXECUTE FUNCTION set_row_version(); - -DROP TRIGGER IF EXISTS trg_set_row_version_users ON "Users"; -CREATE TRIGGER trg_set_row_version_users -BEFORE INSERT OR UPDATE ON "Users" -FOR EACH ROW -EXECUTE FUNCTION set_row_version(); diff --git a/absolute/src/APITemplate.Infrastructure/Email/ChannelEmailQueue.cs b/absolute/src/APITemplate.Infrastructure/Email/ChannelEmailQueue.cs deleted file mode 100644 index 3ce7a458..00000000 --- a/absolute/src/APITemplate.Infrastructure/Email/ChannelEmailQueue.cs +++ /dev/null @@ -1,20 +0,0 @@ -using APITemplate.Application.Common.Email; -using APITemplate.Infrastructure.BackgroundJobs.Services; - -namespace APITemplate.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/absolute/src/APITemplate.Infrastructure/Email/EmailSendingBackgroundService.cs b/absolute/src/APITemplate.Infrastructure/Email/EmailSendingBackgroundService.cs deleted file mode 100644 index a65ae3f8..00000000 --- a/absolute/src/APITemplate.Infrastructure/Email/EmailSendingBackgroundService.cs +++ /dev/null @@ -1,66 +0,0 @@ -using APITemplate.Application.Common.Email; -using APITemplate.Application.Common.Resilience; -using APITemplate.Infrastructure.BackgroundJobs.Services; -using Microsoft.Extensions.Logging; -using Polly.Registry; - -namespace APITemplate.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) - { - var pipeline = _resiliencePipelineProvider.GetPipeline(ResiliencePipelineKeys.SmtpSend); - - 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/absolute/src/APITemplate.Infrastructure/Email/FailedEmailErrorNormalizer.cs b/absolute/src/APITemplate.Infrastructure/Email/FailedEmailErrorNormalizer.cs deleted file mode 100644 index be2910e2..00000000 --- a/absolute/src/APITemplate.Infrastructure/Email/FailedEmailErrorNormalizer.cs +++ /dev/null @@ -1,21 +0,0 @@ -using APITemplate.Domain.Entities; - -namespace APITemplate.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/absolute/src/APITemplate.Infrastructure/Email/FailedEmailStore.cs b/absolute/src/APITemplate.Infrastructure/Email/FailedEmailStore.cs deleted file mode 100644 index 82cb54c5..00000000 --- a/absolute/src/APITemplate.Infrastructure/Email/FailedEmailStore.cs +++ /dev/null @@ -1,89 +0,0 @@ -using APITemplate.Application.Common.Email; -using APITemplate.Application.Common.Options; -using APITemplate.Domain.Entities; -using APITemplate.Domain.Interfaces; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace APITemplate.Infrastructure.Email; - -/// -/// Infrastructure implementation of that persists a -/// record when delivery fails, provided the email is marked retryable and the email-retry job is enabled. -/// 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 bool _enabled; - private readonly ILogger _logger; - - public FailedEmailStore( - IServiceScopeFactory scopeFactory, - IOptions options, - ILogger logger - ) - { - _scopeFactory = scopeFactory; - _enabled = options.Value.EmailRetry.Enabled; - _logger = logger; - } - - /// - /// Persists a new for if the message is - /// retryable and the email-retry feature is enabled; silently swallows storage errors to avoid - /// masking the original send failure. - /// - public async Task StoreFailedAsync( - EmailMessage message, - string error, - CancellationToken ct = default - ) - { - if (!_enabled || !message.Retryable) - { - return; - } - - try - { - using var scope = _scopeFactory.CreateScope(); - var repository = scope.ServiceProvider.GetRequiredService(); - var unitOfWork = scope.ServiceProvider.GetRequiredService(); - var timeProvider = scope.ServiceProvider.GetRequiredService(); - - var failedEmail = new FailedEmail - { - 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 unitOfWork.CommitAsync(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/absolute/src/APITemplate.Infrastructure/Email/FluidEmailTemplateRenderer.cs b/absolute/src/APITemplate.Infrastructure/Email/FluidEmailTemplateRenderer.cs deleted file mode 100644 index 59177c75..00000000 --- a/absolute/src/APITemplate.Infrastructure/Email/FluidEmailTemplateRenderer.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System.Collections.Concurrent; -using System.Reflection; -using APITemplate.Application.Common.Email; -using Fluid; - -namespace APITemplate.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 - ) - { - var template = await GetOrParseTemplateAsync(templateName); - var context = new TemplateContext(model); - return await template.RenderAsync(context); - } - - private static async Task GetOrParseTemplateAsync(string templateName) - { - if (TemplateCache.TryGetValue(templateName, out var cached)) - return cached; - - var templateContent = await LoadTemplateAsync(templateName); - - if (!Parser.TryParse(templateContent, out var template, out var error)) - throw new InvalidOperationException( - $"Failed to parse email template '{templateName}': {error}" - ); - - TemplateCache.TryAdd(templateName, template); - return template; - } - - private static async Task LoadTemplateAsync(string templateName) - { - var resourceName = - $"{ResourceAssembly.GetName().Name}.Email.Templates.{templateName}.liquid"; - - await using var stream = - ResourceAssembly.GetManifestResourceStream(resourceName) - ?? throw new InvalidOperationException( - $"Email template '{templateName}' not found as embedded resource '{resourceName}'." - ); - - using var reader = new StreamReader(stream); - return await reader.ReadToEndAsync(); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Email/MailKitEmailSender.cs b/absolute/src/APITemplate.Infrastructure/Email/MailKitEmailSender.cs deleted file mode 100644 index d78fc711..00000000 --- a/absolute/src/APITemplate.Infrastructure/Email/MailKitEmailSender.cs +++ /dev/null @@ -1,52 +0,0 @@ -using APITemplate.Application.Common.Email; -using APITemplate.Application.Common.Options; -using MailKit.Net.Smtp; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using MimeKit; - -namespace APITemplate.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/absolute/src/APITemplate.Infrastructure/Email/Templates/tenant-invitation.liquid b/absolute/src/APITemplate.Infrastructure/Email/Templates/tenant-invitation.liquid deleted file mode 100644 index 7919b675..00000000 --- a/absolute/src/APITemplate.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/absolute/src/APITemplate.Infrastructure/Email/Templates/user-registration.liquid b/absolute/src/APITemplate.Infrastructure/Email/Templates/user-registration.liquid deleted file mode 100644 index f0f37e0e..00000000 --- a/absolute/src/APITemplate.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/absolute/src/APITemplate.Infrastructure/Email/Templates/user-role-changed.liquid b/absolute/src/APITemplate.Infrastructure/Email/Templates/user-role-changed.liquid deleted file mode 100644 index 21222fa4..00000000 --- a/absolute/src/APITemplate.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/absolute/src/APITemplate.Infrastructure/FileStorage/LocalFileStorageService.cs b/absolute/src/APITemplate.Infrastructure/FileStorage/LocalFileStorageService.cs deleted file mode 100644 index 39140671..00000000 --- a/absolute/src/APITemplate.Infrastructure/FileStorage/LocalFileStorageService.cs +++ /dev/null @@ -1,110 +0,0 @@ -using APITemplate.Application.Common.Context; -using APITemplate.Application.Common.Contracts; -using APITemplate.Application.Common.Options; -using Microsoft.Extensions.Options; - -namespace APITemplate.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 - ) - { - var tenantDir = Path.Combine(_options.BasePath, _tenantProvider.TenantId.ToString()); - Directory.CreateDirectory(tenantDir); - - var safeExtension = Path.GetExtension(Path.GetFileName(fileName)); - var storedFileName = $"{Guid.NewGuid()}{safeExtension}"; - var storagePath = Path.Combine(tenantDir, storedFileName); - - ValidatePathWithinBasePath(storagePath); - - long sizeBytes; - await using ( - var 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) - { - var fullPath = Path.GetFullPath(path); - var 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/absolute/src/APITemplate.Infrastructure/GlobalUsings.cs b/absolute/src/APITemplate.Infrastructure/GlobalUsings.cs deleted file mode 100644 index 038e0642..00000000 --- a/absolute/src/APITemplate.Infrastructure/GlobalUsings.cs +++ /dev/null @@ -1,18 +0,0 @@ -global using APITemplate.Application.Common.Context; -global using APITemplate.Application.Common.DTOs; -global using APITemplate.Application.Common.Errors; -global using APITemplate.Application.Common.Options; -global using APITemplate.Application.Common.Options.BackgroundJobs; -global using APITemplate.Application.Common.Options.Infrastructure; -global using APITemplate.Application.Common.Options.Security; -global using APITemplate.Application.Common.Security; -global using APITemplate.Application.Features.Category.DTOs; -global using APITemplate.Application.Features.Product.DTOs; -global using APITemplate.Application.Features.Product.Repositories; -global using APITemplate.Application.Features.Product.Specifications; -global using APITemplate.Domain.Common; -global using APITemplate.Domain.Entities.Contracts; -global using APITemplate.Domain.Entities.ProductData; -global using APITemplate.Domain.Interfaces; -global using APITemplate.Infrastructure.Security.Keycloak; -global using APITemplate.Infrastructure.Security.Tenant; diff --git a/absolute/src/APITemplate.Infrastructure/Health/HealthCheckNames.cs b/absolute/src/APITemplate.Infrastructure/Health/HealthCheckNames.cs deleted file mode 100644 index 0e141cef..00000000 --- a/absolute/src/APITemplate.Infrastructure/Health/HealthCheckNames.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace APITemplate.Infrastructure.Health; - -public static class HealthCheckNames -{ - public const string PostgreSql = "postgresql"; - public const string MongoDb = "mongodb"; - public const string Keycloak = "keycloak"; - public const string Dragonfly = "dragonfly"; -} diff --git a/absolute/src/APITemplate.Infrastructure/Health/KeycloakHealthCheck.cs b/absolute/src/APITemplate.Infrastructure/Health/KeycloakHealthCheck.cs deleted file mode 100644 index e1e52c71..00000000 --- a/absolute/src/APITemplate.Infrastructure/Health/KeycloakHealthCheck.cs +++ /dev/null @@ -1,53 +0,0 @@ -using APITemplate.Application.Common.Options; -using APITemplate.Infrastructure.Security; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Options; - -namespace APITemplate.Infrastructure.Health; - -/// -/// ASP.NET Core health check that verifies Keycloak availability by probing its -/// OpenID Connect discovery endpoint with a 5-second timeout. -/// -public sealed class KeycloakHealthCheck : IHealthCheck -{ - private static readonly TimeSpan CheckTimeout = TimeSpan.FromSeconds(5); - - private readonly HttpClient _httpClient; - private readonly string _discoveryUrl; - - public KeycloakHealthCheck( - IHttpClientFactory httpClientFactory, - IOptions keycloakOptions - ) - { - _httpClient = httpClientFactory.CreateClient(nameof(KeycloakHealthCheck)); - var keycloak = keycloakOptions.Value; - _discoveryUrl = KeycloakUrlHelper.BuildDiscoveryUrl(keycloak.AuthServerUrl, keycloak.Realm); - } - - /// - /// Issues an HTTP GET to the Keycloak discovery URL and returns - /// on a 2xx response, or on a non-success status or exception. - /// - public async Task CheckHealthAsync( - HealthCheckContext context, - CancellationToken cancellationToken = default - ) - { - try - { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(CheckTimeout); - - var response = await _httpClient.GetAsync(_discoveryUrl, cts.Token); - return response.IsSuccessStatusCode - ? HealthCheckResult.Healthy() - : HealthCheckResult.Unhealthy($"Keycloak returned {(int)response.StatusCode}"); - } - catch (Exception ex) - { - return HealthCheckResult.Unhealthy("Keycloak is not reachable", ex); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Health/MongoDbHealthCheck.cs b/absolute/src/APITemplate.Infrastructure/Health/MongoDbHealthCheck.cs deleted file mode 100644 index 894cb340..00000000 --- a/absolute/src/APITemplate.Infrastructure/Health/MongoDbHealthCheck.cs +++ /dev/null @@ -1,39 +0,0 @@ -using APITemplate.Infrastructure.Persistence; -using Microsoft.Extensions.Diagnostics.HealthChecks; - -namespace APITemplate.Infrastructure.Health; - -/// -/// ASP.NET Core health check that verifies MongoDB availability by sending a ping command -/// to the configured database with a 5-second timeout. -/// -public sealed class MongoDbHealthCheck : IHealthCheck -{ - private static readonly TimeSpan CheckTimeout = TimeSpan.FromSeconds(5); - - private readonly MongoDbContext _context; - - public MongoDbHealthCheck(MongoDbContext context) => _context = context; - - /// - /// Pings MongoDB within the timeout and returns - /// on success or if the ping fails. - /// - public async Task CheckHealthAsync( - HealthCheckContext context, - CancellationToken cancellationToken = default - ) - { - try - { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(CheckTimeout); - await _context.PingAsync(cts.Token); - return HealthCheckResult.Healthy(); - } - catch - { - return HealthCheckResult.Unhealthy("MongoDB is not reachable"); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Idempotency/DistributedCacheIdempotencyStore.cs b/absolute/src/APITemplate.Infrastructure/Idempotency/DistributedCacheIdempotencyStore.cs deleted file mode 100644 index 2eada643..00000000 --- a/absolute/src/APITemplate.Infrastructure/Idempotency/DistributedCacheIdempotencyStore.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System.Collections.Concurrent; -using System.Text.Json; -using APITemplate.Application.Common.Contracts; -using StackExchange.Redis; - -namespace APITemplate.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 - ) - { - var 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 - ) - { - var lockKey = KeyPrefix + key + IdempotencyStoreConstants.LockSuffix; - var lockValue = Guid.NewGuid().ToString("N"); - - var 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 - ) - { - var 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 var lockValue)) - return; - - var lockKey = KeyPrefix + key + IdempotencyStoreConstants.LockSuffix; - await _database.ScriptEvaluateAsync( - ReleaseLockScript, - new { key = (RedisKey)lockKey, value = (RedisValue)lockValue } - ); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Idempotency/IdempotencyStoreConstants.cs b/absolute/src/APITemplate.Infrastructure/Idempotency/IdempotencyStoreConstants.cs deleted file mode 100644 index 1cdaa5c3..00000000 --- a/absolute/src/APITemplate.Infrastructure/Idempotency/IdempotencyStoreConstants.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace APITemplate.Infrastructure.Idempotency; - -/// Shared key-naming constants used by and . -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/absolute/src/APITemplate.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs b/absolute/src/APITemplate.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs deleted file mode 100644 index f1a4fc4e..00000000 --- a/absolute/src/APITemplate.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System.Collections.Concurrent; -using System.Text.Json; -using APITemplate.Application.Common.Contracts; - -namespace APITemplate.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 var entry) && entry.Expiry > _timeProvider.GetUtcNow()) - { - var 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(); - - var lockKey = key + IdempotencyStoreConstants.LockSuffix; - var lockValue = Guid.NewGuid().ToString("N"); - var expiry = _timeProvider.GetUtcNow().Add(ttl); - var 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 - ) - { - var json = JsonSerializer.Serialize(entry); - var 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 var lockValue)) - return Task.CompletedTask; - - var 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() - { - var now = _timeProvider.GetUtcNow(); - foreach (var kvp in _store) - { - if (kvp.Value.Expiry <= now) - _store.TryRemove(kvp); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Logging/ActivityTraceEnricher.cs b/absolute/src/APITemplate.Infrastructure/Logging/ActivityTraceEnricher.cs deleted file mode 100644 index 2aec40bd..00000000 --- a/absolute/src/APITemplate.Infrastructure/Logging/ActivityTraceEnricher.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Diagnostics; -using Serilog.Core; -using Serilog.Events; - -namespace APITemplate.Infrastructure.Logging; - -/// -/// Serilog that appends W3C-format TraceId and SpanId -/// properties from the current to every log event, -/// enabling correlation between structured logs and distributed traces. -/// -public sealed class ActivityTraceEnricher : ILogEventEnricher -{ - /// Reads the current ambient activity and adds TraceId and SpanId properties when non-default values are present. - public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) - { - var activity = Activity.Current; - if (activity is null) - return; - - if (activity.TraceId != default) - { - logEvent.AddPropertyIfAbsent( - propertyFactory.CreateProperty("TraceId", activity.TraceId.ToHexString()) - ); - } - - if (activity.SpanId != default) - { - logEvent.AddPropertyIfAbsent( - propertyFactory.CreateProperty("SpanId", activity.SpanId.ToHexString()) - ); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Logging/LogDataClassifications.cs b/absolute/src/APITemplate.Infrastructure/Logging/LogDataClassifications.cs deleted file mode 100644 index 22abd63b..00000000 --- a/absolute/src/APITemplate.Infrastructure/Logging/LogDataClassifications.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Microsoft.Extensions.Compliance.Classification; - -namespace APITemplate.Infrastructure.Logging; - -/// -/// Defines the project-specific taxonomy used to classify -/// log parameters for compliance-aware redaction in the Microsoft.Extensions.Compliance pipeline. -/// -public static class LogDataClassifications -{ - private const string TaxonomyName = "APITemplate"; - - /// Classification for personally identifiable information such as email addresses and names. - public static DataClassification Personal => new(TaxonomyName, nameof(Personal)); - - /// Classification for sensitive business data that must not appear in plain-text logs. - public static DataClassification Sensitive => new(TaxonomyName, nameof(Sensitive)); - - /// Classification for data that carries no privacy concern and may be logged as-is. - public static DataClassification Public => new(TaxonomyName, nameof(Public)); -} - -/// -/// Marks a log parameter or property as personally identifiable information, causing it to be -/// redacted by the configured classification policy. -/// -[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] -public sealed class PersonalDataAttribute : DataClassificationAttribute -{ - public PersonalDataAttribute() - : base(LogDataClassifications.Personal) { } -} - -/// -/// Marks a log parameter or property as sensitive business data, causing it to be -/// redacted by the configured classification policy. -/// -[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] -public sealed class SensitiveDataAttribute : DataClassificationAttribute -{ - public SensitiveDataAttribute() - : base(LogDataClassifications.Sensitive) { } -} diff --git a/absolute/src/APITemplate.Infrastructure/Logging/RedactionConfiguration.cs b/absolute/src/APITemplate.Infrastructure/Logging/RedactionConfiguration.cs deleted file mode 100644 index 3ad740d2..00000000 --- a/absolute/src/APITemplate.Infrastructure/Logging/RedactionConfiguration.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace APITemplate.Infrastructure.Logging; - -/// -/// Provides helper methods for resolving redaction configuration values, centralising the -/// precedence logic (environment variable first, then options, then error) used at startup. -/// -public static class RedactionConfiguration -{ - /// - /// Resolves the HMAC key for log redaction by checking the environment variable named in - /// first, then falling back to the inline HmacKey value. - /// Throws if neither source provides a non-empty key. - /// - public static string ResolveHmacKey( - RedactionOptions options, - Func getEnvironmentVariable - ) - { - var key = getEnvironmentVariable(options.HmacKeyEnvironmentVariable); - if (!string.IsNullOrWhiteSpace(key)) - return key; - - if (!string.IsNullOrWhiteSpace(options.HmacKey)) - return options.HmacKey; - - throw new InvalidOperationException( - $"Missing redaction HMAC key. Set environment variable '{options.HmacKeyEnvironmentVariable}' or configure 'Redaction:HmacKey'." - ); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260302153430_AddCategory.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260302153430_AddCategory.Designer.cs deleted file mode 100644 index 6c033d08..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260302153430_AddCategory.Designer.cs +++ /dev/null @@ -1,174 +0,0 @@ -// -using System; -using APITemplate.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 APITemplate.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260302153430_AddCategory")] - partial class AddCategory - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("APITemplate.Domain.Entities.Category", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("now()"); - - b.Property("Description") - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("Id"); - - b.ToTable("Categories"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CategoryId") - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("now()"); - - b.Property("Description") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Price") - .HasPrecision(18, 2) - .HasColumnType("numeric(18,2)"); - - b.HasKey("Id"); - - b.HasIndex("CategoryId"); - - b.ToTable("Products"); - }); - - modelBuilder.Entity("APITemplate.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"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.ProductReview", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Comment") - .HasMaxLength(2000) - .HasColumnType("character varying(2000)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("now()"); - - b.Property("ProductId") - .HasColumnType("uuid"); - - b.Property("Rating") - .HasColumnType("integer"); - - b.Property("ReviewerName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("Id"); - - b.HasIndex("ProductId"); - - b.ToTable("ProductReviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("Category"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Product"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("Reviews"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260302153430_AddCategory.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260302153430_AddCategory.cs deleted file mode 100644 index 8a6af202..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260302153430_AddCategory.cs +++ /dev/null @@ -1,95 +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/absolute/src/APITemplate.Infrastructure/Migrations/20260304124643_AddMultiTenantAuditSoftDelete.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260304124643_AddMultiTenantAuditSoftDelete.Designer.cs deleted file mode 100644 index 9f2899f4..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260304124643_AddMultiTenantAuditSoftDelete.Designer.cs +++ /dev/null @@ -1,534 +0,0 @@ -// -using System; -using APITemplate.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 APITemplate.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260304124643_AddMultiTenantAuditSoftDelete")] - partial class AddMultiTenantAuditSoftDelete - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("APITemplate.Domain.Entities.AppUser", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("now()"); - - b.Property("CreatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("PasswordHash") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("now()"); - - b.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "Email") - .IsUnique(); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "Username") - .IsUnique(); - - b.ToTable("Users", t => - { - t.HasCheckConstraint("CK_Users_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Category", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("now()"); - - b.Property("CreatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("now()"); - - b.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - 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("APITemplate.Domain.Entities.Product", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CategoryId") - .HasColumnType("uuid"); - - b.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("now()"); - - b.Property("CreatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("now()"); - - b.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system"); - - b.HasKey("Id"); - - b.HasIndex("CategoryId"); - - b.HasIndex("TenantId"); - - 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("APITemplate.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("APITemplate.Domain.Entities.ProductReview", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Comment") - .HasMaxLength(2000) - .HasColumnType("character varying(2000)"); - - b.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("now()"); - - b.Property("CreatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("ProductId") - .HasColumnType("uuid"); - - b.Property("Rating") - .HasColumnType("integer"); - - b.Property("ReviewerName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("now()"); - - b.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system"); - - b.HasKey("Id"); - - b.HasIndex("ProductId"); - - 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("APITemplate.Domain.Entities.Tenant", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Code") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("CreatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("now()"); - - b.Property("CreatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("now()"); - - b.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system"); - - 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("APITemplate.Domain.Entities.AppUser", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Tenant"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Category", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Category"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Product"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("Reviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260304124643_AddMultiTenantAuditSoftDelete.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260304124643_AddMultiTenantAuditSoftDelete.cs deleted file mode 100644 index f697b9f4..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260304124643_AddMultiTenantAuditSoftDelete.cs +++ /dev/null @@ -1,617 +0,0 @@ -using System; -using APITemplate.Infrastructure.Database; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Migrations -{ - /// - public partial class AddMultiTenantAuditSoftDelete : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - var defaultTenantId = new Guid("11111111-1111-1111-1111-111111111111"); - - migrationBuilder.RenameColumn( - name: "CreatedAt", - table: "Products", - newName: "UpdatedAtUtc"); - - migrationBuilder.RenameColumn( - name: "CreatedAt", - table: "ProductReviews", - newName: "UpdatedAtUtc"); - - migrationBuilder.RenameColumn( - name: "CreatedAt", - table: "Categories", - newName: "UpdatedAtUtc"); - - migrationBuilder.AddColumn( - name: "CreatedAtUtc", - table: "Products", - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()"); - - migrationBuilder.AddColumn( - name: "CreatedBy", - table: "Products", - type: "character varying(200)", - maxLength: 200, - nullable: false, - defaultValue: "system"); - - migrationBuilder.AddColumn( - name: "DeletedAtUtc", - table: "Products", - type: "timestamp with time zone", - nullable: true); - - migrationBuilder.AddColumn( - name: "DeletedBy", - table: "Products", - type: "character varying(200)", - maxLength: 200, - nullable: true); - - migrationBuilder.AddColumn( - name: "IsDeleted", - table: "Products", - type: "boolean", - nullable: false, - defaultValue: false); - - migrationBuilder.AddColumn( - name: "RowVersion", - table: "Products", - type: "bytea", - rowVersion: true, - nullable: false, - defaultValue: new byte[0]); - - migrationBuilder.AddColumn( - name: "TenantId", - table: "Products", - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); - - migrationBuilder.AddColumn( - name: "UpdatedBy", - table: "Products", - type: "character varying(200)", - maxLength: 200, - nullable: false, - defaultValue: "system"); - - migrationBuilder.AddColumn( - name: "CreatedAtUtc", - table: "ProductReviews", - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()"); - - migrationBuilder.AddColumn( - name: "CreatedBy", - table: "ProductReviews", - type: "character varying(200)", - maxLength: 200, - nullable: false, - defaultValue: "system"); - - migrationBuilder.AddColumn( - name: "DeletedAtUtc", - table: "ProductReviews", - type: "timestamp with time zone", - nullable: true); - - migrationBuilder.AddColumn( - name: "DeletedBy", - table: "ProductReviews", - type: "character varying(200)", - maxLength: 200, - nullable: true); - - migrationBuilder.AddColumn( - name: "IsDeleted", - table: "ProductReviews", - type: "boolean", - nullable: false, - defaultValue: false); - - migrationBuilder.AddColumn( - name: "RowVersion", - table: "ProductReviews", - type: "bytea", - rowVersion: true, - nullable: false, - defaultValue: new byte[0]); - - migrationBuilder.AddColumn( - name: "TenantId", - table: "ProductReviews", - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); - - migrationBuilder.AddColumn( - name: "UpdatedBy", - table: "ProductReviews", - type: "character varying(200)", - maxLength: 200, - nullable: false, - defaultValue: "system"); - - migrationBuilder.AddColumn( - name: "CreatedAtUtc", - table: "Categories", - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()"); - - migrationBuilder.AddColumn( - name: "CreatedBy", - table: "Categories", - type: "character varying(200)", - maxLength: 200, - nullable: false, - defaultValue: "system"); - - migrationBuilder.AddColumn( - name: "DeletedAtUtc", - table: "Categories", - type: "timestamp with time zone", - nullable: true); - - migrationBuilder.AddColumn( - name: "DeletedBy", - table: "Categories", - type: "character varying(200)", - maxLength: 200, - nullable: true); - - migrationBuilder.AddColumn( - name: "IsDeleted", - table: "Categories", - type: "boolean", - nullable: false, - defaultValue: false); - - migrationBuilder.AddColumn( - name: "RowVersion", - table: "Categories", - type: "bytea", - rowVersion: true, - nullable: false, - defaultValue: new byte[0]); - - migrationBuilder.AddColumn( - name: "TenantId", - table: "Categories", - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); - - migrationBuilder.AddColumn( - name: "UpdatedBy", - table: "Categories", - type: "character varying(200)", - maxLength: 200, - nullable: false, - 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), - 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) - }, - 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: "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), - 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) - }, - 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.InsertData( - table: "Tenants", - columns: new[] - { - "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($""" - UPDATE "Categories" - SET "CreatedAtUtc" = "UpdatedAtUtc", - "TenantId" = '{defaultTenantId}', - "CreatedBy" = 'migration', - "UpdatedBy" = 'migration' - WHERE "TenantId" = '00000000-0000-0000-0000-000000000000'; - """); - - migrationBuilder.Sql($""" - UPDATE "Products" - SET "CreatedAtUtc" = "UpdatedAtUtc", - "TenantId" = '{defaultTenantId}', - "CreatedBy" = 'migration', - "UpdatedBy" = 'migration' - WHERE "TenantId" = '00000000-0000-0000-0000-000000000000'; - """); - - 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"); - - 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" }); - - migrationBuilder.AddCheckConstraint( - name: "CK_Products_SoftDeleteConsistency", - table: "Products", - sql: "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - - 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" }); - - migrationBuilder.AddCheckConstraint( - name: "CK_ProductReviews_SoftDeleteConsistency", - table: "ProductReviews", - sql: "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - - 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.AddCheckConstraint( - name: "CK_Categories_SoftDeleteConsistency", - table: "Categories", - sql: "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - - 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_TenantId", - table: "Users", - column: "TenantId"); - - migrationBuilder.CreateIndex( - name: "IX_Users_TenantId_Email", - table: "Users", - columns: new[] { "TenantId", "Email" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_Users_TenantId_IsDeleted", - table: "Users", - columns: new[] { "TenantId", "IsDeleted" }); - - migrationBuilder.CreateIndex( - name: "IX_Users_TenantId_Username", - table: "Users", - columns: new[] { "TenantId", "Username" }, - unique: true); - - migrationBuilder.AddForeignKey( - name: "FK_Categories_Tenants_TenantId", - table: "Categories", - column: "TenantId", - principalTable: "Tenants", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - - migrationBuilder.AddForeignKey( - name: "FK_ProductReviews_Tenants_TenantId", - table: "ProductReviews", - column: "TenantId", - principalTable: "Tenants", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - - migrationBuilder.AddForeignKey( - name: "FK_Products_Tenants_TenantId", - table: "Products", - column: "TenantId", - principalTable: "Tenants", - principalColumn: "Id", - 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")); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_Categories_Tenants_TenantId", - table: "Categories"); - - migrationBuilder.DropForeignKey( - name: "FK_ProductReviews_Tenants_TenantId", - table: "ProductReviews"); - - migrationBuilder.DropForeignKey( - name: "FK_Products_Tenants_TenantId", - table: "Products"); - - migrationBuilder.DropTable( - name: "Users"); - - migrationBuilder.DropTable( - name: "Tenants"); - - migrationBuilder.DropIndex( - name: "IX_Products_TenantId", - table: "Products"); - - migrationBuilder.DropIndex( - name: "IX_Products_TenantId_IsDeleted", - table: "Products"); - - migrationBuilder.DropIndex( - name: "IX_Products_TenantId_Name", - table: "Products"); - - migrationBuilder.DropCheckConstraint( - name: "CK_Products_SoftDeleteConsistency", - table: "Products"); - - migrationBuilder.DropIndex( - name: "IX_ProductReviews_TenantId", - table: "ProductReviews"); - - migrationBuilder.DropIndex( - name: "IX_ProductReviews_TenantId_IsDeleted", - table: "ProductReviews"); - - migrationBuilder.DropIndex( - name: "IX_ProductReviews_TenantId_ProductId", - table: "ProductReviews"); - - migrationBuilder.DropCheckConstraint( - name: "CK_ProductReviews_SoftDeleteConsistency", - table: "ProductReviews"); - - migrationBuilder.DropIndex( - name: "IX_Categories_TenantId", - table: "Categories"); - - migrationBuilder.DropIndex( - name: "IX_Categories_TenantId_IsDeleted", - table: "Categories"); - - migrationBuilder.DropIndex( - name: "IX_Categories_TenantId_Name", - table: "Categories"); - - migrationBuilder.DropCheckConstraint( - name: "CK_Categories_SoftDeleteConsistency", - table: "Categories"); - - migrationBuilder.DropColumn( - name: "CreatedAtUtc", - table: "Products"); - - migrationBuilder.DropColumn( - name: "CreatedBy", - table: "Products"); - - migrationBuilder.DropColumn( - name: "DeletedAtUtc", - table: "Products"); - - migrationBuilder.DropColumn( - name: "DeletedBy", - table: "Products"); - - migrationBuilder.DropColumn( - name: "IsDeleted", - table: "Products"); - - migrationBuilder.DropColumn( - name: "RowVersion", - table: "Products"); - - migrationBuilder.DropColumn( - name: "TenantId", - table: "Products"); - - migrationBuilder.DropColumn( - name: "UpdatedBy", - table: "Products"); - - migrationBuilder.DropColumn( - name: "CreatedAtUtc", - table: "ProductReviews"); - - migrationBuilder.DropColumn( - name: "CreatedBy", - table: "ProductReviews"); - - migrationBuilder.DropColumn( - name: "DeletedAtUtc", - table: "ProductReviews"); - - migrationBuilder.DropColumn( - name: "DeletedBy", - table: "ProductReviews"); - - migrationBuilder.DropColumn( - name: "IsDeleted", - table: "ProductReviews"); - - migrationBuilder.DropColumn( - name: "RowVersion", - table: "ProductReviews"); - - migrationBuilder.DropColumn( - name: "TenantId", - table: "ProductReviews"); - - migrationBuilder.DropColumn( - name: "UpdatedBy", - table: "ProductReviews"); - - migrationBuilder.DropColumn( - name: "CreatedAtUtc", - table: "Categories"); - - migrationBuilder.DropColumn( - name: "CreatedBy", - table: "Categories"); - - migrationBuilder.DropColumn( - name: "DeletedAtUtc", - table: "Categories"); - - migrationBuilder.DropColumn( - name: "DeletedBy", - table: "Categories"); - - migrationBuilder.DropColumn( - name: "IsDeleted", - table: "Categories"); - - migrationBuilder.DropColumn( - name: "RowVersion", - table: "Categories"); - - migrationBuilder.DropColumn( - name: "TenantId", - table: "Categories"); - - migrationBuilder.DropColumn( - name: "UpdatedBy", - table: "Categories"); - - migrationBuilder.RenameColumn( - name: "UpdatedAtUtc", - table: "Products", - newName: "CreatedAt"); - - migrationBuilder.RenameColumn( - name: "UpdatedAtUtc", - table: "ProductReviews", - newName: "CreatedAt"); - - migrationBuilder.RenameColumn( - name: "UpdatedAtUtc", - table: "Categories", - newName: "CreatedAt"); - - migrationBuilder.Sql(SqlResource.Load("Procedures.get_product_category_stats_v2_down.sql")); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260304174656_AddUserRoleForPlatformAdmin.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260304174656_AddUserRoleForPlatformAdmin.Designer.cs deleted file mode 100644 index 919be8a6..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260304174656_AddUserRoleForPlatformAdmin.Designer.cs +++ /dev/null @@ -1,644 +0,0 @@ -// -using System; -using APITemplate.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 APITemplate.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260304174656_AddUserRoleForPlatformAdmin")] - partial class AddUserRoleForPlatformAdmin - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("APITemplate.Domain.Entities.AppUser", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("PasswordHash") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("Role") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasDefaultValue("TenantUser"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "Email") - .IsUnique(); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "Username") - .IsUnique(); - - b.ToTable("Users", t => - { - t.HasCheckConstraint("CK_Users_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Category", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - 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("APITemplate.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") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("CategoryId"); - - b.HasIndex("TenantId"); - - 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("APITemplate.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("APITemplate.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") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("ProductId") - .HasColumnType("uuid"); - - b.Property("Rating") - .HasColumnType("integer"); - - b.Property("ReviewerName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("ProductId"); - - 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("APITemplate.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") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - 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("APITemplate.Domain.Entities.AppUser", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("AppUserId"); - - b1.ToTable("Users"); - - b1.WithOwner() - .HasForeignKey("AppUserId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Tenant"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Category", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("CategoryId"); - - b1.ToTable("Categories"); - - b1.WithOwner() - .HasForeignKey("CategoryId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("ProductId"); - - b1.ToTable("Products"); - - b1.WithOwner() - .HasForeignKey("ProductId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Category"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("ProductReviewId"); - - b1.ToTable("ProductReviews"); - - b1.WithOwner() - .HasForeignKey("ProductReviewId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Product"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("TenantId"); - - b1.ToTable("Tenants"); - - b1.WithOwner() - .HasForeignKey("TenantId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("Reviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260304174656_AddUserRoleForPlatformAdmin.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260304174656_AddUserRoleForPlatformAdmin.cs deleted file mode 100644 index 128e1179..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260304174656_AddUserRoleForPlatformAdmin.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Migrations -{ - /// - public partial class AddUserRoleForPlatformAdmin : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "Role", - table: "Users", - type: "character varying(32)", - maxLength: 32, - nullable: false, - defaultValue: "TenantUser"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "Role", - table: "Users"); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260304181202_AddNormalizedUsernameForAuth.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260304181202_AddNormalizedUsernameForAuth.Designer.cs deleted file mode 100644 index b13361c4..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260304181202_AddNormalizedUsernameForAuth.Designer.cs +++ /dev/null @@ -1,651 +0,0 @@ -// -using System; -using APITemplate.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 APITemplate.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260304181202_AddNormalizedUsernameForAuth")] - partial class AddNormalizedUsernameForAuth - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("APITemplate.Domain.Entities.AppUser", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("NormalizedUsername") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("Role") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasDefaultValue("TenantUser"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedUsername") - .IsUnique(); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "Email") - .IsUnique(); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "Username"); - - b.ToTable("Users", t => - { - t.HasCheckConstraint("CK_Users_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Category", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - 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("APITemplate.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") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("CategoryId"); - - b.HasIndex("TenantId"); - - 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("APITemplate.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("APITemplate.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") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("ProductId") - .HasColumnType("uuid"); - - b.Property("Rating") - .HasColumnType("integer"); - - b.Property("ReviewerName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("ProductId"); - - 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("APITemplate.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") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - 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("APITemplate.Domain.Entities.AppUser", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("AppUserId"); - - b1.ToTable("Users"); - - b1.WithOwner() - .HasForeignKey("AppUserId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Tenant"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Category", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("CategoryId"); - - b1.ToTable("Categories"); - - b1.WithOwner() - .HasForeignKey("CategoryId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("ProductId"); - - b1.ToTable("Products"); - - b1.WithOwner() - .HasForeignKey("ProductId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Category"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("ProductReviewId"); - - b1.ToTable("ProductReviews"); - - b1.WithOwner() - .HasForeignKey("ProductReviewId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Product"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("TenantId"); - - b1.ToTable("Tenants"); - - b1.WithOwner() - .HasForeignKey("TenantId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("Reviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260304181202_AddNormalizedUsernameForAuth.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260304181202_AddNormalizedUsernameForAuth.cs deleted file mode 100644 index 59c0051a..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260304181202_AddNormalizedUsernameForAuth.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Migrations -{ - /// - public partial class AddNormalizedUsernameForAuth : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Users_TenantId_Username", - table: "Users"); - - migrationBuilder.AddColumn( - name: "NormalizedUsername", - table: "Users", - type: "character varying(100)", - maxLength: 100, - nullable: true); - - migrationBuilder.Sql( - """ - UPDATE "Users" - SET "NormalizedUsername" = UPPER(TRIM("Username")) - WHERE "NormalizedUsername" IS NULL; - """); - - migrationBuilder.AlterColumn( - name: "NormalizedUsername", - table: "Users", - type: "character varying(100)", - maxLength: 100, - nullable: false, - oldClrType: typeof(string), - oldType: "character varying(100)", - oldMaxLength: 100, - oldNullable: true); - - migrationBuilder.CreateIndex( - name: "IX_Users_NormalizedUsername", - table: "Users", - column: "NormalizedUsername", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_Users_TenantId_Username", - table: "Users", - columns: new[] { "TenantId", "Username" }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Users_NormalizedUsername", - table: "Users"); - - migrationBuilder.DropIndex( - name: "IX_Users_TenantId_Username", - table: "Users"); - - migrationBuilder.DropColumn( - name: "NormalizedUsername", - table: "Users"); - - migrationBuilder.CreateIndex( - name: "IX_Users_TenantId_Username", - table: "Users", - columns: new[] { "TenantId", "Username" }, - unique: true); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260304185009_AddPostgresRowVersionTriggers.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260304185009_AddPostgresRowVersionTriggers.Designer.cs deleted file mode 100644 index 5c91c7b2..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260304185009_AddPostgresRowVersionTriggers.Designer.cs +++ /dev/null @@ -1,651 +0,0 @@ -// -using System; -using APITemplate.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 APITemplate.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260304185009_AddPostgresRowVersionTriggers")] - partial class AddPostgresRowVersionTriggers - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("APITemplate.Domain.Entities.AppUser", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("NormalizedUsername") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("Role") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasDefaultValue("TenantUser"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedUsername") - .IsUnique(); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "Email") - .IsUnique(); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "Username"); - - b.ToTable("Users", t => - { - t.HasCheckConstraint("CK_Users_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Category", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - 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("APITemplate.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") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("CategoryId"); - - b.HasIndex("TenantId"); - - 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("APITemplate.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("APITemplate.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") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("ProductId") - .HasColumnType("uuid"); - - b.Property("Rating") - .HasColumnType("integer"); - - b.Property("ReviewerName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("ProductId"); - - 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("APITemplate.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") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - 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("APITemplate.Domain.Entities.AppUser", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("AppUserId"); - - b1.ToTable("Users"); - - b1.WithOwner() - .HasForeignKey("AppUserId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Tenant"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Category", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("CategoryId"); - - b1.ToTable("Categories"); - - b1.WithOwner() - .HasForeignKey("CategoryId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("ProductId"); - - b1.ToTable("Products"); - - b1.WithOwner() - .HasForeignKey("ProductId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Category"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("ProductReviewId"); - - b1.ToTable("ProductReviews"); - - b1.WithOwner() - .HasForeignKey("ProductReviewId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Product"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("TenantId"); - - b1.ToTable("Tenants"); - - b1.WithOwner() - .HasForeignKey("TenantId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("Reviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260304185009_AddPostgresRowVersionTriggers.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260304185009_AddPostgresRowVersionTriggers.cs deleted file mode 100644 index be71082e..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260304185009_AddPostgresRowVersionTriggers.cs +++ /dev/null @@ -1,23 +0,0 @@ -using APITemplate.Infrastructure.Database; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Migrations -{ - /// - public partial class AddPostgresRowVersionTriggers : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql(SqlResource.Load("Triggers.row_version_triggers_v1_up.sql")); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql(SqlResource.Load("Triggers.row_version_triggers_v1_down.sql")); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260304193511_MakeNormalizedUsernameUniquePerTenant.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260304193511_MakeNormalizedUsernameUniquePerTenant.Designer.cs deleted file mode 100644 index ed010408..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260304193511_MakeNormalizedUsernameUniquePerTenant.Designer.cs +++ /dev/null @@ -1,649 +0,0 @@ -// -using System; -using APITemplate.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 APITemplate.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260304193511_MakeNormalizedUsernameUniquePerTenant")] - partial class MakeNormalizedUsernameUniquePerTenant - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("APITemplate.Domain.Entities.AppUser", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("NormalizedUsername") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("Role") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasDefaultValue("TenantUser"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "Email") - .IsUnique(); - - b.HasIndex("TenantId", "IsDeleted"); - - 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("APITemplate.Domain.Entities.Category", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - 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("APITemplate.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") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("CategoryId"); - - b.HasIndex("TenantId"); - - 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("APITemplate.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("APITemplate.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") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("ProductId") - .HasColumnType("uuid"); - - b.Property("Rating") - .HasColumnType("integer"); - - b.Property("ReviewerName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("ProductId"); - - 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("APITemplate.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") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - 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("APITemplate.Domain.Entities.AppUser", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("AppUserId"); - - b1.ToTable("Users"); - - b1.WithOwner() - .HasForeignKey("AppUserId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Tenant"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Category", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("CategoryId"); - - b1.ToTable("Categories"); - - b1.WithOwner() - .HasForeignKey("CategoryId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("ProductId"); - - b1.ToTable("Products"); - - b1.WithOwner() - .HasForeignKey("ProductId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Category"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("ProductReviewId"); - - b1.ToTable("ProductReviews"); - - b1.WithOwner() - .HasForeignKey("ProductReviewId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Product"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("TenantId"); - - b1.ToTable("Tenants"); - - b1.WithOwner() - .HasForeignKey("TenantId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("Reviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260304193511_MakeNormalizedUsernameUniquePerTenant.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260304193511_MakeNormalizedUsernameUniquePerTenant.cs deleted file mode 100644 index 2ab8ebbb..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260304193511_MakeNormalizedUsernameUniquePerTenant.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Migrations -{ - /// - public partial class MakeNormalizedUsernameUniquePerTenant : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Users_NormalizedUsername", - 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); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Users_TenantId_NormalizedUsername", - table: "Users"); - - migrationBuilder.CreateIndex( - name: "IX_Users_NormalizedUsername", - table: "Users", - column: "NormalizedUsername", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_Users_TenantId_Username", - table: "Users", - columns: new[] { "TenantId", "Username" }); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260304194634_DisablePostgresRowVersionTriggersForAppManagedConcurrency.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260304194634_DisablePostgresRowVersionTriggersForAppManagedConcurrency.Designer.cs deleted file mode 100644 index dbd85bbb..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260304194634_DisablePostgresRowVersionTriggersForAppManagedConcurrency.Designer.cs +++ /dev/null @@ -1,644 +0,0 @@ -// -using System; -using APITemplate.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 APITemplate.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260304194634_DisablePostgresRowVersionTriggersForAppManagedConcurrency")] - partial class DisablePostgresRowVersionTriggersForAppManagedConcurrency - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("APITemplate.Domain.Entities.AppUser", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("NormalizedUsername") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("Role") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasDefaultValue("TenantUser"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "Email") - .IsUnique(); - - b.HasIndex("TenantId", "IsDeleted"); - - 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("APITemplate.Domain.Entities.Category", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - 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("APITemplate.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") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("CategoryId"); - - b.HasIndex("TenantId"); - - 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("APITemplate.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("APITemplate.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") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("ProductId") - .HasColumnType("uuid"); - - b.Property("Rating") - .HasColumnType("integer"); - - b.Property("ReviewerName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("ProductId"); - - 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("APITemplate.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") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - 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("APITemplate.Domain.Entities.AppUser", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("AppUserId"); - - b1.ToTable("Users"); - - b1.WithOwner() - .HasForeignKey("AppUserId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Tenant"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Category", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("CategoryId"); - - b1.ToTable("Categories"); - - b1.WithOwner() - .HasForeignKey("CategoryId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("ProductId"); - - b1.ToTable("Products"); - - b1.WithOwner() - .HasForeignKey("ProductId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Category"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("ProductReviewId"); - - b1.ToTable("ProductReviews"); - - b1.WithOwner() - .HasForeignKey("ProductReviewId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Product"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("TenantId"); - - b1.ToTable("Tenants"); - - b1.WithOwner() - .HasForeignKey("TenantId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("Reviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260304194634_DisablePostgresRowVersionTriggersForAppManagedConcurrency.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260304194634_DisablePostgresRowVersionTriggersForAppManagedConcurrency.cs deleted file mode 100644 index ecf91337..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260304194634_DisablePostgresRowVersionTriggersForAppManagedConcurrency.cs +++ /dev/null @@ -1,23 +0,0 @@ -using APITemplate.Infrastructure.Database; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Migrations -{ - /// - public partial class DisablePostgresRowVersionTriggersForAppManagedConcurrency : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql(SqlResource.Load("Triggers.row_version_triggers_v1_down.sql")); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql(SqlResource.Load("Triggers.row_version_triggers_v1_up.sql")); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260305184129_SyncModelChanges.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260305184129_SyncModelChanges.Designer.cs deleted file mode 100644 index e813272d..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260305184129_SyncModelChanges.Designer.cs +++ /dev/null @@ -1,652 +0,0 @@ -// -using System; -using APITemplate.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 APITemplate.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260305184129_SyncModelChanges")] - partial class SyncModelChanges - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("APITemplate.Domain.Entities.AppUser", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("NormalizedUsername") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("Role") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(32) - .HasColumnType("character varying(32)") - .HasDefaultValue("TenantUser"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "Email") - .IsUnique(); - - b.HasIndex("TenantId", "IsDeleted"); - - 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("APITemplate.Domain.Entities.Category", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - 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("APITemplate.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") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("CategoryId"); - - b.HasIndex("TenantId"); - - 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("APITemplate.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("APITemplate.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") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("ProductId") - .HasColumnType("uuid"); - - b.Property("Rating") - .HasColumnType("integer"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("ProductId"); - - b.HasIndex("TenantId"); - - b.HasIndex("UserId"); - - 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("APITemplate.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") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("RowVersion") - .IsConcurrencyToken() - .IsRequired() - .HasColumnType("bytea"); - - b.Property("TenantId") - .HasColumnType("uuid"); - - 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("APITemplate.Domain.Entities.AppUser", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("AppUserId"); - - b1.ToTable("Users"); - - b1.WithOwner() - .HasForeignKey("AppUserId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Tenant"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Category", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("CategoryId"); - - b1.ToTable("Categories"); - - b1.WithOwner() - .HasForeignKey("CategoryId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("ProductId"); - - b1.ToTable("Products"); - - b1.WithOwner() - .HasForeignKey("ProductId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Category"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.AppUser", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("ProductReviewId"); - - b1.ToTable("ProductReviews"); - - b1.WithOwner() - .HasForeignKey("ProductReviewId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Product"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("TenantId"); - - b1.ToTable("Tenants"); - - b1.WithOwner() - .HasForeignKey("TenantId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("Reviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260305184129_SyncModelChanges.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260305184129_SyncModelChanges.cs deleted file mode 100644 index 87ba3c19..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260305184129_SyncModelChanges.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Migrations -{ - /// - public partial class SyncModelChanges : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - // Step 1: Add UserId as nullable so existing rows are not rejected. - migrationBuilder.AddColumn( - name: "UserId", - table: "ProductReviews", - type: "uuid", - 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(""" - 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"); - - // Step 4: Make UserId non-nullable now that all rows have a value. - migrationBuilder.AlterColumn( - name: "UserId", - table: "ProductReviews", - type: "uuid", - nullable: false, - oldClrType: typeof(Guid), - oldType: "uuid", - oldNullable: true); - - migrationBuilder.CreateIndex( - name: "IX_ProductReviews_UserId", - table: "ProductReviews", - column: "UserId"); - - migrationBuilder.AddForeignKey( - name: "FK_ProductReviews_Users_UserId", - table: "ProductReviews", - column: "UserId", - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_ProductReviews_Users_UserId", - table: "ProductReviews"); - - migrationBuilder.DropIndex( - name: "IX_ProductReviews_UserId", - table: "ProductReviews"); - - migrationBuilder.DropColumn( - name: "UserId", - table: "ProductReviews"); - - migrationBuilder.AddColumn( - name: "ReviewerName", - table: "ProductReviews", - type: "character varying(100)", - maxLength: 100, - nullable: false, - defaultValue: ""); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260306210000_RenameUserRoleTenantUserToUser.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260306210000_RenameUserRoleTenantUserToUser.cs deleted file mode 100644 index 3604d0a8..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260306210000_RenameUserRoleTenantUserToUser.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Migrations -{ - /// - public partial class RenameUserRoleTenantUserToUser : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql(""" - UPDATE "Users" SET "Role" = 'User' WHERE "Role" = 'TenantUser'; - """); - - migrationBuilder.AlterColumn( - name: "Role", - table: "Users", - type: "character varying(32)", - maxLength: 32, - nullable: false, - defaultValue: "User", - oldClrType: typeof(string), - oldType: "character varying(32)", - oldMaxLength: 32, - oldDefaultValue: "TenantUser"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql(""" - UPDATE "Users" SET "Role" = 'TenantUser' WHERE "Role" = 'User'; - """); - - migrationBuilder.AlterColumn( - name: "Role", - table: "Users", - type: "character varying(32)", - maxLength: 32, - nullable: false, - defaultValue: "TenantUser", - oldClrType: typeof(string), - oldType: "character varying(32)", - oldMaxLength: 32, - oldDefaultValue: "User"); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260306224502_SwitchToXminConcurrency.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260306224502_SwitchToXminConcurrency.Designer.cs deleted file mode 100644 index 19439b47..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260306224502_SwitchToXminConcurrency.Designer.cs +++ /dev/null @@ -1,657 +0,0 @@ -// -using System; -using APITemplate.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 APITemplate.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260306224502_SwitchToXminConcurrency")] - partial class SwitchToXminConcurrency - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("APITemplate.Domain.Entities.AppUser", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("NormalizedUsername") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - 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("TenantId"); - - b.HasIndex("TenantId", "Email") - .IsUnique(); - - b.HasIndex("TenantId", "IsDeleted"); - - 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("APITemplate.Domain.Entities.Category", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("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("APITemplate.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") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("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("APITemplate.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("APITemplate.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") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("ProductId"); - - b.HasIndex("TenantId"); - - b.HasIndex("UserId"); - - 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("APITemplate.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") - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - 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("APITemplate.Domain.Entities.AppUser", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("AppUserId"); - - b1.ToTable("Users"); - - b1.WithOwner() - .HasForeignKey("AppUserId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Tenant"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Category", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("CategoryId"); - - b1.ToTable("Categories"); - - b1.WithOwner() - .HasForeignKey("CategoryId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("ProductId"); - - b1.ToTable("Products"); - - b1.WithOwner() - .HasForeignKey("ProductId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Category"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.AppUser", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("ProductReviewId"); - - b1.ToTable("ProductReviews"); - - b1.WithOwner() - .HasForeignKey("ProductReviewId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("Product"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.OwnsOne("APITemplate.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") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("CreatedBy"); - - b1.Property("UpdatedAtUtc") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAtUtc") - .HasDefaultValueSql("now()"); - - b1.Property("UpdatedBy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(200) - .HasColumnType("character varying(200)") - .HasDefaultValue("system") - .HasColumnName("UpdatedBy"); - - b1.HasKey("TenantId"); - - b1.ToTable("Tenants"); - - b1.WithOwner() - .HasForeignKey("TenantId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("Reviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260306224502_SwitchToXminConcurrency.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260306224502_SwitchToXminConcurrency.cs deleted file mode 100644 index 5a34bb19..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260306224502_SwitchToXminConcurrency.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Migrations -{ - /// - public partial class SwitchToXminConcurrency : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "RowVersion", - table: "Users"); - - migrationBuilder.DropColumn( - name: "RowVersion", - table: "Tenants"); - - migrationBuilder.DropColumn( - name: "RowVersion", - table: "Products"); - - migrationBuilder.DropColumn( - name: "RowVersion", - table: "ProductReviews"); - - migrationBuilder.DropColumn( - name: "RowVersion", - table: "Categories"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "RowVersion", - table: "Users", - type: "bytea", - nullable: false, - defaultValue: new byte[0]); - - migrationBuilder.AddColumn( - name: "RowVersion", - table: "Tenants", - type: "bytea", - nullable: false, - defaultValue: new byte[0]); - - migrationBuilder.AddColumn( - name: "RowVersion", - table: "Products", - type: "bytea", - nullable: false, - defaultValue: new byte[0]); - - migrationBuilder.AddColumn( - name: "RowVersion", - table: "ProductReviews", - type: "bytea", - nullable: false, - defaultValue: new byte[0]); - - migrationBuilder.AddColumn( - name: "RowVersion", - table: "Categories", - type: "bytea", - nullable: false, - defaultValue: new byte[0]); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260306235337_ChangeAuditActorFieldsToGuid.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260306235337_ChangeAuditActorFieldsToGuid.Designer.cs deleted file mode 100644 index d999a749..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260306235337_ChangeAuditActorFieldsToGuid.Designer.cs +++ /dev/null @@ -1,632 +0,0 @@ -// -using System; -using APITemplate.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 APITemplate.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260306235337_ChangeAuditActorFieldsToGuid")] - partial class ChangeAuditActorFieldsToGuid - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("APITemplate.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("NormalizedUsername") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - 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("TenantId"); - - b.HasIndex("TenantId", "Email") - .IsUnique(); - - b.HasIndex("TenantId", "IsDeleted"); - - 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("APITemplate.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("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("APITemplate.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("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("APITemplate.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("APITemplate.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("ProductId"); - - b.HasIndex("TenantId"); - - b.HasIndex("UserId"); - - 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("APITemplate.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("APITemplate.Domain.Entities.AppUser", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.AppUser", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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(); - - b.Navigation("Product"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("Reviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260306235337_ChangeAuditActorFieldsToGuid.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260306235337_ChangeAuditActorFieldsToGuid.cs deleted file mode 100644 index c4f4d59f..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260306235337_ChangeAuditActorFieldsToGuid.cs +++ /dev/null @@ -1,199 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Migrations -{ - /// - public partial class ChangeAuditActorFieldsToGuid : Migration - { - /// - 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" }) - { - 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}'; - ALTER TABLE "{table}" ALTER COLUMN "CreatedBy" DROP DEFAULT; - ALTER TABLE "{table}" ALTER COLUMN "CreatedBy" TYPE uuid USING "CreatedBy"::uuid; - ALTER TABLE "{table}" ALTER COLUMN "CreatedBy" SET DEFAULT '00000000-0000-0000-0000-000000000000'; - ALTER TABLE "{table}" ALTER COLUMN "UpdatedBy" DROP DEFAULT; - 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; - """); - } - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn( - name: "UpdatedBy", - table: "Users", - type: "character varying(200)", - maxLength: 200, - nullable: false, - defaultValue: "system", - oldClrType: typeof(Guid), - oldType: "uuid", - oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000")); - - migrationBuilder.AlterColumn( - name: "DeletedBy", - table: "Users", - type: "character varying(200)", - maxLength: 200, - nullable: true, - oldClrType: typeof(Guid), - oldType: "uuid", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "CreatedBy", - table: "Users", - type: "character varying(200)", - maxLength: 200, - nullable: false, - defaultValue: "system", - oldClrType: typeof(Guid), - oldType: "uuid", - oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000")); - - migrationBuilder.AlterColumn( - name: "UpdatedBy", - table: "Tenants", - type: "character varying(200)", - maxLength: 200, - nullable: false, - defaultValue: "system", - oldClrType: typeof(Guid), - oldType: "uuid", - oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000")); - - migrationBuilder.AlterColumn( - name: "DeletedBy", - table: "Tenants", - type: "character varying(200)", - maxLength: 200, - nullable: true, - oldClrType: typeof(Guid), - oldType: "uuid", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "CreatedBy", - table: "Tenants", - type: "character varying(200)", - maxLength: 200, - nullable: false, - defaultValue: "system", - oldClrType: typeof(Guid), - oldType: "uuid", - oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000")); - - migrationBuilder.AlterColumn( - name: "UpdatedBy", - table: "Products", - type: "character varying(200)", - maxLength: 200, - nullable: false, - defaultValue: "system", - oldClrType: typeof(Guid), - oldType: "uuid", - oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000")); - - migrationBuilder.AlterColumn( - name: "DeletedBy", - table: "Products", - type: "character varying(200)", - maxLength: 200, - nullable: true, - oldClrType: typeof(Guid), - oldType: "uuid", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "CreatedBy", - table: "Products", - type: "character varying(200)", - maxLength: 200, - nullable: false, - defaultValue: "system", - oldClrType: typeof(Guid), - oldType: "uuid", - oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000")); - - migrationBuilder.AlterColumn( - name: "UpdatedBy", - table: "ProductReviews", - type: "character varying(200)", - maxLength: 200, - nullable: false, - defaultValue: "system", - oldClrType: typeof(Guid), - oldType: "uuid", - oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000")); - - migrationBuilder.AlterColumn( - name: "DeletedBy", - table: "ProductReviews", - type: "character varying(200)", - maxLength: 200, - nullable: true, - oldClrType: typeof(Guid), - oldType: "uuid", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "CreatedBy", - table: "ProductReviews", - type: "character varying(200)", - maxLength: 200, - nullable: false, - defaultValue: "system", - oldClrType: typeof(Guid), - oldType: "uuid", - oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000")); - - migrationBuilder.AlterColumn( - name: "UpdatedBy", - table: "Categories", - type: "character varying(200)", - maxLength: 200, - nullable: false, - defaultValue: "system", - oldClrType: typeof(Guid), - oldType: "uuid", - oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000")); - - migrationBuilder.AlterColumn( - name: "DeletedBy", - table: "Categories", - type: "character varying(200)", - maxLength: 200, - nullable: true, - oldClrType: typeof(Guid), - oldType: "uuid", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "CreatedBy", - table: "Categories", - type: "character varying(200)", - maxLength: 200, - nullable: false, - defaultValue: "system", - oldClrType: typeof(Guid), - oldType: "uuid", - oldDefaultValue: new Guid("00000000-0000-0000-0000-000000000000")); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260307174126_AddProductDataLinks.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260307174126_AddProductDataLinks.Designer.cs deleted file mode 100644 index 491cd432..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260307174126_AddProductDataLinks.Designer.cs +++ /dev/null @@ -1,660 +0,0 @@ -// -using System; -using APITemplate.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 APITemplate.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260307174126_AddProductDataLinks")] - partial class AddProductDataLinks - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("APITemplate.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("NormalizedUsername") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - 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("TenantId"); - - b.HasIndex("TenantId", "Email") - .IsUnique(); - - b.HasIndex("TenantId", "IsDeleted"); - - 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("APITemplate.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("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("APITemplate.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("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("APITemplate.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("APITemplate.Domain.Entities.ProductDataLink", b => - { - b.Property("ProductId") - .HasColumnType("uuid"); - - b.Property("ProductDataId") - .HasColumnType("uuid"); - - b.HasKey("ProductId", "ProductDataId"); - - b.HasIndex("ProductDataId"); - - b.ToTable("ProductDataLinks"); - }); - - modelBuilder.Entity("APITemplate.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("ProductId"); - - b.HasIndex("TenantId"); - - b.HasIndex("UserId"); - - 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("APITemplate.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("APITemplate.Domain.Entities.AppUser", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductDataLink", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("ProductDataLinks") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Product"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.AppUser", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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(); - - b.Navigation("Product"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("ProductDataLinks"); - - b.Navigation("Reviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260307174126_AddProductDataLinks.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260307174126_AddProductDataLinks.cs deleted file mode 100644 index 4d39f7c9..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260307174126_AddProductDataLinks.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Migrations -{ - /// - public partial class AddProductDataLinks : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "ProductDataLinks", - columns: table => new - { - ProductId = 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.ForeignKey( - name: "FK_ProductDataLinks_Products_ProductId", - column: x => x.ProductId, - principalTable: "Products", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_ProductDataLinks_ProductDataId", - table: "ProductDataLinks", - column: "ProductDataId"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "ProductDataLinks"); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260307191126_SoftDeleteProductDataLinksAndMetadata.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260307191126_SoftDeleteProductDataLinksAndMetadata.Designer.cs deleted file mode 100644 index a07d390c..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260307191126_SoftDeleteProductDataLinksAndMetadata.Designer.cs +++ /dev/null @@ -1,730 +0,0 @@ -// -using System; -using APITemplate.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 APITemplate.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260307191126_SoftDeleteProductDataLinksAndMetadata")] - partial class SoftDeleteProductDataLinksAndMetadata - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("APITemplate.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("NormalizedUsername") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - 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("TenantId"); - - b.HasIndex("TenantId", "Email") - .IsUnique(); - - b.HasIndex("TenantId", "IsDeleted"); - - 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("APITemplate.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("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("APITemplate.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("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("APITemplate.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("APITemplate.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("APITemplate.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("ProductId"); - - b.HasIndex("TenantId"); - - b.HasIndex("UserId"); - - 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("APITemplate.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("APITemplate.Domain.Entities.AppUser", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductDataLink", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("ProductDataLinks") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.AppUser", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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(); - - b.Navigation("Product"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("ProductDataLinks"); - - b.Navigation("Reviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260307191126_SoftDeleteProductDataLinksAndMetadata.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260307191126_SoftDeleteProductDataLinksAndMetadata.cs deleted file mode 100644 index 6a11277e..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260307191126_SoftDeleteProductDataLinksAndMetadata.cs +++ /dev/null @@ -1,186 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Migrations -{ - /// - public partial class SoftDeleteProductDataLinksAndMetadata : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_ProductDataLinks_Products_ProductId", - table: "ProductDataLinks"); - - migrationBuilder.DropIndex( - name: "IX_ProductDataLinks_ProductDataId", - table: "ProductDataLinks"); - - migrationBuilder.AddColumn( - name: "CreatedAtUtc", - table: "ProductDataLinks", - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()"); - - migrationBuilder.AddColumn( - name: "CreatedBy", - table: "ProductDataLinks", - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); - - migrationBuilder.AddColumn( - name: "DeletedAtUtc", - table: "ProductDataLinks", - type: "timestamp with time zone", - nullable: true); - - migrationBuilder.AddColumn( - name: "DeletedBy", - table: "ProductDataLinks", - type: "uuid", - nullable: true); - - migrationBuilder.AddColumn( - name: "IsDeleted", - table: "ProductDataLinks", - type: "boolean", - nullable: false, - defaultValue: false); - - migrationBuilder.AddColumn( - name: "TenantId", - table: "ProductDataLinks", - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); - - migrationBuilder.AddColumn( - name: "UpdatedAtUtc", - table: "ProductDataLinks", - type: "timestamp with time zone", - nullable: false, - defaultValueSql: "now()"); - - migrationBuilder.AddColumn( - name: "UpdatedBy", - table: "ProductDataLinks", - type: "uuid", - nullable: false, - defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); - - migrationBuilder.AddColumn( - name: "xmin", - table: "ProductDataLinks", - type: "xid", - rowVersion: true, - nullable: false, - defaultValue: 0u); - - 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.AddCheckConstraint( - name: "CK_ProductDataLinks_SoftDeleteConsistency", - table: "ProductDataLinks", - sql: "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - - migrationBuilder.AddForeignKey( - name: "FK_ProductDataLinks_Products_ProductId", - table: "ProductDataLinks", - column: "ProductId", - principalTable: "Products", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_ProductDataLinks_Products_ProductId", - table: "ProductDataLinks"); - - migrationBuilder.DropIndex( - name: "IX_ProductDataLinks_TenantId", - table: "ProductDataLinks"); - - migrationBuilder.DropIndex( - name: "IX_ProductDataLinks_TenantId_IsDeleted", - table: "ProductDataLinks"); - - migrationBuilder.DropIndex( - name: "IX_ProductDataLinks_TenantId_ProductDataId_IsDeleted", - table: "ProductDataLinks"); - - migrationBuilder.DropCheckConstraint( - name: "CK_ProductDataLinks_SoftDeleteConsistency", - table: "ProductDataLinks"); - - migrationBuilder.DropColumn( - name: "CreatedAtUtc", - table: "ProductDataLinks"); - - migrationBuilder.DropColumn( - name: "CreatedBy", - table: "ProductDataLinks"); - - migrationBuilder.DropColumn( - name: "DeletedAtUtc", - table: "ProductDataLinks"); - - migrationBuilder.DropColumn( - name: "DeletedBy", - table: "ProductDataLinks"); - - migrationBuilder.DropColumn( - name: "IsDeleted", - table: "ProductDataLinks"); - - migrationBuilder.DropColumn( - name: "TenantId", - table: "ProductDataLinks"); - - migrationBuilder.DropColumn( - name: "UpdatedAtUtc", - table: "ProductDataLinks"); - - migrationBuilder.DropColumn( - name: "UpdatedBy", - table: "ProductDataLinks"); - - migrationBuilder.DropColumn( - name: "xmin", - table: "ProductDataLinks"); - - migrationBuilder.CreateIndex( - name: "IX_ProductDataLinks_ProductDataId", - table: "ProductDataLinks", - column: "ProductDataId"); - - migrationBuilder.AddForeignKey( - name: "FK_ProductDataLinks_Products_ProductId", - table: "ProductDataLinks", - column: "ProductId", - principalTable: "Products", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260308182543_AddFullTextSearchIndexes.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260308182543_AddFullTextSearchIndexes.Designer.cs deleted file mode 100644 index a04b5f50..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260308182543_AddFullTextSearchIndexes.Designer.cs +++ /dev/null @@ -1,740 +0,0 @@ -// -using System; -using APITemplate.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 APITemplate.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260308182543_AddFullTextSearchIndexes")] - partial class AddFullTextSearchIndexes - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("APITemplate.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("NormalizedUsername") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - 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("TenantId"); - - b.HasIndex("TenantId", "Email") - .IsUnique(); - - b.HasIndex("TenantId", "IsDeleted"); - - 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("APITemplate.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("APITemplate.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("APITemplate.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("APITemplate.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("APITemplate.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("ProductId"); - - b.HasIndex("TenantId"); - - b.HasIndex("UserId"); - - 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("APITemplate.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("APITemplate.Domain.Entities.AppUser", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductDataLink", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("ProductDataLinks") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.AppUser", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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(); - - b.Navigation("Product"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("ProductDataLinks"); - - b.Navigation("Reviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260308182543_AddFullTextSearchIndexes.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260308182543_AddFullTextSearchIndexes.cs deleted file mode 100644 index 879dbb43..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260308182543_AddFullTextSearchIndexes.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Migrations -{ - /// - public partial class AddFullTextSearchIndexes : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - 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" }) - .Annotation("Npgsql:IndexMethod", "GIN") - .Annotation("Npgsql:TsVectorConfig", "english"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Products_Name_Description", - table: "Products"); - - migrationBuilder.DropIndex( - name: "IX_Categories_Name_Description", - table: "Categories"); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260310000812_AddNormalizedEmailForUsers.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260310000812_AddNormalizedEmailForUsers.Designer.cs deleted file mode 100644 index 239828f1..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260310000812_AddNormalizedEmailForUsers.Designer.cs +++ /dev/null @@ -1,745 +0,0 @@ -// -using System; -using APITemplate.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 APITemplate.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260310000812_AddNormalizedEmailForUsers")] - partial class AddNormalizedEmailForUsers - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("APITemplate.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("NormalizedEmail") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)"); - - b.Property("NormalizedUsername") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - 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("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("APITemplate.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("APITemplate.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("APITemplate.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("APITemplate.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("APITemplate.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("ProductId"); - - b.HasIndex("TenantId"); - - b.HasIndex("UserId"); - - 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("APITemplate.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("APITemplate.Domain.Entities.AppUser", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductDataLink", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("ProductDataLinks") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.AppUser", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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(); - - b.Navigation("Product"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("ProductDataLinks"); - - b.Navigation("Reviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260310000812_AddNormalizedEmailForUsers.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260310000812_AddNormalizedEmailForUsers.cs deleted file mode 100644 index ca59a3eb..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260310000812_AddNormalizedEmailForUsers.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Migrations -{ - /// - public partial class AddNormalizedEmailForUsers : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Users_TenantId_Email", - table: "Users"); - - migrationBuilder.AddColumn( - name: "NormalizedEmail", - table: "Users", - type: "character varying(320)", - maxLength: 320, - nullable: true); - - migrationBuilder.Sql("UPDATE \"Users\" SET \"NormalizedEmail\" = UPPER(TRIM(\"Email\"));"); - - migrationBuilder.AlterColumn( - name: "NormalizedEmail", - table: "Users", - type: "character varying(320)", - maxLength: 320, - nullable: false, - oldClrType: typeof(string), - oldType: "character varying(320)", - oldMaxLength: 320, - oldNullable: true); - - migrationBuilder.CreateIndex( - name: "IX_Users_TenantId_NormalizedEmail", - table: "Users", - columns: new[] { "TenantId", "NormalizedEmail" }, - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_Users_TenantId_NormalizedEmail", - table: "Users"); - - migrationBuilder.DropColumn( - name: "NormalizedEmail", - table: "Users"); - - migrationBuilder.CreateIndex( - name: "IX_Users_TenantId_Email", - table: "Users", - columns: new[] { "TenantId", "Email" }, - unique: true); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260315000709_AddTenantInvitationNormalizedEmail.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260315000709_AddTenantInvitationNormalizedEmail.Designer.cs deleted file mode 100644 index 0c08ac73..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260315000709_AddTenantInvitationNormalizedEmail.Designer.cs +++ /dev/null @@ -1,972 +0,0 @@ -// -using System; -using APITemplate.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 APITemplate.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260315000709_AddTenantInvitationNormalizedEmail")] - partial class AddTenantInvitationNormalizedEmail - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.3") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("APITemplate.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("NormalizedEmail") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)"); - - b.Property("NormalizedUsername") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("PasswordHash") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - 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("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("APITemplate.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("APITemplate.Domain.Entities.PasswordResetToken", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("ExpiresAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("IsUsed") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("TokenHash") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.HasIndex("TokenHash"); - - b.HasIndex("UserId"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.ToTable("PasswordResetTokens", t => - { - t.HasCheckConstraint("CK_PasswordResetTokens_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("APITemplate.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("APITemplate.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("APITemplate.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("APITemplate.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("ProductId"); - - b.HasIndex("TenantId"); - - b.HasIndex("UserId"); - - 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("APITemplate.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("APITemplate.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("APITemplate.Domain.Entities.AppUser", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.PasswordResetToken", b => - { - b.HasOne("APITemplate.Domain.Entities.AppUser", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("PasswordResetTokenId") - .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("PasswordResetTokenId"); - - b1.ToTable("PasswordResetTokens"); - - b1.WithOwner() - .HasForeignKey("PasswordResetTokenId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductDataLink", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("ProductDataLinks") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.AppUser", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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(); - - b.Navigation("Product"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.TenantInvitation", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("ProductDataLinks"); - - b.Navigation("Reviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260315000709_AddTenantInvitationNormalizedEmail.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260315000709_AddTenantInvitationNormalizedEmail.cs deleted file mode 100644 index 84b30e48..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260315000709_AddTenantInvitationNormalizedEmail.cs +++ /dev/null @@ -1,128 +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/absolute/src/APITemplate.Infrastructure/Migrations/20260315005556_RemovePasswordHashAddKeycloakUserId.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260315005556_RemovePasswordHashAddKeycloakUserId.Designer.cs deleted file mode 100644 index 3ced99b0..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260315005556_RemovePasswordHashAddKeycloakUserId.Designer.cs +++ /dev/null @@ -1,975 +0,0 @@ -// -using System; -using APITemplate.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 APITemplate.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260315005556_RemovePasswordHashAddKeycloakUserId")] - partial class RemovePasswordHashAddKeycloakUserId - { - /// - 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("APITemplate.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("APITemplate.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("APITemplate.Domain.Entities.PasswordResetToken", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DeletedAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("DeletedBy") - .HasColumnType("uuid"); - - b.Property("ExpiresAtUtc") - .HasColumnType("timestamp with time zone"); - - b.Property("IsDeleted") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("IsUsed") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("TenantId") - .HasColumnType("uuid"); - - b.Property("TokenHash") - .IsRequired() - .HasMaxLength(128) - .HasColumnType("character varying(128)"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.HasIndex("TokenHash"); - - b.HasIndex("UserId"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.ToTable("PasswordResetTokens", t => - { - t.HasCheckConstraint("CK_PasswordResetTokens_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("APITemplate.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("APITemplate.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("APITemplate.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("APITemplate.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("ProductId"); - - b.HasIndex("TenantId"); - - b.HasIndex("UserId"); - - 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("APITemplate.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("APITemplate.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("APITemplate.Domain.Entities.AppUser", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.PasswordResetToken", b => - { - b.HasOne("APITemplate.Domain.Entities.AppUser", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("PasswordResetTokenId") - .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("PasswordResetTokenId"); - - b1.ToTable("PasswordResetTokens"); - - b1.WithOwner() - .HasForeignKey("PasswordResetTokenId"); - }); - - b.Navigation("Audit") - .IsRequired(); - - b.Navigation("User"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductDataLink", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("ProductDataLinks") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.AppUser", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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(); - - b.Navigation("Product"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.TenantInvitation", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("ProductDataLinks"); - - b.Navigation("Reviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260315005556_RemovePasswordHashAddKeycloakUserId.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260315005556_RemovePasswordHashAddKeycloakUserId.cs deleted file mode 100644 index 7c1aa8ec..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260315005556_RemovePasswordHashAddKeycloakUserId.cs +++ /dev/null @@ -1,58 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Migrations -{ - /// - public partial class RemovePasswordHashAddKeycloakUserId : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "PasswordHash", - table: "Users"); - - migrationBuilder.AddColumn( - name: "KeycloakUserId", - table: "Users", - type: "character varying(256)", - maxLength: 256, - nullable: true); - - migrationBuilder.CreateIndex( - name: "IX_Users_KeycloakUserId", - table: "Users", - column: "KeycloakUserId", - unique: true, - filter: "\"KeycloakUserId\" IS NOT NULL"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - // WARNING: This rollback is schema-only. All PasswordHash data was permanently lost - // 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.DropColumn( - name: "KeycloakUserId", - table: "Users"); - - migrationBuilder.AddColumn( - name: "PasswordHash", - table: "Users", - type: "character varying(1000)", - maxLength: 1000, - nullable: false, - defaultValue: ""); - - migrationBuilder.Sql("ALTER TABLE \"Users\" ALTER COLUMN \"PasswordHash\" DROP DEFAULT;"); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260315014428_DropPasswordResetTokensTable.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260315014428_DropPasswordResetTokensTable.Designer.cs deleted file mode 100644 index c2ab40d1..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260315014428_DropPasswordResetTokensTable.Designer.cs +++ /dev/null @@ -1,866 +0,0 @@ -// -using System; -using APITemplate.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 APITemplate.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260315014428_DropPasswordResetTokensTable")] - partial class DropPasswordResetTokensTable - { - /// - 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("APITemplate.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("APITemplate.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("APITemplate.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("APITemplate.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("APITemplate.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("APITemplate.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("ProductId"); - - b.HasIndex("TenantId"); - - b.HasIndex("UserId"); - - 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("APITemplate.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("APITemplate.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("APITemplate.Domain.Entities.AppUser", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductDataLink", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("ProductDataLinks") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.AppUser", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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(); - - b.Navigation("Product"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.TenantInvitation", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("ProductDataLinks"); - - b.Navigation("Reviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260315014428_DropPasswordResetTokensTable.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260315014428_DropPasswordResetTokensTable.cs deleted file mode 100644 index 14fc36b3..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260315014428_DropPasswordResetTokensTable.cs +++ /dev/null @@ -1,73 +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/absolute/src/APITemplate.Infrastructure/Migrations/20260316105703_AddFailedEmails.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260316105703_AddFailedEmails.Designer.cs deleted file mode 100644 index fdc0a713..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260316105703_AddFailedEmails.Designer.cs +++ /dev/null @@ -1,909 +0,0 @@ -// -using System; -using APITemplate.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 APITemplate.Infrastructure.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260316105703_AddFailedEmails")] - partial class AddFailedEmails - { - /// - 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("APITemplate.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("APITemplate.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("APITemplate.Domain.Entities.FailedEmail", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - 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("To") - .IsRequired() - .HasMaxLength(320) - .HasColumnType("character varying(320)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeadLettered", "RetryCount", "LastAttemptAtUtc"); - - b.ToTable("FailedEmails"); - }); - - modelBuilder.Entity("APITemplate.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("APITemplate.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("APITemplate.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("APITemplate.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("ProductId"); - - b.HasIndex("TenantId"); - - b.HasIndex("UserId"); - - 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("APITemplate.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("APITemplate.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("APITemplate.Domain.Entities.AppUser", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductDataLink", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("ProductDataLinks") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.AppUser", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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(); - - b.Navigation("Product"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.TenantInvitation", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("ProductDataLinks"); - - b.Navigation("Reviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260316105703_AddFailedEmails.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260316105703_AddFailedEmails.cs deleted file mode 100644 index e3867d39..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260316105703_AddFailedEmails.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Infrastructure.Migrations -{ - /// - public partial class AddFailedEmails : 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 - ), - IsDeadLettered = table.Column(type: "boolean", nullable: false), - }, - constraints: table => - { - table.PrimaryKey("PK_FailedEmails", x => x.Id); - } - ); - - migrationBuilder.CreateIndex( - name: "IX_FailedEmails_IsDeadLettered_RetryCount_LastAttemptAtUtc", - table: "FailedEmails", - columns: new[] { "IsDeadLettered", "RetryCount", "LastAttemptAtUtc" } - ); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "FailedEmails"); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260316144833_AddFailedEmailTemplateName.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260316144833_AddFailedEmailTemplateName.Designer.cs deleted file mode 100644 index a755751a..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260316144833_AddFailedEmailTemplateName.Designer.cs +++ /dev/null @@ -1,913 +0,0 @@ -// -using System; -using APITemplate.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 APITemplate.Infrastructure.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260316144833_AddFailedEmailTemplateName")] - partial class AddFailedEmailTemplateName - { - /// - 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("APITemplate.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("APITemplate.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("APITemplate.Domain.Entities.FailedEmail", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - 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", "RetryCount", "LastAttemptAtUtc"); - - b.ToTable("FailedEmails"); - }); - - modelBuilder.Entity("APITemplate.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("APITemplate.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("APITemplate.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("APITemplate.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("ProductId"); - - b.HasIndex("TenantId"); - - b.HasIndex("UserId"); - - 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("APITemplate.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("APITemplate.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("APITemplate.Domain.Entities.AppUser", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductDataLink", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("ProductDataLinks") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.AppUser", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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(); - - b.Navigation("Product"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.TenantInvitation", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("ProductDataLinks"); - - b.Navigation("Reviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260316144833_AddFailedEmailTemplateName.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260316144833_AddFailedEmailTemplateName.cs deleted file mode 100644 index 482fd930..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260316144833_AddFailedEmailTemplateName.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Infrastructure.Migrations -{ - /// - public partial class AddFailedEmailTemplateName : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "TemplateName", - table: "FailedEmails", - type: "character varying(100)", - maxLength: 100, - nullable: true - ); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn(name: "TemplateName", table: "FailedEmails"); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260316202617_AddFailedEmailExpirationIndex.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260316202617_AddFailedEmailExpirationIndex.Designer.cs deleted file mode 100644 index d8236e84..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260316202617_AddFailedEmailExpirationIndex.Designer.cs +++ /dev/null @@ -1,915 +0,0 @@ -// -using System; -using APITemplate.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 APITemplate.Infrastructure.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260316202617_AddFailedEmailExpirationIndex")] - partial class AddFailedEmailExpirationIndex - { - /// - 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("APITemplate.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("APITemplate.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("APITemplate.Domain.Entities.FailedEmail", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - 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", "CreatedAtUtc"); - - b.HasIndex("IsDeadLettered", "RetryCount", "LastAttemptAtUtc"); - - b.ToTable("FailedEmails"); - }); - - modelBuilder.Entity("APITemplate.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("APITemplate.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("APITemplate.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("APITemplate.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("ProductId"); - - b.HasIndex("TenantId"); - - b.HasIndex("UserId"); - - 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("APITemplate.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("APITemplate.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("APITemplate.Domain.Entities.AppUser", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductDataLink", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("ProductDataLinks") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.AppUser", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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(); - - b.Navigation("Product"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.TenantInvitation", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("ProductDataLinks"); - - b.Navigation("Reviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260316202617_AddFailedEmailExpirationIndex.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260316202617_AddFailedEmailExpirationIndex.cs deleted file mode 100644 index 99ee497c..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260316202617_AddFailedEmailExpirationIndex.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Infrastructure.Migrations -{ - /// - public partial class AddFailedEmailExpirationIndex : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateIndex( - name: "IX_FailedEmails_IsDeadLettered_CreatedAtUtc", - table: "FailedEmails", - columns: new[] { "IsDeadLettered", "CreatedAtUtc" } - ); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_FailedEmails_IsDeadLettered_CreatedAtUtc", - table: "FailedEmails" - ); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260316220129_AddFailedEmailClaims.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260316220129_AddFailedEmailClaims.Designer.cs deleted file mode 100644 index 7d99e073..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260316220129_AddFailedEmailClaims.Designer.cs +++ /dev/null @@ -1,925 +0,0 @@ -// -using System; -using APITemplate.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 APITemplate.Infrastructure.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260316220129_AddFailedEmailClaims")] - partial class AddFailedEmailClaims - { - /// - 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("APITemplate.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("APITemplate.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("APITemplate.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"); - }); - - modelBuilder.Entity("APITemplate.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("APITemplate.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("APITemplate.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("APITemplate.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("ProductId"); - - b.HasIndex("TenantId"); - - b.HasIndex("UserId"); - - 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("APITemplate.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("APITemplate.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("APITemplate.Domain.Entities.AppUser", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductDataLink", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("ProductDataLinks") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.AppUser", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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(); - - b.Navigation("Product"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.TenantInvitation", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("ProductDataLinks"); - - b.Navigation("Reviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260316220129_AddFailedEmailClaims.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260316220129_AddFailedEmailClaims.cs deleted file mode 100644 index f1108591..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260316220129_AddFailedEmailClaims.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Infrastructure.Migrations -{ - /// - public partial class AddFailedEmailClaims : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_FailedEmails_IsDeadLettered_CreatedAtUtc", - table: "FailedEmails" - ); - - migrationBuilder.DropIndex( - name: "IX_FailedEmails_IsDeadLettered_RetryCount_LastAttemptAtUtc", - table: "FailedEmails" - ); - - migrationBuilder.AddColumn( - name: "ClaimedAtUtc", - table: "FailedEmails", - type: "timestamp with time zone", - nullable: true - ); - - migrationBuilder.AddColumn( - name: "ClaimedBy", - table: "FailedEmails", - type: "character varying(200)", - maxLength: 200, - nullable: true - ); - - migrationBuilder.AddColumn( - name: "ClaimedUntilUtc", - table: "FailedEmails", - type: "timestamp with time zone", - nullable: true - ); - - 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.DropIndex( - name: "IX_FailedEmails_IsDeadLettered_ClaimedUntilUtc_CreatedAtUtc", - table: "FailedEmails" - ); - - migrationBuilder.DropIndex( - name: "IX_FailedEmails_IsDeadLettered_RetryCount_ClaimedUntilUtc_Last~", - table: "FailedEmails" - ); - - migrationBuilder.DropColumn(name: "ClaimedAtUtc", table: "FailedEmails"); - - migrationBuilder.DropColumn(name: "ClaimedBy", table: "FailedEmails"); - - migrationBuilder.DropColumn(name: "ClaimedUntilUtc", table: "FailedEmails"); - - migrationBuilder.CreateIndex( - name: "IX_FailedEmails_IsDeadLettered_CreatedAtUtc", - table: "FailedEmails", - columns: new[] { "IsDeadLettered", "CreatedAtUtc" } - ); - - migrationBuilder.CreateIndex( - name: "IX_FailedEmails_IsDeadLettered_RetryCount_LastAttemptAtUtc", - table: "FailedEmails", - columns: new[] { "IsDeadLettered", "RetryCount", "LastAttemptAtUtc" } - ); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260317100000_AddStoredProceduresForReindexAndEmailClaim.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260317100000_AddStoredProceduresForReindexAndEmailClaim.Designer.cs deleted file mode 100644 index 7474d90c..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260317100000_AddStoredProceduresForReindexAndEmailClaim.Designer.cs +++ /dev/null @@ -1,925 +0,0 @@ -// -using System; -using APITemplate.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 APITemplate.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260317100000_AddStoredProceduresForReindexAndEmailClaim")] - partial class AddStoredProceduresForReindexAndEmailClaim - { - /// - 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("APITemplate.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("APITemplate.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("APITemplate.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"); - }); - - modelBuilder.Entity("APITemplate.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("APITemplate.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("APITemplate.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("APITemplate.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("ProductId"); - - b.HasIndex("TenantId"); - - b.HasIndex("UserId"); - - 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("APITemplate.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("APITemplate.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("APITemplate.Domain.Entities.AppUser", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductDataLink", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("ProductDataLinks") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.AppUser", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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(); - - b.Navigation("Product"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.TenantInvitation", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("ProductDataLinks"); - - b.Navigation("Reviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260317100000_AddStoredProceduresForReindexAndEmailClaim.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260317100000_AddStoredProceduresForReindexAndEmailClaim.cs deleted file mode 100644 index 44dd65e6..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260317100000_AddStoredProceduresForReindexAndEmailClaim.cs +++ /dev/null @@ -1,39 +0,0 @@ -using APITemplate.Infrastructure.Database; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Migrations -{ - /// - public partial class AddStoredProceduresForReindexAndEmailClaim : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql(SqlResource.Load("Procedures.get_fts_index_names_v1_up.sql")); - migrationBuilder.Sql(SqlResource.Load("Procedures.get_index_bloat_percent_v1_up.sql")); - migrationBuilder.Sql( - SqlResource.Load("Procedures.claim_retryable_failed_emails_v1_up.sql") - ); - migrationBuilder.Sql( - SqlResource.Load("Procedures.claim_expired_failed_emails_v1_up.sql") - ); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.Sql( - SqlResource.Load("Procedures.claim_expired_failed_emails_v1_down.sql") - ); - migrationBuilder.Sql( - SqlResource.Load("Procedures.claim_retryable_failed_emails_v1_down.sql") - ); - migrationBuilder.Sql( - SqlResource.Load("Procedures.get_index_bloat_percent_v1_down.sql") - ); - migrationBuilder.Sql(SqlResource.Load("Procedures.get_fts_index_names_v1_down.sql")); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260318201620_AddExampleEndpointEntities.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260318201620_AddExampleEndpointEntities.Designer.cs deleted file mode 100644 index f4190375..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260318201620_AddExampleEndpointEntities.Designer.cs +++ /dev/null @@ -1,1158 +0,0 @@ -// -using System; -using APITemplate.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 APITemplate.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260318201620_AddExampleEndpointEntities")] - partial class AddExampleEndpointEntities - { - /// - 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("APITemplate.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("APITemplate.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("APITemplate.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("ExampleFiles", t => - { - t.HasCheckConstraint("CK_ExampleFiles_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("APITemplate.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"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.JobExecution", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - 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") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("JobType") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Parameters") - .HasColumnType("text"); - - b.Property("ProgressPercent") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasDefaultValue(0); - - 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.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "Status"); - - b.ToTable("JobExecutions", t => - { - t.HasCheckConstraint("CK_JobExecutions_Progress", "\"ProgressPercent\" >= 0 AND \"ProgressPercent\" <= 100"); - - t.HasCheckConstraint("CK_JobExecutions_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("APITemplate.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("APITemplate.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("APITemplate.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("APITemplate.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("ProductId"); - - b.HasIndex("TenantId"); - - b.HasIndex("UserId"); - - 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("APITemplate.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("APITemplate.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("APITemplate.Domain.Entities.AppUser", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.StoredFile", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("ExampleFiles"); - - b1.WithOwner() - .HasForeignKey("StoredFileId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.JobExecution", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("JobExecutionId") - .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("JobExecutionId"); - - b1.ToTable("JobExecutions"); - - b1.WithOwner() - .HasForeignKey("JobExecutionId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductDataLink", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("ProductDataLinks") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.AppUser", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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(); - - b.Navigation("Product"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.TenantInvitation", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("ProductDataLinks"); - - b.Navigation("Reviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260318201620_AddExampleEndpointEntities.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260318201620_AddExampleEndpointEntities.cs deleted file mode 100644 index 15d0f877..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260318201620_AddExampleEndpointEntities.cs +++ /dev/null @@ -1,218 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Migrations -{ - /// - public partial class AddExampleEndpointEntities : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "ExampleFiles", - 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_ExampleFiles", x => x.Id); - table.CheckConstraint( - "CK_ExampleFiles_SoftDeleteConsistency", - "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)" - ); - table.ForeignKey( - name: "FK_ExampleFiles_Tenants_TenantId", - column: x => x.TenantId, - principalTable: "Tenants", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict - ); - } - ); - - 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, - defaultValue: 0 - ), - Parameters = table.Column(type: "text", 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), - 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_JobExecutions", x => x.Id); - table.CheckConstraint( - "CK_JobExecutions_Progress", - "\"ProgressPercent\" >= 0 AND \"ProgressPercent\" <= 100" - ); - table.CheckConstraint( - "CK_JobExecutions_SoftDeleteConsistency", - "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)" - ); - table.ForeignKey( - name: "FK_JobExecutions_Tenants_TenantId", - column: x => x.TenantId, - principalTable: "Tenants", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict - ); - } - ); - - migrationBuilder.CreateIndex( - name: "IX_ExampleFiles_TenantId", - table: "ExampleFiles", - column: "TenantId" - ); - - migrationBuilder.CreateIndex( - name: "IX_ExampleFiles_TenantId_IsDeleted", - table: "ExampleFiles", - columns: new[] { "TenantId", "IsDeleted" } - ); - - migrationBuilder.CreateIndex( - name: "IX_JobExecutions_TenantId", - table: "JobExecutions", - column: "TenantId" - ); - - migrationBuilder.CreateIndex( - name: "IX_JobExecutions_TenantId_IsDeleted", - table: "JobExecutions", - columns: new[] { "TenantId", "IsDeleted" } - ); - - migrationBuilder.CreateIndex( - name: "IX_JobExecutions_TenantId_Status", - table: "JobExecutions", - columns: new[] { "TenantId", "Status" } - ); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "ExampleFiles"); - - migrationBuilder.DropTable(name: "JobExecutions"); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260318232224_AddCallbackUrlToJobExecution.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260318232224_AddCallbackUrlToJobExecution.Designer.cs deleted file mode 100644 index 622bcaf1..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260318232224_AddCallbackUrlToJobExecution.Designer.cs +++ /dev/null @@ -1,1162 +0,0 @@ -// -using System; -using APITemplate.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 APITemplate.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20260318232224_AddCallbackUrlToJobExecution")] - partial class AddCallbackUrlToJobExecution - { - /// - 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("APITemplate.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("APITemplate.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("APITemplate.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"); - }); - - modelBuilder.Entity("APITemplate.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") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("JobType") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Parameters") - .HasColumnType("text"); - - b.Property("ProgressPercent") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasDefaultValue(0); - - 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.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "Status"); - - b.ToTable("JobExecutions", t => - { - t.HasCheckConstraint("CK_JobExecutions_Progress", "\"ProgressPercent\" >= 0 AND \"ProgressPercent\" <= 100"); - - t.HasCheckConstraint("CK_JobExecutions_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("APITemplate.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("APITemplate.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("APITemplate.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("APITemplate.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("ProductId"); - - b.HasIndex("TenantId"); - - b.HasIndex("UserId"); - - 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("APITemplate.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("ExampleFiles", null, t => - { - t.HasCheckConstraint("CK_ExampleFiles_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("APITemplate.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("APITemplate.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("APITemplate.Domain.Entities.AppUser", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.JobExecution", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("JobExecutionId") - .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("JobExecutionId"); - - b1.ToTable("JobExecutions"); - - b1.WithOwner() - .HasForeignKey("JobExecutionId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductDataLink", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("ProductDataLinks") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.AppUser", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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(); - - b.Navigation("Product"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.StoredFile", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("ExampleFiles"); - - b1.WithOwner() - .HasForeignKey("StoredFileId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.TenantInvitation", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("ProductDataLinks"); - - b.Navigation("Reviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/20260318232224_AddCallbackUrlToJobExecution.cs b/absolute/src/APITemplate.Infrastructure/Migrations/20260318232224_AddCallbackUrlToJobExecution.cs deleted file mode 100644 index 4b17ac3d..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/20260318232224_AddCallbackUrlToJobExecution.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Migrations -{ - /// - public partial class AddCallbackUrlToJobExecution : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "CallbackUrl", - table: "JobExecutions", - type: "character varying(2048)", - maxLength: 2048, - nullable: true - ); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn(name: "CallbackUrl", table: "JobExecutions"); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/absolute/src/APITemplate.Infrastructure/Migrations/AppDbContextModelSnapshot.cs deleted file mode 100644 index b3cc67cd..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ /dev/null @@ -1,1159 +0,0 @@ -// -using System; -using APITemplate.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace APITemplate.Migrations -{ - [DbContext(typeof(AppDbContext))] - partial class AppDbContextModelSnapshot : 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("APITemplate.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("APITemplate.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("APITemplate.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"); - }); - - modelBuilder.Entity("APITemplate.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") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(false); - - b.Property("JobType") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("Parameters") - .HasColumnType("text"); - - b.Property("ProgressPercent") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasDefaultValue(0); - - 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.Property("xmin") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.HasIndex("TenantId", "IsDeleted"); - - b.HasIndex("TenantId", "Status"); - - b.ToTable("JobExecutions", t => - { - t.HasCheckConstraint("CK_JobExecutions_Progress", "\"ProgressPercent\" >= 0 AND \"ProgressPercent\" <= 100"); - - t.HasCheckConstraint("CK_JobExecutions_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("APITemplate.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("APITemplate.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("APITemplate.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("APITemplate.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("ProductId"); - - b.HasIndex("TenantId"); - - b.HasIndex("UserId"); - - 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("APITemplate.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("ExampleFiles", null, t => - { - t.HasCheckConstraint("CK_ExampleFiles_SoftDeleteConsistency", "\"IsDeleted\" OR (\"DeletedAtUtc\" IS NULL AND \"DeletedBy\" IS NULL)"); - }); - }); - - modelBuilder.Entity("APITemplate.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("APITemplate.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("APITemplate.Domain.Entities.AppUser", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany("Users") - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.JobExecution", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.Domain.Entities.AuditInfo", "Audit", b1 => - { - b1.Property("JobExecutionId") - .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("JobExecutionId"); - - b1.ToTable("JobExecutions"); - - b1.WithOwner() - .HasForeignKey("JobExecutionId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.HasOne("APITemplate.Domain.Entities.Category", "Category") - .WithMany("Products") - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductDataLink", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("ProductDataLinks") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.ProductReview", b => - { - b.HasOne("APITemplate.Domain.Entities.Product", "Product") - .WithMany("Reviews") - .HasForeignKey("ProductId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("APITemplate.Domain.Entities.AppUser", "User") - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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(); - - b.Navigation("Product"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.StoredFile", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", null) - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("ExampleFiles"); - - b1.WithOwner() - .HasForeignKey("StoredFileId"); - }); - - b.Navigation("Audit") - .IsRequired(); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.TenantInvitation", b => - { - b.HasOne("APITemplate.Domain.Entities.Tenant", "Tenant") - .WithMany() - .HasForeignKey("TenantId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.OwnsOne("APITemplate.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("APITemplate.Domain.Entities.Category", b => - { - b.Navigation("Products"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Product", b => - { - b.Navigation("ProductDataLinks"); - - b.Navigation("Reviews"); - }); - - modelBuilder.Entity("APITemplate.Domain.Entities.Tenant", b => - { - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/M001_CreateProductDataIndexes.cs b/absolute/src/APITemplate.Infrastructure/Migrations/M001_CreateProductDataIndexes.cs deleted file mode 100644 index 496caac4..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/M001_CreateProductDataIndexes.cs +++ /dev/null @@ -1,39 +0,0 @@ -using APITemplate.Domain.Entities; -using Kot.MongoDB.Migrations; -using MongoDB.Driver; - -namespace APITemplate.Infrastructure.Migrations; - -/// -/// Creates indexes on the product_data collection: -/// - idx_type : ascending on _t (discriminator) — speeds up ?type=image|video filter -/// - idx_created : descending on CreatedAt — speeds up time-based ordering -/// -public sealed class M001_CreateProductDataIndexes : MongoMigration -{ - public M001_CreateProductDataIndexes() : base("1.0.0") { } - - public override async Task UpAsync(IMongoDatabase db, IClientSessionHandle session, CancellationToken ct) - { - var collection = db.GetCollection("product_data"); - - var indexes = new[] - { - new CreateIndexModel( - Builders.IndexKeys.Ascending("_t"), - new CreateIndexOptions { Name = "idx_type" }), - - new CreateIndexModel( - Builders.IndexKeys.Descending(x => x.CreatedAt), - new CreateIndexOptions { Name = "idx_created" }) - }; - - await collection.Indexes.CreateManyAsync(indexes, 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/absolute/src/APITemplate.Infrastructure/Migrations/M002_AddProductDataSoftDeleteIndexes.cs b/absolute/src/APITemplate.Infrastructure/Migrations/M002_AddProductDataSoftDeleteIndexes.cs deleted file mode 100644 index fadb2502..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/M002_AddProductDataSoftDeleteIndexes.cs +++ /dev/null @@ -1,36 +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/absolute/src/APITemplate.Infrastructure/Migrations/TickerQ/20260316213820_AddTickerQSchedulerStore.Designer.cs b/absolute/src/APITemplate.Infrastructure/Migrations/TickerQ/20260316213820_AddTickerQSchedulerStore.Designer.cs deleted file mode 100644 index 01af0210..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/TickerQ/20260316213820_AddTickerQSchedulerStore.Designer.cs +++ /dev/null @@ -1,240 +0,0 @@ -// -using System; -using APITemplate.Infrastructure.BackgroundJobs.TickerQ; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace APITemplate.Infrastructure.Migrations.TickerQ -{ - [DbContext(typeof(TickerQSchedulerDbContext))] - [Migration("20260316213820_AddTickerQSchedulerStore")] - partial class AddTickerQSchedulerStore - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("tickerq") - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("TickerQ.Utilities.Entities.CronTickerEntity", b => - { - b.Property("Id") - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .HasColumnType("text"); - - b.Property("Expression") - .HasColumnType("text"); - - b.Property("Function") - .HasColumnType("text"); - - b.Property("InitIdentifier") - .HasColumnType("text"); - - b.Property("IsEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("Request") - .HasColumnType("bytea"); - - b.Property("Retries") - .HasColumnType("integer"); - - b.PrimitiveCollection("RetryIntervals") - .HasColumnType("integer[]"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("Expression") - .HasDatabaseName("IX_CronTickers_Expression"); - - b.HasIndex("Function", "Expression") - .HasDatabaseName("IX_Function_Expression"); - - b.ToTable("CronTickers", "tickerq"); - }); - - modelBuilder.Entity("TickerQ.Utilities.Entities.CronTickerOccurrenceEntity", b => - { - b.Property("Id") - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CronTickerId") - .HasColumnType("uuid"); - - b.Property("ElapsedTime") - .HasColumnType("bigint"); - - b.Property("ExceptionMessage") - .HasColumnType("text"); - - b.Property("ExecutedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ExecutionTime") - .HasColumnType("timestamp with time zone"); - - b.Property("LockHolder") - .HasColumnType("text"); - - b.Property("LockedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("RetryCount") - .HasColumnType("integer"); - - b.Property("SkippedReason") - .HasColumnType("text"); - - b.Property("Status") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("CronTickerId") - .HasDatabaseName("IX_CronTickerOccurrence_CronTickerId"); - - b.HasIndex("ExecutionTime") - .HasDatabaseName("IX_CronTickerOccurrence_ExecutionTime"); - - b.HasIndex("CronTickerId", "ExecutionTime") - .IsUnique() - .HasDatabaseName("UQ_CronTickerId_ExecutionTime"); - - b.HasIndex("Status", "ExecutionTime") - .HasDatabaseName("IX_CronTickerOccurrence_Status_ExecutionTime"); - - b.ToTable("CronTickerOccurrences", "tickerq"); - }); - - modelBuilder.Entity("TickerQ.Utilities.Entities.TimeTickerEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .HasColumnType("text"); - - b.Property("ElapsedTime") - .HasColumnType("bigint"); - - b.Property("ExceptionMessage") - .HasColumnType("text"); - - b.Property("ExecutedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ExecutionTime") - .HasColumnType("timestamp with time zone"); - - b.Property("Function") - .HasColumnType("text"); - - b.Property("InitIdentifier") - .HasColumnType("text"); - - b.Property("LockHolder") - .HasColumnType("text"); - - b.Property("LockedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ParentId") - .HasColumnType("uuid"); - - b.Property("Request") - .HasColumnType("bytea"); - - b.Property("Retries") - .HasColumnType("integer"); - - b.Property("RetryCount") - .HasColumnType("integer"); - - b.PrimitiveCollection("RetryIntervals") - .HasColumnType("integer[]"); - - b.Property("RunCondition") - .HasColumnType("integer"); - - b.Property("SkippedReason") - .HasColumnType("text"); - - b.Property("Status") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("ExecutionTime") - .HasDatabaseName("IX_TimeTicker_ExecutionTime"); - - b.HasIndex("ParentId"); - - b.HasIndex("Status", "ExecutionTime") - .HasDatabaseName("IX_TimeTicker_Status_ExecutionTime"); - - b.ToTable("TimeTickers", "tickerq"); - }); - - modelBuilder.Entity("TickerQ.Utilities.Entities.CronTickerOccurrenceEntity", b => - { - b.HasOne("TickerQ.Utilities.Entities.CronTickerEntity", "CronTicker") - .WithMany() - .HasForeignKey("CronTickerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("CronTicker"); - }); - - modelBuilder.Entity("TickerQ.Utilities.Entities.TimeTickerEntity", b => - { - b.HasOne("TickerQ.Utilities.Entities.TimeTickerEntity", "Parent") - .WithMany("Children") - .HasForeignKey("ParentId") - .OnDelete(DeleteBehavior.NoAction); - - b.Navigation("Parent"); - }); - - modelBuilder.Entity("TickerQ.Utilities.Entities.TimeTickerEntity", b => - { - b.Navigation("Children"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/TickerQ/20260316213820_AddTickerQSchedulerStore.cs b/absolute/src/APITemplate.Infrastructure/Migrations/TickerQ/20260316213820_AddTickerQSchedulerStore.cs deleted file mode 100644 index 096ead4f..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/TickerQ/20260316213820_AddTickerQSchedulerStore.cs +++ /dev/null @@ -1,226 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace APITemplate.Infrastructure.Migrations.TickerQ -{ - /// - public partial class AddTickerQSchedulerStore : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.EnsureSchema(name: "tickerq"); - - migrationBuilder.CreateTable( - name: "CronTickers", - schema: "tickerq", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - Expression = table.Column(type: "text", nullable: true), - Request = table.Column(type: "bytea", nullable: true), - Retries = table.Column(type: "integer", nullable: false), - RetryIntervals = table.Column(type: "integer[]", nullable: true), - IsEnabled = table.Column( - type: "boolean", - nullable: false, - defaultValue: true - ), - Function = table.Column(type: "text", nullable: true), - Description = table.Column(type: "text", nullable: true), - InitIdentifier = table.Column(type: "text", nullable: true), - CreatedAt = table.Column( - type: "timestamp with time zone", - nullable: false - ), - UpdatedAt = table.Column( - type: "timestamp with time zone", - nullable: false - ), - }, - constraints: table => - { - table.PrimaryKey("PK_CronTickers", x => x.Id); - } - ); - - migrationBuilder.CreateTable( - name: "TimeTickers", - schema: "tickerq", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - Function = table.Column(type: "text", nullable: true), - Description = table.Column(type: "text", nullable: true), - InitIdentifier = table.Column(type: "text", nullable: true), - CreatedAt = table.Column( - type: "timestamp with time zone", - nullable: false - ), - UpdatedAt = table.Column( - type: "timestamp with time zone", - nullable: false - ), - Status = table.Column(type: "integer", nullable: false), - LockHolder = table.Column(type: "text", nullable: true), - Request = table.Column(type: "bytea", nullable: true), - ExecutionTime = table.Column( - type: "timestamp with time zone", - nullable: true - ), - LockedAt = table.Column( - type: "timestamp with time zone", - nullable: true - ), - ExecutedAt = table.Column( - type: "timestamp with time zone", - nullable: true - ), - ExceptionMessage = table.Column(type: "text", nullable: true), - SkippedReason = table.Column(type: "text", nullable: true), - ElapsedTime = table.Column(type: "bigint", nullable: false), - Retries = table.Column(type: "integer", nullable: false), - RetryCount = table.Column(type: "integer", nullable: false), - RetryIntervals = table.Column(type: "integer[]", nullable: true), - ParentId = table.Column(type: "uuid", nullable: true), - RunCondition = table.Column(type: "integer", nullable: true), - }, - constraints: table => - { - table.PrimaryKey("PK_TimeTickers", x => x.Id); - table.ForeignKey( - name: "FK_TimeTickers_TimeTickers_ParentId", - column: x => x.ParentId, - principalSchema: "tickerq", - principalTable: "TimeTickers", - principalColumn: "Id" - ); - } - ); - - migrationBuilder.CreateTable( - name: "CronTickerOccurrences", - schema: "tickerq", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - Status = table.Column(type: "integer", nullable: false), - LockHolder = table.Column(type: "text", nullable: true), - ExecutionTime = table.Column( - type: "timestamp with time zone", - nullable: false - ), - CronTickerId = table.Column(type: "uuid", nullable: false), - LockedAt = table.Column( - type: "timestamp with time zone", - nullable: true - ), - ExecutedAt = table.Column( - type: "timestamp with time zone", - nullable: true - ), - ExceptionMessage = table.Column(type: "text", nullable: true), - SkippedReason = table.Column(type: "text", nullable: true), - ElapsedTime = table.Column(type: "bigint", nullable: false), - RetryCount = table.Column(type: "integer", nullable: false), - CreatedAt = table.Column( - type: "timestamp with time zone", - nullable: false - ), - UpdatedAt = table.Column( - type: "timestamp with time zone", - nullable: false - ), - }, - constraints: table => - { - table.PrimaryKey("PK_CronTickerOccurrences", x => x.Id); - table.ForeignKey( - name: "FK_CronTickerOccurrences_CronTickers_CronTickerId", - column: x => x.CronTickerId, - principalSchema: "tickerq", - principalTable: "CronTickers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade - ); - } - ); - - migrationBuilder.CreateIndex( - name: "IX_CronTickerOccurrence_CronTickerId", - schema: "tickerq", - table: "CronTickerOccurrences", - column: "CronTickerId" - ); - - migrationBuilder.CreateIndex( - name: "IX_CronTickerOccurrence_ExecutionTime", - schema: "tickerq", - table: "CronTickerOccurrences", - column: "ExecutionTime" - ); - - migrationBuilder.CreateIndex( - name: "IX_CronTickerOccurrence_Status_ExecutionTime", - schema: "tickerq", - table: "CronTickerOccurrences", - columns: new[] { "Status", "ExecutionTime" } - ); - - migrationBuilder.CreateIndex( - name: "UQ_CronTickerId_ExecutionTime", - schema: "tickerq", - table: "CronTickerOccurrences", - columns: new[] { "CronTickerId", "ExecutionTime" }, - unique: true - ); - - migrationBuilder.CreateIndex( - name: "IX_CronTickers_Expression", - schema: "tickerq", - table: "CronTickers", - column: "Expression" - ); - - migrationBuilder.CreateIndex( - name: "IX_Function_Expression", - schema: "tickerq", - table: "CronTickers", - columns: new[] { "Function", "Expression" } - ); - - migrationBuilder.CreateIndex( - name: "IX_TimeTicker_ExecutionTime", - schema: "tickerq", - table: "TimeTickers", - column: "ExecutionTime" - ); - - migrationBuilder.CreateIndex( - name: "IX_TimeTicker_Status_ExecutionTime", - schema: "tickerq", - table: "TimeTickers", - columns: new[] { "Status", "ExecutionTime" } - ); - - migrationBuilder.CreateIndex( - name: "IX_TimeTickers_ParentId", - schema: "tickerq", - table: "TimeTickers", - column: "ParentId" - ); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "CronTickerOccurrences", schema: "tickerq"); - - migrationBuilder.DropTable(name: "TimeTickers", schema: "tickerq"); - - migrationBuilder.DropTable(name: "CronTickers", schema: "tickerq"); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Migrations/TickerQ/TickerQSchedulerDbContextModelSnapshot.cs b/absolute/src/APITemplate.Infrastructure/Migrations/TickerQ/TickerQSchedulerDbContextModelSnapshot.cs deleted file mode 100644 index c47be45f..00000000 --- a/absolute/src/APITemplate.Infrastructure/Migrations/TickerQ/TickerQSchedulerDbContextModelSnapshot.cs +++ /dev/null @@ -1,237 +0,0 @@ -// -using System; -using APITemplate.Infrastructure.BackgroundJobs.TickerQ; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace APITemplate.Infrastructure.Migrations.TickerQ -{ - [DbContext(typeof(TickerQSchedulerDbContext))] - partial class TickerQSchedulerDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("tickerq") - .HasAnnotation("ProductVersion", "10.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("TickerQ.Utilities.Entities.CronTickerEntity", b => - { - b.Property("Id") - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .HasColumnType("text"); - - b.Property("Expression") - .HasColumnType("text"); - - b.Property("Function") - .HasColumnType("text"); - - b.Property("InitIdentifier") - .HasColumnType("text"); - - b.Property("IsEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("boolean") - .HasDefaultValue(true); - - b.Property("Request") - .HasColumnType("bytea"); - - b.Property("Retries") - .HasColumnType("integer"); - - b.PrimitiveCollection("RetryIntervals") - .HasColumnType("integer[]"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("Expression") - .HasDatabaseName("IX_CronTickers_Expression"); - - b.HasIndex("Function", "Expression") - .HasDatabaseName("IX_Function_Expression"); - - b.ToTable("CronTickers", "tickerq"); - }); - - modelBuilder.Entity("TickerQ.Utilities.Entities.CronTickerOccurrenceEntity", b => - { - b.Property("Id") - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CronTickerId") - .HasColumnType("uuid"); - - b.Property("ElapsedTime") - .HasColumnType("bigint"); - - b.Property("ExceptionMessage") - .HasColumnType("text"); - - b.Property("ExecutedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ExecutionTime") - .HasColumnType("timestamp with time zone"); - - b.Property("LockHolder") - .HasColumnType("text"); - - b.Property("LockedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("RetryCount") - .HasColumnType("integer"); - - b.Property("SkippedReason") - .HasColumnType("text"); - - b.Property("Status") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("CronTickerId") - .HasDatabaseName("IX_CronTickerOccurrence_CronTickerId"); - - b.HasIndex("ExecutionTime") - .HasDatabaseName("IX_CronTickerOccurrence_ExecutionTime"); - - b.HasIndex("CronTickerId", "ExecutionTime") - .IsUnique() - .HasDatabaseName("UQ_CronTickerId_ExecutionTime"); - - b.HasIndex("Status", "ExecutionTime") - .HasDatabaseName("IX_CronTickerOccurrence_Status_ExecutionTime"); - - b.ToTable("CronTickerOccurrences", "tickerq"); - }); - - modelBuilder.Entity("TickerQ.Utilities.Entities.TimeTickerEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Description") - .HasColumnType("text"); - - b.Property("ElapsedTime") - .HasColumnType("bigint"); - - b.Property("ExceptionMessage") - .HasColumnType("text"); - - b.Property("ExecutedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ExecutionTime") - .HasColumnType("timestamp with time zone"); - - b.Property("Function") - .HasColumnType("text"); - - b.Property("InitIdentifier") - .HasColumnType("text"); - - b.Property("LockHolder") - .HasColumnType("text"); - - b.Property("LockedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("ParentId") - .HasColumnType("uuid"); - - b.Property("Request") - .HasColumnType("bytea"); - - b.Property("Retries") - .HasColumnType("integer"); - - b.Property("RetryCount") - .HasColumnType("integer"); - - b.PrimitiveCollection("RetryIntervals") - .HasColumnType("integer[]"); - - b.Property("RunCondition") - .HasColumnType("integer"); - - b.Property("SkippedReason") - .HasColumnType("text"); - - b.Property("Status") - .HasColumnType("integer"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("ExecutionTime") - .HasDatabaseName("IX_TimeTicker_ExecutionTime"); - - b.HasIndex("ParentId"); - - b.HasIndex("Status", "ExecutionTime") - .HasDatabaseName("IX_TimeTicker_Status_ExecutionTime"); - - b.ToTable("TimeTickers", "tickerq"); - }); - - modelBuilder.Entity("TickerQ.Utilities.Entities.CronTickerOccurrenceEntity", b => - { - b.HasOne("TickerQ.Utilities.Entities.CronTickerEntity", "CronTicker") - .WithMany() - .HasForeignKey("CronTickerId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("CronTicker"); - }); - - modelBuilder.Entity("TickerQ.Utilities.Entities.TimeTickerEntity", b => - { - b.HasOne("TickerQ.Utilities.Entities.TimeTickerEntity", "Parent") - .WithMany("Children") - .HasForeignKey("ParentId") - .OnDelete(DeleteBehavior.NoAction); - - b.Navigation("Parent"); - }); - - modelBuilder.Entity("TickerQ.Utilities.Entities.TimeTickerEntity", b => - { - b.Navigation("Children"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Observability/ApiMetrics.cs b/absolute/src/APITemplate.Infrastructure/Observability/ApiMetrics.cs deleted file mode 100644 index aacd435c..00000000 --- a/absolute/src/APITemplate.Infrastructure/Observability/ApiMetrics.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Diagnostics.Metrics; - -namespace APITemplate.Infrastructure.Observability; - -/// -/// Static facade for custom API-level metrics emitted via the shared application meter. -/// Records rate-limit rejections and handled exception counts with relevant dimensional tags. -/// -public static class ApiMetrics -{ - private static readonly Meter Meter = new(ObservabilityConventions.MeterName); - - private static readonly Counter RateLimitRejections = Meter.CreateCounter( - TelemetryMetricNames.RateLimitRejections, - description: "Total number of rejected requests by the ASP.NET Core rate limiter." - ); - - private static readonly Counter HandledExceptions = Meter.CreateCounter( - TelemetryMetricNames.HandledExceptions, - description: "Total number of exceptions translated to API responses." - ); - - /// - /// Increments the rate-limit rejection counter, tagged with the policy name, HTTP method, and endpoint route. - /// - public static void RecordRateLimitRejection(string policy, string method, string endpoint) - { - RateLimitRejections.Add( - 1, - [ - new KeyValuePair(TelemetryTagKeys.RateLimitPolicy, policy), - new KeyValuePair(TelemetryTagKeys.HttpMethod, method), - new KeyValuePair(TelemetryTagKeys.HttpRoute, endpoint), - ] - ); - } - - /// - /// Increments the handled-exception counter, tagged with the HTTP status code, error code, and exception type. - /// - public static void RecordHandledException( - int statusCode, - string errorCode, - string exceptionType - ) - { - HandledExceptions.Add( - 1, - [ - new KeyValuePair(TelemetryTagKeys.ErrorCode, errorCode), - new KeyValuePair( - TelemetryTagKeys.HttpResponseStatusCode, - statusCode - ), - new KeyValuePair(TelemetryTagKeys.ExceptionType, exceptionType), - ] - ); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Observability/AuthTelemetry.cs b/absolute/src/APITemplate.Infrastructure/Observability/AuthTelemetry.cs deleted file mode 100644 index 64f42321..00000000 --- a/absolute/src/APITemplate.Infrastructure/Observability/AuthTelemetry.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System.Diagnostics; -using System.Diagnostics.Metrics; -using APITemplate.Application.Common.Security; -using Microsoft.AspNetCore.Http; - -namespace APITemplate.Infrastructure.Observability; - -/// -/// Static facade for authentication-related telemetry, recording metric counters and -/// diagnostic activities for common auth failure scenarios in both JWT Bearer and BFF cookie flows. -/// -public static class AuthTelemetry -{ - private static readonly ActivitySource ActivitySource = new( - ObservabilityConventions.ActivitySourceName - ); - private static readonly Meter Meter = new(ObservabilityConventions.MeterName); - - private static readonly Counter AuthFailures = Meter.CreateCounter( - TelemetryMetricNames.AuthFailures, - description: "Authentication and BFF session failures grouped by scheme and reason." - ); - - /// Records a failure caused by a missing tenant claim in the validated token. - public static void RecordMissingTenantClaim(HttpContext httpContext, string scheme) => - RecordFailure( - TelemetryActivityNames.TokenValidated, - scheme, - TelemetryFailureReasons.MissingTenantClaim, - ResolveSurface(httpContext.Request.Path) - ); - - /// Records a failure during the BFF cookie session refresh flow. - public static void RecordCookieRefreshFailed(Exception? exception = null) => - RecordFailure( - TelemetryActivityNames.CookieSessionRefresh, - AuthConstants.BffSchemes.Cookie, - TelemetryFailureReasons.RefreshFailed, - TelemetrySurfaces.Bff, - exception - ); - - /// Records a failure because no refresh token was present in the cookie properties. - public static void RecordMissingRefreshToken() => - RecordFailure( - TelemetryActivityNames.CookieSessionRefresh, - AuthConstants.BffSchemes.Cookie, - TelemetryFailureReasons.MissingRefreshToken, - TelemetrySurfaces.Bff - ); - - /// Records a failure because the Keycloak token endpoint returned a non-success response. - public static void RecordTokenEndpointRejected() => - RecordFailure( - TelemetryActivityNames.CookieSessionRefresh, - AuthConstants.BffSchemes.Cookie, - TelemetryFailureReasons.TokenEndpointRejected, - TelemetrySurfaces.Bff - ); - - /// Records a failure caused by an unhandled exception during token refresh. - public static void RecordTokenRefreshException(Exception exception) => - RecordFailure( - TelemetryActivityNames.CookieSessionRefresh, - AuthConstants.BffSchemes.Cookie, - TelemetryFailureReasons.TokenRefreshException, - TelemetrySurfaces.Bff, - exception - ); - - /// Records an unauthorized redirect-to-login event in the BFF cookie scheme. - public static void RecordUnauthorizedRedirect() => - RecordFailure( - TelemetryActivityNames.RedirectToLogin, - AuthConstants.BffSchemes.Cookie, - TelemetryFailureReasons.UnauthorizedRedirect, - TelemetrySurfaces.Bff - ); - - private static void RecordFailure( - string activityName, - string scheme, - string reason, - string surface, - Exception? exception = null - ) - { - AuthFailures.Add( - 1, - [ - new KeyValuePair(TelemetryTagKeys.AuthScheme, scheme), - new KeyValuePair(TelemetryTagKeys.AuthFailureReason, reason), - new KeyValuePair(TelemetryTagKeys.ApiSurface, surface), - ] - ); - - using var activity = ActivitySource.StartActivity(activityName, ActivityKind.Internal); - activity?.SetTag(TelemetryTagKeys.AuthScheme, scheme); - activity?.SetTag(TelemetryTagKeys.AuthFailureReason, reason); - activity?.SetTag(TelemetryTagKeys.ApiSurface, surface); - activity?.SetStatus(ActivityStatusCode.Error); - if (exception is not null) - activity?.SetTag(TelemetryTagKeys.ExceptionType, exception.GetType().Name); - } - - private static string ResolveSurface(PathString path) => - TelemetryApiSurfaceResolver.Resolve(path); -} diff --git a/absolute/src/APITemplate.Infrastructure/Observability/CacheTelemetry.cs b/absolute/src/APITemplate.Infrastructure/Observability/CacheTelemetry.cs deleted file mode 100644 index 2cfd6892..00000000 --- a/absolute/src/APITemplate.Infrastructure/Observability/CacheTelemetry.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System.Diagnostics; -using System.Diagnostics.Metrics; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.OutputCaching; - -namespace APITemplate.Infrastructure.Observability; - -/// -/// Static facade for output-cache telemetry: invalidation activities/metrics and -/// per-request cache outcome counters (hit, store, bypass) with policy and surface tags. -/// -public static class CacheTelemetry -{ - private static readonly ActivitySource ActivitySource = new( - ObservabilityConventions.ActivitySourceName - ); - private static readonly Meter Meter = new(ObservabilityConventions.MeterName); - - private static readonly Counter OutputCacheInvalidations = Meter.CreateCounter( - TelemetryMetricNames.OutputCacheInvalidations, - description: "Total number of output cache invalidation operations." - ); - - private static readonly Histogram OutputCacheInvalidationDurationMs = - Meter.CreateHistogram( - TelemetryMetricNames.OutputCacheInvalidationDuration, - unit: "ms", - description: "Duration of output cache invalidation operations." - ); - - private static readonly Counter OutputCacheOutcomes = Meter.CreateCounter( - TelemetryMetricNames.OutputCacheOutcomes, - description: "Observed output cache outcomes." - ); - - /// - /// Starts a diagnostic activity for an output-cache invalidation operation, tagging it with the cache tag name. - /// - public static Activity? StartOutputCacheInvalidationActivity(string tag) - { - var activity = ActivitySource.StartActivity( - TelemetryActivityNames.OutputCacheInvalidate, - ActivityKind.Internal - ); - activity?.SetTag(TelemetryTagKeys.CacheTag, tag); - return activity; - } - - /// - /// Records an output-cache invalidation event and its duration in milliseconds for the given cache tag. - /// - public static void RecordOutputCacheInvalidation(string tag, TimeSpan duration) - { - var tags = new TagList { { TelemetryTagKeys.CacheTag, tag } }; - - OutputCacheInvalidations.Add(1, tags); - OutputCacheInvalidationDurationMs.Record(duration.TotalMilliseconds, tags); - } - - /// - /// Stores the resolved output cache policy name on the current request so subsequent calls avoid re-resolving it. - /// - public static void ConfigureRequest(OutputCacheContext context) - { - context.HttpContext.Items[TelemetryContextKeys.OutputCachePolicyName] = ResolvePolicyName( - context - ); - } - - /// Records a cache hit outcome for the given output cache context. - public static void RecordCacheHit(OutputCacheContext context) => - RecordCacheOutcome(context, TelemetryOutcomeValues.Hit); - - /// - /// Records a store or bypass outcome based on whether the response was eligible for caching. - /// - public static void RecordResponseOutcome(OutputCacheContext context) - { - var outcome = context.AllowCacheStorage - ? TelemetryOutcomeValues.Store - : TelemetryOutcomeValues.Bypass; - RecordCacheOutcome(context, outcome); - } - - private static void RecordCacheOutcome(OutputCacheContext context, string outcome) - { - OutputCacheOutcomes.Add( - 1, - [ - new KeyValuePair( - TelemetryTagKeys.CachePolicy, - ResolvePolicyName(context) - ), - new KeyValuePair( - TelemetryTagKeys.ApiSurface, - ResolveSurface(context.HttpContext.Request.Path) - ), - new KeyValuePair(TelemetryTagKeys.CacheOutcome, outcome), - ] - ); - } - - private static string ResolvePolicyName(OutputCacheContext context) - { - if ( - context.HttpContext.Items.TryGetValue( - TelemetryContextKeys.OutputCachePolicyName, - out var cached - ) && cached is string name - ) - return name; - - return context - .HttpContext.GetEndpoint() - ?.Metadata.OfType() - .Select(attribute => attribute.PolicyName) - .FirstOrDefault(policyName => !string.IsNullOrWhiteSpace(policyName)) - ?? TelemetryDefaults.Default; - } - - private static string ResolveSurface(PathString path) => - TelemetryApiSurfaceResolver.Resolve(path); -} diff --git a/absolute/src/APITemplate.Infrastructure/Observability/ConflictTelemetry.cs b/absolute/src/APITemplate.Infrastructure/Observability/ConflictTelemetry.cs deleted file mode 100644 index 3a963014..00000000 --- a/absolute/src/APITemplate.Infrastructure/Observability/ConflictTelemetry.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Diagnostics.Metrics; - -namespace APITemplate.Infrastructure.Observability; - -/// -/// Static facade for conflict-related metrics, distinguishing optimistic-concurrency -/// EF Core exceptions from domain-layer conflict exceptions. -/// -public static class ConflictTelemetry -{ - private static readonly Meter Meter = new(ObservabilityConventions.MeterName); - - private static readonly Counter ConcurrencyConflicts = Meter.CreateCounter( - TelemetryMetricNames.ConcurrencyConflicts, - description: "Number of optimistic concurrency conflicts." - ); - - private static readonly Counter DomainConflicts = Meter.CreateCounter( - TelemetryMetricNames.DomainConflicts, - description: "Number of domain conflict responses." - ); - - /// - /// Increments the appropriate conflict counter based on the exception type. - /// EF Core concurrency exceptions increment the concurrency counter; domain - /// increments the domain-conflicts counter tagged with . - /// - public static void Record(Exception exception, string errorCode) - { - if (exception is Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException) - { - ConcurrencyConflicts.Add(1); - return; - } - - if (exception is Domain.Exceptions.ConflictException) - { - DomainConflicts.Add( - 1, - [new KeyValuePair(TelemetryTagKeys.ErrorCode, errorCode)] - ); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Observability/GraphQlTelemetry.cs b/absolute/src/APITemplate.Infrastructure/Observability/GraphQlTelemetry.cs deleted file mode 100644 index 8231a81e..00000000 --- a/absolute/src/APITemplate.Infrastructure/Observability/GraphQlTelemetry.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System.Diagnostics; -using System.Diagnostics.Metrics; - -namespace APITemplate.Infrastructure.Observability; - -/// -/// Static facade for GraphQL-specific metrics: request counts/durations, error counts by phase, -/// document and operation cache hit/miss counters, and per-operation cost histograms. -/// -public static class GraphQlTelemetry -{ - private static readonly Meter Meter = new(ObservabilityConventions.MeterName); - - private static readonly Counter Requests = Meter.CreateCounter( - TelemetryMetricNames.GraphQlRequests, - description: "Total number of GraphQL requests observed." - ); - - private static readonly Counter Errors = Meter.CreateCounter( - TelemetryMetricNames.GraphQlErrors, - description: "Total number of GraphQL errors observed." - ); - - private static readonly Counter DocumentCacheHits = Meter.CreateCounter( - TelemetryMetricNames.GraphQlDocumentCacheHits, - description: "GraphQL document cache hits." - ); - - private static readonly Counter DocumentCacheMisses = Meter.CreateCounter( - TelemetryMetricNames.GraphQlDocumentCacheMisses, - description: "GraphQL document cache misses." - ); - - private static readonly Counter OperationCacheHits = Meter.CreateCounter( - TelemetryMetricNames.GraphQlOperationCacheHits, - description: "GraphQL operation cache hits." - ); - - private static readonly Counter OperationCacheMisses = Meter.CreateCounter( - TelemetryMetricNames.GraphQlOperationCacheMisses, - description: "GraphQL operation cache misses." - ); - - private static readonly Histogram RequestDurationMs = Meter.CreateHistogram( - TelemetryMetricNames.GraphQlRequestDuration, - unit: "ms", - description: "GraphQL request execution duration." - ); - - private static readonly Histogram OperationCost = Meter.CreateHistogram( - TelemetryMetricNames.GraphQlOperationCost, - description: "Computed GraphQL operation cost." - ); - - /// Increments the request counter and records duration, tagged with operation type and error status. - public static void RecordRequest(string operationType, bool hasErrors, TimeSpan duration) - { - var tags = new TagList - { - { TelemetryTagKeys.GraphQlOperationType, operationType }, - { TelemetryTagKeys.GraphQlHasErrors, hasErrors }, - }; - - Requests.Add(1, tags); - RequestDurationMs.Record(duration.TotalMilliseconds, tags); - } - - /// Increments the error counter tagged with the request phase. - public static void RecordRequestError() => RecordError(TelemetryGraphQlValues.RequestPhase); - - /// Increments the error counter tagged with the syntax phase. - public static void RecordSyntaxError() => RecordError(TelemetryGraphQlValues.SyntaxPhase); - - /// Increments the error counter tagged with the validation phase. - public static void RecordValidationError() => - RecordError(TelemetryGraphQlValues.ValidationPhase); - - /// Increments the error counter tagged with the resolver phase. - public static void RecordResolverError() => RecordError(TelemetryGraphQlValues.ResolverPhase); - - /// Increments the document-cache hit counter. - public static void RecordDocumentCacheHit() => DocumentCacheHits.Add(1); - - /// Increments the document-cache miss counter. - public static void RecordDocumentCacheMiss() => DocumentCacheMisses.Add(1); - - /// Increments the operation-cache hit counter. - public static void RecordOperationCacheHit() => OperationCacheHits.Add(1); - - /// Increments the operation-cache miss counter. - public static void RecordOperationCacheMiss() => OperationCacheMisses.Add(1); - - /// - /// Records both field-based and type-based operation cost in the cost histogram, - /// each tagged with the corresponding cost kind. - /// - public static void RecordOperationCost(double fieldCost, double typeCost) - { - OperationCost.Record( - fieldCost, - new TagList - { - { TelemetryTagKeys.GraphQlCostKind, TelemetryGraphQlValues.FieldCostKind }, - } - ); - OperationCost.Record( - typeCost, - new TagList - { - { TelemetryTagKeys.GraphQlCostKind, TelemetryGraphQlValues.TypeCostKind }, - } - ); - } - - private static void RecordError(string phase) - { - Errors.Add(1, [new KeyValuePair(TelemetryTagKeys.GraphQlPhase, phase)]); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Observability/HealthCheckMetricsPublisher.cs b/absolute/src/APITemplate.Infrastructure/Observability/HealthCheckMetricsPublisher.cs deleted file mode 100644 index ca207b25..00000000 --- a/absolute/src/APITemplate.Infrastructure/Observability/HealthCheckMetricsPublisher.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Collections.Concurrent; -using System.Diagnostics.Metrics; -using Microsoft.Extensions.Diagnostics.HealthChecks; - -namespace APITemplate.Infrastructure.Observability; - -/// -/// implementation that surfaces health-check results -/// as an observable gauge metric (1 = healthy, 0 = unhealthy/degraded) per named service. -/// -public sealed class HealthCheckMetricsPublisher : IHealthCheckPublisher -{ - private static readonly Meter Meter = new(ObservabilityConventions.HealthMeterName); - private static readonly ConcurrentDictionary Statuses = new( - StringComparer.OrdinalIgnoreCase - ); - - private readonly ObservableGauge _gauge; - - public HealthCheckMetricsPublisher() - { - _gauge = Meter.CreateObservableGauge( - TelemetryMetricNames.HealthStatus, - ObserveStatuses, - unit: null, - description: "Current health check status where 1=healthy and 0=unhealthy/degraded." - ); - } - - /// - /// Receives the latest health report and updates the in-memory status dictionary - /// that the observable gauge reads on the next collection cycle. - /// - public Task PublishAsync(HealthReport report, CancellationToken cancellationToken) - { - foreach (var entry in report.Entries) - { - Statuses[entry.Key] = entry.Value.Status == HealthStatus.Healthy ? 1 : 0; - } - - return Task.CompletedTask; - } - - private static IEnumerable> ObserveStatuses() - { - foreach (var status in Statuses) - { - yield return new Measurement( - status.Value, - new KeyValuePair(TelemetryTagKeys.Service, status.Key) - ); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Observability/HttpRouteResolver.cs b/absolute/src/APITemplate.Infrastructure/Observability/HttpRouteResolver.cs deleted file mode 100644 index 609347cc..00000000 --- a/absolute/src/APITemplate.Infrastructure/Observability/HttpRouteResolver.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Text.RegularExpressions; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace APITemplate.Infrastructure.Observability; - -/// -/// Resolves the normalized route template for the current HTTP request, substituting -/// the {version} route token with its actual value to produce stable metric tags. -/// -public static partial class HttpRouteResolver -{ - [GeneratedRegex( - @"\{version(?::[^}]*)?\}", - RegexOptions.IgnoreCase | RegexOptions.CultureInvariant - )] - private static partial Regex VersionTokenRegex(); - - /// - /// Returns the route template for the matched endpoint, falling back to the raw request path - /// when no route endpoint is matched. The version token is replaced with its actual value. - /// - public static string Resolve(HttpContext httpContext) - { - var routeTemplate = httpContext.GetEndpoint() is RouteEndpoint routeEndpoint - ? routeEndpoint.RoutePattern.RawText - : null; - - if (string.IsNullOrWhiteSpace(routeTemplate)) - return httpContext.Request.Path.Value ?? TelemetryDefaults.Unknown; - - return ReplaceVersionToken(routeTemplate, httpContext.Request.RouteValues); - } - - /// - /// Replaces the first {version:...} token in with - /// the actual version value from , or returns the template unchanged - /// when no version route value is present. - /// - public static string ReplaceVersionToken(string routeTemplate, RouteValueDictionary routeValues) - { - if (string.IsNullOrWhiteSpace(routeTemplate)) - return TelemetryDefaults.Unknown; - - if (!routeValues.TryGetValue("version", out var versionValue) || versionValue is null) - return routeTemplate; - - var version = versionValue.ToString(); - if (string.IsNullOrWhiteSpace(version)) - return routeTemplate; - - return VersionTokenRegex().Replace(routeTemplate, version, 1); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Observability/ObservabilityConventions.cs b/absolute/src/APITemplate.Infrastructure/Observability/ObservabilityConventions.cs deleted file mode 100644 index d394e209..00000000 --- a/absolute/src/APITemplate.Infrastructure/Observability/ObservabilityConventions.cs +++ /dev/null @@ -1,271 +0,0 @@ -namespace APITemplate.Infrastructure.Observability; - -/// Shared names for the application's OpenTelemetry activity source and meters. -public static class ObservabilityConventions -{ - public const string ActivitySourceName = "APITemplate"; - public const string MeterName = "APITemplate"; - public const string HealthMeterName = "APITemplate.Health"; -} - -/// Canonical metric instrument names emitted by the application meter. -public static class TelemetryMetricNames -{ - public const string RateLimitRejections = "apitemplate_rate_limit_rejections"; - public const string HandledExceptions = "apitemplate_exceptions_handled"; - public const string OutputCacheInvalidations = "apitemplate_output_cache_invalidations"; - public const string OutputCacheInvalidationDuration = - "apitemplate_output_cache_invalidation_duration"; - public const string OutputCacheOutcomes = "apitemplate_output_cache_outcomes"; - public const string GraphQlRequests = "apitemplate_graphql_requests"; - public const string GraphQlErrors = "apitemplate_graphql_errors"; - public const string GraphQlDocumentCacheHits = "apitemplate_graphql_document_cache_hits"; - public const string GraphQlDocumentCacheMisses = "apitemplate_graphql_document_cache_misses"; - public const string GraphQlOperationCacheHits = "apitemplate_graphql_operation_cache_hits"; - public const string GraphQlOperationCacheMisses = "apitemplate_graphql_operation_cache_misses"; - public const string GraphQlRequestDuration = "apitemplate_graphql_request_duration"; - public const string GraphQlOperationCost = "apitemplate_graphql_operation_cost"; - public const string HealthStatus = "apitemplate_healthcheck_status"; - public const string AuthFailures = "apitemplate_auth_failures"; - public const string ValidationRequestsRejected = "apitemplate_validation_requests_rejected"; - public const string ValidationErrors = "apitemplate_validation_errors"; - public const string ConcurrencyConflicts = "apitemplate_concurrency_conflicts"; - public const string DomainConflicts = "apitemplate_domain_conflicts"; -} - -/// Canonical tag/attribute key names applied to metrics and traces. -public static class TelemetryTagKeys -{ - public const string ApiSurface = "apitemplate.api.surface"; - public const string Authenticated = "apitemplate.authenticated"; - public const string TenantId = "tenant.id"; - public const string AuthScheme = "auth.scheme"; - public const string AuthFailureReason = "auth.failure_reason"; - public const string CacheOutcome = "cache.outcome"; - public const string CachePolicy = "cache.policy"; - public const string CacheTag = "cache.tag"; - public const string DbSystem = "db.system"; - public const string DbOperation = "db.operation"; - public const string DbStoredProcedureName = "db.stored_procedure.name"; - public const string DbResultCount = "db.result_count"; - public const string ErrorCode = "error.code"; - public const string ExceptionType = "exception.type"; - public const string GraphQlCostKind = "graphql.cost.kind"; - public const string GraphQlHasErrors = "graphql.has_errors"; - public const string GraphQlOperationType = "graphql.operation.type"; - public const string GraphQlPhase = "graphql.phase"; - public const string HttpMethod = "http.request.method"; - public const string HttpResponseStatusCode = "http.response.status_code"; - public const string HttpRoute = "http.route"; - public const string RateLimitPolicy = "rate_limit.policy"; - public const string Service = "service"; - public const string StartupComponent = "startup.component"; - public const string StartupStep = "startup.step"; - public const string StartupSuccess = "startup.success"; - public const string ValidationDtoType = "validation.dto_type"; - public const string ValidationProperty = "validation.property"; -} - -/// Canonical activity/span names recorded in the application activity source. -public static class TelemetryActivityNames -{ - public const string OutputCacheInvalidate = "output_cache.invalidate"; - public const string CookieSessionRefresh = "auth.cookie-session-refresh"; - public const string RedirectToLogin = "auth.redirect-to-login"; - public const string TokenValidated = "auth.token-validated"; - - public static string Startup(string step) => $"startup.{step}"; - - public static string StoredProcedure(string operation) => $"stored_procedure.{operation}"; -} - -/// Well-known tag values representing cache outcome states. -public static class TelemetryOutcomeValues -{ - public const string Hit = "hit"; - public const string Store = "store"; - public const string Bypass = "bypass"; -} - -/// Well-known tag values identifying the reason for an authentication failure. -public static class TelemetryFailureReasons -{ - public const string MissingRefreshToken = "missing_refresh_token"; - public const string MissingTenantClaim = "missing_tenant_claim"; - public const string RefreshFailed = "refresh_failed"; - public const string TokenEndpointRejected = "token_endpoint_rejected"; - public const string TokenRefreshException = "token_refresh_exception"; - public const string UnauthorizedRedirect = "unauthorized_redirect"; -} - -/// Well-known tag values that identify the API surface a request was served from. -public static class TelemetrySurfaces -{ - public const string Bff = "bff"; - public const string Documentation = "documentation"; - public const string GraphQl = "graphql"; - public const string Health = "health"; - public const string Rest = "rest"; -} - -/// Default fallback values used when a tag or setting cannot be resolved. -public static class TelemetryDefaults -{ - public const string AspireOtlpEndpoint = "http://localhost:4317"; - public const string Default = "default"; - public const string Sql = "sql"; - public const string Unknown = "unknown"; -} - -/// Keys used to store transient telemetry values in . -public static class TelemetryContextKeys -{ - public const string OutputCachePolicyName = "OutputCachePolicyName"; -} - -/// URL path prefixes used to classify requests into API surface areas. -public static class TelemetryPathPrefixes -{ - public const string GraphQl = "/graphql"; - public const string Health = "/health"; - public const string OpenApi = "/openapi"; - public const string Scalar = "/scalar"; -} - -/// Well-known tag values specific to GraphQL telemetry (phases and cost kinds). -public static class TelemetryGraphQlValues -{ - public const string FieldCostKind = "field"; - public const string RequestPhase = "request"; - public const string ResolverPhase = "resolver"; - public const string SyntaxPhase = "syntax"; - public const string TypeCostKind = "type"; - public const string ValidationPhase = "validation"; -} - -/// Well-known step names used to identify individual startup task activities. -public static class TelemetryStartupSteps -{ - public const string Migrate = "migrate"; - public const string SeedAuthBootstrap = "seed-auth-bootstrap"; - public const string WaitKeycloakReady = "wait-keycloak-ready"; -} - -/// Well-known component names tagged on startup activity spans. -public static class TelemetryStartupComponents -{ - public const string AuthBootstrap = "auth-bootstrap"; - public const string Keycloak = "keycloak"; - public const string MongoDb = "mongodb"; - public const string PostgreSql = "postgresql"; -} - -/// Well-known database system tag values used on database-related spans and metrics. -public static class TelemetryDatabaseSystems -{ - public const string MongoDb = "mongodb"; - public const string PostgreSql = "postgresql"; -} - -/// Well-known operation names used to tag stored procedure activity spans. -public static class TelemetryStoredProcedureOperations -{ - public const string Execute = "execute"; - public const string QueryFirst = "query-first"; - public const string QueryMany = "query-many"; -} - -/// Meter names from ASP.NET Core and other Microsoft libraries used to subscribe to built-in metrics. -public static class TelemetryMeterNames -{ - public const string AspNetCoreAuthentication = "Microsoft.AspNetCore.Authentication"; - public const string AspNetCoreAuthorization = "Microsoft.AspNetCore.Authorization"; - public const string AspNetCoreConnections = "Microsoft.AspNetCore.Http.Connections"; - public const string AspNetCoreDiagnostics = "Microsoft.AspNetCore.Diagnostics"; - public const string AspNetCoreHosting = "Microsoft.AspNetCore.Hosting"; - public const string AspNetCoreRateLimiting = "Microsoft.AspNetCore.RateLimiting"; - public const string AspNetCoreRouting = "Microsoft.AspNetCore.Routing"; - public const string AspNetCoreServerKestrel = "Microsoft.AspNetCore.Server.Kestrel"; -} - -/// Semantic-convention instrument names for HTTP client and server request durations. -public static class TelemetryInstrumentNames -{ - public const string HttpClientRequestDuration = "http.client.request.duration"; - public const string HttpServerRequestDuration = "http.server.request.duration"; -} - -/// OpenTelemetry resource attribute key names used when configuring service identity and environment metadata. -public static class TelemetryResourceAttributeKeys -{ - public const string AssemblyName = "assembly.name"; - public const string DeploymentEnvironmentName = "deployment.environment.name"; - public const string HostName = "host.name"; - public const string HostArchitecture = "host.arch"; - public const string OsType = "os.type"; - public const string ProcessPid = "process.pid"; - public const string ProcessRuntimeName = "process.runtime.name"; - public const string ProcessRuntimeVersion = "process.runtime.version"; - public const string ServiceNamespace = "service.namespace"; - public const string ServiceInstanceId = "service.instance.id"; - public const string ServiceName = "service.name"; - public const string ServiceVersion = "service.version"; -} - -/// Pre-defined histogram bucket boundaries for common metric instruments. -public static class TelemetryHistogramBoundaries -{ - public static readonly double[] HttpRequestDurationSeconds = - [ - 0.005, - 0.01, - 0.025, - 0.05, - 0.075, - 0.1, - 0.25, - 0.5, - 0.75, - 1, - 2.5, - 5, - 10, - ]; - - public static readonly double[] CacheOperationDurationMs = - [ - 1, - 5, - 10, - 25, - 50, - 100, - 250, - 500, - 1000, - ]; - - public static readonly double[] GraphQlRequestDurationMs = - [ - 1, - 5, - 10, - 25, - 50, - 100, - 250, - 500, - 1000, - 2500, - 5000, - ]; -} - -/// Third-party library names used as OpenTelemetry activity sources and meters. -public static class TelemetryThirdPartySources -{ - public const string MongoDbDriverDiagnosticSources = - "MongoDB.Driver.Core.Extensions.DiagnosticSources"; - - public const string Wolverine = "Wolverine"; -} diff --git a/absolute/src/APITemplate.Infrastructure/Observability/StartupTelemetry.cs b/absolute/src/APITemplate.Infrastructure/Observability/StartupTelemetry.cs deleted file mode 100644 index 094c0ac1..00000000 --- a/absolute/src/APITemplate.Infrastructure/Observability/StartupTelemetry.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Diagnostics; - -namespace APITemplate.Infrastructure.Observability; - -/// -/// Static facade for startup-phase telemetry, creating diagnostic activities for each -/// startup task (migration, seeding, readiness checks) so they appear as spans in traces. -/// -public static class StartupTelemetry -{ - private static readonly ActivitySource ActivitySource = new( - ObservabilityConventions.ActivitySourceName - ); - - /// Starts a traced startup scope for the relational (PostgreSQL) migration step. - public static Scope StartRelationalMigration() => - StartStep( - TelemetryStartupSteps.Migrate, - TelemetryStartupComponents.PostgreSql, - TelemetryDatabaseSystems.PostgreSql - ); - - /// Starts a traced startup scope for the MongoDB migration step. - public static Scope StartMongoMigration() => - StartStep( - TelemetryStartupSteps.Migrate, - TelemetryStartupComponents.MongoDb, - TelemetryDatabaseSystems.MongoDb - ); - - /// Starts a traced startup scope for the auth-bootstrap seeding step. - public static Scope StartAuthBootstrapSeed() => - StartStep( - TelemetryStartupSteps.SeedAuthBootstrap, - TelemetryStartupComponents.AuthBootstrap - ); - - /// Starts a traced startup scope for the Keycloak readiness-check step. - public static Scope StartKeycloakReadinessCheck() => - StartStep(TelemetryStartupSteps.WaitKeycloakReady, TelemetryStartupComponents.Keycloak); - - private static Scope StartStep(string step, string component, string? dbSystem = null) - { - var activity = StartActivity(step, component); - if (!string.IsNullOrWhiteSpace(dbSystem)) - activity?.SetTag(TelemetryTagKeys.DbSystem, dbSystem); - - return new Scope(activity); - } - - private static Activity? StartActivity(string step, string component) - { - var activity = ActivitySource.StartActivity( - TelemetryActivityNames.Startup(step), - ActivityKind.Internal - ); - activity?.SetTag(TelemetryTagKeys.StartupStep, step); - activity?.SetTag(TelemetryTagKeys.StartupComponent, component); - return activity; - } - - /// - /// Represents an active startup telemetry scope. Call to mark the - /// underlying activity as failed, then dispose to end the activity. - /// - public sealed class Scope(Activity? activity) : IDisposable - { - private readonly Activity? _activity = activity; - - /// Marks the underlying activity as failed and records the exception type. - public void Fail(Exception exception) - { - if (_activity is not null) - { - _activity.SetStatus(ActivityStatusCode.Error, exception.Message); - _activity.SetTag(TelemetryTagKeys.StartupSuccess, false); - _activity.SetTag(TelemetryTagKeys.ExceptionType, exception.GetType().Name); - } - } - - public void Dispose() => _activity?.Dispose(); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Observability/StoredProcedureTelemetry.cs b/absolute/src/APITemplate.Infrastructure/Observability/StoredProcedureTelemetry.cs deleted file mode 100644 index da512938..00000000 --- a/absolute/src/APITemplate.Infrastructure/Observability/StoredProcedureTelemetry.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System.Diagnostics; -using APITemplate.Domain.Interfaces; - -namespace APITemplate.Infrastructure.Observability; - -/// -/// Wraps stored-procedure and raw SQL executions in a diagnostic activity, -/// recording the operation name, command name, result count, and any exception type. -/// -public static class StoredProcedureTelemetry -{ - private static readonly ActivitySource ActivitySource = new( - ObservabilityConventions.ActivitySourceName - ); - - /// Executes inside a traced activity for a single-result stored procedure query. - public static Task TraceQueryFirstAsync( - IStoredProcedure procedure, - Func> action - ) - where TResult : class => - TraceAsync( - TelemetryStoredProcedureOperations.QueryFirst, - procedure.GetType().Name, - action, - result => result is null ? 0 : 1 - ); - - /// Executes inside a traced activity for a multi-result stored procedure query. - public static Task> TraceQueryManyAsync( - IStoredProcedure procedure, - Func>> action - ) - where TResult : class => - TraceAsync( - TelemetryStoredProcedureOperations.QueryMany, - procedure.GetType().Name, - action, - results => results.Count - ); - - /// Executes inside a traced activity for a raw SQL execute operation. - public static Task TraceExecuteAsync(FormattableString sql, Func> action) => - TraceAsync( - TelemetryStoredProcedureOperations.Execute, - ResolveSqlOperationName(sql), - action, - affectedRows => affectedRows - ); - - private static async Task TraceAsync( - string operation, - string commandName, - Func> action, - Func resultCountSelector - ) - { - using var activity = ActivitySource.StartActivity( - TelemetryActivityNames.StoredProcedure(operation), - ActivityKind.Internal - ); - activity?.SetTag(TelemetryTagKeys.DbOperation, operation); - activity?.SetTag(TelemetryTagKeys.DbStoredProcedureName, commandName); - try - { - var result = await action(); - activity?.SetTag(TelemetryTagKeys.DbResultCount, resultCountSelector(result)); - return result; - } - catch (Exception ex) - { - activity?.SetStatus(ActivityStatusCode.Error); - activity?.SetTag(TelemetryTagKeys.ExceptionType, ex.GetType().Name); - throw; - } - } - - private static string ResolveSqlOperationName(FormattableString sql) - { - var commandText = sql.Format.TrimStart(); - var firstToken = commandText - .Split(' ', StringSplitOptions.RemoveEmptyEntries) - .FirstOrDefault(); - return string.IsNullOrWhiteSpace(firstToken) - ? TelemetryDefaults.Sql - : firstToken.ToLowerInvariant(); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Observability/TelemetryApiSurfaceResolver.cs b/absolute/src/APITemplate.Infrastructure/Observability/TelemetryApiSurfaceResolver.cs deleted file mode 100644 index 503ce28c..00000000 --- a/absolute/src/APITemplate.Infrastructure/Observability/TelemetryApiSurfaceResolver.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace APITemplate.Infrastructure.Observability; - -/// -/// Maps an HTTP request path to a logical API surface name (e.g., graphql, health, rest) -/// for use as a telemetry tag value. -/// -public static class TelemetryApiSurfaceResolver -{ - /// - /// Returns the surface name for the given request path by matching well-known prefixes; - /// falls back to for all other paths. - /// - public static string Resolve(PathString path) - { - if (path.StartsWithSegments(TelemetryPathPrefixes.GraphQl)) - return TelemetrySurfaces.GraphQl; - - if (path.StartsWithSegments(TelemetryPathPrefixes.Health)) - return TelemetrySurfaces.Health; - - if ( - path.StartsWithSegments(TelemetryPathPrefixes.Scalar) - || path.StartsWithSegments(TelemetryPathPrefixes.OpenApi) - ) - { - return TelemetrySurfaces.Documentation; - } - - return TelemetrySurfaces.Rest; - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Observability/ValidationTelemetry.cs b/absolute/src/APITemplate.Infrastructure/Observability/ValidationTelemetry.cs deleted file mode 100644 index d03b5915..00000000 --- a/absolute/src/APITemplate.Infrastructure/Observability/ValidationTelemetry.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Diagnostics.Metrics; -using FluentValidation.Results; -using Microsoft.AspNetCore.Mvc.Filters; - -namespace APITemplate.Infrastructure.Observability; - -/// -/// Static facade for validation-related metrics, recording rejected request counts and -/// individual validation error counts with DTO type, route, and property tags. -/// -public static class ValidationTelemetry -{ - private static readonly Meter Meter = new(ObservabilityConventions.MeterName); - - private static readonly Counter ValidationRequestsRejected = Meter.CreateCounter( - TelemetryMetricNames.ValidationRequestsRejected, - description: "Number of requests rejected by validation." - ); - - private static readonly Counter ValidationErrors = Meter.CreateCounter( - TelemetryMetricNames.ValidationErrors, - description: "Number of individual validation errors." - ); - - /// - /// Increments the rejected-requests counter and increments one error counter entry per - /// , each tagged with the DTO type, route, and property name. - /// - public static void RecordValidationFailure( - ActionExecutingContext context, - Type argumentType, - IEnumerable failures - ) - { - var route = ResolveRoute(context); - ValidationRequestsRejected.Add( - 1, - [ - new KeyValuePair( - TelemetryTagKeys.ValidationDtoType, - argumentType.Name - ), - new KeyValuePair(TelemetryTagKeys.HttpRoute, route), - ] - ); - - foreach (var failure in failures) - { - ValidationErrors.Add( - 1, - [ - new KeyValuePair( - TelemetryTagKeys.ValidationDtoType, - argumentType.Name - ), - new KeyValuePair(TelemetryTagKeys.HttpRoute, route), - new KeyValuePair( - TelemetryTagKeys.ValidationProperty, - failure.PropertyName - ), - ] - ); - } - } - - private static string ResolveRoute(ActionExecutingContext context) => - context.ActionDescriptor.AttributeRouteInfo?.Template is { } template - ? HttpRouteResolver.ReplaceVersionToken(template, context.RouteData.Values) - : context.HttpContext.Request.Path.Value ?? TelemetryDefaults.Unknown; -} diff --git a/absolute/src/APITemplate.Infrastructure/Persistence/AppDbContext.cs b/absolute/src/APITemplate.Infrastructure/Persistence/AppDbContext.cs deleted file mode 100644 index 587ae5f4..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/AppDbContext.cs +++ /dev/null @@ -1,220 +0,0 @@ -using APITemplate.Application.Common.Context; -using APITemplate.Domain.Entities; -using APITemplate.Infrastructure.Persistence.Auditing; -using APITemplate.Infrastructure.Persistence.EntityNormalization; -using APITemplate.Infrastructure.Persistence.SoftDelete; -using Microsoft.EntityFrameworkCore; - -namespace APITemplate.Infrastructure.Persistence; - -/// -/// Main EF Core context for relational storage. -/// Enforces multi-tenancy, audit stamping, soft delete, and optimistic concurrency -/// for all entities based on . -/// -/// -/// Key behavior: -/// -/// -/// -/// Global query filters automatically limit reads to the current tenant and exclude soft-deleted rows. -/// -/// -/// -/// -/// and centralize -/// audit field updates (Created*/Updated*/Deleted*). -/// -/// -/// -/// -/// Delete operations are converted to soft delete updates, including soft-cascade from Product to ProductReviews. -/// -/// -/// -/// -public sealed class AppDbContext : DbContext -{ - // Tenant provider drives read isolation (global filters) and default tenant assignment on inserts. - private readonly ITenantProvider _tenantProvider; - private readonly IActorProvider _actorProvider; - private readonly TimeProvider _timeProvider; - private readonly IReadOnlyCollection _softDeleteCascadeRules; - private readonly IEntityNormalizationService _entityNormalizationService; - private readonly IAuditableEntityStateManager _entityStateManager; - private readonly ISoftDeleteProcessor _softDeleteProcessor; - - private Guid CurrentTenantId => _tenantProvider.TenantId; - private bool HasTenant => _tenantProvider.HasTenant; - - public AppDbContext( - DbContextOptions options, - ITenantProvider tenantProvider, - IActorProvider actorProvider, - TimeProvider timeProvider, - IEnumerable softDeleteCascadeRules, - IEntityNormalizationService entityNormalizationService, - IAuditableEntityStateManager entityStateManager, - ISoftDeleteProcessor softDeleteProcessor - ) - : base(options) - { - _tenantProvider = tenantProvider; - _actorProvider = actorProvider; - _timeProvider = timeProvider; - _softDeleteCascadeRules = softDeleteCascadeRules.ToList(); - _entityNormalizationService = entityNormalizationService; - _entityStateManager = entityStateManager; - _softDeleteProcessor = softDeleteProcessor; - } - - public DbSet Products => Set(); - public DbSet ProductDataLinks => Set(); - public DbSet ProductReviews => Set(); - public DbSet Categories => Set(); - public DbSet Tenants => Set(); - public DbSet Users => Set(); - public DbSet TenantInvitations => Set(); - public DbSet ProductCategoryStats => Set(); - public DbSet FailedEmails => Set(); - public DbSet StoredFiles => Set(); - public DbSet JobExecutions => Set(); - - /// - /// Applies entity configurations and auto-registers global tenant/soft-delete query filters. - /// - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); - ApplyGlobalFilters(modelBuilder); - } - - /// - /// Not supported — use instead. - /// - /// Always thrown to prevent sync-over-async deadlocks - /// caused by the async soft-delete cascade rules. - public override int SaveChanges(bool acceptAllChangesOnSuccess) - { - throw new NotSupportedException( - "Use SaveChangesAsync to avoid deadlocks from async soft-delete cascade rules. " - + "All application paths should go through IUnitOfWork.CommitAsync()." - ); - } - - /// - /// Applies audit/soft-delete rules before committing changes asynchronously. - /// - public override async Task SaveChangesAsync( - bool acceptAllChangesOnSuccess, - CancellationToken cancellationToken = default - ) - { - await ApplyEntityAuditingAsync(cancellationToken); - return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); - } - - /// - /// Discovers all model types implementing tenant + soft-delete contracts - /// and wires a generic global filter for each of them. - /// - /// - /// - /// EF Core does not have a built-in way to register a generic query filter for "all - /// types that implement these interfaces". This method finds those types at model - /// build time and applies the filter using reflection. - /// - /// - /// The applied filters ensure: - /// - soft-deleted rows (IsDeleted == true) are excluded implicitly, - /// - and multi-tenant reads are scoped to the current tenant. - /// - /// - private void ApplyGlobalFilters(ModelBuilder modelBuilder) - { - // Iterate over every entity type that EF Core knows about. - // This includes all DbSet<> entities and entities registered via configurations. - - foreach (var entityType in modelBuilder.Model.GetEntityTypes()) - { - // Only apply the filter to entities that support both multi-tenancy and soft delete. - if ( - !typeof(ITenantEntity).IsAssignableFrom(entityType.ClrType) - || !typeof(ISoftDeletable).IsAssignableFrom(entityType.ClrType) - ) - { - continue; - } - - // The SetGlobalFilter method is generic (). We only know the concrete type - // at runtime, so we construct a closed generic method using reflection. - var method = typeof(AppDbContext) - .GetMethod( - nameof(SetGlobalFilter), - System.Reflection.BindingFlags.Instance - | System.Reflection.BindingFlags.NonPublic - )! - .MakeGenericMethod(entityType.ClrType); - - // Invoke the configured generic helper, applying the query filters to the model. - method.Invoke(this, [modelBuilder]); - } - } - - private void SetGlobalFilter(ModelBuilder modelBuilder) - where TEntity : class, ITenantEntity, ISoftDeletable - { - modelBuilder - .Entity() - .HasQueryFilter("SoftDelete", entity => !entity.IsDeleted) - .HasQueryFilter("Tenant", entity => HasTenant && entity.TenantId == CurrentTenantId); - } - - /// - /// Processes tracked entities and stamps audit fields according to current state. - /// - private async Task ApplyEntityAuditingAsync(CancellationToken cancellationToken) - { - var now = _timeProvider.GetUtcNow().UtcDateTime; - var actor = _actorProvider.ActorId; - - foreach ( - var entry in ChangeTracker - .Entries() - .Where(e => e.Entity is IAuditableTenantEntity) - .ToList() - ) - { - var 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/absolute/src/APITemplate.Infrastructure/Persistence/AppDbContextFactory.cs b/absolute/src/APITemplate.Infrastructure/Persistence/AppDbContextFactory.cs deleted file mode 100644 index 9ff132c9..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/AppDbContextFactory.cs +++ /dev/null @@ -1,92 +0,0 @@ -using APITemplate.Application.Common.Context; -using APITemplate.Domain.Entities; -using APITemplate.Infrastructure.Persistence.Auditing; -using APITemplate.Infrastructure.Persistence.EntityNormalization; -using APITemplate.Infrastructure.Persistence.SoftDelete; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.ChangeTracking; -using Microsoft.EntityFrameworkCore.Design; - -namespace APITemplate.Infrastructure.Persistence; - -/// -/// Design-time factory used exclusively by EF Core tooling (dotnet ef migrations add/remove). -/// Not used at runtime. -/// -public sealed class AppDbContextFactory : IDesignTimeDbContextFactory -{ - /// - /// Creates an with null-object collaborators so EF Core tooling - /// can scaffold and apply migrations without a running application host. - /// - public AppDbContext CreateDbContext(string[] args) - { - var configuration = DesignTimeConfigurationHelper.BuildConfiguration(); - var connectionString = DesignTimeConfigurationHelper.GetConnectionString(configuration); - - var options = new DbContextOptionsBuilder() - .UseNpgsql(connectionString) - .Options; - - return new AppDbContext( - options, - new NullTenantProvider(), - new NullActorProvider(), - TimeProvider.System, - [], - new NullEntityNormalizationService(), - new NullAuditableEntityStateManager(), - new NullSoftDeleteProcessor() - ); - } - - private sealed class NullTenantProvider : ITenantProvider - { - public Guid TenantId => Guid.Empty; - public bool HasTenant => false; - } - - private sealed class NullActorProvider : IActorProvider - { - public Guid ActorId => Guid.Empty; - } - - private sealed class NullEntityNormalizationService : IEntityNormalizationService - { - public void Normalize(IAuditableTenantEntity entity) { } - } - - private sealed class NullAuditableEntityStateManager : IAuditableEntityStateManager - { - public void StampAdded( - EntityEntry entry, - IAuditableTenantEntity entity, - DateTime now, - Guid actor, - bool hasTenant, - Guid currentTenantId - ) { } - - public void StampModified(IAuditableTenantEntity entity, DateTime now, Guid actor) { } - - public void MarkSoftDeleted( - EntityEntry entry, - IAuditableTenantEntity entity, - DateTime now, - Guid actor - ) { } - } - - private sealed class NullSoftDeleteProcessor : ISoftDeleteProcessor - { - public Task ProcessAsync( - AppDbContext dbContext, - EntityEntry entry, - IAuditableTenantEntity entity, - DateTime now, - Guid actor, - IReadOnlyCollection softDeleteCascadeRules, - CancellationToken cancellationToken - ) => Task.CompletedTask; - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Persistence/Auditing/AuditableEntityStateManager.cs b/absolute/src/APITemplate.Infrastructure/Persistence/Auditing/AuditableEntityStateManager.cs deleted file mode 100644 index 61dffa0f..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/Auditing/AuditableEntityStateManager.cs +++ /dev/null @@ -1,87 +0,0 @@ -using APITemplate.Domain.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.ChangeTracking; - -namespace APITemplate.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 is Tenant tenant && tenant.TenantId == Guid.Empty) - tenant.TenantId = tenant.Id; - - 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) - { - var 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/absolute/src/APITemplate.Infrastructure/Persistence/Auditing/IAuditableEntityStateManager.cs b/absolute/src/APITemplate.Infrastructure/Persistence/Auditing/IAuditableEntityStateManager.cs deleted file mode 100644 index b3af5daf..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/Auditing/IAuditableEntityStateManager.cs +++ /dev/null @@ -1,32 +0,0 @@ -using APITemplate.Domain.Entities; -using Microsoft.EntityFrameworkCore.ChangeTracking; - -namespace APITemplate.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/absolute/src/APITemplate.Infrastructure/Persistence/AuthBootstrapSeeder.cs b/absolute/src/APITemplate.Infrastructure/Persistence/AuthBootstrapSeeder.cs deleted file mode 100644 index cc7dc709..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/AuthBootstrapSeeder.cs +++ /dev/null @@ -1,104 +0,0 @@ -using APITemplate.Application.Common.Options; -using APITemplate.Domain.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; - -namespace APITemplate.Infrastructure.Persistence; - -/// -/// Seeds the bootstrap tenant on application startup, creating it if absent or restoring it -/// if it was previously soft-deleted or deactivated. -/// -public sealed class AuthBootstrapSeeder -{ - private static readonly Guid DefaultTenantId = Guid.Parse( - "00000000-0000-0000-0000-000000000001" - ); - - private readonly AppDbContext _dbContext; - private readonly BootstrapTenantOptions _tenantOptions; - - public AuthBootstrapSeeder( - AppDbContext dbContext, - IOptions tenantOptions - ) - { - _dbContext = dbContext; - _tenantOptions = tenantOptions.Value; - } - - /// - /// Ensures the configured bootstrap tenant exists and is active. - /// Creates, restores, or reactivates the tenant as needed, then persists any changes. - /// - public async Task SeedAsync(CancellationToken ct = default) - { - var tenantIdentity = GetTenantIdentity(); - var tenant = await FindTenantAsync(tenantIdentity.Code, ct); - var hasChanges = tenant is null ? CreateTenant(tenantIdentity) : RestoreTenant(tenant); - - await SaveIfChangedAsync(hasChanges, ct); - } - - private TenantIdentity GetTenantIdentity() - { - return new TenantIdentity(_tenantOptions.Code.Trim(), _tenantOptions.Name.Trim()); - } - - private Task FindTenantAsync(string tenantCode, CancellationToken ct) - { - // Bypass SoftDelete and Tenant filters — seeder runs at startup without tenant context - // and must find the tenant even if it was soft-deleted (to restore it). - return _dbContext - .Tenants.IgnoreQueryFilters(["SoftDelete", "Tenant"]) - .FirstOrDefaultAsync(t => t.Code == tenantCode, ct); - } - - private bool CreateTenant(TenantIdentity tenantIdentity) - { - var tenant = new Tenant - { - Id = DefaultTenantId, - TenantId = Guid.Empty, - Code = tenantIdentity.Code, - Name = tenantIdentity.Name, - IsActive = true, - }; - - _dbContext.Tenants.Add(tenant); - return true; - } - - private static bool RestoreTenant(Tenant tenant) - { - var hasChanges = EnsureTenantIsActive(tenant); - return EnsureTenantIsNotDeleted(tenant) || hasChanges; - } - - private static bool EnsureTenantIsActive(Tenant tenant) - { - if (tenant.IsActive) - return false; - - tenant.IsActive = true; - return true; - } - - private static bool EnsureTenantIsNotDeleted(Tenant tenant) - { - if (!tenant.IsDeleted) - return false; - - tenant.IsDeleted = false; - tenant.DeletedAtUtc = null; - tenant.DeletedBy = null; - return true; - } - - private Task SaveIfChangedAsync(bool hasChanges, CancellationToken ct) - { - return hasChanges ? _dbContext.SaveChangesAsync(ct) : Task.CompletedTask; - } - - private readonly record struct TenantIdentity(string Code, string Name); -} diff --git a/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/AppUserConfiguration.cs b/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/AppUserConfiguration.cs deleted file mode 100644 index 1d20452b..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/AppUserConfiguration.cs +++ /dev/null @@ -1,59 +0,0 @@ -using APITemplate.Domain.Entities; -using APITemplate.Domain.Enums; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace APITemplate.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); - - // Global uniqueness (not tenant-scoped): a Keycloak user ID is a system-wide identity. - // A user can only belong to one tenant, so no TenantId prefix is needed. - builder - .HasIndex(u => u.KeycloakUserId) - .IsUnique() - .HasFilter("\"KeycloakUserId\" IS NOT NULL"); - - builder.Property(u => u.IsActive).IsRequired().HasDefaultValue(true); - - // Persist the enum as a string (not an int) so the DB values stay readable and stable. - // If we ever add/remove roles, the stored values don’t shift unexpectedly. - builder - .Property(u => u.Role) - .HasConversion() - // Required: every user must have a role. - .IsRequired() - // Keep the underlying column size limited (and avoid long enum names). - .HasMaxLength(32) - // Default new users to a normal user role. - .HasDefaultValue(UserRole.User) - // Sentinel is used by the domain layer for a "missing" enum value. - // It lets us detect cases where the value is invalid or not set. - .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/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/CategoryConfiguration.cs b/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/CategoryConfiguration.cs deleted file mode 100644 index f3c5b338..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/CategoryConfiguration.cs +++ /dev/null @@ -1,31 +0,0 @@ -using APITemplate.Domain.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace APITemplate.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 - .HasOne() - .WithMany() - .HasForeignKey(c => c.TenantId) - .OnDelete(DeleteBehavior.Restrict); - - builder.HasIndex(c => new { c.TenantId, c.Name }).IsUnique(); - builder - .HasIndex(c => new { c.Name, c.Description }) - .HasMethod("GIN") - .IsTsVectorExpressionIndex("english"); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/FailedEmailConfiguration.cs b/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/FailedEmailConfiguration.cs deleted file mode 100644 index 558908ff..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/FailedEmailConfiguration.cs +++ /dev/null @@ -1,47 +0,0 @@ -using APITemplate.Domain.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace APITemplate.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/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/JobExecutionConfiguration.cs b/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/JobExecutionConfiguration.cs deleted file mode 100644 index add8524c..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/JobExecutionConfiguration.cs +++ /dev/null @@ -1,44 +0,0 @@ -using APITemplate.Domain.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace APITemplate.Infrastructure.Persistence.Configurations; - -/// EF Core configuration for the entity, including a progress check constraint and status index. -public sealed class JobExecutionConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(j => j.Id); - builder.ConfigureTenantAuditable(); - - builder.Property(j => j.JobType).IsRequired().HasMaxLength(100); - - builder.Property(j => j.Status).IsRequired().HasMaxLength(20).HasConversion(); - - builder.Property(j => j.ProgressPercent).HasDefaultValue(0); - - builder.Property(j => j.Parameters).HasColumnType("text"); - - builder.Property(j => j.CallbackUrl).HasMaxLength(2048); - - builder.Property(j => j.ResultPayload).HasColumnType("text"); - - builder.Property(j => j.ErrorMessage).HasColumnType("text"); - - builder - .HasOne() - .WithMany() - .HasForeignKey(j => j.TenantId) - .OnDelete(DeleteBehavior.Restrict); - - builder.HasIndex(j => new { j.TenantId, j.Status }); - - builder.ToTable(t => - t.HasCheckConstraint( - "CK_JobExecutions_Progress", - "\"ProgressPercent\" >= 0 AND \"ProgressPercent\" <= 100" - ) - ); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/ProductCategoryStatsConfiguration.cs b/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/ProductCategoryStatsConfiguration.cs deleted file mode 100644 index e127d3ce..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/ProductCategoryStatsConfiguration.cs +++ /dev/null @@ -1,23 +0,0 @@ -using APITemplate.Domain.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace APITemplate.Infrastructure.Persistence.Configurations; - -/// -/// Registers as a keyless entity. -/// 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 void Configure(EntityTypeBuilder builder) - { - // No primary key, no table — result-set only. - builder.HasNoKey(); - - // ExcludeFromMigrations tells EF Core to skip this type when generating migrations. - // The entity exists only as a materialisation target for FromSql() calls. - builder.ToTable("ProductCategoryStats", t => t.ExcludeFromMigrations()); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/ProductConfiguration.cs b/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/ProductConfiguration.cs deleted file mode 100644 index b6d2f2dc..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/ProductConfiguration.cs +++ /dev/null @@ -1,39 +0,0 @@ -using APITemplate.Domain.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace APITemplate.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 - .HasOne() - .WithMany() - .HasForeignKey(p => p.TenantId) - .OnDelete(DeleteBehavior.Restrict); - - builder.HasIndex(p => new { p.TenantId, p.Name }); - builder - .HasIndex(p => new { p.Name, p.Description }) - .HasMethod("GIN") - .IsTsVectorExpressionIndex("english"); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/ProductDataLinkConfiguration.cs b/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/ProductDataLinkConfiguration.cs deleted file mode 100644 index 8e3a269a..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/ProductDataLinkConfiguration.cs +++ /dev/null @@ -1,28 +0,0 @@ -using APITemplate.Domain.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace APITemplate.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/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/ProductReviewConfiguration.cs b/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/ProductReviewConfiguration.cs deleted file mode 100644 index 6e3d88b7..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/ProductReviewConfiguration.cs +++ /dev/null @@ -1,39 +0,0 @@ -using APITemplate.Domain.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace APITemplate.Infrastructure.Persistence.Configurations; - -/// EF Core configuration for the entity defining relationships to product and user. -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 - .HasOne(r => r.Product) - .WithMany(p => p.Reviews) - .HasForeignKey(r => r.ProductId) - .OnDelete(DeleteBehavior.Cascade); - - builder - .HasOne(r => r.User) - .WithMany() - .HasForeignKey(r => r.UserId) - .OnDelete(DeleteBehavior.Restrict); - - builder - .HasOne() - .WithMany() - .HasForeignKey(r => r.TenantId) - .OnDelete(DeleteBehavior.Restrict); - - builder.HasIndex(r => new { r.TenantId, r.ProductId }); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/StoredFileConfiguration.cs b/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/StoredFileConfiguration.cs deleted file mode 100644 index c6bcf5f6..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/StoredFileConfiguration.cs +++ /dev/null @@ -1,32 +0,0 @@ -using APITemplate.Domain.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace APITemplate.Infrastructure.Persistence.Configurations; - -/// EF Core configuration for the entity, mapped to the ExampleFiles table. -public sealed class StoredFileConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - // Table named 'ExampleFiles' as part of the examples/showcase feature - builder.ToTable("ExampleFiles"); - - 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); - - builder - .HasOne() - .WithMany() - .HasForeignKey(e => e.TenantId) - .OnDelete(DeleteBehavior.Restrict); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/TenantAuditableEntityConfigurationExtensions.cs b/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/TenantAuditableEntityConfigurationExtensions.cs deleted file mode 100644 index e2027d11..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/TenantAuditableEntityConfigurationExtensions.cs +++ /dev/null @@ -1,75 +0,0 @@ -using APITemplate.Domain.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace APITemplate.Infrastructure.Persistence.Configurations; - -/// -/// Extension methods that apply the standard tenant, audit, soft-delete, and optimistic-concurrency -/// column configuration to any entity implementing . -/// -internal 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/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/TenantConfiguration.cs b/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/TenantConfiguration.cs deleted file mode 100644 index 74b0e875..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/TenantConfiguration.cs +++ /dev/null @@ -1,24 +0,0 @@ -using APITemplate.Domain.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace APITemplate.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/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/TenantInvitationConfiguration.cs b/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/TenantInvitationConfiguration.cs deleted file mode 100644 index d4945de2..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/Configurations/TenantInvitationConfiguration.cs +++ /dev/null @@ -1,43 +0,0 @@ -using APITemplate.Domain.Entities; -using APITemplate.Domain.Enums; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace APITemplate.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/absolute/src/APITemplate.Infrastructure/Persistence/DesignTimeConfigurationHelper.cs b/absolute/src/APITemplate.Infrastructure/Persistence/DesignTimeConfigurationHelper.cs deleted file mode 100644 index 1f41b59b..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/DesignTimeConfigurationHelper.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Microsoft.Extensions.Configuration; - -namespace APITemplate.Infrastructure.Persistence; - -internal static class DesignTimeConfigurationHelper -{ - private const string DefaultConnectionName = "DefaultConnection"; - private const string FallbackConnectionString = - "Host=localhost;Port=5432;Database=apitemplate;Username=postgres;Password=postgres"; - - public static IConfigurationRoot BuildConfiguration() => - new ConfigurationBuilder() - .AddJsonFile("appsettings.json", optional: true) - .AddJsonFile("appsettings.Development.json", optional: true) - .AddEnvironmentVariables() - .Build(); - - public static string GetConnectionString(IConfiguration configuration) => - configuration.GetConnectionString(DefaultConnectionName) ?? FallbackConnectionString; -} diff --git a/absolute/src/APITemplate.Infrastructure/Persistence/DesignTimeDefaults.cs b/absolute/src/APITemplate.Infrastructure/Persistence/DesignTimeDefaults.cs deleted file mode 100644 index c2e02171..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/DesignTimeDefaults.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace APITemplate.Infrastructure.Persistence; - -internal static class DesignTimeDefaults -{ - public const string FallbackConnectionString = - "Host=localhost;Port=5432;Database=apitemplate;Username=postgres;Password=postgres"; -} diff --git a/absolute/src/APITemplate.Infrastructure/Persistence/EntityNormalization/AppUserEntityNormalizationService.cs b/absolute/src/APITemplate.Infrastructure/Persistence/EntityNormalization/AppUserEntityNormalizationService.cs deleted file mode 100644 index a84548eb..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/EntityNormalization/AppUserEntityNormalizationService.cs +++ /dev/null @@ -1,23 +0,0 @@ -using APITemplate.Domain.Entities; - -namespace APITemplate.Infrastructure.Persistence.EntityNormalization; - -/// -/// Normalizes fields (username and email) to their canonical -/// casing before the entity is persisted, enabling case-insensitive uniqueness checks. -/// -public sealed class AppUserEntityNormalizationService : IEntityNormalizationService -{ - /// - /// Applies normalization to when it is an ; - /// no-ops for all other entity types. - /// - public void Normalize(IAuditableTenantEntity entity) - { - if (entity is not AppUser user) - return; - - user.NormalizedUsername = AppUser.NormalizeUsername(user.Username); - user.NormalizedEmail = AppUser.NormalizeEmail(user.Email); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Persistence/EntityNormalization/IEntityNormalizationService.cs b/absolute/src/APITemplate.Infrastructure/Persistence/EntityNormalization/IEntityNormalizationService.cs deleted file mode 100644 index dc8054c8..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/EntityNormalization/IEntityNormalizationService.cs +++ /dev/null @@ -1,13 +0,0 @@ -using APITemplate.Domain.Entities; - -namespace APITemplate.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/absolute/src/APITemplate.Infrastructure/Persistence/MongoDbContext.cs b/absolute/src/APITemplate.Infrastructure/Persistence/MongoDbContext.cs deleted file mode 100644 index 05443d6f..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/MongoDbContext.cs +++ /dev/null @@ -1,38 +0,0 @@ -using APITemplate.Domain.Entities; -using Microsoft.Extensions.Options; -using MongoDB.Bson; -using MongoDB.Driver; -using MongoDB.Driver.Core.Extensions.DiagnosticSources; - -namespace APITemplate.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) - { - var clientSettings = MongoClientSettings.FromConnectionString( - settings.Value.ConnectionString - ); - clientSettings.ServerSelectionTimeout = TimeSpan.FromSeconds(5); - clientSettings.ClusterConfigurator = cb => - cb.Subscribe(new DiagnosticsActivityEventSubscriber()); - var client = new MongoClient(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/absolute/src/APITemplate.Infrastructure/Persistence/MongoDbSettings.cs b/absolute/src/APITemplate.Infrastructure/Persistence/MongoDbSettings.cs deleted file mode 100644 index 47e627dc..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/MongoDbSettings.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace APITemplate.Infrastructure.Persistence; - -/// -/// Strongly-typed settings for the MongoDB connection, bound from the application configuration. -/// -public sealed class MongoDbSettings -{ - public string ConnectionString { get; init; } = string.Empty; - - public string DatabaseName { get; init; } = string.Empty; -} diff --git a/absolute/src/APITemplate.Infrastructure/Persistence/SoftDelete/ISoftDeleteCascadeRule.cs b/absolute/src/APITemplate.Infrastructure/Persistence/SoftDelete/ISoftDeleteCascadeRule.cs deleted file mode 100644 index d3ae5b11..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/SoftDelete/ISoftDeleteCascadeRule.cs +++ /dev/null @@ -1,28 +0,0 @@ -using APITemplate.Domain.Entities; - -namespace APITemplate.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( - AppDbContext dbContext, - IAuditableTenantEntity entity, - CancellationToken cancellationToken = default); -} diff --git a/absolute/src/APITemplate.Infrastructure/Persistence/SoftDelete/ISoftDeleteProcessor.cs b/absolute/src/APITemplate.Infrastructure/Persistence/SoftDelete/ISoftDeleteProcessor.cs deleted file mode 100644 index 61e1b9cf..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/SoftDelete/ISoftDeleteProcessor.cs +++ /dev/null @@ -1,25 +0,0 @@ -using APITemplate.Domain.Entities; -using Microsoft.EntityFrameworkCore.ChangeTracking; - -namespace APITemplate.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( - AppDbContext dbContext, - EntityEntry entry, - IAuditableTenantEntity entity, - DateTime now, - Guid actor, - IReadOnlyCollection softDeleteCascadeRules, - CancellationToken cancellationToken - ); -} diff --git a/absolute/src/APITemplate.Infrastructure/Persistence/SoftDelete/ProductSoftDeleteCascadeRule.cs b/absolute/src/APITemplate.Infrastructure/Persistence/SoftDelete/ProductSoftDeleteCascadeRule.cs deleted file mode 100644 index f0ecf327..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/SoftDelete/ProductSoftDeleteCascadeRule.cs +++ /dev/null @@ -1,56 +0,0 @@ -using APITemplate.Domain.Entities; -using Microsoft.EntityFrameworkCore; - -namespace APITemplate.Infrastructure.Persistence.SoftDelete; - -/// -/// Explicit soft-delete cascade rule for Product aggregate. -/// When a is soft-deleted, all active reviews belonging -/// to the same tenant are soft-deleted as well. -/// -public sealed class ProductSoftDeleteCascadeRule : ISoftDeleteCascadeRule -{ - /// - /// Handles only entities. - /// - public bool CanHandle(IAuditableTenantEntity entity) => entity is Product; - - /// - /// Returns active product reviews that belong to the same product and tenant. - /// Query filters are intentionally ignored because dependent rows may already - /// be filtered from normal query paths during delete operations. - /// - public async Task> GetDependentsAsync( - AppDbContext dbContext, - IAuditableTenantEntity entity, - CancellationToken cancellationToken = default - ) - { - if (entity is not Product product) - return []; - - var dependents = new List(); - - dependents.AddRange( - await dbContext - .ProductReviews.IgnoreQueryFilters(["SoftDelete", "Tenant"]) - .Where(r => - r.ProductId == product.Id && r.TenantId == product.TenantId && !r.IsDeleted - ) - .Cast() - .ToListAsync(cancellationToken) - ); - - dependents.AddRange( - await dbContext - .ProductDataLinks.IgnoreQueryFilters(["SoftDelete", "Tenant"]) - .Where(d => - d.ProductId == product.Id && d.TenantId == product.TenantId && !d.IsDeleted - ) - .Cast() - .ToListAsync(cancellationToken) - ); - - return dependents; - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Persistence/SoftDelete/SoftDeleteProcessor.cs b/absolute/src/APITemplate.Infrastructure/Persistence/SoftDelete/SoftDeleteProcessor.cs deleted file mode 100644 index 63b818a5..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/SoftDelete/SoftDeleteProcessor.cs +++ /dev/null @@ -1,82 +0,0 @@ -using APITemplate.Domain.Entities; -using APITemplate.Infrastructure.Persistence.Auditing; -using Microsoft.EntityFrameworkCore.ChangeTracking; - -namespace APITemplate.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( - AppDbContext dbContext, - EntityEntry entry, - IAuditableTenantEntity entity, - DateTime now, - Guid actor, - IReadOnlyCollection softDeleteCascadeRules, - CancellationToken cancellationToken - ) - { - var visited = new HashSet(ReferenceEqualityComparer.Instance); - return SoftDeleteWithRulesAsync( - dbContext, - entry, - entity, - now, - actor, - softDeleteCascadeRules, - visited, - cancellationToken - ); - } - - private async Task SoftDeleteWithRulesAsync( - AppDbContext 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 (var rule in softDeleteCascadeRules.Where(r => r.CanHandle(entity))) - { - var dependents = await rule.GetDependentsAsync(dbContext, entity, cancellationToken); - foreach (var dependent in dependents) - { - if (dependent.IsDeleted || dependent.TenantId != entity.TenantId) - continue; - - var dependentEntry = dbContext.Entry(dependent); - await SoftDeleteWithRulesAsync( - dbContext, - dependentEntry, - dependent, - now, - actor, - softDeleteCascadeRules, - visited, - cancellationToken - ); - } - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Persistence/SoftDelete/TenantSoftDeleteCascadeRule.cs b/absolute/src/APITemplate.Infrastructure/Persistence/SoftDelete/TenantSoftDeleteCascadeRule.cs deleted file mode 100644 index b5124aa8..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/SoftDelete/TenantSoftDeleteCascadeRule.cs +++ /dev/null @@ -1,57 +0,0 @@ -using APITemplate.Domain.Entities; -using Microsoft.EntityFrameworkCore; - -namespace APITemplate.Infrastructure.Persistence.SoftDelete; - -/// -/// Explicit soft-delete cascade rule for the Tenant aggregate. -/// When a is soft-deleted, all active users, products, and categories -/// belonging to that tenant are soft-deleted as well. -/// -public sealed class TenantSoftDeleteCascadeRule : ISoftDeleteCascadeRule -{ - /// Handles only entities. - public bool CanHandle(IAuditableTenantEntity entity) => entity is Tenant; - - /// - /// Returns active users, products, and categories that belong to the tenant, - /// bypassing global query filters to ensure already-filtered rows are still found. - /// - public async Task> GetDependentsAsync( - AppDbContext dbContext, - IAuditableTenantEntity entity, - CancellationToken cancellationToken = default - ) - { - if (entity is not Tenant tenant) - return []; - - var dependents = new List(); - - dependents.AddRange( - await dbContext - .Users.IgnoreQueryFilters(["SoftDelete", "Tenant"]) - .Where(u => u.TenantId == tenant.Id && !u.IsDeleted) - .Cast() - .ToListAsync(cancellationToken) - ); - - dependents.AddRange( - await dbContext - .Products.IgnoreQueryFilters(["SoftDelete", "Tenant"]) - .Where(p => p.TenantId == tenant.Id && !p.IsDeleted) - .Cast() - .ToListAsync(cancellationToken) - ); - - dependents.AddRange( - await dbContext - .Categories.IgnoreQueryFilters(["SoftDelete", "Tenant"]) - .Where(c => c.TenantId == tenant.Id && !c.IsDeleted) - .Cast() - .ToListAsync(cancellationToken) - ); - - return dependents; - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Persistence/Startup/PostgresAdvisoryLockStartupTaskCoordinator.cs b/absolute/src/APITemplate.Infrastructure/Persistence/Startup/PostgresAdvisoryLockStartupTaskCoordinator.cs deleted file mode 100644 index f783c33d..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/Startup/PostgresAdvisoryLockStartupTaskCoordinator.cs +++ /dev/null @@ -1,119 +0,0 @@ -using APITemplate.Application.Common.Startup; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Npgsql; - -namespace APITemplate.Infrastructure.Persistence.Startup; - -/// -/// Serializes selected startup tasks across multiple application instances by taking a -/// PostgreSQL advisory lock for the duration of the task. -/// -/// -/// This coordinator exists because startup bootstrap work is not safe to run concurrently -/// when several nodes start against the same database at the same time. Without coordination, -/// parallel execution of relational migrations, auth/bootstrap seeding, or background-job -/// scheduler bootstrap can race and fail with duplicate work, conflicting DDL, or partially -/// initialized shared state. -/// -/// PostgreSQL advisory locks provide a process-independent mutex keyed by an application-defined -/// number. Each startup task name is mapped to a stable lock key so only one instance executes -/// that task at a time, while other instances wait. The lock is held on a dedicated Npgsql -/// connection because advisory locks are scoped to the database session that acquired them. -/// -/// When the active instance finishes, or its connection is dropped, PostgreSQL releases the -/// lock and another instance may continue. For non-PostgreSQL providers this type returns a -/// no-op async disposable lease, because advisory locks are a PostgreSQL-specific -/// coordination mechanism. -/// -public sealed class PostgresAdvisoryLockStartupTaskCoordinator : IStartupTaskCoordinator -{ - private readonly AppDbContext _dbContext; - private readonly ILogger _logger; - - public PostgresAdvisoryLockStartupTaskCoordinator( - AppDbContext dbContext, - ILogger logger - ) - { - _dbContext = dbContext; - _logger = logger; - } - - /// - /// Acquires a startup-task lease backed by a PostgreSQL advisory lock when the current - /// relational provider is Npgsql. - /// - /// - /// Logical startup task identifier. Enum values are stable and double as advisory lock keys. - /// - /// Cancellation token used while waiting for the advisory lock. - public async Task AcquireAsync( - StartupTaskName startupTask, - CancellationToken ct = default - ) - { - if (!_dbContext.Database.IsNpgsql()) - { - return NoOpAsyncDisposable.Instance; - } - - var connectionString = - _dbContext.Database.GetConnectionString() - ?? throw new InvalidOperationException( - "PostgreSQL startup coordination requires a relational connection string." - ); - - var advisoryConnection = new NpgsqlConnection(connectionString); - - try - { - await advisoryConnection.OpenAsync(ct); - - await using var lockCommand = advisoryConnection.CreateCommand(); - lockCommand.CommandText = "SELECT pg_advisory_lock(@lockKey)"; - lockCommand.Parameters.AddWithValue("lockKey", (long)startupTask); - - _logger.LogDebug("Waiting for startup coordination lock {StartupTask}.", startupTask); - await lockCommand.ExecuteNonQueryAsync(ct); - - return new PostgresAdvisoryLockLease(advisoryConnection, startupTask); - } - catch - { - await advisoryConnection.DisposeAsync(); - throw; - } - } - - private sealed class PostgresAdvisoryLockLease( - NpgsqlConnection advisoryConnection, - StartupTaskName startupTask - ) : IAsyncDisposable - { - private bool _disposed; - - public async ValueTask DisposeAsync() - { - if (_disposed) - { - return; - } - - _disposed = true; - - await using var unlockCommand = advisoryConnection.CreateCommand(); - unlockCommand.CommandText = "SELECT pg_advisory_unlock(@lockKey)"; - unlockCommand.Parameters.AddWithValue("lockKey", (long)startupTask); - await unlockCommand.ExecuteNonQueryAsync(CancellationToken.None); - await advisoryConnection.DisposeAsync(); - } - } - - private sealed class NoOpAsyncDisposable : IAsyncDisposable - { - public static NoOpAsyncDisposable Instance { get; } = new(); - - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/DbContextCommandTimeoutScope.cs b/absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/DbContextCommandTimeoutScope.cs deleted file mode 100644 index 95d22b4f..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/DbContextCommandTimeoutScope.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace APITemplate.Infrastructure.Persistence; - -/// -/// Temporarily overrides the EF Core command timeout for the duration of a scope, -/// restoring the previous timeout value when disposed. -/// -internal sealed class DbContextCommandTimeoutScope(AppDbContext 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) - { - var 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() - { - var scope = Interlocked.Exchange(ref _scope, null); - if (scope is null) - return; - - scope.SetCommandTimeoutIfSupported(previousTimeout); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/DbContextTrackedStateManager.cs b/absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/DbContextTrackedStateManager.cs deleted file mode 100644 index 6be7e247..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/DbContextTrackedStateManager.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.ChangeTracking; - -namespace APITemplate.Infrastructure.Persistence; - -/// -/// 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(AppDbContext 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 (var entry in dbContext.ChangeTracker.Entries().ToList()) - { - if (!snapshot.TryGetValue(entry.Entity, out var 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/absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/EfCoreTransactionProvider.cs b/absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/EfCoreTransactionProvider.cs deleted file mode 100644 index aae2308f..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/EfCoreTransactionProvider.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Data; -using APITemplate.Domain.Options; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage; - -namespace APITemplate.Infrastructure.Persistence; - -/// -/// 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(AppDbContext 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/absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/IDbTransactionProvider.cs b/absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/IDbTransactionProvider.cs deleted file mode 100644 index d102497f..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/IDbTransactionProvider.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Data; -using APITemplate.Domain.Options; -using Microsoft.EntityFrameworkCore.Storage; - -namespace APITemplate.Infrastructure.Persistence; - -/// -/// 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/absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/ManagedTransactionScope.cs b/absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/ManagedTransactionScope.cs deleted file mode 100644 index 87a4bf22..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/ManagedTransactionScope.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace APITemplate.Infrastructure.Persistence; - -/// -/// 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() - { - var scope = Interlocked.Exchange(ref _scope, null); - scope?.Exit(); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWork.cs b/absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWork.cs deleted file mode 100644 index 5d92fa43..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWork.cs +++ /dev/null @@ -1,313 +0,0 @@ -using System.Data; -using APITemplate.Application.Common.Options; -using APITemplate.Domain.Interfaces; -using APITemplate.Domain.Options; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace APITemplate.Infrastructure.Persistence; - -/// -/// 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 AppDbContext _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( - AppDbContext 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); - } - - var effectiveOptions = _transactionDefaults.Resolve(null); - _logger.CommitStarted(effectiveOptions.RetryEnabled ?? true, effectiveOptions.TimeoutSeconds); - var 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. - /// - /// - /// Do not call inside . The outermost transaction saves and commits - /// after the delegate completes successfully, and nested calls use savepoints only. - /// - /// 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) - { - var currentTransaction = _transactionProvider.CurrentTransaction; - if (currentTransaction is not null) - return await ExecuteWithinSavepointAsync(currentTransaction, action, options, ct); - - var effectiveOptions = _transactionDefaults.Resolve(options); - return await ExecuteAsOutermostTransactionAsync(action, effectiveOptions, ct); - } - - /// - /// Executes a nested transaction scope by using a savepoint inside the active outer transaction. - /// Only null/default nested options are allowed unless they resolve to the same effective outer policy. - /// - /// Type returned by the nested delegate. - /// Currently active outer database transaction. - /// Nested delegate executed under a savepoint inside . - /// - /// Optional nested overrides. Conflicting values are rejected because nested scopes cannot redefine - /// the already active outer transaction policy. - /// - /// Cancellation token propagated to savepoint operations. - /// The value returned by when the nested scope succeeds. - private async Task ExecuteWithinSavepointAsync( - IDbContextTransaction transaction, - Func> action, - TransactionOptions? options, - CancellationToken ct) - { - ValidateNestedTransactionOptions(options); - var savepointName = $"uow_sp_{Interlocked.Increment(ref _savepointCounter)}"; - var snapshot = _trackedStateManager.Capture(); - - // Nested work reuses the active transaction and isolates rollback via a savepoint. - _logger.SavepointCreating(savepointName); - await transaction.CreateSavepointAsync(savepointName, ct); - try - { - using var scope = _managedTransactionScope.Enter(); - var 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. - /// - /// Type returned by the transactional delegate. - /// Delegate that stages all relational changes for the outer transaction. - /// Resolved transaction policy after config defaults and per-call overrides are merged. - /// Cancellation token propagated to strategy, transaction, and save operations. - /// The value returned by after the transaction commits successfully. - private async Task ExecuteAsOutermostTransactionAsync( - Func> action, - TransactionOptions effectiveOptions, - CancellationToken ct) - { - var strategy = _transactionProvider.CreateExecutionStrategy(effectiveOptions); - var previousActiveOptions = _activeTransactionOptions; - - return await strategy.ExecuteAsync( - state: action, - operation: async (_, transactionalAction, cancellationToken) => - { - _activeTransactionOptions = effectiveOptions; - using var 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)) - { - // Providers without transaction support still use the same unit-of-work flow, - // but save without an explicit database transaction. - _logger.DatabaseTransactionUnsupported(ex); - } - - var snapshot = _trackedStateManager.Capture(); - - try - { - using var scope = _managedTransactionScope.Enter(); - var 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. - /// - /// Optional nested transaction overrides. - 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; - - var 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. - /// Some providers treat savepoint release as optional, so unsupported cases are ignored. - /// - /// Active database transaction that owns the savepoint. - /// Provider-specific name of the savepoint to release. - /// Cancellation token propagated to the provider. - 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/absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWorkExecutionStrategyFactory.cs b/absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWorkExecutionStrategyFactory.cs deleted file mode 100644 index 1ea0af13..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWorkExecutionStrategyFactory.cs +++ /dev/null @@ -1,37 +0,0 @@ -using APITemplate.Domain.Options; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage; -using Npgsql.EntityFrameworkCore.PostgreSQL; - -namespace APITemplate.Infrastructure.Persistence; - -/// -/// 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/absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWorkLogs.cs b/absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWorkLogs.cs deleted file mode 100644 index ad514f88..00000000 --- a/absolute/src/APITemplate.Infrastructure/Persistence/UnitOfWork/UnitOfWorkLogs.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System.Data; -using Microsoft.Extensions.Logging; - -namespace APITemplate.Infrastructure.Persistence; - -/// -/// 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/absolute/src/APITemplate.Infrastructure/Repositories/CategoryRepository.cs b/absolute/src/APITemplate.Infrastructure/Repositories/CategoryRepository.cs deleted file mode 100644 index 103b12b2..00000000 --- a/absolute/src/APITemplate.Infrastructure/Repositories/CategoryRepository.cs +++ /dev/null @@ -1,44 +0,0 @@ -using APITemplate.Application.Common.Context; -using APITemplate.Domain.Entities; -using APITemplate.Domain.Interfaces; -using APITemplate.Infrastructure.Persistence; -using APITemplate.Infrastructure.StoredProcedures; - -namespace APITemplate.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( - AppDbContext dbContext, - IStoredProcedureExecutor spExecutor, - ITenantProvider tenantProvider - ) - : base(dbContext) - { - _spExecutor = spExecutor; - _tenantProvider = tenantProvider; - } - - /// - /// Retrieves aggregate product statistics for the given category via a stored procedure, - /// passing the current tenant ID explicitly to enforce data isolation at the DB level. - /// - public Task GetStatsByIdAsync( - Guid categoryId, - CancellationToken ct = default - ) - { - // Stored procedures bypass EF global query filters, so tenant must be passed explicitly for DB-side isolation. - return _spExecutor.QueryFirstAsync( - new GetProductCategoryStatsProcedure(categoryId, _tenantProvider.TenantId), - ct - ); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Repositories/FailedEmailRepository.cs b/absolute/src/APITemplate.Infrastructure/Repositories/FailedEmailRepository.cs deleted file mode 100644 index fbc6a0f6..00000000 --- a/absolute/src/APITemplate.Infrastructure/Repositories/FailedEmailRepository.cs +++ /dev/null @@ -1,86 +0,0 @@ -using APITemplate.Domain.Entities; -using APITemplate.Domain.Interfaces; -using APITemplate.Infrastructure.Persistence; -using APITemplate.Infrastructure.StoredProcedures; -using Microsoft.EntityFrameworkCore; - -namespace APITemplate.Infrastructure.Repositories; - -/// -/// EF Core repository for that coordinates direct context access -/// with stored-procedure-based batch claiming for concurrent retry processing. -/// -public sealed class FailedEmailRepository : IFailedEmailRepository -{ - private readonly AppDbContext _dbContext; - private readonly IStoredProcedureExecutor _executor; - - public FailedEmailRepository(AppDbContext dbContext, IStoredProcedureExecutor executor) - { - _dbContext = dbContext; - _executor = executor; - } - - /// 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; - } - - /// Atomically claims a batch of retryable failed emails via the stored procedure. - public async Task> ClaimRetryableBatchAsync( - int maxRetryAttempts, - int batchSize, - string claimedBy, - DateTime claimedAtUtc, - DateTime claimedUntilUtc, - CancellationToken ct = default - ) - { - var procedure = new ClaimRetryableFailedEmailsProcedure( - maxRetryAttempts, - batchSize, - claimedBy, - claimedAtUtc, - claimedUntilUtc - ); - var result = await _executor.QueryManyAsync(procedure, ct); - return result.ToList(); - } - - /// Atomically claims a batch of expired (dead-letter candidate) failed emails via the stored procedure. - public async Task> ClaimExpiredBatchAsync( - DateTime cutoff, - int batchSize, - string claimedBy, - DateTime claimedAtUtc, - DateTime claimedUntilUtc, - CancellationToken ct = default - ) - { - var procedure = new ClaimExpiredFailedEmailsProcedure( - cutoff, - batchSize, - claimedBy, - claimedAtUtc, - claimedUntilUtc - ); - var result = await _executor.QueryManyAsync(procedure, ct); - return result.ToList(); - } - - /// 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; - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Repositories/JobExecutionRepository.cs b/absolute/src/APITemplate.Infrastructure/Repositories/JobExecutionRepository.cs deleted file mode 100644 index f4767890..00000000 --- a/absolute/src/APITemplate.Infrastructure/Repositories/JobExecutionRepository.cs +++ /dev/null @@ -1,13 +0,0 @@ -using APITemplate.Domain.Entities; -using APITemplate.Domain.Interfaces; -using APITemplate.Infrastructure.Persistence; -using Ardalis.Specification.EntityFrameworkCore; - -namespace APITemplate.Infrastructure.Repositories; - -/// EF Core repository for , inheriting all standard CRUD and specification query support from . -public sealed class JobExecutionRepository : RepositoryBase, IJobExecutionRepository -{ - public JobExecutionRepository(AppDbContext dbContext) - : base(dbContext) { } -} diff --git a/absolute/src/APITemplate.Infrastructure/Repositories/Pagination/PagedProjectionBuilder.cs b/absolute/src/APITemplate.Infrastructure/Repositories/Pagination/PagedProjectionBuilder.cs deleted file mode 100644 index ee0efb2b..00000000 --- a/absolute/src/APITemplate.Infrastructure/Repositories/Pagination/PagedProjectionBuilder.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Linq.Expressions; - -namespace APITemplate.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). - // EF Core translates this into a scalar SQL sub-query: (SELECT COUNT(*) FROM ... WHERE ...). - // countSource.Expression carries the full filtered IQueryable (filters applied, no Skip/Take), - // so the COUNT covers all matching rows regardless of paging. - var countCall = Expression.Call( - typeof(Queryable), // static class containing the method - nameof(Queryable.Count), // method name to call - [typeof(T)], // generic type argument: Count - countSource.Expression // the IQueryable expression tree as the argument - ); - - // Get the PagedRow(TResult item, int totalCount) constructor via reflection - // so we can build a "new PagedRow(...)" expression node. - var 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) - // - selector.Body is the original projection (e.g. new ProductResponse(product.Name, ...)) - // - countCall is the scalar COUNT sub-query expression built above - // EF Core sees this as a single SELECT with an inline sub-query for the count column. - var newExpr = Expression.New(ctor, selector.Body, countCall); - - // Reuse the lambda parameter from the original selector (e.g. the "product" in product => new ProductResponse(...)). - // This ensures the new combined expression operates on the same entity parameter that EF Core already understands. - var entityParam = selector.Parameters[0]; - - // Wrap everything into a lambda: entity => new PagedRow(projection(entity), COUNT(*)) - // This is the final expression that replaces the original .Select() projection, - // producing rows that carry both the projected DTO and the total count. - return Expression.Lambda>>(newExpr, entityParam); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Repositories/Pagination/PagedRow.cs b/absolute/src/APITemplate.Infrastructure/Repositories/Pagination/PagedRow.cs deleted file mode 100644 index 6e07348b..00000000 --- a/absolute/src/APITemplate.Infrastructure/Repositories/Pagination/PagedRow.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Infrastructure/Repositories/ProductDataLinkRepository.cs b/absolute/src/APITemplate.Infrastructure/Repositories/ProductDataLinkRepository.cs deleted file mode 100644 index ae772352..00000000 --- a/absolute/src/APITemplate.Infrastructure/Repositories/ProductDataLinkRepository.cs +++ /dev/null @@ -1,96 +0,0 @@ -using APITemplate.Application.Common.Context; -using APITemplate.Domain.Entities; -using APITemplate.Domain.Interfaces; -using APITemplate.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; - -namespace APITemplate.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 AppDbContext _dbContext; - private readonly ITenantProvider _tenantProvider; - - public ProductDataLinkRepository(AppDbContext dbContext, ITenantProvider tenantProvider) - { - _dbContext = dbContext; - _tenantProvider = tenantProvider; - } - - /// - /// Returns links for the given product, optionally including soft-deleted entries by bypassing global filters. - /// - public async Task> ListByProductIdAsync( - Guid productId, - bool includeDeleted = false, - CancellationToken ct = default - ) - { - var 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>(); - - var query = includeDeleted - ? _dbContext - .ProductDataLinks.IgnoreQueryFilters() - .Where(link => - link.TenantId == _tenantProvider.TenantId && productIds.Contains(link.ProductId) - ) - : _dbContext.ProductDataLinks.Where(link => productIds.Contains(link.ProductId)); - - var links = await query.ToListAsync(ct); - return links - .GroupBy(link => link.ProductId) - .ToDictionary( - group => group.Key, - group => (IReadOnlyList)group.ToList() - ); - } - - /// Returns true when at least one active link references the specified product data document. - public Task HasActiveLinksForProductDataAsync( - Guid productDataId, - CancellationToken ct = default - ) => _dbContext.ProductDataLinks.AnyAsync(link => link.ProductDataId == productDataId, ct); - - /// - /// Stages removal of all active links for the given product data document so they - /// are soft-deleted when the unit of work commits. - /// - public async Task SoftDeleteActiveLinksForProductDataAsync( - Guid productDataId, - CancellationToken ct = default - ) - { - var links = await _dbContext - .ProductDataLinks.Where(link => link.ProductDataId == productDataId) - .ToListAsync(ct); - - if (links.Count == 0) - return; - - _dbContext.ProductDataLinks.RemoveRange(links); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Repositories/ProductDataRepository.cs b/absolute/src/APITemplate.Infrastructure/Repositories/ProductDataRepository.cs deleted file mode 100644 index ef6641bb..00000000 --- a/absolute/src/APITemplate.Infrastructure/Repositories/ProductDataRepository.cs +++ /dev/null @@ -1,126 +0,0 @@ -using APITemplate.Application.Common.Context; -using APITemplate.Domain.Entities; -using APITemplate.Domain.Interfaces; -using APITemplate.Infrastructure.Persistence; -using MongoDB.Driver; - -namespace APITemplate.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; - } - - /// Returns a single non-deleted document matching the given ID within the current tenant, or null if not found. - public async Task GetByIdAsync(Guid id, CancellationToken ct = default) => - await _collection - .Find(x => x.Id == id && x.TenantId == _tenantProvider.TenantId && !x.IsDeleted) - .FirstOrDefaultAsync(ct); - - /// Returns non-deleted documents for the given IDs within the current tenant; deduplicates the ID list before querying. - public async Task> GetByIdsAsync( - IEnumerable ids, - CancellationToken ct = default - ) - { - var 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); - } - - /// Returns all non-deleted documents for the current tenant, optionally filtered by the MongoDB discriminator type. - public async Task> GetAllAsync( - string? type = null, - CancellationToken ct = default - ) - { - var 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); - } - - /// Inserts a new document into the collection and returns the inserted document. - public async Task CreateAsync( - ProductData productData, - CancellationToken ct = default - ) - { - await _collection.InsertOneAsync(productData, cancellationToken: ct); - return productData; - } - - /// Soft-deletes a single document by setting IsDeleted, DeletedAtUtc, and DeletedBy. - public async Task SoftDeleteAsync( - Guid id, - Guid actorId, - DateTime deletedAtUtc, - CancellationToken ct = default - ) - { - var 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 - ); - } - - /// - /// Soft-deletes all non-deleted documents belonging to the specified tenant in a single - /// UpdateMany operation and returns the count of modified documents. - /// - public async Task SoftDeleteByTenantAsync( - Guid tenantId, - Guid actorId, - DateTime deletedAtUtc, - CancellationToken ct = default - ) - { - var filter = Builders.Filter.And( - Builders.Filter.Eq(x => x.TenantId, tenantId), - Builders.Filter.Eq(x => x.IsDeleted, false) - ); - - var update = Builders - .Update.Set(x => x.IsDeleted, true) - .Set(x => x.DeletedAtUtc, deletedAtUtc) - .Set(x => x.DeletedBy, actorId); - - var result = await _collection.UpdateManyAsync(filter, update, cancellationToken: ct); - return result.ModifiedCount; - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Repositories/ProductRepository.cs b/absolute/src/APITemplate.Infrastructure/Repositories/ProductRepository.cs deleted file mode 100644 index 8d4885bd..00000000 --- a/absolute/src/APITemplate.Infrastructure/Repositories/ProductRepository.cs +++ /dev/null @@ -1,123 +0,0 @@ -using APITemplate.Domain.Entities; -using APITemplate.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; - -namespace APITemplate.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("0 - 50", 0m, 50m, 0), - new("50 - 100", 50m, 100m, 0), - new("100 - 250", 100m, 250m, 0), - new("250 - 500", 250m, 500m, 0), - new("500+", 500m, null, 0), - ]; - - public ProductRepository(AppDbContext 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 - ) - { - var specification = new ProductCategoryFacetSpecification(filter); - var query = - Ardalis.Specification.EntityFrameworkCore.SpecificationEvaluator.Default.GetQuery( - AppDb.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 - ) - { - var specification = new ProductPriceFacetSpecification(filter); - var query = - Ardalis.Specification.EntityFrameworkCore.SpecificationEvaluator.Default.GetQuery( - AppDb.Products.AsQueryable(), - specification - ); - - var 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 - { - "0 - 50" => counts?.ZeroToFifty ?? 0, - "50 - 100" => counts?.FiftyToOneHundred ?? 0, - "100 - 250" => counts?.OneHundredToTwoHundredFifty ?? 0, - "250 - 500" => counts?.TwoHundredFiftyToFiveHundred ?? 0, - "500+" => counts?.FiveHundredAndAbove ?? 0, - _ => 0, - }, - } - ) - .ToArray(); - } - - private sealed record PriceFacetCounts( - int ZeroToFifty, - int FiftyToOneHundred, - int OneHundredToTwoHundredFifty, - int TwoHundredFiftyToFiveHundred, - int FiveHundredAndAbove - ); -} diff --git a/absolute/src/APITemplate.Infrastructure/Repositories/ProductReviewRepository.cs b/absolute/src/APITemplate.Infrastructure/Repositories/ProductReviewRepository.cs deleted file mode 100644 index 69d397ba..00000000 --- a/absolute/src/APITemplate.Infrastructure/Repositories/ProductReviewRepository.cs +++ /dev/null @@ -1,14 +0,0 @@ -using APITemplate.Domain.Entities; -using APITemplate.Domain.Interfaces; -using APITemplate.Infrastructure.Persistence; - -namespace APITemplate.Infrastructure.Repositories; - -/// EF Core repository for , inheriting all standard CRUD and specification query support from . -public sealed class ProductReviewRepository - : RepositoryBase, - IProductReviewRepository -{ - public ProductReviewRepository(AppDbContext dbContext) - : base(dbContext) { } -} diff --git a/absolute/src/APITemplate.Infrastructure/Repositories/RepositoryBase.cs b/absolute/src/APITemplate.Infrastructure/Repositories/RepositoryBase.cs deleted file mode 100644 index 3d837e4e..00000000 --- a/absolute/src/APITemplate.Infrastructure/Repositories/RepositoryBase.cs +++ /dev/null @@ -1,157 +0,0 @@ -using APITemplate.Domain.Exceptions; -using APITemplate.Infrastructure.Persistence; -using APITemplate.Infrastructure.Repositories.Pagination; -using Ardalis.Specification; -using Microsoft.EntityFrameworkCore; - -namespace APITemplate.Infrastructure.Repositories; - -/// -/// Base repository that wraps the Ardalis Specification EF Core repository, overriding write methods -/// to stage changes without flushing — persistence is deferred to . -/// -// Generic base repository — T is constrained to class (reference type) so EF Core can track it. -// abstract = cannot be instantiated directly, must be inherited (e.g. ProductRepository : RepositoryBase). -// SaveChangesAsync is intentionally NOT called here — use IUnitOfWork.CommitAsync() in the service layer. -public abstract class RepositoryBase - : Ardalis.Specification.EntityFrameworkCore.RepositoryBase, - IRepository - where T : class -{ - // Cast to AppDbContext — Ardalis exposes DbContext as the base DbContext type - protected AppDbContext AppDb => (AppDbContext)DbContext; - - protected RepositoryBase(AppDbContext 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 (e.g. TenantRepository) can customise the source queryable. - var baseQuery = ApplySpecification((ISpecification)spec); - var 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." - ); - - var combinedSelector = spec.Selector.BuildPaged(countSource); - - // Apply skip/take + combined select → single SQL query - var skip = (pageNumber - 1) * pageSize; - var 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) - { - var totalCount = await baseQuery.CountAsync(ct); - if (totalCount > 0) - { - var 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) - { - var entity = - await GetByIdAsync(id, ct) - ?? throw new NotFoundException( - typeof(T).Name, - id, - errorCode ?? ErrorCatalog.General.NotFound - ); - DbContext.Set().Remove(entity); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Repositories/StoredFileRepository.cs b/absolute/src/APITemplate.Infrastructure/Repositories/StoredFileRepository.cs deleted file mode 100644 index a9ad1aa4..00000000 --- a/absolute/src/APITemplate.Infrastructure/Repositories/StoredFileRepository.cs +++ /dev/null @@ -1,13 +0,0 @@ -using APITemplate.Domain.Entities; -using APITemplate.Domain.Interfaces; -using APITemplate.Infrastructure.Persistence; -using Ardalis.Specification.EntityFrameworkCore; - -namespace APITemplate.Infrastructure.Repositories; - -/// EF Core repository for , inheriting all standard CRUD and specification query support from . -public sealed class StoredFileRepository : RepositoryBase, IStoredFileRepository -{ - public StoredFileRepository(AppDbContext dbContext) - : base(dbContext) { } -} diff --git a/absolute/src/APITemplate.Infrastructure/Repositories/TenantInvitationRepository.cs b/absolute/src/APITemplate.Infrastructure/Repositories/TenantInvitationRepository.cs deleted file mode 100644 index 3a0d77a1..00000000 --- a/absolute/src/APITemplate.Infrastructure/Repositories/TenantInvitationRepository.cs +++ /dev/null @@ -1,38 +0,0 @@ -using APITemplate.Domain.Entities; -using APITemplate.Domain.Enums; -using APITemplate.Domain.Interfaces; -using APITemplate.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; - -namespace APITemplate.Infrastructure.Repositories; - -/// -/// EF Core repository for with token hash and pending-invitation lookup methods. -/// -public sealed class TenantInvitationRepository - : RepositoryBase, - ITenantInvitationRepository -{ - public TenantInvitationRepository(AppDbContext dbContext) - : base(dbContext) { } - - /// Returns a pending invitation matching the given token hash, or null if none is found. - public Task GetValidByTokenHashAsync( - string tokenHash, - CancellationToken ct = default - ) => - AppDb.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 - ) => - AppDb.TenantInvitations.AnyAsync( - i => i.NormalizedEmail == normalizedEmail && i.Status == InvitationStatus.Pending, - ct - ); -} diff --git a/absolute/src/APITemplate.Infrastructure/Repositories/TenantRepository.cs b/absolute/src/APITemplate.Infrastructure/Repositories/TenantRepository.cs deleted file mode 100644 index c81ef922..00000000 --- a/absolute/src/APITemplate.Infrastructure/Repositories/TenantRepository.cs +++ /dev/null @@ -1,65 +0,0 @@ -using APITemplate.Domain.Entities; -using APITemplate.Domain.Interfaces; -using APITemplate.Infrastructure.Persistence; -using Ardalis.Specification; -using Microsoft.EntityFrameworkCore; - -namespace APITemplate.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 -{ - public TenantRepository(AppDbContext dbContext) - : base(dbContext) { } - - private IQueryable UnfilteredTenants => AppDb.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 AppDb - .Tenants.IgnoreQueryFilters(["Tenant", "SoftDelete"]) - .AnyAsync(t => t.Code == code, ct); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Repositories/UserRepository.cs b/absolute/src/APITemplate.Infrastructure/Repositories/UserRepository.cs deleted file mode 100644 index cb0c072c..00000000 --- a/absolute/src/APITemplate.Infrastructure/Repositories/UserRepository.cs +++ /dev/null @@ -1,24 +0,0 @@ -using APITemplate.Application.Features.User.Specifications; -using APITemplate.Domain.Entities; -using APITemplate.Domain.Interfaces; -using APITemplate.Infrastructure.Persistence; - -namespace APITemplate.Infrastructure.Repositories; - -/// EF Core repository for with specification-based lookup by email and username. -public sealed class UserRepository : RepositoryBase, IUserRepository -{ - public UserRepository(AppDbContext 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/absolute/src/APITemplate.Infrastructure/Security/DragonflyTicketStore.cs b/absolute/src/APITemplate.Infrastructure/Security/DragonflyTicketStore.cs deleted file mode 100644 index a52d4b6a..00000000 --- a/absolute/src/APITemplate.Infrastructure/Security/DragonflyTicketStore.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System.Security.Cryptography; -using APITemplate.Application.Common.Options; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.DataProtection; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Options; - -namespace APITemplate.Infrastructure.Security; - -/// -/// A ticket store implementation that persists ASP.NET Core authentication tickets -/// into an (typically DragonFly/Redis) under a unique key. -/// -/// -/// This allows the authentication cookie to contain only a small key, while the full -/// (claims + properties) is stored in a shared cache -/// and can be retrieved by any application instance. -/// -public sealed class DragonflyTicketStore : ITicketStore -{ - private const string KeyPrefix = "bff:ticket:"; - - private readonly IDistributedCache _cache; - private readonly BffOptions _options; - private readonly IDataProtector _protector; - - /// - /// Initializes a new instance of . - /// - /// A distributed cache instance used to persist ticket bytes. - /// Configuration options for session timeout etc. - /// Data protection provider for encrypting ticket bytes. - public DragonflyTicketStore( - IDistributedCache cache, - IOptions options, - IDataProtectionProvider dataProtection - ) - { - _cache = cache; - _options = options.Value; - _protector = dataProtection.CreateProtector("bff:ticket"); - } - - /// - /// Stores the given authentication ticket in the distributed cache and returns the key. - /// - /// The authentication ticket to store. - /// The key under which the ticket is stored. - public async Task StoreAsync(AuthenticationTicket ticket) - { - var key = KeyPrefix + Guid.NewGuid().ToString("N"); - await RenewAsync(key, ticket); - return key; - } - - /// - /// Updates the cached ticket under the specified key (usually to renew its expiration). - /// - /// The cache key previously returned by . - /// The ticket to store. - public async Task RenewAsync(string key, AuthenticationTicket ticket) - { - var bytes = _protector.Protect(TicketSerializer.Default.Serialize(ticket)); - var entryOptions = new DistributedCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_options.SessionTimeoutMinutes), - }; - await _cache.SetAsync(key, bytes, entryOptions); - } - - /// - /// Retrieves an authentication ticket by key from the distributed cache. - /// - /// The cache key previously returned by . - /// The ticket if found and successfully decrypted; otherwise null. - public async Task RetrieveAsync(string key) - { - var bytes = await _cache.GetAsync(key); - if (bytes is null) - return null; - - try - { - return TicketSerializer.Default.Deserialize(_protector.Unprotect(bytes)); - } - catch (CryptographicException) - { - return null; - } - } - - /// - /// Removes the ticket stored under the given key from the cache. - /// - /// The key of the ticket to remove. - public Task RemoveAsync(string key) => _cache.RemoveAsync(key); -} diff --git a/absolute/src/APITemplate.Infrastructure/Security/HttpActorProvider.cs b/absolute/src/APITemplate.Infrastructure/Security/HttpActorProvider.cs deleted file mode 100644 index b7c9ed02..00000000 --- a/absolute/src/APITemplate.Infrastructure/Security/HttpActorProvider.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Security.Claims; -using APITemplate.Application.Common.Context; -using APITemplate.Application.Common.Options; -using APITemplate.Application.Common.Security; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Options; - -namespace APITemplate.Infrastructure.Security; - -/// -/// Resolves actor identity for auditing from the current HTTP principal. -/// -/// -/// Uses a prioritized claim lookup and falls back to configured system identity -/// when no user claim is available (for example background/system execution paths). -/// -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 - { - 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) - ?? user?.FindFirstValue(AuthConstants.Claims.Subject) - ?? user?.FindFirstValue(ClaimTypes.Name); - - return Guid.TryParse(raw, out var id) ? id : _systemIdentity.DefaultActorId; - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Security/HttpTenantProvider.cs b/absolute/src/APITemplate.Infrastructure/Security/HttpTenantProvider.cs deleted file mode 100644 index 30d6b9bb..00000000 --- a/absolute/src/APITemplate.Infrastructure/Security/HttpTenantProvider.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Security.Claims; -using APITemplate.Application.Common.Context; -using APITemplate.Application.Common.Security; -using Microsoft.AspNetCore.Http; - -namespace APITemplate.Infrastructure.Security; - -/// -/// Resolves tenant identity from the current authenticated HTTP principal. -/// -/// -/// Reads the tenant_id claim and returns when missing/invalid. -/// Intended for scoped, request-bound usage through . -/// -public sealed class HttpTenantProvider : ITenantProvider -{ - private readonly IHttpContextAccessor _httpContextAccessor; - - public HttpTenantProvider(IHttpContextAccessor httpContextAccessor) - { - _httpContextAccessor = httpContextAccessor; - } - - public Guid TenantId - { - get - { - var claimValue = _httpContextAccessor.HttpContext?.User.FindFirstValue( - AuthConstants.Claims.TenantId - ); - // Invalid or missing tenant claim is represented as Guid.Empty and treated as "no tenant". - return Guid.TryParse(claimValue, out var tenantId) ? tenantId : Guid.Empty; - } - } - - public bool HasTenant => TenantId != Guid.Empty; -} diff --git a/absolute/src/APITemplate.Infrastructure/Security/Keycloak/CookieSessionRefresher.cs b/absolute/src/APITemplate.Infrastructure/Security/Keycloak/CookieSessionRefresher.cs deleted file mode 100644 index b5f62648..00000000 --- a/absolute/src/APITemplate.Infrastructure/Security/Keycloak/CookieSessionRefresher.cs +++ /dev/null @@ -1,199 +0,0 @@ -using System.Net.Http.Json; -using APITemplate.Application.Common.Options; -using APITemplate.Application.Common.Security; -using APITemplate.Infrastructure.Observability; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace APITemplate.Infrastructure.Security.Keycloak; - -/// -/// Provides the cookie authentication principal validation callback used to transparently -/// refresh Keycloak-backed BFF sessions when access tokens are close to expiration. -/// -/// -/// This type is part of the Infrastructure assembly's public surface so that authentication -/// configuration in the API layer can wire into -/// for the BFF cookie scheme. -/// It is not intended for general-purpose use outside of authentication setup. -/// -public static class CookieSessionRefresher -{ - /// - /// Validates an incoming cookie principal and, when appropriate, attempts to refresh - /// the underlying Keycloak session and update the authentication cookie. - /// - /// The cookie validation context supplied by ASP.NET Core authentication. - public static async Task OnValidatePrincipal(CookieValidatePrincipalContext context) - { - if (!TryCreateRefreshRequest(context, out var refreshRequest)) - return; - - var tokenResponse = await TryRefreshSessionAsync(context, refreshRequest); - if (tokenResponse is null) - { - AuthTelemetry.RecordCookieRefreshFailed(); - context.RejectPrincipal(); - return; - } - - ApplyRefreshedSession(context, tokenResponse, refreshRequest.RefreshToken); - } - - private static bool TryCreateRefreshRequest( - CookieValidatePrincipalContext context, - out RefreshRequest refreshRequest) - { - refreshRequest = default; - - if (!TryGetExpiration(context, out var expiresAt)) - return false; - - if (!IsRefreshRequired(context, expiresAt)) - return false; - - if (!TryGetRefreshToken(context, out var refreshToken)) - { - AuthTelemetry.RecordMissingRefreshToken(); - context.RejectPrincipal(); - return false; - } - - refreshRequest = new RefreshRequest(GetKeycloakOptions(context), refreshToken); - return true; - } - - private static bool TryGetExpiration( - CookieValidatePrincipalContext context, - out DateTimeOffset expiresAt) - { - expiresAt = default; - 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) - { - var bffOptions = context.HttpContext.RequestServices - .GetRequiredService>().Value; - - return expiresAt - DateTimeOffset.UtcNow - <= TimeSpan.FromMinutes(bffOptions.TokenRefreshThresholdMinutes); - } - - private static bool TryGetRefreshToken( - CookieValidatePrincipalContext context, - out string refreshToken) - { - refreshToken = context.Properties.GetTokenValue(AuthConstants.CookieTokenNames.RefreshToken) ?? string.Empty; - return !string.IsNullOrEmpty(refreshToken); - } - - private static async Task TryRefreshSessionAsync( - CookieValidatePrincipalContext context, - RefreshRequest refreshRequest) - { - var tokenEndpoint = KeycloakUrlHelper.BuildTokenEndpoint( - refreshRequest.KeycloakOptions.AuthServerUrl, - refreshRequest.KeycloakOptions.Realm); - using var client = CreateTokenClient(context); - - try - { - using var response = await SendRefreshRequestAsync( - context, - client, - tokenEndpoint, - refreshRequest); - - if (!response.IsSuccessStatusCode) - { - AuthTelemetry.RecordTokenEndpointRejected(); - return null; - } - - return await response.Content.ReadFromJsonAsync(context.HttpContext.RequestAborted); - } - catch (Exception ex) - { - AuthTelemetry.RecordTokenRefreshException(ex); - GetLogger(context).LogWarning(ex, "Token refresh failed, rejecting principal."); - return null; - } - } - - private static HttpClient CreateTokenClient(CookieValidatePrincipalContext context) - { - return context.HttpContext.RequestServices - .GetRequiredService() - .CreateClient(AuthConstants.HttpClients.KeycloakToken); - } - - private static Task SendRefreshRequestAsync( - CookieValidatePrincipalContext context, - HttpClient client, - string tokenEndpoint, - RefreshRequest refreshRequest) - { - return client.PostAsync( - tokenEndpoint, - BuildRefreshRequestContent(refreshRequest.KeycloakOptions, refreshRequest.RefreshToken), - context.HttpContext.RequestAborted); - } - - private static KeycloakOptions GetKeycloakOptions(CookieValidatePrincipalContext context) - { - return context.HttpContext.RequestServices - .GetRequiredService>().Value; - } - - - private static FormUrlEncodedContent BuildRefreshRequestContent( - KeycloakOptions keycloakOptions, - string refreshToken) - { - var formParams = new Dictionary - { - [AuthConstants.OAuth2FormParameters.GrantType] = AuthConstants.OAuth2GrantTypes.RefreshToken, - [AuthConstants.OAuth2FormParameters.ClientId] = keycloakOptions.Resource, - [AuthConstants.OAuth2FormParameters.RefreshToken] = refreshToken - }; - - if (!string.IsNullOrEmpty(keycloakOptions.Credentials.Secret)) - formParams[AuthConstants.OAuth2FormParameters.ClientSecret] = keycloakOptions.Credentials.Secret; - - return new FormUrlEncodedContent(formParams); - } - - private static void ApplyRefreshedSession( - CookieValidatePrincipalContext context, - KeycloakTokenResponse tokenResponse, - string refreshToken) - { - context.Properties.UpdateTokenValue(AuthConstants.CookieTokenNames.AccessToken, tokenResponse.AccessToken); - context.Properties.UpdateTokenValue( - AuthConstants.CookieTokenNames.RefreshToken, - tokenResponse.RefreshToken ?? refreshToken); - context.Properties.UpdateTokenValue( - AuthConstants.CookieTokenNames.ExpiresAt, - DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn).ToString("o")); - context.ShouldRenew = true; - } - - private static ILogger GetLogger(CookieValidatePrincipalContext context) - { - return context.HttpContext.RequestServices - .GetRequiredService() - .CreateLogger(nameof(CookieSessionRefresher)); - } - - private readonly record struct RefreshRequest( - KeycloakOptions KeycloakOptions, - string RefreshToken); -} diff --git a/absolute/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakAdminService.cs b/absolute/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakAdminService.cs deleted file mode 100644 index d4642431..00000000 --- a/absolute/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakAdminService.cs +++ /dev/null @@ -1,170 +0,0 @@ -using APITemplate.Application.Common.Options; -using APITemplate.Application.Common.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 APITemplate.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; - } - - /// - /// Creates a new user in Keycloak and, on a best-effort basis, sends them an - /// email-verify + set-password action email. Returns the new Keycloak user ID. - /// - public async Task CreateUserAsync( - string username, - string email, - CancellationToken ct = default - ) - { - var user = new UserRepresentation - { - Username = username, - Email = email, - Enabled = true, - EmailVerified = false, - }; - - using var response = await _userClient.CreateUserWithResponseAsync(_realm, user, ct); - response.EnsureSuccessStatusCode(); - - var keycloakUserId = ExtractUserIdFromLocation(response); - - _logger.LogInformation( - "Created Keycloak user {Username} with id {KeycloakUserId}", - username, - keycloakUserId - ); - - // Best-effort: if the setup email fails, we still return the created user ID so the - // caller can persist the local record. The user can be sent a password reset later. - 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; - } - - /// Triggers a Keycloak update-password action email for the given user. - 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 - ); - } - - /// Enables or disables the Keycloak account for the given user. - public async Task SetUserEnabledAsync( - string keycloakUserId, - bool enabled, - CancellationToken ct = default - ) - { - var patch = new UserRepresentation { Enabled = enabled }; - await _userClient.UpdateUserAsync(_realm, keycloakUserId, patch, ct); - - _logger.LogInformation( - "Set Keycloak user {KeycloakUserId} enabled={Enabled}", - keycloakUserId, - enabled - ); - } - - /// - /// Deletes the Keycloak user. A 404 response is treated as success to handle idempotent retries. - /// - 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) - { - // Treat 404 as success — the user was already deleted (e.g., retry scenario) - _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) - { - var location = - response.Headers.Location - ?? throw new InvalidOperationException( - "Keycloak CreateUser response did not include a Location header." - ); - - // Location is: {base}/admin/realms/{realm}/users/{id} - var 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/absolute/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakAdminTokenHandler.cs b/absolute/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakAdminTokenHandler.cs deleted file mode 100644 index 2bcc3d33..00000000 --- a/absolute/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakAdminTokenHandler.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Net.Http.Headers; - -namespace APITemplate.Infrastructure.Security.Keycloak; - -/// -/// A transient that attaches a cached Keycloak -/// service-account Bearer token to every outbound admin API request. -/// Token acquisition and caching are delegated to the singleton . -/// -public sealed class KeycloakAdminTokenHandler : DelegatingHandler -{ - private readonly KeycloakAdminTokenProvider _tokenProvider; - - public KeycloakAdminTokenHandler(KeycloakAdminTokenProvider tokenProvider) - { - _tokenProvider = tokenProvider; - } - - /// - /// Fetches a valid service-account token from - /// and attaches it as a Bearer Authorization header before forwarding the request. - /// - protected override async Task SendAsync( - HttpRequestMessage request, - CancellationToken cancellationToken - ) - { - var token = await _tokenProvider.GetTokenAsync(cancellationToken); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - return await base.SendAsync(request, cancellationToken); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakAdminTokenProvider.cs b/absolute/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakAdminTokenProvider.cs deleted file mode 100644 index 603a56cf..00000000 --- a/absolute/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakAdminTokenProvider.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System.Net.Http.Json; -using APITemplate.Application.Common.Options; -using APITemplate.Application.Common.Security; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace APITemplate.Infrastructure.Security.Keycloak; - -/// -/// Singleton service that acquires and caches a Keycloak service-account (client credentials) token. -/// Tokens are kept in memory until they expire; a 30-second safety margin prevents -/// using a token that is about to expire mid-flight. -/// -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 string? _cachedToken; - private DateTimeOffset _tokenExpiresAt = DateTimeOffset.MinValue; - private readonly SemaphoreSlim _lock = new(1, 1); - - public KeycloakAdminTokenProvider( - IHttpClientFactory httpClientFactory, - IOptions keycloakOptions, - ILogger logger - ) - { - _httpClientFactory = httpClientFactory; - _keycloakOptions = keycloakOptions; - _logger = logger; - } - - /// - /// Returns a cached service-account access token, refreshing it via the Keycloak token endpoint - /// when it is absent or within the 30-second expiry margin. Thread-safe via . - /// - public async Task GetTokenAsync(CancellationToken cancellationToken) - { - if (IsTokenValid()) - return _cachedToken!; - - await _lock.WaitAsync(cancellationToken); - try - { - // Double-check after acquiring the lock. - if (IsTokenValid()) - return _cachedToken!; - - var 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 = DateTimeOffset.UtcNow.AddSeconds(response.ExpiresIn); - return _cachedToken; - } - finally - { - _lock.Release(); - } - } - - private async Task FetchTokenAsync(CancellationToken cancellationToken) - { - var keycloak = _keycloakOptions.Value; - var tokenEndpoint = KeycloakUrlHelper.BuildTokenEndpoint( - keycloak.AuthServerUrl, - keycloak.Realm - ); - - using var client = _httpClientFactory.CreateClient(AuthConstants.HttpClients.KeycloakToken); - using var content = new FormUrlEncodedContent( - new Dictionary - { - [AuthConstants.OAuth2FormParameters.GrantType] = AuthConstants - .OAuth2GrantTypes - .ClientCredentials, - [AuthConstants.OAuth2FormParameters.ClientId] = keycloak.Resource, - [AuthConstants.OAuth2FormParameters.ClientSecret] = keycloak.Credentials.Secret, - } - ); - - using var response = await client.PostAsync(tokenEndpoint, content, cancellationToken); - - if (!response.IsSuccessStatusCode) - { - var body = await response.Content.ReadAsStringAsync(cancellationToken); - _logger.LogError( - "Failed to acquire Keycloak admin token. Status: {Status}. Body: {Body}", - (int)response.StatusCode, - body - ); - response.EnsureSuccessStatusCode(); // throws - } - - var token = - await response.Content.ReadFromJsonAsync(cancellationToken) - ?? throw new InvalidOperationException("Keycloak token endpoint returned empty body."); - - return token; - } - - private bool IsTokenValid() => - _cachedToken is not null && DateTimeOffset.UtcNow < _tokenExpiresAt - ExpiryMargin; - - public void Dispose() => _lock.Dispose(); -} diff --git a/absolute/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakClaimMapper.cs b/absolute/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakClaimMapper.cs deleted file mode 100644 index 93ef17f9..00000000 --- a/absolute/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakClaimMapper.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.Security.Claims; -using System.Text.Json; -using APITemplate.Application.Common.Security; - -namespace APITemplate.Infrastructure.Security.Keycloak; - -/// -/// Maps Keycloak-specific JWT claims into standard .NET claim types expected by -/// ASP.NET Core authorization policies (preferred_username → , -/// realm_access.roles → ). -/// -public static class KeycloakClaimMapper -{ - /// Maps both the preferred username and realm roles from the Keycloak token into standard .NET claims. - public static void MapKeycloakClaims(ClaimsIdentity identity) - { - MapUsername(identity); - MapRealmRoles(identity); - } - - private static void MapUsername(ClaimsIdentity identity) - { - if (identity.FindFirst(ClaimTypes.Name) != null) - return; - - var preferred = identity.FindFirst(AuthConstants.Claims.PreferredUsername); - if (preferred != null) - identity.AddClaim(new Claim(ClaimTypes.Name, preferred.Value)); - } - - private static void MapRealmRoles(ClaimsIdentity identity) - { - var realmAccess = identity.FindFirst(AuthConstants.Claims.RealmAccess); - if (realmAccess == null) - return; - - using var doc = JsonDocument.Parse(realmAccess.Value); - if (!doc.RootElement.TryGetProperty(AuthConstants.Claims.Roles, out var roles)) - return; - - foreach (var role in roles.EnumerateArray()) - { - var value = role.GetString(); - if (!string.IsNullOrEmpty(value)) - identity.AddClaim(new Claim(ClaimTypes.Role, value)); - } - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakTokenResponse.cs b/absolute/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakTokenResponse.cs deleted file mode 100644 index 8f8f7715..00000000 --- a/absolute/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakTokenResponse.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Text.Json.Serialization; - -namespace APITemplate.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/absolute/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakUrlHelper.cs b/absolute/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakUrlHelper.cs deleted file mode 100644 index 12a06575..00000000 --- a/absolute/src/APITemplate.Infrastructure/Security/Keycloak/KeycloakUrlHelper.cs +++ /dev/null @@ -1,22 +0,0 @@ -using APITemplate.Application.Common.Security; - -namespace APITemplate.Infrastructure.Security.Keycloak; - -/// -/// Builds well-known Keycloak URL components (authority, discovery, token endpoint) from -/// configured base URL and realm name. -/// -public static class KeycloakUrlHelper -{ - /// Returns the realm authority URL: {authServerUrl}/realms/{realm}. - public static string BuildAuthority(string authServerUrl, string realm) => - $"{authServerUrl.TrimEnd('/')}/realms/{realm}"; - - /// Returns the OpenID Connect discovery document URL for the given realm. - public static string BuildDiscoveryUrl(string authServerUrl, string realm) => - $"{BuildAuthority(authServerUrl, realm)}/.well-known/openid-configuration"; - - /// Returns the OAuth2 token endpoint URL for the given realm. - public static string BuildTokenEndpoint(string authServerUrl, string realm) => - $"{BuildAuthority(authServerUrl, realm)}/{AuthConstants.OpenIdConnect.TokenEndpointPath}"; -} diff --git a/absolute/src/APITemplate.Infrastructure/Security/SecureTokenGenerator.cs b/absolute/src/APITemplate.Infrastructure/Security/SecureTokenGenerator.cs deleted file mode 100644 index 2f3211c4..00000000 --- a/absolute/src/APITemplate.Infrastructure/Security/SecureTokenGenerator.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Security.Cryptography; -using APITemplate.Application.Common.Email; - -namespace APITemplate.Infrastructure.Security; - -/// -/// Generates cryptographically random tokens and produces their SHA-256 hex digest -/// for safe storage in the database. -/// -public sealed class SecureTokenGenerator : ISecureTokenGenerator -{ - /// Generates a 32-byte cryptographically random token encoded as Base64. - public string GenerateToken() - { - var bytes = RandomNumberGenerator.GetBytes(32); - return Convert.ToBase64String(bytes); - } - - /// Computes and returns the lowercase hex-encoded SHA-256 hash of . - public string HashToken(string token) - { - var bytes = System.Text.Encoding.UTF8.GetBytes(token); - var hash = SHA256.HashData(bytes); - return Convert.ToHexStringLower(hash); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Security/Tenant/TenantClaimValidator.cs b/absolute/src/APITemplate.Infrastructure/Security/Tenant/TenantClaimValidator.cs deleted file mode 100644 index d596a1f8..00000000 --- a/absolute/src/APITemplate.Infrastructure/Security/Tenant/TenantClaimValidator.cs +++ /dev/null @@ -1,166 +0,0 @@ -using System.Security.Claims; -using APITemplate.Application.Common.Security; -using APITemplate.Infrastructure.Observability; -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 APITemplate.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 -{ - /// - /// JWT Bearer token callback executed after signature and lifetime checks. - /// Maps Keycloak claims, enforces tenant claim presence for user tokens, and logs - /// authentication details for diagnostics. - /// - /// The JWT token validation context. - /// A completed task. - public static async Task OnTokenValidated(JwtTokenValidatedContext context) - { - var identity = context.Principal?.Identity as ClaimsIdentity; - if (identity != null) - KeycloakClaimMapper.MapKeycloakClaims(identity); - - if (!IsServiceAccount(context.Principal)) - await TryProvisionUserAsync(context.HttpContext, context.Principal); - - if (!HasValidTenantClaim(context.Principal) && !IsServiceAccount(context.Principal)) - { - AuthTelemetry.RecordMissingTenantClaim( - context.HttpContext, - JwtBearerDefaults.AuthenticationScheme - ); - context.Fail($"Missing required {AuthConstants.Claims.TenantId} claim."); - } - - LogTokenValidated( - context.HttpContext, - context.Principal, - JwtBearerDefaults.AuthenticationScheme - ); - } - - /// - /// OpenID Connect token callback executed after token validation in BFF login flow. - /// Applies the same tenant and claim-mapping rules as JWT Bearer validation. - /// - /// The OIDC token validation context. - /// A completed task. - public static async Task OnTokenValidated(OidcTokenValidatedContext context) - { - var identity = context.Principal?.Identity as ClaimsIdentity; - if (identity != null) - KeycloakClaimMapper.MapKeycloakClaims(identity); - - if (!IsServiceAccount(context.Principal)) - await TryProvisionUserAsync(context.HttpContext, context.Principal); - - if (!HasValidTenantClaim(context.Principal) && !IsServiceAccount(context.Principal)) - { - AuthTelemetry.RecordMissingTenantClaim( - context.HttpContext, - OpenIdConnectDefaults.AuthenticationScheme - ); - context.Fail($"Missing required {AuthConstants.Claims.TenantId} claim."); - } - - LogTokenValidated( - context.HttpContext, - context.Principal, - OpenIdConnectDefaults.AuthenticationScheme - ); - } - - /// - /// Checks whether the principal has a non-empty GUID value in the tenant_id claim. - /// - /// The authenticated principal. - /// true when a valid tenant claim exists; otherwise false. - public static bool HasValidTenantClaim(ClaimsPrincipal? principal) - { - return principal?.HasClaim(c => - c.Type == AuthConstants.Claims.TenantId - && Guid.TryParse(c.Value, out var tenantId) - && tenantId != Guid.Empty - ) == true; - } - - private static async Task TryProvisionUserAsync( - HttpContext httpContext, - ClaimsPrincipal? principal - ) - { - try - { - var sub = principal?.FindFirstValue(AuthConstants.Claims.Subject); - var email = principal?.FindFirstValue(ClaimTypes.Email); - var username = principal?.FindFirstValue(AuthConstants.Claims.PreferredUsername); - - if ( - string.IsNullOrEmpty(sub) - || string.IsNullOrEmpty(email) - || string.IsNullOrEmpty(username) - ) - return; - - var provisioningService = - httpContext.RequestServices.GetRequiredService(); - - await provisioningService.ProvisionIfNeededAsync(sub, email, username); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - var logger = httpContext - .RequestServices.GetRequiredService() - .CreateLogger(typeof(TenantClaimValidator)); - - logger.UserProvisioningFailed(ex); - } - } - - private static bool IsServiceAccount(ClaimsPrincipal? principal) - { - var 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 - ) - { - var logger = httpContext - .RequestServices.GetRequiredService() - .CreateLogger(typeof(TenantClaimValidator)); - - if (principal?.Identity is not ClaimsIdentity identity) - { - logger.TokenValidatedNoIdentity(scheme); - return; - } - - var claims = identity.Claims.Select(c => $"{c.Type}={c.Value}").ToList(); - - logger.TokenValidatedWithClaims(scheme, claims.Count, string.Join("; ", claims)); - - var name = identity.FindFirst(ClaimTypes.Name)?.Value; - var roles = identity.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray(); - var tenantId = identity.FindFirst(AuthConstants.Claims.TenantId)?.Value; - - logger.UserAuthenticated(scheme, name, tenantId, string.Join(", ", roles)); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Security/Tenant/TenantClaimValidatorLogs.cs b/absolute/src/APITemplate.Infrastructure/Security/Tenant/TenantClaimValidatorLogs.cs deleted file mode 100644 index c72551e8..00000000 --- a/absolute/src/APITemplate.Infrastructure/Security/Tenant/TenantClaimValidatorLogs.cs +++ /dev/null @@ -1,49 +0,0 @@ -using APITemplate.Infrastructure.Logging; -using Microsoft.Extensions.Logging; - -namespace APITemplate.Infrastructure.Security.Tenant; - -/// -/// Compile-time source-generated logger extension methods for diagnostics. -/// -internal static partial class TenantClaimValidatorLogs -{ - [LoggerMessage( - EventId = 2001, - Level = LogLevel.Warning, - Message = "[{Scheme}] Token validated but no identity found" - )] - public static partial void TokenValidatedNoIdentity(this ILogger logger, string scheme); - - [LoggerMessage( - EventId = 2002, - Level = LogLevel.Debug, - Message = "[{Scheme}] Token validated with {ClaimCount} claims: {Claims}" - )] - public static partial void TokenValidatedWithClaims( - this ILogger logger, - string scheme, - int claimCount, - [SensitiveData] string claims - ); - - [LoggerMessage( - EventId = 2003, - Level = LogLevel.Information, - Message = "[{Scheme}] Authenticated user={User}, tenant={TenantId}, roles=[{Roles}]" - )] - public static partial void UserAuthenticated( - this ILogger logger, - string scheme, - [PersonalData] string? user, - [SensitiveData] string? tenantId, - string roles - ); - - [LoggerMessage( - EventId = 2004, - Level = LogLevel.Warning, - Message = "User provisioning failed during token validation — authentication will continue" - )] - public static partial void UserProvisioningFailed(this ILogger logger, Exception exception); -} diff --git a/absolute/src/APITemplate.Infrastructure/Security/Tenant/UserProvisioningService.cs b/absolute/src/APITemplate.Infrastructure/Security/Tenant/UserProvisioningService.cs deleted file mode 100644 index 6b20b475..00000000 --- a/absolute/src/APITemplate.Infrastructure/Security/Tenant/UserProvisioningService.cs +++ /dev/null @@ -1,113 +0,0 @@ -using APITemplate.Application.Common.Security; -using APITemplate.Domain.Entities; -using APITemplate.Domain.Enums; -using APITemplate.Domain.Interfaces; -using APITemplate.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -namespace APITemplate.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 -{ - // AppDbContext is injected directly (rather than via repository interfaces) because: - // 1. IgnoreQueryFilters() is required — no tenant context exists during OnTokenValidated - // 2. Both reads use global filter bypass; routing through repositories would require - // adding filter-bypass methods to the repository interfaces for a single use case - private readonly AppDbContext _db; - private readonly IUnitOfWork _unitOfWork; - private readonly ILogger _logger; - - public UserProvisioningService( - AppDbContext db, - IUnitOfWork unitOfWork, - ILogger logger) - { - _db = db; - _unitOfWork = unitOfWork; - _logger = logger; - } - - /// - public async Task ProvisionIfNeededAsync( - string keycloakUserId, - string email, - string username, - 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() - .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; - } - - // 2. Look for an accepted invitation matching the normalised email. - // Bypass tenant filter — at this point no tenant context is active. - var normalizedEmail = AppUser.NormalizeEmail(email); - - var 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; - } - - // 3. Provision a new user from the invitation data. - var user = new AppUser - { - Username = username, - Email = email, - KeycloakUserId = keycloakUserId, - // TenantId must be set explicitly here. During OnTokenValidated, no tenant context - // is active (ITenantProvider.HasTenant == false), so AuditableEntityStateManager - // will NOT auto-assign TenantId from the tenant provider. This explicit assignment - // is load-bearing and must not be removed. - 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) - { - // Concurrent request may have provisioned this user — re-fetch the winner. - _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/absolute/src/APITemplate.Infrastructure/StoredProcedures/ClaimExpiredFailedEmailsProcedure.cs b/absolute/src/APITemplate.Infrastructure/StoredProcedures/ClaimExpiredFailedEmailsProcedure.cs deleted file mode 100644 index 7bd17744..00000000 --- a/absolute/src/APITemplate.Infrastructure/StoredProcedures/ClaimExpiredFailedEmailsProcedure.cs +++ /dev/null @@ -1,21 +0,0 @@ -using APITemplate.Domain.Entities; -using APITemplate.Domain.Interfaces; - -namespace APITemplate.Infrastructure.StoredProcedures; - -/// -/// Calls the claim_expired_failed_emails(...) PostgreSQL function. -/// Atomically selects and claims a batch of expired failed emails using -/// FOR UPDATE SKIP LOCKED to avoid contention between concurrent workers. -/// -public sealed record ClaimExpiredFailedEmailsProcedure( - DateTime Cutoff, - int BatchSize, - string ClaimedBy, - DateTime ClaimedAtUtc, - DateTime ClaimedUntilUtc -) : IStoredProcedure -{ - public FormattableString ToSql() => - $"SELECT * FROM claim_expired_failed_emails({Cutoff}, {BatchSize}, {ClaimedBy}, {ClaimedAtUtc}, {ClaimedUntilUtc})"; -} diff --git a/absolute/src/APITemplate.Infrastructure/StoredProcedures/ClaimRetryableFailedEmailsProcedure.cs b/absolute/src/APITemplate.Infrastructure/StoredProcedures/ClaimRetryableFailedEmailsProcedure.cs deleted file mode 100644 index fcf6e2b2..00000000 --- a/absolute/src/APITemplate.Infrastructure/StoredProcedures/ClaimRetryableFailedEmailsProcedure.cs +++ /dev/null @@ -1,21 +0,0 @@ -using APITemplate.Domain.Entities; -using APITemplate.Domain.Interfaces; - -namespace APITemplate.Infrastructure.StoredProcedures; - -/// -/// Calls the claim_retryable_failed_emails(...) PostgreSQL function. -/// Atomically selects and claims a batch of retryable failed emails using -/// FOR UPDATE SKIP LOCKED to avoid contention between concurrent workers. -/// -public sealed record ClaimRetryableFailedEmailsProcedure( - int MaxRetryAttempts, - int BatchSize, - string ClaimedBy, - DateTime ClaimedAtUtc, - DateTime ClaimedUntilUtc -) : IStoredProcedure -{ - public FormattableString ToSql() => - $"SELECT * FROM claim_retryable_failed_emails({MaxRetryAttempts}, {BatchSize}, {ClaimedBy}, {ClaimedAtUtc}, {ClaimedUntilUtc})"; -} diff --git a/absolute/src/APITemplate.Infrastructure/StoredProcedures/GetFtsIndexNamesProcedure.cs b/absolute/src/APITemplate.Infrastructure/StoredProcedures/GetFtsIndexNamesProcedure.cs deleted file mode 100644 index 95d3a727..00000000 --- a/absolute/src/APITemplate.Infrastructure/StoredProcedures/GetFtsIndexNamesProcedure.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Infrastructure/StoredProcedures/GetIndexBloatPercentProcedure.cs b/absolute/src/APITemplate.Infrastructure/StoredProcedures/GetIndexBloatPercentProcedure.cs deleted file mode 100644 index 1f39ef42..00000000 --- a/absolute/src/APITemplate.Infrastructure/StoredProcedures/GetIndexBloatPercentProcedure.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace APITemplate.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/absolute/src/APITemplate.Infrastructure/StoredProcedures/GetProductCategoryStatsProcedure.cs b/absolute/src/APITemplate.Infrastructure/StoredProcedures/GetProductCategoryStatsProcedure.cs deleted file mode 100644 index a5ddbc29..00000000 --- a/absolute/src/APITemplate.Infrastructure/StoredProcedures/GetProductCategoryStatsProcedure.cs +++ /dev/null @@ -1,20 +0,0 @@ -using APITemplate.Domain.Entities; -using APITemplate.Domain.Interfaces; - -namespace APITemplate.Infrastructure.StoredProcedures; - -/// -/// Calls the get_product_category_stats(p_category_id, p_tenant_id) PostgreSQL function. -/// -/// Result columns returned by the function: -/// category_id, category_name, product_count, average_price, total_reviews -/// -/// EF Core maps each column to the corresponding property on -/// by name (case-insensitive). -/// -public sealed record GetProductCategoryStatsProcedure(Guid CategoryId, Guid TenantId) - : IStoredProcedure -{ - public FormattableString ToSql() => - $"SELECT * FROM get_product_category_stats({CategoryId}, {TenantId})"; -} diff --git a/absolute/src/APITemplate.Infrastructure/StoredProcedures/StoredProcedureExecutor.cs b/absolute/src/APITemplate.Infrastructure/StoredProcedures/StoredProcedureExecutor.cs deleted file mode 100644 index d502be85..00000000 --- a/absolute/src/APITemplate.Infrastructure/StoredProcedures/StoredProcedureExecutor.cs +++ /dev/null @@ -1,54 +0,0 @@ -using APITemplate.Domain.Interfaces; -using APITemplate.Infrastructure.Observability; -using APITemplate.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; - -namespace APITemplate.Infrastructure.StoredProcedures; - -/// -/// EF Core implementation of . -/// -/// All methods use FromSql(FormattableString) which converts each -/// interpolated argument into a named SQL parameter — SQL injection is impossible -/// as long as callers pass parameters via interpolation, never via concatenation. -/// -/// The DbContext.Set<T>() approach is used instead of a typed DbSet property -/// so that adding a new stored procedure result type only requires: -/// 1. A new keyless entity + HasNoKey() configuration -/// 2. A new IStoredProcedure<T> implementation -/// No changes to AppDbContext or this executor are needed. -/// -public sealed class StoredProcedureExecutor : IStoredProcedureExecutor -{ - private readonly AppDbContext _dbContext; - - public StoredProcedureExecutor(AppDbContext dbContext) - { - _dbContext = dbContext; - } - - public Task QueryFirstAsync( - IStoredProcedure procedure, - CancellationToken ct = default) - where TResult : class - => StoredProcedureTelemetry.TraceQueryFirstAsync( - procedure, - () => _dbContext.Set() - .FromSql(procedure.ToSql()) - .FirstOrDefaultAsync(ct)); - - public Task> QueryManyAsync( - IStoredProcedure procedure, - CancellationToken ct = default) - where TResult : class - => StoredProcedureTelemetry.TraceQueryManyAsync( - procedure, - async () => await _dbContext.Set() - .FromSql(procedure.ToSql()) - .ToListAsync(ct)); - - public Task ExecuteAsync(FormattableString sql, CancellationToken ct = default) - => StoredProcedureTelemetry.TraceExecuteAsync( - sql, - () => _dbContext.Database.ExecuteSqlAsync(sql, ct)); -} diff --git a/absolute/src/APITemplate.Infrastructure/Webhooks/ChannelOutgoingWebhookQueue.cs b/absolute/src/APITemplate.Infrastructure/Webhooks/ChannelOutgoingWebhookQueue.cs deleted file mode 100644 index 7c6b6d94..00000000 --- a/absolute/src/APITemplate.Infrastructure/Webhooks/ChannelOutgoingWebhookQueue.cs +++ /dev/null @@ -1,20 +0,0 @@ -using APITemplate.Application.Common.BackgroundJobs; -using APITemplate.Application.Features.Examples.DTOs; -using APITemplate.Infrastructure.BackgroundJobs.Services; - -namespace APITemplate.Infrastructure.Webhooks; - -/// -/// Bounded implementation for outgoing webhook dispatch, -/// exposing both producer () and consumer () interfaces. -/// -public sealed class ChannelOutgoingWebhookQueue - : BoundedChannelQueue, - IOutgoingWebhookQueue, - IOutgoingWebhookQueueReader -{ - private const int DefaultCapacity = 500; - - public ChannelOutgoingWebhookQueue() - : base(DefaultCapacity) { } -} diff --git a/absolute/src/APITemplate.Infrastructure/Webhooks/ChannelWebhookQueue.cs b/absolute/src/APITemplate.Infrastructure/Webhooks/ChannelWebhookQueue.cs deleted file mode 100644 index 1f288ba0..00000000 --- a/absolute/src/APITemplate.Infrastructure/Webhooks/ChannelWebhookQueue.cs +++ /dev/null @@ -1,20 +0,0 @@ -using APITemplate.Application.Common.BackgroundJobs; -using APITemplate.Application.Features.Examples.DTOs; -using APITemplate.Infrastructure.BackgroundJobs.Services; - -namespace APITemplate.Infrastructure.Webhooks; - -/// -/// Bounded implementation for incoming webhook processing, -/// exposing both producer () and consumer () interfaces. -/// -public sealed class ChannelWebhookQueue - : BoundedChannelQueue, - IWebhookProcessingQueue, - IWebhookQueueReader -{ - private const int DefaultCapacity = 500; - - public ChannelWebhookQueue() - : base(DefaultCapacity) { } -} diff --git a/absolute/src/APITemplate.Infrastructure/Webhooks/HmacHelper.cs b/absolute/src/APITemplate.Infrastructure/Webhooks/HmacHelper.cs deleted file mode 100644 index 10e52f1e..00000000 --- a/absolute/src/APITemplate.Infrastructure/Webhooks/HmacHelper.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Security.Cryptography; -using System.Text; - -namespace APITemplate.Infrastructure.Webhooks; - -/// -/// 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) - { - var signedContent = $"{timestamp}.{payload}"; - var contentBytes = Encoding.UTF8.GetBytes(signedContent); - return HMACSHA256.HashData(keyBytes, contentBytes); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Webhooks/HmacWebhookPayloadSigner.cs b/absolute/src/APITemplate.Infrastructure/Webhooks/HmacWebhookPayloadSigner.cs deleted file mode 100644 index a7ee2c3a..00000000 --- a/absolute/src/APITemplate.Infrastructure/Webhooks/HmacWebhookPayloadSigner.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Text; -using APITemplate.Application.Common.Contracts; -using APITemplate.Application.Common.Options; -using Microsoft.Extensions.Options; - -namespace APITemplate.Infrastructure.Webhooks; - -/// -/// Signs outgoing webhook payloads using HMAC-SHA256 with a shared secret, producing -/// a signature and UTC Unix timestamp for inclusion in request headers. -/// -public sealed class HmacWebhookPayloadSigner : IWebhookPayloadSigner -{ - private readonly byte[] _keyBytes; - private readonly TimeProvider _timeProvider; - - public HmacWebhookPayloadSigner(IOptions options, TimeProvider timeProvider) - { - _keyBytes = Encoding.UTF8.GetBytes(options.Value.Secret); - _timeProvider = timeProvider; - } - - /// Computes the HMAC-SHA256 signature over the current timestamp and payload, returning both values as a . - public WebhookSignatureResult Sign(string payload) - { - var timestamp = _timeProvider.GetUtcNow().ToUnixTimeSeconds().ToString(); - var hashBytes = HmacHelper.ComputeHash(_keyBytes, timestamp, payload); - var signature = Convert.ToHexStringLower(hashBytes); - - return new WebhookSignatureResult(signature, timestamp); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Webhooks/HmacWebhookPayloadValidator.cs b/absolute/src/APITemplate.Infrastructure/Webhooks/HmacWebhookPayloadValidator.cs deleted file mode 100644 index 14f2df43..00000000 --- a/absolute/src/APITemplate.Infrastructure/Webhooks/HmacWebhookPayloadValidator.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using APITemplate.Application.Common.Contracts; -using APITemplate.Application.Common.Options; -using Microsoft.Extensions.Options; - -namespace APITemplate.Infrastructure.Webhooks; - -/// -/// Validates incoming webhook payloads by recomputing the HMAC-SHA256 signature and -/// verifying it against the received signature using constant-time comparison, with a -/// configurable timestamp tolerance window to prevent replay attacks. -/// -public sealed class HmacWebhookPayloadValidator : IWebhookPayloadValidator -{ - private readonly byte[] _keyBytes; - private readonly int _toleranceSeconds; - private readonly TimeProvider _timeProvider; - - public HmacWebhookPayloadValidator(IOptions options, TimeProvider timeProvider) - { - _keyBytes = Encoding.UTF8.GetBytes(options.Value.Secret); - _toleranceSeconds = options.Value.TimestampToleranceSeconds; - _timeProvider = timeProvider; - } - - /// - /// Returns true when the is within the configured tolerance, - /// the is valid hex, and the recomputed HMAC matches via constant-time comparison. - /// - public bool IsValid(string payload, string signature, string timestamp) - { - if (!long.TryParse(timestamp, out var unixSeconds)) - return false; - - var now = _timeProvider.GetUtcNow().ToUnixTimeSeconds(); - var delta = now > unixSeconds ? now - unixSeconds : unixSeconds - now; - if (delta > _toleranceSeconds) - return false; - - var hashBytes = HmacHelper.ComputeHash(_keyBytes, timestamp, payload); - - byte[] signatureBytes; - try - { - signatureBytes = Convert.FromHexString(signature); - } - catch (FormatException) - { - return false; - } - - return CryptographicOperations.FixedTimeEquals(hashBytes, signatureBytes); - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Webhooks/LoggingWebhookEventHandler.cs b/absolute/src/APITemplate.Infrastructure/Webhooks/LoggingWebhookEventHandler.cs deleted file mode 100644 index fac0b253..00000000 --- a/absolute/src/APITemplate.Infrastructure/Webhooks/LoggingWebhookEventHandler.cs +++ /dev/null @@ -1,33 +0,0 @@ -using APITemplate.Application.Common.Contracts; -using APITemplate.Application.Features.Examples.DTOs; -using Microsoft.Extensions.Logging; - -namespace APITemplate.Infrastructure.Webhooks; - -/// -/// A catch-all (EventType = "*") that logs all received -/// webhook events, primarily useful for debugging and as a no-op example handler. -/// -public sealed class LoggingWebhookEventHandler : IWebhookEventHandler -{ - private readonly ILogger _logger; - - public LoggingWebhookEventHandler(ILogger logger) - { - _logger = logger; - } - - public string EventType => "*"; - - /// Logs the event type and ID at information level and returns a completed task. - public Task HandleAsync(WebhookPayload payload, CancellationToken ct = default) - { - _logger.LogInformation( - "Received webhook: Type={EventType}, Id={EventId}", - payload.EventType, - payload.EventId - ); - - return Task.CompletedTask; - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Webhooks/OutgoingWebhookBackgroundService.cs b/absolute/src/APITemplate.Infrastructure/Webhooks/OutgoingWebhookBackgroundService.cs deleted file mode 100644 index 30409fc2..00000000 --- a/absolute/src/APITemplate.Infrastructure/Webhooks/OutgoingWebhookBackgroundService.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.Net.Http.Headers; -using System.Text; -using APITemplate.Application.Common.BackgroundJobs; -using APITemplate.Application.Common.Contracts; -using APITemplate.Application.Features.Examples.DTOs; -using APITemplate.Infrastructure.BackgroundJobs.Services; -using Microsoft.Extensions.Logging; - -namespace APITemplate.Infrastructure.Webhooks; - -/// -/// Background service that drains the outgoing webhook queue, signs each payload with HMAC-SHA256, -/// and delivers it via HTTP POST to the registered callback URL. -/// -public sealed class OutgoingWebhookBackgroundService - : QueueConsumerBackgroundService -{ - private readonly IHttpClientFactory _httpClientFactory; - private readonly IWebhookPayloadSigner _signer; - private readonly ILogger _logger; - - public OutgoingWebhookBackgroundService( - IOutgoingWebhookQueueReader queue, - IHttpClientFactory httpClientFactory, - IWebhookPayloadSigner signer, - ILogger logger - ) - : base(queue) - { - _httpClientFactory = httpClientFactory; - _signer = signer; - _logger = logger; - } - - /// Signs and HTTP-POSTs the outgoing webhook item to its registered callback URL. - protected override async Task ProcessItemAsync(OutgoingWebhookItem item, CancellationToken ct) - { - var signatureResult = _signer.Sign(item.SerializedPayload); - - using var client = _httpClientFactory.CreateClient(WebhookConstants.OutgoingHttpClientName); - using var request = new HttpRequestMessage(HttpMethod.Post, item.CallbackUrl) - { - Content = new StringContent( - item.SerializedPayload, - Encoding.UTF8, - new MediaTypeHeaderValue("application/json") - ), - }; - - request.Headers.Add(WebhookConstants.SignatureHeader, signatureResult.Signature); - request.Headers.Add(WebhookConstants.TimestampHeader, signatureResult.Timestamp); - - using var response = await client.SendAsync(request, ct); - response.EnsureSuccessStatusCode(); - - _logger.LogInformation("Outgoing webhook delivered to {Url}", item.CallbackUrl); - } - - /// Logs delivery failures at error level and returns a completed task to allow the queue to continue processing. - protected override Task HandleErrorAsync( - OutgoingWebhookItem item, - Exception ex, - CancellationToken ct - ) - { - _logger.LogError(ex, "Failed to deliver outgoing webhook to {Url}", item.CallbackUrl); - return Task.CompletedTask; - } -} diff --git a/absolute/src/APITemplate.Infrastructure/Webhooks/WebhookProcessingBackgroundService.cs b/absolute/src/APITemplate.Infrastructure/Webhooks/WebhookProcessingBackgroundService.cs deleted file mode 100644 index 7a5d165c..00000000 --- a/absolute/src/APITemplate.Infrastructure/Webhooks/WebhookProcessingBackgroundService.cs +++ /dev/null @@ -1,78 +0,0 @@ -using APITemplate.Application.Common.BackgroundJobs; -using APITemplate.Application.Common.Contracts; -using APITemplate.Application.Features.Examples.DTOs; -using APITemplate.Infrastructure.BackgroundJobs.Services; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace APITemplate.Infrastructure.Webhooks; - -/// -/// Background service that drains the incoming webhook processing queue, dispatching each -/// payload to all registered implementations that match -/// the event type or are registered as catch-all ("*") handlers. -/// -public sealed class WebhookProcessingBackgroundService - : QueueConsumerBackgroundService -{ - private readonly IServiceScopeFactory _scopeFactory; - private readonly ILogger _logger; - - public WebhookProcessingBackgroundService( - IWebhookQueueReader queue, - IServiceScopeFactory scopeFactory, - ILogger logger - ) - : base(queue) - { - _scopeFactory = scopeFactory; - _logger = logger; - } - - /// - /// Resolves all registered handlers in a fresh DI scope and invokes those whose event type - /// matches the payload or is the wildcard "*". Logs a warning when no handler is matched. - /// - protected override async Task ProcessItemAsync(WebhookPayload payload, CancellationToken ct) - { - await using var scope = _scopeFactory.CreateAsyncScope(); - var handlers = scope.ServiceProvider.GetRequiredService< - IEnumerable - >(); - - var handled = false; - foreach (var handler in handlers) - { - if (handler.EventType == "*" || handler.EventType == payload.EventType) - { - await handler.HandleAsync(payload, ct); - handled = true; - } - } - - if (!handled) - { - _logger.LogWarning( - "No handler registered for webhook event type '{EventType}' (Id={EventId})", - payload.EventType, - payload.EventId - ); - } - } - - /// Logs processing failures at error level and returns a completed task to allow the queue to continue. - protected override Task HandleErrorAsync( - WebhookPayload payload, - Exception ex, - CancellationToken ct - ) - { - _logger.LogError( - ex, - "Failed to process webhook: Type={EventType}, Id={EventId}", - payload.EventType, - payload.EventId - ); - return Task.CompletedTask; - } -} From 29675ac5b5f3443e3c59418a76063d5310adcbb2 Mon Sep 17 00:00:00 2001 From: "tadeas.zribko" Date: Sat, 4 Apr 2026 14:04:37 +0200 Subject: [PATCH 8/8] refactor: Simplify interface method declarations in IFailedEmailRepository - Removed redundant access modifiers from method declarations in IFailedEmailRepository for improved readability. - Updated TenantInvitationEmailHandler to use named parameters for clarity in method calls. --- .../Notifications/Domain/IFailedEmailRepository.cs | 10 +++++----- .../TenantInvitationEmailHandler.cs | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Modules/Notifications/Domain/IFailedEmailRepository.cs b/src/Modules/Notifications/Domain/IFailedEmailRepository.cs index d7c22277..f3275084 100644 --- a/src/Modules/Notifications/Domain/IFailedEmailRepository.cs +++ b/src/Modules/Notifications/Domain/IFailedEmailRepository.cs @@ -7,13 +7,13 @@ namespace Notifications.Domain; public interface IFailedEmailRepository { /// Persists a new failed-email record to the store. - public Task AddAsync(FailedEmail failedEmail, CancellationToken ct = default); + Task AddAsync(FailedEmail failedEmail, CancellationToken ct = default); /// /// Atomically claims a batch of unclaimed, retryable emails (those below ) /// and returns them for processing. /// - public Task> ClaimRetryableBatchAsync( + Task> ClaimRetryableBatchAsync( int maxRetryAttempts, int batchSize, string claimedBy, @@ -26,7 +26,7 @@ public Task> ClaimRetryableBatchAsync( /// Atomically claims a batch of emails whose claim lock has expired past , /// allowing stale claims to be retried. /// - public Task> ClaimExpiredBatchAsync( + Task> ClaimExpiredBatchAsync( DateTime cutoff, int batchSize, string claimedBy, @@ -36,8 +36,8 @@ public Task> ClaimExpiredBatchAsync( ); /// Persists changes to an existing failed-email record (e.g. retry count increment or dead-letter flag). - public Task UpdateAsync(FailedEmail failedEmail, CancellationToken ct = default); + Task UpdateAsync(FailedEmail failedEmail, CancellationToken ct = default); /// Permanently removes a successfully processed failed-email record from the store. - public Task DeleteAsync(FailedEmail failedEmail, CancellationToken ct = default); + Task DeleteAsync(FailedEmail failedEmail, CancellationToken ct = default); } diff --git a/src/Modules/Notifications/Features/SendTenantInvitationEmail/TenantInvitationEmailHandler.cs b/src/Modules/Notifications/Features/SendTenantInvitationEmail/TenantInvitationEmailHandler.cs index dd18bf74..dc2c6d7d 100644 --- a/src/Modules/Notifications/Features/SendTenantInvitationEmail/TenantInvitationEmailHandler.cs +++ b/src/Modules/Notifications/Features/SendTenantInvitationEmail/TenantInvitationEmailHandler.cs @@ -30,7 +30,7 @@ await emailQueue.EnqueueAsync( $"You've been invited to {@event.TenantName}", html, EmailTemplateNames.TenantInvitation, - true + Retryable: true ), ct );