From d87963fceb0ac7275d9ea8c0f0de65f818128866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20Falc=C3=A3o=20Jr?= Date: Thu, 16 Nov 2023 12:20:14 -0600 Subject: [PATCH] Add new functionality to multiple services This commit introduces significant changes to various services. It adds a new messaging system with MassTransit implementation for the Contracts.Abstractions. New proto3 files have been created for Account, Catalog, and Notification services. Detailed boundary definitions have been added, including commands, events, queries, and their respective validations where necessary. It further involves changes relating to the Order service and Identity service, including authentication and user management functionality. --- .assets/img/diagram.png | Bin 0 -> 70696 bytes Directory.Packages.props | 115 +++++++ EventualShop.sln.DotSettings | 3 + src/Contracts/Abstractions/Messages/Event.cs | 6 + .../Boundaries/Account/Account.proto | 41 +++ src/Contracts/Boundaries/Account/Command.cs | 25 ++ .../Boundaries/Account/DomainEvent.cs | 35 ++ .../Boundaries/Account/Projection.cs | 38 +++ src/Contracts/Boundaries/Account/Query.cs | 26 ++ .../Cataloging/Catalog/CatalogingQuery.proto | 67 ++++ .../Cataloging/Catalog/DomainEvent.cs | 25 ++ .../Cataloging/Catalog/Projection.cs | 59 ++++ .../Boundaries/Cataloging/Catalog/Query.cs | 32 ++ src/Contracts/Boundaries/Identity/Command.cs | 20 ++ .../Boundaries/Identity/DelayedEvent.cs | 8 + .../Boundaries/Identity/DomainEvent.cs | 20 ++ .../Boundaries/Identity/Identity.proto | 26 ++ .../Boundaries/Identity/Projection.cs | 20 ++ src/Contracts/Boundaries/Identity/Query.cs | 13 + .../Validators/ConfirmEmailValidator.cs | 16 + .../Validators/RegisterUserValidator.cs | 27 ++ .../Boundaries/Notification/Command.cs | 19 ++ .../Boundaries/Notification/DomainEvent.cs | 17 + .../Notification/Notification.proto | 27 ++ .../Boundaries/Notification/Projection.cs | 31 ++ .../Boundaries/Notification/Query.cs | 14 + src/Contracts/Boundaries/Order/Command.cs | 13 + src/Contracts/Boundaries/Order/DomainEvent.cs | 12 + src/Contracts/Boundaries/Order/Order.proto | 37 ++ src/Contracts/Boundaries/Order/Projection.cs | 32 ++ src/Contracts/Boundaries/Order/Query.cs | 20 ++ src/Contracts/Boundaries/Payment/Command.cs | 25 ++ .../Boundaries/Payment/DomainEvent.cs | 33 ++ .../Boundaries/Payment/Payment.proto | 54 +++ .../Boundaries/Payment/Projection.cs | 52 +++ src/Contracts/Boundaries/Payment/Query.cs | 25 ++ .../Boundaries/Shopping/Checkout/Command.cs | 16 + .../Shopping/Checkout/DomainEvent.cs | 18 + .../Shopping/Checkout/NotificationEvent.cs | 8 + .../Shopping/Checkout/SummaryEvent.cs | 11 + .../Shopping/ShoppingCart/Command.cs | 20 ++ .../Shopping/ShoppingCart/DomainEvent.cs | 26 ++ .../ShoppingCart/NotificationEvent.cs | 8 + .../Shopping/ShoppingCart/Projection.cs | 73 ++++ .../Boundaries/Shopping/ShoppingCart/Query.cs | 43 +++ .../ShoppingCart/ShoppingCartQueries.proto | 84 +++++ .../Shopping/ShoppingCart/SummaryEvent.cs | 11 + src/Contracts/Boundaries/Warehouse/Command.cs | 17 + .../Boundaries/Warehouse/DomainEvent.cs | 27 ++ .../Boundaries/Warehouse/Projection.cs | 30 ++ src/Contracts/Boundaries/Warehouse/Query.cs | 20 ++ .../Validators/CreateInventoryValidator.cs | 15 + .../ReceiveInventoryItemValidator.cs | 23 ++ .../Boundaries/Warehouse/Warehouse.proto | 36 ++ .../Abstractions/IEventBusGateway.cs | 12 + .../Abstractions/IEventStoreGateway.cs | 41 +++ .../Application/Abstractions/IUnitOfWork.cs | 6 + .../Command/Application/Application.csproj | 14 + .../ServiceCollectionExtensions.cs | 13 + .../Services/ApplicationService.cs | 105 ++++++ .../Services/IApplicationService.cs | 28 ++ .../Commands/CreateCatalogItemInteractor.cs | 19 ++ .../Commands/RemoveCatalogItemInteractor.cs | 17 + .../Commands/ActivateCatalogInteractor.cs | 17 + .../ChangeCatalogDescriptionInteractor.cs | 18 + .../Commands/ChangeCatalogTitleInteractor.cs | 18 + .../Commands/CreateCatalogInteractor.cs | 19 ++ .../Commands/DeactivateCatalogInteractor.cs | 17 + .../Commands/DeleteCatalogInteractor.cs | 17 + .../Abstractions/Aggregates/AggregateRoot.cs | 34 ++ .../Abstractions/Aggregates/IAggregateRoot.cs | 14 + .../Domain/Abstractions/DomainException.cs | 31 ++ .../Domain/Abstractions/Entities/Entity.cs | 20 ++ .../Domain/Abstractions/Entities/IEntity.cs | 8 + .../Abstractions/EventStore/Snapshot.cs | 13 + .../Abstractions/EventStore/StoreEvent.cs | 14 + .../Abstractions/Identities/GuidIdentity.cs | 29 ++ .../Command/Domain/Aggregates/AppId.cs | 15 + .../Aggregates/CatalogItems/CatalogItem.cs | 45 +++ .../Aggregates/CatalogItems/CatalogItemId.cs | 15 + .../Domain/Aggregates/Catalogs/Catalog.cs | 80 +++++ .../Domain/Aggregates/Catalogs/CatalogId.cs | 15 + .../Domain/Aggregates/Pricing/PricingId.cs | 15 + .../Domain/Aggregates/Products/Product.cs | 36 ++ .../Domain/Aggregates/Products/ProductId.cs | 15 + .../Cataloging/Command/Domain/Domain.csproj | 10 + .../Domain/Enumerations/CatalogStatus.cs | 22 ++ .../Cataloging/Command/Domain/Exceptions.cs | 10 + .../Command/Domain/ValueObjects/Amount.cs | 36 ++ .../Command/Domain/ValueObjects/Brand.cs | 26 ++ .../Command/Domain/ValueObjects/Category.cs | 19 ++ .../Command/Domain/ValueObjects/Currency.cs | 45 +++ .../Domain/ValueObjects/Description.cs | 20 ++ .../Command/Domain/ValueObjects/Money.cs | 57 ++++ .../Command/Domain/ValueObjects/PictureUri.cs | 28 ++ .../Command/Domain/ValueObjects/Price.cs | 14 + .../Domain/ValueObjects/ProductName.cs | 21 ++ .../Command/Domain/ValueObjects/Quantity.cs | 50 +++ .../Command/Domain/ValueObjects/Sku.cs | 64 ++++ .../Command/Domain/ValueObjects/Title.cs | 20 ++ .../Command/Domain/ValueObjects/Version.cs | 38 +++ .../Configurations/SnapshotConfiguration.cs | 47 +++ .../Configurations/StoreEventConfiguration.cs | 54 +++ .../Contexts/Converters/AggregateConverter.cs | 43 +++ .../Contexts/Converters/EventConverter.cs | 40 +++ .../Converters/IdentifierConverter.cs | 10 + .../Contexts/Converters/VersionConverter.cs | 7 + .../Contexts/EventStoreDbContext.cs | 12 + .../Extensions/HostExtensions.cs | 17 + .../Extensions/ServiceCollectionExtensions.cs | 52 +++ .../Options/EventStoreOptions.cs | 9 + .../Options/SqlServerRetryingOptions.cs | 10 + .../EventStoreGateway.cs | 78 +++++ .../Infrastructure.EventStore.csproj | 14 + .../Infrastructure.EventStore/UnitOfWork.cs | 20 ++ .../Command/WorkerService/.dockerignore | 25 ++ .../Command/WorkerService/Dockerfile | 42 +++ .../Command/WorkerService/Program.cs | 77 +++++ .../Properties/launchSettings.json | 11 + .../WorkerService/WorkerService.csproj | 10 + .../appsettings.Development.json | 11 + .../WorkerService/appsettings.Production.json | 11 + .../WorkerService/appsettings.Staging.json | 11 + .../Command/WorkerService/appsettings.json | 52 +++ .../Application/Abstractions/IInteractor.cs | 25 ++ .../Abstractions/IProjectionGateway.cs | 19 ++ .../Query/Application/Application.csproj | 5 + .../ServiceCollectionExtensions.cs | 29 ++ ...logGridItemWhenCatalogChangedInteractor.cs | 65 ++++ ...logItemCardWhenCatalogChangedInteractor.cs | 24 ++ ...ItemDetailsWhenCatalogChangedInteractor.cs | 24 ++ ...temListItemWhenCatalogChangedInteractor.cs | 31 ++ .../GetCatalogItemDetailsInteractor.cs | 11 + .../ListCatalogItemsCardsInteractor.cs | 12 + .../ListCatalogItemsListItemsInteractor.cs | 12 + .../ListCatalogsGridItemsInteractor.cs | 12 + .../Query/GrpcService/.dockerignore | 25 ++ .../Query/GrpcService/CatalogGrpcService.cs | 71 ++++ .../Cataloging/Query/GrpcService/Dockerfile | 42 +++ .../Query/GrpcService/GrpcService.csproj | 10 + .../Cataloging/Query/GrpcService/Program.cs | 74 ++++ .../Properties/launchSettings.json | 13 + .../GrpcService/appsettings.Development.json | 8 + .../GrpcService/appsettings.Production.json | 8 + .../GrpcService/appsettings.Staging.json | 8 + .../Query/GrpcService/appsettings.json | 40 +++ .../Abstractions/Consumer.cs | 12 + ...talogGridItemWhenCatalogChangedConsumer.cs | 32 ++ ...talogItemCardWhenCatalogChangedConsumer.cs | 7 + ...ogItemDetailsWhenCatalogChangedConsumer.cs | 11 + ...gItemListItemWhenCatalogChangedConsumer.cs | 20 ++ .../Extensions/NameFormatterExtensions.cs | 16 + ...abbitMqBusFactoryConfiguratorExtensions.cs | 38 +++ .../Extensions/ServiceCollectionExtensions.cs | 81 +++++ .../Options/EventBusOptions.cs | 13 + .../Infrastructure.EventBus.csproj | 16 + .../PipeFilters/ContractValidatorFilter.cs | 36 ++ .../PipeObservers/LoggingConsumeObserver.cs | 24 ++ .../PipeObservers/LoggingReceiveObserver.cs | 51 +++ .../Abstractions/IMongoDbContext.cs | 8 + .../Abstractions/MongoDbContext.cs | 18 + .../ServiceCollectionExtensions.cs | 18 + .../Infrastructure.Projections.csproj | 9 + .../Pagination/PagedResult.cs | 30 ++ .../ProjectionDbContext.cs | 6 + .../ProjectionGateway.cs | 61 ++++ .../Abstractions/Consumer.cs | 12 + .../Consumers/Commands/ChangeEmailConsumer.cs | 7 + .../Commands/ChangePasswordConsumer.cs | 7 + .../Commands/ConfirmEmailConsumer.cs | 7 + .../Commands/RegisterUserConsumer.cs | 7 + .../Events/AccountDeactivatedConsumer.cs | 8 + .../Events/AccountDeletedConsumer.cs | 7 + .../Consumers/Events/EmailChangedConsumer.cs | 7 + .../EmailConfirmationExpiredConsumer.cs | 8 + .../Events/EmailConfirmedConsumer.cs | 7 + .../Events/UserRegisteredConsumer.cs | 7 + .../Extensions/NameFormatterExtensions.cs | 16 + ...abbitMqBusFactoryConfiguratorExtensions.cs | 31 ++ .../Extensions/ServiceCollectionExtensions.cs | 111 ++++++ .../Options/MessageBusOptions.cs | 13 + .../EventBusGateway.cs | 17 + .../Infrastructure.EventBus.csproj | 16 + .../PipeFilters/BusinessValidatorFilter.cs | 25 ++ .../PipeFilters/ContractValidatorFilter.cs | 36 ++ .../PipeObservers/LoggingConsumeObserver.cs | 24 ++ .../PipeObservers/LoggingPublishObserver.cs | 34 ++ .../PipeObservers/LoggingReceiveObserver.cs | 51 +++ .../PipeObservers/LoggingSendObserver.cs | 34 ++ .../Abstractions/Gateways/IEmailGateway.cs | 5 + .../Abstractions/Gateways/IEventBusGateway.cs | 10 + .../Gateways/IEventStoreGateway.cs | 10 + .../Gateways/INotificationGateway.cs | 11 + .../Gateways/INotificationService.cs | 9 + .../Gateways/NotificationService.cs | 52 +++ .../Application/Abstractions/IInteractor.cs | 9 + .../Command/Application/Abstractions/ILazy.cs | 6 + .../Application/Abstractions/IUnitOfWork.cs | 6 + .../Command/Application/Application.csproj | 18 + .../Extensions/ServiceCollectionExtensions.cs | 19 ++ .../Resources/EmailResource.Designer.cs | 71 ++++ .../Application/Resources/EmailResource.resx | 25 ++ .../Services/ApplicationService.cs | 34 ++ .../Services/IApplicationService.cs | 13 + ...otificationWhenUserRegisteredInteractor.cs | 32 ++ ...tionWhenNotificationRequestedInteractor.cs | 20 ++ .../Abstractions/Aggregates/AggregateRoot.cs | 43 +++ .../Abstractions/Aggregates/IAggregateRoot.cs | 11 + .../Domain/Abstractions/Entities/Entity.cs | 18 + .../Domain/Abstractions/Entities/IEntity.cs | 7 + .../EventStore/IEventStoreRepository.cs | 12 + .../Abstractions/EventStore/Snapshot.cs | 9 + .../Abstractions/EventStore/StoreEvent.cs | 10 + .../Command/Domain/Aggregates/Notification.cs | 61 ++++ .../Aggregates/NotificationValidator.cs | 12 + .../Notification/Command/Domain/Domain.csproj | 9 + .../Domain/Entities/NotificationMethod.cs | 41 +++ .../Entities/NotificationMethodValidator.cs | 12 + .../Enumerations/NotificationMethodStatus.cs | 36 ++ .../Domain/Enumerations/NotificationStatus.cs | 28 ++ .../Command/Domain/ValueObject/Email.cs | 9 + .../Domain/ValueObject/INotificationOption.cs | 3 + .../Command/Domain/ValueObject/PushMobile.cs | 12 + .../Command/Domain/ValueObject/PushWeb.cs | 12 + .../Command/Domain/ValueObject/Sms.cs | 12 + .../Configurations/SnapshotConfiguration.cs | 35 ++ .../Configurations/StoreEventConfiguration.cs | 41 +++ .../Contexts/Converters/AggregateConverter.cs | 38 +++ .../Contexts/Converters/EventConverter.cs | 38 +++ .../Contexts/EventStoreDbContext.cs | 19 ++ .../Extensions/ServiceCollectionExtensions.cs | 55 +++ .../Options/EventStoreOptions.cs | 8 + .../Options/SqlServerRetryingOptions.cs | 10 + .../EventStoreGateway.cs | 44 +++ .../EventStoreRepository.cs | 43 +++ .../Exceptions/AggregateNotFoundException.cs | 5 + .../Infrastructure.EventStore.csproj | 14 + ...20230213214413_First Migration.Designer.cs | 88 +++++ .../20230213214413_First Migration.cs | 56 +++ ...0230213214427_Quartz Migration.Designer.cs | 88 +++++ .../20230213214427_Quartz Migration.cs | 319 ++++++++++++++++++ .../EventStoreDbContextModelSnapshot.cs | 85 +++++ .../Infrastructure.EventStore/UnitOfWork.cs | 20 ++ .../Extensions/ServiceCollectionExtensions.cs | 38 +++ .../DependencyInjection/LazyFactory.cs | 11 + .../Options/SmtpOptions.cs | 12 + .../Infrastructure.SMTP/EmailGateway.cs | 52 +++ .../Infrastructure.SMTP.csproj | 10 + .../Command/WorkerService/.dockerignore | 25 ++ .../Command/WorkerService/Dockerfile | 44 +++ .../Command/WorkerService/Program.cs | 93 +++++ .../Properties/launchSettings.json | 11 + .../WorkerService/WorkerService.csproj | 11 + .../appsettings.Development.json | 15 + .../WorkerService/appsettings.Production.json | 15 + .../WorkerService/appsettings.Staging.json | 15 + .../Command/WorkerService/appsettings.json | 57 ++++ .../Application/Abstractions/IInteractor.cs | 25 ++ .../Abstractions/IProjectionGateway.cs | 19 ++ .../Query/Application/Application.csproj | 5 + .../ServiceCollectionExtensions.cs | 23 ++ ...etailsWhenNotificationChangedInteractor.cs | 20 ++ .../ListNotificationsDetailsInteractor.cs | 12 + .../Query/GrpcService/.dockerignore | 25 ++ .../GrpcService/CommunicationGrpcService.cs | 28 ++ .../Notification/Query/GrpcService/Dockerfile | 42 +++ .../Query/GrpcService/GrpcService.csproj | 10 + .../Notification/Query/GrpcService/Program.cs | 74 ++++ .../Properties/launchSettings.json | 13 + .../GrpcService/appsettings.Development.json | 8 + .../GrpcService/appsettings.Production.json | 8 + .../GrpcService/appsettings.Staging.json | 8 + .../Query/GrpcService/appsettings.json | 40 +++ .../Abstractions/Consumer.cs | 12 + ...nDetailsWhenNotificationChangedConsumer.cs | 8 + .../Extensions/NameFormatterExtensions.cs | 16 + ...abbitMqBusFactoryConfiguratorExtensions.cs | 26 ++ .../Extensions/ServiceCollectionExtensions.cs | 81 +++++ .../Options/EventBusOptions.cs | 13 + .../Infrastructure.EventBus.csproj | 16 + .../PipeFilters/ContractValidatorFilter.cs | 36 ++ .../PipeObservers/LoggingConsumeObserver.cs | 24 ++ .../PipeObservers/LoggingReceiveObserver.cs | 51 +++ .../Abstractions/IMongoDbContext.cs | 8 + .../Abstractions/MongoDbContext.cs | 18 + .../ServiceCollectionExtensions.cs | 18 + .../Infrastructure.Projections.csproj | 9 + .../Pagination/PagedResult.cs | 30 ++ .../ProjectionDbContext.cs | 6 + .../ProjectionGateway.cs | 61 ++++ .../Abstractions/Gateways/IEventBusGateway.cs | 12 + .../Application/Abstractions/IInteractor.cs | 7 + .../Application/Abstractions/IUnitOfWork.cs | 6 + .../Extensions/ServiceCollectionExtensions.cs | 40 +++ .../Services/ApplicationService.cs | 106 ++++++ .../Services/IApplicationService.cs | 28 ++ .../Commands/AddBillingAddressInteractor.cs | 28 ++ .../Commands/AddCreditCardInteractor.cs | 25 ++ .../Commands/AddDebitCardInteractor.cs | 25 ++ .../Checkouts/Commands/AddPayPalInteractor.cs | 23 ++ .../Commands/AddShippingAddressInteractor.cs | 28 ++ .../Commands/AddCartItemInteractor.cs | 36 ++ .../ChangeCartItemQuantityInteractor.cs | 22 ++ .../Commands/CheckOutCartInteractor.cs | 16 + .../Commands/DiscardCartInteractor.cs | 16 + .../RebuildCartProjectionInteractor.cs | 15 + .../Commands/RemoveCartItemInteractor.cs | 17 + .../Commands/StartShoppingInteractor.cs | 21 ++ ...rojectionRebuiltWhenRequestedInteractor.cs | 23 ++ .../Abstractions/Aggregates/AggregateRoot.cs | 33 ++ .../Abstractions/Aggregates/IAggregateRoot.cs | 14 + .../Domain/Abstractions/DomainException.cs | 22 ++ .../Domain/Abstractions/Entities/Entity.cs | 20 ++ .../Domain/Abstractions/Entities/IEntity.cs | 8 + .../Abstractions/EventStore/Snapshot.cs | 13 + .../Abstractions/EventStore/StoreEvent.cs | 14 + .../Abstractions/Identities/GuidIdentity.cs | 29 ++ .../Domain/Aggregates/Checkouts/Checkout.cs | 82 +++++ .../Domain/Aggregates/Checkouts/CheckoutId.cs | 15 + .../Domain/Aggregates/ShoppingCarts/CartId.cs | 15 + .../Aggregates/ShoppingCarts/CatalogId.cs | 15 + .../Aggregates/ShoppingCarts/CustomerId.cs | 15 + .../Aggregates/ShoppingCarts/InventoryId.cs | 15 + .../Aggregates/ShoppingCarts/ShoppingCart.cs | 166 +++++++++ .../Shopping/Command/Domain/Domain.csproj | 10 + .../Domain/Entities/CartItems/CartItem.cs | 62 ++++ .../Domain/Entities/CartItems/CartItemId.cs | 19 ++ .../Command/Domain/Enumerations/CartStatus.cs | 31 ++ .../Domain/ValueObjects/Addresses/Address.cs | 21 ++ .../Domain/ValueObjects/Addresses/City.cs | 18 + .../ValueObjects/Addresses/Complement.cs | 17 + .../Domain/ValueObjects/Addresses/Country.cs | 18 + .../Domain/ValueObjects/Addresses/Number.cs | 17 + .../Domain/ValueObjects/Addresses/State.cs | 18 + .../Domain/ValueObjects/Addresses/Street.cs | 18 + .../Domain/ValueObjects/Addresses/ZipCode.cs | 18 + .../Command/Domain/ValueObjects/Currency.cs | 47 +++ .../Command/Domain/ValueObjects/Money.cs | 58 ++++ .../PaymentMethods/CardHolderName.cs | 21 ++ .../ValueObjects/PaymentMethods/CreditCard.cs | 12 + .../PaymentMethods/CreditCardNumber.cs | 25 ++ .../Domain/ValueObjects/PaymentMethods/Cvv.cs | 22 ++ .../ValueObjects/PaymentMethods/DebitCard.cs | 16 + .../PaymentMethods/ExpirationDate.cs | 39 +++ .../PaymentMethods/IPaymentMethod.cs | 8 + .../ValueObjects/PaymentMethods/PayPal.cs | 12 + .../GrpcService/appsettings.Production.json | 11 + .../GrpcService/appsettings.Staging.json | 11 + .../Command/GrpcService/appsettings.json | 58 ++++ .../Configurations/SnapshotConfiguration.cs | 47 +++ .../Contexts/Converters/AggregateConverter.cs | 43 +++ .../Contexts/Converters/EventConverter.cs | 40 +++ .../Contexts/EventStoreDbContext.cs | 12 + .../Extensions/ServiceCollectionExtensions.cs | 53 +++ .../Options/EventStoreOptions.cs | 9 + .../Options/SqlServerRetryingOptions.cs | 10 + .../EventStoreGateway.cs | 78 +++++ .../Infrastructure.EventStore.csproj | 14 + .../Infrastructure.EventStore/UnitOfWork.cs | 20 ++ .../Command/WorkerService/.dockerignore | 25 ++ .../Shopping/Command/WorkerService/Dockerfile | 42 +++ .../Shopping/Command/WorkerService/Program.cs | 38 +++ .../Properties/launchSettings.json | 11 + .../WorkerService/WorkerService.csproj | 10 + .../appsettings.Development.json | 11 + .../WorkerService/appsettings.Production.json | 11 + .../WorkerService/appsettings.Staging.json | 11 + .../Command/WorkerService/appsettings.json | 52 +++ .../Application/Abstractions/IInteractor.cs | 25 ++ .../Abstractions/IProjectionGateway.cs | 19 ++ .../ServiceCollectionExtensions.cs | 30 ++ ...ectCartDetailsWhenCartChangedInteractor.cs | 88 +++++ ...rtItemListItemWhenCartChangedInteractor.cs | 50 +++ ...MethodListItemWhenCartChangedInteractor.cs | 56 +++ ...etCustomerShoppingCartDetailsInteractor.cs | 11 + .../GetPaymentMethodDetailsInteractor.cs | 11 + .../GetShoppingCartDetailsInteractor.cs | 11 + .../GetShoppingCartItemDetailsInteractor.cs | 11 + .../ListPaymentMethodsListItemsInteractor.cs | 12 + ...istShoppingCartItemsListItemsInteractor.cs | 12 + .../Shopping/Query/GrpcService/.dockerignore | 25 ++ .../Shopping/Query/GrpcService/Dockerfile | 42 +++ .../Query/GrpcService/GrpcService.csproj | 10 + .../Shopping/Query/GrpcService/Program.cs | 82 +++++ .../Properties/launchSettings.json | 13 + .../GrpcService/ShoppingCartGrpcService.cs | 94 ++++++ .../GrpcService/appsettings.Development.json | 8 + .../GrpcService/appsettings.Production.json | 8 + .../GrpcService/appsettings.Staging.json | 8 + .../GrpcService/appsettings.Testing.json | 8 + .../Query/GrpcService/appsettings.json | 42 +++ .../Abstractions/Consumer.cs | 12 + .../ProjectCartDetailsWhenChangedConsumer.cs | 40 +++ ...CartItemListItemWhenCartChangedConsumer.cs | 29 ++ ...ntMethodListItemWhenCartChangedConsumer.cs | 25 ++ .../Extensions/NameFormatterExtensions.cs | 16 + ...abbitMqBusFactoryConfiguratorExtensions.cs | 46 +++ .../Extensions/ServiceCollectionExtensions.cs | 101 ++++++ .../Options/EventBusOptions.cs | 13 + .../Infrastructure.EventBus.csproj | 16 + .../PipeFilters/ContractValidatorFilter.cs | 36 ++ .../PipeObservers/LoggingConsumeObserver.cs | 24 ++ .../PipeObservers/LoggingReceiveObserver.cs | 51 +++ .../Abstractions/IMongoDbContext.cs | 8 + .../Abstractions/MongoDbContext.cs | 18 + .../ServiceCollectionExtensions.cs | 18 + .../Infrastructure.Projections.csproj | 9 + .../Pagination/PagedResult.cs | 30 ++ .../ProjectionDbContext.cs | 6 + .../ProjectionGateway.cs | 61 ++++ .../Abstractions/Gateways/IEventBusGateway.cs | 10 + .../Gateways/IEventStoreGateway.cs | 10 + .../Application/Abstractions/IInteractor.cs | 9 + .../Application/Abstractions/IUnitOfWork.cs | 6 + .../Command/Application/Application.csproj | 8 + .../Extensions/ServiceCollectionExtensions.cs | 26 ++ .../Services/ApplicationService.cs | 34 ++ .../Services/IApplicationService.cs | 13 + .../Commands/CreateInventoryInteractor.cs | 16 + .../DecreaseInventoryAdjustInteractor.cs | 16 + .../IncreaseInventoryAdjustInteractor.cs | 16 + .../ReceiveInventoryItemInteractor.cs | 16 + ...nventoryItemWhenCartItemAddedInteractor.cs | 26 ++ .../Abstractions/Aggregates/AggregateRoot.cs | 43 +++ .../Abstractions/Aggregates/IAggregateRoot.cs | 11 + .../Domain/Abstractions/Entities/Entity.cs | 16 + .../Domain/Abstractions/Entities/IEntity.cs | 7 + .../EventStore/IEventStoreRepository.cs | 12 + .../Abstractions/EventStore/Snapshot.cs | 9 + .../Abstractions/EventStore/StoreEvent.cs | 10 + .../Command/Domain/Aggregates/Inventory.cs | 118 +++++++ .../Domain/Aggregates/InventoryValidator.cs | 12 + .../Warehousing/Command/Domain/Domain.csproj | 10 + .../Adjustments/DecreaseAdjustment.cs | 7 + .../Entities/Adjustments/IAdjustment.cs | 7 + .../Adjustments/IncreaseAdjustment.cs | 7 + .../Entities/InventoryItems/InventoryItem.cs | 76 +++++ .../InventoryItems/InventoryItemValidator.cs | 5 + .../Command/Domain/Entities/Reserve.cs | 9 + .../Domain/ValueObjects/Products/Product.cs | 18 + .../ValueObjects/Products/ProductValidator.cs | 5 + .../Configurations/SnapshotConfiguration.cs | 35 ++ .../Configurations/StoreEventConfiguration.cs | 41 +++ .../Contexts/Converters/AggregateConverter.cs | 38 +++ .../Contexts/Converters/EventConverter.cs | 38 +++ .../Contexts/EventStoreDbContext.cs | 19 ++ .../Extensions/ServiceCollectionExtensions.cs | 55 +++ .../Options/EventStoreOptions.cs | 8 + .../Options/SqlServerRetryingOptions.cs | 10 + .../EventStoreGateway.cs | 44 +++ .../EventStoreRepository.cs | 43 +++ .../Exceptions/AggregateNotFoundException.cs | 5 + .../Infrastructure.EventStore.csproj | 14 + ...20230213214413_First Migration.Designer.cs | 88 +++++ .../20230213214413_First Migration.cs | 56 +++ ...0230213214427_Quartz Migration.Designer.cs | 88 +++++ .../20230213214427_Quartz Migration.cs | 319 ++++++++++++++++++ .../EventStoreDbContextModelSnapshot.cs | 85 +++++ .../Infrastructure.EventStore/UnitOfWork.cs | 20 ++ .../Command/WorkerService/.dockerignore | 25 ++ .../Command/WorkerService/Dockerfile | 42 +++ .../Command/WorkerService/Program.cs | 86 +++++ .../Properties/launchSettings.json | 11 + .../WorkerService/WorkerService.csproj | 10 + .../appsettings.Development.json | 11 + .../WorkerService/appsettings.Production.json | 11 + .../WorkerService/appsettings.Staging.json | 11 + .../Command/WorkerService/appsettings.json | 52 +++ .../Application/Abstractions/IInteractor.cs | 25 ++ .../Abstractions/IProjectionGateway.cs | 19 ++ .../Query/Application/Application.csproj | 5 + .../ServiceCollectionExtensions.cs | 25 ++ ...yGridItemWhenInventoryChangedInteractor.cs | 21 ++ ...tItemWhenInventoryItemChangedInteractor.cs | 52 +++ .../ListInventoriesGridItemInteractor.cs | 12 + .../ListInventoryItemsListItemsInteractor.cs | 12 + .../Query/GrpcService/.dockerignore | 25 ++ .../Warehousing/Query/GrpcService/Dockerfile | 42 +++ .../Query/GrpcService/GrpcService.csproj | 10 + .../Warehousing/Query/GrpcService/Program.cs | 74 ++++ .../Properties/launchSettings.json | 13 + .../Query/GrpcService/WarehouseGrpcService.cs | 45 +++ .../GrpcService/appsettings.Development.json | 8 + .../GrpcService/appsettings.Production.json | 8 + .../GrpcService/appsettings.Staging.json | 8 + .../Query/GrpcService/appsettings.json | 40 +++ .../Abstractions/Consumer.cs | 12 + ...oryGridItemWhenInventoryChangedConsumer.cs | 12 + ...istItemWhenInventoryItemChangedConsumer.cs | 25 ++ .../Extensions/NameFormatterExtensions.cs | 16 + ...abbitMqBusFactoryConfiguratorExtensions.cs | 31 ++ .../Extensions/ServiceCollectionExtensions.cs | 81 +++++ .../Options/EventBusOptions.cs | 13 + .../Infrastructure.EventBus.csproj | 16 + .../PipeFilters/ContractValidatorFilter.cs | 36 ++ .../PipeObservers/LoggingConsumeObserver.cs | 24 ++ .../PipeObservers/LoggingReceiveObserver.cs | 51 +++ .../Abstractions/IMongoDbContext.cs | 8 + .../Abstractions/MongoDbContext.cs | 18 + .../ServiceCollectionExtensions.cs | 18 + .../Infrastructure.Projections.csproj | 9 + .../Pagination/PagedResult.cs | 30 ++ .../ProjectionDbContext.cs | 6 + .../ProjectionGateway.cs | 61 ++++ .../Extensions/WebApplicationExtensions.cs | 28 ++ .../Options/GrpcClientOptions.cs | 19 ++ 506 files changed, 14064 insertions(+) create mode 100644 .assets/img/diagram.png create mode 100644 Directory.Packages.props create mode 100644 EventualShop.sln.DotSettings create mode 100644 src/Contracts/Abstractions/Messages/Event.cs create mode 100644 src/Contracts/Boundaries/Account/Account.proto create mode 100644 src/Contracts/Boundaries/Account/Command.cs create mode 100644 src/Contracts/Boundaries/Account/DomainEvent.cs create mode 100644 src/Contracts/Boundaries/Account/Projection.cs create mode 100644 src/Contracts/Boundaries/Account/Query.cs create mode 100644 src/Contracts/Boundaries/Cataloging/Catalog/CatalogingQuery.proto create mode 100644 src/Contracts/Boundaries/Cataloging/Catalog/DomainEvent.cs create mode 100644 src/Contracts/Boundaries/Cataloging/Catalog/Projection.cs create mode 100644 src/Contracts/Boundaries/Cataloging/Catalog/Query.cs create mode 100644 src/Contracts/Boundaries/Identity/Command.cs create mode 100644 src/Contracts/Boundaries/Identity/DelayedEvent.cs create mode 100644 src/Contracts/Boundaries/Identity/DomainEvent.cs create mode 100644 src/Contracts/Boundaries/Identity/Identity.proto create mode 100644 src/Contracts/Boundaries/Identity/Projection.cs create mode 100644 src/Contracts/Boundaries/Identity/Query.cs create mode 100644 src/Contracts/Boundaries/Identity/Validators/ConfirmEmailValidator.cs create mode 100644 src/Contracts/Boundaries/Identity/Validators/RegisterUserValidator.cs create mode 100644 src/Contracts/Boundaries/Notification/Command.cs create mode 100644 src/Contracts/Boundaries/Notification/DomainEvent.cs create mode 100644 src/Contracts/Boundaries/Notification/Notification.proto create mode 100644 src/Contracts/Boundaries/Notification/Projection.cs create mode 100644 src/Contracts/Boundaries/Notification/Query.cs create mode 100644 src/Contracts/Boundaries/Order/Command.cs create mode 100644 src/Contracts/Boundaries/Order/DomainEvent.cs create mode 100644 src/Contracts/Boundaries/Order/Order.proto create mode 100644 src/Contracts/Boundaries/Order/Projection.cs create mode 100644 src/Contracts/Boundaries/Order/Query.cs create mode 100644 src/Contracts/Boundaries/Payment/Command.cs create mode 100644 src/Contracts/Boundaries/Payment/DomainEvent.cs create mode 100644 src/Contracts/Boundaries/Payment/Payment.proto create mode 100644 src/Contracts/Boundaries/Payment/Projection.cs create mode 100644 src/Contracts/Boundaries/Payment/Query.cs create mode 100644 src/Contracts/Boundaries/Shopping/Checkout/Command.cs create mode 100644 src/Contracts/Boundaries/Shopping/Checkout/DomainEvent.cs create mode 100644 src/Contracts/Boundaries/Shopping/Checkout/NotificationEvent.cs create mode 100644 src/Contracts/Boundaries/Shopping/Checkout/SummaryEvent.cs create mode 100644 src/Contracts/Boundaries/Shopping/ShoppingCart/Command.cs create mode 100644 src/Contracts/Boundaries/Shopping/ShoppingCart/DomainEvent.cs create mode 100644 src/Contracts/Boundaries/Shopping/ShoppingCart/NotificationEvent.cs create mode 100644 src/Contracts/Boundaries/Shopping/ShoppingCart/Projection.cs create mode 100644 src/Contracts/Boundaries/Shopping/ShoppingCart/Query.cs create mode 100644 src/Contracts/Boundaries/Shopping/ShoppingCart/ShoppingCartQueries.proto create mode 100644 src/Contracts/Boundaries/Shopping/ShoppingCart/SummaryEvent.cs create mode 100644 src/Contracts/Boundaries/Warehouse/Command.cs create mode 100644 src/Contracts/Boundaries/Warehouse/DomainEvent.cs create mode 100644 src/Contracts/Boundaries/Warehouse/Projection.cs create mode 100644 src/Contracts/Boundaries/Warehouse/Query.cs create mode 100644 src/Contracts/Boundaries/Warehouse/Validators/CreateInventoryValidator.cs create mode 100644 src/Contracts/Boundaries/Warehouse/Validators/ReceiveInventoryItemValidator.cs create mode 100644 src/Contracts/Boundaries/Warehouse/Warehouse.proto create mode 100644 src/Services/Cataloging/Command/Application/Abstractions/IEventBusGateway.cs create mode 100644 src/Services/Cataloging/Command/Application/Abstractions/IEventStoreGateway.cs create mode 100644 src/Services/Cataloging/Command/Application/Abstractions/IUnitOfWork.cs create mode 100644 src/Services/Cataloging/Command/Application/Application.csproj create mode 100644 src/Services/Cataloging/Command/Application/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Services/Cataloging/Command/Application/Services/ApplicationService.cs create mode 100644 src/Services/Cataloging/Command/Application/Services/IApplicationService.cs create mode 100644 src/Services/Cataloging/Command/Application/UseCases/CatalogItems/Commands/CreateCatalogItemInteractor.cs create mode 100644 src/Services/Cataloging/Command/Application/UseCases/CatalogItems/Commands/RemoveCatalogItemInteractor.cs create mode 100644 src/Services/Cataloging/Command/Application/UseCases/Catalogs/Commands/ActivateCatalogInteractor.cs create mode 100644 src/Services/Cataloging/Command/Application/UseCases/Catalogs/Commands/ChangeCatalogDescriptionInteractor.cs create mode 100644 src/Services/Cataloging/Command/Application/UseCases/Catalogs/Commands/ChangeCatalogTitleInteractor.cs create mode 100644 src/Services/Cataloging/Command/Application/UseCases/Catalogs/Commands/CreateCatalogInteractor.cs create mode 100644 src/Services/Cataloging/Command/Application/UseCases/Catalogs/Commands/DeactivateCatalogInteractor.cs create mode 100644 src/Services/Cataloging/Command/Application/UseCases/Catalogs/Commands/DeleteCatalogInteractor.cs create mode 100644 src/Services/Cataloging/Command/Domain/Abstractions/Aggregates/AggregateRoot.cs create mode 100644 src/Services/Cataloging/Command/Domain/Abstractions/Aggregates/IAggregateRoot.cs create mode 100644 src/Services/Cataloging/Command/Domain/Abstractions/DomainException.cs create mode 100644 src/Services/Cataloging/Command/Domain/Abstractions/Entities/Entity.cs create mode 100644 src/Services/Cataloging/Command/Domain/Abstractions/Entities/IEntity.cs create mode 100644 src/Services/Cataloging/Command/Domain/Abstractions/EventStore/Snapshot.cs create mode 100644 src/Services/Cataloging/Command/Domain/Abstractions/EventStore/StoreEvent.cs create mode 100644 src/Services/Cataloging/Command/Domain/Abstractions/Identities/GuidIdentity.cs create mode 100644 src/Services/Cataloging/Command/Domain/Aggregates/AppId.cs create mode 100644 src/Services/Cataloging/Command/Domain/Aggregates/CatalogItems/CatalogItem.cs create mode 100644 src/Services/Cataloging/Command/Domain/Aggregates/CatalogItems/CatalogItemId.cs create mode 100644 src/Services/Cataloging/Command/Domain/Aggregates/Catalogs/Catalog.cs create mode 100644 src/Services/Cataloging/Command/Domain/Aggregates/Catalogs/CatalogId.cs create mode 100644 src/Services/Cataloging/Command/Domain/Aggregates/Pricing/PricingId.cs create mode 100644 src/Services/Cataloging/Command/Domain/Aggregates/Products/Product.cs create mode 100644 src/Services/Cataloging/Command/Domain/Aggregates/Products/ProductId.cs create mode 100644 src/Services/Cataloging/Command/Domain/Domain.csproj create mode 100644 src/Services/Cataloging/Command/Domain/Enumerations/CatalogStatus.cs create mode 100644 src/Services/Cataloging/Command/Domain/Exceptions.cs create mode 100644 src/Services/Cataloging/Command/Domain/ValueObjects/Amount.cs create mode 100644 src/Services/Cataloging/Command/Domain/ValueObjects/Brand.cs create mode 100644 src/Services/Cataloging/Command/Domain/ValueObjects/Category.cs create mode 100644 src/Services/Cataloging/Command/Domain/ValueObjects/Currency.cs create mode 100644 src/Services/Cataloging/Command/Domain/ValueObjects/Description.cs create mode 100644 src/Services/Cataloging/Command/Domain/ValueObjects/Money.cs create mode 100644 src/Services/Cataloging/Command/Domain/ValueObjects/PictureUri.cs create mode 100644 src/Services/Cataloging/Command/Domain/ValueObjects/Price.cs create mode 100644 src/Services/Cataloging/Command/Domain/ValueObjects/ProductName.cs create mode 100644 src/Services/Cataloging/Command/Domain/ValueObjects/Quantity.cs create mode 100644 src/Services/Cataloging/Command/Domain/ValueObjects/Sku.cs create mode 100644 src/Services/Cataloging/Command/Domain/ValueObjects/Title.cs create mode 100644 src/Services/Cataloging/Command/Domain/ValueObjects/Version.cs create mode 100644 src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/Configurations/SnapshotConfiguration.cs create mode 100644 src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/Configurations/StoreEventConfiguration.cs create mode 100644 src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/Converters/AggregateConverter.cs create mode 100644 src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/Converters/EventConverter.cs create mode 100644 src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/Converters/IdentifierConverter.cs create mode 100644 src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/Converters/VersionConverter.cs create mode 100644 src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/EventStoreDbContext.cs create mode 100644 src/Services/Cataloging/Command/Infrastructure.EventStore/DependencyInjection/Extensions/HostExtensions.cs create mode 100644 src/Services/Cataloging/Command/Infrastructure.EventStore/DependencyInjection/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/Services/Cataloging/Command/Infrastructure.EventStore/DependencyInjection/Options/EventStoreOptions.cs create mode 100644 src/Services/Cataloging/Command/Infrastructure.EventStore/DependencyInjection/Options/SqlServerRetryingOptions.cs create mode 100644 src/Services/Cataloging/Command/Infrastructure.EventStore/EventStoreGateway.cs create mode 100644 src/Services/Cataloging/Command/Infrastructure.EventStore/Infrastructure.EventStore.csproj create mode 100644 src/Services/Cataloging/Command/Infrastructure.EventStore/UnitOfWork.cs create mode 100644 src/Services/Cataloging/Command/WorkerService/.dockerignore create mode 100644 src/Services/Cataloging/Command/WorkerService/Dockerfile create mode 100644 src/Services/Cataloging/Command/WorkerService/Program.cs create mode 100644 src/Services/Cataloging/Command/WorkerService/Properties/launchSettings.json create mode 100644 src/Services/Cataloging/Command/WorkerService/WorkerService.csproj create mode 100644 src/Services/Cataloging/Command/WorkerService/appsettings.Development.json create mode 100644 src/Services/Cataloging/Command/WorkerService/appsettings.Production.json create mode 100644 src/Services/Cataloging/Command/WorkerService/appsettings.Staging.json create mode 100644 src/Services/Cataloging/Command/WorkerService/appsettings.json create mode 100644 src/Services/Cataloging/Query/Application/Abstractions/IInteractor.cs create mode 100644 src/Services/Cataloging/Query/Application/Abstractions/IProjectionGateway.cs create mode 100644 src/Services/Cataloging/Query/Application/Application.csproj create mode 100644 src/Services/Cataloging/Query/Application/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Services/Cataloging/Query/Application/UseCases/Events/ProjectCatalogGridItemWhenCatalogChangedInteractor.cs create mode 100644 src/Services/Cataloging/Query/Application/UseCases/Events/ProjectCatalogItemCardWhenCatalogChangedInteractor.cs create mode 100644 src/Services/Cataloging/Query/Application/UseCases/Events/ProjectCatalogItemDetailsWhenCatalogChangedInteractor.cs create mode 100644 src/Services/Cataloging/Query/Application/UseCases/Events/ProjectCatalogItemListItemWhenCatalogChangedInteractor.cs create mode 100644 src/Services/Cataloging/Query/Application/UseCases/Queries/GetCatalogItemDetailsInteractor.cs create mode 100644 src/Services/Cataloging/Query/Application/UseCases/Queries/ListCatalogItemsCardsInteractor.cs create mode 100644 src/Services/Cataloging/Query/Application/UseCases/Queries/ListCatalogItemsListItemsInteractor.cs create mode 100644 src/Services/Cataloging/Query/Application/UseCases/Queries/ListCatalogsGridItemsInteractor.cs create mode 100644 src/Services/Cataloging/Query/GrpcService/.dockerignore create mode 100644 src/Services/Cataloging/Query/GrpcService/CatalogGrpcService.cs create mode 100644 src/Services/Cataloging/Query/GrpcService/Dockerfile create mode 100644 src/Services/Cataloging/Query/GrpcService/GrpcService.csproj create mode 100644 src/Services/Cataloging/Query/GrpcService/Program.cs create mode 100644 src/Services/Cataloging/Query/GrpcService/Properties/launchSettings.json create mode 100644 src/Services/Cataloging/Query/GrpcService/appsettings.Development.json create mode 100644 src/Services/Cataloging/Query/GrpcService/appsettings.Production.json create mode 100644 src/Services/Cataloging/Query/GrpcService/appsettings.Staging.json create mode 100644 src/Services/Cataloging/Query/GrpcService/appsettings.json create mode 100644 src/Services/Cataloging/Query/Infrastructure.EventBus/Abstractions/Consumer.cs create mode 100644 src/Services/Cataloging/Query/Infrastructure.EventBus/Consumers/Events/ProjectCatalogGridItemWhenCatalogChangedConsumer.cs create mode 100644 src/Services/Cataloging/Query/Infrastructure.EventBus/Consumers/Events/ProjectCatalogItemCardWhenCatalogChangedConsumer.cs create mode 100644 src/Services/Cataloging/Query/Infrastructure.EventBus/Consumers/Events/ProjectCatalogItemDetailsWhenCatalogChangedConsumer.cs create mode 100644 src/Services/Cataloging/Query/Infrastructure.EventBus/Consumers/Events/ProjectCatalogItemListItemWhenCatalogChangedConsumer.cs create mode 100644 src/Services/Cataloging/Query/Infrastructure.EventBus/DependencyInjection/Extensions/NameFormatterExtensions.cs create mode 100644 src/Services/Cataloging/Query/Infrastructure.EventBus/DependencyInjection/Extensions/RabbitMqBusFactoryConfiguratorExtensions.cs create mode 100644 src/Services/Cataloging/Query/Infrastructure.EventBus/DependencyInjection/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/Services/Cataloging/Query/Infrastructure.EventBus/DependencyInjection/Options/EventBusOptions.cs create mode 100644 src/Services/Cataloging/Query/Infrastructure.EventBus/Infrastructure.EventBus.csproj create mode 100644 src/Services/Cataloging/Query/Infrastructure.EventBus/PipeFilters/ContractValidatorFilter.cs create mode 100644 src/Services/Cataloging/Query/Infrastructure.EventBus/PipeObservers/LoggingConsumeObserver.cs create mode 100644 src/Services/Cataloging/Query/Infrastructure.EventBus/PipeObservers/LoggingReceiveObserver.cs create mode 100644 src/Services/Cataloging/Query/Infrastructure.Projections/Abstractions/IMongoDbContext.cs create mode 100644 src/Services/Cataloging/Query/Infrastructure.Projections/Abstractions/MongoDbContext.cs create mode 100644 src/Services/Cataloging/Query/Infrastructure.Projections/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Services/Cataloging/Query/Infrastructure.Projections/Infrastructure.Projections.csproj create mode 100644 src/Services/Cataloging/Query/Infrastructure.Projections/Pagination/PagedResult.cs create mode 100644 src/Services/Cataloging/Query/Infrastructure.Projections/ProjectionDbContext.cs create mode 100644 src/Services/Cataloging/Query/Infrastructure.Projections/ProjectionGateway.cs create mode 100644 src/Services/Identity/Command/Infrastructure.EventBus/Abstractions/Consumer.cs create mode 100644 src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Commands/ChangeEmailConsumer.cs create mode 100644 src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Commands/ChangePasswordConsumer.cs create mode 100644 src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Commands/ConfirmEmailConsumer.cs create mode 100644 src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Commands/RegisterUserConsumer.cs create mode 100644 src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Events/AccountDeactivatedConsumer.cs create mode 100644 src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Events/AccountDeletedConsumer.cs create mode 100644 src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Events/EmailChangedConsumer.cs create mode 100644 src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Events/EmailConfirmationExpiredConsumer.cs create mode 100644 src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Events/EmailConfirmedConsumer.cs create mode 100644 src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Events/UserRegisteredConsumer.cs create mode 100644 src/Services/Identity/Command/Infrastructure.EventBus/DependencyInjection/Extensions/NameFormatterExtensions.cs create mode 100644 src/Services/Identity/Command/Infrastructure.EventBus/DependencyInjection/Extensions/RabbitMqBusFactoryConfiguratorExtensions.cs create mode 100644 src/Services/Identity/Command/Infrastructure.EventBus/DependencyInjection/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/Services/Identity/Command/Infrastructure.EventBus/DependencyInjection/Options/MessageBusOptions.cs create mode 100644 src/Services/Identity/Command/Infrastructure.EventBus/EventBusGateway.cs create mode 100644 src/Services/Identity/Command/Infrastructure.EventBus/Infrastructure.EventBus.csproj create mode 100644 src/Services/Identity/Command/Infrastructure.EventBus/PipeFilters/BusinessValidatorFilter.cs create mode 100644 src/Services/Identity/Command/Infrastructure.EventBus/PipeFilters/ContractValidatorFilter.cs create mode 100644 src/Services/Identity/Command/Infrastructure.EventBus/PipeObservers/LoggingConsumeObserver.cs create mode 100644 src/Services/Identity/Command/Infrastructure.EventBus/PipeObservers/LoggingPublishObserver.cs create mode 100644 src/Services/Identity/Command/Infrastructure.EventBus/PipeObservers/LoggingReceiveObserver.cs create mode 100644 src/Services/Identity/Command/Infrastructure.EventBus/PipeObservers/LoggingSendObserver.cs create mode 100644 src/Services/Notification/Command/Application/Abstractions/Gateways/IEmailGateway.cs create mode 100644 src/Services/Notification/Command/Application/Abstractions/Gateways/IEventBusGateway.cs create mode 100644 src/Services/Notification/Command/Application/Abstractions/Gateways/IEventStoreGateway.cs create mode 100644 src/Services/Notification/Command/Application/Abstractions/Gateways/INotificationGateway.cs create mode 100644 src/Services/Notification/Command/Application/Abstractions/Gateways/INotificationService.cs create mode 100644 src/Services/Notification/Command/Application/Abstractions/Gateways/NotificationService.cs create mode 100644 src/Services/Notification/Command/Application/Abstractions/IInteractor.cs create mode 100644 src/Services/Notification/Command/Application/Abstractions/ILazy.cs create mode 100644 src/Services/Notification/Command/Application/Abstractions/IUnitOfWork.cs create mode 100644 src/Services/Notification/Command/Application/Application.csproj create mode 100644 src/Services/Notification/Command/Application/DependencyInjection/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/Services/Notification/Command/Application/Resources/EmailResource.Designer.cs create mode 100644 src/Services/Notification/Command/Application/Resources/EmailResource.resx create mode 100644 src/Services/Notification/Command/Application/Services/ApplicationService.cs create mode 100644 src/Services/Notification/Command/Application/Services/IApplicationService.cs create mode 100644 src/Services/Notification/Command/Application/UseCases/Events/RequestNotificationWhenUserRegisteredInteractor.cs create mode 100644 src/Services/Notification/Command/Application/UseCases/Events/SendNotificationWhenNotificationRequestedInteractor.cs create mode 100644 src/Services/Notification/Command/Domain/Abstractions/Aggregates/AggregateRoot.cs create mode 100644 src/Services/Notification/Command/Domain/Abstractions/Aggregates/IAggregateRoot.cs create mode 100644 src/Services/Notification/Command/Domain/Abstractions/Entities/Entity.cs create mode 100644 src/Services/Notification/Command/Domain/Abstractions/Entities/IEntity.cs create mode 100644 src/Services/Notification/Command/Domain/Abstractions/EventStore/IEventStoreRepository.cs create mode 100644 src/Services/Notification/Command/Domain/Abstractions/EventStore/Snapshot.cs create mode 100644 src/Services/Notification/Command/Domain/Abstractions/EventStore/StoreEvent.cs create mode 100644 src/Services/Notification/Command/Domain/Aggregates/Notification.cs create mode 100644 src/Services/Notification/Command/Domain/Aggregates/NotificationValidator.cs create mode 100644 src/Services/Notification/Command/Domain/Domain.csproj create mode 100644 src/Services/Notification/Command/Domain/Entities/NotificationMethod.cs create mode 100644 src/Services/Notification/Command/Domain/Entities/NotificationMethodValidator.cs create mode 100644 src/Services/Notification/Command/Domain/Enumerations/NotificationMethodStatus.cs create mode 100644 src/Services/Notification/Command/Domain/Enumerations/NotificationStatus.cs create mode 100644 src/Services/Notification/Command/Domain/ValueObject/Email.cs create mode 100644 src/Services/Notification/Command/Domain/ValueObject/INotificationOption.cs create mode 100644 src/Services/Notification/Command/Domain/ValueObject/PushMobile.cs create mode 100644 src/Services/Notification/Command/Domain/ValueObject/PushWeb.cs create mode 100644 src/Services/Notification/Command/Domain/ValueObject/Sms.cs create mode 100644 src/Services/Notification/Command/Infrastructure.EventStore/Contexts/Configurations/SnapshotConfiguration.cs create mode 100644 src/Services/Notification/Command/Infrastructure.EventStore/Contexts/Configurations/StoreEventConfiguration.cs create mode 100644 src/Services/Notification/Command/Infrastructure.EventStore/Contexts/Converters/AggregateConverter.cs create mode 100644 src/Services/Notification/Command/Infrastructure.EventStore/Contexts/Converters/EventConverter.cs create mode 100644 src/Services/Notification/Command/Infrastructure.EventStore/Contexts/EventStoreDbContext.cs create mode 100644 src/Services/Notification/Command/Infrastructure.EventStore/DependencyInjection/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/Services/Notification/Command/Infrastructure.EventStore/DependencyInjection/Options/EventStoreOptions.cs create mode 100644 src/Services/Notification/Command/Infrastructure.EventStore/DependencyInjection/Options/SqlServerRetryingOptions.cs create mode 100644 src/Services/Notification/Command/Infrastructure.EventStore/EventStoreGateway.cs create mode 100644 src/Services/Notification/Command/Infrastructure.EventStore/EventStoreRepository.cs create mode 100644 src/Services/Notification/Command/Infrastructure.EventStore/Exceptions/AggregateNotFoundException.cs create mode 100644 src/Services/Notification/Command/Infrastructure.EventStore/Infrastructure.EventStore.csproj create mode 100644 src/Services/Notification/Command/Infrastructure.EventStore/Migrations/20230213214413_First Migration.Designer.cs create mode 100644 src/Services/Notification/Command/Infrastructure.EventStore/Migrations/20230213214413_First Migration.cs create mode 100644 src/Services/Notification/Command/Infrastructure.EventStore/Migrations/20230213214427_Quartz Migration.Designer.cs create mode 100644 src/Services/Notification/Command/Infrastructure.EventStore/Migrations/20230213214427_Quartz Migration.cs create mode 100644 src/Services/Notification/Command/Infrastructure.EventStore/Migrations/EventStoreDbContextModelSnapshot.cs create mode 100644 src/Services/Notification/Command/Infrastructure.EventStore/UnitOfWork.cs create mode 100644 src/Services/Notification/Command/Infrastructure.SMTP/DependencyInjection/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/Services/Notification/Command/Infrastructure.SMTP/DependencyInjection/LazyFactory.cs create mode 100644 src/Services/Notification/Command/Infrastructure.SMTP/DependencyInjection/Options/SmtpOptions.cs create mode 100644 src/Services/Notification/Command/Infrastructure.SMTP/EmailGateway.cs create mode 100644 src/Services/Notification/Command/Infrastructure.SMTP/Infrastructure.SMTP.csproj create mode 100644 src/Services/Notification/Command/WorkerService/.dockerignore create mode 100644 src/Services/Notification/Command/WorkerService/Dockerfile create mode 100644 src/Services/Notification/Command/WorkerService/Program.cs create mode 100644 src/Services/Notification/Command/WorkerService/Properties/launchSettings.json create mode 100644 src/Services/Notification/Command/WorkerService/WorkerService.csproj create mode 100644 src/Services/Notification/Command/WorkerService/appsettings.Development.json create mode 100644 src/Services/Notification/Command/WorkerService/appsettings.Production.json create mode 100644 src/Services/Notification/Command/WorkerService/appsettings.Staging.json create mode 100644 src/Services/Notification/Command/WorkerService/appsettings.json create mode 100644 src/Services/Notification/Query/Application/Abstractions/IInteractor.cs create mode 100644 src/Services/Notification/Query/Application/Abstractions/IProjectionGateway.cs create mode 100644 src/Services/Notification/Query/Application/Application.csproj create mode 100644 src/Services/Notification/Query/Application/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Services/Notification/Query/Application/UseCases/Events/ProjectNotificationDetailsWhenNotificationChangedInteractor.cs create mode 100644 src/Services/Notification/Query/Application/UseCases/Queries/ListNotificationsDetailsInteractor.cs create mode 100644 src/Services/Notification/Query/GrpcService/.dockerignore create mode 100644 src/Services/Notification/Query/GrpcService/CommunicationGrpcService.cs create mode 100644 src/Services/Notification/Query/GrpcService/Dockerfile create mode 100644 src/Services/Notification/Query/GrpcService/GrpcService.csproj create mode 100644 src/Services/Notification/Query/GrpcService/Program.cs create mode 100644 src/Services/Notification/Query/GrpcService/Properties/launchSettings.json create mode 100644 src/Services/Notification/Query/GrpcService/appsettings.Development.json create mode 100644 src/Services/Notification/Query/GrpcService/appsettings.Production.json create mode 100644 src/Services/Notification/Query/GrpcService/appsettings.Staging.json create mode 100644 src/Services/Notification/Query/GrpcService/appsettings.json create mode 100644 src/Services/Notification/Query/Infrastructure.EventBus/Abstractions/Consumer.cs create mode 100644 src/Services/Notification/Query/Infrastructure.EventBus/Consumers/Events/ProjectNotificationDetailsWhenNotificationChangedConsumer.cs create mode 100644 src/Services/Notification/Query/Infrastructure.EventBus/DependencyInjection/Extensions/NameFormatterExtensions.cs create mode 100644 src/Services/Notification/Query/Infrastructure.EventBus/DependencyInjection/Extensions/RabbitMqBusFactoryConfiguratorExtensions.cs create mode 100644 src/Services/Notification/Query/Infrastructure.EventBus/DependencyInjection/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/Services/Notification/Query/Infrastructure.EventBus/DependencyInjection/Options/EventBusOptions.cs create mode 100644 src/Services/Notification/Query/Infrastructure.EventBus/Infrastructure.EventBus.csproj create mode 100644 src/Services/Notification/Query/Infrastructure.EventBus/PipeFilters/ContractValidatorFilter.cs create mode 100644 src/Services/Notification/Query/Infrastructure.EventBus/PipeObservers/LoggingConsumeObserver.cs create mode 100644 src/Services/Notification/Query/Infrastructure.EventBus/PipeObservers/LoggingReceiveObserver.cs create mode 100644 src/Services/Notification/Query/Infrastructure.Projections/Abstractions/IMongoDbContext.cs create mode 100644 src/Services/Notification/Query/Infrastructure.Projections/Abstractions/MongoDbContext.cs create mode 100644 src/Services/Notification/Query/Infrastructure.Projections/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Services/Notification/Query/Infrastructure.Projections/Infrastructure.Projections.csproj create mode 100644 src/Services/Notification/Query/Infrastructure.Projections/Pagination/PagedResult.cs create mode 100644 src/Services/Notification/Query/Infrastructure.Projections/ProjectionDbContext.cs create mode 100644 src/Services/Notification/Query/Infrastructure.Projections/ProjectionGateway.cs create mode 100644 src/Services/Shopping/Command/Application/Abstractions/Gateways/IEventBusGateway.cs create mode 100644 src/Services/Shopping/Command/Application/Abstractions/IInteractor.cs create mode 100644 src/Services/Shopping/Command/Application/Abstractions/IUnitOfWork.cs create mode 100644 src/Services/Shopping/Command/Application/DependencyInjection/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/Services/Shopping/Command/Application/Services/ApplicationService.cs create mode 100644 src/Services/Shopping/Command/Application/Services/IApplicationService.cs create mode 100644 src/Services/Shopping/Command/Application/UseCases/Checkouts/Commands/AddBillingAddressInteractor.cs create mode 100644 src/Services/Shopping/Command/Application/UseCases/Checkouts/Commands/AddCreditCardInteractor.cs create mode 100644 src/Services/Shopping/Command/Application/UseCases/Checkouts/Commands/AddDebitCardInteractor.cs create mode 100644 src/Services/Shopping/Command/Application/UseCases/Checkouts/Commands/AddPayPalInteractor.cs create mode 100644 src/Services/Shopping/Command/Application/UseCases/Checkouts/Commands/AddShippingAddressInteractor.cs create mode 100644 src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/AddCartItemInteractor.cs create mode 100644 src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/ChangeCartItemQuantityInteractor.cs create mode 100644 src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/CheckOutCartInteractor.cs create mode 100644 src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/DiscardCartInteractor.cs create mode 100644 src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/RebuildCartProjectionInteractor.cs create mode 100644 src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/RemoveCartItemInteractor.cs create mode 100644 src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/StartShoppingInteractor.cs create mode 100644 src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Events/PublishProjectionRebuiltWhenRequestedInteractor.cs create mode 100644 src/Services/Shopping/Command/Domain/Abstractions/Aggregates/AggregateRoot.cs create mode 100644 src/Services/Shopping/Command/Domain/Abstractions/Aggregates/IAggregateRoot.cs create mode 100644 src/Services/Shopping/Command/Domain/Abstractions/DomainException.cs create mode 100644 src/Services/Shopping/Command/Domain/Abstractions/Entities/Entity.cs create mode 100644 src/Services/Shopping/Command/Domain/Abstractions/Entities/IEntity.cs create mode 100644 src/Services/Shopping/Command/Domain/Abstractions/EventStore/Snapshot.cs create mode 100644 src/Services/Shopping/Command/Domain/Abstractions/EventStore/StoreEvent.cs create mode 100644 src/Services/Shopping/Command/Domain/Abstractions/Identities/GuidIdentity.cs create mode 100644 src/Services/Shopping/Command/Domain/Aggregates/Checkouts/Checkout.cs create mode 100644 src/Services/Shopping/Command/Domain/Aggregates/Checkouts/CheckoutId.cs create mode 100644 src/Services/Shopping/Command/Domain/Aggregates/ShoppingCarts/CartId.cs create mode 100644 src/Services/Shopping/Command/Domain/Aggregates/ShoppingCarts/CatalogId.cs create mode 100644 src/Services/Shopping/Command/Domain/Aggregates/ShoppingCarts/CustomerId.cs create mode 100644 src/Services/Shopping/Command/Domain/Aggregates/ShoppingCarts/InventoryId.cs create mode 100644 src/Services/Shopping/Command/Domain/Aggregates/ShoppingCarts/ShoppingCart.cs create mode 100644 src/Services/Shopping/Command/Domain/Domain.csproj create mode 100644 src/Services/Shopping/Command/Domain/Entities/CartItems/CartItem.cs create mode 100644 src/Services/Shopping/Command/Domain/Entities/CartItems/CartItemId.cs create mode 100644 src/Services/Shopping/Command/Domain/Enumerations/CartStatus.cs create mode 100644 src/Services/Shopping/Command/Domain/ValueObjects/Addresses/Address.cs create mode 100644 src/Services/Shopping/Command/Domain/ValueObjects/Addresses/City.cs create mode 100644 src/Services/Shopping/Command/Domain/ValueObjects/Addresses/Complement.cs create mode 100644 src/Services/Shopping/Command/Domain/ValueObjects/Addresses/Country.cs create mode 100644 src/Services/Shopping/Command/Domain/ValueObjects/Addresses/Number.cs create mode 100644 src/Services/Shopping/Command/Domain/ValueObjects/Addresses/State.cs create mode 100644 src/Services/Shopping/Command/Domain/ValueObjects/Addresses/Street.cs create mode 100644 src/Services/Shopping/Command/Domain/ValueObjects/Addresses/ZipCode.cs create mode 100644 src/Services/Shopping/Command/Domain/ValueObjects/Currency.cs create mode 100644 src/Services/Shopping/Command/Domain/ValueObjects/Money.cs create mode 100644 src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/CardHolderName.cs create mode 100644 src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/CreditCard.cs create mode 100644 src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/CreditCardNumber.cs create mode 100644 src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/Cvv.cs create mode 100644 src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/DebitCard.cs create mode 100644 src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/ExpirationDate.cs create mode 100644 src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/IPaymentMethod.cs create mode 100644 src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/PayPal.cs create mode 100644 src/Services/Shopping/Command/GrpcService/appsettings.Production.json create mode 100644 src/Services/Shopping/Command/GrpcService/appsettings.Staging.json create mode 100644 src/Services/Shopping/Command/GrpcService/appsettings.json create mode 100644 src/Services/Shopping/Command/Infrastructure.EventStore/Contexts/Configurations/SnapshotConfiguration.cs create mode 100644 src/Services/Shopping/Command/Infrastructure.EventStore/Contexts/Converters/AggregateConverter.cs create mode 100644 src/Services/Shopping/Command/Infrastructure.EventStore/Contexts/Converters/EventConverter.cs create mode 100644 src/Services/Shopping/Command/Infrastructure.EventStore/Contexts/EventStoreDbContext.cs create mode 100644 src/Services/Shopping/Command/Infrastructure.EventStore/DependencyInjection/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/Services/Shopping/Command/Infrastructure.EventStore/DependencyInjection/Options/EventStoreOptions.cs create mode 100644 src/Services/Shopping/Command/Infrastructure.EventStore/DependencyInjection/Options/SqlServerRetryingOptions.cs create mode 100644 src/Services/Shopping/Command/Infrastructure.EventStore/EventStoreGateway.cs create mode 100644 src/Services/Shopping/Command/Infrastructure.EventStore/Infrastructure.EventStore.csproj create mode 100644 src/Services/Shopping/Command/Infrastructure.EventStore/UnitOfWork.cs create mode 100644 src/Services/Shopping/Command/WorkerService/.dockerignore create mode 100644 src/Services/Shopping/Command/WorkerService/Dockerfile create mode 100644 src/Services/Shopping/Command/WorkerService/Program.cs create mode 100644 src/Services/Shopping/Command/WorkerService/Properties/launchSettings.json create mode 100644 src/Services/Shopping/Command/WorkerService/WorkerService.csproj create mode 100644 src/Services/Shopping/Command/WorkerService/appsettings.Development.json create mode 100644 src/Services/Shopping/Command/WorkerService/appsettings.Production.json create mode 100644 src/Services/Shopping/Command/WorkerService/appsettings.Staging.json create mode 100644 src/Services/Shopping/Command/WorkerService/appsettings.json create mode 100644 src/Services/Shopping/Query/Application/Abstractions/IInteractor.cs create mode 100644 src/Services/Shopping/Query/Application/Abstractions/IProjectionGateway.cs create mode 100644 src/Services/Shopping/Query/Application/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Services/Shopping/Query/Application/UseCases/Events/ProjectCartDetailsWhenCartChangedInteractor.cs create mode 100644 src/Services/Shopping/Query/Application/UseCases/Events/ProjectCartItemListItemWhenCartChangedInteractor.cs create mode 100644 src/Services/Shopping/Query/Application/UseCases/Events/ProjectPaymentMethodListItemWhenCartChangedInteractor.cs create mode 100644 src/Services/Shopping/Query/Application/UseCases/Queries/GetCustomerShoppingCartDetailsInteractor.cs create mode 100644 src/Services/Shopping/Query/Application/UseCases/Queries/GetPaymentMethodDetailsInteractor.cs create mode 100644 src/Services/Shopping/Query/Application/UseCases/Queries/GetShoppingCartDetailsInteractor.cs create mode 100644 src/Services/Shopping/Query/Application/UseCases/Queries/GetShoppingCartItemDetailsInteractor.cs create mode 100644 src/Services/Shopping/Query/Application/UseCases/Queries/ListPaymentMethodsListItemsInteractor.cs create mode 100644 src/Services/Shopping/Query/Application/UseCases/Queries/ListShoppingCartItemsListItemsInteractor.cs create mode 100644 src/Services/Shopping/Query/GrpcService/.dockerignore create mode 100644 src/Services/Shopping/Query/GrpcService/Dockerfile create mode 100644 src/Services/Shopping/Query/GrpcService/GrpcService.csproj create mode 100644 src/Services/Shopping/Query/GrpcService/Program.cs create mode 100644 src/Services/Shopping/Query/GrpcService/Properties/launchSettings.json create mode 100644 src/Services/Shopping/Query/GrpcService/ShoppingCartGrpcService.cs create mode 100644 src/Services/Shopping/Query/GrpcService/appsettings.Development.json create mode 100644 src/Services/Shopping/Query/GrpcService/appsettings.Production.json create mode 100644 src/Services/Shopping/Query/GrpcService/appsettings.Staging.json create mode 100644 src/Services/Shopping/Query/GrpcService/appsettings.Testing.json create mode 100644 src/Services/Shopping/Query/GrpcService/appsettings.json create mode 100644 src/Services/Shopping/Query/Infrastructure.EventBus/Abstractions/Consumer.cs create mode 100644 src/Services/Shopping/Query/Infrastructure.EventBus/Consumers/Events/ProjectCartDetailsWhenChangedConsumer.cs create mode 100644 src/Services/Shopping/Query/Infrastructure.EventBus/Consumers/Events/ProjectCartItemListItemWhenCartChangedConsumer.cs create mode 100644 src/Services/Shopping/Query/Infrastructure.EventBus/Consumers/Events/ProjectPaymentMethodListItemWhenCartChangedConsumer.cs create mode 100644 src/Services/Shopping/Query/Infrastructure.EventBus/DependencyInjection/Extensions/NameFormatterExtensions.cs create mode 100644 src/Services/Shopping/Query/Infrastructure.EventBus/DependencyInjection/Extensions/RabbitMqBusFactoryConfiguratorExtensions.cs create mode 100644 src/Services/Shopping/Query/Infrastructure.EventBus/DependencyInjection/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/Services/Shopping/Query/Infrastructure.EventBus/DependencyInjection/Options/EventBusOptions.cs create mode 100644 src/Services/Shopping/Query/Infrastructure.EventBus/Infrastructure.EventBus.csproj create mode 100644 src/Services/Shopping/Query/Infrastructure.EventBus/PipeFilters/ContractValidatorFilter.cs create mode 100644 src/Services/Shopping/Query/Infrastructure.EventBus/PipeObservers/LoggingConsumeObserver.cs create mode 100644 src/Services/Shopping/Query/Infrastructure.EventBus/PipeObservers/LoggingReceiveObserver.cs create mode 100644 src/Services/Shopping/Query/Infrastructure.Projections/Abstractions/IMongoDbContext.cs create mode 100644 src/Services/Shopping/Query/Infrastructure.Projections/Abstractions/MongoDbContext.cs create mode 100644 src/Services/Shopping/Query/Infrastructure.Projections/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Services/Shopping/Query/Infrastructure.Projections/Infrastructure.Projections.csproj create mode 100644 src/Services/Shopping/Query/Infrastructure.Projections/Pagination/PagedResult.cs create mode 100644 src/Services/Shopping/Query/Infrastructure.Projections/ProjectionDbContext.cs create mode 100644 src/Services/Shopping/Query/Infrastructure.Projections/ProjectionGateway.cs create mode 100644 src/Services/Warehousing/Command/Application/Abstractions/Gateways/IEventBusGateway.cs create mode 100644 src/Services/Warehousing/Command/Application/Abstractions/Gateways/IEventStoreGateway.cs create mode 100644 src/Services/Warehousing/Command/Application/Abstractions/IInteractor.cs create mode 100644 src/Services/Warehousing/Command/Application/Abstractions/IUnitOfWork.cs create mode 100644 src/Services/Warehousing/Command/Application/Application.csproj create mode 100644 src/Services/Warehousing/Command/Application/DependencyInjection/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/Services/Warehousing/Command/Application/Services/ApplicationService.cs create mode 100644 src/Services/Warehousing/Command/Application/Services/IApplicationService.cs create mode 100644 src/Services/Warehousing/Command/Application/UseCases/Commands/CreateInventoryInteractor.cs create mode 100644 src/Services/Warehousing/Command/Application/UseCases/Commands/DecreaseInventoryAdjustInteractor.cs create mode 100644 src/Services/Warehousing/Command/Application/UseCases/Commands/IncreaseInventoryAdjustInteractor.cs create mode 100644 src/Services/Warehousing/Command/Application/UseCases/Commands/ReceiveInventoryItemInteractor.cs create mode 100644 src/Services/Warehousing/Command/Application/UseCases/Events/ReserveInventoryItemWhenCartItemAddedInteractor.cs create mode 100644 src/Services/Warehousing/Command/Domain/Abstractions/Aggregates/AggregateRoot.cs create mode 100644 src/Services/Warehousing/Command/Domain/Abstractions/Aggregates/IAggregateRoot.cs create mode 100644 src/Services/Warehousing/Command/Domain/Abstractions/Entities/Entity.cs create mode 100644 src/Services/Warehousing/Command/Domain/Abstractions/Entities/IEntity.cs create mode 100644 src/Services/Warehousing/Command/Domain/Abstractions/EventStore/IEventStoreRepository.cs create mode 100644 src/Services/Warehousing/Command/Domain/Abstractions/EventStore/Snapshot.cs create mode 100644 src/Services/Warehousing/Command/Domain/Abstractions/EventStore/StoreEvent.cs create mode 100644 src/Services/Warehousing/Command/Domain/Aggregates/Inventory.cs create mode 100644 src/Services/Warehousing/Command/Domain/Aggregates/InventoryValidator.cs create mode 100644 src/Services/Warehousing/Command/Domain/Domain.csproj create mode 100644 src/Services/Warehousing/Command/Domain/Entities/Adjustments/DecreaseAdjustment.cs create mode 100644 src/Services/Warehousing/Command/Domain/Entities/Adjustments/IAdjustment.cs create mode 100644 src/Services/Warehousing/Command/Domain/Entities/Adjustments/IncreaseAdjustment.cs create mode 100644 src/Services/Warehousing/Command/Domain/Entities/InventoryItems/InventoryItem.cs create mode 100644 src/Services/Warehousing/Command/Domain/Entities/InventoryItems/InventoryItemValidator.cs create mode 100644 src/Services/Warehousing/Command/Domain/Entities/Reserve.cs create mode 100644 src/Services/Warehousing/Command/Domain/ValueObjects/Products/Product.cs create mode 100644 src/Services/Warehousing/Command/Domain/ValueObjects/Products/ProductValidator.cs create mode 100644 src/Services/Warehousing/Command/Infrastructure.EventStore/Contexts/Configurations/SnapshotConfiguration.cs create mode 100644 src/Services/Warehousing/Command/Infrastructure.EventStore/Contexts/Configurations/StoreEventConfiguration.cs create mode 100644 src/Services/Warehousing/Command/Infrastructure.EventStore/Contexts/Converters/AggregateConverter.cs create mode 100644 src/Services/Warehousing/Command/Infrastructure.EventStore/Contexts/Converters/EventConverter.cs create mode 100644 src/Services/Warehousing/Command/Infrastructure.EventStore/Contexts/EventStoreDbContext.cs create mode 100644 src/Services/Warehousing/Command/Infrastructure.EventStore/DependencyInjection/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/Services/Warehousing/Command/Infrastructure.EventStore/DependencyInjection/Options/EventStoreOptions.cs create mode 100644 src/Services/Warehousing/Command/Infrastructure.EventStore/DependencyInjection/Options/SqlServerRetryingOptions.cs create mode 100644 src/Services/Warehousing/Command/Infrastructure.EventStore/EventStoreGateway.cs create mode 100644 src/Services/Warehousing/Command/Infrastructure.EventStore/EventStoreRepository.cs create mode 100644 src/Services/Warehousing/Command/Infrastructure.EventStore/Exceptions/AggregateNotFoundException.cs create mode 100644 src/Services/Warehousing/Command/Infrastructure.EventStore/Infrastructure.EventStore.csproj create mode 100644 src/Services/Warehousing/Command/Infrastructure.EventStore/Migrations/20230213214413_First Migration.Designer.cs create mode 100644 src/Services/Warehousing/Command/Infrastructure.EventStore/Migrations/20230213214413_First Migration.cs create mode 100644 src/Services/Warehousing/Command/Infrastructure.EventStore/Migrations/20230213214427_Quartz Migration.Designer.cs create mode 100644 src/Services/Warehousing/Command/Infrastructure.EventStore/Migrations/20230213214427_Quartz Migration.cs create mode 100644 src/Services/Warehousing/Command/Infrastructure.EventStore/Migrations/EventStoreDbContextModelSnapshot.cs create mode 100644 src/Services/Warehousing/Command/Infrastructure.EventStore/UnitOfWork.cs create mode 100644 src/Services/Warehousing/Command/WorkerService/.dockerignore create mode 100644 src/Services/Warehousing/Command/WorkerService/Dockerfile create mode 100644 src/Services/Warehousing/Command/WorkerService/Program.cs create mode 100644 src/Services/Warehousing/Command/WorkerService/Properties/launchSettings.json create mode 100644 src/Services/Warehousing/Command/WorkerService/WorkerService.csproj create mode 100644 src/Services/Warehousing/Command/WorkerService/appsettings.Development.json create mode 100644 src/Services/Warehousing/Command/WorkerService/appsettings.Production.json create mode 100644 src/Services/Warehousing/Command/WorkerService/appsettings.Staging.json create mode 100644 src/Services/Warehousing/Command/WorkerService/appsettings.json create mode 100644 src/Services/Warehousing/Query/Application/Abstractions/IInteractor.cs create mode 100644 src/Services/Warehousing/Query/Application/Abstractions/IProjectionGateway.cs create mode 100644 src/Services/Warehousing/Query/Application/Application.csproj create mode 100644 src/Services/Warehousing/Query/Application/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Services/Warehousing/Query/Application/UseCases/Events/ProjectInventoryGridItemWhenInventoryChangedInteractor.cs create mode 100644 src/Services/Warehousing/Query/Application/UseCases/Events/ProjectInventoryItemListItemWhenInventoryItemChangedInteractor.cs create mode 100644 src/Services/Warehousing/Query/Application/UseCases/Queries/ListInventoriesGridItemInteractor.cs create mode 100644 src/Services/Warehousing/Query/Application/UseCases/Queries/ListInventoryItemsListItemsInteractor.cs create mode 100644 src/Services/Warehousing/Query/GrpcService/.dockerignore create mode 100644 src/Services/Warehousing/Query/GrpcService/Dockerfile create mode 100644 src/Services/Warehousing/Query/GrpcService/GrpcService.csproj create mode 100644 src/Services/Warehousing/Query/GrpcService/Program.cs create mode 100644 src/Services/Warehousing/Query/GrpcService/Properties/launchSettings.json create mode 100644 src/Services/Warehousing/Query/GrpcService/WarehouseGrpcService.cs create mode 100644 src/Services/Warehousing/Query/GrpcService/appsettings.Development.json create mode 100644 src/Services/Warehousing/Query/GrpcService/appsettings.Production.json create mode 100644 src/Services/Warehousing/Query/GrpcService/appsettings.Staging.json create mode 100644 src/Services/Warehousing/Query/GrpcService/appsettings.json create mode 100644 src/Services/Warehousing/Query/Infrastructure.EventBus/Abstractions/Consumer.cs create mode 100644 src/Services/Warehousing/Query/Infrastructure.EventBus/Consumers/Events/ProjectInventoryGridItemWhenInventoryChangedConsumer.cs create mode 100644 src/Services/Warehousing/Query/Infrastructure.EventBus/Consumers/Events/ProjectInventoryItemListItemWhenInventoryItemChangedConsumer.cs create mode 100644 src/Services/Warehousing/Query/Infrastructure.EventBus/DependencyInjection/Extensions/NameFormatterExtensions.cs create mode 100644 src/Services/Warehousing/Query/Infrastructure.EventBus/DependencyInjection/Extensions/RabbitMqBusFactoryConfiguratorExtensions.cs create mode 100644 src/Services/Warehousing/Query/Infrastructure.EventBus/DependencyInjection/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/Services/Warehousing/Query/Infrastructure.EventBus/DependencyInjection/Options/EventBusOptions.cs create mode 100644 src/Services/Warehousing/Query/Infrastructure.EventBus/Infrastructure.EventBus.csproj create mode 100644 src/Services/Warehousing/Query/Infrastructure.EventBus/PipeFilters/ContractValidatorFilter.cs create mode 100644 src/Services/Warehousing/Query/Infrastructure.EventBus/PipeObservers/LoggingConsumeObserver.cs create mode 100644 src/Services/Warehousing/Query/Infrastructure.EventBus/PipeObservers/LoggingReceiveObserver.cs create mode 100644 src/Services/Warehousing/Query/Infrastructure.Projections/Abstractions/IMongoDbContext.cs create mode 100644 src/Services/Warehousing/Query/Infrastructure.Projections/Abstractions/MongoDbContext.cs create mode 100644 src/Services/Warehousing/Query/Infrastructure.Projections/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Services/Warehousing/Query/Infrastructure.Projections/Infrastructure.Projections.csproj create mode 100644 src/Services/Warehousing/Query/Infrastructure.Projections/Pagination/PagedResult.cs create mode 100644 src/Services/Warehousing/Query/Infrastructure.Projections/ProjectionDbContext.cs create mode 100644 src/Services/Warehousing/Query/Infrastructure.Projections/ProjectionGateway.cs create mode 100644 src/Web/WebAPI/DependencyInjection/Extensions/WebApplicationExtensions.cs create mode 100644 src/Web/WebAPI/DependencyInjection/Options/GrpcClientOptions.cs diff --git a/.assets/img/diagram.png b/.assets/img/diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..5c3d102047f18bfb18fd91c8b64c305b4bee8a47 GIT binary patch literal 70696 zcmeFYWmH|w(k@D5LBc{4oZ!JdxVyUr78=}wyDbtVxVyUs2@4Ma0t9z=2=4BF=6d%& zdw+lLxcB_HV;sg{Oqg?acg?Em>Z<4I4pCN=LPaJ(MnFJ7m5~-#ML>7~0e+zi^ z&P(8W?L!8JOMKrF4T8T1HR$mJE(op6CV;RAUzTxUq`>v}5%~X1b4z)g`;)b-k`haU z7Ane?Cp_F?NpVS}z<`@ukh|v{H*e$2ET5{|)+2@U_DGIc5E>D$hBIfKD;B4&u0V0# zwFplDZ{PcPd73O`YH=KeyeUZZDTxhaZp3@2y2VEcwziBX2s*Vxkr4YI?c<+lJ|BB! z)8NW>zG=x5QD-FoaC>)kB$}$?Wt9X{QyZr-7{@6p@3s4g>|L{15-_}|{QqW)%kTpb3=9s=<^F=jx+E{ zvWGLdk)9>G#W{+hLf|pioN}?1sO2ZA`N-2O78IaU;f@m|{u}Y_)!iK1rq^L?;ITT4 zB4H&v5^-VRDNP$WdESj!w(Di2!%3O4_ChE%SQUBwDpeI31^KnwuwEhX5`rwXV!(MN z@B8-LAwMDL{rLENkb&%{` zA`MiM1>}EzIq9#|#LWc%DAMVqN}2ei!v$aUKSk)JoM6@K>Z;njTGcFY z5!}}%-ujw?DBE*?A>|-fleS9z*j{4hwtS-er?dT93n9}tw$pS_Vm14XhSW4ON6{7b@`yB{?k{X7ZOcQ3YrP- zvG~^*(8;T#Wm<@b=iRFZy6km^?d^5Hybk#2eeEO;8jFl(@hPAiYkfb__5djSHcdL9 zwvsXbioOBA`;3ArHMkXoL$3AwMWbM}j9}=~V@Bc|zJDfNJUs`;ptk8%l8QD?K34IV z|17+RR>sFRXVEBCT59kkh5&;^0d6Dw_I=$o_0JNc>1;>XCuM0o#;nq#z*h)XG^C`z z(Phb3P!SPo4UMgfLW8jAslj9I*n<>jP!cJ|p^Bv+36I#fa=FWfM7~J2wzhW5EvVmO zHwM$V+6hq*to)ZbiqFqZ;-f(~XZQ&h*_eDtQi9~ii;ZE4F{7;QKbBvY-;J=w?HJ@d zB`tVeyC1dX-6!VbPTXQG3ELEJZ+?*N@#~W< zX@Ndt+hi`VC!JRz$D(`SL{5pLIb+mrk#r~fIY%?ywFL`AmVhd9*29@y3<$;f3BR_kq&g4ea38p&t_Z0iL zJc(mR+qyFhJwmqm(Zi&h{;xX0IYI{UaqIfnWC>o^qN#-q&zo_wVx=?Ovr^oP-Iet@ zLfmmvO{f!6g1I9?(5FNBwB1X%rRA73f|Nw>!8xkWPWqLeHs2y`RD_vObGU(~GlHwj z)TwLvJ<2Hz6bWUwkuJV5XpsYpOEDub9XB#RXw5JHlT@Vt{rNqeAdT__#_bs;8veZo zX1v6oMuk&kNJ{{sN)GpyUTup4(UUemUyEi;YJe#5#pz-qkyoeJL_T>8H+XDJm54z0 zqBFs=PgEczwT8C_VM0#K((w8heOesW`xIu&7RR?_8|tYy@fa8C z`*t;IeYeu#BxUL)zMj?u)jesR*h(rsh_X_TALSbu>6f`xl4F&gMf+If1dt^UO^Ph# zH-q24S87IpsFG)^^lEh&q3}$0}Bet6p{$OA3c>jWWcK2WHuKO?TuY#@Rq6`vL+!bCpD1T)=XY-!R=Nipu-9o z;mp~HSx9!Q&UQCMeroi~{rbOk_jvw`*&r_9QIuXCM}11K@aE|8o&s0t9ajDj|J$H9 z4!NsdHW5jdR?_(8&yla2CmdkA9`{kC4fQONkTjKi6 z`Aa*{v2U@d+!k+K@@J^e<$9jHMWB{9uW=kZb7;u^HSMhAc9Np7=)kH}8nPcYS|87% zbom?ka)YnkE4bs2oA23gddMSjWBv0z&njhT29<5s#86z~-T!opQJ!H45tWFxN73Lr zUt7Vv_D!sD5ETs4Uzs{lmy-%fZx8zezS?xe#(bY|!Fw*s57~Y1R_J%DW{czf^!QM3 zxBP*Pt=0D_Ga(@%Hum&nt=sKr>9v1OF)83n)xe5{eTu^bH_=UVJdiesK2@%czo10O z5wctTLwasr?PR~+-{zdX?8v2XK`sQG07`J_5Ib3F$;u>8)U?7(3M=}3MHhRFRY^Nk7CS2Cn)H69 zh(bg9`kVp?QdY5=amRo{GWS2OhV%zQJ+KVipuHt6BUxMy3fc&}G|x{R;vPGpBB`NKO>^nm$VbqQaDjTK@c;HA=-)zBW1)}rcdz--e-~_*QY4y z^S;GY$~wlEyJQ!fO$~ILSY>EcS0#k}(m3l|1!=oEz>0E2RftWdRersPA;Cm!dI$Xs zTA>A>a7jo=O7KEG<3R!f0(TdC(*<%#BL0XG?|5QYKe=Jb$;lyySz20#goL!C2zSEc zVoF||+{)1^`Nv|09)n4SbF9egOp5re3fYDDWPN2pj3Q)Y%2honZ?c5ojv$lVRk`?N zkg3;eM&eFo!Qp=!Qk?T47+3$p;+WRGmF$%IJ4P}_Y#Ygw1xxzT^Bc>)!>YVjJS$+*PJS&)ks7$~^kOk**Vfk-_Sobsn@KCe2`t+w`)Z@pPkIs6CpcCPKKVR=Nv( z1EH8Fg?X=$C9;>0vp`WKLYXNkmWGC-C2B>BUYEM4w__`ZL4{mP^*=A5<$Ib<=EI&> zhXQ5;$u%`K6BFu&h}lN)^)$T2KEb_dr`=41vP)GI-wz!>mM9RtgyLJg+4b-Dhcpk; zh$8Jt^l%TBsg4eXsH@R+o~|gpv$)o0Z7FoW<@c1LED|1#u6r@4URUU6aVM>)yr7aZ zaT+|TJtB!yr0PJu6j5~j^K83z!8AJj!W(p}xjjsk8MAL0pZyJxoQR01rAq8yK3PK17?e=jn~4>q6P0gtBi1MS^t~dg%{SBQCob>%O9Tx>iCS4!dA%#~ z4Al62WY{^NSiSm<9N5&2pF~H8*M&mK_n0L3)$5)MRqXSIZjtl7nowS;&Q402E>g%HB?4-9G*-hpVoOAhFtAUSoL!C{^})wF8W z2i%okXmX@M|4b|x)6xdujdL?|v?rIG2-zX0y?%RB<$3OIEA4^vH^{GE_4M`e`ab>& z3F+M&Ognn%gLi#x+{8!X$$#xWay7r$18sgt}Oj!&K=EKB&e(dKuE!R5o zQW6poM0`^d6V6*h8Nx)whpa-|O?~DP394*+>-Ca3DDNFD*n=(QcNGi1z=eD{k zCx`jaV2-aHC%4Kw6M~9g^zkB`@IJl)rLWa`QuQYopnv2iKi>si zdHI2rj-V|d2{!GEhg6%{%+>TxPI6FCP_VI8XLI4+LvZASqh2oyRi@=IskMCD;kdHM zVV5%5CM@zwV#?qd;&D4%cyTwq;#jc|;rZ|vQR7F;6E7VZ8PiPvjS4{n+rtJ0g} ztgw=X#N<_08k9q;^?kZCeaEytM=`?6bI^ePRn_h zn+;tK$mJo1?WeoA7BW#4QNM!{(^|Z)?16VuLP3k;K{cP12Y-M0o}Z^#0_|^0vkgSP zzyhq6Jp?$pa0C_&n2MTOZ6Z%(CuL`Oti?y>;goXYY28INwY4Z8Xo@d2DA@j5ou_c zs5^^4!@t(yH^xAZyf6NxQPDwN2yWM< zz*O}-S==+~+W}a*?GBWdQXsI4+^KSi0P1<1rgaov7LHjI#01M!3Z$E1mR^{C5~)(E z@2?^H+OD$t`)o~TBzRKiYQc3caQ(4{w0_SwmGdvvoM(m85CIqx{oJ&Pw&}i_Su6z2 ztI@*i#>ELPdY6llC{B%am@qRdF0S*8!}?e zuKJ30i@gg2BeIGVB^(pHaynE`66iW({MZMfs(sB$a@1|^Ex-M?O>cMx#|BJ9Wr|Sq zw(#)q^MUiQiY>&Jr=Cf)J7x9}J1Mqxh$;3=-^kjO3V<(YxZEZ%Ec9ER2i? z{YlI~%+1!qL17_T3@@$4Z|YO3F=pBB&ABbONbZZMhwy~Zt-rb7{a7if-hA%Yzk0_H zhWyYKQ~QqlRTMwyqNTRU4CF&F@p+w~Rr(~v|MhE!uia+OxeHU?BhTy8xsxz3-(Y0i z9kUG{pYJp24t_oBhX4MRYe&u9w#sI<1c&(&%1yk=L>-f z3mAw!sv(-;TR+tNX>L-84?jHg#e225d9QgdsZ(O25u4^xQKj-Eh1=He{>z8vEaz%5TM6OSzwfoRzIU1Fh{|PO*pK#Wh z-xdyZAAxjTogD6y+*N+AnfMHQJ{|aYn%(ZbN-}1SJm+4pnJH=897a!_Ju2!0R}43A zdkS7UF-&3s3rQgCQ~Y@DlnZ|0c{C+X&I#7VJ-Yl(NjD2oHxU|!m2}9@Di>J~y+YeP z3B`6bA;$`$lQl5xHDI=kizIznPz@I@&M3_ogoUZ^WT^JSdYUPV)p#sh1c=lnkgD8m zWDJRFQn!QPf#x>?Wm6w%$|D$FDaQh?2H501JYJk>z8tPemz=F=^4OAkGF$>fTd2DwYvg2{X;*E5-2NkdNXu+ui{FE&d z6!qIpUgqi(=2rFT7rI)O1tx7Y^QaH_l1<(kaaHtow>KN>jnqvg?*K5@5ePU49IDw2 zGk3+bS)e>2i7;5!yGSxLfhgzs<4-nIS7m^0lT+OsBD@FDWfc^#&9c8_nYAUqnx<^t z+hLV2H*E3_e9^L^`AA@xJ;=_bw?P-bj)UuIG@3nzHH3K3-%=S4l+}cWLU~Y)Lg(7R zP0mJJwm1wQV+H2Y5MU{e{YM|`aiCV8yUUX`DcP_0dM|o=*u-uauBoQdm}5SSJ8HKY zHy5LIwzW~;UGdIk=wAsfnXd*`;iur!($WGdiT1@Yfn=Sr_3c`!CA1{2tjh6ek?akc zqB=WUxp7~Jdl%S8O8ZW~J~*$zGi%43-XACkXV|uyt&!yEYIx+dE=g_iLaZ%H1p+VW zYd|X#!3zx68~wP4wB&EDxf^zeko#O0(NfZ#c{4M6^%TfWZxd+miOdl*_Jt1bw*h;p z+Q0KKn}MZ;S<(c`86qP0(1X9-H3_x28cHtTqU325s^%4#J1+B4%|M~^yX;0`hH}In z*K&d>Oms6B`}a)j#j@iY%#P$qA7I(t7eZEv<|uRu3%1x9XTKX>rL9>LYUG9j!bO7t z9KIN{5yHaCdLm|vwMH|rc+#kF5}CUcJT7*0x3reUAi6|NIfux9n|R51tv6CZJx|M! z>FyD(k6w$%eY=0WMTVwALQyg|U1<=APxEEiua<(+vt&Az?TNJmLec6%dx6Zr_nn)N(wA!I!g*;d$X_pu87o zk>z5P1=j3@Ro;|b9b&#wgj~gz4`zDvPHk9#Qs4g*A!E`&rp>-jg7oxZAC}4YZsI{V zw#U7XVdc03b*ZC^zu0^$cgl_+fYTR`Vxws^af@L`QlI&0qN*wTf=Z_HPcBf zm+27Yz@(wbo~NiDBia+}^P~uFM!_LxUc9x`n;G}|n_L9hKWzI=t9=K04X!T{^8a^y zj;%CkRs98q;{H0l%u#&hs&7bi`D zl_XEd`%ajd>6c(qe5!gjeQ?e(ypF(!ds)nLQxS3()2igpA^FesL{I{Q{+ofZgJS_W zMuq(TbfX`DYGUK!;$mZ4pbxhz9Y~%c&5k~|=Q4Qsq5oY$faDZog5*C7ir>G1t$(Ef z{|4UvmB#)beiRS#_w&omF*&)q!d?1=pe*!HuBED~IyN@;0s4ZBEadCgqkzoV$R#y6 zh<@P@L@4En$GhbxCIed`>#4k_`>j?{Oax(8c;KY5`uf#icUnlLpl^lCP7qlF0ihE} z1`x8PAAlB%jc2tLhx#@L0@l;KkJl%5hHwT$7~W2Gx#dA=t*Z#njLYR>FDpDu4jU1m zFpz+>=nFYeNm>iOI$ivR${Yyr5uMNAJ#PQ$E=r(v68->AWbz^9<(0rgK~P=?tc4KT z)B1Rk)%xhkg#Yr!c8P!9_wh_floH`fE}Z037WeaS<+LPEr=^*lEOkM_#d;y^@Q>`| zcUMi8)<-eOeiN3K2eUQ3I|fhf6(R^Uh=5KIB*+9FNKgs2r3-`|G%J@v{`^77p+gYH zgcF7|$Z1@BG8;lAwv(k>q8fn60l-Mvj#X79+S-nhXmYOVWFJ02@SY)L!|^(V;who0 zo12SOqNi&lVzK845^y$BxVF8cmBS7yNq5M3iZPKd|9*;Q#wa%APp# z^78k`D;-iX09V-Rb)|@h&`SBA0hNt#^4G5p=Ia4CuB@!=h&&}F1%Q(cTD&o>ejgzL zg@nXEU-B;uI{&$>qGI8?)D5$Mz#{O|*SEXW>~*x%ye3A5icN*uivOREqtieDH`-&~ z@LWblrmD6UKrxNFBZ>HelarIn3|ceIViFQA4(7SuF+ZXL{$Bj*AMFZ4jt5fMzGMF| z8%({vz6Po>xg;hRU;uy?yt_Q8uBvJTqPialOoOYK!OOwPnYj8F0L23Ef7`wIkE{U5 z1hm?4JKC_DtFZ*W1~5%W5c<(rLD*V+k8zJvl(_=_W! zAXvZAk&>R?8i1nCc?DM%ug$ZrNqgMP>{|Bc>!T27=jYWpP>1~v3FkOWl3~Ul_1+*= za^Y@$WHUkOo7S~{%Z!Cx9%g-ux5H}sol|J99a&-*Ui6Kz?Y|Y0B8WL398=+Ijknay&*a}YCTQfH;#aLne|${+l3lq zYVE@t))vu+FhjsN4BHbWYVPjW%p??%mr(9p4HqWx2T3uvMO@jiujvduuxIA=40AO{ z$lu4_cXi!)%w!1p-Zl>8Y?Ct2XMD#!;5`%F)?@<)rh~`8z8EE(njb&18QdRkFSfmb z)GZU19ThbkO(q5)=WEw<5027(SV6<|2EIOLWDkHh>HTzuw0L`%nQd0;^R){KTxb&c z5G@ued}j?8DUeapAZ$7%qXy$dBnKz4@o6&a)J=+Txh&iyq5sF1d#J&g0$z!*Y#<)j zXXT_PWoAOpw?~IE_|WlKzDS;5T=?9aZj@?#=XKrZc3kfX4t~`a22T;qlu19|x1`wM zOZslN^j9|#u%NI_)9K==PlPgpC4=Ts%jLc=p#b6A{U5!`_WIq6NyWy-GU+x5_ePzP zL`d_x?ABO}l51*eLW}-Z5|KvwdKD=td@ETvk`(@PB7}thLB9N1S~GcIjs$=y{08Q# zI()&8Ujy(&Sy@>+4pH)RKhJ6yjOJ`~yRNQ|S~0!B{UkRlYZ*wmOZX`${Po{cQJs!6 zV;ABQMuh!w8Yx1Zd7&wi>;Js48nC+*~i} z>G3%Gw)KXC$(gy9v8-xa`d)wOQPu)I4S1V#fI03R_>Gb3bJI0Hl_H*WrryFxi)6&^ zBt7A_GR_15e^qMb-T9r}m-sVXxw@?&eOtJ#Tew`jyz+ zMY%h52pY7S*q*TFvw4+x!C&Vf`u*bq%^=?pxBIwW@rB%fbd{G4Vd3H75fI2&CDMrg zgCscX$Vzbg_}~Q;PR1SY%sEZ*{CVkzchH*UgzR_; z><+ehyy(M^XVTQje}|igSGcQQefMls)U+K|x7KiKHcv9UyjP0U z_?P@}iq|ivgD>%EAiMJws?izI$1nLY;ZMAp|dHM)cm8JtvW^)FgHtWnZ z8j*QL14|1U1q(;>o0kl|69S5Gr|K)TYh^e33dH?k^dApCJbjL~;AJkG9{<2PVA8Yz zVfj0G0P!Ih)tdYH6I&urz4TLY_oT@;##(#S7~LxA@2GG4*yJ-d=?@`C?5uPJ2Kwxx zvoOGXLAKTCp_rvOSpSVzG~iRdCky32LB~3Fw(qhzLv0IzT=%?oxV!0-?miRol1zqFwauk*vWf7 za9!GElBGW)(MLNTkDH1f%ClOhZyIZU3I(%LnI~__XCXg^j%R%c6H2&kdmo*+Y@IGD zGdXpHDru9(`dqKawO9M}0;@m${f=!sr($`N>dP?q>#PrZ)M%mEdH)EtBk9nnT5LY< zf~a(AujSJ?=b4;$V3X9ek{#{OPoDb~MfZ+~cOLw|yso-x^YkK08s=i1+^$pWm|DEc z64F5andx{Vsf0O_m7_XnBrQw?8-}z#`gS6Fxz~Jm5vNV{5fk0KljfLA;}_ zi-UYo0$hE}OHp*ujGSqnBpf~j7IKwR>BPL_j&nupN&tZhZOP7>EoMT=Gv$5E_H`W! zapRlw`w;2clxO=Ws#3SlP;8@BabIP=6x-B0!~H}tezy(4B9||0s_m4MGeEU9wJ8PD z8LRKnPhk#9LzRDrM#iW3?sau5KH-EnSGy^iGM1}iTkSVLP%%wA{-~!~-&7Z9ToIK4G*?KK)y(7jx!%rYB6TahF%& zsxdU^6(rga{V(ISW_hoIdYnElVe*nx6NP7OA26a|{l9ZIxmQ1}l^Dhi) zQhvGb(6AQ(HpbP>;yarAjO>>;gmx8imQw|T!5)9r<{wpMGGPv-KvL9gdLtwZ)LPBV z%nI}Kv1rh~H7)JVmm=qtS08&1(ONU?J-IR4;%4>s>8RpHo0y9fUQl`ENQ?Hw`PZUc z;H_4a6#8oE`9$MpwRO>F*@btAME#Fm>_x-d!=o*X3I%rlbtx_SY1T9OjW~V7CLNNuE%e~%BA64 zb@zSmr!dP!8m4;uU+olkKU}^7Il#ZNo*+L+v=HyR-?spa6y*1*GOtjIXi#hkQd z#!AadLpIX&WO&|9LqN~N=-3@Qg=2!bc^iSwyZje#4fAvp>uEb!5(*C+DdS&3e%wzOJHm`b zb^o3;3Ru|Rk)<^UjN~ihgujCA4`p0kb<+y*gpp~MB_;JQHc6ooX}8#~6r-U!*8&jY z8gVjnSw%nIbovGcqwh0f;tYcfL)l5)P4aMZlQ!)^96p(StaX7!zU0EC;w!8z;hcQo z|HNFeHil$?ETcp^xKgXjvjt3OnW7tlWnH=F4Vpca$!09*^)qR^7I@nbJ-%ZnwNf!P z`&{6iWX<~O@Wk80*j@?mgC5k7yb2?Rz51dgOv=_Ool4WB;7THgH91Fcy*9@e#CrUO zIlW*gvT55$_g+KsoKa@eug+*J5+VniX@i75Wl>W+#z?f$slpa`BE-6-iJo^+#<~7U zm>7J#yE$ZrjVIuL$hCBe4XY%wtm;xQi|0y89Gw8tpb1oXvI>}u1z1023-wcbkeZ|( z(IsY;=1}eSWN^E$_{SCj6xA|`I*NxcmJVHj!@etlw#7jZ$gV@pMW*hQ)tFY-i)_I|;5FaEaiw$mr)Cm!CG&Kb@29#rfIQcAC}Un#xVQ%dJ{^1RWa@BxyN zYxq77<;drzZP0G769~(-UNp^}%w7Ebjz+_Hadk(K7E16vhR5syxdx{%lx0)R*-Da5 zLHvZ_+9ZNzg@q%v&-8OyH;d=E> zXOrJB`)=w_bToUTap8>b(-EQ+=-&f`zf_=_K!~TvVz4kZ<*@(z+k7~a62QSB_J2cK zym8LMeChk3V}j7TzgU-!*E?-%!%;Hwo^HF7A%~k8!`xZ9)9mbwj0$ZGcu$*FNn-_g z8NBqv{Mez6#{R?+C2BE-vgo={I_;iMs|+)r?)tTWDPHyv1N-KwT{{PvL3Z;R8uCjp z&y06!`E;*y7n5N<{`YlI7FZ%Qli9rr>_*bs(dK@sWc_s}C`yDc!!a3_m@m1}ED zWT0Ko0%2J?8N2n|sOi%<@Ew|FUR<~&^AZ)#-MrTHgNH7wEl}heH_UaObOX((F{<%<@u3ei z@u0Q54$ep`Vap!}`Gb{bKQY6G*PsVhAAWv|L+4L)?~xXD4!+wZ8*~OcwUBl)U+Fv$ zXquh(htJtF>24B_mNnH{z9=>Dn)NwR@Bn5gJNiHF4M5J14|nSe*Va-1AlGf)1h{jx zftKLYNyEInxoOJmkpa}YWM_pbj#+WWy54JG7Bl{N$@+F!hk8&U#Q>>q4spL^&D5CD z-1+R^n5#9W+!sUky-KYMxz?hQZ_Pz)GLR`Y_t8=M@oyeuQX={tl@61k$ARt!Dcs-> z;UfLEU?P5})9mTIRxfpq+-Hn9OL5W($fhE6ULKalKzdqNp|T7LdQqK67lM4rD zR=M*Rx4MblsHSH3Ds63_d8p7Xa`4b#=u29++dwiO?f?4H{UlG+PolEdF0!1PS+LLh$8G*wwC_EoiBy*!36u=F{on)G$8E=P7Mjn4=9 zwaa@)6l=ApTUeIJmb;M^rOLiB0XqgN2cue{dWn?{mD0Y3ZKD@}U+w!K!=)tk)y_|I{kTW^DlE zT9BV_Vq&^*(M%y|9sOasn62Ym7V*MCbno%F*tM6!vZqrV;^KLo;;?)c)bw;j?L`Sc zJv}`wZOHQimPYTzCAL&P&i zbBFr(sXpi1sTZ2YMwDyXiZ~08J%a~bEjwaO6U+tu*zwE;d4tIsdxMKh(u?|#69#F~ zgW*(0JfSibA3@9y@)x^|UT6lMJs<%5PO86dN=)C84& zazocsatx0;EW`mkKls(@$={bILf8~aRiim};u`+s4RMEmAkj^c+=86^Gg(>DK~YM= z_BGDx{TZMVYF_^B@*--@<@tFoQgX#penAo4VCKP*aUpI|a#o2OhqYe`ydp6ju&S69 zs9kfzN&pVUd)?g_XH2rM`nf5dUUvZjPS)=TG1oVCRu0ZMwLPD$=27_hz%*%v)l>dR z1_pDAe)-k0bE2}s9?B9~v^D=b`jtmg+>aQtXEpJ5-l8T!T@r}n4R%Y2>u6)4{{c?W zI;%-No*sRytgJqM{Al*kY@)jdHzyK(X~~Ot6(bR^j|4>ZSoZW#fAuErP270gfq&L4 zNbn)CYq3;2Ie>1X&kwmL0VKi!OuK*8v3bIfXdp2^Jlh)X?d=5!M=WI8dxufw^D;xa zm9=b!z)DgkApUXyfNwVWKaL3+^fSUjrtD3WUg=f^khTBus5Koli2=(d_^-GoA|@jL z#o{7k6F`h!+p*8Mum}B=s2neJ4!y$}is}B7yQ{>)NQ=n;Q~w`A>#`ZP*Ve=w00bu> ziU5HI8;Ha4F9Aru!a(Yya(Rmym=*&{h9vb4BNmVr_E47!9$kv zO9aa2z)%)-a5BMqirdQapfG=|>1q<7roboh=*Vy2&io2^LWwxF@E5Y zb{+qnLHcl^Ish`-jjhM8OD_GImBB+qNQDFA>@#UxBsH~H(p^})Ls?gE5&b{xFCQ`0 z2I|yszr{{qKtV)61(+X%)ZM4YBLk;R{=`t)iCRl1G$hspN;0x$qfRkZ9W5oh>)r$p8Maz68_SIEVLFMQ?8?KI(9;GpN#X%qB%!A>r5s`eiw|&F z)mQt2Oq&FKDpsec197p=YYoM~B{uYjk;E4`yU)H*!FM=CoUe#U1|pAjGj(RPJht3j zbp)0k%RQcDJsr-jmF7|C3Np-V6x$y^+@a)fB3LlM`{jRnyz(tn^r694$o~o0?dj<0 zQ4a4JG85&7FCo{EQxWO1&El?nj0(V2u)l@dgxKv$T&&}TYNL2vhp{xWaW@)n$pP(r zt&JC&5)I;rGu#x0k0(*Sj9SOWNPqoS{chpL^Sp2>iAd3${&ka5Rz}jEjF9~m?rR-o z+84Sp8Ghc=fLA=-38iyrv5=9;45nrz&c)t1-(5DoHh%sE5tvvA3a5v9o+YYx`7mk0 zjJnIrAasg=7!g)8Gh%J+F(s$oB6`iC@FiSB6NC|VxG*w`8K(!+N2#BuVH*&l41t5W zj3l}uI4jfMlbs9#XG+_M?WS_@DbW5h?haGkm_U8!PV|*f*a=RcQr!S^VEXDv$fOCN zOZwwkz3#bXOVf|wBdY2|9YqqIReU@sj(bb5G2{l|(K>ke2<-6eJ^tl%1qdHMMZlMs z`rIFuA$XwEe5-;UbtNi<5RV1OrTm5zTuJ~V6_$asR+P_R*`>gM@#_Eg(f=>B(RMIz z=(79H_aYSgR%DjR0L@}_bpVI~flwfN%9`QG1BVF1>)p05_9`A9F-3i24&hE<0B$Ob38#hNOBg_${y%?}PRht=6##nGEGY^KMqAHQ zWdi`tmz*FKB;%dNh{Jv~6{+{*~h5{LeQf&25FI2JvG5&Is$MrGsf zr%R1@95E9gB*n#bf4Msx%+<;zGUWI&1JE|5y5a$qE5>t#O84sQ5i{(UrY*oZ1}?68 zfTgL_ZH%3(FW0GGYV{T3;YmRpO$uVML>MsvdOdFa0;Z#(u~=wu0GJmd*r+w#>sPO` zbCz3t-WH-EwAQZ<`gwb4ke=&I_w`x*{_>27&lO9%9*yXy+mWE#q3&&hoRC|U8%Fj^ zVVr?Lp?g->eXX3qe$7_sgP`x@J0c@%Dx5J<&f7k_`bKO2{rxVgW-kS7%d6iZqBE_3w!k#qGy!|#CdR>5Ve zT;~cnf&gTMZrpDXg*(W=o50>C_uUCvE2NUn{fUxspp_kx55d*GS`-q=&hmU)7TP;* z;f*HOgIr#KIcrBj_{&vD3;6*sS^%q@&%h16cfTFAKKg6FAmkCuINieN^0>N9!sD>| z2cY8ARR2_ne0hTaVMzkuf#_(AL{ne`x0#jI?F3(O(qOl?=G&W!t&S+AU;f2|86QlM zCwqH(FwB%|$e$mTK^9sZO^uD$zBzFTV97qqPPUN6$>%%(Heu`Q>z{ir{&cw#6P#>f zVz+gvpsa9vx?L@Ji^>bV=T@jrhVFg4m1oo!~%Q`o7n)K)q`a!uxIM;$^9dssw{WOR}ZZ;UHyk9PueIQ zc!}SEBM=F6FQ&DT#e^9<18(M*^4uP8FTCm=4OvAkTf8%}>@Ujgex1L@4TWl+REXV3wY)i6bL}?ll1}i*6!k(+;*Xu@N#`TDc_FW0@#wy zuF$Cz5{Y_N-RL%cJ4ICaDNu05Bdnp${38*?w^2HC`2(HK&@ekPaA~cDkoiibYNnK; z-C96sDj6^Nc5QRBc$~4%*3jD4@UpZN^59p7WVx@fPu||%KsE3ufWDFvrjFc4G0pVx!T^!jjr=5nafn$t61l_zi@)Kd+N3>~-B!b@V zSBD)*c7cZ3iT6a6u9Zqh{2lR@Eq8Tc%Bft57kXCOf!{05xz7Ufly93t(N0np^lL8O zC(s(}whjjGoDu)LLB0r0Lp;E}zrU%Dds}~I2k>;}4}xKYT)Ues_iym93L7=Qq|f`O({)o5Fy?y>FTyWsq{@64$WfZ zP*sk$SO+}LQ7I$oHxS{5(EhS~c)i(2m5s;rPKZ5Mz z$!9uNk70shQnN2hE$seelK)IXq?2R1_Q(VBhoy3EEV#1m$w#+k&T+*{bI_uVio~%k-9WItM zdY`WME`AA6gBl!NQ}g@WIs-JM`r-Drvte@s>p$YU!hNW)xr-N=hdERxGBU8+3g}FIoFj7qOHjUV= zh(LEezM6&#OwA?dQ9YiO;vQ@-oO~SLo@g>4PUC7<{ThwZD)rqQzz_9551a1n?;d-4 zQER8PEUB%f<-9-R01)J?tgPp2kdwRjH1uHoG}GTKw=bsj-`s|YTqj`r(4yS4nv_eO zn76q%jni8t7=~L%x#MLQ9uQ`MzB|7&t-S13G0w}1D(kMrhw-7MSJ+x8M+u7{W`O$938q?67`MaeZ+^MhY9?w- z7+G;tPX91eFcBda0y~mlDp^Ww{M2*3E^Vt2LGAOq7l|Ft60!qom_L64y0m|MdRA)} zN2Ra}gnnRMDSlmm4Ekv=UIO{i9-vnr@M!dfL5qcw0!)lI>{GyzZogmtypw)vVM9j;AE1_EhLKjkOwgg*!Mp<~sKIf#ztX*G=25i+_wnN00PuYwa4Y8BHC0DRV@WZx$*PI7P;p3=0n15}a~ijadk2&uxfVT_bN7AAcP+ zbkkG)`Ln#byF0h0qG(5+(dv2~69XX$5q^vw^Lu`Na!D;LPEI<%&C}Hi$h>R~oeiVD zL%%S2uCYX%k#SyQ=PPhnVl|!v>%!iTV6D;TTR6C6rMUD*av2d(VWFXa>%p(1f`cR0 zs7P=LB?7-biwc@72(+wuNcq8%gUNcYzM3nc7O`Gejs@ij9Hb16TIIC2P-k$faMG1$ z_i1u6?q27;NGU)aw0TishF8z@=53GvHQq^VIbZ6>2|7~~&x45j@se5h)(7LCR;&Zr zDSJO_`}x3K<98TT+CQU5MuNBTc<7}qnk2pjh1Khbi<^`U5E3hWe@k`y$srCoA~-xz zk8HGCwT6=CJR~gfsc~gwGvtl0bHhQ0#s=4CyjH`4duOlB4A5Jq`^m6Am!^Z$x4xfO zjT>uUqecupbW^;+?H#0)ca1)m434eFgv$EENM+>Y@>Gfw_`EI|9$Gf$xo#7hj5?96 zrYw|2e784aK-EtbjnkiXKRdggy~V-Aj%&jIYyB-fOEnbA5i#p1F@gIAr`I6l4as2b zdtJU>pG+g;Yb9TCtmXaS9}c7EQiD$#%9HlD9SM)Wk1cq%k3@^5-iCy)rj6{Q@1~81 zd8y)jUi59uiz=41aCePMSkw)hduc@;(22o_O-O(1Ez#4MNh`>yeQu6Ndo`SScRiZL zrYUl|)-CDe+w|29ow-ycwM5J5M=jxUi>Ytj?DeZr5;yTfMA`S#)3SA1qjgb#TP<@b z#OL|g`4wTk1xLXabDRqpbC)YCF(=6PeX8%lT~c9G1WZEaDipuVp%z-<#fG?f2RLNE zR3^B^yL#^-rv}d^%+7S)Q}K$rKUvamLho);pFz=sCUOF8es$vd|QyJy#mI5?h#WK)GEk;dkeKv+0e3>O5ICEA$$q zql%$(lYTYIK$5tg1+t{!hyE7YW4b2m(Wjju8LcA2{jFDII^0S3&w{(!-l@{a6DOwaujHM9=%)I zv%meaG|N?l+@W6Dc&-r~ba83Q_+4`gv0m?>)Av@CpE<=YU9ie7gLfh8`RJ(KgzbF01^*H-*CT+E*xfN>($olpWjC(d;&hhI^G^b?y_jgK_jM z^5uo!^^qF2Z6FiBv^gq7?-cB`5^Lv&n{8*#zQvtqzDR5q_(Evr$fSN75q4QEQ0HTr zMXX?LkIfyCTA%W3g`6Y!GU=lOnX=@fO3(`lYaib|;R@e-e5mRhqq2GhHnZeiRL$UF zPUBhJv%Vz5V^)SL8#avMpw#h<;deyMGId;7c*#zxj z?b_WoIi(v2LmD?Ybe+zFG3AxJpCRf=l7abZ?{u~6Y8uMrB&Hgg+ zZrYt}G^SX1?SWX{gu39QvAP)Y+RL@yXz#Ryii+{#{9L@2ZsOMXZ|I`=;tNw|TvT6| zgZ0?gKB=7R)*ouE9$1|wA93wIo8j-W$scT(XCY&QMg{ApxtIx-b3Ay6h)VVQW*R2X zvFU-@ASvBh5Hj*v+nCO->LzeR$u4*PV(NnK!-jQ5b^OxZ5Nx>6KF;#Tqo<;eRnTIl z{#=9oKFMTh2pTurw{PFdo6u+Nh#1tBlX=ZWkXurgb22ma!xWpJ1cy2N8TPn~!oL=C*IVfg^Eu%D_)&HS z?-egPg2w*yx?6$1&PyxT8Y@>?d)7+=1n#Duh&U zZt?K6OTzfcLM5WW@d)dWUGHkPsHwVO zO!NfGcXs?fcc>>>Z~CBT9<|*`v_Z>+#_AmJ%x=`;OD=z!I%CP=!zxVfjx+fv+$2B(w)#i3YncPm~b(4qy36?bU{Cl0b_?km3$|@$G&7&$&34 z=iD7Gd6JoAtue=(YmV{$Uj79kXZKwzSy{9f4p=~^%!1cpDjG~_c;C&D@m%dnH~B(s z2(3(CcF_?QF-lbzdZ8Bn{z9UDzV^$z9*PwY4_k#oHf;w1kJ)`uBBxsAYJ=O1m52j0 ztp9e*BV${}yW+KRH{Fcr;5z&CGL)IG@7CD4&dapw~j$jaN6;Fyo5s{*dlEiV_msE~_e#z$j zEi4xhY($bY=g73=1z*9^y~s?rF31|Gfr zlpOkq>4iS`A7euX0((G$RfCOhpX$a^-3%F0M@+%>4kWH{iMD2;KEhSt@+r$f@bqP# zvahlrEZTs^ga`-8%br>9Z{3T`kQt;+;jiHZ)!Bcsv**l4B zJdWD)MY$YUgyr&QHV5u=9Q{_Sp)9{b`2i;%x92ji@Y9^n@}r+ne)f%bldjhVS4sdl^)fUH__jIYKDMcq1aiD-n+Mx>KFEZW&~L7`ea zPg`FCP@wAV;VOZF$f?rVJ5I9ObEAsl_w&2mV!pF2%g1r_%1Y362xzWc`y|-oe>S@} zw06u|_`8m(Z9vY3jKMfFeYWK1dXE!E1>NDkVWwG@&`@QxZ5nVck4^#_Yzm8ZMc+WO za*ay04KUvv=RPy=o_8PhUZP%@~ab#ubzmLy@jPU3q zahAbOcLqfE=qD)h79aws$J^b%yy3rf-v~}vch(KB$QTDqrh$rgAMZqwUoB1vuuOmc z=W`A$Fi*odY+4HqF}+Fnpa0!wvxS@GS}@|UpZ1D z(Hty9fjrrH+$|EIaaX&WB?LU(i_XL9w4q1;a%o}T@l~p;?P3?c`2tw^ks<-D8IQ^D zZA;BhBh)p(PBh%fxL``Ak zrE^Rsj4Tsu@jCkeCx8`Sn_8^NzVxwxV1~bZ%Zct#s*>JDZYHl%g}*S31@T2o z(<>vca`kU!D#?u2`?CVTBf2fS(BPl6riWN(`C>xB{pXFZ3VkpIv5X-6?$A_KCnZON z!nA`eMoBgXUv<5z1@oWz^li#!5blB!2Vez*yWhWz0m=2Gt~|YWAQ^dF z^&C=cuGadOKAleJ(b4{39-1(a#4=pbSMU=M-)Re}(y)Tf`27=^$7k=%= zgMo$=oKBM^2VE$-!z&W3lh=T-P)bA2MIzw}fyWVHTE+Z{rdWFKS`v8R`IuK%hGH%( z;A91$B`i(duW&Hh56FEw|DE{jR`zJbn8rppiSgY6O(J~wK`&zbRc)4ai4 z14m=Yvms>SB2e8hBM51NubS^&LHZWUI3Kxf~Z zmpLcu;}}&hwU^3&mwIAuWuvMT9Mjd=iS|{7dt2%FoHlnftV0UBURDCaUfE*J=~?)d zCDGf5%`>Opql-R#Z$(X+J-$=eYQEQe55^$A^GFLm=^3qYhL$JfSe#EK|_uG0r`~Kdw4@ zrc3kFjK?bSaf5*q7w%r5^N<;vmeh&X_l;n27Cgc9+H_GlIU|b0rZ`!n62l&; z`uC{qM60MAOS{vZKZCdjB{Fjs6WSmHvnMPawSxIDMmrc%eTIjD^0yPncmvm%PE8NThH+U<+zCi!e7uN4P=}&!b~ID)-3n2;c<{Zu)FzWb z{)gIIriox&LKlhzb@S6zX=JBsrP;UI7XuQ{h7;=qytLNWJgz4=@yCcUz{z{UVL~U8 z_*imYuFNHW=NA-_*RbvT4{l;N?kPU20wJE;EtWc2m@k~pmH_+QTqE8DW%o(qYl93= z6!!JwTC;bm$TB6yu3SG?NoD|Z@oJ{BZEK7@m!Eo|yKV0arwB0@?tqVzom>FdIXH=K z4fXPzM`?FSiIa!x)a5ES)WEIEf zeMpFFh`cf+rTxR(rsLS}7nEc;aKu$p(|9~sQi0TTYTMF!zXMbJ12v(W%Q~Wv^|M4k zZG~l#zm@~_t6*)-b>C4%L)zi`dD2k*(Xbq&oBWTB{r~)`p~7HBO=?F{Hl=uEpx#{b z66jON2md>HS6)invbLq_Ju6&K1GvsCrFI%?uX4SY^rlvhn*lgd`e9~0cKzywH=(bD z?wjSasv>^>$yHps-a0l8-yR1ea)$04g8_}%9taegb4ffIk11Ly&2(t|xM{#8_)m}9 zE|}f3QS(m(&??d~5zy*F1Hd6{t?{Yba6v5)?$(;GB4uyKct65Z z(>dUYT?X3R@G=N_bEETxrrH)FI#n*>~Vk z|EOPk)?cGN(pPtl9c!0p%{F1{qI}xMqpJ@#S^>)$-t;S^j4n;({QcWk!SDaI4^~-P9WF?Cx^n3hB zv7{|>DV7t^3y?b=M)By&jW)KOPPJ88yFDCk`JvWe0{{ z);Kx)*`e0R(VIQ%x46PH-f4Jx>^NUPJ7qLYcP?K2#p9>{)lNV|m_4R7;3OO@EvW-V_z10lI1lhsN4cwI-Jb2ljT!lUy2w zSs~H|6ZCFQ{m8&f(5xb7b1~-I6xv(pzH_j`uGM~{_-w6}Z9ZERciXTV4-if+K@U3? zii1aiZ57sX9O5V-D;Lla?fWzPkF+^V+v}HqF009`?-gu`h*tHX!p+YpzY8-z=JQ3? zdIsMG#%vsqBb0HHgth4W-_y&-50&aE#CH`h=Z1izTeA+Z(QFl6551+;p`wo6nBS&= z{+{lSz!s>iCqY7}A4)v*0E~J0gBCS7f zTLewI`Xjows1)tR$GxJq0| zzzHX!?`e7LmM0SF%yIbRxX!)p9VDhrh$`*b&$Qt}c|dG(+E>r;PB)r;9M;KlzhPPA zYhnNehp}UV^3KAn8Ob;2Y!pfosAJBYACbyM*^Q?7C%L|^HF=j4r8{1RwtZ5MQU=3gI|OF?6npoZ^x>gT`Ck~takdT5E``iwl zrn>v07DOJ`S^V=pUPSB-Ej8)R@%~D@ek0c|25x%1#bwxB_ayU|QkU)a|_eP%PV#>$Jn5#{HD21PE4 z6{V1rEp3&?IHR)bprOlSY32o!zqOObIZnE<_HYSVVzJTWxpJVdcxdaS(DKnVB5vY` zX4`2a>qg&1zpnSEkC^~uq9y#u$j6Nb(Ta6r2CyMoiYh7;z!Z^0c)n-pr=Y#GvXk5C zncqY^+qddgALbM?LvJixBO|@Be~*{Y~47_+`iwrs5Qds0euEbOQhbL@cQ`^kj>0?lnNNJ-nH}|@f z-`nfXyqyZCqzRu_-bRX$D`=Ityj@ibPkFV5AMt=Ddm_lRPM_Flg@nmLLU8bBl0w_w zKjv?$m` z{j?rDVYV+BQm7UQJD#^IdjSNm(93=4f}h9uK&tShhC#jX6L4VI?<)daD%M^xJL828 ztfS;4mi?NghH#DI;UJMZzOjKs99AA5G+Ma9T6*U~U9sG5z=^@S0)9FoW9N)ig_;28 zsIR%p7RiCsk2V|(c^+>$CdXpQB8neT#V{NNuFgtVX?+}vL;5KObwQ;w%B@j|IZH+t zMG+q82_%T7T1^Zyze91R;~47;E>

#t7km{I;``7Ij{;EvZ9dLobqyxFG1tAQgXd;EYPGtgUKj6u z32i?D@VX*kAi+WBxb@OaoW4M`M^`O7vb)D{rn$~dt&e(v-N|ppo~;RHx*L&OcRX&bo+ME{ELRS`&xhSuu@>vLh?weg>ZJ zz=r0yA?b{v9@9?ytv~E1kfE9|5;c-AicOC8{^bl78~evqOF z0D@?<_3d{n^F*MPRx~dB+4chQM!P<>S7l+7(+S)_;Q-i*s~G@CjmJdhJE$tZt15eX zaUU$@lPAx}$S5d|_ZOSzV9_M_c^yHtv`#-Ls=CK`O4J;Go{{&NivikO^K9qkXuKms z>UX z&+jk}$$q2woPEfRiYX~7<`G^U*sn^iGZftdZ0J4o9Bcc85oy&fJTLEFCnL9PK8E0X z&50EDH*5rOUO0s~I7}q%ak|&l9}>WCVB6ms8?yt{o_H;le_uZWH?ab^VC~W|=hM%o zqm~Xem)<~@KbaLspwh1Nlm>l?NGit=(*~rUfZmVB-hXUH0C3gRe>-I zxHqVqJPwG|G<-cq{3Qjv@|ZZ2OJdZEGdWj{GG#nG#bz6jPA5R~chfl$ZboNjN#J5~ z54kyo`y8g{ZL0U-cWJl%e<$DR#E@HsUFvHkyPGDMw<}u3Z`2CjLCW2nk~!w~1uU!kHC{nXKG! zu9yoP&$A?INFS@9(r7(CAAocXytZgRKML_4a9cEuvyh7WY+THfhuWR6hf4X48O2$( z_a|NuQ8QJ*ence$McAS*6a|;l2OmIo7exkXr`IT7^#hh3!)#GQ;?xX(4>qjt)K0Nz zskgF{!ZI%TdUVBaKn8V5^kY{=D!;Rd7IvelTdZhrmlPk51Fi8#a!wO({>o(4Xbw4Bz8)9PvO2WuuTUM7!%xeOY&~}i8E_cK16CO40Ij)uWcZcOC@`cm3K)0 zkTay?gfC1M)|p0$D*J8FyL;m)&*$vYg)5p_LQP6A1wL9Swe{0k0Wa68!Tx!es`Md{ zZPzx|k&iP*t|C-S^xxX|B7vq(JA~K2MxKa$(QDizs*?n78>3~mQGp%dp6`a9E zFa@%1zFiU40F>A&pb0BED3?npY$!rfaR1fr@jff$cy{0_byWt?+jNowTzB8c7`D|) zw!F}d6{4rtjh+kS@ns3cA>-nn*>I-4dUwAu@MMwSHS=6z~iV8mcFL9x>+I%JQnYO}? z@a-v{HQBT@uUQA^-v|_=LVN_h^h+R?f{c;aX@f}9&>bkL!h=zeml9~Ij~PGWeG~VR z3YstD^Lsy%PKcG%^f)@(bau*$*lj1c=Kz|ak@Zj>xH6wk_R!ZAu6$g3%p)tT*&L@6 zeowvlOXgimBdZtlrEjuE^mO((dPRm-sRkkrAid}`{kyJEAU&hZe0&Mhx2ySgmD!CU zH(>5?XF8xZdcL(Ovu#u=5jj}Mm8$t+ePGnUh#h{Jd#L^NpvihZR6_d4o;q`tO4E_p zj`iD{p-VNMB=DfX+EZY)TJ#Ta%GQI;>MXbD8tG^!j173u@(2XrHz8VAtTX#=B2-lT zb>B>QJ3*`x(9O}aOYCz@TPWQ7)LNE@>_fA@#%$mRs4XvyX^^#+@(Mk*ZE8%}lz zBMBLGYOLD<(yRpdd}4SwnhGQ_nCT@vuuI85VBVG^S9bx-(e5njSxn17Q5f|U#u#(+ zC7VvJSzUUKepikhX!YY5{9_F$@I`1>hCS?OuvR<9DID0=R`Tf&E15XL*U`z%;n#I1 zLf*A^lS`ZcHLciZfl(rN zEZh6(;l4t)tfdD*|CPsl>W$`VGLCNY?>tY}?mh#rVK(4E*Wi?cg^`EVWs`BoPf8(} z`yFk_M?63)dni5?$=2&R;p9sGcz-=^u`lqhO1zItY1zp>`_D=h&DiMzPc$L{c(_>B z*Itc?2@W3!aOdO6 z;fOqWBY?ueb#k-1Pn=&bx|XGzq1L8B3)9j8P)#~kXvShaM_KU%T}bv%pj^+V$6ekI zmbS6yDa5_Djl*hx<<*VtTF`Chl+Glg_;z;n)q3_VvG%Y^r6qc}MHX(*WSO%7sqJyQ zpW$@8EWx_X9XUT$eLxBeaT3%oTdgbtl6OSJ1Wk9|EA(}^poJV3Lsrqr1%lo_4MG~udCI=pV= z{>Y18TTvV~h`yrX@+y^cRw&L$2eTCc?VuHM{={boIK{@PKT{dEHsjqVJ0tvO6$I`5 zrk6hJ6jvvc(UZ4XYu8vx!NF)6wnoXc3#x!M`~dADZu@YK4{b$V0b6FK65O(`#Pv?# zehsm8Amr9j@p;J7seU+YwWAhv{(VP}ISvOF6ds@E5^&|%Q@Mu}Pe$(#2jX4h_PFQw zP3;PeHX*6sx0R*{9(XN#MV5*IaraTb##zwIsOQ(VApXTD zZq5qNhOc*Rv5a!!w+)(}YyqPF{=f+*noqvnibhZ>+C|Yq@ss08YH)BHyFi~PK2tSa zkKRW|&dO0a3s0KrJvj=GV%x-W{u`O$0w7;5Z}-)pyYz@)7f|ImVj%Q@hP1bBE{ui1 zUqSJfPi>IokJ`(rv5_vdJ;<7BU-o*k3$PV~zJLpB^ZC*Wh@UUMLC}54+dVzVr$3WC zB*lcM0V=|`cY(ABpxlt#ec~l~+L2nTYmUOiTt@uqlQ8uaJ1s?dBhjZD^`@u4b|S9@ zB?Hd>O1CZ!MOqzT$E+J=A(0R_qO}ljF)DN@%6h`Ko2#`b2o5Ph3~}TC>`B-ATBY+)Tm?#%R@1_Ao_%2P9A|kDZ=NMW+4qe-pv~0ZOqDrE#uN4Mw zseR(l$3s!{WzGfxVfFe|3~l?sIp_H<9VWJwE!8J;nqJHc+kStPSFFYD>@u7)eU1Bc zK*k+=U+~}Py3cl4#VmNU*5lYrFKdn`m#uzU*-GU5RPvDsrmtk^aN8AxSgY>e69LIg=jOODY1$iqyGGiPSd9s*3R=}h{#6b-HUSR_#$QO=VGyC3#n{&+Ini$nU zCLJ23c$u2BGdq=Q?4Zh4Nt)Q!${^XqFc3GN;gGmv$lA^N2k2(uv!MMY zZQ4RxfTay^!aa2AoTac*s@fk1DvcqZ|6xmP|SO&P5deGjdJzv2% zcT)kKzVF9t!I!<~5wNp4Q^O<-g>1Obg@a3SnurnthD#rDb9%<{sVaBlI$rzs>`nB< z@xCkOoO}LytGT(*Ls4K$6PZ%VgRM~GXz#gJg7~c1_}0_#WB(MEUv{Xuxo)5CIgZOJ zNoKm8paQL=1i8*3-_U~PLZ9pZS^n(>>#9*?V1ASuz(!MK0(PN7&bED=Fd!}bo-?g=P*f&oln5jzh5y@Kbj=SBf?Wi+;(bNmJLDc<7M|KR%42&MVsfi(#j1;M!2h z&Z<4S7GkOy;Je{?Mtg1NM7bla^*83FsEUzKFSx81k#4O*f9iId^J)+dN%ncj^-X@h+2bTJ_bQwjDpQa0+LO#4bd1p!8mS7C?O@5E|_f*cT9pMnroW z18`YaA6~*&3eD4Xb`V;;qZaYX)OCp$mi-fpnw(k+jeb36a>txix>9WT4aMA!fRxs5 z@-%{6VS~5u$DC7>2`S^5a1@?cZAoLaw&&2CM~`#7KV1!);~D*Ci;ID^bm-{RRO!c% zbm{t9%P15<%fG{fLqw5qjdPZqkdzRzQRbf6@B>3*UZ#PafR#T`|2UY0`X?ST7wb%>CYo z;>xC{M;ea)_mF?p$tuz9my$`3Q z3c9gvyd@60{;%o`ZSz8vafiJZ8Md-5mdv@GX*FaHof63%XUnt(coVN@zMRb(=Q)1? z*1}z_lFl;$!{C!LyW`g$Wr8$criOirq^j)Ygd8%a^A(qr*7*j+AHkZx_}Yy^k`x$D z7Rbwh%s`v@*z6n)P@S^a3H7TNq&Az}3r z+P9zPg|-@asEe1$)8FuNh7LF0^o8TOk~vc(Bm%U`?MVSu_UlF`uY+Oysv&Wkw(L2O z-HMe#s+m<=w*A6;$GNfe$ksxzTco>yI6B1ta6gKg@Z%VwNXvJ-ml|crly_mip}9 z{bRIFT_ZWrVyxM7cHs0GN5fRzg?C5|^JP;%7E=AdSh;-G6;I{(kgW-iq=ru{NtB74 zcV8|j9Vwu-{PV0wEF(V~gMv|ps^b;v`Pxyk+f#;5#A8z4Utmxm-NVT5rD{_Atu-}S zaeH~4auicszs>?P*6&as5IU~Izb9~Wr)gKF3G}-$KcBX1JP3Ic0U1B5$~&vytWj>k zjY9_3F-H9emQzv9`mYkCA=lXRT8xq7=A&ilGNSrlpePTyw^@n2> zVmSB+J}my^weNrqZz}h7e`F*P9I=a(e2;7pB%4p!OoXlF*l5}FEcYTZ7#v>TaIl=K z8K(YncE2BFcZl~#v+5VE5-(lDMUfgOU>qmeLod{T?>D#mi5uwFCbntCdX|vNyroL} znZ-Ncdj2Q=>S}SpU*YV1kf7M7Q*yr zIu31vNAWp>*D8<_4nkJy*{JJz4KI18rcV$f z)MQu_b{bvTC{S*x3HR=yo(+hp$X*x%;{$v;>B%2I`X|dlen+{eoqeX>T7S@qhO#>1 zF}PuVvCTN&*&$ozz-G=r2(73foSUo4A?UTumKLPmqh7Asj5C_Oh#)a=hA5iyDuWF& zE~3WGLnlclLA;x2?~ZO#qn96j52UjV(66#q3rJxN&#q-m6ESNaUjUR^p?p_Y8< zGiSO=_JSFN2cG;JYYt$qx+Y2GGYNEv0YUV=r)*Q18!jYh=PSe2f3xAM3<&2BZ%3ak zdv4pD$!Cgq_$V2;3`}Z0W9snnq0^VF2P|ar*aY+%Xxgxw?ANxzW^vKTsXJ zmlql9y5IA#-|IIkgn-QkR9pmRYLPG7jXwOL2hh~akE5+yVOc(;z#cdKLNsZp#49JnNm-^`y)PBAXoPn5hYo2fSM zv6Z9y5|IsIVU-y6M*VEfiy)^yHe6loxw0HlJ((OTNvv!e*~-d*IFd{aU?+<9qNv^Y zO0=E3iVlcs_VA*0Kn*!l#b|@s-j0V4=RwA^x%YLlCuqbx!wM&w$Ndm0&S@`{pc|+7 zL@?KIr1*fXIZQx+*KWjgV(xmv{Jl|@f>W6~f(#BF#omEHNqdtx+$O5ASYRB*duz}9Y@=H1ptefs*H z58tA{-1$29jzaU%mCq`1k%g6|zx>(dj{UpK1@$8k>s!k@N8pAn1mNExLy(|+y_G{^i;D*>?loQiv375jxiZXyJ!hkn++q0-UGe|?()GsSKoSxXjyrGF z@$m3S0F81U#|KIG5Z!xsE(dqhaq{!?0rBn$-5rHjI@3@7CW_<=%GrF?+@QZX4Wklt zA|kr(V85fr_rE!%|3{mAH>cqIQrN!hyFS2RpghlQVq~(av-3!rrv!W@|I^3Y{J3R7 zC??GSjORbyQF%fy@Fit-dsl~4L_~av=#S5L18&$8?tPWL)h9>&T|0p(Bz6)&egFNT zzB%n_3;D<*5f|52sh3&kRiNL*_8Nv~`Uz!(FJiaaj$xUd!%y!$MSdl`bI4 z=`T0eAb!(!bHRO!nIQUabW|hU#ul$8XqU_AkTO)tWu_6SyuY0@+|eO-3z+~ov3K}? z^~9uF_S3{F(24u%{S91P{23eTWMRQ{6g>e6SR7|IP$S4Oz5R5|71sDhx3)`#%~MTI zcXL!rPoEq?>o?tY z;{m)QcV=dy!4av5p!AzYz!^QK?%7ZPUslcjmIrZteUZ4u#nL)HHW!5-5jnpx-uiLP zDMFO3%?(%7{w_b>ZSd50b^Yj$a^nJM6=@*J_^j)IcaCa^iHRF~^vt_W!`Ll=VG$p< zM+~4QO2q#g8en3t)Z<6&0x%7OCV&SKIo#kl{L%TFgV$+wvKsZBiCa2#2_!h!otAb- zyZg-R>fBIKp+neC<|P-`(9Dd1-Nk^Xa$3guzn2=%aPLe|-@d#Q@DD{e%<`4SzHH>< z<@G&%^X?g;=Bn|{IkL(Sa)ukVyzu@(;rv-l9NKc z9=yf;2bU1`9E3YzNLyR8gZ){Y8lA&7H@1EEU+b;o-ci2geWg9nRl@+2*hb_w@Lu-@ zZaxI)yW{`8SBgRt1f-rcqT6u8tIcV?MJKrf|H%OiHzolD#n+-+hzJlcB~Cz~@zVmo z&gQ9wlm{?y&c3l0vxZLxqhU9kr!5M)QdCyRRRw3xKtyZQBt{hFS*_3Zr3o4)|68SsF}bqDZD z@rj8c48L>U|Ia81KXHH`2N*{99B&DK`DYRXk|5odoNiA0U>Jnm<$hR9!z+odF_*)~ z$4_I@BDKna&CJB!kz8o>&yC}G*SE_i!6h<+1ig68X2il`_kSN20M4v$Z({%hN|{Is z-Lrr{K`$1f{LcH>Uq@_K=t7wQ=@lc%6}Mkt}u; z1pbs;V)H+dprJsaBrosc>A8c39Ym0`tDJ)q2Llmw{si9H?>(+B50C`~uR{RKmde(m zOufb(*v4SIQ0@nXlF2et0I9JfYF!@Cgl78-YE&wCj3}ydCD!ybVJ`wykp5oLG`eCY zU-2qaOePLDA|Vn!8V2wcE>63??+)Nwy4KEZl(zcSnwXgU`O_Z6+I@7D+SJq(8ygEq zm4{P!mTCf&#;JGDUVOT+TkOy=&T4fp3U>h5K!(c<~j*21wGv@ zdgxlBWT~_$zrU1R2@2ndzX(=>#bp)S)_z=FkG{(tBa@~efQq-(cu&@%+4!2z&mYnl z8gf=mf7ukYFNEX;-V=9H-kP>^s=DEx@Evqq&`U$*L3u+;f1iU*m`G5g#xo2YY){ zBm-IjtjEeg^6vWrmAV@=a&_tB8aMKu*(&#^5#YK2d@)N)OYp6krj-9HXHG{so?^eA z)GYNeeA&P(8pn^AulVnjCRZ9;fb!Y3s_Kn;aYBO>Oj5ON&>U&@lFWmG0Nxg$x&64V zHgQhXpxOa*d>=RZv*-3)ozrN-%TDby40jdBr!C+LlHDeApyLh4+{~;-oKuqFdTq`8C8eRe+Dq?bPZoAtb>Ex_zb+*EFs}KJBB+MjOR(TyKI%)D zd9Ydn!|K#YBh|>%dN<@zZwqQ8i*$+tb10}(Mw2q zpTXRXo+vuI`JG|NVi+rnR?(^0G&W7i74$6@UJi0I7)+ zU_$`B4*;+N)OW8BH%NcKFFOWITue6~SD%-&H!eU%?MmM~Bfm6zN6ipw3tMZCninVu z-J4&70l19}SHr`<)vB;Nx#apso|Dkg%2Kf6BWgrLhu$xPBM z(ay6SN0q{Vk+s0FXEg}fc=LO5uKGmb>#{nk>DhD`rKeMRCt-Sx*?5sTU3+`S3n_Ft zikT9bQscfNBNa%t14HqWOm3CDb4MxW*rPk@B7<#4U#1)VS)?|yB~RwON2 z%(JyXAcCOTfBD69qeIUXJG_Z9JtnMQRD^!RvCU=W^7#RU@t5NypP-hV?-Ud0O!^G{ zSk!oam>}00+h7Wi&8|t4#Sn#^ZW)kHTWDh5N(Qh2n-5cggxvklo-f14QcBgCJ{R{+ z1PWp)bwP3MzMTCH1?t_HYut5Bm#VOu9eb!&j>TuOUq>Cej+|Vess@aYI@Qt z!oxGX zH$NJpqmAliD^gwDH2^3Z5{1kY<)oy8NIGI4OHR{Qf$NW>1E9Ye=O%4dy zbHvETmwx8=C22ume_KODtzKc2eU^70ZN;%)Lop6$E=3O3XmpSI&BY>D%MtaGWV#MzMBPG~d zjBc)oKqh!2%=m*<^BSs|SBZ7}2=vrUT7`qwqGX7FvwE|>Yq5<7D{r2-(K4v>>SNaf z9~ym|cPpVd_dH3?3 z>Ao36`FZM>8P*uZV^m*TQ7yJO>wzPnlh`_}s23lor`|s7koRv@oRh@qtiFr#OMLAr zVf)z@39a(__4c7EudrNdR-ov8u|rf3vn(C8tDGfg-mSjeePQlv=hUe{CX98b7PiMs z0eGOTPj8$o?stCtp*|@iNB12GBC~EMJxNSQ~Zfv4W}V z&*|OV)m5bs%I_@G=fVk`uhO-HM6P2h>& zQ9}b{Y`{Zy_~PI8)!hp)U~fqCIWGpF)&fK!@e6W z93jRJJ~RR?z#~{74L#P-dEeAyERC|`Q$m@hi{}UPCV>%66rBmq=EpOUwc+V2MQpOo z-GshI0i3N}uTS4Pb7i^f`m3}2d5>8TZw$=xoZTc8H1>N+lTFHR#&F-^L}g8u1?7HG znIWACNCP|%xM&6G8cAqnq6rU={}7EUShjy})X^pYjZAVGBKnBf!}G`Xc)U)A%FRmj zTl@dvt0I=V4hRB#Kc|(FbWUWtGWyD8#^M|u*@LzPP7CUxHkNq{t)Agzgew2MdF@Tc z#|ZKZE5{4KyEyt)r)PZ&Zr$g4N0rKVn|~WyXNOui*9E6kuZ2;LT9wcE_eVbYyAE{p zR*cmu*Y~A!Uj&bqRsa1a@p5$Zc#TrcvS?~?8g_#G{eF(1TuO2D{Oz&2hc^JBp4(T~ z)NGIn15?tzjl71umI|Ni+Xa!5 zLEo2J&gEQf$I6+4i_s%}1q0xL<4x@cvO>doFY8>i7ZG!Xs~=5>HmcBC|+vU0krfy~sG(#dZ?(aqVqXuWf9bo7*!V zlHDRC`vA{}!T$co$^^gxDuE^3NKW|{6g{y-N|!qgoS-r>G4A*_SObC`O>2OnJ>bMe z$2k`+1Q@l(tMq7$X#go~)@HhGj_Qq4Vd=%r=PMG(%_b&clv{LsP}}d}-vLAP?6;M3 z4!p>sT9e4<;?HEuC%;NE=9vVpZBKfXe!Rlr;;s2XN%sZrOkOs6odD6Kpz$>JSpWbY zNg!ImCV63Sk-?A*?oQWlc^&+g*K;RYMMd(&&kaR2D}P~ltP>LVqtPD-W#9R=O&?-UZZ?xd9h6+opE&Ax7hojxmj|iv0`sG{5@E`4pOJs z#SR{^kVpvzHTjY^2yJm`J~0-31=j(3LG|waJxVVArL4cfmp_RtU9-QN@c zh)|T-882 z1NA=>8!d+JlKC|JW3-9j&fkXXI^N74#}kt5d1h|>Ed5!yo|{fdCO2@@@LQcia}kFcSQalWIr>FBNbWqA2Mid}(iGHp;}$w^4MQ7H4$wWo zN zHPkY7e`rl4bghdri4Z+f{Hjwk!*8B_rmtBpeoSvp1hu;+(u2gdcR%Ehqk_VGH~L8F zJ|mdDp1=;ywt{*kk%12rnILEh=MSWzqY#YCFak7&{D$8w7z3x7wwTyxLHKoYuR$!N z7Jck!k`q!_eKTR@YM+wF1w86f?Ff?{P~3xWyDQfKV~u^FNG)GA>^S_Uu$fbchC2Jn z(uKQOmM{O+F;KwtpPgPYbmqz_{N81TrB;!(&bd?7A z!GGr(D@HB)B^)Ja6za~~mw1#l`6A@}Y&qO`piJiEP{`-y8!_BzlGQ z21g$a_P0Ja{pQ5O^0YY?{;bPpHfV-4;CR$L#VI{oo)bHCgeZ~S*hn@uz)XC|9-Vq_ z6@SrW{S{m2g#OkOy#^(|tB6;N8Vvb;4((D`isKMk8|nOaXBr31S#wk_K;0zS>e% z3=}kJlL}#`?%mlb?S_|XK8aE8y`RBN@DK??s`@W3Ugwa-R~yV^Jr8|{cJ*~4P3Nw? z)fedkXY&FZhzj-&MIp6h3S`59Ja~Bi!`eaVNt0t{n+=(M3w&!#Z0fnk=4me01jDU= zm~Y1Z0A^%Tf?}`v#bWjtwi+X{<{Vs)E7!~Hrfzz3A~Z*A+vBn&@|FP+t^>VxnyFot zADLCX_j_U4fE?H%!<7Z}f3f$Le^o_YyD-K^Qbb@QAR#TVL6A@y0qF*j7Lo4m5b5sj z?(UQpknZm8-t?JxKhN|20q;4#^X2R>=!&`KnlZ*)V_fsPQtnS^v(Qf~t9BW#9HdG76x5a8QFzw(^UDIN zbA+7%4OFH>0gY%R-8Tq2y?=X7eVtD>3Mrg};?CI(*f+NSLMArOKg?PKQGf_SLLf2U z|4+D%dUvWe(i$Ca@A(tuNZlB(_dkejq3fC)8P<4SxnHX3ZAe zOxiEW^#@_aaIfB3iGv1uI^xf@85S!)C*~}O{yu063Hw-9Gw#U74eRCVa7A%E`!Nw5 z+*D(41Z=#x<;d+tg2f!?N6OBNpJRf$M-f0r98G8YwtlblS`DIhxWEP!Yh z!ocGA*K%$D`5#RUV#99ZUv*f|)sA`-%hNd37s|~S=}Rzyk~KFs`$&vto)qW1az{wk z-Mbl*t+)qi1|-hXW-?5eKp-Ddru~7uHY6*H?CiaaJAdE6>$k;vGMxbEzHh zI6>bMOs#?FuJr3n2J3Ej5H$;rq9kPkVRpCgk%~|!s$Y(WW}mk;Y@<%DFFz*`9h05{ z(g|~rs}yY0VhGOC&2kmFWE%ax2;_opmKQ#|_-r~BY{WU{=e&dca{MOJV!{S{dAzMN zptFYO?5yUc|86Zsn&v0^g(}+(#!{(;_2~&t+6Y3UGK0aU{B{}8qTLIi6ms}c_DBl6mfb&|OED@H^V)a&+J(bsI8ymGo zQ$9(RhKj|~HhGzLE?`G3%P$aqv5G}eFdWa*aHTGm+?yT?=+!qg#QS1ww^)@854B~^ zJ;{F-4FqX^m5t5ZFk0U^4ZY3|G#RS-GDeeCZsVboJf0&AMYC>XOA!?O+#K+eUaM?1 zLYjt85}Hx@-27|noPorirWDTSXMtc_w<1946ioBdIyURH!GidvD$wF78?hin#$ag(8=;9_>pZwD;n%Oxgj4 zW2Wz|ygak*dH|4{I+|9D!`eupSiRC>t=%8+{-_i=?0t5vAaHHxTiIDCT$Vb5gbg(e1cdhF zyqSwN%9*RVK1^%(j^!-W+~(zfLVt;8=Q48|6=>ieEhi;4@TcZ;dC(j*vCQ|+3cTz= z83yw9;*nKG2}lUcJga{7R2mBi03GiU*zO(9z^e&}iw>MeBcwc>CJw$91L6<8O>a}; z9NW;doAwNWg%27I&Ua@@H20qGA?1QiH7=SMj66H5RQF&0hR)mQTwlmWim|pfMx4CB z-Q2S~YPB~pNU$8YeeJYH7>su0tka@U1I(-;ue?Nz*$2dCfA?Oh7ZdE9olXOu zisy-SyI7>Uw4TpCBrQ{Zhi$xh!^mi=F_naAjA6039NE>MWX@Fk{?9EQY=cjR(Bz{j z=Xt1l|INd9z3|pheu?i_*`s#d_NVI01T=b_Ru$Hu1B`1j~sbduidhQ>+bkk^>Npj_;3tGXP}4zbp|Fh&F+kQ>u^%xb1Q-vSx*u zxAebLNIo1F#BY!2M>v6)>#RBcgDkN7I!H{x-`>%W^l&MKP`^Qa2?|JHLXrlN=YRR6 z8*EoyzRcvv>;$@;)2>v6|9MnMGth?3-|UA0fH#=^LF^Aft|w(Adh|c|+-_u0GU$YT zr8gCcGC_Rt5xFRj7y7?M)k7^q9wN0OeA2bS1ekKlP;%BP8xVW>gn@wl@xM}9mx~7r z*=ded)bCN3r`i!ZK!ZHwy?E7KZs6I&$$WGou}UEeKex8_$WEW|4yDm;?(Y-flMNFI3rDs1gWRBe*=fiF_sMx|x8zrpwn#{I>H} z8yrP&kuWfrPP`6&ov7+Uea%NED<1|yhOu|>DWd~_4}RGEmjnItGJ-${EG>^tc*l(@ z^x^Q$*l4MN4?awBaR(m6!NJL7290p|HRJUa1pCNbQ$#QkB4S_wVx8QW%OQ;5f}1bY z=Q1d(1BvyP_cEb#9Fz%lLJEANV!K>ggp0%b`kc(@_#?6ugp+N2gZ zD$v!;tgJVfkAcQ`_vhGP9)=L9pBhb+x%?=rIF``WBgR-M4=`3Dp6B8YJQRV@CwN48 zbSvcNW|KyRDkt<-8hs^D`3E1PpBsGUJcg15UlSgG(|WF*;=E=QTP+nPn`hrphTsNFli0U7UYp9Dy8pQ9k3oS`0`o|{`)l$P6O z>;nV0Irv{MLbGRNv`1|%ePd>Rmh$?Oz6TRB1y&CgwtUX@-EI8~4Q7uk9ljpd3O@bo zo%P+&6!MU=zO4{#v8rPck?kg9zB{buO#8hl6ar1p})$=tT z02TrlityFGBV~$PIMLQU$l>-anJin3E*4qF-rU;zh4j$W%}-D~m$^J6JUxb&<W^vyKbZR=pqJbhlne~w zK43y8WLDC8-7BhW((2ntAP?Q{BSXI&jE~#GX|h`BgqMO{S^ewg6-C|KTy^*V zXHHO7fKWN<%T4l0VA)FsPa%77pa?HPM$=WvVaxQ%A#dR6C6Xx6qArB|I!E3iHL$+W@lkf zah3hMmKGQi5bf_tub}(0poGuyZ$8QYY}QR4F7A<*gaQFJ4hY{a_QSl9CL~bd_%E*- z4tWcag=Pp)pFZ@aTiH=PVq|;`aJH^Da4N{ZtlB@rFYop#I!0rpLaN|La{}g9q~k_Zs}A3t37g&EL$C4sc(;Ex8>!{)$b3XeeBj^-4@_>s8MNl=2=rP_qm)J9NNBYd$JL`Ht@px!(B$j@}{dlA*Wo*G8Z#O|eW(fBMFMUb?= zX#f2>LnN1I5FL_VFDG%Rv(e%{R#}6L{+uvf!c7x&JvDIn{@MkGC z)H_CvK++TdE5Wq*6wGuO8=MwAm>hWgBAExYH63>3hh^&w+)pyV@$i{jl>b{%6FNe| zVq@{Ju*xmgdL#i%{{`UgpDNMhcXHzFXaR6t&Fw=D@R3eUZx9NJw4iu@U^vXi{X=^C zg&+B$kYC63-{ZQtNQMXkyuH`2DT;+c16Ae!VBko#Ofa|+Yxf2-OrFLz-EA1SI~pD75dzT)V7+ckmZRR{HZw8Mr1t_l8evd zw@WWhZ3Ch`ii#8dC^dl0AIOSia@pEm0EU0t{aJf}z6L;!Inee2c<&#S*a3Rz_ix^~ z#b^c%54_)|0Qi|wb7*@(dqb!OI=8OIftnF@y;&>Z6JoL>e7@l&vIaFy2X)0!x|p@B zkQT+xAW0ANvU<}B6)krRt>ayqo%EeOJ%iJHrt1lwTEnuv^|C~JI8dQRHP2Daqg)Uf zA296o?-e^Vcj}9jf^wQIA~ZWPxc+*Eaei_lCMv32XZ4nX2cU>H4m}#xHJ&@T9tC{s z0~;32fRa6P%jl?l!UuT$za}2tmIb^Qx}J9$XFE!RVB+~=r1bKj9?S4M!9NrWwBim+ zr)mhiKHh|&r%5_Vc>Rz-ab-3X{plp(P^#Uf&hboWyPb;em?alr1A?{v2W)RDIR!97 zh!c}*W(9=J!=bVb38P1i%aR7wuOkoh#R*|mcBW}Yud$!;DNDqC7|V9Ov=dFV)67X= zSaR%uO9g@Sy1VeDwkaG)f{E;U83^hid%Kd6}N$#;*( zXCUM%tr~Imst$}@FmS=}{UnttW3a^UHA_N)!*?u7?(_y7=SmPcm?6UNi&NCgE)GVQ zo`_iTk(V6+wo?%@N~r?qpH2l zO%T;6uFU(=WL3}4Gj(Pt$=_; zK|#SoF8E|nxP?j=V^BAy%Gss~Q}pGh*RVQyZBc>=4d)IeY8vIFiF*V2AjYDF+Xz}( z>|88h_`VA$y`v@<-@H@+u@zCykK%$#A;oX&n?(~`GQRL}YWrz_3T+5qf)g4=u2>9Nw<`9wdzYu~ zJ43@EjROqd1Q3m@G5!3a=Dcif9@EtW-BX0x+1mS@59K6%>NrZ$OzA#HLhm|4>5IwR z|Bbxw_r%2ObQQM**e0I9uPV3Mhy}j4)yB9DW)@iHtls|*o_li zUoK65Apm|)`p)hd#|=_HfF&4pO(TjoEzN-mbQhRi?NhXTeGrSbbs z`Eb6K=-O+r@X5o(THn~cX{8lk-mDN7&p7eJq+E$sLb^40u#vCcQUvIKhOUmF-S%G4 zBtbeDsf+g~%18uxxeF+C2O5=BQWdhyeV_6;BdXfv`@F=;Gdj_xM+I|B*0P6Rd1LHK~V#|M)P@%d|b>)1++#<34oa_6KoJ8{JEainFl z%WY>caWZ{aObj)6i^p?xjtUHYqj?H}ngQEEPOphzXw(K4W#5LrO}UX-iW4yFHSWu# zNN2ox@TfArJ?e*r2XCkgz-wBT9E?UF1~JpV55X1poI5Me=nZA^yX z(X1~&K3>lGVN2<=c#`^Z#f5n;=iXXnXVp+PrUYeC_J@XHJ*^Crf;QM55NY$he_w9O z6CN5G8XIfB)anV)k{5e37sru+8sEiFLYPxub4X$p%zNe*ji!!8)bh}2v=M{Ysm>;} zHa)4P7o?!x9WjtO+=>+I;>J$UzzEn1!(}P(E7e>l8;0x0Dn}okmU+oy)u{asJtJg1 zNPYRM?bSTq2qsv2+%*i2Y^U`$^zs{yFHVℑXS)Vk;Rl~;3MztkP9bh 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%0A1mV>`zO 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 + + Eventual Shop + Copyright (c) 2021-2023 Antonio Falcão Jr. + Antônio Falcão Jr. + net9.0 + preview + true + enable + enable + Linux + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/EventualShop.sln.DotSettings b/EventualShop.sln.DotSettings new file mode 100644 index 000000000..9de99b108 --- /dev/null +++ b/EventualShop.sln.DotSettings @@ -0,0 +1,3 @@ + + BRL + CAD \ No newline at end of file diff --git a/src/Contracts/Abstractions/Messages/Event.cs b/src/Contracts/Abstractions/Messages/Event.cs new file mode 100644 index 000000000..e328b8c6d --- /dev/null +++ b/src/Contracts/Abstractions/Messages/Event.cs @@ -0,0 +1,6 @@ +using MassTransit; + +namespace Contracts.Abstractions.Messages; + +[ExcludeFromTopology] +public abstract record Event : Message, IEvent; \ No newline at end of file diff --git a/src/Contracts/Boundaries/Account/Account.proto b/src/Contracts/Boundaries/Account/Account.proto new file mode 100644 index 000000000..99542134d --- /dev/null +++ b/src/Contracts/Boundaries/Account/Account.proto @@ -0,0 +1,41 @@ +syntax = "proto3"; + +package Contracts.Services.Account.Protobuf; + +import "Abstractions/Abstractions.proto"; + +service AccountService { + rpc GetAccountDetails(GetAccountDetailsRequest) returns (Abstractions.Protobuf.GetResponse); + rpc ListAccountsDetails(ListAccountsDetailsRequest) returns (Abstractions.Protobuf.ListResponse); + rpc ListShippingAddressesListItems(ListShippingAddressesListItemsRequest) returns (Abstractions.Protobuf.ListResponse); +} + +// Requests + +message GetAccountDetailsRequest { + string AccountId = 1; +} + +message ListAccountsDetailsRequest { + Abstractions.Protobuf.Paging Paging = 1; +} + +message ListShippingAddressesListItemsRequest { + string AccountId = 1; + Abstractions.Protobuf.Paging Paging = 2; +} + +// Projections + +message AccountDetails { + string AccountId = 1; + string FirstName = 2; + string LastName = 3; + string Email = 4; +} + +message AddressListItem { + string AddressId = 1; + string AccountId = 2; + Abstractions.Protobuf.Address Address = 3; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Account/Command.cs b/src/Contracts/Boundaries/Account/Command.cs new file mode 100644 index 000000000..377364dd3 --- /dev/null +++ b/src/Contracts/Boundaries/Account/Command.cs @@ -0,0 +1,25 @@ +using Contracts.Abstractions.Messages; +using Contracts.DataTransferObjects; + +namespace Contracts.Boundaries.Account; + +public static class Command +{ + public record CreateAccount(string FirstName, string LastName, string Email) : Message, ICommand; + + public record AddShippingAddress(Guid AccountId, Dto.Address Address) : Message, ICommand; + + public record AddBillingAddress(Guid AccountId, Dto.Address Address) : Message, ICommand; + + public record DeleteAccount(Guid AccountId) : Message, ICommand; + + public record DeleteShippingAddress(Guid AccountId, Guid AddressId) : Message, ICommand; + + public record DeleteBillingAddress(Guid AccountId, Guid AddressId) : Message, ICommand; + + public record PreferShippingAddress(Guid AccountId, Guid AddressId) : Message, ICommand; + + public record PreferBillingAddress(Guid AccountId, Guid AddressId) : Message, ICommand; + + public record ActiveAccount(Guid AccountId) : Message, ICommand; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Account/DomainEvent.cs b/src/Contracts/Boundaries/Account/DomainEvent.cs new file mode 100644 index 000000000..74597165a --- /dev/null +++ b/src/Contracts/Boundaries/Account/DomainEvent.cs @@ -0,0 +1,35 @@ +using Contracts.Abstractions.Messages; +using Contracts.DataTransferObjects; + +namespace Contracts.Boundaries.Account; + +public static class DomainEvent +{ + public record AccountDeleted(Guid AccountId, string Status, string Version) : Message, IDomainEvent; + + public record BillingAddressDeleted(Guid AccountId, Guid AddressId, string Version) : Message, IDomainEvent; + + public record ShippingAddressDeleted(Guid AccountId, Guid AddressId, string Version) : Message, IDomainEvent; + + public record AccountDeactivated(Guid AccountId, string Status, string Version) : Message, IDomainEvent; + + public record AccountCreated(Guid AccountId, string FirstName, string LastName, string Email, string Status, string Version) : Message, IDomainEvent; + + public record AccountActivated(Guid AccountId, string Status, string Version) : Message, IDomainEvent; + + public record BillingAddressAdded(Guid AccountId, Guid AddressId, Dto.Address Address, string Version) : Message, IDomainEvent; + + public record BillingAddressRestored(Guid AccountId, Guid AddressId, string Version) : Message, IDomainEvent; + + public record ShippingAddressAdded(Guid AccountId, Guid AddressId, Dto.Address Address, string Version) : Message, IDomainEvent; + + public record ShippingAddressRestored(Guid AccountId, Guid AddressId, string Version) : Message, IDomainEvent; + + public record BillingAddressPreferred(Guid AccountId, Guid AddressId, string Version) : Message, IDomainEvent; + + public record ShippingAddressPreferred(Guid AccountId, Guid AddressId, string Version) : Message, IDomainEvent; + + public record PrimaryBillingAddressRemoved(Guid AccountId, Guid AddressId, string Version) : Message, IDomainEvent; + + public record PrimaryShippingAddressRemoved(Guid AccountId, Guid AddressId, string Version) : Message, IDomainEvent; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Account/Projection.cs b/src/Contracts/Boundaries/Account/Projection.cs new file mode 100644 index 000000000..d1d3e9d71 --- /dev/null +++ b/src/Contracts/Boundaries/Account/Projection.cs @@ -0,0 +1,38 @@ +using Contracts.Abstractions; +using Contracts.DataTransferObjects; + +namespace Contracts.Boundaries.Account; + +public static class Projection +{ + public record AccountDetails(Guid Id, string FirstName, string LastName, string Email, string Status, bool IsDeleted, ulong Version) : IProjection + { + public static implicit operator Services.Account.Protobuf.AccountDetails(AccountDetails account) + => new() + { + AccountId = account.Id.ToString(), + FirstName = account.FirstName, + LastName = account.LastName, + Email = account.Email + }; + } + + public record BillingAddressListItem(Guid Id, Guid AccountId, Dto.Address Address, bool IsDeleted, ulong Version) + : AddressListItem(Id, AccountId, Address, IsDeleted, Version); + + public record ShippingAddressListItem(Guid Id, Guid AccountId, Dto.Address Address, bool IsDeleted, ulong Version) + : AddressListItem(Id, AccountId, Address, IsDeleted, Version); + + public abstract record AddressListItem(Guid Id, Guid AccountId, Dto.Address Address, bool IsDeleted, ulong Version) : IProjection + { + public static implicit operator Services.Account.Protobuf.AddressListItem(AddressListItem item) + { + return new() + { + AddressId = item.Id.ToString(), + AccountId = item.AccountId.ToString(), + Address = item.Address + }; + } + } +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Account/Query.cs b/src/Contracts/Boundaries/Account/Query.cs new file mode 100644 index 000000000..eef5a55ee --- /dev/null +++ b/src/Contracts/Boundaries/Account/Query.cs @@ -0,0 +1,26 @@ +using Contracts.Abstractions.Messages; +using Contracts.Abstractions.Paging; +using Contracts.Services.Account.Protobuf; + +namespace Contracts.Boundaries.Account; + +public static class Query +{ + public record GetAccountDetails(Guid AccountId) : IQuery + { + public static implicit operator GetAccountDetails(GetAccountDetailsRequest request) + => new(new Guid(request.AccountId)); + } + + public record ListAccountsDetails(Paging Paging) : IQuery + { + public static implicit operator ListAccountsDetails(ListAccountsDetailsRequest request) + => new(request.Paging); + } + + public record ListShippingAddressesListItems(Guid AccountId, Paging Paging) : IQuery + { + public static implicit operator ListShippingAddressesListItems(ListShippingAddressesListItemsRequest request) + => new(new(request.AccountId), request.Paging); + } +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Cataloging/Catalog/CatalogingQuery.proto b/src/Contracts/Boundaries/Cataloging/Catalog/CatalogingQuery.proto new file mode 100644 index 000000000..414e00e7b --- /dev/null +++ b/src/Contracts/Boundaries/Cataloging/Catalog/CatalogingQuery.proto @@ -0,0 +1,67 @@ +syntax = "proto3"; + +package Contracts.Services.Cataloging.Query.Protobuf; + +import "Abstractions/Abstractions.proto"; + +service CatalogingQueryService { + rpc GetCatalogItemDetails(GetCatalogItemDetailsRequest) returns (Abstractions.Protobuf.GetResponse); + rpc ListCatalogsGridItems(ListCatalogsGridItemsRequest) returns (Abstractions.Protobuf.ListResponse); + rpc ListCatalogItemsCards(ListCatalogItemsCardsRequest) returns (Abstractions.Protobuf.ListResponse); + rpc ListCatalogItemsListItems(ListCatalogItemsListItemsRequest) returns (Abstractions.Protobuf.ListResponse); +} + +// Requests + +message ListCatalogsGridItemsRequest { + Abstractions.Protobuf.Paging Paging = 1; +} + +message ListCatalogItemsListItemsRequest { + string CatalogId = 1; + Abstractions.Protobuf.Paging Paging = 2; +} + +message ListCatalogItemsCardsRequest { + string CatalogId = 1; + Abstractions.Protobuf.Paging Paging = 2; +} + +message GetCatalogItemDetailsRequest { + string CatalogId = 1; + string ItemId = 2; +} + +// Projections + +message CatalogGridItem { + string CatalogId = 1; + string Title = 2; + string Description = 3; + string ImageUrl = 4; + bool IsActive = 5; +} + +message CatalogItemListItem { + string CatalogId = 1; + string ItemId = 2; + string ProductName = 3; +} + +message CatalogItemCard { + string CatalogId = 1; + string ItemId = 2; + Abstractions.Protobuf.Product Product = 3; + string Description = 4; + string ImageUrl = 5; + Abstractions.Protobuf.Money UnitPrice = 6; +} + +message CatalogItemDetails { + string CatalogId = 1; + string ItemId = 2; + string Description = 3; + string ImageUrl = 4; + Abstractions.Protobuf.Product Product = 5; + Abstractions.Protobuf.Money UnitPrice = 6; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Cataloging/Catalog/DomainEvent.cs b/src/Contracts/Boundaries/Cataloging/Catalog/DomainEvent.cs new file mode 100644 index 000000000..3797484c0 --- /dev/null +++ b/src/Contracts/Boundaries/Cataloging/Catalog/DomainEvent.cs @@ -0,0 +1,25 @@ +using Contracts.Abstractions.Messages; +using Contracts.DataTransferObjects; + +namespace Contracts.Boundaries.Cataloging.Catalog; + +public static class DomainEvent +{ + public record CatalogCreated(string CatalogId, string AppId, string Title, string Description, string Version) : Message, IDomainEvent; + + public record CatalogDeleted(string CatalogId, string Status, string Version) : Message, IDomainEvent; + + public record CatalogActivated(string CatalogId, string Status, string Version) : Message, IDomainEvent; + + public record CatalogInactivated(string CatalogId, string Status, string Version) : Message, IDomainEvent; + + public record CatalogTitleChanged(Guid CatalogId, string Title, string Version) : Message, IDomainEvent; + + public record CatalogDescriptionChanged(Guid CatalogId, string Description, string Version) : Message, IDomainEvent; + + public record CatalogItemAdded(Guid CatalogId, Guid ItemId, Guid InventoryId, Dto.Product Product, Dto.Money UnitPrice, string Sku, int Quantity, string Version) : Message, IDomainEvent; + + public record CatalogItemRemoved(Guid CatalogId, Guid ItemId, string Version) : Message, IDomainEvent; + + public record CatalogItemIncreased(Guid CatalogId, Guid ItemId, Guid InventoryId, int Quantity, string Version) : Message, IDomainEvent; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Cataloging/Catalog/Projection.cs b/src/Contracts/Boundaries/Cataloging/Catalog/Projection.cs new file mode 100644 index 000000000..ad852a95c --- /dev/null +++ b/src/Contracts/Boundaries/Cataloging/Catalog/Projection.cs @@ -0,0 +1,59 @@ +using Contracts.Abstractions; +using Contracts.DataTransferObjects; + +namespace Contracts.Boundaries.Cataloging.Catalog; + +public static class Projection +{ + public record CatalogGridItem(Guid Id, string Title, string Description, string ImageUrl, bool IsActive, bool IsDeleted, ulong Version) : IProjection + { + public static implicit operator Services.Cataloging.Query.Protobuf.CatalogGridItem(CatalogGridItem catalog) + => new() + { + CatalogId = catalog.Id.ToString(), + Title = catalog.Title, + Description = catalog.Description, + ImageUrl = catalog.ImageUrl, + IsActive = catalog.IsActive + }; + } + + public record CatalogItemListItem(Guid Id, Guid CatalogId, Guid ProductId, Dto.Product Product, bool IsDeleted, ulong Version) : IProjection + { + public static implicit operator Services.Cataloging.Query.Protobuf.CatalogItemListItem(CatalogItemListItem item) + => new() + { + CatalogId = item.CatalogId.ToString(), + ItemId = item.Id.ToString(), + ProductName = item.Product.Name + }; + } + + public record CatalogItemCard(Guid Id, Guid CatalogId, Dto.Product Product, Dto.Money Price, string ImageUrl, bool IsDeleted, ulong Version) : IProjection + { + public static implicit operator Services.Cataloging.Query.Protobuf.CatalogItemCard(CatalogItemCard item) + => new() + { + CatalogId = item.CatalogId.ToString(), + ItemId = item.Id.ToString(), + Product = item.Product, + Description = "item.Product.Description", // TODO + ImageUrl = item.ImageUrl, + UnitPrice = item.Price + }; + } + + public record CatalogItemDetails(Guid Id, Guid CatalogId, Dto.Product Product, Dto.Money Price, string ImageUrl, bool IsDeleted, ulong Version) : IProjection + { + public static implicit operator Services.Cataloging.Query.Protobuf.CatalogItemDetails(CatalogItemDetails item) + => new() + { + CatalogId = item.CatalogId.ToString(), + ItemId = item.Id.ToString(), + Product = item.Product, + Description = "item.Product.Description", // TODO + ImageUrl = item.ImageUrl, + UnitPrice = item.Price + }; + } +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Cataloging/Catalog/Query.cs b/src/Contracts/Boundaries/Cataloging/Catalog/Query.cs new file mode 100644 index 000000000..c689b3ea0 --- /dev/null +++ b/src/Contracts/Boundaries/Cataloging/Catalog/Query.cs @@ -0,0 +1,32 @@ +using Contracts.Abstractions.Messages; +using Contracts.Services.Cataloging.Query.Protobuf; +using Paging = Contracts.Abstractions.Paging.Paging; + +namespace Contracts.Boundaries.Cataloging.Catalog; + +public static class Query +{ + public record ListCatalogsGridItems(Paging Paging) : IQuery + { + public static implicit operator ListCatalogsGridItems(ListCatalogsGridItemsRequest request) + => new(request.Paging); + } + + public record ListCatalogItemsListItems(Guid CatalogId, Paging Paging) : IQuery + { + public static implicit operator ListCatalogItemsListItems(ListCatalogItemsListItemsRequest request) + => new(new(request.CatalogId), request.Paging); + } + + public record ListCatalogItemsCards(Guid CatalogId, Paging Paging) : IQuery + { + public static implicit operator ListCatalogItemsCards(ListCatalogItemsCardsRequest request) + => new(new(request.CatalogId), request.Paging); + } + + public record GetCatalogItemDetails(Guid CatalogId, Guid ItemId) : IQuery + { + public static implicit operator GetCatalogItemDetails(GetCatalogItemDetailsRequest request) + => new(new(request.CatalogId), new(request.ItemId)); + } +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Identity/Command.cs b/src/Contracts/Boundaries/Identity/Command.cs new file mode 100644 index 000000000..4a586049a --- /dev/null +++ b/src/Contracts/Boundaries/Identity/Command.cs @@ -0,0 +1,20 @@ +using Contracts.Abstractions.Messages; + +namespace Contracts.Boundaries.Identity; + +public static class Command +{ + public record ChangeEmail(Guid UserId, string Email) : Message, ICommand; + + public record ConfirmEmail(Guid UserId, string Email) : Message, ICommand; + + public record ExpiryEmail(Guid UserId, string Email) : Message, ICommand; + + public record RegisterUser(Guid UserId, string FirstName, string LastName, string Email, string Password) : Message, ICommand; + + public record ChangePassword(Guid UserId, string NewPassword, string NewPasswordConfirmation) : Message, ICommand; + + public record DefinePrimaryEmail(Guid UserId, string Email) : Message, ICommand; + + public record DeleteUser(Guid UserId) : Message, ICommand; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Identity/DelayedEvent.cs b/src/Contracts/Boundaries/Identity/DelayedEvent.cs new file mode 100644 index 000000000..409bd598c --- /dev/null +++ b/src/Contracts/Boundaries/Identity/DelayedEvent.cs @@ -0,0 +1,8 @@ +using Contracts.Abstractions.Messages; + +namespace Contracts.Boundaries.Identity; + +public static class DelayedEvent +{ + public record EmailConfirmationExpired(Guid UserId, string Email) : Message, IDelayedEvent; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Identity/DomainEvent.cs b/src/Contracts/Boundaries/Identity/DomainEvent.cs new file mode 100644 index 000000000..46139969d --- /dev/null +++ b/src/Contracts/Boundaries/Identity/DomainEvent.cs @@ -0,0 +1,20 @@ +using Contracts.Abstractions.Messages; + +namespace Contracts.Boundaries.Identity; + +public static class DomainEvent +{ + public record UserDeleted(Guid UserId, string Version) : Message, IDomainEvent; + + public record UserRegistered(Guid UserId, string FirstName, string LastName, string Email, string Password, string Version) : Message, IDomainEvent; + + public record EmailChanged(Guid UserId, string Email, string Version) : Message, IDomainEvent; + + public record UserPasswordChanged(Guid UserId, string Password, string Version) : Message, IDomainEvent; + + public record EmailConfirmed(Guid UserId, string Email, string Version) : Message, IDomainEvent; + + public record EmailExpired(Guid UserId, string Email, string Version) : Message, IDomainEvent; + + public record PrimaryEmailDefined(Guid UserId, string Email, string Version) : Message, IDomainEvent; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Identity/Identity.proto b/src/Contracts/Boundaries/Identity/Identity.proto new file mode 100644 index 000000000..b1db5c9e5 --- /dev/null +++ b/src/Contracts/Boundaries/Identity/Identity.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package Contracts.Services.Identity.Protobuf; + +import "Abstractions/Abstractions.proto"; + +service IdentityService { + rpc Login(LoginRequest) returns (Abstractions.Protobuf.GetResponse); +} + +// Requests + +message LoginRequest { + string Email = 1; + string Password = 2; +} + +// Projections + +message UserDetails { + string UserId = 1; + string FirstName = 2; + string LastName = 3; + string Email = 4; + string Token = 5; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Identity/Projection.cs b/src/Contracts/Boundaries/Identity/Projection.cs new file mode 100644 index 000000000..e9a89de64 --- /dev/null +++ b/src/Contracts/Boundaries/Identity/Projection.cs @@ -0,0 +1,20 @@ +using Contracts.Abstractions; +using MongoDB.Bson.Serialization.Attributes; + +namespace Contracts.Boundaries.Identity; + +public static class Projection +{ + public record UserDetails(Guid Id, string FirstName, string LastName, string Email, string Password, bool IsDeleted, ulong Version, [property: BsonIgnore] string? Token = default) : IProjection + { + public static implicit operator Services.Identity.Protobuf.UserDetails(UserDetails userDetails) + => new() + { + UserId = userDetails.Id.ToString(), + Email = userDetails.Email, + FirstName = userDetails.FirstName, + LastName = userDetails.LastName, + Token = userDetails.Token + }; + } +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Identity/Query.cs b/src/Contracts/Boundaries/Identity/Query.cs new file mode 100644 index 000000000..46ab8f5b8 --- /dev/null +++ b/src/Contracts/Boundaries/Identity/Query.cs @@ -0,0 +1,13 @@ +using Contracts.Abstractions.Messages; +using Contracts.Services.Identity.Protobuf; + +namespace Contracts.Boundaries.Identity; + +public static class Query +{ + public record Login(string Email, string Password) : IQuery + { + public static implicit operator Login(LoginRequest request) + => new(request.Email, request.Password); + } +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Identity/Validators/ConfirmEmailValidator.cs b/src/Contracts/Boundaries/Identity/Validators/ConfirmEmailValidator.cs new file mode 100644 index 000000000..888d5b7e6 --- /dev/null +++ b/src/Contracts/Boundaries/Identity/Validators/ConfirmEmailValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace Contracts.Boundaries.Identity.Validators; + +public class ConfirmEmailValidator : AbstractValidator +{ + public ConfirmEmailValidator() + { + RuleFor(user => user.UserId) + .NotEmpty(); + + RuleFor(user => user.Email) + .NotEmpty() + .EmailAddress(); + } +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Identity/Validators/RegisterUserValidator.cs b/src/Contracts/Boundaries/Identity/Validators/RegisterUserValidator.cs new file mode 100644 index 000000000..108682863 --- /dev/null +++ b/src/Contracts/Boundaries/Identity/Validators/RegisterUserValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation; + +namespace Contracts.Boundaries.Identity.Validators; + +public class RegisterUserValidator : AbstractValidator +{ + public RegisterUserValidator() + { + RuleFor(account => account.FirstName) + .Length(4, 30); + + RuleFor(account => account.LastName) + .Length(4, 30) + .NotEqual(user => user.FirstName); + + RuleFor(account => account.Email) + .EmailAddress(); + + RuleFor(account => account.Password) + .MinimumLength(8) + .MaximumLength(16) + .Must(password => password.Any(char.IsUpper)).WithMessage("Password must contain 1 uppercase letter") + .Must(password => password.Any(char.IsLower)).WithMessage("Password must contain 1 lowercase letter") + .Must(password => password.Any(char.IsDigit)).WithMessage("Password must contain 1 number") + .Must(password => password.Any(char.IsSymbol)).WithMessage("Password must contain 1 symbol"); + } +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Notification/Command.cs b/src/Contracts/Boundaries/Notification/Command.cs new file mode 100644 index 000000000..07af30c93 --- /dev/null +++ b/src/Contracts/Boundaries/Notification/Command.cs @@ -0,0 +1,19 @@ +using Contracts.Abstractions.Messages; +using Contracts.DataTransferObjects; + +namespace Contracts.Boundaries.Notification; + +public static class Command +{ + public record RequestNotification(IEnumerable Methods) : Message, ICommand; + + public record EmitNotificationMethod(Guid NotificationId, Guid MethodId) : Message, ICommand; + + public record FailNotificationMethod(Guid NotificationId, Guid MethodId) : Message, ICommand; + + public record CancelNotificationMethod(Guid NotificationId, Guid MethodId) : Message, ICommand; + + public record SendNotificationMethod(Guid NotificationId, Guid MethodId) : Message, ICommand; + + public record ResetNotificationMethod(Guid NotificationId, Guid MethodId) : Message, ICommand; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Notification/DomainEvent.cs b/src/Contracts/Boundaries/Notification/DomainEvent.cs new file mode 100644 index 000000000..3f927a42a --- /dev/null +++ b/src/Contracts/Boundaries/Notification/DomainEvent.cs @@ -0,0 +1,17 @@ +using Contracts.Abstractions.Messages; +using Contracts.DataTransferObjects; + +namespace Contracts.Boundaries.Notification; + +public static class DomainEvent +{ + public record NotificationRequested(Guid NotificationId, IEnumerable Methods, string Version) : Message, IDomainEvent; + + public record NotificationMethodFailed(Guid NotificationId, Guid MethodId, string Version) : Message, IDomainEvent; + + public record NotificationMethodSent(Guid NotificationId, Guid MethodId, string Version) : Message, IDomainEvent; + + public record NotificationMethodCancelled(Guid NotificationId, Guid MethodId, string Version) : Message, IDomainEvent; + + public record NotificationMethodReset(Guid NotificationId, Guid MethodId, string Version) : Message, IDomainEvent; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Notification/Notification.proto b/src/Contracts/Boundaries/Notification/Notification.proto new file mode 100644 index 000000000..54648b665 --- /dev/null +++ b/src/Contracts/Boundaries/Notification/Notification.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; + +package Contracts.Services.Communication.Protobuf; + +import "Abstractions/Abstractions.proto"; + +service CommunicationService { + rpc ListNotificationsDetails(ListNotificationsDetailsRequest) returns (Abstractions.Protobuf.ListResponse); +} + +// Requests + +message ListNotificationsDetailsRequest { + Abstractions.Protobuf.Paging Paging = 1; +} + +// Projections + +message NotificationDetails { + string NotificationId = 1; +} + +message NotificationMethodDetails{ + string MethodId = 1; + string NotificationId = 2; + Abstractions.Protobuf.NotificationOption Option = 3; +} diff --git a/src/Contracts/Boundaries/Notification/Projection.cs b/src/Contracts/Boundaries/Notification/Projection.cs new file mode 100644 index 000000000..32ede6675 --- /dev/null +++ b/src/Contracts/Boundaries/Notification/Projection.cs @@ -0,0 +1,31 @@ +using Contracts.Abstractions; +using Contracts.DataTransferObjects; + +namespace Contracts.Boundaries.Notification; + +public static class Projection +{ + public record NotificationDetails(Guid Id, bool IsDeleted, ulong Version) : IProjection + { + public static implicit operator Services.Communication.Protobuf.NotificationDetails(NotificationDetails notification) + => new() { NotificationId = notification.Id.ToString() }; + } + + public record NotificationMethodDetails(Guid Id, Guid NotificationId, Dto.INotificationOption Option, bool IsDeleted, ulong Version) : IProjection + { + public static implicit operator Services.Communication.Protobuf.NotificationMethodDetails(NotificationMethodDetails method) + => new() + { + MethodId = method.Id.ToString(), + NotificationId = method.NotificationId.ToString(), + Option = method.Option switch + { + Dto.Email email => new() { Email = email }, + Dto.Sms sms => new() { Sms = sms }, + Dto.PushMobile pushMobile => new() { PushMobile = pushMobile }, + Dto.PushWeb pushWeb => new() { PushWeb = pushWeb }, + _ => default + } + }; + } +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Notification/Query.cs b/src/Contracts/Boundaries/Notification/Query.cs new file mode 100644 index 000000000..d68e90e8b --- /dev/null +++ b/src/Contracts/Boundaries/Notification/Query.cs @@ -0,0 +1,14 @@ +using Contracts.Abstractions.Messages; +using Contracts.Abstractions.Paging; +using Contracts.Services.Communication.Protobuf; + +namespace Contracts.Boundaries.Notification; + +public static class Query +{ + public record ListNotificationsDetails(Paging Paging) : IQuery + { + public static implicit operator ListNotificationsDetails(ListNotificationsDetailsRequest request) + => new(request.Paging); + } +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Order/Command.cs b/src/Contracts/Boundaries/Order/Command.cs new file mode 100644 index 000000000..2b0e95d09 --- /dev/null +++ b/src/Contracts/Boundaries/Order/Command.cs @@ -0,0 +1,13 @@ +using Contracts.Abstractions.Messages; +using Contracts.DataTransferObjects; + +namespace Contracts.Boundaries.Order; + +public static class Command +{ + public record PlaceOrder(string CartId, string CustomerId, Dto.Money Total, Dto.Address BillingAddress, Dto.Address ShippingAddress, IEnumerable Items, IEnumerable PaymentMethods) : Message, ICommand; + + public record ConfirmOrder(string OrderId) : Message, ICommand; + + public record CancelOrder(string OrderId) : Message, ICommand; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Order/DomainEvent.cs b/src/Contracts/Boundaries/Order/DomainEvent.cs new file mode 100644 index 000000000..2b0256747 --- /dev/null +++ b/src/Contracts/Boundaries/Order/DomainEvent.cs @@ -0,0 +1,12 @@ +using Contracts.Abstractions.Messages; +using Contracts.DataTransferObjects; + +namespace Contracts.Boundaries.Order; + +public static class DomainEvent +{ + public record OrderPlaced(Guid OrderId, Guid CustomerId, Dto.Money Total, Dto.Address BillingAddress, Dto.Address ShippingAddress, IEnumerable Items, + IEnumerable PaymentMethods, string Status, string Version) : Message, IDomainEvent; + + public record OrderConfirmed(Guid OrderId, string Status, string Version) : Message, IDomainEvent; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Order/Order.proto b/src/Contracts/Boundaries/Order/Order.proto new file mode 100644 index 000000000..c1ed19bf4 --- /dev/null +++ b/src/Contracts/Boundaries/Order/Order.proto @@ -0,0 +1,37 @@ +syntax = "proto3"; + +package Contracts.Services.Order.Protobuf; + +import "Abstractions/Abstractions.proto"; + +service OrderService { + rpc GetOrderDetails(GetOrderDetailsRequest) returns (Abstractions.Protobuf.GetResponse); + rpc ListOrdersGridItems(ListOrdersGridItemsRequest) returns (Abstractions.Protobuf.ListResponse); +} + +// Requests + +message GetOrderDetailsRequest { + string OrderId = 1; +} + +message ListOrdersGridItemsRequest { + string CustomerId = 1; + Abstractions.Protobuf.Paging Paging = 2; +} + +// Projections + +message OrderDetails { + string OrderId = 1; + string CustomerId = 2; + Abstractions.Protobuf.Money Total = 3; + string Status = 4; +} + +message OrderGridItem { + string OrderId = 1; + string CustomerId = 2; + Abstractions.Protobuf.Money Total = 3; + string Status = 4; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Order/Projection.cs b/src/Contracts/Boundaries/Order/Projection.cs new file mode 100644 index 000000000..86ea4931d --- /dev/null +++ b/src/Contracts/Boundaries/Order/Projection.cs @@ -0,0 +1,32 @@ +using Contracts.Abstractions; +using Contracts.DataTransferObjects; + +namespace Contracts.Boundaries.Order; + +public static class Projection +{ + public record OrderDetails(Guid Id, Guid CustomerId, Dto.Money Total, Dto.Address BillingAddress, Dto.Address ShippingAddress, IEnumerable Items, + IEnumerable PaymentMethods, string Status, bool IsDeleted, ulong Version) : IProjection + { + public static implicit operator Services.Order.Protobuf.OrderDetails(OrderDetails order) + => new() + { + OrderId = order.Id.ToString(), + CustomerId = order.CustomerId.ToString(), + Total = order.Total, + Status = order.Status + }; + } + + public record OrderGridItem(Guid Id, Guid CustomerId, Dto.Money Total, string Status, bool IsDeleted, ulong Version) : IProjection + { + public static implicit operator Services.Order.Protobuf.OrderGridItem(OrderGridItem order) + => new() + { + OrderId = order.Id.ToString(), + CustomerId = order.CustomerId.ToString(), + Total = order.Total, + Status = order.Status + }; + } +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Order/Query.cs b/src/Contracts/Boundaries/Order/Query.cs new file mode 100644 index 000000000..16daae764 --- /dev/null +++ b/src/Contracts/Boundaries/Order/Query.cs @@ -0,0 +1,20 @@ +using Contracts.Abstractions.Messages; +using Contracts.Abstractions.Paging; +using Contracts.Services.Order.Protobuf; + +namespace Contracts.Boundaries.Order; + +public static class Query +{ + public record GetOrderDetails(Guid OrderId) : IQuery + { + public static implicit operator GetOrderDetails(GetOrderDetailsRequest request) + => new(new Guid(request.OrderId)); + } + + public record ListOrdersGridItems(Guid CustomerId, Paging Paging) : IQuery + { + public static implicit operator ListOrdersGridItems(ListOrdersGridItemsRequest request) + => new(new(request.CustomerId), request.Paging); + } +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Payment/Command.cs b/src/Contracts/Boundaries/Payment/Command.cs new file mode 100644 index 000000000..5532a61ba --- /dev/null +++ b/src/Contracts/Boundaries/Payment/Command.cs @@ -0,0 +1,25 @@ +using Contracts.Abstractions.Messages; +using Contracts.DataTransferObjects; + +namespace Contracts.Boundaries.Payment; + +public static class Command +{ + public record RequestPayment(Guid OrderId, Dto.Money AmountDue, Dto.Address BillingAddress, IEnumerable PaymentMethods) : Message, ICommand; + + public record ProceedWithPayment(Guid PaymentId, Guid OrderId) : Message, ICommand; + + public record CancelPayment(Guid PaymentId, Guid OrderId) : Message, ICommand; + + public record AuthorizePaymentMethod(Guid PaymentId, Guid PaymentMethodId, Guid TransactionId) : Message, ICommand; + + public record DenyPaymentMethod(Guid PaymentId, Guid PaymentMethodId, Guid TransactionId) : Message, ICommand; + + public record CancelPaymentMethod(Guid PaymentId, Guid PaymentMethodId, Guid TransactionId) : Message, ICommand; + + public record RefundPaymentMethod(Guid PaymentId, Guid PaymentMethodId, Guid TransactionId) : Message, ICommand; + + public record DenyPaymentMethodRefund(Guid PaymentId, Guid PaymentMethodId, Guid TransactionId) : Message, ICommand; + + public record DenyPaymentMethodCancellation(Guid PaymentId, Guid PaymentMethodId, Guid TransactionId) : Message, ICommand; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Payment/DomainEvent.cs b/src/Contracts/Boundaries/Payment/DomainEvent.cs new file mode 100644 index 000000000..224a6e55d --- /dev/null +++ b/src/Contracts/Boundaries/Payment/DomainEvent.cs @@ -0,0 +1,33 @@ +using Contracts.Abstractions.Messages; +using Contracts.DataTransferObjects; + +namespace Contracts.Boundaries.Payment; + +public static class DomainEvent +{ + public record PaymentRequested(Guid PaymentId, Guid OrderId, Dto.Money Amount, Dto.Address BillingAddress, string Status, string Version) : Message, IDomainEvent; + + public record PaymentCanceled(Guid PaymentId, Guid OrderId, string Status, string Version) : Message, IDomainEvent; + + public record PaymentCompleted(Guid PaymentId, Guid OrderId, string Status, string Version) : Message, IDomainEvent; + + public record PaymentNotCompleted(Guid PaymentId, Guid OrderId, string Status, string Version) : Message, IDomainEvent; + + public record PaymentMethodAuthorized(Guid PaymentId, Guid PaymentMethodId, Guid TransactionId, string Status, string Version) : Message, IDomainEvent; + + public record PaymentMethodDenied(Guid PaymentId, Guid PaymentMethodId, Guid TransactionId, string Status, string Version) : Message, IDomainEvent; + + public record PaymentMethodRefunded(Guid PaymentId, Guid PaymentMethodId, Guid TransactionId, string Status, string Version) : Message, IDomainEvent; + + public record PaymentMethodRefundDenied(Guid PaymentId, Guid PaymentMethodId, Guid TransactionId, string Status, string Version) : Message, IDomainEvent; + + public record PaymentMethodCancellationDenied(Guid PaymentId, Guid PaymentMethodId, Guid TransactionId, string Status, string Version) : Message, IDomainEvent; + + public record PaymentMethodCanceled(Guid PaymentId, Guid PaymentMethodId, Guid TransactionId, string Status, string Version) : Message, IDomainEvent; + + public record CreditCardAdded(Guid CartId, Guid MethodId, Dto.Money Amount, Dto.CreditCard CreditCard, string Status, string Version) : Message, IDomainEvent; + + public record DebitCardAdded(Guid CartId, Guid MethodId, Dto.Money Amount, Dto.DebitCard DebitCard, string Status, string Version) : Message, IDomainEvent; + + public record PayPalAdded(Guid CartId, Guid MethodId, Dto.Money Amount, Dto.PayPal PayPal, string Status, string Version) : Message, IDomainEvent; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Payment/Payment.proto b/src/Contracts/Boundaries/Payment/Payment.proto new file mode 100644 index 000000000..755113acd --- /dev/null +++ b/src/Contracts/Boundaries/Payment/Payment.proto @@ -0,0 +1,54 @@ +syntax = "proto3"; + +package Contracts.Services.Payment.Protobuf; + +import "Abstractions/Abstractions.proto"; + +service PaymentService { + rpc GetPaymentDetails(GetPaymentDetailsRequest) returns (Abstractions.Protobuf.GetResponse); + rpc GetPaymentMethodDetails(GetPaymentMethodDetailsRequest) returns (Abstractions.Protobuf.GetResponse); + rpc ListPaymentMethodListItem(ListPaymentMethodListItemRequest) returns (Abstractions.Protobuf.ListResponse); +} + +// Requests + +message GetPaymentDetailsRequest { + string PaymentId = 1; +} + +message GetPaymentMethodDetailsRequest { + string PaymentId = 1; + string MethodId = 2; +} + +message ListPaymentMethodListItemRequest { + string PaymentId = 1; + Abstractions.Protobuf.Paging Paging = 2; +} + +// Projections + +message PaymentDetails { + string PaymentId = 1; + string OrderId = 2; + Abstractions.Protobuf.Money Amount = 3; + string Status = 4; +} + +message PaymentMethodDetails { + string MethodId = 1; + string PaymentId = 2; + string OrderId = 3; + Abstractions.Protobuf.Money Amount = 4; + Abstractions.Protobuf.PaymentOption Option = 5; + string Status = 6; +} + +message PaymentMethodListItem { + string MethodId = 1; + string PaymentId = 2; + string OrderId = 3; + Abstractions.Protobuf.Money Amount = 4; + string Option = 5; + string Status = 6; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Payment/Projection.cs b/src/Contracts/Boundaries/Payment/Projection.cs new file mode 100644 index 000000000..1913ed20c --- /dev/null +++ b/src/Contracts/Boundaries/Payment/Projection.cs @@ -0,0 +1,52 @@ +using Contracts.Abstractions; +using Contracts.DataTransferObjects; + +namespace Contracts.Boundaries.Payment; + +public static class Projection +{ + public record PaymentDetails(Guid Id, Guid OrderId, Dto.Money Amount, string Status, bool IsDeleted, ulong Version) : IProjection + { + public static implicit operator Services.Payment.Protobuf.PaymentDetails(PaymentDetails payment) + => new() + { + PaymentId = payment.Id.ToString(), + OrderId = payment.OrderId.ToString(), + Amount = payment.Amount, + Status = payment.Status + }; + } + + public record PaymentMethodDetails(Guid Id, Guid PaymentId, Guid OrderId, Dto.Money Amount, Dto.IPaymentOption Option, string Status, bool IsDeleted, ulong Version) : IProjection + { + public static implicit operator Services.Payment.Protobuf.PaymentMethodDetails(PaymentMethodDetails method) + => new() + { + MethodId = method.Id.ToString(), + PaymentId = method.PaymentId.ToString(), + OrderId = method.OrderId.ToString(), + Amount = method.Amount, + Status = method.Status, + Option = method.Option switch + { + Dto.CreditCard creditCard => new() { CreditCard = creditCard }, + Dto.DebitCard debitCard => new() { DebitCard = debitCard }, + Dto.PayPal payPal => new() { PayPal = payPal }, + _ => default + } + }; + } + + public record PaymentMethodListItem(Guid Id, Guid OrderId, Dto.Money Amount, string Option, string Status, bool IsDeleted, ulong Version) : IProjection + { + public static implicit operator Services.Payment.Protobuf.PaymentMethodListItem(PaymentMethodListItem method) + => new() + { + MethodId = method.Id.ToString(), + OrderId = method.OrderId.ToString(), + Amount = method.Amount, + Status = method.Status, + Option = method.Option + }; + } +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Payment/Query.cs b/src/Contracts/Boundaries/Payment/Query.cs new file mode 100644 index 000000000..7258a8c6f --- /dev/null +++ b/src/Contracts/Boundaries/Payment/Query.cs @@ -0,0 +1,25 @@ +using Contracts.Abstractions.Messages; +using Contracts.Abstractions.Paging; + +namespace Contracts.Boundaries.Payment; + +public static class Query +{ + public record GetPaymentDetails(Guid PaymentId) : IQuery + { + public static implicit operator GetPaymentDetails(Services.Payment.Protobuf.GetPaymentDetailsRequest request) + => new(new Guid(request.PaymentId)); + } + + public record GetPaymentMethodDetails(Guid MethodId) : IQuery + { + public static implicit operator GetPaymentMethodDetails(Services.Payment.Protobuf.GetPaymentMethodDetailsRequest request) + => new(new Guid(request.MethodId)); + } + + public record ListPaymentMethodListItem(Guid PaymentId, Paging Paging) : IQuery + { + public static implicit operator ListPaymentMethodListItem(Services.Payment.Protobuf.ListPaymentMethodListItemRequest request) + => new(new(request.PaymentId), request.Paging); + } +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Shopping/Checkout/Command.cs b/src/Contracts/Boundaries/Shopping/Checkout/Command.cs new file mode 100644 index 000000000..39a879162 --- /dev/null +++ b/src/Contracts/Boundaries/Shopping/Checkout/Command.cs @@ -0,0 +1,16 @@ +using Contracts.Abstractions.Messages; + +namespace Contracts.Boundaries.Shopping.Checkout; + +public static class Command +{ + public record AddBillingAddress(string CheckoutId, string City, string Complement, string Country, string Number, string State, string Street, string ZipCode) : Message, ICommand; + + public record AddCreditCard(string CheckoutId, string ExpirationDate, string Number, string HolderName, string Cvv) : Message, ICommand; + + public record AddDebitCard(string CheckoutId, string ExpirationDate, string Number, string HolderName, string Cvv) : Message, ICommand; + + public record AddPayPal(string CheckoutId, string Email, string Password) : Message, ICommand; + + public record AddShippingAddress(string CheckoutId, string City, string Complement, string Country, string Number, string State, string Street, string ZipCode) : Message, ICommand; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Shopping/Checkout/DomainEvent.cs b/src/Contracts/Boundaries/Shopping/Checkout/DomainEvent.cs new file mode 100644 index 000000000..50eba7365 --- /dev/null +++ b/src/Contracts/Boundaries/Shopping/Checkout/DomainEvent.cs @@ -0,0 +1,18 @@ +using Contracts.Abstractions.Messages; + +namespace Contracts.Boundaries.Shopping.Checkout; + +public static class DomainEvent +{ + public record CheckoutStarted(string CheckoutId, string CartId, string Version) : Message, IDomainEvent; + + public record BillingAddressAdded(string CheckoutId, string CartId, string City, string Complement, string Country, string Number, string State, string Street, string ZipCode, string Version) : Message, IDomainEvent; + + public record DebitCardAdded(string CheckoutId, string CartId, string ExpirationDate, string Number, string HolderName, string Cvv, string Version) : Message, IDomainEvent; + + public record CreditCardAdded(string CheckoutId, string CartId, string ExpirationDate, string Number, string HolderName, string Cvv, string Version) : Message, IDomainEvent; + + public record PayPalAdded(string CheckoutId, string CartId, string Email, string Password, string Version) : Message, IDomainEvent; + + public record ShippingAddressAdded(string CheckoutId, string CartId, string City, string Complement, string Country, string Number, string State, string Street, string ZipCode, string Version) : Message, IDomainEvent; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Shopping/Checkout/NotificationEvent.cs b/src/Contracts/Boundaries/Shopping/Checkout/NotificationEvent.cs new file mode 100644 index 000000000..276c9e03a --- /dev/null +++ b/src/Contracts/Boundaries/Shopping/Checkout/NotificationEvent.cs @@ -0,0 +1,8 @@ +using Contracts.Abstractions.Messages; + +namespace Contracts.Boundaries.Shopping.Checkout; + +public static class NotificationEvent +{ + public record CartProjectionRebuildRequested(Guid CartId, string Name) : Message, IEvent; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Shopping/Checkout/SummaryEvent.cs b/src/Contracts/Boundaries/Shopping/Checkout/SummaryEvent.cs new file mode 100644 index 000000000..620b61c5e --- /dev/null +++ b/src/Contracts/Boundaries/Shopping/Checkout/SummaryEvent.cs @@ -0,0 +1,11 @@ +using Contracts.Abstractions.Messages; +using Contracts.DataTransferObjects; + +namespace Contracts.Boundaries.Shopping.Checkout; + +public static class SummaryEvent +{ + public record CartProjectionRebuilt(Dto.ShoppingCart Cart, string Version) : Message, ISummaryEvent; + + public record CartSubmitted(Dto.ShoppingCart Cart, string Version) : Message, ISummaryEvent; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Shopping/ShoppingCart/Command.cs b/src/Contracts/Boundaries/Shopping/ShoppingCart/Command.cs new file mode 100644 index 000000000..d9c24eae3 --- /dev/null +++ b/src/Contracts/Boundaries/Shopping/ShoppingCart/Command.cs @@ -0,0 +1,20 @@ +using Contracts.Abstractions.Messages; + +namespace Contracts.Boundaries.Shopping.ShoppingCart; + +public static class Command +{ + public record AddCartItem(string CartId, string ProductId, ushort Quantity, string Currency) : Message, ICommand; + + public record ChangeCartItemQuantity(string CartId, string ProductId, ushort NewQuantity) : Message, ICommand; + + public record CheckOutCart(string CartId) : Message, ICommand; + + public record DiscardCart(string CartId) : Message, ICommand; + + public record RebuildCartProjection(string Projection) : Message, ICommand; + + public record RemoveCartItem(string CartId, string ProductId) : Message, ICommand; + + public record StartShopping(string CustomerId) : Message, ICommand; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Shopping/ShoppingCart/DomainEvent.cs b/src/Contracts/Boundaries/Shopping/ShoppingCart/DomainEvent.cs new file mode 100644 index 000000000..61d467362 --- /dev/null +++ b/src/Contracts/Boundaries/Shopping/ShoppingCart/DomainEvent.cs @@ -0,0 +1,26 @@ +using Contracts.Abstractions.Messages; + +namespace Contracts.Boundaries.Shopping.ShoppingCart; + +public static class DomainEvent +{ + public record CartCheckedOut(string CartId, string Status, string Version) : Message, IDomainEvent; + + public record ShoppingStarted(string CartId, string CustomerId, string Status, string Version) + : Message, IDomainEvent; + + public record CartDiscarded(string CartId, string Status, string Version) : Message, IDomainEvent; + + public record CartItemAdded(string CartId, string ItemId, string ProductId, string ProductName, string PictureUri, + string Sku, string Quantity, IDictionary Prices, IDictionary Totals, + string Version) : Message, IDomainEvent; + + public record CartItemDecreased(string CartId, string ItemId, string ProductId, string NewQuantity, + IDictionary Prices, IDictionary Totals, string Version) : Message, IDomainEvent; + + public record CartItemIncreased(string CartId, string ItemId, string ProductId, string NewQuantity, + IDictionary Prices, IDictionary Totals, string Version) : Message, IDomainEvent; + + public record CartItemRemoved(string CartId, string ItemId, string ProductId, IDictionary Prices, + IDictionary Totals, string Version) : Message, IDomainEvent; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Shopping/ShoppingCart/NotificationEvent.cs b/src/Contracts/Boundaries/Shopping/ShoppingCart/NotificationEvent.cs new file mode 100644 index 000000000..b4c721e81 --- /dev/null +++ b/src/Contracts/Boundaries/Shopping/ShoppingCart/NotificationEvent.cs @@ -0,0 +1,8 @@ +using Contracts.Abstractions.Messages; + +namespace Contracts.Boundaries.Shopping.ShoppingCart; + +public static class NotificationEvent +{ + public record CartProjectionRebuildRequested(string CartId, string Name) : Message, IEvent; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Shopping/ShoppingCart/Projection.cs b/src/Contracts/Boundaries/Shopping/ShoppingCart/Projection.cs new file mode 100644 index 000000000..381188415 --- /dev/null +++ b/src/Contracts/Boundaries/Shopping/ShoppingCart/Projection.cs @@ -0,0 +1,73 @@ +using Contracts.Abstractions; +using Contracts.DataTransferObjects; + +namespace Contracts.Boundaries.Shopping.ShoppingCart; + +public static class Projection +{ + public record ShoppingCartDetails(Guid Id, Guid CustomerId, Dto.Money Total, string Status, bool IsDeleted, ulong Version) : IProjection + { + public static implicit operator Contracts.Shopping.Queries.ShoppingCartDetails(ShoppingCartDetails cart) + => new() + { + CartId = cart.Id.ToString(), + CustomerId = cart.CustomerId.ToString(), + Status = cart.Status, + Total = cart.Total + }; + } + + public record ShoppingCartItemDetails(Guid Id, Guid CartId, Dto.Product Product, int Quantity, bool IsDeleted, ulong Version) : IProjection + { + public static implicit operator Contracts.Shopping.Queries.ShoppingCartItemDetails(ShoppingCartItemDetails item) + => new() + { + ItemId = item.Id.ToString(), + CartId = item.CartId.ToString(), + Product = item.Product, + Quantity = item.Quantity + }; + } + + public record PaymentMethodDetails(Guid Id, Guid CartId, Dto.Money Amount, Dto.IPaymentOption Option, bool IsDeleted, ulong Version) : IProjection + { + public static implicit operator Contracts.Shopping.Queries.PaymentMethodDetails(PaymentMethodDetails method) + => new() + { + MethodId = method.Id.ToString(), + CartId = method.CartId.ToString(), + Amount = method.Amount, + Option = method.Option switch + { + Dto.CreditCard creditCard => new() { CreditCard = creditCard }, + Dto.DebitCard debitCard => new() { DebitCard = debitCard }, + Dto.PayPal payPal => new() { PayPal = payPal }, + _ => default + } + }; + } + + public record ShoppingCartItemListItem(Guid Id, Guid CartId, string ProductName, int Quantity, bool IsDeleted, ulong Version) : IProjection + { + public static implicit operator Contracts.Shopping.Queries.ShoppingCartItemListItem(ShoppingCartItemListItem item) + => new() + { + ItemId = item.Id.ToString(), + CartId = item.CartId.ToString(), + ProductName = item.ProductName, + Quantity = item.Quantity + }; + } + + public record PaymentMethodListItem(Guid Id, Guid CartId, Dto.Money Amount, string Option, bool IsDeleted, ulong Version) : IProjection + { + public static implicit operator Contracts.Shopping.Queries.PaymentMethodListItem(PaymentMethodListItem method) + => new() + { + MethodId = method.Id.ToString(), + CartId = method.CartId.ToString(), + Amount = method.Amount, + Option = method.Option + }; + } +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Shopping/ShoppingCart/Query.cs b/src/Contracts/Boundaries/Shopping/ShoppingCart/Query.cs new file mode 100644 index 000000000..31728ecfb --- /dev/null +++ b/src/Contracts/Boundaries/Shopping/ShoppingCart/Query.cs @@ -0,0 +1,43 @@ +using Contracts.Abstractions.Messages; +using Contracts.Abstractions.Paging; + +namespace Contracts.Boundaries.Shopping.ShoppingCart; + +public static class Query +{ + public record GetShoppingCartDetails(Guid CartId) : IQuery + { + public static implicit operator GetShoppingCartDetails(Contracts.Shopping.Queries.GetShoppingCartDetailsRequest request) + => new(new Guid(request.CartId)); + } + + public record GetCustomerShoppingCartDetails(Guid CustomerId) : IQuery + { + public static implicit operator GetCustomerShoppingCartDetails(Contracts.Shopping.Queries.GetCustomerShoppingCartDetailsRequest request) + => new(new Guid(request.CustomerId)); + } + + public record GetShoppingCartItemDetails(Guid CartId, Guid ItemId) : IQuery + { + public static implicit operator GetShoppingCartItemDetails(Contracts.Shopping.Queries.GetShoppingCartItemDetailsRequest request) + => new(new(request.CartId), new(request.ItemId)); + } + + public record GetPaymentMethodDetails(Guid CartId, Guid MethodId) : IQuery + { + public static implicit operator GetPaymentMethodDetails(Contracts.Shopping.Queries.GetPaymentMethodDetailsRequest request) + => new(new(request.CartId), new(request.MethodId)); + } + + public record ListShoppingCartItemsListItems(Guid CartId, Paging Paging) : IQuery + { + public static implicit operator ListShoppingCartItemsListItems(Contracts.Shopping.Queries.ListShoppingCartItemsListItemsRequest request) + => new(new(request.CartId), request.Paging); + } + + public record ListPaymentMethodsListItems(Guid CartId, Paging Paging) : IQuery + { + public static implicit operator ListPaymentMethodsListItems(Contracts.Shopping.Queries.ListPaymentMethodsListItemsRequest request) + => new(new(request.CartId), request.Paging); + } +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Shopping/ShoppingCart/ShoppingCartQueries.proto b/src/Contracts/Boundaries/Shopping/ShoppingCart/ShoppingCartQueries.proto new file mode 100644 index 000000000..048f5d85a --- /dev/null +++ b/src/Contracts/Boundaries/Shopping/ShoppingCart/ShoppingCartQueries.proto @@ -0,0 +1,84 @@ +syntax = "proto3"; + +package Contracts.Shopping.Queries; + +import "Abstractions/Abstractions.proto"; + +service CartQueryService { + rpc GetShoppingCartDetails(GetShoppingCartDetailsRequest) returns (Abstractions.Protobuf.GetResponse); + rpc GetCustomerShoppingCartDetails(GetCustomerShoppingCartDetailsRequest) returns (Abstractions.Protobuf.GetResponse); + rpc GetPaymentMethodDetails(GetPaymentMethodDetailsRequest) returns (Abstractions.Protobuf.GetResponse); + rpc GetShoppingCartItemDetails(GetShoppingCartItemDetailsRequest) returns (Abstractions.Protobuf.GetResponse); + rpc ListPaymentMethodsListItems(ListPaymentMethodsListItemsRequest) returns (Abstractions.Protobuf.ListResponse); + rpc ListShoppingCartItemsListItems(ListShoppingCartItemsListItemsRequest) returns (Abstractions.Protobuf.ListResponse); +} + +//// Requests + +message GetShoppingCartDetailsRequest { + string CartId = 1; +} + +message GetCustomerShoppingCartDetailsRequest { + string CustomerId = 1; +} + +message GetShoppingCartItemDetailsRequest { + string CartId = 1; + string ItemId = 2; +} + +message GetPaymentMethodDetailsRequest { + string CartId = 1; + string MethodId = 2; +} + +message ListPaymentMethodsListItemsRequest { + string CartId = 1; + Abstractions.Protobuf.Paging Paging = 2; +} + +message ListShoppingCartItemsListItemsRequest { + string CartId = 1; + Abstractions.Protobuf.Paging Paging = 2; +} + +//// Projections + +// Cart +message ShoppingCartDetails { + string CartId = 1; + string CustomerId = 2; + string Status = 3; + Abstractions.Protobuf.Money Total = 4; +} + +// Items +message ShoppingCartItemDetails{ + string ItemId = 1; + string CartId = 2; + Abstractions.Protobuf.Product Product = 3; + int32 Quantity = 4; +} + +message ShoppingCartItemListItem{ + string ItemId = 1; + string CartId = 2; + string ProductName = 3; + int32 Quantity = 4; +} + +// Payment +message PaymentMethodDetails { + string MethodId = 1; + string CartId = 2; + Abstractions.Protobuf.Money Amount = 3; + Abstractions.Protobuf.PaymentOption Option = 4; +} + +message PaymentMethodListItem { + string MethodId = 1; + string CartId = 2; + Abstractions.Protobuf.Money Amount = 3; + string Option = 4; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Shopping/ShoppingCart/SummaryEvent.cs b/src/Contracts/Boundaries/Shopping/ShoppingCart/SummaryEvent.cs new file mode 100644 index 000000000..e2ed3aec5 --- /dev/null +++ b/src/Contracts/Boundaries/Shopping/ShoppingCart/SummaryEvent.cs @@ -0,0 +1,11 @@ +using Contracts.Abstractions.Messages; +using Contracts.DataTransferObjects; + +namespace Contracts.Boundaries.Shopping.ShoppingCart; + +public static class SummaryEvent +{ + public record CartProjectionRebuilt(Dto.ShoppingCart Cart, string Version) : Message, ISummaryEvent; + + public record CartSubmitted(Dto.ShoppingCart Cart, string Version) : Message, ISummaryEvent; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Warehouse/Command.cs b/src/Contracts/Boundaries/Warehouse/Command.cs new file mode 100644 index 000000000..b53a500b5 --- /dev/null +++ b/src/Contracts/Boundaries/Warehouse/Command.cs @@ -0,0 +1,17 @@ +using Contracts.Abstractions.Messages; +using Contracts.DataTransferObjects; + +namespace Contracts.Boundaries.Warehouse; + +public static class Command +{ + public record ReceiveInventoryItem(Guid InventoryId, Dto.Product Product, decimal Cost, int Quantity) : Message, ICommand; + + public record IncreaseInventoryAdjust(Guid InventoryId, Guid ItemId, int Quantity, string Reason) : Message, ICommand; + + public record DecreaseInventoryAdjust(Guid InventoryId, Guid ItemId, int Quantity, string Reason) : Message, ICommand; + + public record ReserveInventoryItem(Guid InventoryId, Guid CatalogId, Guid CartId, Dto.Product Product, int Quantity) : Message, ICommand; + + public record CreateInventory(Guid InventoryId, Guid OwnerId) : Message, ICommand; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Warehouse/DomainEvent.cs b/src/Contracts/Boundaries/Warehouse/DomainEvent.cs new file mode 100644 index 000000000..ce1591114 --- /dev/null +++ b/src/Contracts/Boundaries/Warehouse/DomainEvent.cs @@ -0,0 +1,27 @@ +using Contracts.Abstractions.Messages; +using Contracts.DataTransferObjects; + +namespace Contracts.Boundaries.Warehouse; + +public static class DomainEvent +{ + public record InventoryCreated(Guid InventoryId, Guid OwnerId, string Version) : Message, IDomainEvent; + + public record InventoryItemReceived(Guid InventoryId, Guid ItemId, Dto.Product Product, decimal Cost, int Quantity, string Sku, string Version) : Message, IDomainEvent; + + public record InventoryAdjustmentIncreased(Guid InventoryId, Guid ItemId, string Reason, int Quantity, string Version) : Message, IDomainEvent; + + public record InventoryAdjustmentDecreased(Guid InventoryId, Guid ItemId, string Reason, int Quantity, string Version) : Message, IDomainEvent; + + public record InventoryAdjustmentNotDecreased(Guid InventoryId, Guid ItemId, string Reason, int QuantityDesired, int QuantityAvailable, string Version) : Message, IDomainEvent; + + public record InventoryReserved(Guid InventoryId, Guid ItemId, Guid CatalogId, Guid CartId, Dto.Product Product, int Quantity, DateTimeOffset Expiration, string Version) : Message, IDomainEvent; + + public record StockDepleted(Guid InventoryId, Guid ItemId, Dto.Product Product, string Version) : Message, IDomainEvent; + + public record InventoryNotReserved(Guid InventoryId, Guid ItemId, Guid CartId, int QuantityDesired, int QuantityAvailable, string Version) : Message, IDomainEvent; + + public record InventoryItemIncreased(Guid InventoryId, Guid ItemId, int Quantity, string Version) : Message, IDomainEvent; + + public record InventoryItemDecreased(Guid InventoryId, Guid ItemId, int Quantity, string Version) : Message, IDomainEvent; +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Warehouse/Projection.cs b/src/Contracts/Boundaries/Warehouse/Projection.cs new file mode 100644 index 000000000..b2226cbe8 --- /dev/null +++ b/src/Contracts/Boundaries/Warehouse/Projection.cs @@ -0,0 +1,30 @@ +using Contracts.Abstractions; +using Contracts.DataTransferObjects; + +namespace Contracts.Boundaries.Warehouse; + +public static class Projection +{ + public record InventoryGridItem(Guid Id, Guid OwnerId, bool IsDeleted, ulong Version) : IProjection + { + public static implicit operator Services.Warehouse.Protobuf.InventoryGridItem(InventoryGridItem inventoryGridItem) + => new() + { + InventoryId = inventoryGridItem.Id.ToString(), + OwnerId = inventoryGridItem.OwnerId.ToString() + }; + } + + public record InventoryItemListItem(Guid Id, Guid InventoryId, Dto.Product Product, int Quantity, string Sku, bool IsDeleted, ulong Version) : IProjection + { + public static implicit operator Services.Warehouse.Protobuf.InventoryItemListItem(InventoryItemListItem item) + => new() + { + ItemId = item.Id.ToString(), + InventoryId = item.InventoryId.ToString(), + Product = item.Product, + Sku = item.Sku, + Quantity = item.Quantity + }; + } +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Warehouse/Query.cs b/src/Contracts/Boundaries/Warehouse/Query.cs new file mode 100644 index 000000000..5d7a551da --- /dev/null +++ b/src/Contracts/Boundaries/Warehouse/Query.cs @@ -0,0 +1,20 @@ +using Contracts.Abstractions.Messages; +using Contracts.Abstractions.Paging; + +namespace Contracts.Boundaries.Warehouse; + +public static class Query +{ + public record ListInventoryGridItems(Paging Paging) : IQuery + { + public static implicit operator ListInventoryGridItems(Services.Warehouse.Protobuf.ListInventoryGridItemsRequest request) + => new(request.Paging); + + } + + public record ListInventoryItemsListItems(Guid InventoryId, Paging Paging) : IQuery + { + public static implicit operator ListInventoryItemsListItems(Services.Warehouse.Protobuf.ListInventoryItemsListItemsRequest request) + => new(new(request.InventoryId), request.Paging); + } +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Warehouse/Validators/CreateInventoryValidator.cs b/src/Contracts/Boundaries/Warehouse/Validators/CreateInventoryValidator.cs new file mode 100644 index 000000000..d6a325e3c --- /dev/null +++ b/src/Contracts/Boundaries/Warehouse/Validators/CreateInventoryValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; + +namespace Contracts.Boundaries.Warehouse.Validators; + +public class CreateInventoryValidator : AbstractValidator +{ + public CreateInventoryValidator() + { + RuleFor(inventory => inventory.InventoryId) + .NotEmpty(); + + RuleFor(inventory => inventory.OwnerId) + .NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Warehouse/Validators/ReceiveInventoryItemValidator.cs b/src/Contracts/Boundaries/Warehouse/Validators/ReceiveInventoryItemValidator.cs new file mode 100644 index 000000000..757b80a97 --- /dev/null +++ b/src/Contracts/Boundaries/Warehouse/Validators/ReceiveInventoryItemValidator.cs @@ -0,0 +1,23 @@ +using Contracts.DataTransferObjects.Validators; +using FluentValidation; + +namespace Contracts.Boundaries.Warehouse.Validators; + +public class ReceiveInventoryItemValidator : AbstractValidator +{ + public ReceiveInventoryItemValidator() + { + RuleFor(request => request.InventoryId) + .NotEmpty(); + + RuleFor(request => request.Cost) + .GreaterThan(0); + + RuleFor(request => request.Quantity) + .GreaterThan(0); + + RuleFor(request => request.Product) + .SetValidator(new ProductValidator()) + .OverridePropertyName(string.Empty); + } +} \ No newline at end of file diff --git a/src/Contracts/Boundaries/Warehouse/Warehouse.proto b/src/Contracts/Boundaries/Warehouse/Warehouse.proto new file mode 100644 index 000000000..98eb09259 --- /dev/null +++ b/src/Contracts/Boundaries/Warehouse/Warehouse.proto @@ -0,0 +1,36 @@ +syntax = "proto3"; + +package Contracts.Services.Warehouse.Protobuf; + +import "Abstractions/Abstractions.proto"; + +service WarehouseService { + rpc ListInventoryGridItems(ListInventoryGridItemsRequest) returns (Abstractions.Protobuf.ListResponse); + rpc ListInventoryItems(ListInventoryItemsListItemsRequest) returns (Abstractions.Protobuf.ListResponse); +} + +// Requests + +message ListInventoryGridItemsRequest { + Abstractions.Protobuf.Paging Paging = 1; +} + +message ListInventoryItemsListItemsRequest { + string InventoryId = 1; + Abstractions.Protobuf.Paging Paging = 2; +} + +// Projections + +message InventoryGridItem { + string InventoryId = 1; + string OwnerId = 2; +} + +message InventoryItemListItem { + string ItemId = 1; + string InventoryId = 2; + Abstractions.Protobuf.Product Product = 3; + string Sku = 4; + int32 Quantity = 5; +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Application/Abstractions/IEventBusGateway.cs b/src/Services/Cataloging/Command/Application/Abstractions/IEventBusGateway.cs new file mode 100644 index 000000000..b80e11c8d --- /dev/null +++ b/src/Services/Cataloging/Command/Application/Abstractions/IEventBusGateway.cs @@ -0,0 +1,12 @@ +using Contracts.Abstractions.Messages; + +namespace Application.Abstractions; + +public interface IEventBusGateway +{ + Task PublishAsync(TEvent @event, CancellationToken cancellationToken) + where TEvent : class, IEvent; + + Task SchedulePublishAsync(TEvent @event, DateTimeOffset scheduledTime, CancellationToken cancellationToken) + where TEvent : class, IDelayedEvent; +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Application/Abstractions/IEventStoreGateway.cs b/src/Services/Cataloging/Command/Application/Abstractions/IEventStoreGateway.cs new file mode 100644 index 000000000..b1fc9da8d --- /dev/null +++ b/src/Services/Cataloging/Command/Application/Abstractions/IEventStoreGateway.cs @@ -0,0 +1,41 @@ +using System.Linq.Expressions; +using Contracts.Abstractions.Messages; +using Domain.Abstractions.Aggregates; +using Domain.Abstractions.EventStore; +using Domain.Abstractions.Identities; +using Version = Domain.ValueObjects.Version; + +namespace Application.Abstractions; + +public interface IEventStoreGateway +{ + Task AppendAsync(StoreEvent storeEvent, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new(); + + Task AppendAsync(Snapshot snapshot, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new(); + + Task> GetStreamAsync(TId id, Version version, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new(); + + Task> GetStreamAsync + (Expression, bool>> predicate, Version version, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new(); + + Task?> GetSnapshotAsync(TId id, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new(); + + Task?> GetSnapshotAsync + (Expression, bool>> predicate, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new(); + + IAsyncEnumerable StreamAggregatesId() + where TAggregate : IAggregateRoot + where TId : IIdentifier, new(); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Application/Abstractions/IUnitOfWork.cs b/src/Services/Cataloging/Command/Application/Abstractions/IUnitOfWork.cs new file mode 100644 index 000000000..9a3479aed --- /dev/null +++ b/src/Services/Cataloging/Command/Application/Abstractions/IUnitOfWork.cs @@ -0,0 +1,6 @@ +namespace Application.Abstractions; + +public interface IUnitOfWork +{ + Task ExecuteAsync(Func operationAsync, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Application/Application.csproj b/src/Services/Cataloging/Command/Application/Application.csproj new file mode 100644 index 000000000..0e27727a1 --- /dev/null +++ b/src/Services/Cataloging/Command/Application/Application.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Application/DependencyInjection/ServiceCollectionExtensions.cs b/src/Services/Cataloging/Command/Application/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..38a5cb579 --- /dev/null +++ b/src/Services/Cataloging/Command/Application/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,13 @@ +using System.Reflection; +using Application.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Application.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + => services + .AddScoped() + .AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly())); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Application/Services/ApplicationService.cs b/src/Services/Cataloging/Command/Application/Services/ApplicationService.cs new file mode 100644 index 000000000..e73f39058 --- /dev/null +++ b/src/Services/Cataloging/Command/Application/Services/ApplicationService.cs @@ -0,0 +1,105 @@ +using System.Linq.Expressions; +using Application.Abstractions; +using Contracts.Abstractions.Messages; +using Domain.Abstractions.Aggregates; +using Domain.Abstractions.EventStore; +using Domain.Abstractions.Identities; +using InvalidOperationException = System.InvalidOperationException; +using Version = Domain.ValueObjects.Version; + +namespace Application.Services; + +public class ApplicationService( + IEventStoreGateway eventStoreGateway, + //IOptions options, + IEventBusGateway eventBusGateway, + IUnitOfWork unitOfWork) + : IApplicationService +{ + public async Task LoadAggregateAsync(TId id, CancellationToken cancellationToken) + where TAggregate : class, IAggregateRoot, new() + where TId : IIdentifier, new() + { + var snapshot = await eventStoreGateway.GetSnapshotAsync(id, cancellationToken); + var events = await eventStoreGateway.GetStreamAsync(id, snapshot?.Version ?? Version.Zero, cancellationToken); + return LoadAggregate(snapshot, events); + } + + public async Task LoadAggregateAsync(Func predicate, CancellationToken cancellationToken) + where TAggregate : class, IAggregateRoot, new() + where TId : IIdentifier, new() + { + var snapshotExpression = BuildExpression, bool>(predicate); + var storeEventExpression = BuildExpression, bool>(predicate); + + var snapshot = await eventStoreGateway.GetSnapshotAsync(snapshotExpression, cancellationToken); + var events = await eventStoreGateway.GetStreamAsync(storeEventExpression, snapshot?.Version ?? Version.Zero, cancellationToken); + + if (snapshot is null && events is { Count: 0 }) return new(); + + var aggregate = snapshot?.Aggregate ?? new(); + aggregate.LoadFromHistory(events); + + return aggregate is { IsDeleted: false } + ? aggregate + : throw new InvalidOperationException($"Aggregate {typeof(TAggregate).Name} is deleted."); + } + + private static TAggregate LoadAggregate(Snapshot? snapshot, List events) + where TAggregate : class, IAggregateRoot, new() + where TId : IIdentifier, new() + { + if (snapshot is null && events is { Count: 0 }) + throw new InvalidOperationException($"Aggregate {typeof(TAggregate).Name} not found."); + + var aggregate = snapshot?.Aggregate ?? new TAggregate(); + aggregate.LoadFromHistory(events); + + return aggregate is { IsDeleted: false } + ? aggregate + : throw new InvalidOperationException($"Aggregate {typeof(TAggregate).Name} is deleted."); + } + + private static Expression> BuildExpression(Func func) + { + var inputParameter = Expression.Parameter(typeof(TEntity), "entity"); + var convertedParameter = Expression.Convert(inputParameter, typeof(TInput)); + var body = Expression.Invoke(Expression.Constant(func), convertedParameter); + return Expression.Lambda>(body, inputParameter); + } + + public Task AppendEventsAsync(TAggregate aggregate, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new() + => unitOfWork.ExecuteAsync( + operationAsync: async ct => + { + while (aggregate.TryDequeueEvent(out var @event)) + { + if (@event is null) continue; + + var storeEvent = StoreEvent.Create(aggregate, @event); + await eventStoreGateway.AppendAsync(storeEvent, ct); + + if (storeEvent.Version % 5) //options.Value.SnapshotInterval) + { + var snapshot = Snapshot.Create(aggregate, storeEvent); + await eventStoreGateway.AppendAsync(snapshot, ct); + } + + await eventBusGateway.PublishAsync(@event, ct); + } + }, + cancellationToken: cancellationToken); + + public IAsyncEnumerable StreamAggregatesId() + where TAggregate : IAggregateRoot + where TId : IIdentifier, new() + => eventStoreGateway.StreamAggregatesId(); + + public Task PublishEventAsync(IEvent @event, CancellationToken cancellationToken) + => eventBusGateway.PublishAsync(@event, cancellationToken); + + public Task SchedulePublishAsync(IDelayedEvent @event, DateTimeOffset scheduledTime, CancellationToken cancellationToken) + => eventBusGateway.SchedulePublishAsync(@event, scheduledTime, cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Application/Services/IApplicationService.cs b/src/Services/Cataloging/Command/Application/Services/IApplicationService.cs new file mode 100644 index 000000000..41a2cd118 --- /dev/null +++ b/src/Services/Cataloging/Command/Application/Services/IApplicationService.cs @@ -0,0 +1,28 @@ +using Contracts.Abstractions.Messages; +using Domain.Abstractions.Aggregates; +using Domain.Abstractions.Identities; + +namespace Application.Services; + +public interface IApplicationService +{ + Task AppendEventsAsync(TAggregate aggregate, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new(); + + Task LoadAggregateAsync(TId id, CancellationToken cancellationToken) + where TAggregate : class, IAggregateRoot, new() + where TId : IIdentifier, new(); + + Task LoadAggregateAsync(Func predicate, CancellationToken cancellationToken) + where TAggregate : class, IAggregateRoot, new() + where TId : IIdentifier, new(); + + IAsyncEnumerable StreamAggregatesId() + where TAggregate : IAggregateRoot + where TId : IIdentifier, new(); + + Task PublishEventAsync(IEvent @event, CancellationToken cancellationToken); + + Task SchedulePublishAsync(IDelayedEvent @event, DateTimeOffset scheduledTime, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Application/UseCases/CatalogItems/Commands/CreateCatalogItemInteractor.cs b/src/Services/Cataloging/Command/Application/UseCases/CatalogItems/Commands/CreateCatalogItemInteractor.cs new file mode 100644 index 000000000..170e8861f --- /dev/null +++ b/src/Services/Cataloging/Command/Application/UseCases/CatalogItems/Commands/CreateCatalogItemInteractor.cs @@ -0,0 +1,19 @@ +using Application.Services; +using Domain.Aggregates; +using Domain.Aggregates.CatalogItems; +using Domain.Aggregates.Catalogs; +using Domain.Aggregates.Products; +using MediatR; + +namespace Application.UseCases.CatalogItems.Commands; + +public record CreateCatalogItem(AppId AppId, CatalogId CatalogId, ProductId ProductId) : IRequest; + +public class CreateCatalogItemInteractor(IApplicationService service) : IRequestHandler +{ + public Task Handle(CreateCatalogItem cmd, CancellationToken cancellationToken) + { + var newItem = CatalogItem.Create(cmd.AppId, cmd.CatalogId, cmd.ProductId); + return service.AppendEventsAsync(newItem, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Application/UseCases/CatalogItems/Commands/RemoveCatalogItemInteractor.cs b/src/Services/Cataloging/Command/Application/UseCases/CatalogItems/Commands/RemoveCatalogItemInteractor.cs new file mode 100644 index 000000000..6a8a91219 --- /dev/null +++ b/src/Services/Cataloging/Command/Application/UseCases/CatalogItems/Commands/RemoveCatalogItemInteractor.cs @@ -0,0 +1,17 @@ +using Application.Services; +using Domain.Aggregates.CatalogItems; +using MediatR; + +namespace Application.UseCases.CatalogItems.Commands; + +public record RemoveCatalogItem(CatalogItemId ItemId) : IRequest; + +public class RemoveCatalogItemInteractor(IApplicationService service) : IRequestHandler +{ + public async Task Handle(RemoveCatalogItem cmd, CancellationToken cancellationToken) + { + var item = await service.LoadAggregateAsync(cmd.ItemId, cancellationToken); + item.RemoveCatalogItem(cmd.ItemId); + await service.AppendEventsAsync(item, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Application/UseCases/Catalogs/Commands/ActivateCatalogInteractor.cs b/src/Services/Cataloging/Command/Application/UseCases/Catalogs/Commands/ActivateCatalogInteractor.cs new file mode 100644 index 000000000..87c2707c3 --- /dev/null +++ b/src/Services/Cataloging/Command/Application/UseCases/Catalogs/Commands/ActivateCatalogInteractor.cs @@ -0,0 +1,17 @@ +using Application.Services; +using Domain.Aggregates.Catalogs; +using MediatR; + +namespace Application.UseCases.Catalogs.Commands; + +public record ActivateCatalog(CatalogId CatalogId) : IRequest; + +public class ActivateCatalogInteractor(IApplicationService service) : IRequestHandler +{ + public async Task Handle(ActivateCatalog cmd, CancellationToken cancellationToken) + { + var catalog = await service.LoadAggregateAsync(cmd.CatalogId, cancellationToken); + catalog.Activate(); + await service.AppendEventsAsync(catalog, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Application/UseCases/Catalogs/Commands/ChangeCatalogDescriptionInteractor.cs b/src/Services/Cataloging/Command/Application/UseCases/Catalogs/Commands/ChangeCatalogDescriptionInteractor.cs new file mode 100644 index 000000000..84f3c7f0d --- /dev/null +++ b/src/Services/Cataloging/Command/Application/UseCases/Catalogs/Commands/ChangeCatalogDescriptionInteractor.cs @@ -0,0 +1,18 @@ +using Application.Services; +using Domain.Aggregates.Catalogs; +using Domain.ValueObjects; +using MediatR; + +namespace Application.UseCases.Catalogs.Commands; + +public record ChangeCatalogDescription(CatalogId CatalogId, Description NewDescription) : IRequest; + +public class ChangeCatalogDescriptionInteractor(IApplicationService service) : IRequestHandler +{ + public async Task Handle(ChangeCatalogDescription cmd, CancellationToken cancellationToken) + { + var catalog = await service.LoadAggregateAsync(cmd.CatalogId, cancellationToken); + catalog.ChangeDescription(cmd.NewDescription); + await service.AppendEventsAsync(catalog, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Application/UseCases/Catalogs/Commands/ChangeCatalogTitleInteractor.cs b/src/Services/Cataloging/Command/Application/UseCases/Catalogs/Commands/ChangeCatalogTitleInteractor.cs new file mode 100644 index 000000000..00e587ca3 --- /dev/null +++ b/src/Services/Cataloging/Command/Application/UseCases/Catalogs/Commands/ChangeCatalogTitleInteractor.cs @@ -0,0 +1,18 @@ +using Application.Services; +using Domain.Aggregates.Catalogs; +using Domain.ValueObjects; +using MediatR; + +namespace Application.UseCases.Catalogs.Commands; + +public record ChangeCatalogTitle(CatalogId CatalogId, Title NewTitle) : IRequest; + +public class ChangeCatalogTitleInteractor(IApplicationService service) : IRequestHandler +{ + public async Task Handle(ChangeCatalogTitle cmd, CancellationToken cancellationToken) + { + var catalog = await service.LoadAggregateAsync(cmd.CatalogId, cancellationToken); + catalog.ChangeCatalogTitle(cmd.NewTitle); + await service.AppendEventsAsync(catalog, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Application/UseCases/Catalogs/Commands/CreateCatalogInteractor.cs b/src/Services/Cataloging/Command/Application/UseCases/Catalogs/Commands/CreateCatalogInteractor.cs new file mode 100644 index 000000000..23a793a28 --- /dev/null +++ b/src/Services/Cataloging/Command/Application/UseCases/Catalogs/Commands/CreateCatalogInteractor.cs @@ -0,0 +1,19 @@ +using Application.Services; +using Domain.Aggregates; +using Domain.Aggregates.Catalogs; +using Domain.ValueObjects; +using MediatR; + +namespace Application.UseCases.Catalogs.Commands; + +public record CreateCatalog(AppId AppId, Title Title, Description Description) : IRequest; + +public class CreateCatalogInteractor(IApplicationService service) : IRequestHandler +{ + public async Task Handle(CreateCatalog cmd, CancellationToken cancellationToken) + { + var newCatalog = Catalog.Create(cmd.AppId, cmd.Title, cmd.Description); + await service.AppendEventsAsync(newCatalog, cancellationToken); + return newCatalog.Id; + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Application/UseCases/Catalogs/Commands/DeactivateCatalogInteractor.cs b/src/Services/Cataloging/Command/Application/UseCases/Catalogs/Commands/DeactivateCatalogInteractor.cs new file mode 100644 index 000000000..f2b076b2f --- /dev/null +++ b/src/Services/Cataloging/Command/Application/UseCases/Catalogs/Commands/DeactivateCatalogInteractor.cs @@ -0,0 +1,17 @@ +using Application.Services; +using Domain.Aggregates.Catalogs; +using MediatR; + +namespace Application.UseCases.Catalogs.Commands; + +public record DeactivateCatalog(CatalogId CatalogId) : IRequest; + +public class DeactivateCatalogInteractor(IApplicationService service) : IRequestHandler +{ + public async Task Handle(DeactivateCatalog cmd, CancellationToken cancellationToken) + { + var catalog = await service.LoadAggregateAsync(cmd.CatalogId, cancellationToken); + catalog.Deactivate(); + await service.AppendEventsAsync(catalog, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Application/UseCases/Catalogs/Commands/DeleteCatalogInteractor.cs b/src/Services/Cataloging/Command/Application/UseCases/Catalogs/Commands/DeleteCatalogInteractor.cs new file mode 100644 index 000000000..2e40dc621 --- /dev/null +++ b/src/Services/Cataloging/Command/Application/UseCases/Catalogs/Commands/DeleteCatalogInteractor.cs @@ -0,0 +1,17 @@ +using Application.Services; +using Domain.Aggregates.Catalogs; +using MediatR; + +namespace Application.UseCases.Catalogs.Commands; + +public record DeleteCatalog(CatalogId CatalogId) : IRequest; + +public class DeleteCatalogInteractor(IApplicationService service) : IRequestHandler +{ + public async Task Handle(DeleteCatalog cmd, CancellationToken cancellationToken) + { + var catalog = await service.LoadAggregateAsync(cmd.CatalogId, cancellationToken); + catalog.Delete(); + await service.AppendEventsAsync(catalog, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/Abstractions/Aggregates/AggregateRoot.cs b/src/Services/Cataloging/Command/Domain/Abstractions/Aggregates/AggregateRoot.cs new file mode 100644 index 000000000..b82db14b4 --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/Abstractions/Aggregates/AggregateRoot.cs @@ -0,0 +1,34 @@ +using Contracts.Abstractions.Messages; +using Domain.Abstractions.Entities; +using Domain.Abstractions.Identities; +using Version = Domain.ValueObjects.Version; + +namespace Domain.Abstractions.Aggregates; + +public abstract class AggregateRoot : Entity, IAggregateRoot + where TId : IIdentifier, new() +{ + private readonly Queue _events = new(); + public Version Version { get; private set; } = Version.Zero; + + public void LoadFromHistory(IEnumerable events) + { + foreach (var @event in events) + { + ApplyEvent(@event); + Version = (Version)@event.Version; + } + } + + public bool TryDequeueEvent(out IDomainEvent? @event) => _events.TryDequeue(out @event); + private void EnqueueEvent(IDomainEvent @event) => _events.Enqueue(@event); + + protected void RaiseEvent(IDomainEvent @event) + { + Version = Version.Next; + ApplyEvent(@event); + EnqueueEvent(@event); + } + + protected abstract void ApplyEvent(IDomainEvent @event); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/Abstractions/Aggregates/IAggregateRoot.cs b/src/Services/Cataloging/Command/Domain/Abstractions/Aggregates/IAggregateRoot.cs new file mode 100644 index 000000000..174557dd9 --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/Abstractions/Aggregates/IAggregateRoot.cs @@ -0,0 +1,14 @@ +using Contracts.Abstractions.Messages; +using Domain.Abstractions.Entities; +using Domain.Abstractions.Identities; +using Version = Domain.ValueObjects.Version; + +namespace Domain.Abstractions.Aggregates; + +public interface IAggregateRoot : IEntity + where TId : IIdentifier, new() +{ + Version Version { get; } + void LoadFromHistory(IEnumerable events); + bool TryDequeueEvent(out IDomainEvent? @event); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/Abstractions/DomainException.cs b/src/Services/Cataloging/Command/Domain/Abstractions/DomainException.cs new file mode 100644 index 000000000..ce79e9022 --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/Abstractions/DomainException.cs @@ -0,0 +1,31 @@ +using Contracts.Abstractions.Messages; +using Version = Domain.ValueObjects.Version; + +namespace Domain.Abstractions; + +public interface IDomainException +{ + string Message { get; } +} + +public abstract class DomainException(string message) : InvalidOperationException(message), IDomainException + where TException : DomainException, new() +{ + public static TException New() => new(); + + public static void ThrowIf(bool condition) + { + if (condition) throw new TException(); + } + + public static void ThrowIfNull(T t) + { + if (t is null) throw new TException(); + } + + public static void Throw() + => throw new TException(); + + public static IDomainEvent Throw(Version _) + => throw new TException(); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/Abstractions/Entities/Entity.cs b/src/Services/Cataloging/Command/Domain/Abstractions/Entities/Entity.cs new file mode 100644 index 000000000..6548a80c9 --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/Abstractions/Entities/Entity.cs @@ -0,0 +1,20 @@ +namespace Domain.Abstractions.Entities; + +public abstract class Entity : IEntity + where TId : notnull, new() +{ + public TId Id { get; protected set; } = new(); + public bool IsDeleted { get; protected set; } + + public static bool operator ==(Entity left, Entity right) + => left.Id.Equals(right.Id); + + public static bool operator !=(Entity left, Entity right) + => left.Id.Equals(right.Id) is false; + + public override bool Equals(object? obj) + => obj is Entity entity && Id.Equals(entity.Id); + + public override int GetHashCode() + => HashCode.Combine(Id); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/Abstractions/Entities/IEntity.cs b/src/Services/Cataloging/Command/Domain/Abstractions/Entities/IEntity.cs new file mode 100644 index 000000000..fe71e1292 --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/Abstractions/Entities/IEntity.cs @@ -0,0 +1,8 @@ +namespace Domain.Abstractions.Entities; + +public interface IEntity + where TId : notnull +{ + TId Id { get; } + bool IsDeleted { get; } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/Abstractions/EventStore/Snapshot.cs b/src/Services/Cataloging/Command/Domain/Abstractions/EventStore/Snapshot.cs new file mode 100644 index 000000000..734c2f464 --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/Abstractions/EventStore/Snapshot.cs @@ -0,0 +1,13 @@ +using Domain.Abstractions.Aggregates; +using Domain.Abstractions.Identities; +using Version = Domain.ValueObjects.Version; + +namespace Domain.Abstractions.EventStore; + +public record Snapshot(TId AggregateId, TAggregate Aggregate, Version Version, DateTimeOffset Timestamp) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new() +{ + public static Snapshot Create(TAggregate aggregate, StoreEvent @event) + => new(aggregate.Id, aggregate, @event.Version, @event.Timestamp); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/Abstractions/EventStore/StoreEvent.cs b/src/Services/Cataloging/Command/Domain/Abstractions/EventStore/StoreEvent.cs new file mode 100644 index 000000000..61d7db765 --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/Abstractions/EventStore/StoreEvent.cs @@ -0,0 +1,14 @@ +using Contracts.Abstractions.Messages; +using Domain.Abstractions.Aggregates; +using Domain.Abstractions.Identities; +using Version = Domain.ValueObjects.Version; + +namespace Domain.Abstractions.EventStore; + +public record StoreEvent(TId AggregateId, string EventType, IDomainEvent Event, Version Version, DateTimeOffset Timestamp) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new() +{ + public static StoreEvent Create(TAggregate aggregate, IDomainEvent @event) + => new(aggregate.Id, @event.GetType().Name, @event, aggregate.Version, @event.Timestamp); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/Abstractions/Identities/GuidIdentity.cs b/src/Services/Cataloging/Command/Domain/Abstractions/Identities/GuidIdentity.cs new file mode 100644 index 000000000..0bafa334a --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/Abstractions/Identities/GuidIdentity.cs @@ -0,0 +1,29 @@ +using static Domain.Exceptions; + +namespace Domain.Abstractions.Identities; + +public interface IIdentifier; + +public abstract record GuidIdentifier : IIdentifier +{ + public Guid Value { get; init; } + + protected GuidIdentifier() + { + Value = Guid.NewGuid(); + } + + protected GuidIdentifier(string value) + { + InvalidIdentifier.ThrowIf( + Guid.TryParse(value, out var result) is false); + + Value = result; + } + + public static implicit operator string(GuidIdentifier id) => id.Value.ToString(); + public static implicit operator Guid(GuidIdentifier id) => id.Value; + public static bool operator ==(GuidIdentifier id, string value) => id.Value.CompareTo(value) is 0; + public static bool operator !=(GuidIdentifier id, string value) => id.Value.CompareTo(value) is not 0; + public override string ToString() => Value.ToString(); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/Aggregates/AppId.cs b/src/Services/Cataloging/Command/Domain/Aggregates/AppId.cs new file mode 100644 index 000000000..7a0ecda71 --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/Aggregates/AppId.cs @@ -0,0 +1,15 @@ +using Domain.Abstractions.Identities; + +namespace Domain.Aggregates; + +public record AppId : GuidIdentifier +{ + public AppId() { } + public AppId(string value) : base(value) { } + + public static AppId New => new(); + public static readonly AppId Undefined = new() { Value = Guid.Empty }; + + public static explicit operator AppId(string value) => new(value); + public override string ToString() => base.ToString(); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/Aggregates/CatalogItems/CatalogItem.cs b/src/Services/Cataloging/Command/Domain/Aggregates/CatalogItems/CatalogItem.cs new file mode 100644 index 000000000..75fc7fc15 --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/Aggregates/CatalogItems/CatalogItem.cs @@ -0,0 +1,45 @@ +using Contracts.Abstractions.Messages; +using Contracts.Boundaries.Cataloging.CatalogItem; +using Domain.Abstractions.Aggregates; +using Domain.Aggregates.Catalogs; +using Domain.Aggregates.Products; +using Version = Domain.ValueObjects.Version; + +namespace Domain.Aggregates.CatalogItems; + +public class CatalogItem : AggregateRoot +{ + public AppId AppId { get; private set; } = AppId.Undefined; + public CatalogId CatalogId { get; private set; } = CatalogId.Undefined; + public ProductId ProductId { get; private set; } = ProductId.Undefined; + + public static CatalogItem Create(AppId appId, CatalogId catalogId, ProductId productId) + { + CatalogItem item = new(); + DomainEvent.CatalogItemCreated @event = new(item.Id, appId, catalogId, productId, Version.Initial); + item.RaiseEvent(@event); + return item; + } + + public void RemoveCatalogItem(CatalogItemId itemId) + { + // TODO: specify the exception type + if (IsDeleted) + throw new InvalidOperationException("Catalog item is already deleted."); + + RaiseEvent(new DomainEvent.CatalogItemRemoved(Id, itemId, Version.Next)); + } + + protected override void ApplyEvent(IDomainEvent @event) => When(@event as dynamic); + + private void When(DomainEvent.CatalogItemCreated @event) + { + Id = (CatalogItemId)@event.ItemId; + AppId = (AppId)@event.AppId; + CatalogId = (CatalogId)@event.CatalogId; + ProductId = (ProductId)@event.ProductId; + } + + private void When(DomainEvent.CatalogItemRemoved _) + => IsDeleted = true; +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/Aggregates/CatalogItems/CatalogItemId.cs b/src/Services/Cataloging/Command/Domain/Aggregates/CatalogItems/CatalogItemId.cs new file mode 100644 index 000000000..7c3906858 --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/Aggregates/CatalogItems/CatalogItemId.cs @@ -0,0 +1,15 @@ +using Domain.Abstractions.Identities; + +namespace Domain.Aggregates.CatalogItems; + +public record CatalogItemId : GuidIdentifier +{ + public CatalogItemId() { } + public CatalogItemId(string value) : base(value) { } + + public static CatalogItemId New => new(); + public static readonly CatalogItemId Undefined = new() { Value = Guid.Empty }; + + public static explicit operator CatalogItemId(string value) => new(value); + public override string ToString() => base.ToString(); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/Aggregates/Catalogs/Catalog.cs b/src/Services/Cataloging/Command/Domain/Aggregates/Catalogs/Catalog.cs new file mode 100644 index 000000000..742d7a0ca --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/Aggregates/Catalogs/Catalog.cs @@ -0,0 +1,80 @@ +using Contracts.Abstractions.Messages; +using Contracts.Boundaries.Cataloging.Catalog; +using Domain.Abstractions.Aggregates; +using Domain.Enumerations; +using Domain.ValueObjects; +using Version = Domain.ValueObjects.Version; + +namespace Domain.Aggregates.Catalogs; + +public class Catalog : AggregateRoot +{ + public AppId AppId { get; private set; } = AppId.Undefined; + public CatalogStatus Status { get; private set; } = CatalogStatus.Empty; + public Title Title { get; private set; } = Title.Undefined; + public Description Description { get; private set; } = Description.Undefined; + + public static Catalog Create(AppId appId, Title title, Description description) + { + Catalog catalog = new(); + DomainEvent.CatalogCreated @event = new(catalog.Id, appId, title, description, Version.Initial); + catalog.RaiseEvent(@event); + return catalog; + } + + public void Activate() + { + if (Status is CatalogStatusActive) + throw new InvalidOperationException("Catalog is already active."); + + if (Status is CatalogStatusEmpty) + throw new InvalidOperationException("Catalog is empty."); + + RaiseEvent(new DomainEvent.CatalogActivated(Id, CatalogStatus.Active, Version.Next)); + } + + public void Deactivate() + { + if (Status is CatalogStatusInactive) + throw new InvalidOperationException("Catalog is already inactive."); + + RaiseEvent(new DomainEvent.CatalogInactivated(Id, CatalogStatus.Inactive, Version.Next)); + } + + public void ChangeCatalogTitle(Title title) + => RaiseEvent(new DomainEvent.CatalogTitleChanged(Id, title, Version.Next)); + + public void ChangeDescription(Description description) + => RaiseEvent(new DomainEvent.CatalogDescriptionChanged(Id, description, Version.Next)); + + public void Delete() + => RaiseEvent(new DomainEvent.CatalogDeleted(Id, CatalogStatus.Discarded, Version.Next)); + + protected override void ApplyEvent(IDomainEvent @event) => When(@event as dynamic); + + private void When(DomainEvent.CatalogCreated @event) + { + Id = (CatalogId)@event.CatalogId; + AppId = (AppId)@event.AppId; + Title = (Title)@event.Title; + Description = (Description)@event.Description; + } + + private void When(DomainEvent.CatalogDescriptionChanged @event) + => Description = (Description)@event.Description; + + private void When(DomainEvent.CatalogTitleChanged @event) + => Title = (Title)@event.Title; + + private void When(DomainEvent.CatalogActivated @event) + => Status = (CatalogStatus)@event.Status; + + private void When(DomainEvent.CatalogInactivated @event) + => Status = (CatalogStatus)@event.Status; + + private void When(DomainEvent.CatalogDeleted @event) + { + Status = (CatalogStatus)@event.Status; + IsDeleted = true; + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/Aggregates/Catalogs/CatalogId.cs b/src/Services/Cataloging/Command/Domain/Aggregates/Catalogs/CatalogId.cs new file mode 100644 index 000000000..720ab8ec7 --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/Aggregates/Catalogs/CatalogId.cs @@ -0,0 +1,15 @@ +using Domain.Abstractions.Identities; + +namespace Domain.Aggregates.Catalogs; + +public record CatalogId : GuidIdentifier +{ + public CatalogId() { } + public CatalogId(string value) : base(value) { } + + public static CatalogId New => new(); + public static readonly CatalogId Undefined = new() { Value = Guid.Empty }; + + public static explicit operator CatalogId(string value) => new(value); + public override string ToString() => base.ToString(); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/Aggregates/Pricing/PricingId.cs b/src/Services/Cataloging/Command/Domain/Aggregates/Pricing/PricingId.cs new file mode 100644 index 000000000..a09d41070 --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/Aggregates/Pricing/PricingId.cs @@ -0,0 +1,15 @@ +using Domain.Abstractions.Identities; + +namespace Domain.Aggregates.Pricing; + +public record PricingId : GuidIdentifier +{ + public PricingId() { } + public PricingId(string value) : base(value) { } + + public static PricingId New => new(); + public static readonly PricingId Undefined = new() { Value = Guid.Empty }; + + public static explicit operator PricingId(string value) => new(value); + public override string ToString() => base.ToString(); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/Aggregates/Products/Product.cs b/src/Services/Cataloging/Command/Domain/Aggregates/Products/Product.cs new file mode 100644 index 000000000..3ce9aeb3b --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/Aggregates/Products/Product.cs @@ -0,0 +1,36 @@ +using Contracts.Abstractions.Messages; +using Contracts.Boundaries.Shopping.Products; +using Domain.Abstractions.Aggregates; +using Domain.ValueObjects; +using Version = Domain.ValueObjects.Version; + +namespace Domain.Aggregates.Products; + +public class Product : AggregateRoot +{ + private readonly Dictionary _prices = new(); + public ProductName Name { get; private set; } = ProductName.Undefined; + public PictureUri PictureUri { get; private set; } = PictureUri.Undefined; + public Sku Sku { get; private set; } = Sku.Undefined; + public Quantity Stock { get; private set; } = Quantity.Zero; + public IDictionary Prices => _prices.AsReadOnly(); + + public static Product Create(ProductId id, ProductName name, Price price) + { + Product product = new(); + DomainEvent.ProductCreated @event = new(id, name, price.Amount, price.Currency, Version.Initial); + product.RaiseEvent(@event); + return product; + } + + protected override void ApplyEvent(IDomainEvent @event) => When(@event as dynamic); + + private void When(DomainEvent.ProductCreated @event) + { + Id = (ProductId)@event.ProductId; + Name = (ProductName)@event.Name; + + var currency = (Currency)@event.Currency; + _prices[currency] = new((Amount)@event.Price, currency); + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/Aggregates/Products/ProductId.cs b/src/Services/Cataloging/Command/Domain/Aggregates/Products/ProductId.cs new file mode 100644 index 000000000..a5ae06346 --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/Aggregates/Products/ProductId.cs @@ -0,0 +1,15 @@ +using Domain.Abstractions.Identities; + +namespace Domain.Aggregates.Products; + +public record ProductId : GuidIdentifier +{ + public ProductId() { } + public ProductId(string value) : base(value) { } + + public static ProductId New => new(); + public static readonly ProductId Undefined = new() { Value = Guid.Empty }; + + public static explicit operator ProductId(string value) => new(value); + public override string ToString() => base.ToString(); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/Domain.csproj b/src/Services/Cataloging/Command/Domain/Domain.csproj new file mode 100644 index 000000000..32d29577c --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/Domain.csproj @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/Services/Cataloging/Command/Domain/Enumerations/CatalogStatus.cs b/src/Services/Cataloging/Command/Domain/Enumerations/CatalogStatus.cs new file mode 100644 index 000000000..ef0484761 --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/Enumerations/CatalogStatus.cs @@ -0,0 +1,22 @@ +using Ardalis.SmartEnum; + +namespace Domain.Enumerations; + +public class CatalogStatus(string name, int value) : SmartEnum(name, value) +{ + public static readonly CatalogStatusEmpty Empty = new(); + public static readonly CatalogStatusActive Active = new(); + public static readonly CatalogStatusInactive Inactive = new(); + public static readonly CatalogStatusDiscarded Discarded = new(); + + public static explicit operator CatalogStatus(int value) => FromValue(value); + public static explicit operator CatalogStatus(string name) => FromName(name); + public static implicit operator int(CatalogStatus catalogStatus) => catalogStatus.Value; + public static implicit operator string(CatalogStatus catalogStatus) => catalogStatus.Name; + public override string ToString() => Name; +} + +public class CatalogStatusEmpty() : CatalogStatus(nameof(Empty), 0); +public class CatalogStatusActive() : CatalogStatus(nameof(Active), 1); +public class CatalogStatusInactive() : CatalogStatus(nameof(Inactive), 2); +public class CatalogStatusDiscarded() : CatalogStatus(nameof(Discarded), 3); \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/Exceptions.cs b/src/Services/Cataloging/Command/Domain/Exceptions.cs new file mode 100644 index 000000000..b7c6de9ab --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/Exceptions.cs @@ -0,0 +1,10 @@ +using Domain.Abstractions; + +namespace Domain; + +public static class Exceptions +{ + public class InvalidIdentifier() : DomainException("Invalid identifier."); + + public class AggregateNotFound() : DomainException("Aggregate not found."); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/ValueObjects/Amount.cs b/src/Services/Cataloging/Command/Domain/ValueObjects/Amount.cs new file mode 100644 index 000000000..ff3a997ca --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/ValueObjects/Amount.cs @@ -0,0 +1,36 @@ +using System.Globalization; + +namespace Domain.ValueObjects; + +public record Amount +{ + private readonly decimal _value; + + private Amount(string amount) + { + _value = decimal.Parse(amount, NumberStyles.Number, CultureInfo.InvariantCulture); + } + + public Amount(decimal amount) + { + _value = amount; + } + + public static Amount Zero => new(decimal.Zero); + + public static explicit operator Amount(decimal amount) => new(amount); + public static implicit operator decimal(Amount amount) => amount._value; + public static explicit operator Amount(string amount) => new(amount); + public static implicit operator string(Amount amount) => amount.ToString(); + + public static Amount operator +(Amount amount, Amount other) => new(amount._value + other._value); + public static Amount operator -(Amount amount, Amount other) => new(amount._value - other._value); + public static Amount operator *(Amount amount, Amount other) => new(amount._value * other._value); + public static Amount operator /(Amount amount, Amount other) => new(amount._value / other._value); + public static Amount operator %(Amount amount, Amount other) => new(amount._value % other._value); + public static bool operator >(Amount amount, Amount other) => amount._value > other._value; + public static bool operator <(Amount amount, Amount other) => amount._value < other._value; + + public string ToString(string? format, IFormatProvider? provider) => _value.ToString(format, provider); + public override string ToString() => _value.ToString("N", NumberFormatInfo.InvariantInfo); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/ValueObjects/Brand.cs b/src/Services/Cataloging/Command/Domain/ValueObjects/Brand.cs new file mode 100644 index 000000000..8823d514c --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/ValueObjects/Brand.cs @@ -0,0 +1,26 @@ +namespace Domain.ValueObjects; + +public record Brand +{ + private readonly string _name; + private readonly string _prefix; + + public Brand(string name, string prefix) + { + name = name.Trim(); + prefix = prefix.Trim(); + + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrEmpty(prefix); + + ArgumentOutOfRangeException.ThrowIfGreaterThan(name.Length, 30); + ArgumentOutOfRangeException.ThrowIfGreaterThan(prefix.Length, 5); + + _name = name; + _prefix = prefix; + } + + public static explicit operator Brand((string name, string prefix) tuple) => new(tuple.name, tuple.prefix); + public static implicit operator string(Brand brand) => brand._prefix; + public override string ToString() => _name; +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/ValueObjects/Category.cs b/src/Services/Cataloging/Command/Domain/ValueObjects/Category.cs new file mode 100644 index 000000000..2c410768f --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/ValueObjects/Category.cs @@ -0,0 +1,19 @@ +namespace Domain.ValueObjects; + +public record Category +{ + private readonly string _value; + + public Category(string category) + { + category = category.Trim(); + ArgumentException.ThrowIfNullOrEmpty(category); + ArgumentOutOfRangeException.ThrowIfGreaterThan(category.Length, 50); + + _value = category; + } + + public static explicit operator Category(string category) => new(category); + public static implicit operator string(Category category) => category._value; + public override string ToString() => _value; +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/ValueObjects/Currency.cs b/src/Services/Cataloging/Command/Domain/ValueObjects/Currency.cs new file mode 100644 index 000000000..51123ca81 --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/ValueObjects/Currency.cs @@ -0,0 +1,45 @@ +using System.Globalization; + +namespace Domain.ValueObjects; + +public record Currency(string IsoCode, string Name, string Country, NumberFormatInfo FormatInfo) +{ + public static readonly Currency BRL = new("BRL", "Brazilian real", "Brazil", new CultureInfo("pt-BR").NumberFormat); + public static readonly Currency CAD = new("CAD", "Canadian dollar", "Canada", new CultureInfo("en-CA").NumberFormat); + public static readonly Currency USD = new("USD", "United States dollar", "United States", new CultureInfo("en-US").NumberFormat); + public static readonly Currency EUR = new("EUR", "Euro", "European Union", new CultureInfo("fr-FR").NumberFormat); + public static readonly Currency GBP = new("GBP", "British pound", "United Kingdom", new CultureInfo("en-GB").NumberFormat); + public static readonly Currency JPY = new("JPY", "Japanese yen", "Japan", new CultureInfo("ja-JP").NumberFormat); + public static readonly Currency CHF = new("CHF", "Swiss franc", "Switzerland", new CultureInfo("de-CH").NumberFormat); + public static readonly Currency AUD = new("AUD", "Australian dollar", "Australia", new CultureInfo("en-AU").NumberFormat); + public static readonly Currency CNY = new("CNY", "Chinese yuan", "China", new CultureInfo("zh-CN").NumberFormat); + public static readonly Currency INR = new("INR", "Indian rupee", "India", new CultureInfo("hi-IN").NumberFormat); + public static readonly Currency MXN = new("MXN", "Mexican peso", "Mexico", new CultureInfo("es-MX").NumberFormat); + public static readonly Currency Undefined = new("Undefined", "Undefined", "Undefined", NumberFormatInfo.InvariantInfo); + + public Currency(string IsoCode) : this(IsoCode, All[IsoCode].Name, All[IsoCode].Country, All[IsoCode].FormatInfo) { } + + public static Dictionary All { get; } = new() + { + { BRL.IsoCode, BRL }, { CAD.IsoCode, CAD }, { USD.IsoCode, USD }, + { EUR.IsoCode, EUR }, { GBP.IsoCode, GBP }, { JPY.IsoCode, JPY }, + { CHF.IsoCode, CHF }, { AUD.IsoCode, AUD }, { CNY.IsoCode, CNY }, + { INR.IsoCode, INR }, { MXN.IsoCode, MXN } + }; + + public static explicit operator Currency(string isoCode) + => All.TryGetValue(isoCode, out var currency) ? currency + : throw new ArgumentException($"Currency {isoCode} is not supported."); + + public static implicit operator string(Currency currency) => currency.IsoCode; + + public static bool operator ==(Currency currency, string value) + => string.Equals(currency.IsoCode, value.Trim(), StringComparison.OrdinalIgnoreCase) || + string.Equals(currency.FormatInfo.CurrencySymbol, value.Trim(), StringComparison.OrdinalIgnoreCase); + + public static bool operator !=(Currency currency, string value) + => string.Equals(currency.IsoCode, value.Trim(), StringComparison.OrdinalIgnoreCase) && + string.Equals(currency.FormatInfo.CurrencySymbol, value.Trim(), StringComparison.OrdinalIgnoreCase) is false; + + public override string ToString() => IsoCode; +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/ValueObjects/Description.cs b/src/Services/Cataloging/Command/Domain/ValueObjects/Description.cs new file mode 100644 index 000000000..129bb3cc0 --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/ValueObjects/Description.cs @@ -0,0 +1,20 @@ +namespace Domain.ValueObjects; + +public record Description +{ + private readonly string _value; + + private Description(string description) + { + description = description.Trim(); + ArgumentException.ThrowIfNullOrEmpty(description); + ArgumentOutOfRangeException.ThrowIfGreaterThan(description.Length, 500); + + _value = description; + } + + public static Description Undefined => "Undefined"; + public static implicit operator string(Description description) => description._value; + public static implicit operator Description(string description) => new(description); + public override string ToString() => _value; +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/ValueObjects/Money.cs b/src/Services/Cataloging/Command/Domain/ValueObjects/Money.cs new file mode 100644 index 000000000..f48b0a746 --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/ValueObjects/Money.cs @@ -0,0 +1,57 @@ +namespace Domain.ValueObjects; + +public record Money(Amount Amount, Currency Currency) +{ + public static Money Zero(Currency currency) => new(Amount.Zero, currency); + + public static Money operator +(Money money, Money other) + => ApplyOperator(money, other, (first, second) => first.Amount + second.Amount); + + public static Money operator -(Money money, Money other) + => ApplyOperator(money, other, (first, second) => first.Amount - second.Amount); + + public static Money operator *(Money money, Money other) + => ApplyOperator(money, other, (first, second) => first.Amount * second.Amount); + + public static Money operator *(Money money, Quantity quantity) + => money with { Amount = new(money.Amount * quantity) }; + + public static Money operator /(Money money, Money other) + => ApplyDivideByZeroOperator(money, other, (first, second) => first.Amount / second.Amount); + + public static Money operator %(Money money, Money other) + => ApplyDivideByZeroOperator(money, other, (first, second) => first.Amount % second.Amount); + + public static bool operator >(Money money, Money other) + => ApplyOperator(money, other, (first, second) => first.Amount > second.Amount); + + public static bool operator <(Money money, Money other) + => ApplyOperator(money, other, (first, second) => first.Amount < second.Amount); + + public static implicit operator string(Money money) => money.Amount; + public override string ToString() => Amount.ToString("C", Currency.FormatInfo); + + private static Money ApplyOperator(Money money, Money other, Func operation) + { + EnsureCurrenciesAreEqual(money, other); + return money with { Amount = operation(money, other) }; + } + + private static bool ApplyOperator(Money money, Money other, Func operation) + { + EnsureCurrenciesAreEqual(money, other); + return operation(money, other); + } + + private static Money ApplyDivideByZeroOperator(Money money, Money other, Func operation) + { + if (other.Amount == decimal.Zero) throw new DivideByZeroException(); + return ApplyOperator(money, other, operation); + } + + private static void EnsureCurrenciesAreEqual(Money money, Money other) + { + if (money.Currency != other.Currency) + throw new InvalidOperationException("Currencies must be the same"); + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/ValueObjects/PictureUri.cs b/src/Services/Cataloging/Command/Domain/ValueObjects/PictureUri.cs new file mode 100644 index 000000000..439d072a1 --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/ValueObjects/PictureUri.cs @@ -0,0 +1,28 @@ +namespace Domain.ValueObjects; + +public record PictureUri +{ + private readonly Uri _value; + + public PictureUri(Uri pictureUri) + { + _value = pictureUri; + } + + private PictureUri(string pictureUri) + { + pictureUri = pictureUri.Trim(); + ArgumentException.ThrowIfNullOrEmpty(pictureUri); + + _value = Uri.TryCreate(pictureUri, UriKind.Absolute, out var uri) + ? uri + : throw new ArgumentException("PictureUri must be a valid Uri"); + } + + public static explicit operator PictureUri(Uri pictureUrl) => new(pictureUrl); + public static explicit operator PictureUri(string pictureUrl) => new(pictureUrl); + public static implicit operator string(PictureUri pictureUri) => pictureUri._value.ToString(); + public static PictureUri Undefined => new(string.Empty); + + public override string ToString() => _value.ToString(); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/ValueObjects/Price.cs b/src/Services/Cataloging/Command/Domain/ValueObjects/Price.cs new file mode 100644 index 000000000..3b721130e --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/ValueObjects/Price.cs @@ -0,0 +1,14 @@ +namespace Domain.ValueObjects; + +public record Price : Money +{ + public Price(Amount amount, Currency currency) : base(amount, currency) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual( + amount, Amount.Zero, "Amount must be positive"); + } + + public static implicit operator string(Price price) => price.Amount; + public static Price operator *(Price price, Quantity quantity) => price with { Amount = new(price.Amount * quantity) }; + public override string ToString() => base.ToString(); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/ValueObjects/ProductName.cs b/src/Services/Cataloging/Command/Domain/ValueObjects/ProductName.cs new file mode 100644 index 000000000..7d5442c21 --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/ValueObjects/ProductName.cs @@ -0,0 +1,21 @@ +namespace Domain.ValueObjects; + +public record ProductName +{ + private readonly string _value; + + public ProductName(string productName) + { + productName = productName.Trim(); + ArgumentException.ThrowIfNullOrEmpty(productName); + ArgumentOutOfRangeException.ThrowIfGreaterThan(productName.Length, 50); + + _value = productName; + } + + public static explicit operator ProductName(string productName) => new(productName); + public static implicit operator string(ProductName productName) => productName._value; + public static ProductName Undefined => new("Undefined"); + + public override string ToString() => _value; +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/ValueObjects/Quantity.cs b/src/Services/Cataloging/Command/Domain/ValueObjects/Quantity.cs new file mode 100644 index 000000000..d3448f1a4 --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/ValueObjects/Quantity.cs @@ -0,0 +1,50 @@ +namespace Domain.ValueObjects; + +public record Quantity +{ + private readonly ushort _value; + + public Quantity(ushort quantity) + { + _value = quantity; + } + + private Quantity(string quantity) + { + quantity = quantity.Trim(); + + if (ushort.TryParse(quantity, out var parsedQuantity) is false) + throw new ArgumentException("Quantity must be a valid number"); + + ArgumentOutOfRangeException.ThrowIfZero(parsedQuantity); + + _value = parsedQuantity; + } + + private Quantity(int quantity) + { + if (quantity is < ushort.MinValue or > ushort.MaxValue) + throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be a valid number"); + + _value = (ushort)quantity; + } + + public static Quantity Zero { get; } = new(ushort.MinValue); + public static Quantity Max { get; } = new(ushort.MaxValue); + + public static Quantity Number(ushort quantity) => new(quantity); + public static explicit operator Quantity(ushort quantity) => new(quantity); + public static implicit operator ushort(Quantity quantity) => quantity._value; + public static explicit operator Quantity(string quantity) => new(quantity); + public static implicit operator string(Quantity quantity) => quantity._value.ToString(); + + public static Quantity operator +(Quantity left, Quantity right) => new(left._value + right._value); + public static Quantity operator -(Quantity left, Quantity right) => new(left._value - right._value); + public static Quantity operator *(Quantity left, Quantity right) => new(left._value * right._value); + public static bool operator <(Quantity left, Quantity right) => left._value < right._value; + public static bool operator >(Quantity left, Quantity right) => left._value > right._value; + public static bool operator <=(Quantity left, Quantity right) => left._value <= right._value; + public static bool operator >=(Quantity left, Quantity right) => left._value >= right._value; + + public override string ToString() => _value.ToString(); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/ValueObjects/Sku.cs b/src/Services/Cataloging/Command/Domain/ValueObjects/Sku.cs new file mode 100644 index 000000000..785a30a48 --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/ValueObjects/Sku.cs @@ -0,0 +1,64 @@ +namespace Domain.ValueObjects; + +public record Sku +{ + private readonly string _value; + + public Sku(Brand brand, Category category, Size size) + { + var brandPrefix = (string)brand; + var categoryCode = (string)category; + var sizeCode = (string)size; + + var sku = $"{brandPrefix}-{categoryCode}-{sizeCode}"; + + // TODO: It have to moved to Cataloging context. + //ArgumentOutOfRangeException.ThrowIfGreaterThan(sku.Length, 20); + + _value = sku; + } + + private Sku(string sku) + { + sku = sku.Trim(); + ArgumentException.ThrowIfNullOrEmpty(sku); + + // TODO: It have to moved to Cataloging context. + //ArgumentOutOfRangeException.ThrowIfGreaterThan(sku.Length, 20); + + _value = sku; + } + + public static explicit operator Sku(string sku) => new(sku); + public static implicit operator string(Sku sku) => sku._value; + public static Sku Undefined => new("Undefined"); + + public override string ToString() => _value; +} + +public record Size(string Code, string Description) +{ + public static readonly Size ExtraSmall = new("XS", "Extra Small"); + public static readonly Size Small = new("S", "Small"); + public static readonly Size Medium = new("M", "Medium"); + public static readonly Size Large = new("L", "Large"); + public static readonly Size ExtraLarge = new("XL", "Extra Large"); + public static readonly Size ExtraExtraLarge = new("XXL", "Extra Extra Large"); + + public static Dictionary All { get; } = new() + { + { ExtraSmall.Code, ExtraSmall }, + { Small.Code, Small }, + { Medium.Code, Medium }, + { Large.Code, Large }, + { ExtraLarge.Code, ExtraLarge }, + { ExtraExtraLarge.Code, ExtraExtraLarge } + }; + + public static explicit operator Size(string code) + => All.TryGetValue(code, out var size) ? size + : throw new ArgumentException($"Size {size} is not supported."); + + public static implicit operator string(Size size) => size.Code; + public override string ToString() => Code; +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/ValueObjects/Title.cs b/src/Services/Cataloging/Command/Domain/ValueObjects/Title.cs new file mode 100644 index 000000000..1692ed4e4 --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/ValueObjects/Title.cs @@ -0,0 +1,20 @@ +namespace Domain.ValueObjects; + +public record Title +{ + private readonly string _value; + + public Title(string productName) + { + productName = productName.Trim(); + ArgumentException.ThrowIfNullOrEmpty(productName); + ArgumentOutOfRangeException.ThrowIfGreaterThan(productName.Length, 50); + + _value = productName; + } + + public static Title Undefined => new("Undefined"); + public static implicit operator Title(string title) => new(title); + public static implicit operator string(Title title) => title._value; + public override string ToString() => _value; +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Domain/ValueObjects/Version.cs b/src/Services/Cataloging/Command/Domain/ValueObjects/Version.cs new file mode 100644 index 000000000..2c50b9f8f --- /dev/null +++ b/src/Services/Cataloging/Command/Domain/ValueObjects/Version.cs @@ -0,0 +1,38 @@ +namespace Domain.ValueObjects; + +public record Version +{ + private readonly uint _value; + + private Version(string version) + { + version = version.Trim(); + + if (uint.TryParse(version, out var parsedVersion) is false) + throw new ArgumentException("Version must be a valid number"); + + _value = parsedVersion; + } + + private Version(uint version) + { + _value = version; + } + + public static Version Zero { get; } = new(0); + public static Version Initial { get; } = new(1); + public Version Next => new(_value + 1); + public static Version Number(uint version) => new(version); + public static Version Number(string version) => new(version); + public static Version operator ++(Version version) => new(version._value + 1); + public static explicit operator Version(string version) => new(version); + public static explicit operator Version(uint version) => new(version); + public static implicit operator string(Version version) => version.ToString(); + public static implicit operator uint(Version version) => version._value; + public static bool operator <(Version left, Version right) => left._value < right._value; + public static bool operator >(Version left, Version right) => left._value > right._value; + public static bool operator %(Version left, Version right) => left._value % right._value == 0; + public static bool operator %(Version left, int right) => left._value % right == 0; + public static bool operator %(Version left, ulong right) => left._value % right == 0; + public override string ToString() => _value.ToString(); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/Configurations/SnapshotConfiguration.cs b/src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/Configurations/SnapshotConfiguration.cs new file mode 100644 index 000000000..8cdfe39d8 --- /dev/null +++ b/src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/Configurations/SnapshotConfiguration.cs @@ -0,0 +1,47 @@ +using Domain.Abstractions.Aggregates; +using Domain.Abstractions.EventStore; +using Domain.Abstractions.Identities; +using Domain.Aggregates.CatalogItems; +using Domain.Aggregates.Catalogs; +using Domain.Aggregates.Products; +using Infrastructure.EventStore.Contexts.Converters; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Infrastructure.EventStore.Contexts.Configurations; + +public class CatalogSnapshotConfiguration : SnapshotConfiguration; + +public class CatalogItemSnapshotConfiguration : SnapshotConfiguration; + +public class ProductSnapshotConfiguration : SnapshotConfiguration; + +public abstract class SnapshotConfiguration : IEntityTypeConfiguration> + where TAggregate : AggregateRoot + where TId : GuidIdentifier, new() +{ + public void Configure(EntityTypeBuilder> builder) + { + builder.ToTable($"{typeof(TAggregate).Name}Snapshots"); + + builder.HasKey(snapshot => new { snapshot.Version, snapshot.AggregateId }); + + builder + .Property(snapshot => snapshot.Aggregate) + .HasConversion>() + .IsRequired(); + + builder + .Property(snapshot => snapshot.AggregateId) + .HasConversion>() + .IsRequired(); + + builder.Property(snapshot => snapshot.Timestamp) + .IsRequired(); + + builder + .Property(snapshot => snapshot.Version) + .HasConversion() + .IsRequired(); + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/Configurations/StoreEventConfiguration.cs b/src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/Configurations/StoreEventConfiguration.cs new file mode 100644 index 000000000..4c61ca566 --- /dev/null +++ b/src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/Configurations/StoreEventConfiguration.cs @@ -0,0 +1,54 @@ +using Domain.Abstractions.Aggregates; +using Domain.Abstractions.EventStore; +using Domain.Abstractions.Identities; +using Domain.Aggregates.CatalogItems; +using Domain.Aggregates.Catalogs; +using Domain.Aggregates.Products; +using Infrastructure.EventStore.Contexts.Converters; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Infrastructure.EventStore.Contexts.Configurations; + +public class CatalogStoreEventConfiguration : StoreEventConfiguration; + +public class CatalogItemStoreEventConfiguration : StoreEventConfiguration; + +public class ProductStoreEventConfiguration : StoreEventConfiguration; + +public abstract class StoreEventConfiguration : IEntityTypeConfiguration> + where TAggregate : AggregateRoot + where TId : GuidIdentifier, new() +{ + public void Configure(EntityTypeBuilder> builder) + { + builder.ToTable($"{typeof(TAggregate).Name}StoreEvents"); + + builder.HasKey(@event => new { @event.Version, @event.AggregateId }); + + builder + .Property(@event => @event.AggregateId) + .HasConversion>() + .IsRequired(); + + builder + .Property(@event => @event.Event) + .HasConversion() + .IsRequired(); + + builder + .Property(@event => @event.EventType) + .HasMaxLength(50) + .IsUnicode(false) + .IsRequired(); + + builder + .Property(@event => @event.Timestamp) + .IsRequired(); + + builder + .Property(@event => @event.Version) + .HasConversion() + .IsRequired(); + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/Converters/AggregateConverter.cs b/src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/Converters/AggregateConverter.cs new file mode 100644 index 000000000..efec3d771 --- /dev/null +++ b/src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/Converters/AggregateConverter.cs @@ -0,0 +1,43 @@ +using Contracts.JsonConverters; +using Domain.Abstractions.Aggregates; +using Domain.Abstractions.Identities; +using JsonNet.ContractResolvers; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Newtonsoft.Json; + +namespace Infrastructure.EventStore.Contexts.Converters; + +public class AggregateConverter() : + ValueConverter( + @event => JsonConvert.SerializeObject(@event, typeof(TAggregate), SerializerSettings()), + jsonString => JsonConvert.DeserializeObject(jsonString, DeserializerSettings())) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new() +{ + private static JsonSerializerSettings SerializerSettings() + { + JsonSerializerSettings jsonSerializerSettings = new() + { + TypeNameHandling = TypeNameHandling.Auto + }; + + jsonSerializerSettings.Converters.Add(new DateOnlyJsonConverter()); + jsonSerializerSettings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + + return jsonSerializerSettings; + } + + private static JsonSerializerSettings DeserializerSettings() + { + JsonSerializerSettings jsonDeserializerSettings = new() + { + TypeNameHandling = TypeNameHandling.Auto, + ContractResolver = new PrivateSetterContractResolver() + }; + + jsonDeserializerSettings.Converters.Add(new DateOnlyJsonConverter()); + jsonDeserializerSettings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + + return jsonDeserializerSettings; + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/Converters/EventConverter.cs b/src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/Converters/EventConverter.cs new file mode 100644 index 000000000..61d5e6b75 --- /dev/null +++ b/src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/Converters/EventConverter.cs @@ -0,0 +1,40 @@ +using Contracts.Abstractions.Messages; +using Contracts.JsonConverters; +using JsonNet.ContractResolvers; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Newtonsoft.Json; + +namespace Infrastructure.EventStore.Contexts.Converters; + +public class EventConverter() + : ValueConverter( + @event => JsonConvert.SerializeObject(@event, typeof(IDomainEvent), SerializerSettings()), + jsonString => JsonConvert.DeserializeObject(jsonString, DeserializerSettings())) +{ + private static JsonSerializerSettings SerializerSettings() + { + JsonSerializerSettings jsonSerializerSettings = new() + { + TypeNameHandling = TypeNameHandling.Auto + }; + + jsonSerializerSettings.Converters.Add(new DateOnlyJsonConverter()); + jsonSerializerSettings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + + return jsonSerializerSettings; + } + + private static JsonSerializerSettings DeserializerSettings() + { + JsonSerializerSettings jsonDeserializerSettings = new() + { + TypeNameHandling = TypeNameHandling.Auto, + ContractResolver = new PrivateSetterContractResolver() + }; + + jsonDeserializerSettings.Converters.Add(new DateOnlyJsonConverter()); + jsonDeserializerSettings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + + return jsonDeserializerSettings; + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/Converters/IdentifierConverter.cs b/src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/Converters/IdentifierConverter.cs new file mode 100644 index 000000000..b9275b835 --- /dev/null +++ b/src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/Converters/IdentifierConverter.cs @@ -0,0 +1,10 @@ +using Domain.Abstractions.Identities; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Infrastructure.EventStore.Contexts.Converters; + +public class IdentifierConverter() : + ValueConverter( + identifier => identifier, + value => new() { Value = value }) + where TId : GuidIdentifier, new(); \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/Converters/VersionConverter.cs b/src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/Converters/VersionConverter.cs new file mode 100644 index 000000000..fd773eb12 --- /dev/null +++ b/src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/Converters/VersionConverter.cs @@ -0,0 +1,7 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Version = Domain.ValueObjects.Version; + +namespace Infrastructure.EventStore.Contexts.Converters; + +public class VersionConverter() + : ValueConverter(version => version, number => Version.Number(number)); \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/EventStoreDbContext.cs b/src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/EventStoreDbContext.cs new file mode 100644 index 000000000..75d0e3678 --- /dev/null +++ b/src/Services/Cataloging/Command/Infrastructure.EventStore/Contexts/EventStoreDbContext.cs @@ -0,0 +1,12 @@ +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.EventStore.Contexts; + +public class EventStoreDbContext(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder builder) + { + builder.HasDefaultSchema(nameof(EventStore)); + builder.ApplyConfigurationsFromAssembly(typeof(EventStoreDbContext).Assembly); + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Infrastructure.EventStore/DependencyInjection/Extensions/HostExtensions.cs b/src/Services/Cataloging/Command/Infrastructure.EventStore/DependencyInjection/Extensions/HostExtensions.cs new file mode 100644 index 000000000..dc6b8cc1c --- /dev/null +++ b/src/Services/Cataloging/Command/Infrastructure.EventStore/DependencyInjection/Extensions/HostExtensions.cs @@ -0,0 +1,17 @@ +using Infrastructure.EventStore.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Infrastructure.EventStore.DependencyInjection.Extensions; + +public static class HostExtensions +{ + public static async Task MigrateEventStoreAsync(this IHost host) + { + await using var scope = host.Services.CreateAsyncScope(); + await using var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(); + await dbContext.Database.EnsureCreatedAsync(); + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Infrastructure.EventStore/DependencyInjection/Extensions/ServiceCollectionExtensions.cs b/src/Services/Cataloging/Command/Infrastructure.EventStore/DependencyInjection/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..ca06b9c7e --- /dev/null +++ b/src/Services/Cataloging/Command/Infrastructure.EventStore/DependencyInjection/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,52 @@ +using Application.Abstractions; +using Infrastructure.EventStore.Contexts; +using Infrastructure.EventStore.DependencyInjection.Options; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Infrastructure.EventStore.DependencyInjection.Extensions; + +public static class ServiceCollectionExtensions +{ + public static void AddEventStoreInfrastructure(this IServiceCollection services) + => services + .ConfigureOptions() + .AddScoped() + .AddScoped() + .AddScoped() + .AddDbContextPool((provider, builder) => + { + var configuration = provider.GetRequiredService(); + var options = provider.GetRequiredService>().Value; + + builder + .EnableDetailedErrors() + .EnableSensitiveDataLogging() + .UseSqlServer( + connectionString: configuration.GetConnectionString("EventStore"), + sqlServerOptionsAction: optionsBuilder + => optionsBuilder.ExecutionStrategy( + dependencies => new SqlServerRetryingExecutionStrategy( + dependencies: dependencies, + maxRetryCount: options.MaxRetryCount, + maxRetryDelay: options.MaxRetryDelay, + errorNumbersToAdd: options.ErrorNumbersToAdd)) + .MigrationsAssembly(typeof(EventStoreDbContext).Assembly.GetName().Name)); + }); + + private static IServiceCollection ConfigureOptions(this IServiceCollection services) + => services + .ConfigureOptions() + .ConfigureOptions(); + + private static IServiceCollection ConfigureOptions(this IServiceCollection services) + where TOptions : class + => services + .AddOptions() + .BindConfiguration(typeof(TOptions).Name) + .ValidateDataAnnotations() + .ValidateOnStart() + .Services; +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Infrastructure.EventStore/DependencyInjection/Options/EventStoreOptions.cs b/src/Services/Cataloging/Command/Infrastructure.EventStore/DependencyInjection/Options/EventStoreOptions.cs new file mode 100644 index 000000000..43f571dda --- /dev/null +++ b/src/Services/Cataloging/Command/Infrastructure.EventStore/DependencyInjection/Options/EventStoreOptions.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace Infrastructure.EventStore.DependencyInjection.Options; + +public record EventStoreOptions +{ + [Required, Range(3, 100)] + public ushort SnapshotInterval { get; init; } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Infrastructure.EventStore/DependencyInjection/Options/SqlServerRetryingOptions.cs b/src/Services/Cataloging/Command/Infrastructure.EventStore/DependencyInjection/Options/SqlServerRetryingOptions.cs new file mode 100644 index 000000000..c69326451 --- /dev/null +++ b/src/Services/Cataloging/Command/Infrastructure.EventStore/DependencyInjection/Options/SqlServerRetryingOptions.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace Infrastructure.EventStore.DependencyInjection.Options; + +public record SqlServerRetryOptions +{ + [Required, Range(5, 20)] public int MaxRetryCount { get; init; } + [Required, Timestamp] public TimeSpan MaxRetryDelay { get; init; } + public int[]? ErrorNumbersToAdd { get; init; } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Infrastructure.EventStore/EventStoreGateway.cs b/src/Services/Cataloging/Command/Infrastructure.EventStore/EventStoreGateway.cs new file mode 100644 index 000000000..728f3bbc2 --- /dev/null +++ b/src/Services/Cataloging/Command/Infrastructure.EventStore/EventStoreGateway.cs @@ -0,0 +1,78 @@ +using System.Linq.Expressions; +using Application.Abstractions; +using Contracts.Abstractions.Messages; +using Domain.Abstractions.Aggregates; +using Domain.Abstractions.EventStore; +using Domain.Abstractions.Identities; +using Microsoft.EntityFrameworkCore; +using Version = Domain.ValueObjects.Version; + +namespace Infrastructure.EventStore; + +public class EventStoreGateway(DbContext dbContext) : IEventStoreGateway +{ + public async Task AppendAsync(StoreEvent storeEvent, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new() + { + await dbContext.Set>().AddAsync(storeEvent, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task AppendAsync(Snapshot snapshot, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new() + { + await dbContext.Set>().AddAsync(snapshot, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } + + public Task> GetStreamAsync(TId id, Version version, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new() + => dbContext.Set>() + .AsNoTracking() + .Where(@event => @event.AggregateId.Equals(id)) + .Where(@event => @event.Version > version) + .Select(@event => @event.Event) + .ToListAsync(cancellationToken); + + public Task> GetStreamAsync + (Expression, bool>> predicate, Version version, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new() + => dbContext.Set>() + .AsNoTracking() + .Where(predicate) + .Where(@event => @event.Version > version) + .Select(@event => @event.Event) + .ToListAsync(cancellationToken); + + public Task?> GetSnapshotAsync(TId id, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new() + => dbContext.Set>() + .AsNoTracking() + .Where(snapshot => snapshot.AggregateId.Equals(id)) + .OrderByDescending(snapshot => snapshot.Version) + .FirstOrDefaultAsync(cancellationToken); + + public Task?> GetSnapshotAsync + (Expression, bool>> predicate, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new() + => dbContext.Set>() + .AsNoTracking() + .Where(predicate) + .OrderByDescending(snapshot => snapshot.Version) + .FirstOrDefaultAsync(cancellationToken); + + public IAsyncEnumerable StreamAggregatesId() + where TAggregate : IAggregateRoot + where TId : IIdentifier, new() + => dbContext.Set>() + .AsNoTracking() + .Select(@event => @event.AggregateId) + .Distinct() + .AsAsyncEnumerable(); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Infrastructure.EventStore/Infrastructure.EventStore.csproj b/src/Services/Cataloging/Command/Infrastructure.EventStore/Infrastructure.EventStore.csproj new file mode 100644 index 000000000..7d36ab483 --- /dev/null +++ b/src/Services/Cataloging/Command/Infrastructure.EventStore/Infrastructure.EventStore.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Services/Cataloging/Command/Infrastructure.EventStore/UnitOfWork.cs b/src/Services/Cataloging/Command/Infrastructure.EventStore/UnitOfWork.cs new file mode 100644 index 000000000..89f994e3a --- /dev/null +++ b/src/Services/Cataloging/Command/Infrastructure.EventStore/UnitOfWork.cs @@ -0,0 +1,20 @@ +using Application.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Infrastructure.EventStore; + +public class UnitOfWork(DbContext dbContext) : IUnitOfWork +{ + private readonly DatabaseFacade _database = dbContext.Database; + + public Task ExecuteAsync(Func operationAsync, CancellationToken cancellationToken) + => _database.CreateExecutionStrategy().ExecuteAsync(ct => ExecuteTransactionAsync(operationAsync, ct), cancellationToken); + + private async Task ExecuteTransactionAsync(Func operationAsync, CancellationToken cancellationToken) + { + await using var transaction = await _database.BeginTransactionAsync(cancellationToken); + await operationAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/WorkerService/.dockerignore b/src/Services/Cataloging/Command/WorkerService/.dockerignore new file mode 100644 index 000000000..cd967fc3a --- /dev/null +++ b/src/Services/Cataloging/Command/WorkerService/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/src/Services/Cataloging/Command/WorkerService/Dockerfile b/src/Services/Cataloging/Command/WorkerService/Dockerfile new file mode 100644 index 000000000..0527e1a27 --- /dev/null +++ b/src/Services/Cataloging/Command/WorkerService/Dockerfile @@ -0,0 +1,42 @@ +ARG ASPNET_VERSION="8.0-preview" +ARG SDK_VERSION="8.0-preview" +ARG BASE_ADRESS="mcr.microsoft.com/dotnet" + +FROM $BASE_ADRESS/aspnet:$ASPNET_VERSION AS base +WORKDIR /app + +FROM $BASE_ADRESS/sdk:$SDK_VERSION AS build + +COPY ./global.json ./ +COPY ./nuget.config ./ +COPY ./Directory.Build.props ./ + +WORKDIR /src + +COPY ./src/Services/Catalog/Command/Application/*.csproj ./Services/Catalog/Command/Application/ +COPY ./src/Services/Catalog/Command/Domain/*.csproj ./Services/Catalog/Command/Domain/ +COPY ./src/Services/Catalog/Command/Infrastructure.EventStore/*.csproj ./Services/Catalog/Command/Infrastructure.EventStore/ +COPY ./src/Services/Catalog/Command/Infrastructure.MessageBus/*.csproj ./Services/Catalog/Command/Infrastructure.MessageBus/ +COPY ./src/Services/Catalog/Command/WorkerService/*.csproj ./Services/Catalog/Command/WorkerService/ +COPY ./src/Contracts/*.csproj ./Contracts/ + +RUN dotnet restore -v m ./Services/Catalog/Command/WorkerService + +COPY ./src/Services/Catalog/Command/Application/. ./Services/Catalog/Command/Application/ +COPY ./src/Services/Catalog/Command/Domain/. ./Services/Catalog/Command/Domain/ +COPY ./src/Services/Catalog/Command/Infrastructure.EventStore/. ./Services/Catalog/Command/Infrastructure.EventStore/ +COPY ./src/Services/Catalog/Command/Infrastructure.MessageBus/. ./Services/Catalog/Command/Infrastructure.MessageBus/ +COPY ./src/Services/Catalog/Command/WorkerService/. ./Services/Catalog/Command/WorkerService/ +COPY ./src/Contracts/. ./Contracts/ + +WORKDIR /src/Services/Catalog/Command/WorkerService + +RUN dotnet build -c Release --no-restore -v m -o /app/build + +FROM build AS publish +RUN dotnet publish -c Release --no-restore -v m -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "WorkerService.dll"] \ No newline at end of file diff --git a/src/Services/Cataloging/Command/WorkerService/Program.cs b/src/Services/Cataloging/Command/WorkerService/Program.cs new file mode 100644 index 000000000..212846b27 --- /dev/null +++ b/src/Services/Cataloging/Command/WorkerService/Program.cs @@ -0,0 +1,77 @@ +using Infrastructure.EventStore.Contexts; +using Microsoft.EntityFrameworkCore; +using Serilog; + +var builder = Host.CreateDefaultBuilder(args); + +builder.UseDefaultServiceProvider((context, provider) => +{ + provider.ValidateScopes = + provider.ValidateOnBuild = + context.HostingEnvironment.IsDevelopment(); +}); + +builder.ConfigureAppConfiguration(configuration => +{ + configuration + .AddUserSecrets() + .AddEnvironmentVariables(); +}); + +builder.ConfigureLogging(logging + => logging.ClearProviders().AddSerilog()); + +builder.UseSerilog((context, cfg) + => cfg.ReadFrom.Configuration(context.Configuration)); + +builder.ConfigureServices((context, services) => +{ + // services.AddEventStore(); + // services.AddMessageBus(); + // services.AddEventBusGateway(); + // services.AddApplication(); + // services.AddMessageValidators(); + // + // services.ConfigureEventStoreOptions( + // context.Configuration.GetSection(nameof(EventStoreOptions))); + // + // services.ConfigureSqlServerRetryOptions( + // context.Configuration.GetSection(nameof(SqlServerRetryOptions))); + // + // services.ConfigureEventBusOptions( + // context.Configuration.GetSection(nameof(EventBusOptions))); + // + // services.ConfigureMassTransitHostOptions( + // context.Configuration.GetSection(nameof(MassTransitHostOptions))); + // + // services.ConfigureQuartzOptions( + // context.Configuration.GetSection(nameof(QuartzOptions))); +}); + +using var host = builder.Build(); + +try +{ + var environment = host.Services.GetRequiredService(); + + if (environment.IsDevelopment() || environment.IsStaging()) + { + await using var scope = host.Services.CreateAsyncScope(); + await using var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(); + await dbContext.Database.EnsureCreatedAsync(); + } + + await host.RunAsync(); + Log.Information("Stopped cleanly"); +} +catch (Exception ex) +{ + Log.Fatal(ex, "An unhandled exception occured during bootstrapping"); + await host.StopAsync(); +} +finally +{ + Log.CloseAndFlush(); + host.Dispose(); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/WorkerService/Properties/launchSettings.json b/src/Services/Cataloging/Command/WorkerService/Properties/launchSettings.json new file mode 100644 index 000000000..7fafbfaf7 --- /dev/null +++ b/src/Services/Cataloging/Command/WorkerService/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "Catalog.WorkerService": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Cataloging/Command/WorkerService/WorkerService.csproj b/src/Services/Cataloging/Command/WorkerService/WorkerService.csproj new file mode 100644 index 000000000..e9f294344 --- /dev/null +++ b/src/Services/Cataloging/Command/WorkerService/WorkerService.csproj @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/Services/Cataloging/Command/WorkerService/appsettings.Development.json b/src/Services/Cataloging/Command/WorkerService/appsettings.Development.json new file mode 100644 index 000000000..efd55bb37 --- /dev/null +++ b/src/Services/Cataloging/Command/WorkerService/appsettings.Development.json @@ -0,0 +1,11 @@ +{ + "ConnectionStrings": { + "EventStore": "Server=127.0.0.1,1433;Database=CatalogEventStore;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@127.0.0.1:5672/eventual-shop" + }, + "QuartzOptions": { + "quartz.dataSource.default.connectionString": "Server=127.0.0.1,1433;Database=Quartz;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/WorkerService/appsettings.Production.json b/src/Services/Cataloging/Command/WorkerService/appsettings.Production.json new file mode 100644 index 000000000..ccf111982 --- /dev/null +++ b/src/Services/Cataloging/Command/WorkerService/appsettings.Production.json @@ -0,0 +1,11 @@ +{ + "ConnectionStrings": { + "EventStore": "Server=mssql;Database=CatalogEventStore;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@rabbitmq:5672/eventual-shop" + }, + "QuartzOptions": { + "quartz.dataSource.default.connectionString": "Server=mssql;Database=Quartz;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/WorkerService/appsettings.Staging.json b/src/Services/Cataloging/Command/WorkerService/appsettings.Staging.json new file mode 100644 index 000000000..ccf111982 --- /dev/null +++ b/src/Services/Cataloging/Command/WorkerService/appsettings.Staging.json @@ -0,0 +1,11 @@ +{ + "ConnectionStrings": { + "EventStore": "Server=mssql;Database=CatalogEventStore;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@rabbitmq:5672/eventual-shop" + }, + "QuartzOptions": { + "quartz.dataSource.default.connectionString": "Server=mssql;Database=Quartz;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Command/WorkerService/appsettings.json b/src/Services/Cataloging/Command/WorkerService/appsettings.json new file mode 100644 index 000000000..bf9f55c69 --- /dev/null +++ b/src/Services/Cataloging/Command/WorkerService/appsettings.json @@ -0,0 +1,52 @@ +{ + "HostOptions": { + "ShutdownTimeout": "00:00:25" + }, + "MassTransitHostOptions": { + "WaitUntilStarted": true, + "StartTimeout": "00:00:30", + "StopTimeout": "00:00:15" + }, + "EventBusOptions": { + "ConnectionName": "Catalog", + "SchedulerQueueName": "scheduler", + "retryLimit": 3, + "initialInterval": "00:00:05", + "intervalIncrement": "00:00:10" + }, + "SqlServerRetryOptions": { + "MaxRetryCount": 5, + "MaxRetryDelay": "00:00:05", + "ErrorNumbersToAdd": [] + }, + "EventStoreOptions": { + "SnapshotInterval": 5 + }, + "QuartzOptions": { + "quartz.scheduler.instanceName": "Catalog", + "quartz.scheduler.instanceId": "AUTO", + "quartz.jobStore.dataSource": "default", + "quartz.dataSource.default.provider": "SqlServer", + "quartz.serializer.type": "json", + "quartz.jobStore.type": "Quartz.Impl.AdoJobStore.JobStoreTX, Quartz", + "quartz.jobStore.clustered": true, + "quartz.jobStore.driverDelegateType": "Quartz.Impl.AdoJobStore.SqlServerDelegate, Quartz" + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "MassTransit": "Information", + "Quartz": "Information", + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console" + } + ] + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Application/Abstractions/IInteractor.cs b/src/Services/Cataloging/Query/Application/Abstractions/IInteractor.cs new file mode 100644 index 000000000..2369fd771 --- /dev/null +++ b/src/Services/Cataloging/Query/Application/Abstractions/IInteractor.cs @@ -0,0 +1,25 @@ +using Contracts.Abstractions; +using Contracts.Abstractions.Messages; +using Contracts.Abstractions.Paging; + +namespace Application.Abstractions; + +public interface IInteractor + where TEvent : IEvent +{ + Task InteractAsync(TEvent @event, CancellationToken cancellationToken); +} + +public interface IInteractor + where TQuery : IQuery + where TProjection : IProjection +{ + Task InteractAsync(TQuery query, CancellationToken cancellationToken); +} + +public interface IPagedInteractor + where TQuery : IQuery + where TProjection : IProjection +{ + ValueTask> InteractAsync(TQuery query, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Application/Abstractions/IProjectionGateway.cs b/src/Services/Cataloging/Query/Application/Abstractions/IProjectionGateway.cs new file mode 100644 index 000000000..565180f64 --- /dev/null +++ b/src/Services/Cataloging/Query/Application/Abstractions/IProjectionGateway.cs @@ -0,0 +1,19 @@ +using System.Linq.Expressions; +using Contracts.Abstractions; +using Contracts.Abstractions.Paging; + +namespace Application.Abstractions; + +public interface IProjectionGateway + where TProjection : IProjection +{ + Task FindAsync(Expression> predicate, CancellationToken cancellationToken); + Task GetAsync(TId id, CancellationToken cancellationToken) where TId : struct; + ValueTask> ListAsync(Paging paging, Expression> predicate, CancellationToken cancellationToken); + ValueTask> ListAsync(Paging paging, CancellationToken cancellationToken); + ValueTask ReplaceInsertAsync(TProjection replacement, CancellationToken cancellationToken); + ValueTask RebuildInsertAsync(TProjection replacement, CancellationToken cancellationToken); + Task DeleteAsync(Expression> filter, CancellationToken cancellationToken); + Task DeleteAsync(TId id, CancellationToken cancellationToken) where TId : struct; + Task UpdateFieldAsync(TId id, ulong version, Expression> field, TField value, CancellationToken cancellationToken) where TId : struct; +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Application/Application.csproj b/src/Services/Cataloging/Query/Application/Application.csproj new file mode 100644 index 000000000..13f1ac4a4 --- /dev/null +++ b/src/Services/Cataloging/Query/Application/Application.csproj @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Application/DependencyInjection/ServiceCollectionExtensions.cs b/src/Services/Cataloging/Query/Application/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..8f774a638 --- /dev/null +++ b/src/Services/Cataloging/Query/Application/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,29 @@ +using Application.Abstractions; +using Application.UseCases.Events; +using Application.UseCases.Queries; +using Contracts.Boundaries.Cataloging.Catalog; +using Microsoft.Extensions.DependencyInjection; + +namespace Application.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddInteractors(this IServiceCollection services) + => services + .AddEventInteractors() + .AddQueryInteractors(); + + private static IServiceCollection AddEventInteractors(this IServiceCollection services) + => services + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped(); + + private static IServiceCollection AddQueryInteractors(this IServiceCollection services) + => services + .AddScoped, GetCatalogItemDetailsInteractor>() + .AddScoped, ListCatalogItemsCardsInteractor>() + .AddScoped, ListCatalogsGridItemsInteractor>() + .AddScoped, ListCatalogItemsListItemsInteractor>(); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Application/UseCases/Events/ProjectCatalogGridItemWhenCatalogChangedInteractor.cs b/src/Services/Cataloging/Query/Application/UseCases/Events/ProjectCatalogGridItemWhenCatalogChangedInteractor.cs new file mode 100644 index 000000000..481f7289e --- /dev/null +++ b/src/Services/Cataloging/Query/Application/UseCases/Events/ProjectCatalogGridItemWhenCatalogChangedInteractor.cs @@ -0,0 +1,65 @@ +using Application.Abstractions; +using Contracts.Boundaries.Cataloging.Catalog; + +namespace Application.UseCases.Events; + +public interface IProjectCatalogGridItemWhenCatalogChangedInteractor : + IInteractor, + IInteractor, + IInteractor, + IInteractor, + IInteractor, + IInteractor; + +public class ProjectCatalogGridItemWhenCatalogChangedInteractor(IProjectionGateway projectionGateway) + : IProjectCatalogGridItemWhenCatalogChangedInteractor +{ + public async Task InteractAsync(DomainEvent.CatalogActivated @event, CancellationToken cancellationToken) + => await projectionGateway.UpdateFieldAsync( + id: @event.CatalogId, + version: @event.Version, + field: catalog => catalog.IsActive, + value: true, + cancellationToken: cancellationToken); + + public async Task InteractAsync(DomainEvent.CatalogCreated @event, CancellationToken cancellationToken) + { + Projection.CatalogGridItem gridItem = new( + @event.CatalogId, + @event.Title, + @event.Description, + "image url", // TODO: get image url from event + default, + default, + @event.Version); + + await projectionGateway.ReplaceInsertAsync(gridItem, cancellationToken); + } + + public async Task InteractAsync(DomainEvent.CatalogInactivated @event, CancellationToken cancellationToken) + => await projectionGateway.UpdateFieldAsync( + id: @event.CatalogId, + version: @event.Version, + field: catalog => catalog.IsActive, + value: false, + cancellationToken: cancellationToken); + + public async Task InteractAsync(DomainEvent.CatalogDescriptionChanged @event, CancellationToken cancellationToken) + => await projectionGateway.UpdateFieldAsync( + id: @event.CatalogId, + version: @event.Version, + field: catalog => catalog.Description, + value: @event.Description, + cancellationToken: cancellationToken); + + public async Task InteractAsync(DomainEvent.CatalogTitleChanged @event, CancellationToken cancellationToken) + => await projectionGateway.UpdateFieldAsync( + id: @event.CatalogId, + version: @event.Version, + field: catalog => catalog.Title, + value: @event.Title, + cancellationToken: cancellationToken); + + public async Task InteractAsync(DomainEvent.CatalogDeleted @event, CancellationToken cancellationToken) + => await projectionGateway.DeleteAsync(@event.CatalogId, cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Application/UseCases/Events/ProjectCatalogItemCardWhenCatalogChangedInteractor.cs b/src/Services/Cataloging/Query/Application/UseCases/Events/ProjectCatalogItemCardWhenCatalogChangedInteractor.cs new file mode 100644 index 000000000..7c90a1d07 --- /dev/null +++ b/src/Services/Cataloging/Query/Application/UseCases/Events/ProjectCatalogItemCardWhenCatalogChangedInteractor.cs @@ -0,0 +1,24 @@ +using Application.Abstractions; +using Contracts.Boundaries.Cataloging.Catalog; + +namespace Application.UseCases.Events; + +public interface IProjectCatalogItemCardWhenCatalogChangedInteractor : IInteractor { } + +public class ProjectCatalogItemCardWhenCatalogChangedInteractor(IProjectionGateway projectionGateway) + : IProjectCatalogItemCardWhenCatalogChangedInteractor +{ + public async Task InteractAsync(DomainEvent.CatalogItemAdded @event, CancellationToken cancellationToken) + { + Projection.CatalogItemCard card = new( + @event.ItemId, + @event.CatalogId, + @event.Product, + @event.UnitPrice, + "image url", + false, + @event.Version); + + await projectionGateway.ReplaceInsertAsync(card, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Application/UseCases/Events/ProjectCatalogItemDetailsWhenCatalogChangedInteractor.cs b/src/Services/Cataloging/Query/Application/UseCases/Events/ProjectCatalogItemDetailsWhenCatalogChangedInteractor.cs new file mode 100644 index 000000000..3be723a96 --- /dev/null +++ b/src/Services/Cataloging/Query/Application/UseCases/Events/ProjectCatalogItemDetailsWhenCatalogChangedInteractor.cs @@ -0,0 +1,24 @@ +using Application.Abstractions; +using Contracts.Boundaries.Cataloging.Catalog; + +namespace Application.UseCases.Events; + +public interface IProjectCatalogItemDetailsWhenCatalogChangedInteractor : IInteractor { } + +public class ProjectCatalogItemDetailsWhenCatalogChangedInteractor(IProjectionGateway projectionGateway) + : IProjectCatalogItemDetailsWhenCatalogChangedInteractor +{ + public async Task InteractAsync(DomainEvent.CatalogItemAdded @event, CancellationToken cancellationToken) + { + Projection.CatalogItemDetails card = new( + @event.ItemId, + @event.CatalogId, + @event.Product, + @event.UnitPrice, + "image url", + false, + @event.Version); + + await projectionGateway.ReplaceInsertAsync(card, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Application/UseCases/Events/ProjectCatalogItemListItemWhenCatalogChangedInteractor.cs b/src/Services/Cataloging/Query/Application/UseCases/Events/ProjectCatalogItemListItemWhenCatalogChangedInteractor.cs new file mode 100644 index 000000000..0f13256ff --- /dev/null +++ b/src/Services/Cataloging/Query/Application/UseCases/Events/ProjectCatalogItemListItemWhenCatalogChangedInteractor.cs @@ -0,0 +1,31 @@ +using Application.Abstractions; +using Contracts.Boundaries.Cataloging.Catalog; + +namespace Application.UseCases.Events; + +public interface IProjectCatalogItemListItemWhenCatalogChangedInteractor : + IInteractor, + IInteractor, + IInteractor; + +public class ProjectCatalogItemListItemWhenCatalogChangedInteractor(IProjectionGateway projectionGateway) + : IProjectCatalogItemListItemWhenCatalogChangedInteractor +{ + public async Task InteractAsync(DomainEvent.CatalogItemAdded @event, CancellationToken cancellationToken) + { + Projection.CatalogItemListItem listItem = new( + @event.ItemId, + @event.CatalogId, + @event.Product, + false, + @event.Version); + + await projectionGateway.ReplaceInsertAsync(listItem, cancellationToken); + } + + public async Task InteractAsync(DomainEvent.CatalogDeleted @event, CancellationToken cancellationToken) + => await projectionGateway.DeleteAsync(item => item.CatalogId == @event.CatalogId, cancellationToken); + + public async Task InteractAsync(DomainEvent.CatalogItemRemoved @event, CancellationToken cancellationToken) + => await projectionGateway.DeleteAsync(@event.ItemId, cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Application/UseCases/Queries/GetCatalogItemDetailsInteractor.cs b/src/Services/Cataloging/Query/Application/UseCases/Queries/GetCatalogItemDetailsInteractor.cs new file mode 100644 index 000000000..09fbb08d0 --- /dev/null +++ b/src/Services/Cataloging/Query/Application/UseCases/Queries/GetCatalogItemDetailsInteractor.cs @@ -0,0 +1,11 @@ +using Application.Abstractions; +using Contracts.Boundaries.Cataloging.Catalog; + +namespace Application.UseCases.Queries; + +public class GetCatalogItemDetailsInteractor(IProjectionGateway projectionGateway) + : IInteractor +{ + public Task InteractAsync(Query.GetCatalogItemDetails query, CancellationToken cancellationToken) + => projectionGateway.FindAsync(item => item.CatalogId == query.CatalogId && item.Id == query.ItemId, cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Application/UseCases/Queries/ListCatalogItemsCardsInteractor.cs b/src/Services/Cataloging/Query/Application/UseCases/Queries/ListCatalogItemsCardsInteractor.cs new file mode 100644 index 000000000..f0d38b827 --- /dev/null +++ b/src/Services/Cataloging/Query/Application/UseCases/Queries/ListCatalogItemsCardsInteractor.cs @@ -0,0 +1,12 @@ +using Application.Abstractions; +using Contracts.Abstractions.Paging; +using Contracts.Boundaries.Cataloging.Catalog; + +namespace Application.UseCases.Queries; + +public class ListCatalogItemsCardsInteractor(IProjectionGateway projectionGateway) + : IPagedInteractor +{ + public ValueTask> InteractAsync(Query.ListCatalogItemsCards query, CancellationToken cancellationToken) + => projectionGateway.ListAsync(query.Paging, card => card.CatalogId == query.CatalogId, cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Application/UseCases/Queries/ListCatalogItemsListItemsInteractor.cs b/src/Services/Cataloging/Query/Application/UseCases/Queries/ListCatalogItemsListItemsInteractor.cs new file mode 100644 index 000000000..947c05ee0 --- /dev/null +++ b/src/Services/Cataloging/Query/Application/UseCases/Queries/ListCatalogItemsListItemsInteractor.cs @@ -0,0 +1,12 @@ +using Application.Abstractions; +using Contracts.Abstractions.Paging; +using Contracts.Boundaries.Cataloging.Catalog; + +namespace Application.UseCases.Queries; + +public class ListCatalogItemsListItemsInteractor(IProjectionGateway projectionGateway) + : IPagedInteractor +{ + public ValueTask> InteractAsync(Query.ListCatalogItemsListItems query, CancellationToken cancellationToken) + => projectionGateway.ListAsync(query.Paging, listItem => listItem.CatalogId == query.CatalogId, cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Application/UseCases/Queries/ListCatalogsGridItemsInteractor.cs b/src/Services/Cataloging/Query/Application/UseCases/Queries/ListCatalogsGridItemsInteractor.cs new file mode 100644 index 000000000..12495605c --- /dev/null +++ b/src/Services/Cataloging/Query/Application/UseCases/Queries/ListCatalogsGridItemsInteractor.cs @@ -0,0 +1,12 @@ +using Application.Abstractions; +using Contracts.Abstractions.Paging; +using Contracts.Boundaries.Cataloging.Catalog; + +namespace Application.UseCases.Queries; + +public class ListCatalogsGridItemsInteractor(IProjectionGateway projectionGateway) + : IPagedInteractor +{ + public ValueTask> InteractAsync(Query.ListCatalogsGridItems query, CancellationToken cancellationToken) + => projectionGateway.ListAsync(query.Paging, cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/GrpcService/.dockerignore b/src/Services/Cataloging/Query/GrpcService/.dockerignore new file mode 100644 index 000000000..cd967fc3a --- /dev/null +++ b/src/Services/Cataloging/Query/GrpcService/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/src/Services/Cataloging/Query/GrpcService/CatalogGrpcService.cs b/src/Services/Cataloging/Query/GrpcService/CatalogGrpcService.cs new file mode 100644 index 000000000..af7fa1863 --- /dev/null +++ b/src/Services/Cataloging/Query/GrpcService/CatalogGrpcService.cs @@ -0,0 +1,71 @@ +using Application.Abstractions; +using Contracts.Abstractions.Protobuf; +using Contracts.Boundaries.Cataloging.Catalog; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; + +namespace GrpcService; + +public class CatalogGrpcService(IInteractor getCatalogItemDetailsInteractor, + IPagedInteractor listCatalogItemsCardsInteractor, + IPagedInteractor listCatalogsGridItemsInteractor, + IPagedInteractor listCatalogItemsListItemsInteractor) + : CatalogService.CatalogServiceBase +{ + public override async Task GetCatalogItemDetails(GetCatalogItemDetailsRequest request, ServerCallContext context) + { + var itemDetails = await getCatalogItemDetailsInteractor.InteractAsync(request, context.CancellationToken); + + return itemDetails is null + ? new() { NotFound = new() } + : new() { Projection = Any.Pack((CatalogItemDetails)itemDetails) }; + } + + public override async Task ListCatalogsGridItems(ListCatalogsGridItemsRequest request, ServerCallContext context) + { + var pagedResult = await listCatalogsGridItemsInteractor.InteractAsync(request, context.CancellationToken); + + return pagedResult.Items.Any() + ? new() + { + PagedResult = new() + { + Projections = { pagedResult.Items.Select(item => Any.Pack((CatalogGridItem)item)) }, + Page = pagedResult.Page + } + } + : new() { NoContent = new() }; + } + + public override async Task ListCatalogItemsListItems(ListCatalogItemsListItemsRequest request, ServerCallContext context) + { + var pagedResult = await listCatalogItemsListItemsInteractor.InteractAsync(request, context.CancellationToken); + + return pagedResult.Items.Any() + ? new() + { + PagedResult = new() + { + Projections = { pagedResult.Items.Select(item => Any.Pack((CatalogItemListItem)item)) }, + Page = pagedResult.Page + } + } + : new() { NoContent = new() }; + } + + public override async Task ListCatalogItemsCards(ListCatalogItemsCardsRequest request, ServerCallContext context) + { + var pagedResult = await listCatalogItemsCardsInteractor.InteractAsync(request, context.CancellationToken); + + return pagedResult.Items.Any() + ? new() + { + PagedResult = new() + { + Projections = { pagedResult.Items.Select(item => Any.Pack((CatalogItemCard)item)) }, + Page = pagedResult.Page + } + } + : new() { NoContent = new() }; + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/GrpcService/Dockerfile b/src/Services/Cataloging/Query/GrpcService/Dockerfile new file mode 100644 index 000000000..63c3d2f0f --- /dev/null +++ b/src/Services/Cataloging/Query/GrpcService/Dockerfile @@ -0,0 +1,42 @@ +ARG ASPNET_VERSION="8.0-preview" +ARG SDK_VERSION="8.0-preview" +ARG BASE_ADRESS="mcr.microsoft.com/dotnet" + +FROM $BASE_ADRESS/aspnet:$ASPNET_VERSION AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 + +FROM $BASE_ADRESS/sdk:$SDK_VERSION AS build + +COPY ./global.json ./ +COPY ./nuget.config ./ +COPY ./Directory.Build.props ./ + +WORKDIR /src + +COPY ./src/Services/Catalog/Query/Application/*.csproj ./Services/Catalog/Query/Application/ +COPY ./src/Services/Catalog/Query/GrpcService/*.csproj ./Services/Catalog/Query/GrpcService/ +COPY ./src/Services/Catalog/Query/Infrastructure.EventBus/*.csproj ./Services/Catalog/Query/Infrastructure.EventBus/ +COPY ./src/Services/Catalog/Query/Infrastructure.Projections/*.csproj ./Services/Catalog/Query/Infrastructure.Projections/ +COPY ./src/Contracts/*.csproj ./Contracts/ + +RUN dotnet restore -v m ./Services/Catalog/Query/GrpcService + +COPY ./src/Services/Catalog/Query/Application/. ./Services/Catalog/Query/Application/ +COPY ./src/Services/Catalog/Query/GrpcService/. ./Services/Catalog/Query/GrpcService/ +COPY ./src/Services/Catalog/Query/Infrastructure.EventBus/. ./Services/Catalog/Query/Infrastructure.EventBus/ +COPY ./src/Services/Catalog/Query/Infrastructure.Projections/. ./Services/Catalog/Query/Infrastructure.Projections/ +COPY ./src/Contracts/. ./Contracts/ + +WORKDIR /src/Services/Catalog/Query/GrpcService + +RUN dotnet build -c Release --no-restore -v m -o /app/build + +FROM build AS publish +RUN dotnet publish -c Release --no-restore -v m -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "GrpcService.dll"] diff --git a/src/Services/Cataloging/Query/GrpcService/GrpcService.csproj b/src/Services/Cataloging/Query/GrpcService/GrpcService.csproj new file mode 100644 index 000000000..73b4a732a --- /dev/null +++ b/src/Services/Cataloging/Query/GrpcService/GrpcService.csproj @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/Services/Cataloging/Query/GrpcService/Program.cs b/src/Services/Cataloging/Query/GrpcService/Program.cs new file mode 100644 index 000000000..3606eaa5e --- /dev/null +++ b/src/Services/Cataloging/Query/GrpcService/Program.cs @@ -0,0 +1,74 @@ +using System.Reflection; +using Application.DependencyInjection; +using GrpcService; +using Infrastructure.EventBus.DependencyInjection.Extensions; +using Infrastructure.EventBus.DependencyInjection.Options; +using Infrastructure.Projections.DependencyInjection; +using MassTransit; +using Microsoft.AspNetCore.HttpLogging; +using Serilog; + +var builder = WebApplication.CreateBuilder(args); + +builder.Host.UseDefaultServiceProvider((context, provider) => +{ + provider.ValidateScopes = + provider.ValidateOnBuild = + context.HostingEnvironment.IsDevelopment(); +}); + +builder.Configuration + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .AddEnvironmentVariables(); + +builder.Logging.ClearProviders().AddSerilog(); + +builder.Host.UseSerilog((context, cfg) + => cfg.ReadFrom.Configuration(context.Configuration)); + +builder.Host.ConfigureServices((context, services) => +{ + services.AddCors(options + => options.AddDefaultPolicy(policyBuilder + => policyBuilder + .AllowAnyOrigin() + .AllowAnyHeader() + .AllowAnyMethod())); + + services.AddGrpc(); + services.AddEventBus(); + services.AddMessageValidators(); + services.AddProjections(); + services.AddInteractors(); + + services.ConfigureEventBusOptions( + context.Configuration.GetSection(nameof(EventBusOptions))); + + services.ConfigureMassTransitHostOptions( + context.Configuration.GetSection(nameof(MassTransitHostOptions))); + + services.AddHttpLogging(options + => options.LoggingFields = HttpLoggingFields.All); +}); + +var app = builder.Build(); + +app.UseCors(); +app.UseSerilogRequestLogging(); +app.MapGrpcService(); + +try +{ + await app.RunAsync(); + Log.Information("Stopped cleanly"); +} +catch (Exception ex) +{ + Log.Fatal(ex, "An unhandled exception occured during bootstrapping"); + await app.StopAsync(); +} +finally +{ + Log.CloseAndFlush(); + await app.DisposeAsync(); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/GrpcService/Properties/launchSettings.json b/src/Services/Cataloging/Query/GrpcService/Properties/launchSettings.json new file mode 100644 index 000000000..6cbc5314b --- /dev/null +++ b/src/Services/Cataloging/Query/GrpcService/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "Catalog.GrpcService": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7132;http://localhost:5132", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Cataloging/Query/GrpcService/appsettings.Development.json b/src/Services/Cataloging/Query/GrpcService/appsettings.Development.json new file mode 100644 index 000000000..70e6bd8aa --- /dev/null +++ b/src/Services/Cataloging/Query/GrpcService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "ConnectionStrings": { + "Projections": "mongodb://mongoadmin:secret@127.0.0.1:27017/CatalogProjections/?authSource=admin" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@127.0.0.1:5672/eventual-shop" + } +} diff --git a/src/Services/Cataloging/Query/GrpcService/appsettings.Production.json b/src/Services/Cataloging/Query/GrpcService/appsettings.Production.json new file mode 100644 index 000000000..cce64efb6 --- /dev/null +++ b/src/Services/Cataloging/Query/GrpcService/appsettings.Production.json @@ -0,0 +1,8 @@ +{ + "ConnectionStrings": { + "Projections": "mongodb://mongoadmin:secret@mongodb/CatalogProjections/?authSource=admin" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@rabbitmq:5672/eventual-shop" + } +} diff --git a/src/Services/Cataloging/Query/GrpcService/appsettings.Staging.json b/src/Services/Cataloging/Query/GrpcService/appsettings.Staging.json new file mode 100644 index 000000000..cce64efb6 --- /dev/null +++ b/src/Services/Cataloging/Query/GrpcService/appsettings.Staging.json @@ -0,0 +1,8 @@ +{ + "ConnectionStrings": { + "Projections": "mongodb://mongoadmin:secret@mongodb/CatalogProjections/?authSource=admin" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@rabbitmq:5672/eventual-shop" + } +} diff --git a/src/Services/Cataloging/Query/GrpcService/appsettings.json b/src/Services/Cataloging/Query/GrpcService/appsettings.json new file mode 100644 index 000000000..d21321880 --- /dev/null +++ b/src/Services/Cataloging/Query/GrpcService/appsettings.json @@ -0,0 +1,40 @@ +{ + "HostOptions": { + "ShutdownTimeout": "00:00:25" + }, + "MassTransitHostOptions": { + "WaitUntilStarted": true, + "StartTimeout": "00:00:30", + "StopTimeout": "00:00:15" + }, + "EventBusOptions": { + "ConnectionName": "Catalog", + "retryLimit": 3, + "initialInterval": "00:00:05", + "intervalIncrement": "00:00:10" + }, + "Kestrel": { + "EndpointDefaults": { + "Protocols": "Http2" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "MassTransit": "Information", + "Microsoft": "Information" + } + }, + "WriteTo": [ + { + "Name": "Console" + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId" + ] + } +} diff --git a/src/Services/Cataloging/Query/Infrastructure.EventBus/Abstractions/Consumer.cs b/src/Services/Cataloging/Query/Infrastructure.EventBus/Abstractions/Consumer.cs new file mode 100644 index 000000000..084685cda --- /dev/null +++ b/src/Services/Cataloging/Query/Infrastructure.EventBus/Abstractions/Consumer.cs @@ -0,0 +1,12 @@ +using Application.Abstractions; +using Contracts.Abstractions.Messages; +using MassTransit; + +namespace Infrastructure.EventBus.Abstractions; + +public abstract class Consumer(IInteractor interactor) : IConsumer + where TMessage : class, IEvent +{ + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Infrastructure.EventBus/Consumers/Events/ProjectCatalogGridItemWhenCatalogChangedConsumer.cs b/src/Services/Cataloging/Query/Infrastructure.EventBus/Consumers/Events/ProjectCatalogGridItemWhenCatalogChangedConsumer.cs new file mode 100644 index 000000000..5495a0df1 --- /dev/null +++ b/src/Services/Cataloging/Query/Infrastructure.EventBus/Consumers/Events/ProjectCatalogGridItemWhenCatalogChangedConsumer.cs @@ -0,0 +1,32 @@ +using Application.UseCases.Events; +using MassTransit; + +namespace Infrastructure.EventBus.Consumers.Events; + +public class ProjectCatalogGridItemWhenCatalogChangedConsumer(IProjectCatalogGridItemWhenCatalogChangedInteractor interactor) + : + IConsumer, + IConsumer, + IConsumer, + IConsumer, + IConsumer, + IConsumer +{ + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); + + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); + + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); + + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); + + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); + + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Infrastructure.EventBus/Consumers/Events/ProjectCatalogItemCardWhenCatalogChangedConsumer.cs b/src/Services/Cataloging/Query/Infrastructure.EventBus/Consumers/Events/ProjectCatalogItemCardWhenCatalogChangedConsumer.cs new file mode 100644 index 000000000..f1fc9c68c --- /dev/null +++ b/src/Services/Cataloging/Query/Infrastructure.EventBus/Consumers/Events/ProjectCatalogItemCardWhenCatalogChangedConsumer.cs @@ -0,0 +1,7 @@ +using Application.UseCases.Events; +using Infrastructure.EventBus.Abstractions; + +namespace Infrastructure.EventBus.Consumers.Events; + +public class ProjectCatalogItemCardWhenCatalogChangedConsumer(IProjectCatalogItemCardWhenCatalogChangedInteractor interactor) + : Consumer(interactor); \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Infrastructure.EventBus/Consumers/Events/ProjectCatalogItemDetailsWhenCatalogChangedConsumer.cs b/src/Services/Cataloging/Query/Infrastructure.EventBus/Consumers/Events/ProjectCatalogItemDetailsWhenCatalogChangedConsumer.cs new file mode 100644 index 000000000..32728f2d2 --- /dev/null +++ b/src/Services/Cataloging/Query/Infrastructure.EventBus/Consumers/Events/ProjectCatalogItemDetailsWhenCatalogChangedConsumer.cs @@ -0,0 +1,11 @@ +using Application.UseCases.Events; +using MassTransit; + +namespace Infrastructure.EventBus.Consumers.Events; + +public class ProjectCatalogItemDetailsWhenCatalogChangedConsumer(IProjectCatalogItemDetailsWhenCatalogChangedInteractor interactor) + : IConsumer +{ + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Infrastructure.EventBus/Consumers/Events/ProjectCatalogItemListItemWhenCatalogChangedConsumer.cs b/src/Services/Cataloging/Query/Infrastructure.EventBus/Consumers/Events/ProjectCatalogItemListItemWhenCatalogChangedConsumer.cs new file mode 100644 index 000000000..9d586b0ae --- /dev/null +++ b/src/Services/Cataloging/Query/Infrastructure.EventBus/Consumers/Events/ProjectCatalogItemListItemWhenCatalogChangedConsumer.cs @@ -0,0 +1,20 @@ +using Application.UseCases.Events; +using MassTransit; + +namespace Infrastructure.EventBus.Consumers.Events; + +public class ProjectCatalogItemListItemWhenCatalogChangedConsumer(IProjectCatalogItemListItemWhenCatalogChangedInteractor interactor) + : + IConsumer, + IConsumer, + IConsumer +{ + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); + + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); + + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Infrastructure.EventBus/DependencyInjection/Extensions/NameFormatterExtensions.cs b/src/Services/Cataloging/Query/Infrastructure.EventBus/DependencyInjection/Extensions/NameFormatterExtensions.cs new file mode 100644 index 000000000..fd45af74e --- /dev/null +++ b/src/Services/Cataloging/Query/Infrastructure.EventBus/DependencyInjection/Extensions/NameFormatterExtensions.cs @@ -0,0 +1,16 @@ +using System.Reflection; +using MassTransit; + +namespace Infrastructure.EventBus.DependencyInjection.Extensions; + +internal static class NameFormatterExtensions +{ + public static string ToKebabCaseString(this MemberInfo member) + => KebabCaseEndpointNameFormatter.Instance.SanitizeName(member.Name); +} + +internal class KebabCaseEntityNameFormatter : IEntityNameFormatter +{ + public string FormatEntityName() + => typeof(T).ToKebabCaseString(); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Infrastructure.EventBus/DependencyInjection/Extensions/RabbitMqBusFactoryConfiguratorExtensions.cs b/src/Services/Cataloging/Query/Infrastructure.EventBus/DependencyInjection/Extensions/RabbitMqBusFactoryConfiguratorExtensions.cs new file mode 100644 index 000000000..8dfdccc5c --- /dev/null +++ b/src/Services/Cataloging/Query/Infrastructure.EventBus/DependencyInjection/Extensions/RabbitMqBusFactoryConfiguratorExtensions.cs @@ -0,0 +1,38 @@ +using Contracts.Abstractions.Messages; +using Infrastructure.EventBus.Consumers.Events; +using MassTransit; + +namespace Infrastructure.EventBus.DependencyInjection.Extensions; + +internal static class RabbitMqBusFactoryConfiguratorExtensions +{ + public static void ConfigureEventReceiveEndpoints(this IRabbitMqBusFactoryConfigurator cfg, IRegistrationContext context) + { + cfg.ConfigureEventReceiveEndpoint(context); + cfg.ConfigureEventReceiveEndpoint(context); + cfg.ConfigureEventReceiveEndpoint(context); + cfg.ConfigureEventReceiveEndpoint(context); + + cfg.ConfigureEventReceiveEndpoint(context); + cfg.ConfigureEventReceiveEndpoint(context); + cfg.ConfigureEventReceiveEndpoint(context); + cfg.ConfigureEventReceiveEndpoint(context); + cfg.ConfigureEventReceiveEndpoint(context); + + cfg.ConfigureEventReceiveEndpoint(context); + + cfg.ConfigureEventReceiveEndpoint(context); + } + + private static void ConfigureEventReceiveEndpoint(this IRabbitMqBusFactoryConfigurator bus, IRegistrationContext context) + where TConsumer : class, IConsumer + where TEvent : class, IEvent + => bus.ReceiveEndpoint( + queueName: $"cataloging.query.{typeof(TConsumer).ToKebabCaseString()}.{typeof(TEvent).ToKebabCaseString()}", + configureEndpoint: endpoint => + { + endpoint.ConfigureConsumeTopology = false; + endpoint.Bind(); + endpoint.ConfigureConsumer(context); + }); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Infrastructure.EventBus/DependencyInjection/Extensions/ServiceCollectionExtensions.cs b/src/Services/Cataloging/Query/Infrastructure.EventBus/DependencyInjection/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..a41918a0e --- /dev/null +++ b/src/Services/Cataloging/Query/Infrastructure.EventBus/DependencyInjection/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,81 @@ +using System.Reflection; +using Contracts.Abstractions.Messages; +using Contracts.JsonConverters; +using FluentValidation; +using Infrastructure.EventBus.DependencyInjection.Options; +using Infrastructure.EventBus.PipeFilters; +using Infrastructure.EventBus.PipeObservers; +using MassTransit; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; + +namespace Infrastructure.EventBus.DependencyInjection.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddEventBus(this IServiceCollection services) + => services.AddMassTransit(cfg => + { + cfg.SetKebabCaseEndpointNameFormatter(); + cfg.AddConsumers(Assembly.GetExecutingAssembly()); + + cfg.UsingRabbitMq((context, bus) => + { + var options = context.GetRequiredService>().CurrentValue; + + bus.Host( + hostAddress: options.ConnectionString, + connectionName: $"{options.ConnectionName}.{AppDomain.CurrentDomain.FriendlyName}"); + + bus.UseMessageRetry(retry + => retry.Incremental( + retryLimit: options.RetryLimit, + initialInterval: options.InitialInterval, + intervalIncrement: options.IntervalIncrement)); + + bus.UseNewtonsoftJsonSerializer(); + + bus.ConfigureNewtonsoftJsonSerializer(settings => + { + settings.Converters.Add(new TypeNameHandlingConverter(TypeNameHandling.Objects)); + settings.Converters.Add(new DateOnlyJsonConverter()); + settings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + return settings; + }); + + bus.ConfigureNewtonsoftJsonDeserializer(settings => + { + settings.Converters.Add(new TypeNameHandlingConverter(TypeNameHandling.Objects)); + settings.Converters.Add(new DateOnlyJsonConverter()); + settings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + return settings; + }); + + bus.MessageTopology.SetEntityNameFormatter(new KebabCaseEntityNameFormatter()); + bus.UseConsumeFilter(typeof(ContractValidatorFilter<>), context); + bus.ConnectReceiveObserver(new LoggingReceiveObserver()); + bus.ConnectConsumeObserver(new LoggingConsumeObserver()); + bus.ConfigureEventReceiveEndpoints(context); + bus.ConfigureEndpoints(context); + }); + }); + + public static IServiceCollection AddMessageValidators(this IServiceCollection services) + => services.AddValidatorsFromAssemblyContaining(typeof(IMessage)); + + public static OptionsBuilder ConfigureEventBusOptions(this IServiceCollection services, IConfigurationSection section) + => services + .AddOptions() + .Bind(section) + .ValidateDataAnnotations() + .ValidateOnStart(); + + public static OptionsBuilder ConfigureMassTransitHostOptions(this IServiceCollection services, IConfigurationSection section) + => services + .AddOptions() + .Bind(section) + .ValidateDataAnnotations() + .ValidateOnStart(); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Infrastructure.EventBus/DependencyInjection/Options/EventBusOptions.cs b/src/Services/Cataloging/Query/Infrastructure.EventBus/DependencyInjection/Options/EventBusOptions.cs new file mode 100644 index 000000000..783e591c9 --- /dev/null +++ b/src/Services/Cataloging/Query/Infrastructure.EventBus/DependencyInjection/Options/EventBusOptions.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Infrastructure.EventBus.DependencyInjection.Options; + +public record EventBusOptions +{ + [Required] public required string ConnectionName { get; init; } + [Required] public required Uri ConnectionString { get; init; } + [Required, Range(1, 10)] public int RetryLimit { get; init; } + [Required, Timestamp] public TimeSpan InitialInterval { get; init; } + [Required, Timestamp] public TimeSpan IntervalIncrement { get; init; } + [Required, MinLength(5)] public required string SchedulerQueueName { get; init; } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Infrastructure.EventBus/Infrastructure.EventBus.csproj b/src/Services/Cataloging/Query/Infrastructure.EventBus/Infrastructure.EventBus.csproj new file mode 100644 index 000000000..e941a792b --- /dev/null +++ b/src/Services/Cataloging/Query/Infrastructure.EventBus/Infrastructure.EventBus.csproj @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Infrastructure.EventBus/PipeFilters/ContractValidatorFilter.cs b/src/Services/Cataloging/Query/Infrastructure.EventBus/PipeFilters/ContractValidatorFilter.cs new file mode 100644 index 000000000..8d2083e89 --- /dev/null +++ b/src/Services/Cataloging/Query/Infrastructure.EventBus/PipeFilters/ContractValidatorFilter.cs @@ -0,0 +1,36 @@ +using Contracts.Abstractions.Validations; +using FluentValidation; +using MassTransit; +using Serilog; + +namespace Infrastructure.EventBus.PipeFilters; + +public class ContractValidatorFilter(IValidator? validator = default) : IFilter> + where T : class +{ + public async Task Send(ConsumeContext context, IPipe> next) + { + if (validator is null) + { + await next.Send(context); + return; + } + + var validationResult = await validator.ValidateAsync(context.Message, context.CancellationToken); + + if (validationResult.IsValid) + { + await next.Send(context); + return; + } + + Log.Error("Contract validation errors: {Errors}", validationResult.Errors); + + await context.Send( + destinationAddress: new($"queue:catalog.{KebabCaseEndpointNameFormatter.Instance.SanitizeName(typeof(T).Name)}.contract-errors"), + message: new ContractValidationResult(context.Message, validationResult.Errors.Select(failure => failure.ErrorMessage))); + } + + public void Probe(ProbeContext context) + => context.CreateFilterScope("Contract validation"); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Infrastructure.EventBus/PipeObservers/LoggingConsumeObserver.cs b/src/Services/Cataloging/Query/Infrastructure.EventBus/PipeObservers/LoggingConsumeObserver.cs new file mode 100644 index 000000000..8c8c0818c --- /dev/null +++ b/src/Services/Cataloging/Query/Infrastructure.EventBus/PipeObservers/LoggingConsumeObserver.cs @@ -0,0 +1,24 @@ +using MassTransit; +using Serilog; + +namespace Infrastructure.EventBus.PipeObservers; + +public class LoggingConsumeObserver : IConsumeObserver +{ + public async Task PreConsume(ConsumeContext context) + where T : class + { + await Task.Yield(); + + Log.Information("Consuming {Message} message from {Namespace}, CorrelationId: {CorrelationId}", + typeof(T).Name, typeof(T).Namespace, context.CorrelationId); + } + + public Task PostConsume(ConsumeContext context) + where T : class + => Task.CompletedTask; + + public Task ConsumeFault(ConsumeContext context, Exception exception) + where T : class + => Task.CompletedTask; +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Infrastructure.EventBus/PipeObservers/LoggingReceiveObserver.cs b/src/Services/Cataloging/Query/Infrastructure.EventBus/PipeObservers/LoggingReceiveObserver.cs new file mode 100644 index 000000000..7a6a8c42f --- /dev/null +++ b/src/Services/Cataloging/Query/Infrastructure.EventBus/PipeObservers/LoggingReceiveObserver.cs @@ -0,0 +1,51 @@ +using MassTransit; +using Serilog; + +namespace Infrastructure.EventBus.PipeObservers; + +public class LoggingReceiveObserver : IReceiveObserver +{ + private const string ExchangeKey = "RabbitMQ-ExchangeName"; + + public async Task PreReceive(ReceiveContext context) + { + await Task.Yield(); + + Log.Debug("Receiving message from exchange {Exchange}, Redelivered: {Redelivered}, CorrelationId: {CorrelationId}", + context.TransportHeaders.Get(ExchangeKey), context.Redelivered, context.GetCorrelationId()); + } + + public async Task PostReceive(ReceiveContext context) + { + await Task.Yield(); + + Log.Debug("Message was received from exchange {Exchange}, Redelivered: {Redelivered}, CorrelationId: {CorrelationId}", + context.TransportHeaders.Get(ExchangeKey), context.Redelivered, context.GetCorrelationId()); + } + + public async Task PostConsume(ConsumeContext context, TimeSpan duration, string consumerType) + where T : class + { + await Task.Yield(); + + Log.Debug("{Message} message from {Namespace} was consumed by {ConsumerType}, Duration: {Duration}s, CorrelationId: {CorrelationId}", + typeof(T).Name, typeof(T).Namespace, consumerType, duration.TotalSeconds, context.CorrelationId); + } + + public async Task ConsumeFault(ConsumeContext context, TimeSpan duration, string consumerType, Exception exception) + where T : class + { + await Task.Yield(); + + Log.Error("Fault on consuming message {Message} from {Namespace} by {ConsumerType}, Duration: {Duration}s, Error: {Error}, CorrelationId: {CorrelationId}", + typeof(T).Name, typeof(T).Namespace, consumerType, duration.TotalSeconds, exception.Message, context.CorrelationId); + } + + public async Task ReceiveFault(ReceiveContext context, Exception exception) + { + await Task.Yield(); + + Log.Error("Fault on receiving message from exchange {Exchange}, Redelivered: {Redelivered}, Error: {Error}, CorrelationId: {CorrelationId}", + context.TransportHeaders.Get(ExchangeKey), context.Redelivered, exception.Message, context.GetCorrelationId() ?? new()); + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Infrastructure.Projections/Abstractions/IMongoDbContext.cs b/src/Services/Cataloging/Query/Infrastructure.Projections/Abstractions/IMongoDbContext.cs new file mode 100644 index 000000000..9f626b6de --- /dev/null +++ b/src/Services/Cataloging/Query/Infrastructure.Projections/Abstractions/IMongoDbContext.cs @@ -0,0 +1,8 @@ +using MongoDB.Driver; + +namespace Infrastructure.Projections.Abstractions; + +public interface IMongoDbContext +{ + IMongoCollection GetCollection(); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Infrastructure.Projections/Abstractions/MongoDbContext.cs b/src/Services/Cataloging/Query/Infrastructure.Projections/Abstractions/MongoDbContext.cs new file mode 100644 index 000000000..60397e62b --- /dev/null +++ b/src/Services/Cataloging/Query/Infrastructure.Projections/Abstractions/MongoDbContext.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Configuration; +using MongoDB.Driver; + +namespace Infrastructure.Projections.Abstractions; + +public abstract class MongoDbContext : IMongoDbContext +{ + private readonly IMongoDatabase _database; + + protected MongoDbContext(IConfiguration configuration) + { + MongoUrl mongoUrl = new(configuration.GetConnectionString("Projections")); + _database = new MongoClient(mongoUrl).GetDatabase(mongoUrl.DatabaseName); + } + + public IMongoCollection GetCollection() + => _database.GetCollection(typeof(T).Name); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Infrastructure.Projections/DependencyInjection/ServiceCollectionExtensions.cs b/src/Services/Cataloging/Query/Infrastructure.Projections/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..c20aeb3e0 --- /dev/null +++ b/src/Services/Cataloging/Query/Infrastructure.Projections/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using Application.Abstractions; +using Infrastructure.Projections.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; + +namespace Infrastructure.Projections.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static void AddProjections(this IServiceCollection services) + { + services.AddScoped(typeof(IProjectionGateway<>), typeof(ProjectionGateway<>)); + services.AddScoped(); + BsonSerializer.RegisterSerializer(new GuidSerializer(BsonType.String)); + } +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Infrastructure.Projections/Infrastructure.Projections.csproj b/src/Services/Cataloging/Query/Infrastructure.Projections/Infrastructure.Projections.csproj new file mode 100644 index 000000000..1e2b801e3 --- /dev/null +++ b/src/Services/Cataloging/Query/Infrastructure.Projections/Infrastructure.Projections.csproj @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Infrastructure.Projections/Pagination/PagedResult.cs b/src/Services/Cataloging/Query/Infrastructure.Projections/Pagination/PagedResult.cs new file mode 100644 index 000000000..a231de95e --- /dev/null +++ b/src/Services/Cataloging/Query/Infrastructure.Projections/Pagination/PagedResult.cs @@ -0,0 +1,30 @@ +using Contracts.Abstractions; +using Contracts.Abstractions.Paging; +using MongoDB.Driver; +using MongoDB.Driver.Linq; + +namespace Infrastructure.Projections.Pagination; + +public record PagedResult(IReadOnlyCollection Projections, Paging Paging) : IPagedResult + where TProjection : IProjection +{ + public IReadOnlyCollection Items + => Page.HasNext ? Projections.Take(Paging.Limit).ToList() : Projections; + + public Page Page => new() + { + Current = Paging.Offset + 1, + Size = Paging.Limit, + HasNext = Paging.Limit < Projections.Count, + HasPrevious = Paging.Offset > 0 + }; + + public static async ValueTask> CreateAsync(Paging paging, IQueryable source, CancellationToken cancellationToken) + { + var projections = await ApplyPagination(paging, source).ToListAsync(cancellationToken); + return new PagedResult(projections, paging); + } + + private static IMongoQueryable ApplyPagination(Paging paging, IQueryable source) + => (IMongoQueryable)source.Skip(paging.Limit * paging.Offset).Take(paging.Limit + 1); +} \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Infrastructure.Projections/ProjectionDbContext.cs b/src/Services/Cataloging/Query/Infrastructure.Projections/ProjectionDbContext.cs new file mode 100644 index 000000000..f935de000 --- /dev/null +++ b/src/Services/Cataloging/Query/Infrastructure.Projections/ProjectionDbContext.cs @@ -0,0 +1,6 @@ +using Infrastructure.Projections.Abstractions; +using Microsoft.Extensions.Configuration; + +namespace Infrastructure.Projections; + +public class ProjectionDbContext(IConfiguration configuration) : MongoDbContext(configuration); \ No newline at end of file diff --git a/src/Services/Cataloging/Query/Infrastructure.Projections/ProjectionGateway.cs b/src/Services/Cataloging/Query/Infrastructure.Projections/ProjectionGateway.cs new file mode 100644 index 000000000..d05cf6db6 --- /dev/null +++ b/src/Services/Cataloging/Query/Infrastructure.Projections/ProjectionGateway.cs @@ -0,0 +1,61 @@ +using System.Linq.Expressions; +using Application.Abstractions; +using Contracts.Abstractions; +using Contracts.Abstractions.Paging; +using Infrastructure.Projections.Abstractions; +using Infrastructure.Projections.Pagination; +using MongoDB.Driver; +using MongoDB.Driver.Linq; +using Serilog; + +namespace Infrastructure.Projections; + +public class ProjectionGateway(IMongoDbContext context) : IProjectionGateway + where TProjection : IProjection +{ + private readonly IMongoCollection _collection = context.GetCollection(); + + public Task GetAsync(TId id, CancellationToken cancellationToken) where TId : struct + => FindAsync(projection => projection.Id.Equals(id), cancellationToken); + + public Task FindAsync(Expression> predicate, CancellationToken cancellationToken) + => _collection.AsQueryable().Where(predicate).FirstOrDefaultAsync(cancellationToken)!; + + public ValueTask> ListAsync(Paging paging, Expression> predicate, CancellationToken cancellationToken) + => PagedResult.CreateAsync(paging, _collection.AsQueryable().Where(predicate), cancellationToken); + + public ValueTask> ListAsync(Paging paging, CancellationToken cancellationToken) + => PagedResult.CreateAsync(paging, _collection.AsQueryable(), cancellationToken); + + public Task DeleteAsync(Expression> filter, CancellationToken cancellationToken) + => _collection.DeleteManyAsync(filter, cancellationToken); + + public Task DeleteAsync(TId id, CancellationToken cancellationToken) where TId : struct + => _collection.DeleteOneAsync(projection => projection.Id.Equals(id), cancellationToken); + + public Task UpdateFieldAsync(TId id, ulong version, Expression> field, TField value, CancellationToken cancellationToken) where TId : struct + => _collection.UpdateOneAsync( + filter: projection => projection.Id.Equals(id) && projection.Version < version, + update: new ObjectUpdateDefinition(new()).Set(field, value), + cancellationToken: cancellationToken); + + public ValueTask ReplaceInsertAsync(TProjection replacement, CancellationToken cancellationToken) + => OnReplaceAsync(replacement, projection => projection.Id == replacement.Id && projection.Version < replacement.Version, cancellationToken); + + public ValueTask RebuildInsertAsync(TProjection replacement, CancellationToken cancellationToken) + => OnReplaceAsync(replacement, projection => projection.Id == replacement.Id && projection.Version <= replacement.Version, cancellationToken); + + private async ValueTask OnReplaceAsync(TProjection replacement, Expression> filter, CancellationToken cancellationToken) + { + try + { + await _collection.ReplaceOneAsync(filter, replacement, new ReplaceOptions { IsUpsert = true }, cancellationToken); + } + catch (MongoWriteException e) when (e.WriteError.Category is ServerErrorCategory.DuplicateKey) + { + Log.Warning( + "By passing Duplicate Key when inserting {ProjectionType} with Id {Id}", + typeof(TProjection).Name, replacement.Id); + } + } +} \ No newline at end of file diff --git a/src/Services/Identity/Command/Infrastructure.EventBus/Abstractions/Consumer.cs b/src/Services/Identity/Command/Infrastructure.EventBus/Abstractions/Consumer.cs new file mode 100644 index 000000000..6381cadfb --- /dev/null +++ b/src/Services/Identity/Command/Infrastructure.EventBus/Abstractions/Consumer.cs @@ -0,0 +1,12 @@ +using Application.Abstractions; +using Contracts.Abstractions.Messages; +using MassTransit; + +namespace Infrastructure.EventBus.Abstractions; + +public abstract class Consumer(IInteractor interactor) : IConsumer + where TMessage : class, IMessage +{ + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); +} \ No newline at end of file diff --git a/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Commands/ChangeEmailConsumer.cs b/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Commands/ChangeEmailConsumer.cs new file mode 100644 index 000000000..92a37cd0f --- /dev/null +++ b/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Commands/ChangeEmailConsumer.cs @@ -0,0 +1,7 @@ +using Application.Abstractions; +using Contracts.Boundaries.Identity; +using Infrastructure.EventBus.Abstractions; + +namespace Infrastructure.EventBus.Consumers.Commands; + +public class ChangeEmailConsumer(IInteractor interactor) : Consumer(interactor); \ No newline at end of file diff --git a/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Commands/ChangePasswordConsumer.cs b/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Commands/ChangePasswordConsumer.cs new file mode 100644 index 000000000..a22adc6d8 --- /dev/null +++ b/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Commands/ChangePasswordConsumer.cs @@ -0,0 +1,7 @@ +using Application.Abstractions; +using Contracts.Boundaries.Identity; +using Infrastructure.EventBus.Abstractions; + +namespace Infrastructure.EventBus.Consumers.Commands; + +public class ChangePasswordConsumer(IInteractor interactor) : Consumer(interactor); \ No newline at end of file diff --git a/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Commands/ConfirmEmailConsumer.cs b/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Commands/ConfirmEmailConsumer.cs new file mode 100644 index 000000000..96c04b5c4 --- /dev/null +++ b/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Commands/ConfirmEmailConsumer.cs @@ -0,0 +1,7 @@ +using Application.Abstractions; +using Contracts.Boundaries.Identity; +using Infrastructure.EventBus.Abstractions; + +namespace Infrastructure.EventBus.Consumers.Commands; + +public class ConfirmEmailConsumer(IInteractor interactor) : Consumer(interactor); \ No newline at end of file diff --git a/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Commands/RegisterUserConsumer.cs b/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Commands/RegisterUserConsumer.cs new file mode 100644 index 000000000..bd423c9bc --- /dev/null +++ b/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Commands/RegisterUserConsumer.cs @@ -0,0 +1,7 @@ +using Application.Abstractions; +using Contracts.Boundaries.Identity; +using Infrastructure.EventBus.Abstractions; + +namespace Infrastructure.EventBus.Consumers.Commands; + +public class RegisterUserConsumer(IInteractor interactor) : Consumer(interactor); \ No newline at end of file diff --git a/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Events/AccountDeactivatedConsumer.cs b/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Events/AccountDeactivatedConsumer.cs new file mode 100644 index 000000000..02cafcc84 --- /dev/null +++ b/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Events/AccountDeactivatedConsumer.cs @@ -0,0 +1,8 @@ +using Application.Abstractions; +using Contracts.Boundaries.Account; +using Infrastructure.EventBus.Abstractions; + +namespace Infrastructure.EventBus.Consumers.Events; + +public class AccountDeactivatedConsumer(IInteractor interactor) + : Consumer(interactor); \ No newline at end of file diff --git a/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Events/AccountDeletedConsumer.cs b/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Events/AccountDeletedConsumer.cs new file mode 100644 index 000000000..6b5f132bc --- /dev/null +++ b/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Events/AccountDeletedConsumer.cs @@ -0,0 +1,7 @@ +using Application.Abstractions; +using Contracts.Boundaries.Account; +using Infrastructure.EventBus.Abstractions; + +namespace Infrastructure.EventBus.Consumers.Events; + +public class AccountDeletedConsumer(IInteractor interactor) : Consumer(interactor); \ No newline at end of file diff --git a/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Events/EmailChangedConsumer.cs b/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Events/EmailChangedConsumer.cs new file mode 100644 index 000000000..20b6c985d --- /dev/null +++ b/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Events/EmailChangedConsumer.cs @@ -0,0 +1,7 @@ +using Application.Abstractions; +using Contracts.Boundaries.Identity; +using Infrastructure.EventBus.Abstractions; + +namespace Infrastructure.EventBus.Consumers.Events; + +public class EmailChangedConsumer(IInteractor interactor) : Consumer(interactor); \ No newline at end of file diff --git a/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Events/EmailConfirmationExpiredConsumer.cs b/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Events/EmailConfirmationExpiredConsumer.cs new file mode 100644 index 000000000..e1d1e8e7e --- /dev/null +++ b/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Events/EmailConfirmationExpiredConsumer.cs @@ -0,0 +1,8 @@ +using Application.Abstractions; +using Contracts.Boundaries.Identity; +using Infrastructure.EventBus.Abstractions; + +namespace Infrastructure.EventBus.Consumers.Events; + +public class EmailConfirmationExpiredConsumer(IInteractor interactor) + : Consumer(interactor); \ No newline at end of file diff --git a/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Events/EmailConfirmedConsumer.cs b/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Events/EmailConfirmedConsumer.cs new file mode 100644 index 000000000..a683ae4f4 --- /dev/null +++ b/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Events/EmailConfirmedConsumer.cs @@ -0,0 +1,7 @@ +using Application.Abstractions; +using Contracts.Boundaries.Identity; +using Infrastructure.EventBus.Abstractions; + +namespace Infrastructure.EventBus.Consumers.Events; + +public class EmailConfirmedConsumer(IInteractor interactor) : Consumer(interactor); \ No newline at end of file diff --git a/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Events/UserRegisteredConsumer.cs b/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Events/UserRegisteredConsumer.cs new file mode 100644 index 000000000..60b54617d --- /dev/null +++ b/src/Services/Identity/Command/Infrastructure.EventBus/Consumers/Events/UserRegisteredConsumer.cs @@ -0,0 +1,7 @@ +using Application.Abstractions; +using Contracts.Boundaries.Identity; +using Infrastructure.EventBus.Abstractions; + +namespace Infrastructure.EventBus.Consumers.Events; + +public class UserRegisteredConsumer(IInteractor interactor) : Consumer(interactor); \ No newline at end of file diff --git a/src/Services/Identity/Command/Infrastructure.EventBus/DependencyInjection/Extensions/NameFormatterExtensions.cs b/src/Services/Identity/Command/Infrastructure.EventBus/DependencyInjection/Extensions/NameFormatterExtensions.cs new file mode 100644 index 000000000..fd45af74e --- /dev/null +++ b/src/Services/Identity/Command/Infrastructure.EventBus/DependencyInjection/Extensions/NameFormatterExtensions.cs @@ -0,0 +1,16 @@ +using System.Reflection; +using MassTransit; + +namespace Infrastructure.EventBus.DependencyInjection.Extensions; + +internal static class NameFormatterExtensions +{ + public static string ToKebabCaseString(this MemberInfo member) + => KebabCaseEndpointNameFormatter.Instance.SanitizeName(member.Name); +} + +internal class KebabCaseEntityNameFormatter : IEntityNameFormatter +{ + public string FormatEntityName() + => typeof(T).ToKebabCaseString(); +} \ No newline at end of file diff --git a/src/Services/Identity/Command/Infrastructure.EventBus/DependencyInjection/Extensions/RabbitMqBusFactoryConfiguratorExtensions.cs b/src/Services/Identity/Command/Infrastructure.EventBus/DependencyInjection/Extensions/RabbitMqBusFactoryConfiguratorExtensions.cs new file mode 100644 index 000000000..8c6437237 --- /dev/null +++ b/src/Services/Identity/Command/Infrastructure.EventBus/DependencyInjection/Extensions/RabbitMqBusFactoryConfiguratorExtensions.cs @@ -0,0 +1,31 @@ +using Contracts.Abstractions.Messages; +using Contracts.Boundaries.Identity; +using Infrastructure.EventBus.Consumers.Events; +using MassTransit; +using DomainEvent = Contracts.Boundaries.Account.DomainEvent; + +namespace Infrastructure.EventBus.DependencyInjection.Extensions; + +internal static class RabbitMqBusFactoryConfiguratorExtensions +{ + public static void ConfigureEventReceiveEndpoints(this IRabbitMqBusFactoryConfigurator cfg, IRegistrationContext context) + { + ConfigureEventReceiveEndpoint(cfg, context); + ConfigureEventReceiveEndpoint(cfg, context); + ConfigureEventReceiveEndpoint(cfg, context); + ConfigureEventReceiveEndpoint(cfg, context); + ConfigureEventReceiveEndpoint(cfg, context); + } + + private static void ConfigureEventReceiveEndpoint(this IRabbitMqBusFactoryConfigurator bus, IRegistrationContext context) + where TConsumer : class, IConsumer + where TEvent : class, IEvent + => bus.ReceiveEndpoint( + queueName: $"identity.command.{typeof(TConsumer).ToKebabCaseString()}.{typeof(TEvent).ToKebabCaseString()}", + configureEndpoint: endpoint => + { + endpoint.ConfigureConsumeTopology = false; + endpoint.Bind(); + endpoint.ConfigureConsumer(context); + }); +} \ No newline at end of file diff --git a/src/Services/Identity/Command/Infrastructure.EventBus/DependencyInjection/Extensions/ServiceCollectionExtensions.cs b/src/Services/Identity/Command/Infrastructure.EventBus/DependencyInjection/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..521e54f34 --- /dev/null +++ b/src/Services/Identity/Command/Infrastructure.EventBus/DependencyInjection/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,111 @@ +using System.Reflection; +using Application.Abstractions.Gateways; +using Contracts.Abstractions.Messages; +using Contracts.JsonConverters; +using FluentValidation; +using Infrastructure.EventBus.DependencyInjection.Options; +using Infrastructure.EventBus.PipeFilters; +using Infrastructure.EventBus.PipeObservers; +using MassTransit; +using MassTransit.Configuration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Quartz; + +namespace Infrastructure.EventBus.DependencyInjection.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddMessageBus(this IServiceCollection services) + => services.AddMassTransit(cfg => + { + cfg.SetKebabCaseEndpointNameFormatter(); + cfg.AddConsumers(Assembly.GetExecutingAssembly()); + + cfg.UsingRabbitMq((context, bus) => + { + var options = context.GetRequiredService>().CurrentValue; + + bus.Host( + hostAddress: options.ConnectionString, + connectionName: $"{options.ConnectionName}.{AppDomain.CurrentDomain.FriendlyName}"); + + cfg.AddMessageScheduler(new($"queue:{options.SchedulerQueueName}")); + + bus.UseInMemoryScheduler( + schedulerFactory: context.GetRequiredService(), + queueName: options.SchedulerQueueName); + + bus.UseMessageRetry(retry + => retry.Incremental( + retryLimit: options.RetryLimit, + initialInterval: options.InitialInterval, + intervalIncrement: options.IntervalIncrement)); + + bus.UseNewtonsoftJsonSerializer(); + + bus.ConfigureNewtonsoftJsonSerializer(settings => + { + settings.Converters.Add(new TypeNameHandlingConverter(TypeNameHandling.Objects)); + settings.Converters.Add(new DateOnlyJsonConverter()); + settings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + return settings; + }); + + bus.ConfigureNewtonsoftJsonDeserializer(settings => + { + settings.Converters.Add(new TypeNameHandlingConverter(TypeNameHandling.Objects)); + settings.Converters.Add(new DateOnlyJsonConverter()); + settings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + return settings; + }); + + bus.MessageTopology.SetEntityNameFormatter(new KebabCaseEntityNameFormatter()); + + // TODO - Solve this! + // bus.UseConsumeFilter(typeof(BusinessValidatorFilter<>), context); + + bus.UseConsumeFilter(typeof(ContractValidatorFilter<>), context); + bus.ConnectReceiveObserver(new LoggingReceiveObserver()); + bus.ConnectConsumeObserver(new LoggingConsumeObserver()); + bus.ConnectPublishObserver(new LoggingPublishObserver()); + bus.ConnectSendObserver(new LoggingSendObserver()); + bus.ConfigureEventReceiveEndpoints(context); + bus.ConfigureEndpoints(context); + + bus.ConfigurePublish(pipe => pipe.AddPipeSpecification( + new DelegatePipeSpecification>(ctx + => ctx.CorrelationId = ctx.InitiatorId))); + }); + }) + .AddQuartz(); + + public static IServiceCollection AddEventBusGateway(this IServiceCollection services) + => services.AddScoped(); + + public static IServiceCollection AddMessageValidators(this IServiceCollection services) + => services.AddValidatorsFromAssemblyContaining(typeof(IMessage)); + + public static OptionsBuilder ConfigureMessageBusOptions(this IServiceCollection services, IConfigurationSection section) + => services + .AddOptions() + .Bind(section) + .ValidateDataAnnotations() + .ValidateOnStart(); + + public static OptionsBuilder ConfigureQuartzOptions(this IServiceCollection services, IConfigurationSection section) + => services + .AddOptions() + .Bind(section) + .ValidateDataAnnotations() + .ValidateOnStart(); + + public static OptionsBuilder ConfigureMassTransitHostOptions(this IServiceCollection services, IConfigurationSection section) + => services + .AddOptions() + .Bind(section) + .ValidateDataAnnotations() + .ValidateOnStart(); +} \ No newline at end of file diff --git a/src/Services/Identity/Command/Infrastructure.EventBus/DependencyInjection/Options/MessageBusOptions.cs b/src/Services/Identity/Command/Infrastructure.EventBus/DependencyInjection/Options/MessageBusOptions.cs new file mode 100644 index 000000000..a3281f8c8 --- /dev/null +++ b/src/Services/Identity/Command/Infrastructure.EventBus/DependencyInjection/Options/MessageBusOptions.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Infrastructure.EventBus.DependencyInjection.Options; + +public record MessageBusOptions +{ + [Required] public required string ConnectionName { get; init; } + [Required] public required Uri ConnectionString { get; init; } + [Required, Range(1, 10)] public int RetryLimit { get; init; } + [Required, Timestamp] public TimeSpan InitialInterval { get; init; } + [Required, Timestamp] public TimeSpan IntervalIncrement { get; init; } + [Required, MinLength(5)] public required string SchedulerQueueName { get; init; } +} \ No newline at end of file diff --git a/src/Services/Identity/Command/Infrastructure.EventBus/EventBusGateway.cs b/src/Services/Identity/Command/Infrastructure.EventBus/EventBusGateway.cs new file mode 100644 index 000000000..317bbaa35 --- /dev/null +++ b/src/Services/Identity/Command/Infrastructure.EventBus/EventBusGateway.cs @@ -0,0 +1,17 @@ +using Application.Abstractions.Gateways; +using Contracts.Abstractions.Messages; +using MassTransit; + +namespace Infrastructure.EventBus; + +public class EventBusGateway(IBus bus, IPublishEndpoint publishEndpoint) : IEventBusGateway +{ + public Task PublishAsync(IEnumerable events, CancellationToken cancellationToken) + => Task.WhenAll(events.Select(@event => publishEndpoint.Publish(@event, @event.GetType(), cancellationToken))); + + public Task PublishAsync(IEvent @event, CancellationToken cancellationToken) + => publishEndpoint.Publish(@event, @event.GetType(), cancellationToken); + + public Task SchedulePublishAsync(IDelayedEvent @event, DateTimeOffset scheduledTime, CancellationToken cancellationToken) + => publishEndpoint.CreateMessageScheduler(bus.Topology).SchedulePublish(scheduledTime.UtcDateTime, @event, @event.GetType(), cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Identity/Command/Infrastructure.EventBus/Infrastructure.EventBus.csproj b/src/Services/Identity/Command/Infrastructure.EventBus/Infrastructure.EventBus.csproj new file mode 100644 index 000000000..e941a792b --- /dev/null +++ b/src/Services/Identity/Command/Infrastructure.EventBus/Infrastructure.EventBus.csproj @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Services/Identity/Command/Infrastructure.EventBus/PipeFilters/BusinessValidatorFilter.cs b/src/Services/Identity/Command/Infrastructure.EventBus/PipeFilters/BusinessValidatorFilter.cs new file mode 100644 index 000000000..f10a92d49 --- /dev/null +++ b/src/Services/Identity/Command/Infrastructure.EventBus/PipeFilters/BusinessValidatorFilter.cs @@ -0,0 +1,25 @@ +using Contracts.Abstractions.Validations; +using FluentValidation; +using MassTransit; +using Serilog; + +namespace Infrastructure.EventBus.PipeFilters; + +public class BusinessValidatorFilter : IFilter> + where T : class +{ + public async Task Send(ExceptionConsumeContext context, IPipe> next) + { + if (context.Exception is ValidationException exception) + { + Log.Error("Business validation errors: {Errors}", exception.Errors); + + await context.Send( + destinationAddress: new($"queue:identity.{KebabCaseEndpointNameFormatter.Instance.SanitizeName(typeof(T).Name)}.business-error"), + message: new BusinessValidationResult(context.Message, exception.Errors.Select(failure => failure.ErrorMessage))); + } + } + + public void Probe(ProbeContext context) + => context.CreateFilterScope("Business validation"); +} \ No newline at end of file diff --git a/src/Services/Identity/Command/Infrastructure.EventBus/PipeFilters/ContractValidatorFilter.cs b/src/Services/Identity/Command/Infrastructure.EventBus/PipeFilters/ContractValidatorFilter.cs new file mode 100644 index 000000000..5177301d5 --- /dev/null +++ b/src/Services/Identity/Command/Infrastructure.EventBus/PipeFilters/ContractValidatorFilter.cs @@ -0,0 +1,36 @@ +using Contracts.Abstractions.Validations; +using FluentValidation; +using MassTransit; +using Serilog; + +namespace Infrastructure.EventBus.PipeFilters; + +public class ContractValidatorFilter(IValidator? validator = default) : IFilter> + where T : class +{ + public async Task Send(ConsumeContext context, IPipe> next) + { + if (validator is null) + { + await next.Send(context); + return; + } + + var validationResult = await validator.ValidateAsync(context.Message, context.CancellationToken); + + if (validationResult.IsValid) + { + await next.Send(context); + return; + } + + Log.Error("Contract validation errors: {Errors}", validationResult.Errors); + + await context.Send( + destinationAddress: new($"queue:identity.{KebabCaseEndpointNameFormatter.Instance.SanitizeName(typeof(T).Name)}.contract-errors"), + message: new ContractValidationResult(context.Message, validationResult.Errors.Select(failure => failure.ErrorMessage))); + } + + public void Probe(ProbeContext context) + => context.CreateFilterScope("Contract validation"); +} \ No newline at end of file diff --git a/src/Services/Identity/Command/Infrastructure.EventBus/PipeObservers/LoggingConsumeObserver.cs b/src/Services/Identity/Command/Infrastructure.EventBus/PipeObservers/LoggingConsumeObserver.cs new file mode 100644 index 000000000..8c8c0818c --- /dev/null +++ b/src/Services/Identity/Command/Infrastructure.EventBus/PipeObservers/LoggingConsumeObserver.cs @@ -0,0 +1,24 @@ +using MassTransit; +using Serilog; + +namespace Infrastructure.EventBus.PipeObservers; + +public class LoggingConsumeObserver : IConsumeObserver +{ + public async Task PreConsume(ConsumeContext context) + where T : class + { + await Task.Yield(); + + Log.Information("Consuming {Message} message from {Namespace}, CorrelationId: {CorrelationId}", + typeof(T).Name, typeof(T).Namespace, context.CorrelationId); + } + + public Task PostConsume(ConsumeContext context) + where T : class + => Task.CompletedTask; + + public Task ConsumeFault(ConsumeContext context, Exception exception) + where T : class + => Task.CompletedTask; +} \ No newline at end of file diff --git a/src/Services/Identity/Command/Infrastructure.EventBus/PipeObservers/LoggingPublishObserver.cs b/src/Services/Identity/Command/Infrastructure.EventBus/PipeObservers/LoggingPublishObserver.cs new file mode 100644 index 000000000..1d9a99eda --- /dev/null +++ b/src/Services/Identity/Command/Infrastructure.EventBus/PipeObservers/LoggingPublishObserver.cs @@ -0,0 +1,34 @@ +using MassTransit; +using Serilog; + +namespace Infrastructure.EventBus.PipeObservers; + +public class LoggingPublishObserver : IPublishObserver +{ + public async Task PrePublish(PublishContext context) + where T : class + { + await Task.Yield(); + + Log.Information("Publishing {Message} event from {Namespace}, CorrelationId: {CorrelationId}", + typeof(T).Name, typeof(T).Namespace, context.CorrelationId); + } + + public async Task PostPublish(PublishContext context) + where T : class + { + await Task.Yield(); + + Log.Debug("{MessageType} event, from {Namespace} was published, CorrelationId: {CorrelationId}", + typeof(T).Name, typeof(T).Namespace, context.CorrelationId); + } + + public async Task PublishFault(PublishContext context, Exception exception) + where T : class + { + await Task.Yield(); + + Log.Error("Fault on publishing message {Message} from {Namespace}, Error: {Error}, CorrelationId: {CorrelationId}", + typeof(T).Name, typeof(T).Namespace, exception.Message, context.CorrelationId); + } +} \ No newline at end of file diff --git a/src/Services/Identity/Command/Infrastructure.EventBus/PipeObservers/LoggingReceiveObserver.cs b/src/Services/Identity/Command/Infrastructure.EventBus/PipeObservers/LoggingReceiveObserver.cs new file mode 100644 index 000000000..7a6a8c42f --- /dev/null +++ b/src/Services/Identity/Command/Infrastructure.EventBus/PipeObservers/LoggingReceiveObserver.cs @@ -0,0 +1,51 @@ +using MassTransit; +using Serilog; + +namespace Infrastructure.EventBus.PipeObservers; + +public class LoggingReceiveObserver : IReceiveObserver +{ + private const string ExchangeKey = "RabbitMQ-ExchangeName"; + + public async Task PreReceive(ReceiveContext context) + { + await Task.Yield(); + + Log.Debug("Receiving message from exchange {Exchange}, Redelivered: {Redelivered}, CorrelationId: {CorrelationId}", + context.TransportHeaders.Get(ExchangeKey), context.Redelivered, context.GetCorrelationId()); + } + + public async Task PostReceive(ReceiveContext context) + { + await Task.Yield(); + + Log.Debug("Message was received from exchange {Exchange}, Redelivered: {Redelivered}, CorrelationId: {CorrelationId}", + context.TransportHeaders.Get(ExchangeKey), context.Redelivered, context.GetCorrelationId()); + } + + public async Task PostConsume(ConsumeContext context, TimeSpan duration, string consumerType) + where T : class + { + await Task.Yield(); + + Log.Debug("{Message} message from {Namespace} was consumed by {ConsumerType}, Duration: {Duration}s, CorrelationId: {CorrelationId}", + typeof(T).Name, typeof(T).Namespace, consumerType, duration.TotalSeconds, context.CorrelationId); + } + + public async Task ConsumeFault(ConsumeContext context, TimeSpan duration, string consumerType, Exception exception) + where T : class + { + await Task.Yield(); + + Log.Error("Fault on consuming message {Message} from {Namespace} by {ConsumerType}, Duration: {Duration}s, Error: {Error}, CorrelationId: {CorrelationId}", + typeof(T).Name, typeof(T).Namespace, consumerType, duration.TotalSeconds, exception.Message, context.CorrelationId); + } + + public async Task ReceiveFault(ReceiveContext context, Exception exception) + { + await Task.Yield(); + + Log.Error("Fault on receiving message from exchange {Exchange}, Redelivered: {Redelivered}, Error: {Error}, CorrelationId: {CorrelationId}", + context.TransportHeaders.Get(ExchangeKey), context.Redelivered, exception.Message, context.GetCorrelationId() ?? new()); + } +} \ No newline at end of file diff --git a/src/Services/Identity/Command/Infrastructure.EventBus/PipeObservers/LoggingSendObserver.cs b/src/Services/Identity/Command/Infrastructure.EventBus/PipeObservers/LoggingSendObserver.cs new file mode 100644 index 000000000..7e5aea529 --- /dev/null +++ b/src/Services/Identity/Command/Infrastructure.EventBus/PipeObservers/LoggingSendObserver.cs @@ -0,0 +1,34 @@ +using MassTransit; +using Serilog; + +namespace Infrastructure.EventBus.PipeObservers; + +public class LoggingSendObserver : ISendObserver +{ + public async Task PreSend(SendContext context) + where T : class + { + await Task.Yield(); + + Log.Information("Sending {Message} message from {Namespace}, CorrelationId: {CorrelationId}", + typeof(T).Name, typeof(T).Namespace, context.CorrelationId); + } + + public async Task PostSend(SendContext context) + where T : class + { + await Task.Yield(); + + Log.Debug("{MessageType} message from {Namespace} was sent, CorrelationId: {CorrelationId}", + typeof(T).Name, typeof(T).Namespace, context.CorrelationId); + } + + public async Task SendFault(SendContext context, Exception exception) + where T : class + { + await Task.Yield(); + + Log.Error("Fault on sending message {Message} from {Namespace}, Error: {Error}, CorrelationId: {CorrelationId}", + typeof(T).Name, typeof(T).Namespace, exception.Message, context.CorrelationId); + } +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Application/Abstractions/Gateways/IEmailGateway.cs b/src/Services/Notification/Command/Application/Abstractions/Gateways/IEmailGateway.cs new file mode 100644 index 000000000..2faaee582 --- /dev/null +++ b/src/Services/Notification/Command/Application/Abstractions/Gateways/IEmailGateway.cs @@ -0,0 +1,5 @@ +using Domain.ValueObject; + +namespace Application.Abstractions.Gateways; + +public interface IEmailGateway : INotificationGateway; \ No newline at end of file diff --git a/src/Services/Notification/Command/Application/Abstractions/Gateways/IEventBusGateway.cs b/src/Services/Notification/Command/Application/Abstractions/Gateways/IEventBusGateway.cs new file mode 100644 index 000000000..bfc79f7a6 --- /dev/null +++ b/src/Services/Notification/Command/Application/Abstractions/Gateways/IEventBusGateway.cs @@ -0,0 +1,10 @@ +using Contracts.Abstractions.Messages; + +namespace Application.Abstractions.Gateways; + +public interface IEventBusGateway +{ + Task PublishAsync(IEnumerable events, CancellationToken cancellationToken); + Task PublishAsync(IEvent @event, CancellationToken cancellationToken); + Task SchedulePublishAsync(IDelayedEvent @event, DateTimeOffset scheduledTime, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Application/Abstractions/Gateways/IEventStoreGateway.cs b/src/Services/Notification/Command/Application/Abstractions/Gateways/IEventStoreGateway.cs new file mode 100644 index 000000000..fcb351695 --- /dev/null +++ b/src/Services/Notification/Command/Application/Abstractions/Gateways/IEventStoreGateway.cs @@ -0,0 +1,10 @@ +using Domain.Abstractions.Aggregates; + +namespace Application.Abstractions.Gateways; + +public interface IEventStoreGateway +{ + Task AppendEventsAsync(IAggregateRoot aggregate, CancellationToken cancellationToken); + Task LoadAggregateAsync(Guid aggregateId, CancellationToken cancellationToken) where TAggregate : IAggregateRoot, new(); + IAsyncEnumerable StreamAggregatesId(); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Application/Abstractions/Gateways/INotificationGateway.cs b/src/Services/Notification/Command/Application/Abstractions/Gateways/INotificationGateway.cs new file mode 100644 index 000000000..07293eb76 --- /dev/null +++ b/src/Services/Notification/Command/Application/Abstractions/Gateways/INotificationGateway.cs @@ -0,0 +1,11 @@ +using Domain.Enumerations; +using Domain.ValueObject; + +namespace Application.Abstractions.Gateways; + +public interface INotificationGateway + where TOption : INotificationOption +{ + Task NotifyAsync(TOption option, CancellationToken cancellationToken); + Task CancelAsync(TOption option, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Application/Abstractions/Gateways/INotificationService.cs b/src/Services/Notification/Command/Application/Abstractions/Gateways/INotificationService.cs new file mode 100644 index 000000000..69c32fc41 --- /dev/null +++ b/src/Services/Notification/Command/Application/Abstractions/Gateways/INotificationService.cs @@ -0,0 +1,9 @@ +using Domain.Aggregates; + +namespace Application.Abstractions.Gateways; + +public interface INotificationService +{ + Task NotifyAsync(Notification notification, CancellationToken cancellationToken); + Task CancelAsync(Notification notification, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Application/Abstractions/Gateways/NotificationService.cs b/src/Services/Notification/Command/Application/Abstractions/Gateways/NotificationService.cs new file mode 100644 index 000000000..4e54d7ca4 --- /dev/null +++ b/src/Services/Notification/Command/Application/Abstractions/Gateways/NotificationService.cs @@ -0,0 +1,52 @@ +using Contracts.Boundaries.Notification; +using Domain.Aggregates; +using Domain.Enumerations; +using Domain.ValueObject; + +namespace Application.Abstractions.Gateways; + +public class NotificationService(ILazy> emailGateway, + ILazy> smsGateway, + ILazy> pushMobileGateway, + ILazy> pushWebGateway) + : INotificationService +{ + public async Task NotifyAsync(Notification notification, CancellationToken cancellationToken) + { + foreach (var method in notification.Methods) + { + var status = await InvokeGateway(method.Option, (gateway, option) => gateway.NotifyAsync(option, cancellationToken)); + + notification.Handle(status switch + { + NotificationMethodStatus.SentStatus => new Command.SendNotificationMethod(notification.Id, method.Id), + NotificationMethodStatus.CancelledStatus => new Command.CancelNotificationMethod(notification.Id, method.Id), + _ => new Command.FailNotificationMethod(notification.Id, method.Id) + }); + } + } + + public async Task CancelAsync(Notification notification, CancellationToken cancellationToken) + { + foreach (var method in notification.Methods) + { + var status = await InvokeGateway(method.Option, (gateway, option) => gateway.CancelAsync(option, cancellationToken)); + + // notification.Handle(status switch + // { + // // TODO + // }); + } + } + + private Task InvokeGateway(T option, Func, INotificationOption, Task> operation) + where T : class, INotificationOption + => option switch + { + Email email => operation((INotificationGateway)emailGateway.Instance, email), + Sms sms => operation((INotificationGateway)smsGateway.Instance, sms), + PushMobile pushMobile => operation((INotificationGateway)pushMobileGateway.Instance, pushMobile), + PushWeb pushWeb => operation((INotificationGateway)pushWebGateway.Instance, pushWeb), + _ => new(() => NotificationMethodStatus.Failed) + }; +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Application/Abstractions/IInteractor.cs b/src/Services/Notification/Command/Application/Abstractions/IInteractor.cs new file mode 100644 index 000000000..91bec179a --- /dev/null +++ b/src/Services/Notification/Command/Application/Abstractions/IInteractor.cs @@ -0,0 +1,9 @@ +using Contracts.Abstractions.Messages; + +namespace Application.Abstractions; + +public interface IInteractor + where TMessage : IMessage +{ + Task InteractAsync(TMessage message, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Application/Abstractions/ILazy.cs b/src/Services/Notification/Command/Application/Abstractions/ILazy.cs new file mode 100644 index 000000000..7882beb3a --- /dev/null +++ b/src/Services/Notification/Command/Application/Abstractions/ILazy.cs @@ -0,0 +1,6 @@ +namespace Application.Abstractions; + +public interface ILazy +{ + T Instance { get; } +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Application/Abstractions/IUnitOfWork.cs b/src/Services/Notification/Command/Application/Abstractions/IUnitOfWork.cs new file mode 100644 index 000000000..9a3479aed --- /dev/null +++ b/src/Services/Notification/Command/Application/Abstractions/IUnitOfWork.cs @@ -0,0 +1,6 @@ +namespace Application.Abstractions; + +public interface IUnitOfWork +{ + Task ExecuteAsync(Func operationAsync, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Application/Application.csproj b/src/Services/Notification/Command/Application/Application.csproj new file mode 100644 index 000000000..0cb882f63 --- /dev/null +++ b/src/Services/Notification/Command/Application/Application.csproj @@ -0,0 +1,18 @@ + + + + + + + ResXFileCodeGenerator + EmailResource.Designer.cs + + + + + True + True + EmailResource.resx + + + \ No newline at end of file diff --git a/src/Services/Notification/Command/Application/DependencyInjection/Extensions/ServiceCollectionExtensions.cs b/src/Services/Notification/Command/Application/DependencyInjection/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..68fde83aa --- /dev/null +++ b/src/Services/Notification/Command/Application/DependencyInjection/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +using Application.Abstractions.Gateways; +using Application.Services; +using Application.UseCases.Events; +using Microsoft.Extensions.DependencyInjection; + +namespace Application.DependencyInjection.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddApplicationServices(this IServiceCollection services) + => services + .AddScoped() + .AddScoped(); + + public static IServiceCollection AddEventInteractors(this IServiceCollection services) + => services + .AddScoped() + .AddScoped(); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Application/Resources/EmailResource.Designer.cs b/src/Services/Notification/Command/Application/Resources/EmailResource.Designer.cs new file mode 100644 index 000000000..f68f62e0d --- /dev/null +++ b/src/Services/Notification/Command/Application/Resources/EmailResource.Designer.cs @@ -0,0 +1,71 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Application.Resources { + using System; + + + ///

+ /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class EmailResource { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal EmailResource() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Application.Resources.EmailResource", typeof(EmailResource).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to <html lang="en"> <head> <meta charset="utf-8" /> <title>Confirm your email!</title> </head> <body> <h1>Confirm your email</h1> <p>Hi {0},</p> <p>Thanks for signing up! Please confirm your email address by clicking the link below:</p> <form action="{1}" method="post"> <input type="submit" value="Confirm my email"> </form> <p>Thanks!</p> </body> </html>. + /// + internal static string EmailConfirmationHtml { + get { + return ResourceManager.GetString("EmailConfirmationHtml", resourceCulture); + } + } + } +} diff --git a/src/Services/Notification/Command/Application/Resources/EmailResource.resx b/src/Services/Notification/Command/Application/Resources/EmailResource.resx new file mode 100644 index 000000000..d66698c3f --- /dev/null +++ b/src/Services/Notification/Command/Application/Resources/EmailResource.resx @@ -0,0 +1,25 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Confirm your email!

Confirm your email

Hi {0},

Thanks for signing up! Please confirm your email address by clicking the link below:

Thanks!

]]>
+
+
\ No newline at end of file diff --git a/src/Services/Notification/Command/Application/Services/ApplicationService.cs b/src/Services/Notification/Command/Application/Services/ApplicationService.cs new file mode 100644 index 000000000..2964324ca --- /dev/null +++ b/src/Services/Notification/Command/Application/Services/ApplicationService.cs @@ -0,0 +1,34 @@ +using Application.Abstractions; +using Application.Abstractions.Gateways; +using Contracts.Abstractions.Messages; +using Domain.Abstractions.Aggregates; + +namespace Application.Services; + +public class ApplicationService(IEventStoreGateway eventStoreGateway, + IEventBusGateway eventBusGateway, + IUnitOfWork unitOfWork) + : IApplicationService +{ + public Task LoadAggregateAsync(Guid id, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot, new() + => eventStoreGateway.LoadAggregateAsync(id, cancellationToken); + + public Task AppendEventsAsync(IAggregateRoot aggregate, CancellationToken cancellationToken) + => unitOfWork.ExecuteAsync( + operationAsync: async ct => + { + await eventStoreGateway.AppendEventsAsync(aggregate, ct); + await eventBusGateway.PublishAsync(aggregate.UncommittedEvents, ct); + }, + cancellationToken: cancellationToken); + + public IAsyncEnumerable StreamAggregatesId() + => eventStoreGateway.StreamAggregatesId(); + + public Task PublishEventAsync(IEvent @event, CancellationToken cancellationToken) + => eventBusGateway.PublishAsync(@event, cancellationToken); + + public Task SchedulePublishAsync(IDelayedEvent @event, DateTimeOffset scheduledTime, CancellationToken cancellationToken) + => eventBusGateway.SchedulePublishAsync(@event, scheduledTime, cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Application/Services/IApplicationService.cs b/src/Services/Notification/Command/Application/Services/IApplicationService.cs new file mode 100644 index 000000000..a4e09ca8a --- /dev/null +++ b/src/Services/Notification/Command/Application/Services/IApplicationService.cs @@ -0,0 +1,13 @@ +using Contracts.Abstractions.Messages; +using Domain.Abstractions.Aggregates; + +namespace Application.Services; + +public interface IApplicationService +{ + Task AppendEventsAsync(IAggregateRoot aggregate, CancellationToken cancellationToken); + Task LoadAggregateAsync(Guid id, CancellationToken cancellationToken) where TAggregate : IAggregateRoot, new(); + IAsyncEnumerable StreamAggregatesId(); + Task PublishEventAsync(IEvent @event, CancellationToken cancellationToken); + Task SchedulePublishAsync(IDelayedEvent @event, DateTimeOffset scheduledTime, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Application/UseCases/Events/RequestNotificationWhenUserRegisteredInteractor.cs b/src/Services/Notification/Command/Application/UseCases/Events/RequestNotificationWhenUserRegisteredInteractor.cs new file mode 100644 index 000000000..51c2a7ebe --- /dev/null +++ b/src/Services/Notification/Command/Application/UseCases/Events/RequestNotificationWhenUserRegisteredInteractor.cs @@ -0,0 +1,32 @@ +using Application.Abstractions; +using Application.Resources; +using Application.Services; +using Contracts.Boundaries.Identity; +using Contracts.DataTransferObjects; +using Domain.Aggregates; +using Command = Contracts.Boundaries.Notification.Command; + +namespace Application.UseCases.Events; + +public interface IRequestNotificationWhenUserRegisteredInteractor : IInteractor; + +public class RequestNotificationWhenUserRegisteredInteractor(IApplicationService service) : IRequestNotificationWhenUserRegisteredInteractor +{ + public async Task InteractAsync(DomainEvent.UserRegistered @event, CancellationToken cancellationToken) + { + Notification notification = new(); + var methods = DefineMethods(@event.UserId, @event.FirstName, @event.Email); + var command = new Command.RequestNotification(methods); + notification.Handle(command); + await service.AppendEventsAsync(notification, cancellationToken); + } + + private static IEnumerable DefineMethods(Guid userId, string firstName, string address) + => new[] { new Dto.NotificationMethod(Guid.NewGuid(), new Dto.Email(address, $"Welcome {firstName}!", FormatBody(userId, firstName, address))) }; + + private static string FormatBody(Guid userid, string firstname, string email) + => string.Format( + format: EmailResource.EmailConfirmationHtml, + arg0: firstname, + arg1: $"http://localhost:5000/api/v1/identities/{userid}/confirm-email?email={email}"); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Application/UseCases/Events/SendNotificationWhenNotificationRequestedInteractor.cs b/src/Services/Notification/Command/Application/UseCases/Events/SendNotificationWhenNotificationRequestedInteractor.cs new file mode 100644 index 000000000..4a56ce715 --- /dev/null +++ b/src/Services/Notification/Command/Application/UseCases/Events/SendNotificationWhenNotificationRequestedInteractor.cs @@ -0,0 +1,20 @@ +using Application.Abstractions; +using Application.Abstractions.Gateways; +using Application.Services; +using Contracts.Boundaries.Notification; +using Domain.Aggregates; + +namespace Application.UseCases.Events; + +public interface ISendNotificationWhenNotificationRequestedInteractor : IInteractor { } + +public class SendNotificationWhenNotificationRequestedInteractor(IApplicationService service, INotificationService notificationService) + : ISendNotificationWhenNotificationRequestedInteractor +{ + public async Task InteractAsync(DomainEvent.NotificationRequested @event, CancellationToken cancellationToken) + { + var notification = await service.LoadAggregateAsync(@event.NotificationId, cancellationToken); + await notificationService.NotifyAsync(notification, cancellationToken); + await service.AppendEventsAsync(notification, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Domain/Abstractions/Aggregates/AggregateRoot.cs b/src/Services/Notification/Command/Domain/Abstractions/Aggregates/AggregateRoot.cs new file mode 100644 index 000000000..d72167b26 --- /dev/null +++ b/src/Services/Notification/Command/Domain/Abstractions/Aggregates/AggregateRoot.cs @@ -0,0 +1,43 @@ +using Contracts.Abstractions.Messages; +using Domain.Abstractions.Entities; +using FluentValidation; +using Newtonsoft.Json; + +namespace Domain.Abstractions.Aggregates; + +public abstract class AggregateRoot : Entity, IAggregateRoot + where TValidator : IValidator, new() +{ + private readonly List _events = new(); + + public uint Version { get; private set; } + + [JsonIgnore] + public IEnumerable UncommittedEvents + => _events.AsReadOnly(); + + public void LoadFromHistory(IEnumerable events) + { + foreach (var @event in events) + { + Apply(@event); + Version = @event.Version; + } + } + + public abstract void Handle(ICommand command); + + protected void RaiseEvent(Func func) where TEvent : IDomainEvent + => RaiseEvent((func as Func)!); + + protected void RaiseEvent(FunconRaise) + { + Version++; + var @event = onRaise(Version); + Apply(@event); + Validate(); + _events.Add(@event); + } + + protected abstract void Apply(IDomainEvent @event); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Domain/Abstractions/Aggregates/IAggregateRoot.cs b/src/Services/Notification/Command/Domain/Abstractions/Aggregates/IAggregateRoot.cs new file mode 100644 index 000000000..6d070a93a --- /dev/null +++ b/src/Services/Notification/Command/Domain/Abstractions/Aggregates/IAggregateRoot.cs @@ -0,0 +1,11 @@ +using Contracts.Abstractions.Messages; +using Domain.Abstractions.Entities; + +namespace Domain.Abstractions.Aggregates; + +public interface IAggregateRoot : IEntity +{ + IEnumerable UncommittedEvents { get; } + void LoadFromHistory(IEnumerable events); + void Handle(ICommand command); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Domain/Abstractions/Entities/Entity.cs b/src/Services/Notification/Command/Domain/Abstractions/Entities/Entity.cs new file mode 100644 index 000000000..f2c55684e --- /dev/null +++ b/src/Services/Notification/Command/Domain/Abstractions/Entities/Entity.cs @@ -0,0 +1,18 @@ +using FluentValidation; +using Newtonsoft.Json; + +namespace Domain.Abstractions.Entities; + +public abstract class Entity : IEntity + where TValidator : IValidator, new() +{ + [JsonIgnore] + private readonly TValidator _validator = new(); + + public Guid Id { get; protected set; } + public bool IsDeleted { get; protected set; } + + protected void Validate() + => _validator.Validate(ValidationContext.CreateWithOptions(this, strategy + => strategy.ThrowOnFailures())); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Domain/Abstractions/Entities/IEntity.cs b/src/Services/Notification/Command/Domain/Abstractions/Entities/IEntity.cs new file mode 100644 index 000000000..851f81283 --- /dev/null +++ b/src/Services/Notification/Command/Domain/Abstractions/Entities/IEntity.cs @@ -0,0 +1,7 @@ +namespace Domain.Abstractions.Entities; + +public interface IEntity +{ + Guid Id { get; } + bool IsDeleted { get; } +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Domain/Abstractions/EventStore/IEventStoreRepository.cs b/src/Services/Notification/Command/Domain/Abstractions/EventStore/IEventStoreRepository.cs new file mode 100644 index 000000000..dce4d87ed --- /dev/null +++ b/src/Services/Notification/Command/Domain/Abstractions/EventStore/IEventStoreRepository.cs @@ -0,0 +1,12 @@ +using Contracts.Abstractions.Messages; + +namespace Domain.Abstractions.EventStore; + +public interface IEventStoreRepository +{ + Task AppendEventAsync(StoreEvent storeEvent, CancellationToken cancellationToken); + Task AppendSnapshotAsync(Snapshot snapshot, CancellationToken cancellationToken); + Task> GetStreamAsync(Guid aggregateId, ulong? version, CancellationToken cancellationToken); + Task GetSnapshotAsync(Guid aggregateId, CancellationToken cancellationToken); + IAsyncEnumerable StreamAggregatesId(); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Domain/Abstractions/EventStore/Snapshot.cs b/src/Services/Notification/Command/Domain/Abstractions/EventStore/Snapshot.cs new file mode 100644 index 000000000..7154333dd --- /dev/null +++ b/src/Services/Notification/Command/Domain/Abstractions/EventStore/Snapshot.cs @@ -0,0 +1,9 @@ +using Domain.Abstractions.Aggregates; + +namespace Domain.Abstractions.EventStore; + +public record Snapshot(Guid AggregateId, string AggregateType, IAggregateRoot Aggregate, ulong Version, DateTimeOffset Timestamp) +{ + public static Snapshot Create(IAggregateRoot aggregate, StoreEvent @event) + => new(aggregate.Id, aggregate.GetType().Name, aggregate, @event.Version, @event.Timestamp); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Domain/Abstractions/EventStore/StoreEvent.cs b/src/Services/Notification/Command/Domain/Abstractions/EventStore/StoreEvent.cs new file mode 100644 index 000000000..0f8e5c976 --- /dev/null +++ b/src/Services/Notification/Command/Domain/Abstractions/EventStore/StoreEvent.cs @@ -0,0 +1,10 @@ +using Contracts.Abstractions.Messages; +using Domain.Abstractions.Aggregates; + +namespace Domain.Abstractions.EventStore; + +public record StoreEvent(Guid AggregateId, string AggregateType, string EventType, IDomainEvent Event, ulong Version, DateTimeOffset Timestamp) +{ + public static StoreEvent Create(IAggregateRoot aggregate, IDomainEvent @event) + => new(aggregate.Id, aggregate.GetType().Name, @event.GetType().Name, @event, @event.Version, @event.Timestamp); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Domain/Aggregates/Notification.cs b/src/Services/Notification/Command/Domain/Aggregates/Notification.cs new file mode 100644 index 000000000..f3ab90612 --- /dev/null +++ b/src/Services/Notification/Command/Domain/Aggregates/Notification.cs @@ -0,0 +1,61 @@ +using Contracts.Abstractions.Messages; +using Contracts.Boundaries.Notification; +using Domain.Abstractions.Aggregates; +using Domain.Entities; +using Domain.Enumerations; +using Newtonsoft.Json; +using DomainEvent = Contracts.Boundaries.Notification.DomainEvent; + +namespace Domain.Aggregates; + +public class Notification : AggregateRoot +{ + [JsonProperty] + private readonly List _methods = new(); + + public IEnumerable Methods + => _methods.AsReadOnly(); + + public override void Handle(ICommand command) + => Handle(command as dynamic); + + private void Handle(Command.RequestNotification cmd) + => RaiseEvent(version => new(Guid.NewGuid(), cmd.Methods, version)); + + private void Handle(Command.SendNotificationMethod cmd) + { + if (_methods.SingleOrDefault(method => method.Id == cmd.MethodId) is not { IsDeleted: false } notificationMethod) return; + if (notificationMethod.Status != NotificationMethodStatus.Pending) return; + RaiseEvent(version => new(cmd.NotificationId, cmd.MethodId, version)); + } + + private void Handle(Command.FailNotificationMethod cmd) + => RaiseEvent(version => new(cmd.NotificationId, cmd.MethodId, version)); + + private void Handle(Command.CancelNotificationMethod cmd) + => RaiseEvent(version => new(cmd.NotificationId, cmd.MethodId, version)); + + private void Handle(Command.ResetNotificationMethod cmd) + => RaiseEvent(version => new(cmd.NotificationId, cmd.MethodId, version)); + + protected override void Apply(IDomainEvent @event) + => When(@event as dynamic); + + private void When(DomainEvent.NotificationRequested @event) + { + Id = @event.NotificationId; + _methods.AddRange(@event.Methods.Select(method => (NotificationMethod)method)); + } + + private void When(DomainEvent.NotificationMethodSent @event) + => _methods.First(m => m.Id == @event.MethodId).Send(); + + private void When(DomainEvent.NotificationMethodFailed @event) + => _methods.First(m => m.Id == @event.MethodId).Fail(); + + private void When(DomainEvent.NotificationMethodCancelled @event) + => _methods.First(m => m.Id == @event.MethodId).Cancel(); + + private void When(DomainEvent.NotificationMethodReset @event) + => _methods.First(m => m.Id == @event.MethodId).Reset(); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Domain/Aggregates/NotificationValidator.cs b/src/Services/Notification/Command/Domain/Aggregates/NotificationValidator.cs new file mode 100644 index 000000000..725f91090 --- /dev/null +++ b/src/Services/Notification/Command/Domain/Aggregates/NotificationValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Domain.Aggregates; + +public class NotificationValidator : AbstractValidator +{ + public NotificationValidator() + { + RuleFor(notification => notification.Id) + .NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Domain/Domain.csproj b/src/Services/Notification/Command/Domain/Domain.csproj new file mode 100644 index 000000000..b74f284b0 --- /dev/null +++ b/src/Services/Notification/Command/Domain/Domain.csproj @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/Services/Notification/Command/Domain/Entities/NotificationMethod.cs b/src/Services/Notification/Command/Domain/Entities/NotificationMethod.cs new file mode 100644 index 000000000..806705e3a --- /dev/null +++ b/src/Services/Notification/Command/Domain/Entities/NotificationMethod.cs @@ -0,0 +1,41 @@ +using Contracts.DataTransferObjects; +using Domain.Abstractions.Entities; +using Domain.Enumerations; +using Domain.ValueObject; + +namespace Domain.Entities; + +public class NotificationMethod : Entity +{ + public NotificationMethod(Guid id, INotificationOption option) + { + Id = id; + Option = option; + Status = NotificationMethodStatus.Pending; + } + + public INotificationOption Option { get; } + public NotificationMethodStatus Status { get; private set; } + + public void Send() + => Status = NotificationMethodStatus.Sent; + + public void Fail() + => Status = NotificationMethodStatus.Failed; + + public void Cancel() + => Status = NotificationMethodStatus.Cancelled; + + public void Reset() + => Status = NotificationMethodStatus.Pending; + + public static implicit operator NotificationMethod(Dto.NotificationMethod method) + => new(method.Id, method.Option switch + { + Dto.Email email => (Email)email, + Dto.Sms sms => (Sms)sms, + Dto.PushMobile pushMobile => (PushMobile)pushMobile, + Dto.PushWeb pushWeb => (PushWeb)pushWeb, + _ => throw new NotImplementedException() + }); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Domain/Entities/NotificationMethodValidator.cs b/src/Services/Notification/Command/Domain/Entities/NotificationMethodValidator.cs new file mode 100644 index 000000000..6a0eba125 --- /dev/null +++ b/src/Services/Notification/Command/Domain/Entities/NotificationMethodValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Domain.Entities; + +public class NotificationMethodValidator : AbstractValidator +{ + public NotificationMethodValidator() + { + RuleFor(method => method.Id) + .NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Domain/Enumerations/NotificationMethodStatus.cs b/src/Services/Notification/Command/Domain/Enumerations/NotificationMethodStatus.cs new file mode 100644 index 000000000..073019179 --- /dev/null +++ b/src/Services/Notification/Command/Domain/Enumerations/NotificationMethodStatus.cs @@ -0,0 +1,36 @@ +using Ardalis.SmartEnum; +using Newtonsoft.Json; + +namespace Domain.Enumerations; + +public class NotificationMethodStatus : SmartEnum +{ + [JsonConstructor] + private NotificationMethodStatus(string name, int value) + : base(name, value) { } + + public static readonly NotificationMethodStatus Pending = new PendingStatus(); + public static readonly NotificationMethodStatus Cancelled = new CancelledStatus(); + public static readonly NotificationMethodStatus Sent = new SentStatus(); + public static readonly NotificationMethodStatus Failed = new FailedStatus(); + + public static implicit operator NotificationMethodStatus(string name) + => FromName(name); + + public static implicit operator NotificationMethodStatus(int value) + => FromValue(value); + + public static implicit operator string(NotificationMethodStatus status) + => status.Name; + + public static implicit operator int(NotificationMethodStatus status) + => status.Value; + + public class PendingStatus() : NotificationMethodStatus(nameof(Pending), 1); + + public class CancelledStatus() : NotificationMethodStatus(nameof(Cancelled), 2); + + public class SentStatus() : NotificationMethodStatus(nameof(Sent), 3); + + public class FailedStatus() : NotificationMethodStatus(nameof(Failed), 4); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Domain/Enumerations/NotificationStatus.cs b/src/Services/Notification/Command/Domain/Enumerations/NotificationStatus.cs new file mode 100644 index 000000000..15422819a --- /dev/null +++ b/src/Services/Notification/Command/Domain/Enumerations/NotificationStatus.cs @@ -0,0 +1,28 @@ +using Ardalis.SmartEnum; +using Newtonsoft.Json; + +namespace Domain.Enumerations; + +public class NotificationStatus : SmartEnum +{ + [JsonConstructor] + private NotificationStatus(string name, int value) + : base(name, value) { } + + public static readonly NotificationStatus Active = new(nameof(Active), 1); + public static readonly NotificationStatus Completed = new(nameof(Completed), 2); + public static readonly NotificationStatus Failed = new(nameof(Failed), 3); + public static readonly NotificationStatus Canceled = new(nameof(Canceled), 4); + + public static implicit operator NotificationStatus(string name) + => FromName(name); + + public static implicit operator NotificationStatus(int value) + => FromValue(value); + + public static implicit operator string(NotificationStatus status) + => status.Name; + + public static implicit operator int(NotificationStatus status) + => status.Value; +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Domain/ValueObject/Email.cs b/src/Services/Notification/Command/Domain/ValueObject/Email.cs new file mode 100644 index 000000000..83e7a4f51 --- /dev/null +++ b/src/Services/Notification/Command/Domain/ValueObject/Email.cs @@ -0,0 +1,9 @@ +using Contracts.DataTransferObjects; + +namespace Domain.ValueObject; + +public record Email(string Address, string Subject, string Body) : INotificationOption +{ + public static implicit operator Email(Dto.Email email) + => new(email.Address, email.Subject, email.Body); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Domain/ValueObject/INotificationOption.cs b/src/Services/Notification/Command/Domain/ValueObject/INotificationOption.cs new file mode 100644 index 000000000..73f44169b --- /dev/null +++ b/src/Services/Notification/Command/Domain/ValueObject/INotificationOption.cs @@ -0,0 +1,3 @@ +namespace Domain.ValueObject; + +public interface INotificationOption; \ No newline at end of file diff --git a/src/Services/Notification/Command/Domain/ValueObject/PushMobile.cs b/src/Services/Notification/Command/Domain/ValueObject/PushMobile.cs new file mode 100644 index 000000000..4b9f8087e --- /dev/null +++ b/src/Services/Notification/Command/Domain/ValueObject/PushMobile.cs @@ -0,0 +1,12 @@ +using Contracts.DataTransferObjects; + +namespace Domain.ValueObject; + +public record PushMobile(Guid DeviceId) : INotificationOption +{ + public static implicit operator PushMobile(Guid deviceId) + => new(deviceId); + + public static implicit operator PushMobile(Dto.PushMobile push) + => new(push.DeviceId); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Domain/ValueObject/PushWeb.cs b/src/Services/Notification/Command/Domain/ValueObject/PushWeb.cs new file mode 100644 index 000000000..38d5bea92 --- /dev/null +++ b/src/Services/Notification/Command/Domain/ValueObject/PushWeb.cs @@ -0,0 +1,12 @@ +using Contracts.DataTransferObjects; + +namespace Domain.ValueObject; + +public record PushWeb(Guid UserId) : INotificationOption +{ + public static implicit operator PushWeb(Guid userId) + => new(userId); + + public static implicit operator PushWeb(Dto.PushWeb push) + => new(push.UserId); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Domain/ValueObject/Sms.cs b/src/Services/Notification/Command/Domain/ValueObject/Sms.cs new file mode 100644 index 000000000..b1258c6d6 --- /dev/null +++ b/src/Services/Notification/Command/Domain/ValueObject/Sms.cs @@ -0,0 +1,12 @@ +using Contracts.DataTransferObjects; + +namespace Domain.ValueObject; + +public record Sms(string Number) : INotificationOption +{ + public static implicit operator Sms(string number) + => new(number); + + public static implicit operator Sms(Dto.Sms sms) + => new(sms.Number); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Infrastructure.EventStore/Contexts/Configurations/SnapshotConfiguration.cs b/src/Services/Notification/Command/Infrastructure.EventStore/Contexts/Configurations/SnapshotConfiguration.cs new file mode 100644 index 000000000..8e6472319 --- /dev/null +++ b/src/Services/Notification/Command/Infrastructure.EventStore/Contexts/Configurations/SnapshotConfiguration.cs @@ -0,0 +1,35 @@ +using Domain.Abstractions.EventStore; +using Infrastructure.EventStore.Contexts.Converters; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Infrastructure.EventStore.Contexts.Configurations; + +public class SnapshotConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(snapshot => new { snapshot.Version, snapshot.AggregateId }); + + builder + .Property(snapshot => snapshot.Version) + .IsRequired(); + + builder + .Property(snapshot => snapshot.AggregateId) + .IsRequired(); + + builder + .Property(snapshot => snapshot.AggregateType) + .HasMaxLength(30) + .IsRequired(); + + builder.Property(snapshot => snapshot.Timestamp) + .IsRequired(); + + builder + .Property(snapshot => snapshot.Aggregate) + .HasConversion() + .IsRequired(); + } +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Infrastructure.EventStore/Contexts/Configurations/StoreEventConfiguration.cs b/src/Services/Notification/Command/Infrastructure.EventStore/Contexts/Configurations/StoreEventConfiguration.cs new file mode 100644 index 000000000..bdeff9b0e --- /dev/null +++ b/src/Services/Notification/Command/Infrastructure.EventStore/Contexts/Configurations/StoreEventConfiguration.cs @@ -0,0 +1,41 @@ +using Domain.Abstractions.EventStore; +using Infrastructure.EventStore.Contexts.Converters; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Infrastructure.EventStore.Contexts.Configurations; + +public class StoreEventConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(storeEvent => new { storeEvent.Version, storeEvent.AggregateId }); + + builder + .Property(storeEvent => storeEvent.Version) + .IsRequired(); + + builder + .Property(storeEvent => storeEvent.AggregateId) + .IsRequired(); + + builder + .Property(storeEvent => storeEvent.AggregateType) + .HasMaxLength(30) + .IsRequired(); + + builder + .Property(storeEvent => storeEvent.EventType) + .HasMaxLength(50) + .IsRequired(); + + builder + .Property(storeEvent => storeEvent.Timestamp) + .IsRequired(); + + builder + .Property(storeEvent => storeEvent.Event) + .HasConversion() + .IsRequired(); + } +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Infrastructure.EventStore/Contexts/Converters/AggregateConverter.cs b/src/Services/Notification/Command/Infrastructure.EventStore/Contexts/Converters/AggregateConverter.cs new file mode 100644 index 000000000..634eaf786 --- /dev/null +++ b/src/Services/Notification/Command/Infrastructure.EventStore/Contexts/Converters/AggregateConverter.cs @@ -0,0 +1,38 @@ +using Contracts.JsonConverters; +using Domain.Abstractions.Aggregates; +using JsonNet.ContractResolvers; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Newtonsoft.Json; + +namespace Infrastructure.EventStore.Contexts.Converters; + +public class AggregateConverter() : ValueConverter(@event => JsonConvert.SerializeObject(@event, typeof(IAggregateRoot), SerializerSettings()), + jsonString => JsonConvert.DeserializeObject(jsonString, DeserializerSettings())) +{ + private static JsonSerializerSettings SerializerSettings() + { + JsonSerializerSettings jsonSerializerSettings = new() + { + TypeNameHandling = TypeNameHandling.Auto + }; + + jsonSerializerSettings.Converters.Add(new DateOnlyJsonConverter()); + jsonSerializerSettings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + + return jsonSerializerSettings; + } + + private static JsonSerializerSettings DeserializerSettings() + { + JsonSerializerSettings jsonDeserializerSettings = new() + { + TypeNameHandling = TypeNameHandling.Auto, + ContractResolver = new PrivateSetterContractResolver() + }; + + jsonDeserializerSettings.Converters.Add(new DateOnlyJsonConverter()); + jsonDeserializerSettings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + + return jsonDeserializerSettings; + } +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Infrastructure.EventStore/Contexts/Converters/EventConverter.cs b/src/Services/Notification/Command/Infrastructure.EventStore/Contexts/Converters/EventConverter.cs new file mode 100644 index 000000000..bfdbdb50d --- /dev/null +++ b/src/Services/Notification/Command/Infrastructure.EventStore/Contexts/Converters/EventConverter.cs @@ -0,0 +1,38 @@ +using Contracts.Abstractions.Messages; +using Contracts.JsonConverters; +using JsonNet.ContractResolvers; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Newtonsoft.Json; + +namespace Infrastructure.EventStore.Contexts.Converters; + +public class EventConverter() : ValueConverter(@event => JsonConvert.SerializeObject(@event, typeof(IDomainEvent), SerializerSettings()), + jsonString => JsonConvert.DeserializeObject(jsonString, DeserializerSettings())) +{ + private static JsonSerializerSettings SerializerSettings() + { + JsonSerializerSettings jsonSerializerSettings = new() + { + TypeNameHandling = TypeNameHandling.Auto + }; + + jsonSerializerSettings.Converters.Add(new DateOnlyJsonConverter()); + jsonSerializerSettings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + + return jsonSerializerSettings; + } + + private static JsonSerializerSettings DeserializerSettings() + { + JsonSerializerSettings jsonDeserializerSettings = new() + { + TypeNameHandling = TypeNameHandling.Auto, + ContractResolver = new PrivateSetterContractResolver() + }; + + jsonDeserializerSettings.Converters.Add(new DateOnlyJsonConverter()); + jsonDeserializerSettings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + + return jsonDeserializerSettings; + } +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Infrastructure.EventStore/Contexts/EventStoreDbContext.cs b/src/Services/Notification/Command/Infrastructure.EventStore/Contexts/EventStoreDbContext.cs new file mode 100644 index 000000000..ae9033a8d --- /dev/null +++ b/src/Services/Notification/Command/Infrastructure.EventStore/Contexts/EventStoreDbContext.cs @@ -0,0 +1,19 @@ +using Domain.Abstractions.EventStore; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.EventStore.Contexts; + +public class EventStoreDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet? Events { get; set; } + public DbSet? Snapshots { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.ApplyConfigurationsFromAssembly(typeof(EventStoreDbContext).Assembly); + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + => configurationBuilder + .Properties() + .AreUnicode(false) + .HaveMaxLength(1024); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Infrastructure.EventStore/DependencyInjection/Extensions/ServiceCollectionExtensions.cs b/src/Services/Notification/Command/Infrastructure.EventStore/DependencyInjection/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..b70bef01f --- /dev/null +++ b/src/Services/Notification/Command/Infrastructure.EventStore/DependencyInjection/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,55 @@ +using Application.Abstractions; +using Application.Abstractions.Gateways; +using Domain.Abstractions.EventStore; +using Infrastructure.EventStore.Contexts; +using Infrastructure.EventStore.DependencyInjection.Options; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Infrastructure.EventStore.DependencyInjection.Extensions; + +public static class ServiceCollectionExtensions +{ + public static void AddEventStore(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddDbContextPool((provider, builder) => + { + var configuration = provider.GetRequiredService(); + var options = provider.GetRequiredService>().Value; + + builder + .EnableDetailedErrors() + .EnableSensitiveDataLogging() + .UseSqlServer( + connectionString: configuration.GetConnectionString("EventStore"), + sqlServerOptionsAction: optionsBuilder + => optionsBuilder.ExecutionStrategy( + dependencies => new SqlServerRetryingExecutionStrategy( + dependencies: dependencies, + maxRetryCount: options.MaxRetryCount, + maxRetryDelay: options.MaxRetryDelay, + errorNumbersToAdd: options.ErrorNumbersToAdd)) + .MigrationsAssembly(typeof(EventStoreDbContext).Assembly.GetName().Name)); + }); + } + + public static OptionsBuilder ConfigureSqlServerRetryOptions(this IServiceCollection services, IConfigurationSection section) + => services + .AddOptions() + .Bind(section) + .ValidateDataAnnotations() + .ValidateOnStart(); + + public static OptionsBuilder ConfigureEventStoreOptions(this IServiceCollection services, IConfigurationSection section) + => services + .AddOptions() + .Bind(section) + .ValidateDataAnnotations() + .ValidateOnStart(); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Infrastructure.EventStore/DependencyInjection/Options/EventStoreOptions.cs b/src/Services/Notification/Command/Infrastructure.EventStore/DependencyInjection/Options/EventStoreOptions.cs new file mode 100644 index 000000000..c4cd43fa3 --- /dev/null +++ b/src/Services/Notification/Command/Infrastructure.EventStore/DependencyInjection/Options/EventStoreOptions.cs @@ -0,0 +1,8 @@ +using System.ComponentModel.DataAnnotations; + +namespace Infrastructure.EventStore.DependencyInjection.Options; + +public record EventStoreOptions +{ + [Required, Range(3, 100)] public ulong SnapshotInterval { get; init; } +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Infrastructure.EventStore/DependencyInjection/Options/SqlServerRetryingOptions.cs b/src/Services/Notification/Command/Infrastructure.EventStore/DependencyInjection/Options/SqlServerRetryingOptions.cs new file mode 100644 index 000000000..c69326451 --- /dev/null +++ b/src/Services/Notification/Command/Infrastructure.EventStore/DependencyInjection/Options/SqlServerRetryingOptions.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace Infrastructure.EventStore.DependencyInjection.Options; + +public record SqlServerRetryOptions +{ + [Required, Range(5, 20)] public int MaxRetryCount { get; init; } + [Required, Timestamp] public TimeSpan MaxRetryDelay { get; init; } + public int[]? ErrorNumbersToAdd { get; init; } +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Infrastructure.EventStore/EventStoreGateway.cs b/src/Services/Notification/Command/Infrastructure.EventStore/EventStoreGateway.cs new file mode 100644 index 000000000..4f3075bd5 --- /dev/null +++ b/src/Services/Notification/Command/Infrastructure.EventStore/EventStoreGateway.cs @@ -0,0 +1,44 @@ +using Application.Abstractions.Gateways; +using Domain.Abstractions.Aggregates; +using Domain.Abstractions.EventStore; +using Infrastructure.EventStore.DependencyInjection.Options; +using Infrastructure.EventStore.Exceptions; +using Microsoft.Extensions.Options; + +namespace Infrastructure.EventStore; + +public class EventStoreGateway(IEventStoreRepository repository, IOptions options) + : IEventStoreGateway +{ + private readonly EventStoreOptions _options = options.Value; + + public async Task AppendEventsAsync(IAggregateRoot aggregate, CancellationToken cancellationToken) + { + foreach (var @event in aggregate.UncommittedEvents.Select(@event => StoreEvent.Create(aggregate, @event))) + { + await repository.AppendEventAsync(@event, cancellationToken); + + if (@event.Version % _options.SnapshotInterval is 0) + await repository.AppendSnapshotAsync(Snapshot.Create(aggregate, @event), cancellationToken); + } + } + + public async Task LoadAggregateAsync(Guid aggregateId, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot, new() + { + var snapshot = await repository.GetSnapshotAsync(aggregateId, cancellationToken); + var events = await repository.GetStreamAsync(aggregateId, snapshot?.Version, cancellationToken); + + if (snapshot is null && events is { Count: 0 }) + throw new AggregateNotFoundException(aggregateId, typeof(TAggregate)); + + var aggregate = snapshot?.Aggregate ?? new TAggregate(); + + aggregate.LoadFromHistory(events); + + return (TAggregate)aggregate; + } + + public IAsyncEnumerable StreamAggregatesId() + => repository.StreamAggregatesId(); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Infrastructure.EventStore/EventStoreRepository.cs b/src/Services/Notification/Command/Infrastructure.EventStore/EventStoreRepository.cs new file mode 100644 index 000000000..40f1ab848 --- /dev/null +++ b/src/Services/Notification/Command/Infrastructure.EventStore/EventStoreRepository.cs @@ -0,0 +1,43 @@ +using Contracts.Abstractions.Messages; +using Domain.Abstractions.EventStore; +using Infrastructure.EventStore.Contexts; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.EventStore; + +public class EventStoreRepository(EventStoreDbContext dbContext) : IEventStoreRepository +{ + public async Task AppendEventAsync(StoreEvent storeEvent, CancellationToken cancellationToken) + { + await dbContext.Set().AddAsync(storeEvent, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task AppendSnapshotAsync(Snapshot snapshot, CancellationToken cancellationToken) + { + await dbContext.Set().AddAsync(snapshot, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } + + public Task> GetStreamAsync(Guid aggregateId, ulong? version, CancellationToken cancellationToken) + => dbContext.Set() + .AsNoTracking() + .Where(@event => @event.AggregateId.Equals(aggregateId)) + .Where(@event => @event.Version > (version ?? 0)) + .Select(@event => @event.Event) + .ToListAsync(cancellationToken); + + public Task GetSnapshotAsync(Guid aggregateId, CancellationToken cancellationToken) + => dbContext.Set() + .AsNoTracking() + .Where(snapshot => snapshot.AggregateId.Equals(aggregateId)) + .OrderByDescending(snapshot => snapshot.Version) + .FirstOrDefaultAsync(cancellationToken); + + public IAsyncEnumerable StreamAggregatesId() + => dbContext.Set() + .AsNoTracking() + .Select(@event => @event.AggregateId) + .Distinct() + .AsAsyncEnumerable(); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Infrastructure.EventStore/Exceptions/AggregateNotFoundException.cs b/src/Services/Notification/Command/Infrastructure.EventStore/Exceptions/AggregateNotFoundException.cs new file mode 100644 index 000000000..63d32a051 --- /dev/null +++ b/src/Services/Notification/Command/Infrastructure.EventStore/Exceptions/AggregateNotFoundException.cs @@ -0,0 +1,5 @@ +using System.Reflection; + +namespace Infrastructure.EventStore.Exceptions; + +public class AggregateNotFoundException(Guid aggregateId, MemberInfo aggregateType) : Exception($"{aggregateType.Name} with id {aggregateId} not found"); \ No newline at end of file diff --git a/src/Services/Notification/Command/Infrastructure.EventStore/Infrastructure.EventStore.csproj b/src/Services/Notification/Command/Infrastructure.EventStore/Infrastructure.EventStore.csproj new file mode 100644 index 000000000..7d36ab483 --- /dev/null +++ b/src/Services/Notification/Command/Infrastructure.EventStore/Infrastructure.EventStore.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Services/Notification/Command/Infrastructure.EventStore/Migrations/20230213214413_First Migration.Designer.cs b/src/Services/Notification/Command/Infrastructure.EventStore/Migrations/20230213214413_First Migration.Designer.cs new file mode 100644 index 000000000..ca8f39651 --- /dev/null +++ b/src/Services/Notification/Command/Infrastructure.EventStore/Migrations/20230213214413_First Migration.Designer.cs @@ -0,0 +1,88 @@ +// +using System; +using Infrastructure.EventStore.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Infrastructure.EventStore.Migrations +{ + [DbContext(typeof(EventStoreDbContext))] + [Migration("20230213214413_First Migration")] + partial class FirstMigration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Domain.Abstractions.EventStore.Snapshot", b => + { + b.Property("Version") + .HasColumnType("bigint"); + + b.Property("AggregateId") + .HasColumnType("uniqueidentifier"); + + b.Property("Aggregate") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(30) + .IsUnicode(false) + .HasColumnType("varchar(30)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Version", "AggregateId"); + + b.ToTable("Snapshots"); + }); + + modelBuilder.Entity("Domain.Abstractions.EventStore.StoreEvent", b => + { + b.Property("Version") + .HasColumnType("bigint"); + + b.Property("AggregateId") + .HasColumnType("uniqueidentifier"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(30) + .IsUnicode(false) + .HasColumnType("varchar(30)"); + + b.Property("Event") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Version", "AggregateId"); + + b.ToTable("Events"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Notification/Command/Infrastructure.EventStore/Migrations/20230213214413_First Migration.cs b/src/Services/Notification/Command/Infrastructure.EventStore/Migrations/20230213214413_First Migration.cs new file mode 100644 index 000000000..05f6b88fc --- /dev/null +++ b/src/Services/Notification/Command/Infrastructure.EventStore/Migrations/20230213214413_First Migration.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.EventStore.Migrations +{ + /// + public partial class FirstMigration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Events", + columns: table => new + { + AggregateId = table.Column(type: "uniqueidentifier", nullable: false), + Version = table.Column(type: "bigint", nullable: false), + AggregateType = table.Column(type: "varchar(30)", unicode: false, maxLength: 30, nullable: false), + EventType = table.Column(type: "varchar(50)", unicode: false, maxLength: 50, nullable: false), + Event = table.Column(type: "nvarchar(max)", nullable: false), + Timestamp = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Events", x => new { x.Version, x.AggregateId }); + }); + + migrationBuilder.CreateTable( + name: "Snapshots", + columns: table => new + { + AggregateId = table.Column(type: "uniqueidentifier", nullable: false), + Version = table.Column(type: "bigint", nullable: false), + AggregateType = table.Column(type: "varchar(30)", unicode: false, maxLength: 30, nullable: false), + Aggregate = table.Column(type: "nvarchar(max)", nullable: false), + Timestamp = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Snapshots", x => new { x.Version, x.AggregateId }); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Events"); + + migrationBuilder.DropTable( + name: "Snapshots"); + } + } +} diff --git a/src/Services/Notification/Command/Infrastructure.EventStore/Migrations/20230213214427_Quartz Migration.Designer.cs b/src/Services/Notification/Command/Infrastructure.EventStore/Migrations/20230213214427_Quartz Migration.Designer.cs new file mode 100644 index 000000000..2e5a26f5a --- /dev/null +++ b/src/Services/Notification/Command/Infrastructure.EventStore/Migrations/20230213214427_Quartz Migration.Designer.cs @@ -0,0 +1,88 @@ +// +using System; +using Infrastructure.EventStore.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Infrastructure.EventStore.Migrations +{ + [DbContext(typeof(EventStoreDbContext))] + [Migration("20230213214427_Quartz Migration")] + partial class QuartzMigration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Domain.Abstractions.EventStore.Snapshot", b => + { + b.Property("Version") + .HasColumnType("bigint"); + + b.Property("AggregateId") + .HasColumnType("uniqueidentifier"); + + b.Property("Aggregate") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(30) + .IsUnicode(false) + .HasColumnType("varchar(30)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Version", "AggregateId"); + + b.ToTable("Snapshots"); + }); + + modelBuilder.Entity("Domain.Abstractions.EventStore.StoreEvent", b => + { + b.Property("Version") + .HasColumnType("bigint"); + + b.Property("AggregateId") + .HasColumnType("uniqueidentifier"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(30) + .IsUnicode(false) + .HasColumnType("varchar(30)"); + + b.Property("Event") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Version", "AggregateId"); + + b.ToTable("Events"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Notification/Command/Infrastructure.EventStore/Migrations/20230213214427_Quartz Migration.cs b/src/Services/Notification/Command/Infrastructure.EventStore/Migrations/20230213214427_Quartz Migration.cs new file mode 100644 index 000000000..be0bea122 --- /dev/null +++ b/src/Services/Notification/Command/Infrastructure.EventStore/Migrations/20230213214427_Quartz Migration.cs @@ -0,0 +1,319 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.EventStore.Migrations +{ + /// + public partial class QuartzMigration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@" +IF db_id(N'Quartz') IS NULL +CREATE DATABASE [Quartz] COLLATE SQL_Latin1_General_CP1_CS_AS; +GO + +IF db_id(N'Quartz') IS NOT NULL +USE [Quartz]; +GO + +IF OBJECT_ID(N'[dbo].[QRTZ_CALENDARS]', N'U') IS NULL + BEGIN + + CREATE TABLE [dbo].[QRTZ_CALENDARS] + ( + [SCHED_NAME] nvarchar(120) NOT NULL, + [CALENDAR_NAME] nvarchar(200) NOT NULL, + [CALENDAR] varbinary(max) NOT NULL + ); + + CREATE TABLE [dbo].[QRTZ_CRON_TRIGGERS] + ( + [SCHED_NAME] nvarchar(120) NOT NULL, + [TRIGGER_NAME] nvarchar(150) NOT NULL, + [TRIGGER_GROUP] nvarchar(150) NOT NULL, + [CRON_EXPRESSION] nvarchar(120) NOT NULL, + [TIME_ZONE_ID] nvarchar(80) + ); + + CREATE TABLE [dbo].[QRTZ_FIRED_TRIGGERS] + ( + [SCHED_NAME] nvarchar(120) NOT NULL, + [ENTRY_ID] nvarchar(140) NOT NULL, + [TRIGGER_NAME] nvarchar(150) NOT NULL, + [TRIGGER_GROUP] nvarchar(150) NOT NULL, + [INSTANCE_NAME] nvarchar(200) NOT NULL, + [FIRED_TIME] bigint NOT NULL, + [SCHED_TIME] bigint NOT NULL, + [PRIORITY] int NOT NULL, + [STATE] nvarchar(16) NOT NULL, + [JOB_NAME] nvarchar(150) NULL, + [JOB_GROUP] nvarchar(150) NULL, + [IS_NONCONCURRENT] bit NULL, + [REQUESTS_RECOVERY] bit NULL + ); + + CREATE TABLE [dbo].[QRTZ_PAUSED_TRIGGER_GRPS] + ( + [SCHED_NAME] nvarchar(120) NOT NULL, + [TRIGGER_GROUP] nvarchar(150) NOT NULL + ); + + CREATE TABLE [dbo].[QRTZ_SCHEDULER_STATE] + ( + [SCHED_NAME] nvarchar(120) NOT NULL, + [INSTANCE_NAME] nvarchar(200) NOT NULL, + [LAST_CHECKIN_TIME] bigint NOT NULL, + [CHECKIN_INTERVAL] bigint NOT NULL + ); + + CREATE TABLE [dbo].[QRTZ_LOCKS] + ( + [SCHED_NAME] nvarchar(120) NOT NULL, + [LOCK_NAME] nvarchar(40) NOT NULL + ); + + CREATE TABLE [dbo].[QRTZ_JOB_DETAILS] + ( + [SCHED_NAME] nvarchar(120) NOT NULL, + [JOB_NAME] nvarchar(150) NOT NULL, + [JOB_GROUP] nvarchar(150) NOT NULL, + [DESCRIPTION] nvarchar(250) NULL, + [JOB_CLASS_NAME] nvarchar(250) NOT NULL, + [IS_DURABLE] bit NOT NULL, + [IS_NONCONCURRENT] bit NOT NULL, + [IS_UPDATE_DATA] bit NOT NULL, + [REQUESTS_RECOVERY] bit NOT NULL, + [JOB_DATA] varbinary(max) NULL + ); + + CREATE TABLE [dbo].[QRTZ_SIMPLE_TRIGGERS] + ( + [SCHED_NAME] nvarchar(120) NOT NULL, + [TRIGGER_NAME] nvarchar(150) NOT NULL, + [TRIGGER_GROUP] nvarchar(150) NOT NULL, + [REPEAT_COUNT] int NOT NULL, + [REPEAT_INTERVAL] bigint NOT NULL, + [TIMES_TRIGGERED] int NOT NULL + ); + + CREATE TABLE [dbo].[QRTZ_SIMPROP_TRIGGERS] + ( + [SCHED_NAME] nvarchar(120) NOT NULL, + [TRIGGER_NAME] nvarchar(150) NOT NULL, + [TRIGGER_GROUP] nvarchar(150) NOT NULL, + [STR_PROP_1] nvarchar(512) NULL, + [STR_PROP_2] nvarchar(512) NULL, + [STR_PROP_3] nvarchar(512) NULL, + [INT_PROP_1] int NULL, + [INT_PROP_2] int NULL, + [LONG_PROP_1] bigint NULL, + [LONG_PROP_2] bigint NULL, + [DEC_PROP_1] numeric(13, 4) NULL, + [DEC_PROP_2] numeric(13, 4) NULL, + [BOOL_PROP_1] bit NULL, + [BOOL_PROP_2] bit NULL, + [TIME_ZONE_ID] nvarchar(80) NULL + ); + + CREATE TABLE [dbo].[QRTZ_BLOB_TRIGGERS] + ( + [SCHED_NAME] nvarchar(120) NOT NULL, + [TRIGGER_NAME] nvarchar(150) NOT NULL, + [TRIGGER_GROUP] nvarchar(150) NOT NULL, + [BLOB_DATA] varbinary(max) NULL + ); + + CREATE TABLE [dbo].[QRTZ_TRIGGERS] + ( + [SCHED_NAME] nvarchar(120) NOT NULL, + [TRIGGER_NAME] nvarchar(150) NOT NULL, + [TRIGGER_GROUP] nvarchar(150) NOT NULL, + [JOB_NAME] nvarchar(150) NOT NULL, + [JOB_GROUP] nvarchar(150) NOT NULL, + [DESCRIPTION] nvarchar(250) NULL, + [NEXT_FIRE_TIME] bigint NULL, + [PREV_FIRE_TIME] bigint NULL, + [PRIORITY] int NULL, + [TRIGGER_STATE] nvarchar(16) NOT NULL, + [TRIGGER_TYPE] nvarchar(8) NOT NULL, + [START_TIME] bigint NOT NULL, + [END_TIME] bigint NULL, + [CALENDAR_NAME] nvarchar(200) NULL, + [MISFIRE_INSTR] int NULL, + [JOB_DATA] varbinary(max) NULL + ); + + ALTER TABLE [dbo].[QRTZ_CALENDARS] + WITH NOCHECK ADD + CONSTRAINT [PK_QRTZ_CALENDARS] PRIMARY KEY CLUSTERED + ( + [SCHED_NAME], + [CALENDAR_NAME] + ); + + ALTER TABLE [dbo].[QRTZ_CRON_TRIGGERS] + WITH NOCHECK ADD + CONSTRAINT [PK_QRTZ_CRON_TRIGGERS] PRIMARY KEY CLUSTERED + ( + [SCHED_NAME], + [TRIGGER_NAME], + [TRIGGER_GROUP] + ); + + ALTER TABLE [dbo].[QRTZ_FIRED_TRIGGERS] + WITH NOCHECK ADD + CONSTRAINT [PK_QRTZ_FIRED_TRIGGERS] PRIMARY KEY CLUSTERED + ( + [SCHED_NAME], + [ENTRY_ID] + ); + + ALTER TABLE [dbo].[QRTZ_PAUSED_TRIGGER_GRPS] + WITH NOCHECK ADD + CONSTRAINT [PK_QRTZ_PAUSED_TRIGGER_GRPS] PRIMARY KEY CLUSTERED + ( + [SCHED_NAME], + [TRIGGER_GROUP] + ); + + ALTER TABLE [dbo].[QRTZ_SCHEDULER_STATE] + WITH NOCHECK ADD + CONSTRAINT [PK_QRTZ_SCHEDULER_STATE] PRIMARY KEY CLUSTERED + ( + [SCHED_NAME], + [INSTANCE_NAME] + ); + + ALTER TABLE [dbo].[QRTZ_LOCKS] + WITH NOCHECK ADD + CONSTRAINT [PK_QRTZ_LOCKS] PRIMARY KEY CLUSTERED + ( + [SCHED_NAME], + [LOCK_NAME] + ); + + ALTER TABLE [dbo].[QRTZ_JOB_DETAILS] + WITH NOCHECK ADD + CONSTRAINT [PK_QRTZ_JOB_DETAILS] PRIMARY KEY CLUSTERED + ( + [SCHED_NAME], + [JOB_NAME], + [JOB_GROUP] + ); + + ALTER TABLE [dbo].[QRTZ_SIMPLE_TRIGGERS] + WITH NOCHECK ADD + CONSTRAINT [PK_QRTZ_SIMPLE_TRIGGERS] PRIMARY KEY CLUSTERED + ( + [SCHED_NAME], + [TRIGGER_NAME], + [TRIGGER_GROUP] + ); + + ALTER TABLE [dbo].[QRTZ_SIMPROP_TRIGGERS] + WITH NOCHECK ADD + CONSTRAINT [PK_QRTZ_SIMPROP_TRIGGERS] PRIMARY KEY CLUSTERED + ( + [SCHED_NAME], + [TRIGGER_NAME], + [TRIGGER_GROUP] + ); + + ALTER TABLE [dbo].[QRTZ_TRIGGERS] + WITH NOCHECK ADD + CONSTRAINT [PK_QRTZ_TRIGGERS] PRIMARY KEY CLUSTERED + ( + [SCHED_NAME], + [TRIGGER_NAME], + [TRIGGER_GROUP] + ); + + + ALTER TABLE [dbo].[QRTZ_BLOB_TRIGGERS] + WITH NOCHECK ADD + CONSTRAINT [PK_QRTZ_BLOB_TRIGGERS] PRIMARY KEY CLUSTERED + ( + [SCHED_NAME], + [TRIGGER_NAME], + [TRIGGER_GROUP] + ); + + ALTER TABLE [dbo].[QRTZ_CRON_TRIGGERS] + ADD + CONSTRAINT [FK_QRTZ_CRON_TRIGGERS_QRTZ_TRIGGERS] FOREIGN KEY + ( + [SCHED_NAME], + [TRIGGER_NAME], + [TRIGGER_GROUP] + ) REFERENCES [dbo].[QRTZ_TRIGGERS] ( + [SCHED_NAME], + [TRIGGER_NAME], + [TRIGGER_GROUP] + ) ON DELETE CASCADE; + + ALTER TABLE [dbo].[QRTZ_SIMPLE_TRIGGERS] + ADD + CONSTRAINT [FK_QRTZ_SIMPLE_TRIGGERS_QRTZ_TRIGGERS] FOREIGN KEY + ( + [SCHED_NAME], + [TRIGGER_NAME], + [TRIGGER_GROUP] + ) REFERENCES [dbo].[QRTZ_TRIGGERS] ( + [SCHED_NAME], + [TRIGGER_NAME], + [TRIGGER_GROUP] + ) ON DELETE CASCADE; + + ALTER TABLE [dbo].[QRTZ_SIMPROP_TRIGGERS] + ADD + CONSTRAINT [FK_QRTZ_SIMPROP_TRIGGERS_QRTZ_TRIGGERS] FOREIGN KEY + ( + [SCHED_NAME], + [TRIGGER_NAME], + [TRIGGER_GROUP] + ) REFERENCES [dbo].[QRTZ_TRIGGERS] ( + [SCHED_NAME], + [TRIGGER_NAME], + [TRIGGER_GROUP] + ) ON DELETE CASCADE; + + ALTER TABLE [dbo].[QRTZ_TRIGGERS] + ADD + CONSTRAINT [FK_QRTZ_TRIGGERS_QRTZ_JOB_DETAILS] FOREIGN KEY + ( + [SCHED_NAME], + [JOB_NAME], + [JOB_GROUP] + ) REFERENCES [dbo].[QRTZ_JOB_DETAILS] ( + [SCHED_NAME], + [JOB_NAME], + [JOB_GROUP] + ); + + CREATE INDEX [IDX_QRTZ_T_G_J] ON [dbo].[QRTZ_TRIGGERS] (SCHED_NAME, JOB_GROUP, JOB_NAME); + CREATE INDEX [IDX_QRTZ_T_C] ON [dbo].[QRTZ_TRIGGERS] (SCHED_NAME, CALENDAR_NAME); + CREATE INDEX [IDX_QRTZ_T_N_G_STATE] ON [dbo].[QRTZ_TRIGGERS] (SCHED_NAME, TRIGGER_GROUP, TRIGGER_STATE); + CREATE INDEX [IDX_QRTZ_T_STATE] ON [dbo].[QRTZ_TRIGGERS] (SCHED_NAME, TRIGGER_STATE); + CREATE INDEX [IDX_QRTZ_T_N_STATE] ON [dbo].[QRTZ_TRIGGERS] (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP, TRIGGER_STATE); + CREATE INDEX [IDX_QRTZ_T_NEXT_FIRE_TIME] ON [dbo].[QRTZ_TRIGGERS] (SCHED_NAME, NEXT_FIRE_TIME); + CREATE INDEX [IDX_QRTZ_T_NFT_ST] ON [dbo].[QRTZ_TRIGGERS] (SCHED_NAME, TRIGGER_STATE, NEXT_FIRE_TIME); + CREATE INDEX [IDX_QRTZ_T_NFT_ST_MISFIRE] ON [dbo].[QRTZ_TRIGGERS] (SCHED_NAME, MISFIRE_INSTR, NEXT_FIRE_TIME, TRIGGER_STATE); + CREATE INDEX [IDX_QRTZ_T_NFT_ST_MISFIRE_GRP] ON [dbo].[QRTZ_TRIGGERS] (SCHED_NAME, MISFIRE_INSTR,NEXT_FIRE_TIME, TRIGGER_GROUP, TRIGGER_STATE); + CREATE INDEX [IDX_QRTZ_FT_INST_JOB_REQ_RCVRY] ON [dbo].[QRTZ_FIRED_TRIGGERS] (SCHED_NAME, INSTANCE_NAME, REQUESTS_RECOVERY); + CREATE INDEX [IDX_QRTZ_FT_G_J] ON [dbo].[QRTZ_FIRED_TRIGGERS] (SCHED_NAME, JOB_GROUP, JOB_NAME); + CREATE INDEX [IDX_QRTZ_FT_G_T] ON [dbo].[QRTZ_FIRED_TRIGGERS] (SCHED_NAME, TRIGGER_GROUP, TRIGGER_NAME); + END +GO + +USE [CommunicationEventStore];", true); + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + => migrationBuilder.DropTable(name: "Quartz"); + } +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Infrastructure.EventStore/Migrations/EventStoreDbContextModelSnapshot.cs b/src/Services/Notification/Command/Infrastructure.EventStore/Migrations/EventStoreDbContextModelSnapshot.cs new file mode 100644 index 000000000..c74d5e0ec --- /dev/null +++ b/src/Services/Notification/Command/Infrastructure.EventStore/Migrations/EventStoreDbContextModelSnapshot.cs @@ -0,0 +1,85 @@ +// +using System; +using Infrastructure.EventStore.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Infrastructure.EventStore.Migrations +{ + [DbContext(typeof(EventStoreDbContext))] + partial class EventStoreDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Domain.Abstractions.EventStore.Snapshot", b => + { + b.Property("Version") + .HasColumnType("bigint"); + + b.Property("AggregateId") + .HasColumnType("uniqueidentifier"); + + b.Property("Aggregate") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(30) + .IsUnicode(false) + .HasColumnType("varchar(30)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Version", "AggregateId"); + + b.ToTable("Snapshots"); + }); + + modelBuilder.Entity("Domain.Abstractions.EventStore.StoreEvent", b => + { + b.Property("Version") + .HasColumnType("bigint"); + + b.Property("AggregateId") + .HasColumnType("uniqueidentifier"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(30) + .IsUnicode(false) + .HasColumnType("varchar(30)"); + + b.Property("Event") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Version", "AggregateId"); + + b.ToTable("Events"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Notification/Command/Infrastructure.EventStore/UnitOfWork.cs b/src/Services/Notification/Command/Infrastructure.EventStore/UnitOfWork.cs new file mode 100644 index 000000000..89f994e3a --- /dev/null +++ b/src/Services/Notification/Command/Infrastructure.EventStore/UnitOfWork.cs @@ -0,0 +1,20 @@ +using Application.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Infrastructure.EventStore; + +public class UnitOfWork(DbContext dbContext) : IUnitOfWork +{ + private readonly DatabaseFacade _database = dbContext.Database; + + public Task ExecuteAsync(Func operationAsync, CancellationToken cancellationToken) + => _database.CreateExecutionStrategy().ExecuteAsync(ct => ExecuteTransactionAsync(operationAsync, ct), cancellationToken); + + private async Task ExecuteTransactionAsync(Func operationAsync, CancellationToken cancellationToken) + { + await using var transaction = await _database.BeginTransactionAsync(cancellationToken); + await operationAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Infrastructure.SMTP/DependencyInjection/Extensions/ServiceCollectionExtensions.cs b/src/Services/Notification/Command/Infrastructure.SMTP/DependencyInjection/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..c39c03612 --- /dev/null +++ b/src/Services/Notification/Command/Infrastructure.SMTP/DependencyInjection/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,38 @@ +using System.Net; +using System.Net.Mail; +using Application.Abstractions; +using Application.Abstractions.Gateways; +using Domain.ValueObject; +using Infrastructure.SMTP.DependencyInjection.Options; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Infrastructure.SMTP.DependencyInjection.Extensions; + +public static class ServiceCollectionExtensions +{ + public static void AddEmailGateway(this IServiceCollection services) + => services.AddTransient, EmailGateway>(); + + public static void AddLazyFactory(this IServiceCollection services) + => services.AddTransient(typeof(ILazy<>), typeof(LazyFactory<>)); + + public static void AddSmtpClient(this IServiceCollection services) + => services.AddScoped(provider => + { + var options = provider.GetRequiredService>().Value; + return new(options.Host, options.Port) + { + Credentials = new NetworkCredential(options.Username, options.Password), + EnableSsl = options.EnableSsl + }; + }); + + public static OptionsBuilder ConfigureSmtpOptions(this IServiceCollection services, IConfigurationSection section) + => services + .AddOptions() + .Bind(section) + .ValidateDataAnnotations() + .ValidateOnStart(); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Infrastructure.SMTP/DependencyInjection/LazyFactory.cs b/src/Services/Notification/Command/Infrastructure.SMTP/DependencyInjection/LazyFactory.cs new file mode 100644 index 000000000..05abcafe0 --- /dev/null +++ b/src/Services/Notification/Command/Infrastructure.SMTP/DependencyInjection/LazyFactory.cs @@ -0,0 +1,11 @@ +using Application.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace Infrastructure.SMTP.DependencyInjection; + +public class LazyFactory(IServiceProvider service) : ILazy + where T : notnull +{ + private readonly Lazy _lazy = new(service.GetRequiredService); + public T Instance => _lazy.Value; +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Infrastructure.SMTP/DependencyInjection/Options/SmtpOptions.cs b/src/Services/Notification/Command/Infrastructure.SMTP/DependencyInjection/Options/SmtpOptions.cs new file mode 100644 index 000000000..1b8bbab00 --- /dev/null +++ b/src/Services/Notification/Command/Infrastructure.SMTP/DependencyInjection/Options/SmtpOptions.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace Infrastructure.SMTP.DependencyInjection.Options; + +public record SmtpOptions +{ + [Required, MinLength(5)] public required string Host { get; init; } + [Required] public int Port { get; init; } + [Required, EmailAddress] public required string Username { get; init; } + [Required] public required string Password { get; init; } + [Required] public bool EnableSsl { get; init; } +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Infrastructure.SMTP/EmailGateway.cs b/src/Services/Notification/Command/Infrastructure.SMTP/EmailGateway.cs new file mode 100644 index 000000000..ce697e484 --- /dev/null +++ b/src/Services/Notification/Command/Infrastructure.SMTP/EmailGateway.cs @@ -0,0 +1,52 @@ +using System.Net.Mail; +using Application.Abstractions.Gateways; +using Domain.Enumerations; +using Domain.ValueObject; +using Infrastructure.SMTP.DependencyInjection.Options; +using Microsoft.Extensions.Options; +using Serilog; + +namespace Infrastructure.SMTP; + +public class EmailGateway(IOptionsSnapshot options, SmtpClient smtpClient) + : IEmailGateway +{ + private readonly SmtpOptions _options = options.Value; + + public Task NotifyAsync(Email email, CancellationToken cancellationToken) + => SendAsync(client => client.SendMailAsync(new(_options.Username, email.Address, email.Subject, email.Body) { IsBodyHtml = true }, cancellationToken)); + + public Task CancelAsync(Email option, CancellationToken cancellationToken) + => throw new NotImplementedException(); + + private async Task SendAsync(Func sendEmailAsync) + { + var onSendCompleted = () => NotificationMethodStatus.Pending; + + smtpClient.SendCompleted += (_, @event) => + { + onSendCompleted = @event switch + { + { Error: { } error } => () => + { + Log.Error(error, "Error sending email"); + return NotificationMethodStatus.Failed; + }, + { Cancelled: true } => () => + { + Log.Warning("Email sending cancelled"); + return NotificationMethodStatus.Cancelled; + }, + _ => () => + { + Log.Information("Email sent successfully"); + return NotificationMethodStatus.Sent; + } + }; + }; + + await sendEmailAsync(smtpClient); + + return onSendCompleted(); + } +} \ No newline at end of file diff --git a/src/Services/Notification/Command/Infrastructure.SMTP/Infrastructure.SMTP.csproj b/src/Services/Notification/Command/Infrastructure.SMTP/Infrastructure.SMTP.csproj new file mode 100644 index 000000000..692ea1926 --- /dev/null +++ b/src/Services/Notification/Command/Infrastructure.SMTP/Infrastructure.SMTP.csproj @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/Services/Notification/Command/WorkerService/.dockerignore b/src/Services/Notification/Command/WorkerService/.dockerignore new file mode 100644 index 000000000..cd967fc3a --- /dev/null +++ b/src/Services/Notification/Command/WorkerService/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/src/Services/Notification/Command/WorkerService/Dockerfile b/src/Services/Notification/Command/WorkerService/Dockerfile new file mode 100644 index 000000000..f98ca7826 --- /dev/null +++ b/src/Services/Notification/Command/WorkerService/Dockerfile @@ -0,0 +1,44 @@ +ARG ASPNET_VERSION="8.0-preview" +ARG SDK_VERSION="8.0-preview" +ARG BASE_ADRESS="mcr.microsoft.com/dotnet" + +FROM $BASE_ADRESS/aspnet:$ASPNET_VERSION AS base +WORKDIR /app + +FROM $BASE_ADRESS/sdk:$SDK_VERSION AS build + +COPY ./global.json ./ +COPY ./nuget.config ./ +COPY ./Directory.Build.props ./ + +WORKDIR /src + +COPY ./src/Services/Communication/Command/Application/*.csproj ./Services/Communication/Command/Application/ +COPY ./src/Services/Communication/Command/Domain/*.csproj ./Services/Communication/Command/Domain/ +COPY ./src/Services/Communication/Command/Infrastructure.EventStore/*.csproj ./Services/Communication/Command/Infrastructure.EventStore/ +COPY ./src/Services/Communication/Command/Infrastructure.MessageBus/*.csproj ./Services/Communication/Command/Infrastructure.MessageBus/ +COPY ./src/Services/Communication/Command/Infrastructure.SMTP/*.csproj ./Services/Communication/Command/Infrastructure.SMTP/ +COPY ./src/Services/Communication/Command/WorkerService/*.csproj ./Services/Communication/Command/WorkerService/ +COPY ./src/Contracts/*.csproj ./Contracts/ + +RUN dotnet restore -v m ./Services/Communication/Command/WorkerService + +COPY ./src/Services/Communication/Command/Application/. ./Services/Communication/Command/Application/ +COPY ./src/Services/Communication/Command/Domain/. ./Services/Communication/Command/Domain/ +COPY ./src/Services/Communication/Command/Infrastructure.EventStore/. ./Services/Communication/Command/Infrastructure.EventStore/ +COPY ./src/Services/Communication/Command/Infrastructure.MessageBus/. ./Services/Communication/Command/Infrastructure.MessageBus/ +COPY ./src/Services/Communication/Command/Infrastructure.SMTP/. ./Services/Communication/Command/Infrastructure.SMTP/ +COPY ./src/Services/Communication/Command/WorkerService/. ./Services/Communication/Command/WorkerService/ +COPY ./src/Contracts/. ./Contracts/ + +WORKDIR /src/Services/Communication/Command/WorkerService + +RUN dotnet build -c Release --no-restore -v m -o /app/build + +FROM build AS publish +RUN dotnet publish -c Release --no-restore -v m -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "WorkerService.dll"] \ No newline at end of file diff --git a/src/Services/Notification/Command/WorkerService/Program.cs b/src/Services/Notification/Command/WorkerService/Program.cs new file mode 100644 index 000000000..65ffeffe3 --- /dev/null +++ b/src/Services/Notification/Command/WorkerService/Program.cs @@ -0,0 +1,93 @@ +using Application.DependencyInjection.Extensions; +using Infrastructure.EventBus.DependencyInjection.Extensions; +using Infrastructure.EventBus.DependencyInjection.Options; +using Infrastructure.EventStore.Contexts; +using Infrastructure.EventStore.DependencyInjection.Extensions; +using Infrastructure.EventStore.DependencyInjection.Options; +using Infrastructure.SMTP.DependencyInjection.Extensions; +using Infrastructure.SMTP.DependencyInjection.Options; +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Quartz; +using Serilog; + +var builder = Host.CreateDefaultBuilder(args); + +builder.UseDefaultServiceProvider((context, provider) => +{ + provider.ValidateScopes = + provider.ValidateOnBuild = + context.HostingEnvironment.IsDevelopment(); +}); + +builder.ConfigureAppConfiguration(configuration => +{ + configuration + .AddUserSecrets() + .AddEnvironmentVariables(); +}); + +builder.ConfigureLogging(logging + => logging.ClearProviders().AddSerilog()); + +builder.UseSerilog((context, cfg) + => cfg.ReadFrom.Configuration(context.Configuration)); + +builder.ConfigureServices((context, services) => +{ + services.AddEventStore(); + services.AddMessageBus(); + services.AddEventBusGateway(); + services.AddApplicationServices(); + services.AddEventInteractors(); + services.AddMessageValidators(); + services.AddEmailGateway(); + services.AddSmtpClient(); + services.AddLazyFactory(); + + services.ConfigureEventStoreOptions( + context.Configuration.GetSection(nameof(EventStoreOptions))); + + services.ConfigureSqlServerRetryOptions( + context.Configuration.GetSection(nameof(SqlServerRetryOptions))); + + services.ConfigureEventBusOptions( + context.Configuration.GetSection(nameof(EventBusOptions))); + + services.ConfigureQuartzOptions( + context.Configuration.GetSection(nameof(QuartzOptions))); + + services.ConfigureMassTransitHostOptions( + context.Configuration.GetSection(nameof(MassTransitHostOptions))); + + services.ConfigureSmtpOptions( + context.Configuration.GetSection(nameof(SmtpOptions))); +}); + +using var host = builder.Build(); + +try +{ + var environment = host.Services.GetRequiredService(); + + if (environment.IsDevelopment() || environment.IsStaging()) + { + await using var scope = host.Services.CreateAsyncScope(); + await using var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(); + await dbContext.Database.EnsureCreatedAsync(); + } + + await host.RunAsync(); + Log.Information("Stopped cleanly"); +} +catch (Exception ex) +{ + Log.Fatal(ex, "An unhandled exception occured during bootstrapping"); + await host.StopAsync(); +} +finally +{ + Log.CloseAndFlush(); + host.Dispose(); +} \ No newline at end of file diff --git a/src/Services/Notification/Command/WorkerService/Properties/launchSettings.json b/src/Services/Notification/Command/WorkerService/Properties/launchSettings.json new file mode 100644 index 000000000..78533cc03 --- /dev/null +++ b/src/Services/Notification/Command/WorkerService/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "Communication.WorkerService": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Notification/Command/WorkerService/WorkerService.csproj b/src/Services/Notification/Command/WorkerService/WorkerService.csproj new file mode 100644 index 000000000..a3d3ac076 --- /dev/null +++ b/src/Services/Notification/Command/WorkerService/WorkerService.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/Services/Notification/Command/WorkerService/appsettings.Development.json b/src/Services/Notification/Command/WorkerService/appsettings.Development.json new file mode 100644 index 000000000..e1994f48a --- /dev/null +++ b/src/Services/Notification/Command/WorkerService/appsettings.Development.json @@ -0,0 +1,15 @@ +{ + "ConnectionStrings": { + "EventStore": "Server=127.0.0.1,1433;Database=CommunicationEventStore;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@127.0.0.1:5672/eventual-shop" + }, + "QuartzOptions": { + "quartz.dataSource.default.connectionString": "Server=127.0.0.1,1433;Database=Quartz;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + }, + "SmtpOptions": { + "Username": "no.retry.fake.email@gmail.com", + "Password": "noretryfakeemail" + } +} \ No newline at end of file diff --git a/src/Services/Notification/Command/WorkerService/appsettings.Production.json b/src/Services/Notification/Command/WorkerService/appsettings.Production.json new file mode 100644 index 000000000..0eabf65fa --- /dev/null +++ b/src/Services/Notification/Command/WorkerService/appsettings.Production.json @@ -0,0 +1,15 @@ +{ + "ConnectionStrings": { + "EventStore": "Server=mssql;Database=CommunicationEventStore;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@rabbitmq:5672/eventual-shop" + }, + "QuartzOptions": { + "quartz.dataSource.default.connectionString": "Server=mssql;Database=Quartz;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + }, + "SmtpOptions": { + "Username": "no.retry.fake.email@gmail.com", + "Password": "noretryfakeemail" + } +} \ No newline at end of file diff --git a/src/Services/Notification/Command/WorkerService/appsettings.Staging.json b/src/Services/Notification/Command/WorkerService/appsettings.Staging.json new file mode 100644 index 000000000..0eabf65fa --- /dev/null +++ b/src/Services/Notification/Command/WorkerService/appsettings.Staging.json @@ -0,0 +1,15 @@ +{ + "ConnectionStrings": { + "EventStore": "Server=mssql;Database=CommunicationEventStore;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@rabbitmq:5672/eventual-shop" + }, + "QuartzOptions": { + "quartz.dataSource.default.connectionString": "Server=mssql;Database=Quartz;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + }, + "SmtpOptions": { + "Username": "no.retry.fake.email@gmail.com", + "Password": "noretryfakeemail" + } +} \ No newline at end of file diff --git a/src/Services/Notification/Command/WorkerService/appsettings.json b/src/Services/Notification/Command/WorkerService/appsettings.json new file mode 100644 index 000000000..1b0a79af9 --- /dev/null +++ b/src/Services/Notification/Command/WorkerService/appsettings.json @@ -0,0 +1,57 @@ +{ + "HostOptions": { + "ShutdownTimeout": "00:00:25" + }, + "MassTransitHostOptions": { + "WaitUntilStarted": true, + "StartTimeout": "00:00:30", + "StopTimeout": "00:00:15" + }, + "EventBusOptions": { + "ConnectionName": "Communication", + "SchedulerQueueName": "scheduler", + "retryLimit": 3, + "initialInterval": "00:00:05", + "intervalIncrement": "00:00:10" + }, + "SqlServerRetryOptions": { + "MaxRetryCount": 5, + "MaxRetryDelay": "00:00:05", + "ErrorNumbersToAdd": [] + }, + "EventStoreOptions": { + "SnapshotInterval": 5 + }, + "SmtpOptions": { + "Host": "smtp.gmail.com", + "Port": 587, + "EnableSsl": true + }, + "QuartzOptions": { + "quartz.scheduler.instanceName": "Communication", + "quartz.scheduler.instanceId": "AUTO", + "quartz.jobStore.dataSource": "default", + "quartz.dataSource.default.provider": "SqlServer", + "quartz.serializer.type": "json", + "quartz.jobStore.type": "Quartz.Impl.AdoJobStore.JobStoreTX, Quartz", + "quartz.jobStore.clustered": true, + "quartz.jobStore.driverDelegateType": "Quartz.Impl.AdoJobStore.SqlServerDelegate, Quartz" + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "MassTransit": "Information", + "Quartz": "Information", + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console" + } + ] + } +} \ No newline at end of file diff --git a/src/Services/Notification/Query/Application/Abstractions/IInteractor.cs b/src/Services/Notification/Query/Application/Abstractions/IInteractor.cs new file mode 100644 index 000000000..2369fd771 --- /dev/null +++ b/src/Services/Notification/Query/Application/Abstractions/IInteractor.cs @@ -0,0 +1,25 @@ +using Contracts.Abstractions; +using Contracts.Abstractions.Messages; +using Contracts.Abstractions.Paging; + +namespace Application.Abstractions; + +public interface IInteractor + where TEvent : IEvent +{ + Task InteractAsync(TEvent @event, CancellationToken cancellationToken); +} + +public interface IInteractor + where TQuery : IQuery + where TProjection : IProjection +{ + Task InteractAsync(TQuery query, CancellationToken cancellationToken); +} + +public interface IPagedInteractor + where TQuery : IQuery + where TProjection : IProjection +{ + ValueTask> InteractAsync(TQuery query, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Notification/Query/Application/Abstractions/IProjectionGateway.cs b/src/Services/Notification/Query/Application/Abstractions/IProjectionGateway.cs new file mode 100644 index 000000000..565180f64 --- /dev/null +++ b/src/Services/Notification/Query/Application/Abstractions/IProjectionGateway.cs @@ -0,0 +1,19 @@ +using System.Linq.Expressions; +using Contracts.Abstractions; +using Contracts.Abstractions.Paging; + +namespace Application.Abstractions; + +public interface IProjectionGateway + where TProjection : IProjection +{ + Task FindAsync(Expression> predicate, CancellationToken cancellationToken); + Task GetAsync(TId id, CancellationToken cancellationToken) where TId : struct; + ValueTask> ListAsync(Paging paging, Expression> predicate, CancellationToken cancellationToken); + ValueTask> ListAsync(Paging paging, CancellationToken cancellationToken); + ValueTask ReplaceInsertAsync(TProjection replacement, CancellationToken cancellationToken); + ValueTask RebuildInsertAsync(TProjection replacement, CancellationToken cancellationToken); + Task DeleteAsync(Expression> filter, CancellationToken cancellationToken); + Task DeleteAsync(TId id, CancellationToken cancellationToken) where TId : struct; + Task UpdateFieldAsync(TId id, ulong version, Expression> field, TField value, CancellationToken cancellationToken) where TId : struct; +} \ No newline at end of file diff --git a/src/Services/Notification/Query/Application/Application.csproj b/src/Services/Notification/Query/Application/Application.csproj new file mode 100644 index 000000000..1c8a09f78 --- /dev/null +++ b/src/Services/Notification/Query/Application/Application.csproj @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/Services/Notification/Query/Application/DependencyInjection/ServiceCollectionExtensions.cs b/src/Services/Notification/Query/Application/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..3e590056b --- /dev/null +++ b/src/Services/Notification/Query/Application/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,23 @@ +using Application.Abstractions; +using Application.UseCases.Events; +using Application.UseCases.Queries; +using Contracts.Boundaries.Notification; +using Microsoft.Extensions.DependencyInjection; + +namespace Application.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddInteractors(this IServiceCollection services) + => services + .AddEventInteractors() + .AddQueryInteractors(); + + private static IServiceCollection AddEventInteractors(this IServiceCollection services) + => services + .AddScoped(); + + private static IServiceCollection AddQueryInteractors(this IServiceCollection services) + => services + .AddScoped, ListNotificationsDetailsInteractor>(); +} \ No newline at end of file diff --git a/src/Services/Notification/Query/Application/UseCases/Events/ProjectNotificationDetailsWhenNotificationChangedInteractor.cs b/src/Services/Notification/Query/Application/UseCases/Events/ProjectNotificationDetailsWhenNotificationChangedInteractor.cs new file mode 100644 index 000000000..2c46775b8 --- /dev/null +++ b/src/Services/Notification/Query/Application/UseCases/Events/ProjectNotificationDetailsWhenNotificationChangedInteractor.cs @@ -0,0 +1,20 @@ +using Application.Abstractions; +using Contracts.Boundaries.Notification; + +namespace Application.UseCases.Events; + +public interface IProjectNotificationDetailsWhenNotificationChangedInteractor : IInteractor { } + +public class ProjectNotificationDetailsWhenNotificationChangedInteractor(IProjectionGateway projectionGateway) + : IProjectNotificationDetailsWhenNotificationChangedInteractor +{ + public async Task InteractAsync(DomainEvent.NotificationRequested @event, CancellationToken cancellationToken) + { + Projection.NotificationDetails notificationDetails = new( + @event.NotificationId, + false, + @event.Version); + + await projectionGateway.ReplaceInsertAsync(notificationDetails, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Notification/Query/Application/UseCases/Queries/ListNotificationsDetailsInteractor.cs b/src/Services/Notification/Query/Application/UseCases/Queries/ListNotificationsDetailsInteractor.cs new file mode 100644 index 000000000..1fb640e76 --- /dev/null +++ b/src/Services/Notification/Query/Application/UseCases/Queries/ListNotificationsDetailsInteractor.cs @@ -0,0 +1,12 @@ +using Application.Abstractions; +using Contracts.Abstractions.Paging; +using Contracts.Boundaries.Notification; + +namespace Application.UseCases.Queries; + +public class ListNotificationsDetailsInteractor(IProjectionGateway projectionGateway) + : IPagedInteractor +{ + public ValueTask> InteractAsync(Query.ListNotificationsDetails query, CancellationToken cancellationToken) + => projectionGateway.ListAsync(query.Paging, cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Notification/Query/GrpcService/.dockerignore b/src/Services/Notification/Query/GrpcService/.dockerignore new file mode 100644 index 000000000..cd967fc3a --- /dev/null +++ b/src/Services/Notification/Query/GrpcService/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/src/Services/Notification/Query/GrpcService/CommunicationGrpcService.cs b/src/Services/Notification/Query/GrpcService/CommunicationGrpcService.cs new file mode 100644 index 000000000..ee02e06d0 --- /dev/null +++ b/src/Services/Notification/Query/GrpcService/CommunicationGrpcService.cs @@ -0,0 +1,28 @@ +using Application.Abstractions; +using Contracts.Abstractions.Protobuf; +using Contracts.Boundaries.Notification; +using Contracts.Services.Communication.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; + +namespace GrpcService; + +public class CommunicationGrpcService(IPagedInteractor listNotificationsDetails) + : CommunicationService.CommunicationServiceBase +{ + public override async Task ListNotificationsDetails(ListNotificationsDetailsRequest request, ServerCallContext context) + { + var pagedResult = await listNotificationsDetails.InteractAsync(request, context.CancellationToken); + + return pagedResult.Items.Any() + ? new() + { + PagedResult = new() + { + Projections = { pagedResult.Items.Select(item => Any.Pack((NotificationDetails)item)) }, + Page = pagedResult.Page + } + } + : new() { NoContent = new() }; + } +} \ No newline at end of file diff --git a/src/Services/Notification/Query/GrpcService/Dockerfile b/src/Services/Notification/Query/GrpcService/Dockerfile new file mode 100644 index 000000000..a8608b3b1 --- /dev/null +++ b/src/Services/Notification/Query/GrpcService/Dockerfile @@ -0,0 +1,42 @@ +ARG ASPNET_VERSION="8.0-preview" +ARG SDK_VERSION="8.0-preview" +ARG BASE_ADRESS="mcr.microsoft.com/dotnet" + +FROM $BASE_ADRESS/aspnet:$ASPNET_VERSION AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 + +FROM $BASE_ADRESS/sdk:$SDK_VERSION AS build + +COPY ./global.json ./ +COPY ./nuget.config ./ +COPY ./Directory.Build.props ./ + +WORKDIR /src + +COPY ./src/Services/Communication/Query/Application/*.csproj ./Services/Communication/Query/Application/ +COPY ./src/Services/Communication/Query/GrpcService/*.csproj ./Services/Communication/Query/GrpcService/ +COPY ./src/Services/Communication/Query/Infrastructure.EventBus/*.csproj ./Services/Communication/Query/Infrastructure.EventBus/ +COPY ./src/Services/Communication/Query/Infrastructure.Projections/*.csproj ./Services/Communication/Query/Infrastructure.Projections/ +COPY ./src/Contracts/*.csproj ./Contracts/ + +RUN dotnet restore -v m ./Services/Communication/Query/GrpcService + +COPY ./src/Services/Communication/Query/Application/. ./Services/Communication/Query/Application/ +COPY ./src/Services/Communication/Query/GrpcService/. ./Services/Communication/Query/GrpcService/ +COPY ./src/Services/Communication/Query/Infrastructure.EventBus/. ./Services/Communication/Query/Infrastructure.EventBus/ +COPY ./src/Services/Communication/Query/Infrastructure.Projections/. ./Services/Communication/Query/Infrastructure.Projections/ +COPY ./src/Contracts/. ./Contracts/ + +WORKDIR /src/Services/Communication/Query/GrpcService + +RUN dotnet build -c Release --no-restore -v m -o /app/build + +FROM build AS publish +RUN dotnet publish -c Release --no-restore -v m -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "GrpcService.dll"] diff --git a/src/Services/Notification/Query/GrpcService/GrpcService.csproj b/src/Services/Notification/Query/GrpcService/GrpcService.csproj new file mode 100644 index 000000000..73b4a732a --- /dev/null +++ b/src/Services/Notification/Query/GrpcService/GrpcService.csproj @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/Services/Notification/Query/GrpcService/Program.cs b/src/Services/Notification/Query/GrpcService/Program.cs new file mode 100644 index 000000000..ad94f7a54 --- /dev/null +++ b/src/Services/Notification/Query/GrpcService/Program.cs @@ -0,0 +1,74 @@ +using System.Reflection; +using Application.DependencyInjection; +using GrpcService; +using Infrastructure.EventBus.DependencyInjection.Extensions; +using Infrastructure.EventBus.DependencyInjection.Options; +using Infrastructure.Projections.DependencyInjection; +using MassTransit; +using Microsoft.AspNetCore.HttpLogging; +using Serilog; + +var builder = WebApplication.CreateBuilder(args); + +builder.Host.UseDefaultServiceProvider((context, provider) => +{ + provider.ValidateScopes = + provider.ValidateOnBuild = + context.HostingEnvironment.IsDevelopment(); +}); + +builder.Configuration + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .AddEnvironmentVariables(); + +builder.Logging.ClearProviders().AddSerilog(); + +builder.Host.UseSerilog((context, cfg) + => cfg.ReadFrom.Configuration(context.Configuration)); + +builder.Host.ConfigureServices((context, services) => +{ + services.AddCors(options + => options.AddDefaultPolicy(policyBuilder + => policyBuilder + .AllowAnyOrigin() + .AllowAnyHeader() + .AllowAnyMethod())); + + services.AddGrpc(); + services.AddEventBus(); + services.AddMessageValidators(); + services.AddProjections(); + services.AddInteractors(); + + services.ConfigureEventBusOptions( + context.Configuration.GetSection(nameof(EventBusOptions))); + + services.ConfigureMassTransitHostOptions( + context.Configuration.GetSection(nameof(MassTransitHostOptions))); + + services.AddHttpLogging(options + => options.LoggingFields = HttpLoggingFields.All); +}); + +var app = builder.Build(); + +app.UseCors(); +app.UseSerilogRequestLogging(); +app.MapGrpcService(); + +try +{ + await app.RunAsync(); + Log.Information("Stopped cleanly"); +} +catch (Exception ex) +{ + Log.Fatal(ex, "An unhandled exception occured during bootstrapping"); + await app.StopAsync(); +} +finally +{ + Log.CloseAndFlush(); + await app.DisposeAsync(); +} \ No newline at end of file diff --git a/src/Services/Notification/Query/GrpcService/Properties/launchSettings.json b/src/Services/Notification/Query/GrpcService/Properties/launchSettings.json new file mode 100644 index 000000000..7143321a2 --- /dev/null +++ b/src/Services/Notification/Query/GrpcService/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "Communication.GrpcService": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7133;http://localhost:5133", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/src/Services/Notification/Query/GrpcService/appsettings.Development.json b/src/Services/Notification/Query/GrpcService/appsettings.Development.json new file mode 100644 index 000000000..248a5edba --- /dev/null +++ b/src/Services/Notification/Query/GrpcService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "ConnectionStrings": { + "Projections": "mongodb://mongoadmin:secret@127.0.0.1:27017/CommunicationProjections/?authSource=admin" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@127.0.0.1:5672/eventual-shop" + } +} diff --git a/src/Services/Notification/Query/GrpcService/appsettings.Production.json b/src/Services/Notification/Query/GrpcService/appsettings.Production.json new file mode 100644 index 000000000..b91806e87 --- /dev/null +++ b/src/Services/Notification/Query/GrpcService/appsettings.Production.json @@ -0,0 +1,8 @@ +{ + "ConnectionStrings": { + "Projections": "mongodb://mongoadmin:secret@mongodb/CommunicationProjections/?authSource=admin" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@rabbitmq:5672/eventual-shop" + } +} diff --git a/src/Services/Notification/Query/GrpcService/appsettings.Staging.json b/src/Services/Notification/Query/GrpcService/appsettings.Staging.json new file mode 100644 index 000000000..b91806e87 --- /dev/null +++ b/src/Services/Notification/Query/GrpcService/appsettings.Staging.json @@ -0,0 +1,8 @@ +{ + "ConnectionStrings": { + "Projections": "mongodb://mongoadmin:secret@mongodb/CommunicationProjections/?authSource=admin" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@rabbitmq:5672/eventual-shop" + } +} diff --git a/src/Services/Notification/Query/GrpcService/appsettings.json b/src/Services/Notification/Query/GrpcService/appsettings.json new file mode 100644 index 000000000..b2f137361 --- /dev/null +++ b/src/Services/Notification/Query/GrpcService/appsettings.json @@ -0,0 +1,40 @@ +{ + "HostOptions": { + "ShutdownTimeout": "00:00:25" + }, + "MassTransitHostOptions": { + "WaitUntilStarted": true, + "StartTimeout": "00:00:30", + "StopTimeout": "00:00:15" + }, + "EventBusOptions": { + "ConnectionName": "Communication", + "retryLimit": 3, + "initialInterval": "00:00:05", + "intervalIncrement": "00:00:10" + }, + "Kestrel": { + "EndpointDefaults": { + "Protocols": "Http2" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "MassTransit": "Information", + "Microsoft": "Information" + } + }, + "WriteTo": [ + { + "Name": "Console" + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId" + ] + } +} diff --git a/src/Services/Notification/Query/Infrastructure.EventBus/Abstractions/Consumer.cs b/src/Services/Notification/Query/Infrastructure.EventBus/Abstractions/Consumer.cs new file mode 100644 index 000000000..f97e3c942 --- /dev/null +++ b/src/Services/Notification/Query/Infrastructure.EventBus/Abstractions/Consumer.cs @@ -0,0 +1,12 @@ +using Application.Abstractions; +using Contracts.Abstractions.Messages; +using MassTransit; + +namespace Infrastructure.EventBus.Abstractions; + +public abstract class Consumer(IInteractor interactor) : IConsumer + where TEvent : class, IEvent +{ + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); +} \ No newline at end of file diff --git a/src/Services/Notification/Query/Infrastructure.EventBus/Consumers/Events/ProjectNotificationDetailsWhenNotificationChangedConsumer.cs b/src/Services/Notification/Query/Infrastructure.EventBus/Consumers/Events/ProjectNotificationDetailsWhenNotificationChangedConsumer.cs new file mode 100644 index 000000000..df9599a09 --- /dev/null +++ b/src/Services/Notification/Query/Infrastructure.EventBus/Consumers/Events/ProjectNotificationDetailsWhenNotificationChangedConsumer.cs @@ -0,0 +1,8 @@ +using Application.UseCases.Events; +using Contracts.Boundaries.Notification; +using Infrastructure.EventBus.Abstractions; + +namespace Infrastructure.EventBus.Consumers.Events; + +public class ProjectNotificationDetailsWhenNotificationChangedConsumer(IProjectNotificationDetailsWhenNotificationChangedInteractor interactor) + : Consumer(interactor); \ No newline at end of file diff --git a/src/Services/Notification/Query/Infrastructure.EventBus/DependencyInjection/Extensions/NameFormatterExtensions.cs b/src/Services/Notification/Query/Infrastructure.EventBus/DependencyInjection/Extensions/NameFormatterExtensions.cs new file mode 100644 index 000000000..fd45af74e --- /dev/null +++ b/src/Services/Notification/Query/Infrastructure.EventBus/DependencyInjection/Extensions/NameFormatterExtensions.cs @@ -0,0 +1,16 @@ +using System.Reflection; +using MassTransit; + +namespace Infrastructure.EventBus.DependencyInjection.Extensions; + +internal static class NameFormatterExtensions +{ + public static string ToKebabCaseString(this MemberInfo member) + => KebabCaseEndpointNameFormatter.Instance.SanitizeName(member.Name); +} + +internal class KebabCaseEntityNameFormatter : IEntityNameFormatter +{ + public string FormatEntityName() + => typeof(T).ToKebabCaseString(); +} \ No newline at end of file diff --git a/src/Services/Notification/Query/Infrastructure.EventBus/DependencyInjection/Extensions/RabbitMqBusFactoryConfiguratorExtensions.cs b/src/Services/Notification/Query/Infrastructure.EventBus/DependencyInjection/Extensions/RabbitMqBusFactoryConfiguratorExtensions.cs new file mode 100644 index 000000000..2154e08c6 --- /dev/null +++ b/src/Services/Notification/Query/Infrastructure.EventBus/DependencyInjection/Extensions/RabbitMqBusFactoryConfiguratorExtensions.cs @@ -0,0 +1,26 @@ +using Contracts.Abstractions.Messages; +using Contracts.Boundaries.Notification; +using Infrastructure.EventBus.Consumers.Events; +using MassTransit; + +namespace Infrastructure.EventBus.DependencyInjection.Extensions; + +internal static class RabbitMqBusFactoryConfiguratorExtensions +{ + public static void ConfigureEventReceiveEndpoints(this IRabbitMqBusFactoryConfigurator cfg, IRegistrationContext context) + { + cfg.ConfigureEventReceiveEndpoint(context); + } + + private static void ConfigureEventReceiveEndpoint(this IRabbitMqBusFactoryConfigurator bus, IRegistrationContext context) + where TConsumer : class, IConsumer + where TEvent : class, IEvent + => bus.ReceiveEndpoint( + queueName: $"notification.query.{typeof(TConsumer).ToKebabCaseString()}.{typeof(TEvent).ToKebabCaseString()}", + configureEndpoint: endpoint => + { + endpoint.ConfigureConsumeTopology = false; + endpoint.Bind(); + endpoint.ConfigureConsumer(context); + }); +} \ No newline at end of file diff --git a/src/Services/Notification/Query/Infrastructure.EventBus/DependencyInjection/Extensions/ServiceCollectionExtensions.cs b/src/Services/Notification/Query/Infrastructure.EventBus/DependencyInjection/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..a41918a0e --- /dev/null +++ b/src/Services/Notification/Query/Infrastructure.EventBus/DependencyInjection/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,81 @@ +using System.Reflection; +using Contracts.Abstractions.Messages; +using Contracts.JsonConverters; +using FluentValidation; +using Infrastructure.EventBus.DependencyInjection.Options; +using Infrastructure.EventBus.PipeFilters; +using Infrastructure.EventBus.PipeObservers; +using MassTransit; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; + +namespace Infrastructure.EventBus.DependencyInjection.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddEventBus(this IServiceCollection services) + => services.AddMassTransit(cfg => + { + cfg.SetKebabCaseEndpointNameFormatter(); + cfg.AddConsumers(Assembly.GetExecutingAssembly()); + + cfg.UsingRabbitMq((context, bus) => + { + var options = context.GetRequiredService>().CurrentValue; + + bus.Host( + hostAddress: options.ConnectionString, + connectionName: $"{options.ConnectionName}.{AppDomain.CurrentDomain.FriendlyName}"); + + bus.UseMessageRetry(retry + => retry.Incremental( + retryLimit: options.RetryLimit, + initialInterval: options.InitialInterval, + intervalIncrement: options.IntervalIncrement)); + + bus.UseNewtonsoftJsonSerializer(); + + bus.ConfigureNewtonsoftJsonSerializer(settings => + { + settings.Converters.Add(new TypeNameHandlingConverter(TypeNameHandling.Objects)); + settings.Converters.Add(new DateOnlyJsonConverter()); + settings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + return settings; + }); + + bus.ConfigureNewtonsoftJsonDeserializer(settings => + { + settings.Converters.Add(new TypeNameHandlingConverter(TypeNameHandling.Objects)); + settings.Converters.Add(new DateOnlyJsonConverter()); + settings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + return settings; + }); + + bus.MessageTopology.SetEntityNameFormatter(new KebabCaseEntityNameFormatter()); + bus.UseConsumeFilter(typeof(ContractValidatorFilter<>), context); + bus.ConnectReceiveObserver(new LoggingReceiveObserver()); + bus.ConnectConsumeObserver(new LoggingConsumeObserver()); + bus.ConfigureEventReceiveEndpoints(context); + bus.ConfigureEndpoints(context); + }); + }); + + public static IServiceCollection AddMessageValidators(this IServiceCollection services) + => services.AddValidatorsFromAssemblyContaining(typeof(IMessage)); + + public static OptionsBuilder ConfigureEventBusOptions(this IServiceCollection services, IConfigurationSection section) + => services + .AddOptions() + .Bind(section) + .ValidateDataAnnotations() + .ValidateOnStart(); + + public static OptionsBuilder ConfigureMassTransitHostOptions(this IServiceCollection services, IConfigurationSection section) + => services + .AddOptions() + .Bind(section) + .ValidateDataAnnotations() + .ValidateOnStart(); +} \ No newline at end of file diff --git a/src/Services/Notification/Query/Infrastructure.EventBus/DependencyInjection/Options/EventBusOptions.cs b/src/Services/Notification/Query/Infrastructure.EventBus/DependencyInjection/Options/EventBusOptions.cs new file mode 100644 index 000000000..783e591c9 --- /dev/null +++ b/src/Services/Notification/Query/Infrastructure.EventBus/DependencyInjection/Options/EventBusOptions.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Infrastructure.EventBus.DependencyInjection.Options; + +public record EventBusOptions +{ + [Required] public required string ConnectionName { get; init; } + [Required] public required Uri ConnectionString { get; init; } + [Required, Range(1, 10)] public int RetryLimit { get; init; } + [Required, Timestamp] public TimeSpan InitialInterval { get; init; } + [Required, Timestamp] public TimeSpan IntervalIncrement { get; init; } + [Required, MinLength(5)] public required string SchedulerQueueName { get; init; } +} \ No newline at end of file diff --git a/src/Services/Notification/Query/Infrastructure.EventBus/Infrastructure.EventBus.csproj b/src/Services/Notification/Query/Infrastructure.EventBus/Infrastructure.EventBus.csproj new file mode 100644 index 000000000..e941a792b --- /dev/null +++ b/src/Services/Notification/Query/Infrastructure.EventBus/Infrastructure.EventBus.csproj @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Services/Notification/Query/Infrastructure.EventBus/PipeFilters/ContractValidatorFilter.cs b/src/Services/Notification/Query/Infrastructure.EventBus/PipeFilters/ContractValidatorFilter.cs new file mode 100644 index 000000000..f0aefcaac --- /dev/null +++ b/src/Services/Notification/Query/Infrastructure.EventBus/PipeFilters/ContractValidatorFilter.cs @@ -0,0 +1,36 @@ +using Contracts.Abstractions.Validations; +using FluentValidation; +using MassTransit; +using Serilog; + +namespace Infrastructure.EventBus.PipeFilters; + +public class ContractValidatorFilter(IValidator? validator = default) : IFilter> + where T : class +{ + public async Task Send(ConsumeContext context, IPipe> next) + { + if (validator is null) + { + await next.Send(context); + return; + } + + var validationResult = await validator.ValidateAsync(context.Message, context.CancellationToken); + + if (validationResult.IsValid) + { + await next.Send(context); + return; + } + + Log.Error("Contract validation errors: {Errors}", validationResult.Errors); + + await context.Send( + destinationAddress: new($"queue:communication.{KebabCaseEndpointNameFormatter.Instance.SanitizeName(typeof(T).Name)}.contract-errors"), + message: new ContractValidationResult(context.Message, validationResult.Errors.Select(failure => failure.ErrorMessage))); + } + + public void Probe(ProbeContext context) + => context.CreateFilterScope("Contract validation"); +} \ No newline at end of file diff --git a/src/Services/Notification/Query/Infrastructure.EventBus/PipeObservers/LoggingConsumeObserver.cs b/src/Services/Notification/Query/Infrastructure.EventBus/PipeObservers/LoggingConsumeObserver.cs new file mode 100644 index 000000000..8c8c0818c --- /dev/null +++ b/src/Services/Notification/Query/Infrastructure.EventBus/PipeObservers/LoggingConsumeObserver.cs @@ -0,0 +1,24 @@ +using MassTransit; +using Serilog; + +namespace Infrastructure.EventBus.PipeObservers; + +public class LoggingConsumeObserver : IConsumeObserver +{ + public async Task PreConsume(ConsumeContext context) + where T : class + { + await Task.Yield(); + + Log.Information("Consuming {Message} message from {Namespace}, CorrelationId: {CorrelationId}", + typeof(T).Name, typeof(T).Namespace, context.CorrelationId); + } + + public Task PostConsume(ConsumeContext context) + where T : class + => Task.CompletedTask; + + public Task ConsumeFault(ConsumeContext context, Exception exception) + where T : class + => Task.CompletedTask; +} \ No newline at end of file diff --git a/src/Services/Notification/Query/Infrastructure.EventBus/PipeObservers/LoggingReceiveObserver.cs b/src/Services/Notification/Query/Infrastructure.EventBus/PipeObservers/LoggingReceiveObserver.cs new file mode 100644 index 000000000..7a6a8c42f --- /dev/null +++ b/src/Services/Notification/Query/Infrastructure.EventBus/PipeObservers/LoggingReceiveObserver.cs @@ -0,0 +1,51 @@ +using MassTransit; +using Serilog; + +namespace Infrastructure.EventBus.PipeObservers; + +public class LoggingReceiveObserver : IReceiveObserver +{ + private const string ExchangeKey = "RabbitMQ-ExchangeName"; + + public async Task PreReceive(ReceiveContext context) + { + await Task.Yield(); + + Log.Debug("Receiving message from exchange {Exchange}, Redelivered: {Redelivered}, CorrelationId: {CorrelationId}", + context.TransportHeaders.Get(ExchangeKey), context.Redelivered, context.GetCorrelationId()); + } + + public async Task PostReceive(ReceiveContext context) + { + await Task.Yield(); + + Log.Debug("Message was received from exchange {Exchange}, Redelivered: {Redelivered}, CorrelationId: {CorrelationId}", + context.TransportHeaders.Get(ExchangeKey), context.Redelivered, context.GetCorrelationId()); + } + + public async Task PostConsume(ConsumeContext context, TimeSpan duration, string consumerType) + where T : class + { + await Task.Yield(); + + Log.Debug("{Message} message from {Namespace} was consumed by {ConsumerType}, Duration: {Duration}s, CorrelationId: {CorrelationId}", + typeof(T).Name, typeof(T).Namespace, consumerType, duration.TotalSeconds, context.CorrelationId); + } + + public async Task ConsumeFault(ConsumeContext context, TimeSpan duration, string consumerType, Exception exception) + where T : class + { + await Task.Yield(); + + Log.Error("Fault on consuming message {Message} from {Namespace} by {ConsumerType}, Duration: {Duration}s, Error: {Error}, CorrelationId: {CorrelationId}", + typeof(T).Name, typeof(T).Namespace, consumerType, duration.TotalSeconds, exception.Message, context.CorrelationId); + } + + public async Task ReceiveFault(ReceiveContext context, Exception exception) + { + await Task.Yield(); + + Log.Error("Fault on receiving message from exchange {Exchange}, Redelivered: {Redelivered}, Error: {Error}, CorrelationId: {CorrelationId}", + context.TransportHeaders.Get(ExchangeKey), context.Redelivered, exception.Message, context.GetCorrelationId() ?? new()); + } +} \ No newline at end of file diff --git a/src/Services/Notification/Query/Infrastructure.Projections/Abstractions/IMongoDbContext.cs b/src/Services/Notification/Query/Infrastructure.Projections/Abstractions/IMongoDbContext.cs new file mode 100644 index 000000000..9f626b6de --- /dev/null +++ b/src/Services/Notification/Query/Infrastructure.Projections/Abstractions/IMongoDbContext.cs @@ -0,0 +1,8 @@ +using MongoDB.Driver; + +namespace Infrastructure.Projections.Abstractions; + +public interface IMongoDbContext +{ + IMongoCollection GetCollection(); +} \ No newline at end of file diff --git a/src/Services/Notification/Query/Infrastructure.Projections/Abstractions/MongoDbContext.cs b/src/Services/Notification/Query/Infrastructure.Projections/Abstractions/MongoDbContext.cs new file mode 100644 index 000000000..60397e62b --- /dev/null +++ b/src/Services/Notification/Query/Infrastructure.Projections/Abstractions/MongoDbContext.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Configuration; +using MongoDB.Driver; + +namespace Infrastructure.Projections.Abstractions; + +public abstract class MongoDbContext : IMongoDbContext +{ + private readonly IMongoDatabase _database; + + protected MongoDbContext(IConfiguration configuration) + { + MongoUrl mongoUrl = new(configuration.GetConnectionString("Projections")); + _database = new MongoClient(mongoUrl).GetDatabase(mongoUrl.DatabaseName); + } + + public IMongoCollection GetCollection() + => _database.GetCollection(typeof(T).Name); +} \ No newline at end of file diff --git a/src/Services/Notification/Query/Infrastructure.Projections/DependencyInjection/ServiceCollectionExtensions.cs b/src/Services/Notification/Query/Infrastructure.Projections/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..c20aeb3e0 --- /dev/null +++ b/src/Services/Notification/Query/Infrastructure.Projections/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using Application.Abstractions; +using Infrastructure.Projections.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; + +namespace Infrastructure.Projections.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static void AddProjections(this IServiceCollection services) + { + services.AddScoped(typeof(IProjectionGateway<>), typeof(ProjectionGateway<>)); + services.AddScoped(); + BsonSerializer.RegisterSerializer(new GuidSerializer(BsonType.String)); + } +} \ No newline at end of file diff --git a/src/Services/Notification/Query/Infrastructure.Projections/Infrastructure.Projections.csproj b/src/Services/Notification/Query/Infrastructure.Projections/Infrastructure.Projections.csproj new file mode 100644 index 000000000..1e2b801e3 --- /dev/null +++ b/src/Services/Notification/Query/Infrastructure.Projections/Infrastructure.Projections.csproj @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/Services/Notification/Query/Infrastructure.Projections/Pagination/PagedResult.cs b/src/Services/Notification/Query/Infrastructure.Projections/Pagination/PagedResult.cs new file mode 100644 index 000000000..a231de95e --- /dev/null +++ b/src/Services/Notification/Query/Infrastructure.Projections/Pagination/PagedResult.cs @@ -0,0 +1,30 @@ +using Contracts.Abstractions; +using Contracts.Abstractions.Paging; +using MongoDB.Driver; +using MongoDB.Driver.Linq; + +namespace Infrastructure.Projections.Pagination; + +public record PagedResult(IReadOnlyCollection Projections, Paging Paging) : IPagedResult + where TProjection : IProjection +{ + public IReadOnlyCollection Items + => Page.HasNext ? Projections.Take(Paging.Limit).ToList() : Projections; + + public Page Page => new() + { + Current = Paging.Offset + 1, + Size = Paging.Limit, + HasNext = Paging.Limit < Projections.Count, + HasPrevious = Paging.Offset > 0 + }; + + public static async ValueTask> CreateAsync(Paging paging, IQueryable source, CancellationToken cancellationToken) + { + var projections = await ApplyPagination(paging, source).ToListAsync(cancellationToken); + return new PagedResult(projections, paging); + } + + private static IMongoQueryable ApplyPagination(Paging paging, IQueryable source) + => (IMongoQueryable)source.Skip(paging.Limit * paging.Offset).Take(paging.Limit + 1); +} \ No newline at end of file diff --git a/src/Services/Notification/Query/Infrastructure.Projections/ProjectionDbContext.cs b/src/Services/Notification/Query/Infrastructure.Projections/ProjectionDbContext.cs new file mode 100644 index 000000000..f935de000 --- /dev/null +++ b/src/Services/Notification/Query/Infrastructure.Projections/ProjectionDbContext.cs @@ -0,0 +1,6 @@ +using Infrastructure.Projections.Abstractions; +using Microsoft.Extensions.Configuration; + +namespace Infrastructure.Projections; + +public class ProjectionDbContext(IConfiguration configuration) : MongoDbContext(configuration); \ No newline at end of file diff --git a/src/Services/Notification/Query/Infrastructure.Projections/ProjectionGateway.cs b/src/Services/Notification/Query/Infrastructure.Projections/ProjectionGateway.cs new file mode 100644 index 000000000..d05cf6db6 --- /dev/null +++ b/src/Services/Notification/Query/Infrastructure.Projections/ProjectionGateway.cs @@ -0,0 +1,61 @@ +using System.Linq.Expressions; +using Application.Abstractions; +using Contracts.Abstractions; +using Contracts.Abstractions.Paging; +using Infrastructure.Projections.Abstractions; +using Infrastructure.Projections.Pagination; +using MongoDB.Driver; +using MongoDB.Driver.Linq; +using Serilog; + +namespace Infrastructure.Projections; + +public class ProjectionGateway(IMongoDbContext context) : IProjectionGateway + where TProjection : IProjection +{ + private readonly IMongoCollection _collection = context.GetCollection(); + + public Task GetAsync(TId id, CancellationToken cancellationToken) where TId : struct + => FindAsync(projection => projection.Id.Equals(id), cancellationToken); + + public Task FindAsync(Expression> predicate, CancellationToken cancellationToken) + => _collection.AsQueryable().Where(predicate).FirstOrDefaultAsync(cancellationToken)!; + + public ValueTask> ListAsync(Paging paging, Expression> predicate, CancellationToken cancellationToken) + => PagedResult.CreateAsync(paging, _collection.AsQueryable().Where(predicate), cancellationToken); + + public ValueTask> ListAsync(Paging paging, CancellationToken cancellationToken) + => PagedResult.CreateAsync(paging, _collection.AsQueryable(), cancellationToken); + + public Task DeleteAsync(Expression> filter, CancellationToken cancellationToken) + => _collection.DeleteManyAsync(filter, cancellationToken); + + public Task DeleteAsync(TId id, CancellationToken cancellationToken) where TId : struct + => _collection.DeleteOneAsync(projection => projection.Id.Equals(id), cancellationToken); + + public Task UpdateFieldAsync(TId id, ulong version, Expression> field, TField value, CancellationToken cancellationToken) where TId : struct + => _collection.UpdateOneAsync( + filter: projection => projection.Id.Equals(id) && projection.Version < version, + update: new ObjectUpdateDefinition(new()).Set(field, value), + cancellationToken: cancellationToken); + + public ValueTask ReplaceInsertAsync(TProjection replacement, CancellationToken cancellationToken) + => OnReplaceAsync(replacement, projection => projection.Id == replacement.Id && projection.Version < replacement.Version, cancellationToken); + + public ValueTask RebuildInsertAsync(TProjection replacement, CancellationToken cancellationToken) + => OnReplaceAsync(replacement, projection => projection.Id == replacement.Id && projection.Version <= replacement.Version, cancellationToken); + + private async ValueTask OnReplaceAsync(TProjection replacement, Expression> filter, CancellationToken cancellationToken) + { + try + { + await _collection.ReplaceOneAsync(filter, replacement, new ReplaceOptions { IsUpsert = true }, cancellationToken); + } + catch (MongoWriteException e) when (e.WriteError.Category is ServerErrorCategory.DuplicateKey) + { + Log.Warning( + "By passing Duplicate Key when inserting {ProjectionType} with Id {Id}", + typeof(TProjection).Name, replacement.Id); + } + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Application/Abstractions/Gateways/IEventBusGateway.cs b/src/Services/Shopping/Command/Application/Abstractions/Gateways/IEventBusGateway.cs new file mode 100644 index 000000000..9bb6b0f04 --- /dev/null +++ b/src/Services/Shopping/Command/Application/Abstractions/Gateways/IEventBusGateway.cs @@ -0,0 +1,12 @@ +using Contracts.Abstractions.Messages; + +namespace Application.Abstractions.Gateways; + +public interface IEventBusGateway +{ + Task PublishAsync(TEvent @event, CancellationToken cancellationToken) + where TEvent : class, IEvent; + + Task SchedulePublishAsync(TEvent @event, DateTimeOffset scheduledTime, CancellationToken cancellationToken) + where TEvent : class, IDelayedEvent; +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Application/Abstractions/IInteractor.cs b/src/Services/Shopping/Command/Application/Abstractions/IInteractor.cs new file mode 100644 index 000000000..baf1cf7c5 --- /dev/null +++ b/src/Services/Shopping/Command/Application/Abstractions/IInteractor.cs @@ -0,0 +1,7 @@ +namespace Application.Abstractions; + +public interface IInteractor + where TCommand : class +{ + Task InteractAsync(TCommand cmd, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Application/Abstractions/IUnitOfWork.cs b/src/Services/Shopping/Command/Application/Abstractions/IUnitOfWork.cs new file mode 100644 index 000000000..9a3479aed --- /dev/null +++ b/src/Services/Shopping/Command/Application/Abstractions/IUnitOfWork.cs @@ -0,0 +1,6 @@ +namespace Application.Abstractions; + +public interface IUnitOfWork +{ + Task ExecuteAsync(Func operationAsync, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Application/DependencyInjection/Extensions/ServiceCollectionExtensions.cs b/src/Services/Shopping/Command/Application/DependencyInjection/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..1f3599888 --- /dev/null +++ b/src/Services/Shopping/Command/Application/DependencyInjection/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,40 @@ +using System.Reflection; +using Application.Abstractions; +using Application.Services; +using Application.UseCases.Checkouts.Commands; +using Application.UseCases.ShoppingCarts.Events; +using Microsoft.Extensions.DependencyInjection; +using Checkout = Contracts.Boundaries.Shopping.Checkout.Command; + +namespace Application.DependencyInjection.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + => services + .AddApplicationServices() + .AddCheckoutCommandInteractors() + .AddCheckoutEventInteractors() + .AddShoppingCommandHandlers() + .AddShoppingCartEventInteractors(); + + private static IServiceCollection AddApplicationServices(this IServiceCollection services) + => services.AddScoped(); + + private static IServiceCollection AddCheckoutCommandInteractors(this IServiceCollection services) + => services + .AddScoped, AddBillingAddressInteractor>() + .AddScoped, AddCreditCardInteractor>() + .AddScoped, AddDebitCardInteractor>() + .AddScoped, AddPayPalInteractor>() + .AddScoped, AddShippingAddressInteractor>(); + + private static IServiceCollection AddCheckoutEventInteractors(this IServiceCollection services) + => services; + + private static IServiceCollection AddShoppingCommandHandlers(this IServiceCollection services) + => services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly())); + + private static IServiceCollection AddShoppingCartEventInteractors(this IServiceCollection services) + => services.AddScoped(); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Application/Services/ApplicationService.cs b/src/Services/Shopping/Command/Application/Services/ApplicationService.cs new file mode 100644 index 000000000..8facb50ca --- /dev/null +++ b/src/Services/Shopping/Command/Application/Services/ApplicationService.cs @@ -0,0 +1,106 @@ +using System.Linq.Expressions; +using Application.Abstractions; +using Application.Abstractions.Gateways; +using Contracts.Abstractions.Messages; +using Domain.Abstractions.Aggregates; +using Domain.Abstractions.EventStore; +using Domain.Abstractions.Identities; +using InvalidOperationException = System.InvalidOperationException; +using Version = Domain.ValueObjects.Version; + +namespace Application.Services; + +public class ApplicationService( + IEventStoreGateway eventStoreGateway, + //IOptions options, + IEventBusGateway eventBusGateway, + IUnitOfWork unitOfWork) + : IApplicationService +{ + public async Task LoadAggregateAsync(TId id, CancellationToken cancellationToken) + where TAggregate : class, IAggregateRoot, new() + where TId : IIdentifier, new() + { + var snapshot = await eventStoreGateway.GetSnapshotAsync(id, cancellationToken); + var events = await eventStoreGateway.GetStreamAsync(id, snapshot?.Version ?? Version.Zero, cancellationToken); + return LoadAggregate(snapshot, events); + } + + public async Task LoadAggregateAsync(Func predicate, CancellationToken cancellationToken) + where TAggregate : class, IAggregateRoot, new() + where TId : IIdentifier, new() + { + var snapshotExpression = BuildExpression, bool>(predicate); + var storeEventExpression = BuildExpression, bool>(predicate); + + var snapshot = await eventStoreGateway.GetSnapshotAsync(snapshotExpression, cancellationToken); + var events = await eventStoreGateway.GetStreamAsync(storeEventExpression, snapshot?.Version ?? Version.Zero, cancellationToken); + + if (snapshot is null && events is { Count: 0 }) return new(); + + var aggregate = snapshot?.Aggregate ?? new(); + aggregate.LoadFromHistory(events); + + return aggregate is { IsDeleted: false } + ? aggregate + : throw new InvalidOperationException($"Aggregate {typeof(TAggregate).Name} is deleted."); + } + + private static TAggregate LoadAggregate(Snapshot? snapshot, List events) + where TAggregate : class, IAggregateRoot, new() + where TId : IIdentifier, new() + { + if (snapshot is null && events is { Count: 0 }) + throw new InvalidOperationException($"Aggregate {typeof(TAggregate).Name} not found."); + + var aggregate = snapshot?.Aggregate ?? new TAggregate(); + aggregate.LoadFromHistory(events); + + return aggregate is { IsDeleted: false } + ? aggregate + : throw new InvalidOperationException($"Aggregate {typeof(TAggregate).Name} is deleted."); + } + + private static Expression> BuildExpression(Func func) + { + var inputParameter = Expression.Parameter(typeof(TEntity), "entity"); + var convertedParameter = Expression.Convert(inputParameter, typeof(TInput)); + var body = Expression.Invoke(Expression.Constant(func), convertedParameter); + return Expression.Lambda>(body, inputParameter); + } + + public Task AppendEventsAsync(TAggregate aggregate, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new() + => unitOfWork.ExecuteAsync( + operationAsync: async ct => + { + while (aggregate.TryDequeueEvent(out var @event)) + { + if (@event is null) continue; + + var storeEvent = StoreEvent.Create(aggregate, @event); + await eventStoreGateway.AppendAsync(storeEvent, ct); + + if (storeEvent.Version % 5) //options.Value.SnapshotInterval) + { + var snapshot = Snapshot.Create(aggregate, storeEvent); + await eventStoreGateway.AppendAsync(snapshot, ct); + } + + await eventBusGateway.PublishAsync(@event, ct); + } + }, + cancellationToken: cancellationToken); + + public IAsyncEnumerable StreamAggregatesId() + where TAggregate : IAggregateRoot + where TId : IIdentifier, new() + => eventStoreGateway.StreamAggregatesId(); + + public Task PublishEventAsync(IEvent @event, CancellationToken cancellationToken) + => eventBusGateway.PublishAsync(@event, cancellationToken); + + public Task SchedulePublishAsync(IDelayedEvent @event, DateTimeOffset scheduledTime, CancellationToken cancellationToken) + => eventBusGateway.SchedulePublishAsync(@event, scheduledTime, cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Application/Services/IApplicationService.cs b/src/Services/Shopping/Command/Application/Services/IApplicationService.cs new file mode 100644 index 000000000..41a2cd118 --- /dev/null +++ b/src/Services/Shopping/Command/Application/Services/IApplicationService.cs @@ -0,0 +1,28 @@ +using Contracts.Abstractions.Messages; +using Domain.Abstractions.Aggregates; +using Domain.Abstractions.Identities; + +namespace Application.Services; + +public interface IApplicationService +{ + Task AppendEventsAsync(TAggregate aggregate, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new(); + + Task LoadAggregateAsync(TId id, CancellationToken cancellationToken) + where TAggregate : class, IAggregateRoot, new() + where TId : IIdentifier, new(); + + Task LoadAggregateAsync(Func predicate, CancellationToken cancellationToken) + where TAggregate : class, IAggregateRoot, new() + where TId : IIdentifier, new(); + + IAsyncEnumerable StreamAggregatesId() + where TAggregate : IAggregateRoot + where TId : IIdentifier, new(); + + Task PublishEventAsync(IEvent @event, CancellationToken cancellationToken); + + Task SchedulePublishAsync(IDelayedEvent @event, DateTimeOffset scheduledTime, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Application/UseCases/Checkouts/Commands/AddBillingAddressInteractor.cs b/src/Services/Shopping/Command/Application/UseCases/Checkouts/Commands/AddBillingAddressInteractor.cs new file mode 100644 index 000000000..6ab2eb4b3 --- /dev/null +++ b/src/Services/Shopping/Command/Application/UseCases/Checkouts/Commands/AddBillingAddressInteractor.cs @@ -0,0 +1,28 @@ +using Application.Abstractions; +using Application.Services; +using Contracts.Boundaries.Shopping.Checkout; +using Domain.Aggregates.Checkouts; +using Domain.ValueObjects.Addresses; + +namespace Application.UseCases.Checkouts.Commands; + +public class AddBillingAddressInteractor(IApplicationService service) : IInteractor +{ + public async Task InteractAsync(Command.AddBillingAddress cmd, CancellationToken cancellationToken) + { + var checkout = await service.LoadAggregateAsync((CheckoutId)cmd.CheckoutId, cancellationToken); + + Address address = new( + cmd.City, + cmd.Complement, + cmd.Country, + cmd.Number, + cmd.State, + cmd.Street, + cmd.ZipCode); + + checkout.AddBillingAddress(address); + + await service.AppendEventsAsync(checkout, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Application/UseCases/Checkouts/Commands/AddCreditCardInteractor.cs b/src/Services/Shopping/Command/Application/UseCases/Checkouts/Commands/AddCreditCardInteractor.cs new file mode 100644 index 000000000..7e34dc1b9 --- /dev/null +++ b/src/Services/Shopping/Command/Application/UseCases/Checkouts/Commands/AddCreditCardInteractor.cs @@ -0,0 +1,25 @@ +using Application.Abstractions; +using Application.Services; +using Contracts.Boundaries.Shopping.Checkout; +using Domain.Aggregates.Checkouts; +using Domain.ValueObjects.PaymentMethods; + +namespace Application.UseCases.Checkouts.Commands; + +public class AddCreditCardInteractor(IApplicationService service) : IInteractor +{ + public async Task InteractAsync(Command.AddCreditCard cmd, CancellationToken cancellationToken) + { + var checkout = await service.LoadAggregateAsync((CheckoutId)cmd.CheckoutId, cancellationToken); + + CreditCard creditCard = new( + cmd.ExpirationDate, + cmd.Number, + cmd.HolderName, + cmd.Cvv); + + checkout.AddCreditCard(creditCard); + + await service.AppendEventsAsync(checkout, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Application/UseCases/Checkouts/Commands/AddDebitCardInteractor.cs b/src/Services/Shopping/Command/Application/UseCases/Checkouts/Commands/AddDebitCardInteractor.cs new file mode 100644 index 000000000..894a27ad6 --- /dev/null +++ b/src/Services/Shopping/Command/Application/UseCases/Checkouts/Commands/AddDebitCardInteractor.cs @@ -0,0 +1,25 @@ +using Application.Abstractions; +using Application.Services; +using Contracts.Boundaries.Shopping.Checkout; +using Domain.Aggregates.Checkouts; +using Domain.ValueObjects.PaymentMethods; + +namespace Application.UseCases.Checkouts.Commands; + +public class AddDebitCardInteractor(IApplicationService service) : IInteractor +{ + public async Task InteractAsync(Command.AddDebitCard cmd, CancellationToken cancellationToken) + { + var checkout = await service.LoadAggregateAsync((CheckoutId)cmd.CheckoutId, cancellationToken); + + DebitCard debitCard = new( + cmd.ExpirationDate, + cmd.Number, + cmd.HolderName, + cmd.Cvv); + + checkout.AddDebitCard(debitCard); + + await service.AppendEventsAsync(checkout, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Application/UseCases/Checkouts/Commands/AddPayPalInteractor.cs b/src/Services/Shopping/Command/Application/UseCases/Checkouts/Commands/AddPayPalInteractor.cs new file mode 100644 index 000000000..79c6b4506 --- /dev/null +++ b/src/Services/Shopping/Command/Application/UseCases/Checkouts/Commands/AddPayPalInteractor.cs @@ -0,0 +1,23 @@ +using Application.Abstractions; +using Application.Services; +using Contracts.Boundaries.Shopping.Checkout; +using Domain.Aggregates.Checkouts; +using Domain.ValueObjects.PaymentMethods; + +namespace Application.UseCases.Checkouts.Commands; + +public class AddPayPalInteractor(IApplicationService service) : IInteractor +{ + public async Task InteractAsync(Command.AddPayPal cmd, CancellationToken cancellationToken) + { + var checkout = await service.LoadAggregateAsync((CheckoutId)cmd.CheckoutId, cancellationToken); + + PayPal payPal = new( + cmd.Email, + cmd.Password); + + checkout.AddPayPal(payPal); + + await service.AppendEventsAsync(checkout, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Application/UseCases/Checkouts/Commands/AddShippingAddressInteractor.cs b/src/Services/Shopping/Command/Application/UseCases/Checkouts/Commands/AddShippingAddressInteractor.cs new file mode 100644 index 000000000..0e50a83e4 --- /dev/null +++ b/src/Services/Shopping/Command/Application/UseCases/Checkouts/Commands/AddShippingAddressInteractor.cs @@ -0,0 +1,28 @@ +using Application.Abstractions; +using Application.Services; +using Contracts.Boundaries.Shopping.Checkout; +using Domain.Aggregates.Checkouts; +using Domain.ValueObjects.Addresses; + +namespace Application.UseCases.Checkouts.Commands; + +public class AddShippingAddressInteractor(IApplicationService service) : IInteractor +{ + public async Task InteractAsync(Command.AddShippingAddress cmd, CancellationToken cancellationToken) + { + var checkout = await service.LoadAggregateAsync((CheckoutId)cmd.CheckoutId, cancellationToken); + + Address address = new( + cmd.City, + cmd.Complement, + cmd.Country, + cmd.Number, + cmd.State, + cmd.Street, + cmd.ZipCode); + + checkout.AddShippingAddress(address); + + await service.AppendEventsAsync(checkout, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/AddCartItemInteractor.cs b/src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/AddCartItemInteractor.cs new file mode 100644 index 000000000..4753a291f --- /dev/null +++ b/src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/AddCartItemInteractor.cs @@ -0,0 +1,36 @@ +using Application.Services; +using Domain.Aggregates.Products; +using Domain.Aggregates.ShoppingCarts; +using Domain.Entities.CartItems; +using Domain.ValueObjects; +using MediatR; + +namespace Application.UseCases.ShoppingCarts.Commands; + +public record AddCartItem(CartId CartId, ProductId ProductId, Quantity Quantity) : IRequest; + +public class AddCartItemInteractor(IApplicationService service) : IRequestHandler +{ + public async Task Handle(AddCartItem cmd, CancellationToken cancellationToken) + { + var cart = await service.LoadAggregateAsync(cmd.CartId, cancellationToken); + var product = await service.LoadAggregateAsync(cmd.ProductId, cancellationToken); + + ArgumentOutOfRangeException.ThrowIfGreaterThan(cmd.Quantity, product.Stock, "Product out of stock."); + + CartItem newItem = new( + CartItemId.New, + product.Id, + product.Name, + product.PictureUri, + product.Sku, + product.Prices, + cmd.Quantity); + + cart.AddItem(newItem); + + await service.AppendEventsAsync(cart, cancellationToken); + + return newItem.Id; + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/ChangeCartItemQuantityInteractor.cs b/src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/ChangeCartItemQuantityInteractor.cs new file mode 100644 index 000000000..951099415 --- /dev/null +++ b/src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/ChangeCartItemQuantityInteractor.cs @@ -0,0 +1,22 @@ +using Application.Abstractions; +using Application.Services; +using Contracts.Boundaries.Shopping.ShoppingCart; +using Domain.Aggregates.Products; +using Domain.Aggregates.ShoppingCarts; +using Domain.ValueObjects; + +namespace Application.UseCases.ShoppingCarts.Commands; + +public class ChangeCartItemQuantityInteractor(IApplicationService service) : IInteractor +{ + public async Task InteractAsync(Command.ChangeCartItemQuantity cmd, CancellationToken cancellationToken) + { + var cart = await service.LoadAggregateAsync((CartId)cmd.CartId, cancellationToken); + + cart.ChangeItemQuantity( + (ProductId)cmd.ProductId, + (Quantity)cmd.NewQuantity); + + await service.AppendEventsAsync(cart, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/CheckOutCartInteractor.cs b/src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/CheckOutCartInteractor.cs new file mode 100644 index 000000000..7741b9f2f --- /dev/null +++ b/src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/CheckOutCartInteractor.cs @@ -0,0 +1,16 @@ +using Application.Abstractions; +using Application.Services; +using Contracts.Boundaries.Shopping.ShoppingCart; +using Domain.Aggregates.ShoppingCarts; + +namespace Application.UseCases.ShoppingCarts.Commands; + +public class CheckOutCartInteractor(IApplicationService service) : IInteractor +{ + public async Task InteractAsync(Command.CheckOutCart cmd, CancellationToken cancellationToken) + { + var cart = await service.LoadAggregateAsync((CartId)cmd.CartId, cancellationToken); + cart.CheckOut(); + await service.AppendEventsAsync(cart, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/DiscardCartInteractor.cs b/src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/DiscardCartInteractor.cs new file mode 100644 index 000000000..0202f4ad5 --- /dev/null +++ b/src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/DiscardCartInteractor.cs @@ -0,0 +1,16 @@ +using Application.Abstractions; +using Application.Services; +using Contracts.Boundaries.Shopping.ShoppingCart; +using Domain.Aggregates.ShoppingCarts; + +namespace Application.UseCases.ShoppingCarts.Commands; + +public class DiscardCartInteractor(IApplicationService service) : IInteractor +{ + public async Task InteractAsync(Command.DiscardCart cmd, CancellationToken cancellationToken) + { + var cart = await service.LoadAggregateAsync((CartId)cmd.CartId, cancellationToken); + cart.Discard(); + await service.AppendEventsAsync(cart, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/RebuildCartProjectionInteractor.cs b/src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/RebuildCartProjectionInteractor.cs new file mode 100644 index 000000000..9ce83d048 --- /dev/null +++ b/src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/RebuildCartProjectionInteractor.cs @@ -0,0 +1,15 @@ +using Application.Abstractions; +using Application.Services; +using Contracts.Boundaries.Shopping.ShoppingCart; +using Domain.Aggregates.ShoppingCarts; + +namespace Application.UseCases.ShoppingCarts.Commands; + +public class RebuildCartProjectionInteractor(IApplicationService service) : IInteractor +{ + public async Task InteractAsync(Command.RebuildCartProjection cmd, CancellationToken cancellationToken) + { + await foreach (var cartId in service.StreamAggregatesId().WithCancellation(cancellationToken)) + await service.PublishEventAsync(new NotificationEvent.CartProjectionRebuildRequested(cartId, cmd.Projection), cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/RemoveCartItemInteractor.cs b/src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/RemoveCartItemInteractor.cs new file mode 100644 index 000000000..f1de1dc0c --- /dev/null +++ b/src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/RemoveCartItemInteractor.cs @@ -0,0 +1,17 @@ +using Application.Abstractions; +using Application.Services; +using Contracts.Boundaries.Shopping.ShoppingCart; +using Domain.Aggregates.Products; +using Domain.Aggregates.ShoppingCarts; + +namespace Application.UseCases.ShoppingCarts.Commands; + +public class RemoveCartItemInteractor(IApplicationService service) : IInteractor +{ + public async Task InteractAsync(Command.RemoveCartItem cmd, CancellationToken cancellationToken) + { + var shoppingCart = await service.LoadAggregateAsync((CartId)cmd.CartId, cancellationToken); + shoppingCart.RemoveItem((ProductId)cmd.ProductId); + await service.AppendEventsAsync(shoppingCart, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/StartShoppingInteractor.cs b/src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/StartShoppingInteractor.cs new file mode 100644 index 000000000..c22dbe654 --- /dev/null +++ b/src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Commands/StartShoppingInteractor.cs @@ -0,0 +1,21 @@ +using Application.Services; +using Domain.Aggregates.ShoppingCarts; +using Domain.Enumerations; +using MediatR; + +namespace Application.UseCases.ShoppingCarts.Commands; + +public record StartShopping(CustomerId CustomerId) : IRequest; + +public class StartShoppingInteractor(IApplicationService service) : IRequestHandler +{ + public async Task Handle(StartShopping cmd, CancellationToken cancellationToken) + { + var cart = await service.LoadAggregateAsync(cart => cart.CustomerId == cmd.CustomerId, cancellationToken); + if (cart.Status is CartStatusOpen or CartStatusAbandoned) return cart.Id; + + cart = ShoppingCart.StartShopping(cmd.CustomerId); + await service.AppendEventsAsync(cart, cancellationToken); + return cart.Id; + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Events/PublishProjectionRebuiltWhenRequestedInteractor.cs b/src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Events/PublishProjectionRebuiltWhenRequestedInteractor.cs new file mode 100644 index 000000000..5bc1c3bee --- /dev/null +++ b/src/Services/Shopping/Command/Application/UseCases/ShoppingCarts/Events/PublishProjectionRebuiltWhenRequestedInteractor.cs @@ -0,0 +1,23 @@ +using Application.Abstractions; +using Application.Services; +using Contracts.Boundaries.Shopping.ShoppingCart; +using Contracts.DataTransferObjects; +using Domain.Aggregates.ShoppingCarts; + +namespace Application.UseCases.ShoppingCarts.Events; + +public interface IPublishCartProjectionRebuiltWhenRequestedInteractor : IInteractor; + +public class PublishCartProjectionRebuiltWhenRequestedInteractor(IApplicationService service) : IPublishCartProjectionRebuiltWhenRequestedInteractor +{ + public async Task InteractAsync(NotificationEvent.CartProjectionRebuildRequested cmd, CancellationToken cancellationToken) + { + var shoppingCart = await service.LoadAggregateAsync((CartId)cmd.CartId, cancellationToken); + + // TODO: Solve the projection rebuilding strategy + Dto.ShoppingCart cart = default!; + + SummaryEvent.CartProjectionRebuilt cartProjectionRebuilt = new(cart, shoppingCart.Version); + await service.PublishEventAsync(cartProjectionRebuilt, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/Abstractions/Aggregates/AggregateRoot.cs b/src/Services/Shopping/Command/Domain/Abstractions/Aggregates/AggregateRoot.cs new file mode 100644 index 000000000..7f70ec3e5 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/Abstractions/Aggregates/AggregateRoot.cs @@ -0,0 +1,33 @@ +using Contracts.Abstractions.Messages; +using Domain.Abstractions.Entities; +using Domain.Abstractions.Identities; +using Version = Domain.ValueObjects.Version; + +namespace Domain.Abstractions.Aggregates; + +public abstract class AggregateRoot : Entity, IAggregateRoot + where TId : IIdentifier, new() +{ + private readonly Queue _events = new(); + public Version Version { get; private set; } = Version.Zero; + + public void LoadFromHistory(IEnumerable events) + { + foreach (var @event in events) + { + ApplyEvent(@event); + Version = (Version)@event.Version; + } + } + + public bool TryDequeueEvent(out IDomainEvent? @event) => _events.TryDequeue(out @event); + private void EnqueueEvent(IDomainEvent @event) => _events.Enqueue(@event); + + protected void RaiseEvent(IDomainEvent @event) + { + ApplyEvent(@event); + EnqueueEvent(@event); + } + + protected abstract void ApplyEvent(IDomainEvent @event); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/Abstractions/Aggregates/IAggregateRoot.cs b/src/Services/Shopping/Command/Domain/Abstractions/Aggregates/IAggregateRoot.cs new file mode 100644 index 000000000..174557dd9 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/Abstractions/Aggregates/IAggregateRoot.cs @@ -0,0 +1,14 @@ +using Contracts.Abstractions.Messages; +using Domain.Abstractions.Entities; +using Domain.Abstractions.Identities; +using Version = Domain.ValueObjects.Version; + +namespace Domain.Abstractions.Aggregates; + +public interface IAggregateRoot : IEntity + where TId : IIdentifier, new() +{ + Version Version { get; } + void LoadFromHistory(IEnumerable events); + bool TryDequeueEvent(out IDomainEvent? @event); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/Abstractions/DomainException.cs b/src/Services/Shopping/Command/Domain/Abstractions/DomainException.cs new file mode 100644 index 000000000..d3cb9951d --- /dev/null +++ b/src/Services/Shopping/Command/Domain/Abstractions/DomainException.cs @@ -0,0 +1,22 @@ +using Contracts.Abstractions.Messages; + +namespace Domain.Abstractions; + +public abstract class DomainException(string message) : + InvalidOperationException(message) + where TException : DomainException, new() +{ + public static TException New() => new(); + + public static void ThrowIf(bool condition) + { + if (condition) throw new TException(); + } + + public static void ThrowIfNull(T t) + { + if (t is null) throw new TException(); + } + + public static IDomainEvent Throw() => throw new TException(); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/Abstractions/Entities/Entity.cs b/src/Services/Shopping/Command/Domain/Abstractions/Entities/Entity.cs new file mode 100644 index 000000000..6548a80c9 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/Abstractions/Entities/Entity.cs @@ -0,0 +1,20 @@ +namespace Domain.Abstractions.Entities; + +public abstract class Entity : IEntity + where TId : notnull, new() +{ + public TId Id { get; protected set; } = new(); + public bool IsDeleted { get; protected set; } + + public static bool operator ==(Entity left, Entity right) + => left.Id.Equals(right.Id); + + public static bool operator !=(Entity left, Entity right) + => left.Id.Equals(right.Id) is false; + + public override bool Equals(object? obj) + => obj is Entity entity && Id.Equals(entity.Id); + + public override int GetHashCode() + => HashCode.Combine(Id); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/Abstractions/Entities/IEntity.cs b/src/Services/Shopping/Command/Domain/Abstractions/Entities/IEntity.cs new file mode 100644 index 000000000..fe71e1292 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/Abstractions/Entities/IEntity.cs @@ -0,0 +1,8 @@ +namespace Domain.Abstractions.Entities; + +public interface IEntity + where TId : notnull +{ + TId Id { get; } + bool IsDeleted { get; } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/Abstractions/EventStore/Snapshot.cs b/src/Services/Shopping/Command/Domain/Abstractions/EventStore/Snapshot.cs new file mode 100644 index 000000000..734c2f464 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/Abstractions/EventStore/Snapshot.cs @@ -0,0 +1,13 @@ +using Domain.Abstractions.Aggregates; +using Domain.Abstractions.Identities; +using Version = Domain.ValueObjects.Version; + +namespace Domain.Abstractions.EventStore; + +public record Snapshot(TId AggregateId, TAggregate Aggregate, Version Version, DateTimeOffset Timestamp) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new() +{ + public static Snapshot Create(TAggregate aggregate, StoreEvent @event) + => new(aggregate.Id, aggregate, @event.Version, @event.Timestamp); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/Abstractions/EventStore/StoreEvent.cs b/src/Services/Shopping/Command/Domain/Abstractions/EventStore/StoreEvent.cs new file mode 100644 index 000000000..61d7db765 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/Abstractions/EventStore/StoreEvent.cs @@ -0,0 +1,14 @@ +using Contracts.Abstractions.Messages; +using Domain.Abstractions.Aggregates; +using Domain.Abstractions.Identities; +using Version = Domain.ValueObjects.Version; + +namespace Domain.Abstractions.EventStore; + +public record StoreEvent(TId AggregateId, string EventType, IDomainEvent Event, Version Version, DateTimeOffset Timestamp) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new() +{ + public static StoreEvent Create(TAggregate aggregate, IDomainEvent @event) + => new(aggregate.Id, @event.GetType().Name, @event, aggregate.Version, @event.Timestamp); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/Abstractions/Identities/GuidIdentity.cs b/src/Services/Shopping/Command/Domain/Abstractions/Identities/GuidIdentity.cs new file mode 100644 index 000000000..0bafa334a --- /dev/null +++ b/src/Services/Shopping/Command/Domain/Abstractions/Identities/GuidIdentity.cs @@ -0,0 +1,29 @@ +using static Domain.Exceptions; + +namespace Domain.Abstractions.Identities; + +public interface IIdentifier; + +public abstract record GuidIdentifier : IIdentifier +{ + public Guid Value { get; init; } + + protected GuidIdentifier() + { + Value = Guid.NewGuid(); + } + + protected GuidIdentifier(string value) + { + InvalidIdentifier.ThrowIf( + Guid.TryParse(value, out var result) is false); + + Value = result; + } + + public static implicit operator string(GuidIdentifier id) => id.Value.ToString(); + public static implicit operator Guid(GuidIdentifier id) => id.Value; + public static bool operator ==(GuidIdentifier id, string value) => id.Value.CompareTo(value) is 0; + public static bool operator !=(GuidIdentifier id, string value) => id.Value.CompareTo(value) is not 0; + public override string ToString() => Value.ToString(); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/Aggregates/Checkouts/Checkout.cs b/src/Services/Shopping/Command/Domain/Aggregates/Checkouts/Checkout.cs new file mode 100644 index 000000000..0a1ea74b7 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/Aggregates/Checkouts/Checkout.cs @@ -0,0 +1,82 @@ +using Contracts.Abstractions.Messages; +using Contracts.Boundaries.Shopping.Checkout; +using Domain.Abstractions.Aggregates; +using Domain.Aggregates.ShoppingCarts; +using Domain.ValueObjects.Addresses; +using Domain.ValueObjects.PaymentMethods; +using Version = Domain.ValueObjects.Version; + +namespace Domain.Aggregates.Checkouts; + +public class Checkout : AggregateRoot +{ + public CartId CartId { get; private set; } = CartId.Undefined; + public IPaymentMethod PaymentMethod { get; private set; } = IPaymentMethod.Undefined; + public Address ShippingAddress { get; private set; } = Address.Undefined; + public Address BillingAddress { get; private set; } = Address.Undefined; + + public static Checkout StartCheckout(CartId cartId) + { + Checkout checkout = new(); + DomainEvent.CheckoutStarted @event = new(checkout.Id, cartId, Version.Initial); + checkout.RaiseEvent(@event); + return checkout; + } + + public void AddCreditCard(CreditCard card) + { + if (PaymentMethod.Equals(card)) return; + + RaiseEvent(new DomainEvent.CreditCardAdded(Id, CartId, + card.ExpirationDate, card.Number, card.HolderName, card.Cvv, Version.Next)); + } + + public void AddDebitCard(DebitCard card) + { + if (PaymentMethod.Equals(card)) return; + + RaiseEvent(new DomainEvent.DebitCardAdded(Id, CartId, + card.ExpirationDate, card.Number, card.HolderName, card.Cvv, Version.Next)); + } + + public void AddPayPal(PayPal payPal) + { + if (PaymentMethod.Equals(payPal)) return; + RaiseEvent(new DomainEvent.PayPalAdded(Id, CartId, payPal.Email, payPal.Password, Version.Next)); + } + + public void AddBillingAddress(Address address) + { + if (BillingAddress == address) return; + + RaiseEvent(new DomainEvent.BillingAddressAdded(Id, CartId, address.City, address.Complement, + address.Country, address.Number, address.State, address.Street, address.ZipCode, Version.Next)); + } + + public void AddShippingAddress(Address address) + { + if (ShippingAddress == address) return; + + RaiseEvent(new DomainEvent.ShippingAddressAdded(Id, CartId, address.City, address.Complement, + address.Country, address.Number, address.State, address.Street, address.ZipCode, Version.Next)); + } + + protected override void ApplyEvent(IDomainEvent @event) => When(@event as dynamic); + + private void When(DomainEvent.CreditCardAdded @event) + => PaymentMethod = new CreditCard(@event.ExpirationDate, @event.Number, @event.HolderName, @event.Cvv); + + private void When(DomainEvent.DebitCardAdded @event) + => PaymentMethod = new DebitCard(@event.ExpirationDate, @event.Number, @event.HolderName, @event.Cvv); + + private void When(DomainEvent.PayPalAdded @event) + => PaymentMethod = new PayPal(@event.Email, @event.Password); + + private void When(DomainEvent.BillingAddressAdded @event) + => BillingAddress = new(@event.City, @event.Complement, + @event.Country, @event.Number, @event.State, @event.Street, @event.ZipCode); + + private void When(DomainEvent.ShippingAddressAdded @event) + => ShippingAddress = new(@event.City, @event.Complement, + @event.Country, @event.Number, @event.State, @event.Street, @event.ZipCode); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/Aggregates/Checkouts/CheckoutId.cs b/src/Services/Shopping/Command/Domain/Aggregates/Checkouts/CheckoutId.cs new file mode 100644 index 000000000..39a6cf4d0 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/Aggregates/Checkouts/CheckoutId.cs @@ -0,0 +1,15 @@ +using Domain.Abstractions.Identities; + +namespace Domain.Aggregates.Checkouts; + +public record CheckoutId : GuidIdentifier +{ + public CheckoutId() { } + public CheckoutId(string value) : base(value) { } + + public static CheckoutId New => new(); + public static readonly CheckoutId Undefined = new() { Value = Guid.Empty }; + + public static explicit operator CheckoutId(string value) => new(value); + public override string ToString() => base.ToString(); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/Aggregates/ShoppingCarts/CartId.cs b/src/Services/Shopping/Command/Domain/Aggregates/ShoppingCarts/CartId.cs new file mode 100644 index 000000000..fc3d14c37 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/Aggregates/ShoppingCarts/CartId.cs @@ -0,0 +1,15 @@ +using Domain.Abstractions.Identities; + +namespace Domain.Aggregates.ShoppingCarts; + +public record CartId : GuidIdentifier +{ + public CartId() { } + public CartId(string value) : base(value) { } + + public static CartId New => new(); + public static readonly CartId Undefined = new() { Value = Guid.Empty }; + + public static explicit operator CartId(string value) => new(value); + public override string ToString() => base.ToString(); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/Aggregates/ShoppingCarts/CatalogId.cs b/src/Services/Shopping/Command/Domain/Aggregates/ShoppingCarts/CatalogId.cs new file mode 100644 index 000000000..b9c2f3391 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/Aggregates/ShoppingCarts/CatalogId.cs @@ -0,0 +1,15 @@ +using Domain.Abstractions.Identities; + +namespace Domain.Aggregates.ShoppingCarts; + +public record CatalogId : GuidIdentifier +{ + public CatalogId() { } + public CatalogId(string value) : base(value) { } + + public static CatalogId New => new(); + public static readonly CatalogId Undefined = new() { Value = Guid.Empty }; + + public static explicit operator CatalogId(string value) => new(value); + public override string ToString() => base.ToString(); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/Aggregates/ShoppingCarts/CustomerId.cs b/src/Services/Shopping/Command/Domain/Aggregates/ShoppingCarts/CustomerId.cs new file mode 100644 index 000000000..4d12140e9 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/Aggregates/ShoppingCarts/CustomerId.cs @@ -0,0 +1,15 @@ +using Domain.Abstractions.Identities; + +namespace Domain.Aggregates.ShoppingCarts; + +public record CustomerId : GuidIdentifier +{ + public CustomerId() { } + public CustomerId(string value) : base(value) { } + + public static CustomerId New => new(); + public static readonly CustomerId Undefined = new() { Value = Guid.Empty }; + + public static explicit operator CustomerId(string value) => new(value); + public override string ToString() => base.ToString(); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/Aggregates/ShoppingCarts/InventoryId.cs b/src/Services/Shopping/Command/Domain/Aggregates/ShoppingCarts/InventoryId.cs new file mode 100644 index 000000000..80885c98e --- /dev/null +++ b/src/Services/Shopping/Command/Domain/Aggregates/ShoppingCarts/InventoryId.cs @@ -0,0 +1,15 @@ +using Domain.Abstractions.Identities; + +namespace Domain.Aggregates.ShoppingCarts; + +public record InventoryId : GuidIdentifier +{ + public InventoryId() { } + public InventoryId(string value) : base(value) { } + + public static InventoryId New => new(); + public static readonly InventoryId Undefined = new() { Value = Guid.Empty }; + + public static implicit operator InventoryId(string value) => new(value); + public override string ToString() => base.ToString(); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/Aggregates/ShoppingCarts/ShoppingCart.cs b/src/Services/Shopping/Command/Domain/Aggregates/ShoppingCarts/ShoppingCart.cs new file mode 100644 index 000000000..6f71caee7 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/Aggregates/ShoppingCarts/ShoppingCart.cs @@ -0,0 +1,166 @@ +using Contracts.Abstractions.Messages; +using Contracts.Boundaries.Shopping.ShoppingCart; +using Domain.Abstractions.Aggregates; +using Domain.Aggregates.Products; +using Domain.Entities.CartItems; +using Domain.Enumerations; +using Domain.Extensions; +using Domain.ValueObjects; +using Newtonsoft.Json; +using static Domain.Exceptions; +using Version = Domain.ValueObjects.Version; + +namespace Domain.Aggregates.ShoppingCarts; + +public class ShoppingCart : AggregateRoot +{ + [JsonProperty] + private readonly Dictionary _items = new(); + + [JsonProperty] + private IDictionary _totals = + Currency.All.ToDictionary(Currency (currency) => currency, Money.Zero); + + public CustomerId CustomerId { get; private set; } = CustomerId.Undefined; + public CartStatus Status { get; private set; } = CartStatus.Empty; + public IDictionary Totals => _totals.AsReadOnly(); + public IEnumerable Items => _items.Values; + + public static ShoppingCart StartShopping(CustomerId customerId) + { + ShoppingCart cart = new(); + DomainEvent.ShoppingStarted @event = new(cart.Id, customerId, CartStatus.Open, Version.Initial); + cart.RaiseEvent(@event); + return cart; + } + + public void AddItem(CartItem newItem) + { + CartNotOpen.ThrowIf(Status != CartStatus.Open); + + _items.TryGetValue(newItem.ProductId, out var item); + + RaiseEvent(item is { IsDeleted: false } ? ItemIncreased() : ItemAdded()); + + DomainEvent.CartItemIncreased ItemIncreased() + => new(Id, item.Id, item.ProductId, item.Quantity + newItem.Quantity, + item.Prices.AsString(), Totals.Project(newItem), Version.Next); + + DomainEvent.CartItemAdded ItemAdded() + => new(Id, newItem.Id, newItem.ProductId, newItem.ProductName, newItem.PictureUri, newItem.Sku, + newItem.Quantity, newItem.Prices.AsString(), Totals.Project(newItem), Version.Next); + } + + public void ChangeItemQuantity(ProductId productId, Quantity newQuantity) + { + CartNotOpen.ThrowIf(Status is not CartStatusOpen); + + _items.TryGetValue(productId, out var item); + + CartItemNotFound.ThrowIf(item is not { IsDeleted: false }); + + RaiseEvent(newQuantity switch + { + _ when newQuantity == Quantity.Zero => ItemRemoved(), + _ when newQuantity < item!.Quantity => ItemDecreased(), + _ when newQuantity > item.Quantity => ItemIncreased(), + _ => InvalidQuantity.Throw() + }); + + DomainEvent.CartItemIncreased ItemIncreased() => + new(Id, item.Id, item.ProductId, newQuantity, item.Prices.AsString(), + Totals.Project(item.Prices, newQuantity), Version.Next); + + DomainEvent.CartItemDecreased ItemDecreased() => + new(Id, item.Id, item.ProductId, newQuantity, item.Prices.AsString(), + Totals.Project(item.Prices, newQuantity), Version.Next); + + DomainEvent.CartItemRemoved ItemRemoved() => + new(Id, item!.Id, item.ProductId, item.Prices.AsString(), + Totals.Project(item.Prices, newQuantity), Version.Next); + } + + public void RemoveItem(ProductId productId) + { + _items.TryGetValue(productId, out var item); + + CartItemNotFound.ThrowIf(item is not { IsDeleted: false }); + + RaiseEvent(new DomainEvent.CartItemRemoved(Id, item!.Id, item.ProductId, + item.Prices.AsString(), Totals.Project(item.Prices, Quantity.Zero), Version.Next)); + } + + public void CheckOut() + { + CartNotOpen.ThrowIf(Status is not CartStatusOpen); + CartIsEmpty.ThrowIf(_items is { Count: 0 }); + + RaiseEvent(new DomainEvent.CartCheckedOut(Id, CartStatus.CheckedOut, Version.Next)); + } + + public void Discard() + { + if (Status == CartStatus.Abandoned) return; + RaiseEvent(new DomainEvent.CartDiscarded(Id, CartStatus.Abandoned, Version.Next)); + } + + protected override void ApplyEvent(IDomainEvent @event) => When(@event as dynamic); + + private void When(DomainEvent.ShoppingStarted @event) + { + Id = (CartId)@event.CartId; + CustomerId = (CustomerId)@event.CustomerId; + Status = (CartStatus)@event.Status; + } + + private void When(DomainEvent.CartCheckedOut @event) + => Status = (CartStatus)@event.Status; + + private void When(DomainEvent.CartDiscarded @event) + { + Status = (CartStatus)@event.Status; + IsDeleted = true; + } + + private void When(DomainEvent.CartItemAdded @event) + { + var itemId = (CartItemId)@event.ItemId; + var productId = (ProductId)@event.ProductId; + var productName = (ProductName)@event.ProductName; + var pictureUri = (PictureUri)@event.PictureUri; + var sku = (Sku)@event.Sku; + var quantity = (Quantity)@event.Quantity; + + var prices = @event.Prices.ToPriceDictionary(); + + _items[productId] = new(itemId, productId, productName, pictureUri, sku, prices, quantity); + + _totals = @event.Totals.ToMoneyDictionary(); + } + + private void When(DomainEvent.CartItemIncreased @event) + { + var productId = (ProductId)@event.ProductId; + var newQuantity = (Quantity)@event.NewQuantity; + + _items[productId].SetQuantity(newQuantity); + _totals = @event.Totals.ToMoneyDictionary(); + } + + private void When(DomainEvent.CartItemDecreased @event) + { + var productId = (ProductId)@event.ProductId; + var newQuantity = (Quantity)@event.NewQuantity; + + _items[productId].SetQuantity(newQuantity); + _totals = @event.Totals.ToMoneyDictionary(); + } + + private void When(DomainEvent.CartItemRemoved @event) + { + var productId = (ProductId)@event.ProductId; + + _items[productId].Delete(); + _totals = @event.Totals.ToMoneyDictionary(); + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/Domain.csproj b/src/Services/Shopping/Command/Domain/Domain.csproj new file mode 100644 index 000000000..1504b0fff --- /dev/null +++ b/src/Services/Shopping/Command/Domain/Domain.csproj @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/Services/Shopping/Command/Domain/Entities/CartItems/CartItem.cs b/src/Services/Shopping/Command/Domain/Entities/CartItems/CartItem.cs new file mode 100644 index 000000000..5458af77e --- /dev/null +++ b/src/Services/Shopping/Command/Domain/Entities/CartItems/CartItem.cs @@ -0,0 +1,62 @@ +using Domain.Abstractions.Entities; +using Domain.Aggregates.Products; +using Domain.ValueObjects; + +namespace Domain.Entities.CartItems; + +public class CartItem : Entity +{ + private Dictionary _totals = new(); + private readonly IDictionary _prices; + + public CartItem(CartItemId id, ProductId productId, ProductName productName, PictureUri pictureUri, + Sku sku, IDictionary prices, Quantity quantity) + { + Id = id; + ProductId = productId; + ProductName = productName; + PictureUri = pictureUri; + Sku = sku; + Quantity = quantity; + _prices = prices; + CalculateTotals(); + } + + public ProductId ProductId { get; } + public ProductName ProductName { get; private set; } + public PictureUri PictureUri { get; } + public Sku Sku { get; } + public Quantity Quantity { get; private set; } + public IDictionary Prices => _prices.AsReadOnly(); + public IDictionary Totals => _totals.AsReadOnly(); + + public void SetQuantity(Quantity quantity) + { + Quantity = quantity; + CalculateTotals(); + } + + public void Delete() + => IsDeleted = true; + + private void CalculateTotals() + => _totals = Prices.ToDictionary( + price => price.Key, + price => price.Value * Quantity as Money); + + public static bool operator ==(CartItem left, CartItem right) + => left.Id.Equals(right.Id) && + left.ProductId.Equals(right.ProductId); + + public static bool operator !=(CartItem left, CartItem right) + => left.Id.Equals(right.Id) is false || + left.ProductId.Equals(right.ProductId) is false; + + public override bool Equals(object? obj) + { + if (obj is not CartItem item) return false; + return Id.Equals(item.Id) && ProductId.Equals(item.ProductId); + } + + public override int GetHashCode() => HashCode.Combine(Id, ProductId); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/Entities/CartItems/CartItemId.cs b/src/Services/Shopping/Command/Domain/Entities/CartItems/CartItemId.cs new file mode 100644 index 000000000..b05840870 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/Entities/CartItems/CartItemId.cs @@ -0,0 +1,19 @@ +using Domain.Abstractions.Identities; + +namespace Domain.Entities.CartItems; + +public record CartItemId : GuidIdentifier +{ + // TODO: CartItemId should be a composite of CartId and ProductId + // public CartId CartId { get; init; } = CartId.Undefined; + // public ProductId ProductId { get; init; } = ProductId.Undefined; + + public CartItemId() { } + public CartItemId(string value) : base(value) { } + + public static CartItemId New => new(); + public static readonly CartItemId Undefined = new() { Value = Guid.Empty }; + + public static implicit operator CartItemId(string value) => new(value); + public override string ToString() => base.ToString(); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/Enumerations/CartStatus.cs b/src/Services/Shopping/Command/Domain/Enumerations/CartStatus.cs new file mode 100644 index 000000000..eece51013 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/Enumerations/CartStatus.cs @@ -0,0 +1,31 @@ +namespace Domain.Enumerations; + +public record CartStatus(string Name, int Value) +{ + public static readonly CartStatusEmpty Empty = new(); + public static readonly CartStatusOpen Open = new(); + public static readonly CartStatusAbandoned Abandoned = new(); + public static readonly CartStatusCheckedOut CheckedOut = new(); + + public static explicit operator CartStatus(string name) + => typeof(CartStatus).GetField(name)?.GetValue(default) as CartStatus + ?? throw new ArgumentException($"Invalid {nameof(CartStatus)} name: {name}"); + + public static explicit operator CartStatus(int value) + => typeof(CartStatus).GetFields() + .Select(field => field.GetValue(default) as CartStatus) + .FirstOrDefault(status => status?.Value == value) + ?? throw new ArgumentException($"Invalid {nameof(CartStatus)} value: {value}"); + + public static implicit operator string(CartStatus status) => status.Name; + public static implicit operator int(CartStatus status) => status.Value; + public override string ToString() => Name; +} + +public record CartStatusEmpty() : CartStatus(nameof(Empty), 0); + +public record CartStatusOpen() : CartStatus(nameof(Open), 1); + +public record CartStatusAbandoned() : CartStatus(nameof(Abandoned), 2); + +public record CartStatusCheckedOut() : CartStatus(nameof(CheckedOut), 3); \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/ValueObjects/Addresses/Address.cs b/src/Services/Shopping/Command/Domain/ValueObjects/Addresses/Address.cs new file mode 100644 index 000000000..e633ca628 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/ValueObjects/Addresses/Address.cs @@ -0,0 +1,21 @@ +using Contracts.DataTransferObjects; + +namespace Domain.ValueObjects.Addresses; + +public record Address(City City, Complement Complement, Country Country, Number Number, State State, Street Street, ZipCode ZipCode) +{ + public static implicit operator Address(Dto.Address address) + => new(address.Street, address.City, address.State, address.ZipCode, address.Country, address.Number, address.Complement); + + public static implicit operator Dto.Address(Address address) + => new(address.Street, address.City, address.State, address.ZipCode, address.Country, address.Number, address.Complement); + + public static bool operator ==(Address address, Dto.Address dto) + => dto == (Dto.Address)address; + + public static bool operator !=(Address address, Dto.Address dto) + => dto != (Dto.Address)address; + + public static Address Undefined + => new("Undefined", "Undefined", "Undefined", "Undefined", "Undefined", "Undefined", "Undefined"); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/ValueObjects/Addresses/City.cs b/src/Services/Shopping/Command/Domain/ValueObjects/Addresses/City.cs new file mode 100644 index 000000000..5faf2947b --- /dev/null +++ b/src/Services/Shopping/Command/Domain/ValueObjects/Addresses/City.cs @@ -0,0 +1,18 @@ +namespace Domain.ValueObjects.Addresses; + +public record City +{ + public string Value { get; } + + public City(string city) + { + city = city.Trim(); + ArgumentException.ThrowIfNullOrEmpty(city, nameof(city)); + ArgumentOutOfRangeException.ThrowIfGreaterThan(city.Length, 100, nameof(city)); + + Value = city; + } + + public static implicit operator City(string city) => new(city); + public static implicit operator string(City city) => city.Value; +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/ValueObjects/Addresses/Complement.cs b/src/Services/Shopping/Command/Domain/ValueObjects/Addresses/Complement.cs new file mode 100644 index 000000000..a45fed0a7 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/ValueObjects/Addresses/Complement.cs @@ -0,0 +1,17 @@ +namespace Domain.ValueObjects.Addresses; + +public record Complement +{ + public string Value { get; } + + public Complement(string complement) + { + complement = complement.Trim(); + ArgumentOutOfRangeException.ThrowIfGreaterThan(complement.Length, 10, nameof(complement)); + + Value = string.IsNullOrEmpty(complement) ? "N/A" : complement; + } + + public static implicit operator Complement(string complement) => new(complement); + public static implicit operator string(Complement complement) => complement.Value; +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/ValueObjects/Addresses/Country.cs b/src/Services/Shopping/Command/Domain/ValueObjects/Addresses/Country.cs new file mode 100644 index 000000000..ab9f3b603 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/ValueObjects/Addresses/Country.cs @@ -0,0 +1,18 @@ +namespace Domain.ValueObjects.Addresses; + +public record Country +{ + public string Value { get; } + + public Country(string country) + { + country = country.Trim(); + ArgumentException.ThrowIfNullOrEmpty(country, nameof(country)); + ArgumentOutOfRangeException.ThrowIfGreaterThan(country.Length, 100, nameof(country)); + + Value = country; + } + + public static implicit operator Country(string country) => new(country); + public static implicit operator string(Country country) => country.Value; +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/ValueObjects/Addresses/Number.cs b/src/Services/Shopping/Command/Domain/ValueObjects/Addresses/Number.cs new file mode 100644 index 000000000..c67094e23 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/ValueObjects/Addresses/Number.cs @@ -0,0 +1,17 @@ +namespace Domain.ValueObjects.Addresses; + +public record Number +{ + public string Value { get; } + + public Number(string number) + { + number = number.Trim(); + ArgumentOutOfRangeException.ThrowIfGreaterThan(number.Length, 10, nameof(number)); + + Value = string.IsNullOrEmpty(number) ? "N/A" : number; + } + + public static implicit operator Number(string number) => new(number); + public static implicit operator string(Number number) => number.Value; +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/ValueObjects/Addresses/State.cs b/src/Services/Shopping/Command/Domain/ValueObjects/Addresses/State.cs new file mode 100644 index 000000000..1de22e6c1 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/ValueObjects/Addresses/State.cs @@ -0,0 +1,18 @@ +namespace Domain.ValueObjects.Addresses; + +public record State +{ + public string Value { get; } + + public State(string state) + { + state = state.Trim(); + ArgumentException.ThrowIfNullOrEmpty(state, nameof(state)); + ArgumentOutOfRangeException.ThrowIfGreaterThan(state.Length, 100, nameof(state)); + + Value = state; + } + + public static implicit operator State(string state) => new(state); + public static implicit operator string(State state) => state.Value; +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/ValueObjects/Addresses/Street.cs b/src/Services/Shopping/Command/Domain/ValueObjects/Addresses/Street.cs new file mode 100644 index 000000000..21e2149e0 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/ValueObjects/Addresses/Street.cs @@ -0,0 +1,18 @@ +namespace Domain.ValueObjects.Addresses; + +public record Street +{ + public string Value { get; } + + public Street(string street) + { + street = street.Trim(); + ArgumentException.ThrowIfNullOrEmpty(street, nameof(street)); + ArgumentOutOfRangeException.ThrowIfGreaterThan(street.Length, 100, nameof(street)); + + Value = street; + } + + public static implicit operator Street(string street) => new(street); + public static implicit operator string(Street street) => street.Value; +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/ValueObjects/Addresses/ZipCode.cs b/src/Services/Shopping/Command/Domain/ValueObjects/Addresses/ZipCode.cs new file mode 100644 index 000000000..6bfc234e8 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/ValueObjects/Addresses/ZipCode.cs @@ -0,0 +1,18 @@ +namespace Domain.ValueObjects.Addresses; + +public record ZipCode +{ + public string Value { get; } + + public ZipCode(string zipCode) + { + zipCode = zipCode.Trim(); + ArgumentException.ThrowIfNullOrEmpty(zipCode, nameof(zipCode)); + ArgumentOutOfRangeException.ThrowIfGreaterThan(zipCode.Length, 10, nameof(zipCode)); + + Value = zipCode; + } + + public static implicit operator ZipCode(string zipCode) => new(zipCode); + public static implicit operator string(ZipCode zipCode) => zipCode.Value; +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/ValueObjects/Currency.cs b/src/Services/Shopping/Command/Domain/ValueObjects/Currency.cs new file mode 100644 index 000000000..a7175c387 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/ValueObjects/Currency.cs @@ -0,0 +1,47 @@ +using System.Globalization; + +namespace Domain.ValueObjects; + +public record Currency(string IsoCode, string Name, string Country, NumberFormatInfo FormatInfo) +{ + public static readonly Currency BRL = new("BRL", "Brazilian real", "Brazil", new CultureInfo("pt-BR").NumberFormat); + public static readonly Currency CAD = new("CAD", "Canadian dollar", "Canada", new CultureInfo("en-CA").NumberFormat); + public static readonly Currency USD = new("USD", "United States dollar", "United States", new CultureInfo("en-US").NumberFormat); + public static readonly Currency EUR = new("EUR", "Euro", "European Union", new CultureInfo("fr-FR").NumberFormat); + public static readonly Currency GBP = new("GBP", "British pound", "United Kingdom", new CultureInfo("en-GB").NumberFormat); + public static readonly Currency JPY = new("JPY", "Japanese yen", "Japan", new CultureInfo("ja-JP").NumberFormat); + public static readonly Currency CHF = new("CHF", "Swiss franc", "Switzerland", new CultureInfo("de-CH").NumberFormat); + public static readonly Currency AUD = new("AUD", "Australian dollar", "Australia", new CultureInfo("en-AU").NumberFormat); + public static readonly Currency CNY = new("CNY", "Chinese yuan", "China", new CultureInfo("zh-CN").NumberFormat); + public static readonly Currency INR = new("INR", "Indian rupee", "India", new CultureInfo("hi-IN").NumberFormat); + public static readonly Currency MXN = new("MXN", "Mexican peso", "Mexico", new CultureInfo("es-MX").NumberFormat); + public static readonly Currency Undefined = new("Undefined", "Undefined", "Undefined", NumberFormatInfo.InvariantInfo); + + public Currency(string IsoCode) : this(IsoCode, All[IsoCode].Name, All[IsoCode].Country, All[IsoCode].FormatInfo) { } + + public static Dictionary All { get; } = new() + { + { BRL.IsoCode, BRL }, { CAD.IsoCode, CAD }, { USD.IsoCode, USD }, + { EUR.IsoCode, EUR }, { GBP.IsoCode, GBP }, { JPY.IsoCode, JPY }, + { CHF.IsoCode, CHF }, { AUD.IsoCode, AUD }, { CNY.IsoCode, CNY }, + { INR.IsoCode, INR }, { MXN.IsoCode, MXN } + }; + + public static explicit operator Currency(string isoCode) + => All.TryGetValue(isoCode, out var currency) ? currency + : throw new ArgumentException($"Currency {isoCode} is not supported."); + + public static implicit operator string(Currency currency) => currency.IsoCode; + + public static implicit operator Currency(KeyValuePair pair) => pair.Value; + + public static bool operator ==(Currency currency, string value) + => string.Equals(currency.IsoCode, value.Trim(), StringComparison.OrdinalIgnoreCase) || + string.Equals(currency.FormatInfo.CurrencySymbol, value.Trim(), StringComparison.OrdinalIgnoreCase); + + public static bool operator !=(Currency currency, string value) + => string.Equals(currency.IsoCode, value.Trim(), StringComparison.OrdinalIgnoreCase) && + string.Equals(currency.FormatInfo.CurrencySymbol, value.Trim(), StringComparison.OrdinalIgnoreCase) is false; + + public override string ToString() => IsoCode; +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/ValueObjects/Money.cs b/src/Services/Shopping/Command/Domain/ValueObjects/Money.cs new file mode 100644 index 000000000..8309ae5eb --- /dev/null +++ b/src/Services/Shopping/Command/Domain/ValueObjects/Money.cs @@ -0,0 +1,58 @@ +namespace Domain.ValueObjects; + +public record Money(Amount Amount, Currency Currency) +{ + public static Money Zero(Currency currency) => new(Amount.Zero, currency); + public static Money Zero(KeyValuePair pair) => Zero(pair.Value); + + public static Money operator +(Money money, Money other) + => ApplyOperator(money, other, (first, second) => first.Amount + second.Amount); + + public static Money operator -(Money money, Money other) + => ApplyOperator(money, other, (first, second) => first.Amount - second.Amount); + + public static Money operator *(Money money, Money other) + => ApplyOperator(money, other, (first, second) => first.Amount * second.Amount); + + public static Money operator *(Money money, Quantity quantity) + => money with { Amount = new(money.Amount * quantity) }; + + public static Money operator /(Money money, Money other) + => ApplyDivideByZeroOperator(money, other, (first, second) => first.Amount / second.Amount); + + public static Money operator %(Money money, Money other) + => ApplyDivideByZeroOperator(money, other, (first, second) => first.Amount % second.Amount); + + public static bool operator >(Money money, Money other) + => ApplyOperator(money, other, (first, second) => first.Amount > second.Amount); + + public static bool operator <(Money money, Money other) + => ApplyOperator(money, other, (first, second) => first.Amount < second.Amount); + + public static implicit operator string(Money money) => money.Amount; + public override string ToString() => Amount.ToString("C", Currency.FormatInfo); + + private static Money ApplyOperator(Money money, Money other, Func operation) + { + EnsureCurrenciesAreEqual(money, other); + return money with { Amount = operation(money, other) }; + } + + private static bool ApplyOperator(Money money, Money other, Func operation) + { + EnsureCurrenciesAreEqual(money, other); + return operation(money, other); + } + + private static Money ApplyDivideByZeroOperator(Money money, Money other, Func operation) + { + if (other.Amount == decimal.Zero) throw new DivideByZeroException(); + return ApplyOperator(money, other, operation); + } + + private static void EnsureCurrenciesAreEqual(Money money, Money other) + { + if (money.Currency != other.Currency) + throw new InvalidOperationException("Currencies must be the same"); + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/CardHolderName.cs b/src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/CardHolderName.cs new file mode 100644 index 000000000..659104675 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/CardHolderName.cs @@ -0,0 +1,21 @@ +using static Domain.Exceptions; + +namespace Domain.ValueObjects.PaymentMethods; + +public record CardHolderName +{ + private readonly string _value; + + public CardHolderName(string value) + { + ArgumentException.ThrowIfNullOrEmpty(value); + + InvalidCardholderName.ThrowIf( + value.Length is < 2 or > 50 || value.All(char.IsLetter) is false); + + _value = value.Trim(); + } + + public static implicit operator string(CardHolderName name) => name._value; + public static implicit operator CardHolderName(string name) => new(name); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/CreditCard.cs b/src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/CreditCard.cs new file mode 100644 index 000000000..7262cd7b4 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/CreditCard.cs @@ -0,0 +1,12 @@ +using Contracts.DataTransferObjects; + +namespace Domain.ValueObjects.PaymentMethods; + +public record CreditCard(ExpirationDate ExpirationDate, CreditCardNumber Number, CardHolderName HolderName, Cvv Cvv) : IPaymentMethod +{ + public static implicit operator CreditCard(Dto.CreditCard card) + => new(card.ExpirationDate, card.Number, card.HolderName, card.Cvv); + + public static implicit operator Dto.CreditCard(CreditCard card) + => new(card.ExpirationDate, card.Number, card.HolderName, card.Cvv); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/CreditCardNumber.cs b/src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/CreditCardNumber.cs new file mode 100644 index 000000000..42e728fc8 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/CreditCardNumber.cs @@ -0,0 +1,25 @@ +using static Domain.Exceptions; + +namespace Domain.ValueObjects.PaymentMethods; + +public record CreditCardNumber +{ + private readonly string _value; + + public CreditCardNumber(string value) + { + value = value.Replace(" ", ""); + + InvalidCardNumber.ThrowIf( + string.IsNullOrEmpty(value) || + value.Length is not 16 || + value.All(char.IsDigit) is false); + + _value = value; + } + + public static implicit operator CreditCardNumber(string number) => new(number); + public static implicit operator string(CreditCardNumber number) => number._value; + + public override string ToString() => _value.Insert(4, " ").Insert(9, " ").Insert(14, " "); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/Cvv.cs b/src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/Cvv.cs new file mode 100644 index 000000000..94e969b5c --- /dev/null +++ b/src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/Cvv.cs @@ -0,0 +1,22 @@ +using static Domain.Exceptions; + +namespace Domain.ValueObjects.PaymentMethods; + +public record Cvv +{ + private readonly string _value; + + public Cvv(string value) + { + InvalidSecurityCode.ThrowIf( + value.Length is not 3 || + value.All(char.IsDigit) is false); + + _value = value; + } + + public static implicit operator Cvv(string cvv) => new(cvv); + public static implicit operator string(Cvv cvv) => cvv._value; + + public override string ToString() => _value; +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/DebitCard.cs b/src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/DebitCard.cs new file mode 100644 index 000000000..69ad8d364 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/DebitCard.cs @@ -0,0 +1,16 @@ +using Contracts.DataTransferObjects; + +namespace Domain.ValueObjects.PaymentMethods; + +public record DebitCard( + ExpirationDate ExpirationDate, + CreditCardNumber Number, + CardHolderName HolderName, + Cvv Cvv) : IPaymentMethod +{ + public static implicit operator DebitCard(Dto.DebitCard card) + => new(card.ExpirationDate, card.Number, card.HolderName, card.SecurityCode); + + public static implicit operator Dto.DebitCard(DebitCard card) + => new(card.ExpirationDate, card.Number, card.HolderName, card.Cvv); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/ExpirationDate.cs b/src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/ExpirationDate.cs new file mode 100644 index 000000000..84a67162d --- /dev/null +++ b/src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/ExpirationDate.cs @@ -0,0 +1,39 @@ +using System.Text.RegularExpressions; +using static Domain.Exceptions; + +namespace Domain.ValueObjects.PaymentMethods; + +public record ExpirationDate +{ + private readonly ushort _month; + private readonly ushort _year; + + public ExpirationDate(ushort month, ushort year) + { + InvalidMonth.ThrowIf(month is < 1 or > 12); + InvalidYear.ThrowIf(year < DateTime.UtcNow.Year || year > DateTime.UtcNow.Year + 10); + + _month = month; + _year = year; + } + + public static bool TryParse(string input, out ExpirationDate expiration) + { + expiration = null!; + + if (Regex.IsMatch(input, @"^\d{2}/\d{2}$")) + { + var month = ushort.Parse(input[..2]); + var year = ushort.Parse(input[3..]); + expiration = new(month, year); + return true; + } + + return false; + } + + public static implicit operator ExpirationDate(string input) + => TryParse(input, out var expiration) ? expiration : throw new ArgumentException("Invalid expiration date format."); + + public static implicit operator string(ExpirationDate expiration) => $"{expiration._month:D2}/{expiration._year:D2}"; +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/IPaymentMethod.cs b/src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/IPaymentMethod.cs new file mode 100644 index 000000000..252dcdf64 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/IPaymentMethod.cs @@ -0,0 +1,8 @@ +namespace Domain.ValueObjects.PaymentMethods; + +public interface IPaymentMethod +{ + public static readonly IPaymentMethod Undefined = new UndefinedPaymentMethod(); + + public record UndefinedPaymentMethod : IPaymentMethod; +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/PayPal.cs b/src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/PayPal.cs new file mode 100644 index 000000000..b02b74c20 --- /dev/null +++ b/src/Services/Shopping/Command/Domain/ValueObjects/PaymentMethods/PayPal.cs @@ -0,0 +1,12 @@ +using Contracts.DataTransferObjects; + +namespace Domain.ValueObjects.PaymentMethods; + +public record PayPal(string Email, string Password) : IPaymentMethod +{ + public static implicit operator PayPal(Dto.PayPal payPal) + => new(payPal.Email, payPal.Password); + + public static implicit operator Dto.PayPal(PayPal payPal) + => new(payPal.Email, payPal.Password); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/GrpcService/appsettings.Production.json b/src/Services/Shopping/Command/GrpcService/appsettings.Production.json new file mode 100644 index 000000000..af5730dc3 --- /dev/null +++ b/src/Services/Shopping/Command/GrpcService/appsettings.Production.json @@ -0,0 +1,11 @@ +{ + "ConnectionStrings": { + "EventStore": "Server=mssql;Database=ShoppingEventStore;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@rabbitmq:5672/eventual-shop" + }, + "QuartzOptions": { + "quartz.dataSource.default.connectionString": "Server=mssql;Database=Quartz;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/GrpcService/appsettings.Staging.json b/src/Services/Shopping/Command/GrpcService/appsettings.Staging.json new file mode 100644 index 000000000..af5730dc3 --- /dev/null +++ b/src/Services/Shopping/Command/GrpcService/appsettings.Staging.json @@ -0,0 +1,11 @@ +{ + "ConnectionStrings": { + "EventStore": "Server=mssql;Database=ShoppingEventStore;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@rabbitmq:5672/eventual-shop" + }, + "QuartzOptions": { + "quartz.dataSource.default.connectionString": "Server=mssql;Database=Quartz;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/GrpcService/appsettings.json b/src/Services/Shopping/Command/GrpcService/appsettings.json new file mode 100644 index 000000000..647c523e4 --- /dev/null +++ b/src/Services/Shopping/Command/GrpcService/appsettings.json @@ -0,0 +1,58 @@ +{ + "HostOptions": { + "ShutdownTimeout": "00:00:25" + }, + "MassTransitHostOptions": { + "WaitUntilStarted": true, + "StartTimeout": "00:00:30", + "StopTimeout": "00:00:15" + }, + "EventBusOptions": { + "ConnectionName": "Shopping", + "retryLimit": 3, + "initialInterval": "00:00:05", + "intervalIncrement": "00:00:10" + }, + "SqlServerRetryOptions": { + "MaxRetryCount": 5, + "MaxRetryDelay": "00:00:05", + "ErrorNumbersToAdd": [] + }, + "EventStoreOptions": { + "SnapshotInterval": 5 + }, + "QuartzOptions": { + "quartz.scheduler.instanceName": "Shopping", + "quartz.scheduler.instanceId": "AUTO", + "quartz.jobStore.dataSource": "default", + "quartz.dataSource.default.provider": "SqlServer", + "quartz.serializer.type": "json", + "quartz.jobStore.type": "Quartz.Impl.AdoJobStore.JobStoreTX, Quartz", + "quartz.jobStore.clustered": true, + "quartz.jobStore.driverDelegateType": "Quartz.Impl.AdoJobStore.SqlServerDelegate, Quartz" + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "MassTransit": "Information", + "Quartz": "Information", + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console" + } + ] + }, + "Kestrel": { + "EndpointDefaults": { + "ClientCertificateMode": "AllowCertificate", + "SslProtocols": ["Tls11","Tls12","Tls13"], + "Protocols": "Http2" + } + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Infrastructure.EventStore/Contexts/Configurations/SnapshotConfiguration.cs b/src/Services/Shopping/Command/Infrastructure.EventStore/Contexts/Configurations/SnapshotConfiguration.cs new file mode 100644 index 000000000..b5c5de412 --- /dev/null +++ b/src/Services/Shopping/Command/Infrastructure.EventStore/Contexts/Configurations/SnapshotConfiguration.cs @@ -0,0 +1,47 @@ +using Domain.Abstractions.Aggregates; +using Domain.Abstractions.EventStore; +using Domain.Abstractions.Identities; +using Domain.Aggregates.Checkouts; +using Domain.Aggregates.Products; +using Domain.Aggregates.ShoppingCarts; +using Infrastructure.EventStore.Contexts.Converters; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Infrastructure.EventStore.Contexts.Configurations; + +public class CartSnapshotConfiguration : SnapshotConfiguration; + +public class CheckoutSnapshotConfiguration : SnapshotConfiguration; + +public class ProductSnapshotConfiguration : SnapshotConfiguration; + +public abstract class SnapshotConfiguration : IEntityTypeConfiguration> + where TAggregate : AggregateRoot + where TId : GuidIdentifier, new() +{ + public virtual void Configure(EntityTypeBuilder> builder) + { + builder.ToTable($"{typeof(TAggregate).Name}Snapshots"); + + builder.HasKey(snapshot => new { snapshot.Version, snapshot.AggregateId }); + + builder + .Property(snapshot => snapshot.Aggregate) + .HasConversion>() + .IsRequired(); + + builder + .Property(snapshot => snapshot.AggregateId) + .HasConversion>() + .IsRequired(); + + builder.Property(snapshot => snapshot.Timestamp) + .IsRequired(); + + builder + .Property(snapshot => snapshot.Version) + .HasConversion() + .IsRequired(); + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Infrastructure.EventStore/Contexts/Converters/AggregateConverter.cs b/src/Services/Shopping/Command/Infrastructure.EventStore/Contexts/Converters/AggregateConverter.cs new file mode 100644 index 000000000..efec3d771 --- /dev/null +++ b/src/Services/Shopping/Command/Infrastructure.EventStore/Contexts/Converters/AggregateConverter.cs @@ -0,0 +1,43 @@ +using Contracts.JsonConverters; +using Domain.Abstractions.Aggregates; +using Domain.Abstractions.Identities; +using JsonNet.ContractResolvers; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Newtonsoft.Json; + +namespace Infrastructure.EventStore.Contexts.Converters; + +public class AggregateConverter() : + ValueConverter( + @event => JsonConvert.SerializeObject(@event, typeof(TAggregate), SerializerSettings()), + jsonString => JsonConvert.DeserializeObject(jsonString, DeserializerSettings())) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new() +{ + private static JsonSerializerSettings SerializerSettings() + { + JsonSerializerSettings jsonSerializerSettings = new() + { + TypeNameHandling = TypeNameHandling.Auto + }; + + jsonSerializerSettings.Converters.Add(new DateOnlyJsonConverter()); + jsonSerializerSettings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + + return jsonSerializerSettings; + } + + private static JsonSerializerSettings DeserializerSettings() + { + JsonSerializerSettings jsonDeserializerSettings = new() + { + TypeNameHandling = TypeNameHandling.Auto, + ContractResolver = new PrivateSetterContractResolver() + }; + + jsonDeserializerSettings.Converters.Add(new DateOnlyJsonConverter()); + jsonDeserializerSettings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + + return jsonDeserializerSettings; + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Infrastructure.EventStore/Contexts/Converters/EventConverter.cs b/src/Services/Shopping/Command/Infrastructure.EventStore/Contexts/Converters/EventConverter.cs new file mode 100644 index 000000000..61d5e6b75 --- /dev/null +++ b/src/Services/Shopping/Command/Infrastructure.EventStore/Contexts/Converters/EventConverter.cs @@ -0,0 +1,40 @@ +using Contracts.Abstractions.Messages; +using Contracts.JsonConverters; +using JsonNet.ContractResolvers; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Newtonsoft.Json; + +namespace Infrastructure.EventStore.Contexts.Converters; + +public class EventConverter() + : ValueConverter( + @event => JsonConvert.SerializeObject(@event, typeof(IDomainEvent), SerializerSettings()), + jsonString => JsonConvert.DeserializeObject(jsonString, DeserializerSettings())) +{ + private static JsonSerializerSettings SerializerSettings() + { + JsonSerializerSettings jsonSerializerSettings = new() + { + TypeNameHandling = TypeNameHandling.Auto + }; + + jsonSerializerSettings.Converters.Add(new DateOnlyJsonConverter()); + jsonSerializerSettings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + + return jsonSerializerSettings; + } + + private static JsonSerializerSettings DeserializerSettings() + { + JsonSerializerSettings jsonDeserializerSettings = new() + { + TypeNameHandling = TypeNameHandling.Auto, + ContractResolver = new PrivateSetterContractResolver() + }; + + jsonDeserializerSettings.Converters.Add(new DateOnlyJsonConverter()); + jsonDeserializerSettings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + + return jsonDeserializerSettings; + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Infrastructure.EventStore/Contexts/EventStoreDbContext.cs b/src/Services/Shopping/Command/Infrastructure.EventStore/Contexts/EventStoreDbContext.cs new file mode 100644 index 000000000..75d0e3678 --- /dev/null +++ b/src/Services/Shopping/Command/Infrastructure.EventStore/Contexts/EventStoreDbContext.cs @@ -0,0 +1,12 @@ +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.EventStore.Contexts; + +public class EventStoreDbContext(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder builder) + { + builder.HasDefaultSchema(nameof(EventStore)); + builder.ApplyConfigurationsFromAssembly(typeof(EventStoreDbContext).Assembly); + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Infrastructure.EventStore/DependencyInjection/Extensions/ServiceCollectionExtensions.cs b/src/Services/Shopping/Command/Infrastructure.EventStore/DependencyInjection/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..edefc6c48 --- /dev/null +++ b/src/Services/Shopping/Command/Infrastructure.EventStore/DependencyInjection/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,53 @@ +using Application.Abstractions; +using Application.Abstractions.Gateways; +using Infrastructure.EventStore.Contexts; +using Infrastructure.EventStore.DependencyInjection.Options; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Infrastructure.EventStore.DependencyInjection.Extensions; + +public static class ServiceCollectionExtensions +{ + public static void AddEventStoreInfrastructure(this IServiceCollection services) + => services + .ConfigureOptions() + .AddScoped() + .AddScoped() + .AddScoped() + .AddDbContextPool((provider, builder) => + { + var configuration = provider.GetRequiredService(); + var options = provider.GetRequiredService>().Value; + + builder + .EnableDetailedErrors() + .EnableSensitiveDataLogging() + .UseSqlServer( + connectionString: configuration.GetConnectionString("EventStore"), + sqlServerOptionsAction: optionsBuilder + => optionsBuilder.ExecutionStrategy( + dependencies => new SqlServerRetryingExecutionStrategy( + dependencies: dependencies, + maxRetryCount: options.MaxRetryCount, + maxRetryDelay: options.MaxRetryDelay, + errorNumbersToAdd: options.ErrorNumbersToAdd)) + .MigrationsAssembly(typeof(EventStoreDbContext).Assembly.GetName().Name)); + }); + + private static IServiceCollection ConfigureOptions(this IServiceCollection services) + => services + .ConfigureOptions() + .ConfigureOptions(); + + private static IServiceCollection ConfigureOptions(this IServiceCollection services) + where TOptions : class + => services + .AddOptions() + .BindConfiguration(typeof(TOptions).Name) + .ValidateDataAnnotations() + .ValidateOnStart() + .Services; +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Infrastructure.EventStore/DependencyInjection/Options/EventStoreOptions.cs b/src/Services/Shopping/Command/Infrastructure.EventStore/DependencyInjection/Options/EventStoreOptions.cs new file mode 100644 index 000000000..43f571dda --- /dev/null +++ b/src/Services/Shopping/Command/Infrastructure.EventStore/DependencyInjection/Options/EventStoreOptions.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace Infrastructure.EventStore.DependencyInjection.Options; + +public record EventStoreOptions +{ + [Required, Range(3, 100)] + public ushort SnapshotInterval { get; init; } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Infrastructure.EventStore/DependencyInjection/Options/SqlServerRetryingOptions.cs b/src/Services/Shopping/Command/Infrastructure.EventStore/DependencyInjection/Options/SqlServerRetryingOptions.cs new file mode 100644 index 000000000..c69326451 --- /dev/null +++ b/src/Services/Shopping/Command/Infrastructure.EventStore/DependencyInjection/Options/SqlServerRetryingOptions.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace Infrastructure.EventStore.DependencyInjection.Options; + +public record SqlServerRetryOptions +{ + [Required, Range(5, 20)] public int MaxRetryCount { get; init; } + [Required, Timestamp] public TimeSpan MaxRetryDelay { get; init; } + public int[]? ErrorNumbersToAdd { get; init; } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Infrastructure.EventStore/EventStoreGateway.cs b/src/Services/Shopping/Command/Infrastructure.EventStore/EventStoreGateway.cs new file mode 100644 index 000000000..d2408ec0f --- /dev/null +++ b/src/Services/Shopping/Command/Infrastructure.EventStore/EventStoreGateway.cs @@ -0,0 +1,78 @@ +using System.Linq.Expressions; +using Application.Abstractions.Gateways; +using Contracts.Abstractions.Messages; +using Domain.Abstractions.Aggregates; +using Domain.Abstractions.EventStore; +using Domain.Abstractions.Identities; +using Microsoft.EntityFrameworkCore; +using Version = Domain.ValueObjects.Version; + +namespace Infrastructure.EventStore; + +public class EventStoreGateway(DbContext dbContext) : IEventStoreGateway +{ + public async Task AppendAsync(StoreEvent storeEvent, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new() + { + await dbContext.Set>().AddAsync(storeEvent, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task AppendAsync(Snapshot snapshot, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new() + { + await dbContext.Set>().AddAsync(snapshot, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } + + public Task> GetStreamAsync(TId id, Version version, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new() + => dbContext.Set>() + .AsNoTracking() + .Where(@event => @event.AggregateId.Equals(id)) + .Where(@event => @event.Version > version) + .Select(@event => @event.Event) + .ToListAsync(cancellationToken); + + public Task> GetStreamAsync + (Expression, bool>> predicate, Version version, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new() + => dbContext.Set>() + .AsNoTracking() + .Where(predicate) + .Where(@event => @event.Version > version) + .Select(@event => @event.Event) + .ToListAsync(cancellationToken); + + public Task?> GetSnapshotAsync(TId id, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new() + => dbContext.Set>() + .AsNoTracking() + .Where(snapshot => snapshot.AggregateId.Equals(id)) + .OrderByDescending(snapshot => snapshot.Version) + .FirstOrDefaultAsync(cancellationToken); + + public Task?> GetSnapshotAsync + (Expression, bool>> predicate, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot + where TId : IIdentifier, new() + => dbContext.Set>() + .AsNoTracking() + .Where(predicate) + .OrderByDescending(snapshot => snapshot.Version) + .FirstOrDefaultAsync(cancellationToken); + + public IAsyncEnumerable StreamAggregatesId() + where TAggregate : IAggregateRoot + where TId : IIdentifier, new() + => dbContext.Set>() + .AsNoTracking() + .Select(@event => @event.AggregateId) + .Distinct() + .AsAsyncEnumerable(); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/Infrastructure.EventStore/Infrastructure.EventStore.csproj b/src/Services/Shopping/Command/Infrastructure.EventStore/Infrastructure.EventStore.csproj new file mode 100644 index 000000000..7d36ab483 --- /dev/null +++ b/src/Services/Shopping/Command/Infrastructure.EventStore/Infrastructure.EventStore.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Services/Shopping/Command/Infrastructure.EventStore/UnitOfWork.cs b/src/Services/Shopping/Command/Infrastructure.EventStore/UnitOfWork.cs new file mode 100644 index 000000000..89f994e3a --- /dev/null +++ b/src/Services/Shopping/Command/Infrastructure.EventStore/UnitOfWork.cs @@ -0,0 +1,20 @@ +using Application.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Infrastructure.EventStore; + +public class UnitOfWork(DbContext dbContext) : IUnitOfWork +{ + private readonly DatabaseFacade _database = dbContext.Database; + + public Task ExecuteAsync(Func operationAsync, CancellationToken cancellationToken) + => _database.CreateExecutionStrategy().ExecuteAsync(ct => ExecuteTransactionAsync(operationAsync, ct), cancellationToken); + + private async Task ExecuteTransactionAsync(Func operationAsync, CancellationToken cancellationToken) + { + await using var transaction = await _database.BeginTransactionAsync(cancellationToken); + await operationAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/WorkerService/.dockerignore b/src/Services/Shopping/Command/WorkerService/.dockerignore new file mode 100644 index 000000000..cd967fc3a --- /dev/null +++ b/src/Services/Shopping/Command/WorkerService/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/src/Services/Shopping/Command/WorkerService/Dockerfile b/src/Services/Shopping/Command/WorkerService/Dockerfile new file mode 100644 index 000000000..fccbe58d1 --- /dev/null +++ b/src/Services/Shopping/Command/WorkerService/Dockerfile @@ -0,0 +1,42 @@ +ARG ASPNET_VERSION="8.0-preview" +ARG SDK_VERSION="8.0-preview" +ARG BASE_ADRESS="mcr.microsoft.com/dotnet" + +FROM $BASE_ADRESS/aspnet:$ASPNET_VERSION AS base +WORKDIR /app + +FROM $BASE_ADRESS/sdk:$SDK_VERSION AS build + +COPY ./global.json ./ +COPY ./nuget.config ./ +COPY ./Directory.Build.props ./ + +WORKDIR /src + +COPY ./src/Services/ShoppingCart/Command/Application/*.csproj ./Services/ShoppingCart/Command/Application/ +COPY ./src/Services/ShoppingCart/Command/Domain/*.csproj ./Services/ShoppingCart/Command/Domain/ +COPY ./src/Services/ShoppingCart/Command/Infrastructure.EventStore/*.csproj ./Services/ShoppingCart/Command/Infrastructure.EventStore/ +COPY ./src/Services/ShoppingCart/Command/Infrastructure.MessageBus/*.csproj ./Services/ShoppingCart/Command/Infrastructure.MessageBus/ +COPY ./src/Services/ShoppingCart/Command/WorkerService/*.csproj ./Services/ShoppingCart/Command/WorkerService/ +COPY ./src/Contracts/*.csproj ./Contracts/ + +RUN dotnet restore -v m ./Services/ShoppingCart/Command/WorkerService + +COPY ./src/Services/ShoppingCart/Command/Application/. ./Services/ShoppingCart/Command/Application/ +COPY ./src/Services/ShoppingCart/Command/Domain/. ./Services/ShoppingCart/Command/Domain/ +COPY ./src/Services/ShoppingCart/Command/Infrastructure.EventStore/. ./Services/ShoppingCart/Command/Infrastructure.EventStore/ +COPY ./src/Services/ShoppingCart/Command/Infrastructure.MessageBus/. ./Services/ShoppingCart/Command/Infrastructure.MessageBus/ +COPY ./src/Services/ShoppingCart/Command/WorkerService/. ./Services/ShoppingCart/Command/WorkerService/ +COPY ./src/Contracts/. ./Contracts/ + +WORKDIR /src/Services/ShoppingCart/Command/WorkerService + +RUN dotnet build -c Release --no-restore -v m -o /app/build + +FROM build AS publish +RUN dotnet publish -c Release --no-restore -v m -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "WorkerService.dll"] \ No newline at end of file diff --git a/src/Services/Shopping/Command/WorkerService/Program.cs b/src/Services/Shopping/Command/WorkerService/Program.cs new file mode 100644 index 000000000..ec0d789ac --- /dev/null +++ b/src/Services/Shopping/Command/WorkerService/Program.cs @@ -0,0 +1,38 @@ +using Application.DependencyInjection.Extensions; +using Infrastructure.EventBus.DependencyInjection.Extensions; +using Infrastructure.EventStore.DependencyInjection.Extensions; +using Serilog; +using WorkerService.Extensions; + +using var host = Host + .CreateDefaultBuilder(args) + .ConfigureServiceProvider() + .ConfigureAppConfiguration() + .ConfigureLogging() + .ConfigureServices(services + => services + .AddApplication() + .AddMessageBusInfrastructure() + .AddEventStoreInfrastructure()) + .Build(); + +try +{ + var environment = host.Services.GetRequiredService(); + + if (environment.IsDevelopment() || environment.IsStaging()) + await host.MigrateEventStoreAsync(); + + await host.RunAsync(); + Log.Information("Stopped cleanly"); +} +catch (Exception ex) +{ + Log.Fatal(ex, "An unhandled exception occured during bootstrapping"); + await host.StopAsync(); +} +finally +{ + Log.CloseAndFlush(); + host.Dispose(); +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/WorkerService/Properties/launchSettings.json b/src/Services/Shopping/Command/WorkerService/Properties/launchSettings.json new file mode 100644 index 000000000..ecfd7d3f6 --- /dev/null +++ b/src/Services/Shopping/Command/WorkerService/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "ShoppingCart.WorkerService": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Shopping/Command/WorkerService/WorkerService.csproj b/src/Services/Shopping/Command/WorkerService/WorkerService.csproj new file mode 100644 index 000000000..01ade410e --- /dev/null +++ b/src/Services/Shopping/Command/WorkerService/WorkerService.csproj @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/Services/Shopping/Command/WorkerService/appsettings.Development.json b/src/Services/Shopping/Command/WorkerService/appsettings.Development.json new file mode 100644 index 000000000..f3444c0a7 --- /dev/null +++ b/src/Services/Shopping/Command/WorkerService/appsettings.Development.json @@ -0,0 +1,11 @@ +{ + "ConnectionStrings": { + "EventStore": "Server=127.0.0.1,1433;Database=ShoppingCartEventStore;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@127.0.0.1:5672/eventual-shop" + }, + "QuartzOptions": { + "quartz.dataSource.default.connectionString": "Server=127.0.0.1,1433;Database=Quartz;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/WorkerService/appsettings.Production.json b/src/Services/Shopping/Command/WorkerService/appsettings.Production.json new file mode 100644 index 000000000..91bbd480e --- /dev/null +++ b/src/Services/Shopping/Command/WorkerService/appsettings.Production.json @@ -0,0 +1,11 @@ +{ + "ConnectionStrings": { + "EventStore": "Server=mssql;Database=ShoppingCartEventStore;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@rabbitmq:5672/eventual-shop" + }, + "QuartzOptions": { + "quartz.dataSource.default.connectionString": "Server=mssql;Database=Quartz;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/WorkerService/appsettings.Staging.json b/src/Services/Shopping/Command/WorkerService/appsettings.Staging.json new file mode 100644 index 000000000..91bbd480e --- /dev/null +++ b/src/Services/Shopping/Command/WorkerService/appsettings.Staging.json @@ -0,0 +1,11 @@ +{ + "ConnectionStrings": { + "EventStore": "Server=mssql;Database=ShoppingCartEventStore;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@rabbitmq:5672/eventual-shop" + }, + "QuartzOptions": { + "quartz.dataSource.default.connectionString": "Server=mssql;Database=Quartz;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Command/WorkerService/appsettings.json b/src/Services/Shopping/Command/WorkerService/appsettings.json new file mode 100644 index 000000000..29e7d0610 --- /dev/null +++ b/src/Services/Shopping/Command/WorkerService/appsettings.json @@ -0,0 +1,52 @@ +{ + "HostOptions": { + "ShutdownTimeout": "00:00:25" + }, + "MassTransitHostOptions": { + "WaitUntilStarted": true, + "StartTimeout": "00:00:30", + "StopTimeout": "00:00:15" + }, + "EventBusOptions": { + "ConnectionName": "ShoppingCart", + "SchedulerQueueName": "scheduler", + "retryLimit": 3, + "initialInterval": "00:00:05", + "intervalIncrement": "00:00:10" + }, + "SqlServerRetryOptions": { + "MaxRetryCount": 5, + "MaxRetryDelay": "00:00:05", + "ErrorNumbersToAdd": [] + }, + "EventStoreOptions": { + "SnapshotInterval": 3 + }, + "QuartzOptions": { + "quartz.scheduler.instanceName": "ShoppingCart", + "quartz.scheduler.instanceId": "AUTO", + "quartz.jobStore.dataSource": "default", + "quartz.dataSource.default.provider": "SqlServer", + "quartz.serializer.type": "json", + "quartz.jobStore.type": "Quartz.Impl.AdoJobStore.JobStoreTX, Quartz", + "quartz.jobStore.clustered": true, + "quartz.jobStore.driverDelegateType": "Quartz.Impl.AdoJobStore.SqlServerDelegate, Quartz" + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "MassTransit": "Information", + "Quartz": "Information", + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console" + } + ] + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Application/Abstractions/IInteractor.cs b/src/Services/Shopping/Query/Application/Abstractions/IInteractor.cs new file mode 100644 index 000000000..2369fd771 --- /dev/null +++ b/src/Services/Shopping/Query/Application/Abstractions/IInteractor.cs @@ -0,0 +1,25 @@ +using Contracts.Abstractions; +using Contracts.Abstractions.Messages; +using Contracts.Abstractions.Paging; + +namespace Application.Abstractions; + +public interface IInteractor + where TEvent : IEvent +{ + Task InteractAsync(TEvent @event, CancellationToken cancellationToken); +} + +public interface IInteractor + where TQuery : IQuery + where TProjection : IProjection +{ + Task InteractAsync(TQuery query, CancellationToken cancellationToken); +} + +public interface IPagedInteractor + where TQuery : IQuery + where TProjection : IProjection +{ + ValueTask> InteractAsync(TQuery query, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Application/Abstractions/IProjectionGateway.cs b/src/Services/Shopping/Query/Application/Abstractions/IProjectionGateway.cs new file mode 100644 index 000000000..565180f64 --- /dev/null +++ b/src/Services/Shopping/Query/Application/Abstractions/IProjectionGateway.cs @@ -0,0 +1,19 @@ +using System.Linq.Expressions; +using Contracts.Abstractions; +using Contracts.Abstractions.Paging; + +namespace Application.Abstractions; + +public interface IProjectionGateway + where TProjection : IProjection +{ + Task FindAsync(Expression> predicate, CancellationToken cancellationToken); + Task GetAsync(TId id, CancellationToken cancellationToken) where TId : struct; + ValueTask> ListAsync(Paging paging, Expression> predicate, CancellationToken cancellationToken); + ValueTask> ListAsync(Paging paging, CancellationToken cancellationToken); + ValueTask ReplaceInsertAsync(TProjection replacement, CancellationToken cancellationToken); + ValueTask RebuildInsertAsync(TProjection replacement, CancellationToken cancellationToken); + Task DeleteAsync(Expression> filter, CancellationToken cancellationToken); + Task DeleteAsync(TId id, CancellationToken cancellationToken) where TId : struct; + Task UpdateFieldAsync(TId id, ulong version, Expression> field, TField value, CancellationToken cancellationToken) where TId : struct; +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Application/DependencyInjection/ServiceCollectionExtensions.cs b/src/Services/Shopping/Query/Application/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..1f3c0d0ad --- /dev/null +++ b/src/Services/Shopping/Query/Application/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,30 @@ +using Application.Abstractions; +using Application.UseCases.Events; +using Application.UseCases.Queries; +using Contracts.Boundaries.Shopping.ShoppingCart; +using Microsoft.Extensions.DependencyInjection; + +namespace Application.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddInteractors(this IServiceCollection services) + => services + .AddEventInteractors() + .AddQueryInteractors(); + + private static IServiceCollection AddEventInteractors(this IServiceCollection services) + => services + .AddScoped() + .AddScoped() + .AddScoped(); + + private static IServiceCollection AddQueryInteractors(this IServiceCollection services) + => services + .AddScoped, GetShoppingCartDetailsInteractor>() + .AddScoped, GetCustomerShoppingCartDetailsInteractor>() + .AddScoped, GetPaymentMethodDetailsInteractor>() + .AddScoped, GetShoppingCartItemDetailsInteractor>() + .AddScoped, ListPaymentMethodsListItemsInteractor>() + .AddScoped, ListShoppingCartItemsListItemsInteractor>(); +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Application/UseCases/Events/ProjectCartDetailsWhenCartChangedInteractor.cs b/src/Services/Shopping/Query/Application/UseCases/Events/ProjectCartDetailsWhenCartChangedInteractor.cs new file mode 100644 index 000000000..27d06302d --- /dev/null +++ b/src/Services/Shopping/Query/Application/UseCases/Events/ProjectCartDetailsWhenCartChangedInteractor.cs @@ -0,0 +1,88 @@ +using Application.Abstractions; +using Contracts.Boundaries.Shopping.ShoppingCart; +using Contracts.DataTransferObjects; + +namespace Application.UseCases.Events; + +public interface IProjectCartDetailsWhenCartChangedInteractor : + IInteractor, + IInteractor, + IInteractor, + IInteractor, + IInteractor, + IInteractor, + IInteractor, + IInteractor; + +public class ProjectCartDetailsWhenCartChangedInteractor(IProjectionGateway projectionGateway) + : IProjectCartDetailsWhenCartChangedInteractor +{ + public async Task InteractAsync(DomainEvent.ShoppingStarted @event, CancellationToken cancellationToken) + { + Projection.ShoppingCartDetails shoppingCartDetails = new( + new Guid(@event.CartId), + new Guid(@event.CustomerId), + new Dto.Money("0", "0"), + @event.Status, + false, + Convert.ToUInt64(@event.Version)); + + await projectionGateway.ReplaceInsertAsync(shoppingCartDetails, cancellationToken); + } + + public Task InteractAsync(DomainEvent.CartItemAdded @event, CancellationToken cancellationToken) + => projectionGateway.UpdateFieldAsync( + id: new Guid(@event.CartId), + version: Convert.ToUInt64(@event.Version), + field: cart => cart.Total, + value: new Dto.Money("0", "0"), + cancellationToken: cancellationToken); + + public Task InteractAsync(DomainEvent.CartItemRemoved @event, CancellationToken cancellationToken) + => projectionGateway.UpdateFieldAsync( + id: new Guid(@event.CartId), + version: Convert.ToUInt64(@event.Version), + field: cart => cart.Total, + value: new Dto.Money("0", "0"), + cancellationToken: cancellationToken); + + public Task InteractAsync(DomainEvent.CartItemIncreased @event, CancellationToken cancellationToken) + => projectionGateway.UpdateFieldAsync( + id: new Guid(@event.CartId), + version: Convert.ToUInt64(@event.Version), + field: cart => cart.Total, + value: new Dto.Money("0", "0"), + cancellationToken: cancellationToken); + + public Task InteractAsync(DomainEvent.CartItemDecreased @event, CancellationToken cancellationToken) + => projectionGateway.UpdateFieldAsync( + id: new Guid(@event.CartId), + version: Convert.ToUInt64(@event.Version), + field: cart => cart.Total, + value: new Dto.Money("0", "0"), + cancellationToken: cancellationToken); + + public Task InteractAsync(DomainEvent.CartCheckedOut @event, CancellationToken cancellationToken) + => projectionGateway.UpdateFieldAsync( + id: new Guid(@event.CartId), + version: Convert.ToUInt64(@event.Version), + field: cart => cart.Status, + value: @event.Status, + cancellationToken: cancellationToken); + + public Task InteractAsync(DomainEvent.CartDiscarded @event, CancellationToken cancellationToken) + => projectionGateway.DeleteAsync(new Guid(@event.CartId), cancellationToken); + + public async Task InteractAsync(SummaryEvent.CartProjectionRebuilt @event, CancellationToken cancellationToken) + { + Projection.ShoppingCartDetails shoppingCartDetails = new( + new Guid(@event.Cart.Id), + new Guid(@event.Cart.CustomerId), + @event.Cart.Total, + @event.Cart.Status, + @event.Cart.IsDeleted, + Convert.ToUInt64(@event.Version)); + + await projectionGateway.RebuildInsertAsync(shoppingCartDetails, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Application/UseCases/Events/ProjectCartItemListItemWhenCartChangedInteractor.cs b/src/Services/Shopping/Query/Application/UseCases/Events/ProjectCartItemListItemWhenCartChangedInteractor.cs new file mode 100644 index 000000000..1051a5a73 --- /dev/null +++ b/src/Services/Shopping/Query/Application/UseCases/Events/ProjectCartItemListItemWhenCartChangedInteractor.cs @@ -0,0 +1,50 @@ +using Application.Abstractions; +using Contracts.Boundaries.Shopping.ShoppingCart; + +namespace Application.UseCases.Events; + +public interface IProjectCartItemListItemWhenCartChangedInteractor : + IInteractor, + IInteractor, + IInteractor, + IInteractor, + IInteractor; + +public class ProjectCartItemListItemWhenCartChangedInteractor(IProjectionGateway projectionGateway) + : IProjectCartItemListItemWhenCartChangedInteractor +{ + public async Task InteractAsync(DomainEvent.CartItemAdded @event, CancellationToken cancellationToken) + { + Projection.ShoppingCartItemListItem cartItemListItem = new( + Guid.Parse(@event.ItemId), + Guid.Parse(@event.CartId), + @event.ProductName, + Convert.ToInt32(@event.Quantity), + false, + 10); + + await projectionGateway.ReplaceInsertAsync(cartItemListItem, cancellationToken); + } + + public Task InteractAsync(DomainEvent.CartItemIncreased @event, CancellationToken cancellationToken) + => projectionGateway.UpdateFieldAsync( + id: Guid.Parse(@event.ItemId), + version: ulong.Parse(@event.Version), + field: item => item.Quantity, + value: int.Parse(@event.NewQuantity), + cancellationToken: cancellationToken); + + public Task InteractAsync(DomainEvent.CartItemDecreased @event, CancellationToken cancellationToken) + => projectionGateway.UpdateFieldAsync( + id: Guid.Parse(@event.ItemId), + version: ulong.Parse(@event.Version), + field: item => item.Quantity, + value: int.Parse(@event.NewQuantity), + cancellationToken: cancellationToken); + + public Task InteractAsync(DomainEvent.CartItemRemoved @event, CancellationToken cancellationToken) + => projectionGateway.DeleteAsync(Guid.Parse(@event.ItemId), cancellationToken); + + public Task InteractAsync(DomainEvent.CartDiscarded @event, CancellationToken cancellationToken) + => projectionGateway.DeleteAsync(item => item.CartId == Guid.Parse(@event.CartId), cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Application/UseCases/Events/ProjectPaymentMethodListItemWhenCartChangedInteractor.cs b/src/Services/Shopping/Query/Application/UseCases/Events/ProjectPaymentMethodListItemWhenCartChangedInteractor.cs new file mode 100644 index 000000000..c9a4b455b --- /dev/null +++ b/src/Services/Shopping/Query/Application/UseCases/Events/ProjectPaymentMethodListItemWhenCartChangedInteractor.cs @@ -0,0 +1,56 @@ +using Application.Abstractions; +using Contracts.Boundaries.Shopping.ShoppingCart; + +namespace Application.UseCases.Events; + +public interface IProjectPaymentMethodListItemWhenCartChangedInteractor : + // IInteractor, + // IInteractor, + // IInteractor, + IInteractor; + +public class ProjectPaymentMethodListItemWhenCartChangedInteractor(IProjectionGateway projectionGateway) + : IProjectPaymentMethodListItemWhenCartChangedInteractor +{ + // public async Task InteractAsync(DomainEvent.CreditCardAdded @event, CancellationToken cancellationToken) + // { + // Projection.PaymentMethodListItem creditCard = new( + // @event.MethodId, + // @event.ShoppingCartId, + // @event.Amount, + // @event.CreditCard.GetType().Name, // TODO - It's temporary + // false, + // @event.Version); + // + // await projectionGateway.ReplaceInsertAsync(creditCard, cancellationToken); + // } + // + // public async Task InteractAsync(DomainEvent.DebitCardAdded @event, CancellationToken cancellationToken) + // { + // Projection.PaymentMethodListItem creditCard = new( + // @event.MethodId, + // @event.ShoppingCartId, + // @event.Amount, + // @event.DebitCard.GetType().Name, // TODO - It's temporary + // false, + // @event.Version); + // + // await projectionGateway.ReplaceInsertAsync(creditCard, cancellationToken); + // } + // + // public async Task InteractAsync(DomainEvent.PayPalAdded @event, CancellationToken cancellationToken) + // { + // Projection.PaymentMethodListItem creditCard = new( + // @event.MethodId, + // @event.ShoppingCartId, + // @event.Amount, + // @event.PayPal.GetType().Name, // TODO - It's temporary + // false, + // @event.Version); + // + // await projectionGateway.ReplaceInsertAsync(creditCard, cancellationToken); + // } + + public Task InteractAsync(DomainEvent.CartDiscarded @event, CancellationToken cancellationToken) + => projectionGateway.DeleteAsync(item => item.CartId == Guid.Parse(@event.CartId), cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Application/UseCases/Queries/GetCustomerShoppingCartDetailsInteractor.cs b/src/Services/Shopping/Query/Application/UseCases/Queries/GetCustomerShoppingCartDetailsInteractor.cs new file mode 100644 index 000000000..7b2982dad --- /dev/null +++ b/src/Services/Shopping/Query/Application/UseCases/Queries/GetCustomerShoppingCartDetailsInteractor.cs @@ -0,0 +1,11 @@ +using Application.Abstractions; +using Contracts.Boundaries.Shopping.ShoppingCart; + +namespace Application.UseCases.Queries; + +public class GetCustomerShoppingCartDetailsInteractor(IProjectionGateway projectionGateway) + : IInteractor +{ + public Task InteractAsync(Query.GetCustomerShoppingCartDetails query, CancellationToken cancellationToken) + => projectionGateway.FindAsync(cart => cart.CustomerId == query.CustomerId, cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Application/UseCases/Queries/GetPaymentMethodDetailsInteractor.cs b/src/Services/Shopping/Query/Application/UseCases/Queries/GetPaymentMethodDetailsInteractor.cs new file mode 100644 index 000000000..bf1f28780 --- /dev/null +++ b/src/Services/Shopping/Query/Application/UseCases/Queries/GetPaymentMethodDetailsInteractor.cs @@ -0,0 +1,11 @@ +using Application.Abstractions; +using Contracts.Boundaries.Shopping.ShoppingCart; + +namespace Application.UseCases.Queries; + +public class GetPaymentMethodDetailsInteractor(IProjectionGateway projectionGateway) + : IInteractor +{ + public Task InteractAsync(Query.GetPaymentMethodDetails query, CancellationToken cancellationToken) + => projectionGateway.FindAsync(method => method.Id == query.MethodId && method.CartId == query.CartId, cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Application/UseCases/Queries/GetShoppingCartDetailsInteractor.cs b/src/Services/Shopping/Query/Application/UseCases/Queries/GetShoppingCartDetailsInteractor.cs new file mode 100644 index 000000000..14a867476 --- /dev/null +++ b/src/Services/Shopping/Query/Application/UseCases/Queries/GetShoppingCartDetailsInteractor.cs @@ -0,0 +1,11 @@ +using Application.Abstractions; +using Contracts.Boundaries.Shopping.ShoppingCart; + +namespace Application.UseCases.Queries; + +public class GetShoppingCartDetailsInteractor(IProjectionGateway projectionGateway) + : IInteractor +{ + public Task InteractAsync(Query.GetShoppingCartDetails query, CancellationToken cancellationToken) + => projectionGateway.GetAsync(query.CartId, cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Application/UseCases/Queries/GetShoppingCartItemDetailsInteractor.cs b/src/Services/Shopping/Query/Application/UseCases/Queries/GetShoppingCartItemDetailsInteractor.cs new file mode 100644 index 000000000..fae90caf0 --- /dev/null +++ b/src/Services/Shopping/Query/Application/UseCases/Queries/GetShoppingCartItemDetailsInteractor.cs @@ -0,0 +1,11 @@ +using Application.Abstractions; +using Contracts.Boundaries.Shopping.ShoppingCart; + +namespace Application.UseCases.Queries; + +public class GetShoppingCartItemDetailsInteractor(IProjectionGateway projectionGateway) + : IInteractor +{ + public Task InteractAsync(Query.GetShoppingCartItemDetails query, CancellationToken cancellationToken) + => projectionGateway.FindAsync(item => item.Id == query.ItemId && item.CartId == query.CartId, cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Application/UseCases/Queries/ListPaymentMethodsListItemsInteractor.cs b/src/Services/Shopping/Query/Application/UseCases/Queries/ListPaymentMethodsListItemsInteractor.cs new file mode 100644 index 000000000..dca9eb1a5 --- /dev/null +++ b/src/Services/Shopping/Query/Application/UseCases/Queries/ListPaymentMethodsListItemsInteractor.cs @@ -0,0 +1,12 @@ +using Application.Abstractions; +using Contracts.Abstractions.Paging; +using Contracts.Boundaries.Shopping.ShoppingCart; + +namespace Application.UseCases.Queries; + +public class ListPaymentMethodsListItemsInteractor(IProjectionGateway projectionGateway) + : IPagedInteractor +{ + public ValueTask> InteractAsync(Query.ListPaymentMethodsListItems query, CancellationToken cancellationToken) + => projectionGateway.ListAsync(query.Paging, method => method.CartId == query.CartId, cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Application/UseCases/Queries/ListShoppingCartItemsListItemsInteractor.cs b/src/Services/Shopping/Query/Application/UseCases/Queries/ListShoppingCartItemsListItemsInteractor.cs new file mode 100644 index 000000000..9fde0bf02 --- /dev/null +++ b/src/Services/Shopping/Query/Application/UseCases/Queries/ListShoppingCartItemsListItemsInteractor.cs @@ -0,0 +1,12 @@ +using Application.Abstractions; +using Contracts.Abstractions.Paging; +using Contracts.Boundaries.Shopping.ShoppingCart; + +namespace Application.UseCases.Queries; + +public class ListShoppingCartItemsListItemsInteractor(IProjectionGateway projectionGateway) + : IPagedInteractor +{ + public ValueTask> InteractAsync(Query.ListShoppingCartItemsListItems query, CancellationToken cancellationToken) + => projectionGateway.ListAsync(query.Paging, item => item.CartId == query.CartId, cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/GrpcService/.dockerignore b/src/Services/Shopping/Query/GrpcService/.dockerignore new file mode 100644 index 000000000..cd967fc3a --- /dev/null +++ b/src/Services/Shopping/Query/GrpcService/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/src/Services/Shopping/Query/GrpcService/Dockerfile b/src/Services/Shopping/Query/GrpcService/Dockerfile new file mode 100644 index 000000000..d87b25da7 --- /dev/null +++ b/src/Services/Shopping/Query/GrpcService/Dockerfile @@ -0,0 +1,42 @@ +ARG ASPNET_VERSION="8.0-preview" +ARG SDK_VERSION="8.0-preview" +ARG BASE_ADRESS="mcr.microsoft.com/dotnet" + +FROM $BASE_ADRESS/aspnet:$ASPNET_VERSION AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 + +FROM $BASE_ADRESS/sdk:$SDK_VERSION AS build + +COPY ./global.json ./ +COPY ./nuget.config ./ +COPY ./Directory.Build.props ./ + +WORKDIR /src + +COPY ./src/Services/ShoppingCart/Query/Application/*.csproj ./Services/ShoppingCart/Query/Application/ +COPY ./src/Services/ShoppingCart/Query/GrpcService/*.csproj ./Services/ShoppingCart/Query/GrpcService/ +COPY ./src/Services/ShoppingCart/Query/Infrastructure.EventBus/*.csproj ./Services/ShoppingCart/Query/Infrastructure.EventBus/ +COPY ./src/Services/ShoppingCart/Query/Infrastructure.Projections/*.csproj ./Services/ShoppingCart/Query/Infrastructure.Projections/ +COPY ./src/Contracts/*.csproj ./Contracts/ + +RUN dotnet restore -v m ./Services/ShoppingCart/Query/GrpcService + +COPY ./src/Services/ShoppingCart/Query/Application/. ./Services/ShoppingCart/Query/Application/ +COPY ./src/Services/ShoppingCart/Query/GrpcService/. ./Services/ShoppingCart/Query/GrpcService/ +COPY ./src/Services/ShoppingCart/Query/Infrastructure.EventBus/. ./Services/ShoppingCart/Query/Infrastructure.EventBus/ +COPY ./src/Services/ShoppingCart/Query/Infrastructure.Projections/. ./Services/ShoppingCart/Query/Infrastructure.Projections/ +COPY ./src/Contracts/. ./Contracts/ + +WORKDIR /src/Services/ShoppingCart/Query/GrpcService + +RUN dotnet build -c Release --no-restore -v m -o /app/build + +FROM build AS publish +RUN dotnet publish -c Release --no-restore -v m -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "GrpcService.dll"] diff --git a/src/Services/Shopping/Query/GrpcService/GrpcService.csproj b/src/Services/Shopping/Query/GrpcService/GrpcService.csproj new file mode 100644 index 000000000..73b4a732a --- /dev/null +++ b/src/Services/Shopping/Query/GrpcService/GrpcService.csproj @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/Services/Shopping/Query/GrpcService/Program.cs b/src/Services/Shopping/Query/GrpcService/Program.cs new file mode 100644 index 000000000..740691b3d --- /dev/null +++ b/src/Services/Shopping/Query/GrpcService/Program.cs @@ -0,0 +1,82 @@ +using System.Reflection; +using Application.DependencyInjection; +using GrpcService; +using Infrastructure.EventBus.DependencyInjection.Extensions; +using Infrastructure.EventBus.DependencyInjection.Options; +using Infrastructure.Projections.DependencyInjection; +using MassTransit; +using Microsoft.AspNetCore.HttpLogging; +using Serilog; + +var builder = WebApplication.CreateBuilder(args); + +builder.Host.UseDefaultServiceProvider((context, provider) => +{ + provider.ValidateScopes = + provider.ValidateOnBuild = + context.HostingEnvironment.IsDevelopment(); +}); + +builder.Configuration + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .AddEnvironmentVariables(); + +builder.Logging.ClearProviders().AddSerilog(); + +builder.Host.UseSerilog((context, cfg) + => cfg.ReadFrom.Configuration(context.Configuration)); + +builder.Host.ConfigureServices((context, services) => +{ + services.AddCors(options + => options.AddDefaultPolicy(policyBuilder + => policyBuilder + .AllowAnyOrigin() + .AllowAnyHeader() + .AllowAnyMethod())); + + if (context.HostingEnvironment.IsEnvironment("Testing")) + services.AddTestingEventBus(); + else services.AddEventBus(); + + services.AddGrpc(); + services.AddMessageValidators(); + services.AddProjections(); + services.AddInteractors(); + + services.ConfigureEventBusOptions( + context.Configuration.GetSection(nameof(EventBusOptions))); + + services.ConfigureMassTransitHostOptions( + context.Configuration.GetSection(nameof(MassTransitHostOptions))); + + services.AddHttpLogging(options + => options.LoggingFields = HttpLoggingFields.All); +}); + +var app = builder.Build(); + +app.UseCors(); +app.UseSerilogRequestLogging(); +app.MapGrpcService(); + +try +{ + await app.RunAsync(); + Log.Information("Stopped cleanly"); +} +catch (Exception ex) +{ + Log.Fatal(ex, "An unhandled exception occured during bootstrapping"); + await app.StopAsync(); +} +finally +{ + Log.CloseAndFlush(); + await app.DisposeAsync(); +} + +namespace GrpcService +{ + public partial class Program; +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/GrpcService/Properties/launchSettings.json b/src/Services/Shopping/Query/GrpcService/Properties/launchSettings.json new file mode 100644 index 000000000..498e85b13 --- /dev/null +++ b/src/Services/Shopping/Query/GrpcService/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "ShoppingCart.GrpcService": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7137;http://localhost:5137", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Shopping/Query/GrpcService/ShoppingCartGrpcService.cs b/src/Services/Shopping/Query/GrpcService/ShoppingCartGrpcService.cs new file mode 100644 index 000000000..77f0ffbc1 --- /dev/null +++ b/src/Services/Shopping/Query/GrpcService/ShoppingCartGrpcService.cs @@ -0,0 +1,94 @@ +using Application.Abstractions; +using Contracts.Abstractions.Protobuf; +using Contracts.Boundaries.Shopping.ShoppingCart; +using Contracts.Shopping.Queries; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; + +namespace GrpcService; + +public class ShoppingCartGrpcService(IInteractor getPaymentMethodDetailsInteractor, + IInteractor getShoppingCartDetailsInteractor, + IInteractor getCustomerShoppingCartDetailsInteractor, + IInteractor getShoppingCartItemDetailsInteractor, + IPagedInteractor listPaymentMethodsListItemsInteractor, + IPagedInteractor listShoppingCartItemsListItemsInteractor) + : ShoppingQueryService.ShoppingQueryServiceBase() +{ + private readonly IInteractor _getPaymentMethodDetailsInteractor = getPaymentMethodDetailsInteractor; + + public override Task GetPaymentMethodDetails(GetPaymentMethodDetailsRequest request, ServerCallContext context) + { + // var response = await mediator + // .CreateRequestClient() + // .GetResponse(request, context.CancellationToken); + // + // return response.Message switch + // { + // Projection.PaymentMethodDetails projection => new() { Projection = Any.Pack((PaymentMethodDetails)projection) }, + // NotFound _ => new() { NotFound = new() }, + // _ => throw new InvalidOperationException() + // }; + + return base.GetPaymentMethodDetails(request, context); + } + + public override async Task GetShoppingCartDetails(GetShoppingCartDetailsRequest request, ServerCallContext context) + { + var shoppingCartDetails = await getShoppingCartDetailsInteractor.InteractAsync(request, context.CancellationToken); + + return shoppingCartDetails is null + ? new() { NotFound = new() } + : new() { Projection = Any.Pack((ShoppingCartDetails)shoppingCartDetails) }; + } + + public override async Task GetCustomerShoppingCartDetails(GetCustomerShoppingCartDetailsRequest request, ServerCallContext context) + { + var shoppingCartDetails = await getCustomerShoppingCartDetailsInteractor.InteractAsync(request, context.CancellationToken); + + return shoppingCartDetails is null + ? new() { NotFound = new() } + : new() { Projection = Any.Pack((ShoppingCartDetails)shoppingCartDetails) }; + } + + public override async Task GetShoppingCartItemDetails(GetShoppingCartItemDetailsRequest request, ServerCallContext context) + { + var shoppingCartItemDetails = await getShoppingCartItemDetailsInteractor.InteractAsync(request, context.CancellationToken); + + return shoppingCartItemDetails is null + ? new() { NotFound = new() } + : new() { Projection = Any.Pack((ShoppingCartItemDetails)shoppingCartItemDetails) }; + } + + public override async Task ListPaymentMethodsListItems(ListPaymentMethodsListItemsRequest request, ServerCallContext context) + { + var pagedResult = await listPaymentMethodsListItemsInteractor.InteractAsync(request, context.CancellationToken); + + return pagedResult.Items.Any() + ? new() + { + PagedResult = new() + { + Projections = { pagedResult.Items.Select(item => Any.Pack((PaymentMethodListItem)item)) }, + Page = pagedResult.Page + } + } + : new() { NoContent = new() }; + } + + public override async Task ListShoppingCartItemsListItems(ListShoppingCartItemsListItemsRequest request, ServerCallContext context) + { + var pagedResult = await listShoppingCartItemsListItemsInteractor.InteractAsync(request, context.CancellationToken); + + return pagedResult.Items.Any() + ? new() + { + PagedResult = new() + { + Projections = { pagedResult.Items.Select(item => Any.Pack((ShoppingCartItemListItem)item)) }, + Page = pagedResult.Page + } + } + : new() { NoContent = new() }; + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/GrpcService/appsettings.Development.json b/src/Services/Shopping/Query/GrpcService/appsettings.Development.json new file mode 100644 index 000000000..665dc4243 --- /dev/null +++ b/src/Services/Shopping/Query/GrpcService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "ConnectionStrings": { + "Projections": "mongodb://mongoadmin:secret@127.0.0.1:27017/ShoppingCartProjections/?authSource=admin" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@127.0.0.1:5672/eventual-shop" + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/GrpcService/appsettings.Production.json b/src/Services/Shopping/Query/GrpcService/appsettings.Production.json new file mode 100644 index 000000000..38bda86cd --- /dev/null +++ b/src/Services/Shopping/Query/GrpcService/appsettings.Production.json @@ -0,0 +1,8 @@ +{ + "ConnectionStrings": { + "Projections": "mongodb://mongoadmin:secret@mongodb/ShoppingCartProjections/?authSource=admin" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@rabbitmq:5672/eventual-shop" + } +} diff --git a/src/Services/Shopping/Query/GrpcService/appsettings.Staging.json b/src/Services/Shopping/Query/GrpcService/appsettings.Staging.json new file mode 100644 index 000000000..38bda86cd --- /dev/null +++ b/src/Services/Shopping/Query/GrpcService/appsettings.Staging.json @@ -0,0 +1,8 @@ +{ + "ConnectionStrings": { + "Projections": "mongodb://mongoadmin:secret@mongodb/ShoppingCartProjections/?authSource=admin" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@rabbitmq:5672/eventual-shop" + } +} diff --git a/src/Services/Shopping/Query/GrpcService/appsettings.Testing.json b/src/Services/Shopping/Query/GrpcService/appsettings.Testing.json new file mode 100644 index 000000000..a1ba93f57 --- /dev/null +++ b/src/Services/Shopping/Query/GrpcService/appsettings.Testing.json @@ -0,0 +1,8 @@ +{ + "ConnectionStrings": { + "Projections": "mongodb://mongoadmin:secret@127.0.0.1:27017/ShoppingCartProjections/?authSource=admin" + }, + "EventBusOptions": { + "ConnectionString": "loopback://localhost/eventual-shop" + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/GrpcService/appsettings.json b/src/Services/Shopping/Query/GrpcService/appsettings.json new file mode 100644 index 000000000..4e3a152e0 --- /dev/null +++ b/src/Services/Shopping/Query/GrpcService/appsettings.json @@ -0,0 +1,42 @@ +{ + "HostOptions": { + "ShutdownTimeout": "00:00:25" + }, + "MassTransitHostOptions": { + "WaitUntilStarted": true, + "StartTimeout": "00:00:30", + "StopTimeout": "00:00:15" + }, + "EventBusOptions": { + "ConnectionName": "ShoppingCart", + "retryLimit": 3, + "initialInterval": "00:00:05", + "intervalIncrement": "00:00:10" + }, + "Kestrel": { + "EndpointDefaults": { + "ClientCertificateMode": "AllowCertificate", + "SslProtocols": ["Tls11","Tls12","Tls13"], + "Protocols": "Http2" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "MassTransit": "Information", + "Microsoft": "Information" + } + }, + "WriteTo": [ + { + "Name": "Console" + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId" + ] + } +} diff --git a/src/Services/Shopping/Query/Infrastructure.EventBus/Abstractions/Consumer.cs b/src/Services/Shopping/Query/Infrastructure.EventBus/Abstractions/Consumer.cs new file mode 100644 index 000000000..084685cda --- /dev/null +++ b/src/Services/Shopping/Query/Infrastructure.EventBus/Abstractions/Consumer.cs @@ -0,0 +1,12 @@ +using Application.Abstractions; +using Contracts.Abstractions.Messages; +using MassTransit; + +namespace Infrastructure.EventBus.Abstractions; + +public abstract class Consumer(IInteractor interactor) : IConsumer + where TMessage : class, IEvent +{ + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Infrastructure.EventBus/Consumers/Events/ProjectCartDetailsWhenChangedConsumer.cs b/src/Services/Shopping/Query/Infrastructure.EventBus/Consumers/Events/ProjectCartDetailsWhenChangedConsumer.cs new file mode 100644 index 000000000..76c061279 --- /dev/null +++ b/src/Services/Shopping/Query/Infrastructure.EventBus/Consumers/Events/ProjectCartDetailsWhenChangedConsumer.cs @@ -0,0 +1,40 @@ +using Application.UseCases.Events; +using Contracts.Boundaries.Shopping.ShoppingCart; +using MassTransit; + +namespace Infrastructure.EventBus.Consumers.Events; + +public class ProjectCartDetailsWhenChangedConsumer(IProjectCartDetailsWhenCartChangedInteractor interactor) : + IConsumer, + IConsumer, + IConsumer, + IConsumer, + IConsumer, + IConsumer, + IConsumer, + IConsumer +{ + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); + + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); + + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); + + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); + + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); + + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); + + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); + + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Infrastructure.EventBus/Consumers/Events/ProjectCartItemListItemWhenCartChangedConsumer.cs b/src/Services/Shopping/Query/Infrastructure.EventBus/Consumers/Events/ProjectCartItemListItemWhenCartChangedConsumer.cs new file mode 100644 index 000000000..38fb826da --- /dev/null +++ b/src/Services/Shopping/Query/Infrastructure.EventBus/Consumers/Events/ProjectCartItemListItemWhenCartChangedConsumer.cs @@ -0,0 +1,29 @@ +using Application.UseCases.Events; +using Contracts.Boundaries.Shopping.ShoppingCart; +using MassTransit; + +namespace Infrastructure.EventBus.Consumers.Events; + +public class ProjectCartItemListItemWhenCartChangedConsumer(IProjectCartItemListItemWhenCartChangedInteractor interactor) + : + IConsumer, + IConsumer, + IConsumer, + IConsumer, + IConsumer +{ + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); + + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); + + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); + + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); + + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Infrastructure.EventBus/Consumers/Events/ProjectPaymentMethodListItemWhenCartChangedConsumer.cs b/src/Services/Shopping/Query/Infrastructure.EventBus/Consumers/Events/ProjectPaymentMethodListItemWhenCartChangedConsumer.cs new file mode 100644 index 000000000..583e17257 --- /dev/null +++ b/src/Services/Shopping/Query/Infrastructure.EventBus/Consumers/Events/ProjectPaymentMethodListItemWhenCartChangedConsumer.cs @@ -0,0 +1,25 @@ +using Application.UseCases.Events; +using Contracts.Boundaries.Shopping.ShoppingCart; +using MassTransit; + +namespace Infrastructure.EventBus.Consumers.Events; + +public class ProjectPaymentMethodListItemWhenCartChangedConsumer(IProjectPaymentMethodListItemWhenCartChangedInteractor interactor) + : + // IConsumer, + // IConsumer, + // IConsumer, + IConsumer +{ + // public Task Consume(ConsumeContext context) + // => interactor.InteractAsync(context.Message, context.CancellationToken); + // + // public Task Consume(ConsumeContext context) + // => interactor.InteractAsync(context.Message, context.CancellationToken); + // + // public Task Consume(ConsumeContext context) + // => interactor.InteractAsync(context.Message, context.CancellationToken); + + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Infrastructure.EventBus/DependencyInjection/Extensions/NameFormatterExtensions.cs b/src/Services/Shopping/Query/Infrastructure.EventBus/DependencyInjection/Extensions/NameFormatterExtensions.cs new file mode 100644 index 000000000..b675942e7 --- /dev/null +++ b/src/Services/Shopping/Query/Infrastructure.EventBus/DependencyInjection/Extensions/NameFormatterExtensions.cs @@ -0,0 +1,16 @@ +using System.Reflection; +using MassTransit; + +namespace Infrastructure.EventBus.DependencyInjection.Extensions; + +public static class NameFormatterExtensions +{ + public static string ToKebabCaseString(this MemberInfo member) + => KebabCaseEndpointNameFormatter.Instance.SanitizeName(member.Name); +} + +public class KebabCaseEntityNameFormatter : IEntityNameFormatter +{ + public string FormatEntityName() + => typeof(T).ToKebabCaseString(); +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Infrastructure.EventBus/DependencyInjection/Extensions/RabbitMqBusFactoryConfiguratorExtensions.cs b/src/Services/Shopping/Query/Infrastructure.EventBus/DependencyInjection/Extensions/RabbitMqBusFactoryConfiguratorExtensions.cs new file mode 100644 index 000000000..c7c1cebc5 --- /dev/null +++ b/src/Services/Shopping/Query/Infrastructure.EventBus/DependencyInjection/Extensions/RabbitMqBusFactoryConfiguratorExtensions.cs @@ -0,0 +1,46 @@ +using Contracts.Abstractions.Messages; +using Contracts.Boundaries.Shopping.ShoppingCart; +using Infrastructure.EventBus.Consumers.Events; +using MassTransit; + +namespace Infrastructure.EventBus.DependencyInjection.Extensions; + +internal static class RabbitMqBusFactoryConfiguratorExtensions +{ + public static void ConfigureEventReceiveEndpoints(this IBusFactoryConfigurator cfg, IRegistrationContext context) + { + cfg.ConfigureEventReceiveEndpoint(context); + cfg.ConfigureEventReceiveEndpoint(context); + cfg.ConfigureEventReceiveEndpoint(context); + cfg.ConfigureEventReceiveEndpoint(context); + cfg.ConfigureEventReceiveEndpoint(context); + cfg.ConfigureEventReceiveEndpoint(context); + cfg.ConfigureEventReceiveEndpoint(context); + cfg.ConfigureEventReceiveEndpoint(context); + + cfg.ConfigureEventReceiveEndpoint(context); + cfg.ConfigureEventReceiveEndpoint(context); + cfg.ConfigureEventReceiveEndpoint(context); + cfg.ConfigureEventReceiveEndpoint(context); + cfg.ConfigureEventReceiveEndpoint(context); + + // cfg.ConfigureEventReceiveEndpoint(context); + // cfg.ConfigureEventReceiveEndpoint(context); + // cfg.ConfigureEventReceiveEndpoint(context); + cfg.ConfigureEventReceiveEndpoint(context); + } + + private static void ConfigureEventReceiveEndpoint(this IReceiveConfigurator bus, IRegistrationContext context) + where TConsumer : class, IConsumer + where TEvent : class, IEvent + => bus.ReceiveEndpoint( + queueName: $"shopping.query.{typeof(TConsumer).ToKebabCaseString()}.{typeof(TEvent).ToKebabCaseString()}", + configureEndpoint: endpoint => + { + if (endpoint is IRabbitMqReceiveEndpointConfigurator rabbitMq) rabbitMq.Bind(); + if (endpoint is IInMemoryReceiveEndpointConfigurator inMemory) inMemory.Bind(); + + endpoint.ConfigureConsumeTopology = false; + endpoint.ConfigureConsumer(context); + }); +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Infrastructure.EventBus/DependencyInjection/Extensions/ServiceCollectionExtensions.cs b/src/Services/Shopping/Query/Infrastructure.EventBus/DependencyInjection/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..9996b39f9 --- /dev/null +++ b/src/Services/Shopping/Query/Infrastructure.EventBus/DependencyInjection/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,101 @@ +using System.Reflection; +using Contracts.Abstractions.Messages; +using Contracts.JsonConverters; +using FluentValidation; +using Infrastructure.EventBus.DependencyInjection.Options; +using Infrastructure.EventBus.PipeFilters; +using Infrastructure.EventBus.PipeObservers; +using MassTransit; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; + +namespace Infrastructure.EventBus.DependencyInjection.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddEventBus(this IServiceCollection services) + => services.AddMassTransit(cfg => + { + cfg.SetKebabCaseEndpointNameFormatter(); + cfg.AddConsumers(Assembly.GetExecutingAssembly()); + + cfg.UsingRabbitMq((context, bus) => + { + var options = context.GetRequiredService>().CurrentValue; + + bus.Host( + hostAddress: options.ConnectionString, + connectionName: $"{options.ConnectionName}.{AppDomain.CurrentDomain.FriendlyName}"); + + bus.ConfigureBus(options, context); + }); + }); + + public static IServiceCollection AddTestingEventBus(this IServiceCollection services) + => services.AddMassTransit(cfg => + { + cfg.SetKebabCaseEndpointNameFormatter(); + cfg.AddConsumers(Assembly.GetExecutingAssembly()); + + cfg.UsingInMemory((context, bus) => + { + var options = context.GetRequiredService>().CurrentValue; + bus.Host(options.ConnectionString); + bus.ConfigureBus(options, context); + }); + }); + + private static void ConfigureBus(this IBusFactoryConfigurator bus, EventBusOptions options, IBusRegistrationContext context) + where T : IReceiveEndpointConfigurator + { + bus.UseMessageRetry(retry + => retry.Incremental( + retryLimit: options.RetryLimit, + initialInterval: options.InitialInterval, + intervalIncrement: options.IntervalIncrement)); + + bus.UseNewtonsoftJsonSerializer(); + + bus.ConfigureNewtonsoftJsonSerializer(settings => + { + settings.Converters.Add(new TypeNameHandlingConverter(TypeNameHandling.Objects)); + settings.Converters.Add(new DateOnlyJsonConverter()); + settings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + return settings; + }); + + bus.ConfigureNewtonsoftJsonDeserializer(settings => + { + settings.Converters.Add(new TypeNameHandlingConverter(TypeNameHandling.Objects)); + settings.Converters.Add(new DateOnlyJsonConverter()); + settings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + return settings; + }); + + bus.MessageTopology.SetEntityNameFormatter(new KebabCaseEntityNameFormatter()); + bus.UseConsumeFilter(typeof(ContractValidatorFilter<>), context); + bus.ConnectReceiveObserver(new LoggingReceiveObserver()); + bus.ConnectConsumeObserver(new LoggingConsumeObserver()); + bus.ConfigureEventReceiveEndpoints(context); + bus.ConfigureEndpoints(context); + } + + public static IServiceCollection AddMessageValidators(this IServiceCollection services) + => services.AddValidatorsFromAssemblyContaining(typeof(IMessage)); + + public static OptionsBuilder ConfigureEventBusOptions(this IServiceCollection services, IConfigurationSection section) + => services + .AddOptions() + .Bind(section) + .ValidateDataAnnotations() + .ValidateOnStart(); + + public static OptionsBuilder ConfigureMassTransitHostOptions(this IServiceCollection services, IConfigurationSection section) + => services + .AddOptions() + .Bind(section) + .ValidateDataAnnotations() + .ValidateOnStart(); +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Infrastructure.EventBus/DependencyInjection/Options/EventBusOptions.cs b/src/Services/Shopping/Query/Infrastructure.EventBus/DependencyInjection/Options/EventBusOptions.cs new file mode 100644 index 000000000..783e591c9 --- /dev/null +++ b/src/Services/Shopping/Query/Infrastructure.EventBus/DependencyInjection/Options/EventBusOptions.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Infrastructure.EventBus.DependencyInjection.Options; + +public record EventBusOptions +{ + [Required] public required string ConnectionName { get; init; } + [Required] public required Uri ConnectionString { get; init; } + [Required, Range(1, 10)] public int RetryLimit { get; init; } + [Required, Timestamp] public TimeSpan InitialInterval { get; init; } + [Required, Timestamp] public TimeSpan IntervalIncrement { get; init; } + [Required, MinLength(5)] public required string SchedulerQueueName { get; init; } +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Infrastructure.EventBus/Infrastructure.EventBus.csproj b/src/Services/Shopping/Query/Infrastructure.EventBus/Infrastructure.EventBus.csproj new file mode 100644 index 000000000..e941a792b --- /dev/null +++ b/src/Services/Shopping/Query/Infrastructure.EventBus/Infrastructure.EventBus.csproj @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Services/Shopping/Query/Infrastructure.EventBus/PipeFilters/ContractValidatorFilter.cs b/src/Services/Shopping/Query/Infrastructure.EventBus/PipeFilters/ContractValidatorFilter.cs new file mode 100644 index 000000000..9e10f46b1 --- /dev/null +++ b/src/Services/Shopping/Query/Infrastructure.EventBus/PipeFilters/ContractValidatorFilter.cs @@ -0,0 +1,36 @@ +using Contracts.Abstractions.Validations; +using FluentValidation; +using MassTransit; +using Serilog; + +namespace Infrastructure.EventBus.PipeFilters; + +public class ContractValidatorFilter(IValidator? validator = default) : IFilter> + where T : class +{ + public async Task Send(ConsumeContext context, IPipe> next) + { + if (validator is null) + { + await next.Send(context); + return; + } + + var validationResult = await validator.ValidateAsync(context.Message, context.CancellationToken); + + if (validationResult.IsValid) + { + await next.Send(context); + return; + } + + Log.Error("Contract validation errors: {Errors}", validationResult.Errors); + + await context.Send( + destinationAddress: new($"queue:shopping-cart.{KebabCaseEndpointNameFormatter.Instance.SanitizeName(typeof(T).Name)}.contract-errors"), + message: new ContractValidationResult(context.Message, validationResult.Errors.Select(failure => failure.ErrorMessage))); + } + + public void Probe(ProbeContext context) + => context.CreateFilterScope("Contract validation"); +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Infrastructure.EventBus/PipeObservers/LoggingConsumeObserver.cs b/src/Services/Shopping/Query/Infrastructure.EventBus/PipeObservers/LoggingConsumeObserver.cs new file mode 100644 index 000000000..8c8c0818c --- /dev/null +++ b/src/Services/Shopping/Query/Infrastructure.EventBus/PipeObservers/LoggingConsumeObserver.cs @@ -0,0 +1,24 @@ +using MassTransit; +using Serilog; + +namespace Infrastructure.EventBus.PipeObservers; + +public class LoggingConsumeObserver : IConsumeObserver +{ + public async Task PreConsume(ConsumeContext context) + where T : class + { + await Task.Yield(); + + Log.Information("Consuming {Message} message from {Namespace}, CorrelationId: {CorrelationId}", + typeof(T).Name, typeof(T).Namespace, context.CorrelationId); + } + + public Task PostConsume(ConsumeContext context) + where T : class + => Task.CompletedTask; + + public Task ConsumeFault(ConsumeContext context, Exception exception) + where T : class + => Task.CompletedTask; +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Infrastructure.EventBus/PipeObservers/LoggingReceiveObserver.cs b/src/Services/Shopping/Query/Infrastructure.EventBus/PipeObservers/LoggingReceiveObserver.cs new file mode 100644 index 000000000..7a6a8c42f --- /dev/null +++ b/src/Services/Shopping/Query/Infrastructure.EventBus/PipeObservers/LoggingReceiveObserver.cs @@ -0,0 +1,51 @@ +using MassTransit; +using Serilog; + +namespace Infrastructure.EventBus.PipeObservers; + +public class LoggingReceiveObserver : IReceiveObserver +{ + private const string ExchangeKey = "RabbitMQ-ExchangeName"; + + public async Task PreReceive(ReceiveContext context) + { + await Task.Yield(); + + Log.Debug("Receiving message from exchange {Exchange}, Redelivered: {Redelivered}, CorrelationId: {CorrelationId}", + context.TransportHeaders.Get(ExchangeKey), context.Redelivered, context.GetCorrelationId()); + } + + public async Task PostReceive(ReceiveContext context) + { + await Task.Yield(); + + Log.Debug("Message was received from exchange {Exchange}, Redelivered: {Redelivered}, CorrelationId: {CorrelationId}", + context.TransportHeaders.Get(ExchangeKey), context.Redelivered, context.GetCorrelationId()); + } + + public async Task PostConsume(ConsumeContext context, TimeSpan duration, string consumerType) + where T : class + { + await Task.Yield(); + + Log.Debug("{Message} message from {Namespace} was consumed by {ConsumerType}, Duration: {Duration}s, CorrelationId: {CorrelationId}", + typeof(T).Name, typeof(T).Namespace, consumerType, duration.TotalSeconds, context.CorrelationId); + } + + public async Task ConsumeFault(ConsumeContext context, TimeSpan duration, string consumerType, Exception exception) + where T : class + { + await Task.Yield(); + + Log.Error("Fault on consuming message {Message} from {Namespace} by {ConsumerType}, Duration: {Duration}s, Error: {Error}, CorrelationId: {CorrelationId}", + typeof(T).Name, typeof(T).Namespace, consumerType, duration.TotalSeconds, exception.Message, context.CorrelationId); + } + + public async Task ReceiveFault(ReceiveContext context, Exception exception) + { + await Task.Yield(); + + Log.Error("Fault on receiving message from exchange {Exchange}, Redelivered: {Redelivered}, Error: {Error}, CorrelationId: {CorrelationId}", + context.TransportHeaders.Get(ExchangeKey), context.Redelivered, exception.Message, context.GetCorrelationId() ?? new()); + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Infrastructure.Projections/Abstractions/IMongoDbContext.cs b/src/Services/Shopping/Query/Infrastructure.Projections/Abstractions/IMongoDbContext.cs new file mode 100644 index 000000000..9f626b6de --- /dev/null +++ b/src/Services/Shopping/Query/Infrastructure.Projections/Abstractions/IMongoDbContext.cs @@ -0,0 +1,8 @@ +using MongoDB.Driver; + +namespace Infrastructure.Projections.Abstractions; + +public interface IMongoDbContext +{ + IMongoCollection GetCollection(); +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Infrastructure.Projections/Abstractions/MongoDbContext.cs b/src/Services/Shopping/Query/Infrastructure.Projections/Abstractions/MongoDbContext.cs new file mode 100644 index 000000000..60397e62b --- /dev/null +++ b/src/Services/Shopping/Query/Infrastructure.Projections/Abstractions/MongoDbContext.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Configuration; +using MongoDB.Driver; + +namespace Infrastructure.Projections.Abstractions; + +public abstract class MongoDbContext : IMongoDbContext +{ + private readonly IMongoDatabase _database; + + protected MongoDbContext(IConfiguration configuration) + { + MongoUrl mongoUrl = new(configuration.GetConnectionString("Projections")); + _database = new MongoClient(mongoUrl).GetDatabase(mongoUrl.DatabaseName); + } + + public IMongoCollection GetCollection() + => _database.GetCollection(typeof(T).Name); +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Infrastructure.Projections/DependencyInjection/ServiceCollectionExtensions.cs b/src/Services/Shopping/Query/Infrastructure.Projections/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..c20aeb3e0 --- /dev/null +++ b/src/Services/Shopping/Query/Infrastructure.Projections/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using Application.Abstractions; +using Infrastructure.Projections.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; + +namespace Infrastructure.Projections.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static void AddProjections(this IServiceCollection services) + { + services.AddScoped(typeof(IProjectionGateway<>), typeof(ProjectionGateway<>)); + services.AddScoped(); + BsonSerializer.RegisterSerializer(new GuidSerializer(BsonType.String)); + } +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Infrastructure.Projections/Infrastructure.Projections.csproj b/src/Services/Shopping/Query/Infrastructure.Projections/Infrastructure.Projections.csproj new file mode 100644 index 000000000..1e2b801e3 --- /dev/null +++ b/src/Services/Shopping/Query/Infrastructure.Projections/Infrastructure.Projections.csproj @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/Services/Shopping/Query/Infrastructure.Projections/Pagination/PagedResult.cs b/src/Services/Shopping/Query/Infrastructure.Projections/Pagination/PagedResult.cs new file mode 100644 index 000000000..a231de95e --- /dev/null +++ b/src/Services/Shopping/Query/Infrastructure.Projections/Pagination/PagedResult.cs @@ -0,0 +1,30 @@ +using Contracts.Abstractions; +using Contracts.Abstractions.Paging; +using MongoDB.Driver; +using MongoDB.Driver.Linq; + +namespace Infrastructure.Projections.Pagination; + +public record PagedResult(IReadOnlyCollection Projections, Paging Paging) : IPagedResult + where TProjection : IProjection +{ + public IReadOnlyCollection Items + => Page.HasNext ? Projections.Take(Paging.Limit).ToList() : Projections; + + public Page Page => new() + { + Current = Paging.Offset + 1, + Size = Paging.Limit, + HasNext = Paging.Limit < Projections.Count, + HasPrevious = Paging.Offset > 0 + }; + + public static async ValueTask> CreateAsync(Paging paging, IQueryable source, CancellationToken cancellationToken) + { + var projections = await ApplyPagination(paging, source).ToListAsync(cancellationToken); + return new PagedResult(projections, paging); + } + + private static IMongoQueryable ApplyPagination(Paging paging, IQueryable source) + => (IMongoQueryable)source.Skip(paging.Limit * paging.Offset).Take(paging.Limit + 1); +} \ No newline at end of file diff --git a/src/Services/Shopping/Query/Infrastructure.Projections/ProjectionDbContext.cs b/src/Services/Shopping/Query/Infrastructure.Projections/ProjectionDbContext.cs new file mode 100644 index 000000000..f935de000 --- /dev/null +++ b/src/Services/Shopping/Query/Infrastructure.Projections/ProjectionDbContext.cs @@ -0,0 +1,6 @@ +using Infrastructure.Projections.Abstractions; +using Microsoft.Extensions.Configuration; + +namespace Infrastructure.Projections; + +public class ProjectionDbContext(IConfiguration configuration) : MongoDbContext(configuration); \ No newline at end of file diff --git a/src/Services/Shopping/Query/Infrastructure.Projections/ProjectionGateway.cs b/src/Services/Shopping/Query/Infrastructure.Projections/ProjectionGateway.cs new file mode 100644 index 000000000..d05cf6db6 --- /dev/null +++ b/src/Services/Shopping/Query/Infrastructure.Projections/ProjectionGateway.cs @@ -0,0 +1,61 @@ +using System.Linq.Expressions; +using Application.Abstractions; +using Contracts.Abstractions; +using Contracts.Abstractions.Paging; +using Infrastructure.Projections.Abstractions; +using Infrastructure.Projections.Pagination; +using MongoDB.Driver; +using MongoDB.Driver.Linq; +using Serilog; + +namespace Infrastructure.Projections; + +public class ProjectionGateway(IMongoDbContext context) : IProjectionGateway + where TProjection : IProjection +{ + private readonly IMongoCollection _collection = context.GetCollection(); + + public Task GetAsync(TId id, CancellationToken cancellationToken) where TId : struct + => FindAsync(projection => projection.Id.Equals(id), cancellationToken); + + public Task FindAsync(Expression> predicate, CancellationToken cancellationToken) + => _collection.AsQueryable().Where(predicate).FirstOrDefaultAsync(cancellationToken)!; + + public ValueTask> ListAsync(Paging paging, Expression> predicate, CancellationToken cancellationToken) + => PagedResult.CreateAsync(paging, _collection.AsQueryable().Where(predicate), cancellationToken); + + public ValueTask> ListAsync(Paging paging, CancellationToken cancellationToken) + => PagedResult.CreateAsync(paging, _collection.AsQueryable(), cancellationToken); + + public Task DeleteAsync(Expression> filter, CancellationToken cancellationToken) + => _collection.DeleteManyAsync(filter, cancellationToken); + + public Task DeleteAsync(TId id, CancellationToken cancellationToken) where TId : struct + => _collection.DeleteOneAsync(projection => projection.Id.Equals(id), cancellationToken); + + public Task UpdateFieldAsync(TId id, ulong version, Expression> field, TField value, CancellationToken cancellationToken) where TId : struct + => _collection.UpdateOneAsync( + filter: projection => projection.Id.Equals(id) && projection.Version < version, + update: new ObjectUpdateDefinition(new()).Set(field, value), + cancellationToken: cancellationToken); + + public ValueTask ReplaceInsertAsync(TProjection replacement, CancellationToken cancellationToken) + => OnReplaceAsync(replacement, projection => projection.Id == replacement.Id && projection.Version < replacement.Version, cancellationToken); + + public ValueTask RebuildInsertAsync(TProjection replacement, CancellationToken cancellationToken) + => OnReplaceAsync(replacement, projection => projection.Id == replacement.Id && projection.Version <= replacement.Version, cancellationToken); + + private async ValueTask OnReplaceAsync(TProjection replacement, Expression> filter, CancellationToken cancellationToken) + { + try + { + await _collection.ReplaceOneAsync(filter, replacement, new ReplaceOptions { IsUpsert = true }, cancellationToken); + } + catch (MongoWriteException e) when (e.WriteError.Category is ServerErrorCategory.DuplicateKey) + { + Log.Warning( + "By passing Duplicate Key when inserting {ProjectionType} with Id {Id}", + typeof(TProjection).Name, replacement.Id); + } + } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Application/Abstractions/Gateways/IEventBusGateway.cs b/src/Services/Warehousing/Command/Application/Abstractions/Gateways/IEventBusGateway.cs new file mode 100644 index 000000000..bfc79f7a6 --- /dev/null +++ b/src/Services/Warehousing/Command/Application/Abstractions/Gateways/IEventBusGateway.cs @@ -0,0 +1,10 @@ +using Contracts.Abstractions.Messages; + +namespace Application.Abstractions.Gateways; + +public interface IEventBusGateway +{ + Task PublishAsync(IEnumerable events, CancellationToken cancellationToken); + Task PublishAsync(IEvent @event, CancellationToken cancellationToken); + Task SchedulePublishAsync(IDelayedEvent @event, DateTimeOffset scheduledTime, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Application/Abstractions/Gateways/IEventStoreGateway.cs b/src/Services/Warehousing/Command/Application/Abstractions/Gateways/IEventStoreGateway.cs new file mode 100644 index 000000000..fcb351695 --- /dev/null +++ b/src/Services/Warehousing/Command/Application/Abstractions/Gateways/IEventStoreGateway.cs @@ -0,0 +1,10 @@ +using Domain.Abstractions.Aggregates; + +namespace Application.Abstractions.Gateways; + +public interface IEventStoreGateway +{ + Task AppendEventsAsync(IAggregateRoot aggregate, CancellationToken cancellationToken); + Task LoadAggregateAsync(Guid aggregateId, CancellationToken cancellationToken) where TAggregate : IAggregateRoot, new(); + IAsyncEnumerable StreamAggregatesId(); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Application/Abstractions/IInteractor.cs b/src/Services/Warehousing/Command/Application/Abstractions/IInteractor.cs new file mode 100644 index 000000000..91bec179a --- /dev/null +++ b/src/Services/Warehousing/Command/Application/Abstractions/IInteractor.cs @@ -0,0 +1,9 @@ +using Contracts.Abstractions.Messages; + +namespace Application.Abstractions; + +public interface IInteractor + where TMessage : IMessage +{ + Task InteractAsync(TMessage message, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Application/Abstractions/IUnitOfWork.cs b/src/Services/Warehousing/Command/Application/Abstractions/IUnitOfWork.cs new file mode 100644 index 000000000..9a3479aed --- /dev/null +++ b/src/Services/Warehousing/Command/Application/Abstractions/IUnitOfWork.cs @@ -0,0 +1,6 @@ +namespace Application.Abstractions; + +public interface IUnitOfWork +{ + Task ExecuteAsync(Func operationAsync, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Application/Application.csproj b/src/Services/Warehousing/Command/Application/Application.csproj new file mode 100644 index 000000000..706e03568 --- /dev/null +++ b/src/Services/Warehousing/Command/Application/Application.csproj @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Application/DependencyInjection/Extensions/ServiceCollectionExtensions.cs b/src/Services/Warehousing/Command/Application/DependencyInjection/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..0ff05e53a --- /dev/null +++ b/src/Services/Warehousing/Command/Application/DependencyInjection/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +using Application.Abstractions; +using Application.Services; +using Application.UseCases.Commands; +using Application.UseCases.Events; +using Contracts.Boundaries.Warehouse; +using Microsoft.Extensions.DependencyInjection; + +namespace Application.DependencyInjection.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddApplicationServices(this IServiceCollection services) + => services + .AddScoped(); + + public static IServiceCollection AddCommandInteractors(this IServiceCollection services) + => services + .AddScoped, CreateInventoryInteractor>() + .AddScoped, DecreaseInventoryAdjustInteractor>() + .AddScoped, IncreaseInventoryAdjustInteractor>() + .AddScoped, ReceiveInventoryItemInteractor>(); + + public static IServiceCollection AddEventInteractors(this IServiceCollection services) + => services + .AddScoped(); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Application/Services/ApplicationService.cs b/src/Services/Warehousing/Command/Application/Services/ApplicationService.cs new file mode 100644 index 000000000..2964324ca --- /dev/null +++ b/src/Services/Warehousing/Command/Application/Services/ApplicationService.cs @@ -0,0 +1,34 @@ +using Application.Abstractions; +using Application.Abstractions.Gateways; +using Contracts.Abstractions.Messages; +using Domain.Abstractions.Aggregates; + +namespace Application.Services; + +public class ApplicationService(IEventStoreGateway eventStoreGateway, + IEventBusGateway eventBusGateway, + IUnitOfWork unitOfWork) + : IApplicationService +{ + public Task LoadAggregateAsync(Guid id, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot, new() + => eventStoreGateway.LoadAggregateAsync(id, cancellationToken); + + public Task AppendEventsAsync(IAggregateRoot aggregate, CancellationToken cancellationToken) + => unitOfWork.ExecuteAsync( + operationAsync: async ct => + { + await eventStoreGateway.AppendEventsAsync(aggregate, ct); + await eventBusGateway.PublishAsync(aggregate.UncommittedEvents, ct); + }, + cancellationToken: cancellationToken); + + public IAsyncEnumerable StreamAggregatesId() + => eventStoreGateway.StreamAggregatesId(); + + public Task PublishEventAsync(IEvent @event, CancellationToken cancellationToken) + => eventBusGateway.PublishAsync(@event, cancellationToken); + + public Task SchedulePublishAsync(IDelayedEvent @event, DateTimeOffset scheduledTime, CancellationToken cancellationToken) + => eventBusGateway.SchedulePublishAsync(@event, scheduledTime, cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Application/Services/IApplicationService.cs b/src/Services/Warehousing/Command/Application/Services/IApplicationService.cs new file mode 100644 index 000000000..a4e09ca8a --- /dev/null +++ b/src/Services/Warehousing/Command/Application/Services/IApplicationService.cs @@ -0,0 +1,13 @@ +using Contracts.Abstractions.Messages; +using Domain.Abstractions.Aggregates; + +namespace Application.Services; + +public interface IApplicationService +{ + Task AppendEventsAsync(IAggregateRoot aggregate, CancellationToken cancellationToken); + Task LoadAggregateAsync(Guid id, CancellationToken cancellationToken) where TAggregate : IAggregateRoot, new(); + IAsyncEnumerable StreamAggregatesId(); + Task PublishEventAsync(IEvent @event, CancellationToken cancellationToken); + Task SchedulePublishAsync(IDelayedEvent @event, DateTimeOffset scheduledTime, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Application/UseCases/Commands/CreateInventoryInteractor.cs b/src/Services/Warehousing/Command/Application/UseCases/Commands/CreateInventoryInteractor.cs new file mode 100644 index 000000000..6b2c01aba --- /dev/null +++ b/src/Services/Warehousing/Command/Application/UseCases/Commands/CreateInventoryInteractor.cs @@ -0,0 +1,16 @@ +using Application.Abstractions; +using Application.Services; +using Contracts.Boundaries.Warehouse; +using Domain.Aggregates; + +namespace Application.UseCases.Commands; + +public class CreateInventoryInteractor(IApplicationService service) : IInteractor +{ + public async Task InteractAsync(Command.CreateInventory command, CancellationToken cancellationToken) + { + Inventory inventory = new(); + inventory.Handle(command); + await service.AppendEventsAsync(inventory, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Application/UseCases/Commands/DecreaseInventoryAdjustInteractor.cs b/src/Services/Warehousing/Command/Application/UseCases/Commands/DecreaseInventoryAdjustInteractor.cs new file mode 100644 index 000000000..fff50134c --- /dev/null +++ b/src/Services/Warehousing/Command/Application/UseCases/Commands/DecreaseInventoryAdjustInteractor.cs @@ -0,0 +1,16 @@ +using Application.Abstractions; +using Application.Services; +using Contracts.Boundaries.Warehouse; +using Domain.Aggregates; + +namespace Application.UseCases.Commands; + +public class DecreaseInventoryAdjustInteractor(IApplicationService service) : IInteractor +{ + public async Task InteractAsync(Command.DecreaseInventoryAdjust command, CancellationToken cancellationToken) + { + var inventory = await service.LoadAggregateAsync(command.InventoryId, cancellationToken); + inventory.Handle(command); + await service.AppendEventsAsync(inventory, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Application/UseCases/Commands/IncreaseInventoryAdjustInteractor.cs b/src/Services/Warehousing/Command/Application/UseCases/Commands/IncreaseInventoryAdjustInteractor.cs new file mode 100644 index 000000000..76808d6c0 --- /dev/null +++ b/src/Services/Warehousing/Command/Application/UseCases/Commands/IncreaseInventoryAdjustInteractor.cs @@ -0,0 +1,16 @@ +using Application.Abstractions; +using Application.Services; +using Contracts.Boundaries.Warehouse; +using Domain.Aggregates; + +namespace Application.UseCases.Commands; + +public class IncreaseInventoryAdjustInteractor(IApplicationService service) : IInteractor +{ + public async Task InteractAsync(Command.IncreaseInventoryAdjust command, CancellationToken cancellationToken) + { + var inventory = await service.LoadAggregateAsync(command.InventoryId, cancellationToken); + inventory.Handle(command); + await service.AppendEventsAsync(inventory, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Application/UseCases/Commands/ReceiveInventoryItemInteractor.cs b/src/Services/Warehousing/Command/Application/UseCases/Commands/ReceiveInventoryItemInteractor.cs new file mode 100644 index 000000000..23ecf6fe9 --- /dev/null +++ b/src/Services/Warehousing/Command/Application/UseCases/Commands/ReceiveInventoryItemInteractor.cs @@ -0,0 +1,16 @@ +using Application.Abstractions; +using Application.Services; +using Contracts.Boundaries.Warehouse; +using Domain.Aggregates; + +namespace Application.UseCases.Commands; + +public class ReceiveInventoryItemInteractor(IApplicationService service) : IInteractor +{ + public async Task InteractAsync(Command.ReceiveInventoryItem command, CancellationToken cancellationToken) + { + var inventory = await service.LoadAggregateAsync(command.InventoryId, cancellationToken); + inventory.Handle(command); + await service.AppendEventsAsync(inventory, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Application/UseCases/Events/ReserveInventoryItemWhenCartItemAddedInteractor.cs b/src/Services/Warehousing/Command/Application/UseCases/Events/ReserveInventoryItemWhenCartItemAddedInteractor.cs new file mode 100644 index 000000000..578a3ff8c --- /dev/null +++ b/src/Services/Warehousing/Command/Application/UseCases/Events/ReserveInventoryItemWhenCartItemAddedInteractor.cs @@ -0,0 +1,26 @@ +using Application.Abstractions; +using Application.Services; +using Domain.Aggregates; +using Command = Contracts.Boundaries.Warehouse.Command; +using DomainEvent = Contracts.Boundaries.Shopping.Shopping.DomainEvent; + +namespace Application.UseCases.Events; + +public interface IReserveInventoryItemWhenCartItemAddedInteractor : IInteractor { } + +public class ReserveInventoryItemWhenCartItemAddedInteractor(IApplicationService service) : IReserveInventoryItemWhenCartItemAddedInteractor +{ + public async Task InteractAsync(DomainEvent.CartItemAdded @event, CancellationToken cancellationToken) + { + var inventory = await service.LoadAggregateAsync(Guid.NewGuid() /*@event.InventoryId*/, cancellationToken); + + inventory.Handle(new Command.ReserveInventoryItem( + Guid.NewGuid(), // @event.InventoryId, + Guid.NewGuid(), // TODO - solve relationship + Guid.NewGuid(), // @event.CartId, + @event.Product, + @event.Quantity)); + + await service.AppendEventsAsync(inventory, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Domain/Abstractions/Aggregates/AggregateRoot.cs b/src/Services/Warehousing/Command/Domain/Abstractions/Aggregates/AggregateRoot.cs new file mode 100644 index 000000000..d72167b26 --- /dev/null +++ b/src/Services/Warehousing/Command/Domain/Abstractions/Aggregates/AggregateRoot.cs @@ -0,0 +1,43 @@ +using Contracts.Abstractions.Messages; +using Domain.Abstractions.Entities; +using FluentValidation; +using Newtonsoft.Json; + +namespace Domain.Abstractions.Aggregates; + +public abstract class AggregateRoot : Entity, IAggregateRoot + where TValidator : IValidator, new() +{ + private readonly List _events = new(); + + public uint Version { get; private set; } + + [JsonIgnore] + public IEnumerable UncommittedEvents + => _events.AsReadOnly(); + + public void LoadFromHistory(IEnumerable events) + { + foreach (var @event in events) + { + Apply(@event); + Version = @event.Version; + } + } + + public abstract void Handle(ICommand command); + + protected void RaiseEvent(Func func) where TEvent : IDomainEvent + => RaiseEvent((func as Func)!); + + protected void RaiseEvent(FunconRaise) + { + Version++; + var @event = onRaise(Version); + Apply(@event); + Validate(); + _events.Add(@event); + } + + protected abstract void Apply(IDomainEvent @event); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Domain/Abstractions/Aggregates/IAggregateRoot.cs b/src/Services/Warehousing/Command/Domain/Abstractions/Aggregates/IAggregateRoot.cs new file mode 100644 index 000000000..6d070a93a --- /dev/null +++ b/src/Services/Warehousing/Command/Domain/Abstractions/Aggregates/IAggregateRoot.cs @@ -0,0 +1,11 @@ +using Contracts.Abstractions.Messages; +using Domain.Abstractions.Entities; + +namespace Domain.Abstractions.Aggregates; + +public interface IAggregateRoot : IEntity +{ + IEnumerable UncommittedEvents { get; } + void LoadFromHistory(IEnumerable events); + void Handle(ICommand command); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Domain/Abstractions/Entities/Entity.cs b/src/Services/Warehousing/Command/Domain/Abstractions/Entities/Entity.cs new file mode 100644 index 000000000..ebc5a89f1 --- /dev/null +++ b/src/Services/Warehousing/Command/Domain/Abstractions/Entities/Entity.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace Domain.Abstractions.Entities; + +public abstract class Entity : IEntity + where TValidator : IValidator, new() +{ + public Guid Id { get; protected set; } + public bool IsDeleted { get; protected set; } + + protected void Validate() + => new TValidator() + .Validate(ValidationContext + .CreateWithOptions(this, strategy + => strategy.ThrowOnFailures())); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Domain/Abstractions/Entities/IEntity.cs b/src/Services/Warehousing/Command/Domain/Abstractions/Entities/IEntity.cs new file mode 100644 index 000000000..851f81283 --- /dev/null +++ b/src/Services/Warehousing/Command/Domain/Abstractions/Entities/IEntity.cs @@ -0,0 +1,7 @@ +namespace Domain.Abstractions.Entities; + +public interface IEntity +{ + Guid Id { get; } + bool IsDeleted { get; } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Domain/Abstractions/EventStore/IEventStoreRepository.cs b/src/Services/Warehousing/Command/Domain/Abstractions/EventStore/IEventStoreRepository.cs new file mode 100644 index 000000000..dce4d87ed --- /dev/null +++ b/src/Services/Warehousing/Command/Domain/Abstractions/EventStore/IEventStoreRepository.cs @@ -0,0 +1,12 @@ +using Contracts.Abstractions.Messages; + +namespace Domain.Abstractions.EventStore; + +public interface IEventStoreRepository +{ + Task AppendEventAsync(StoreEvent storeEvent, CancellationToken cancellationToken); + Task AppendSnapshotAsync(Snapshot snapshot, CancellationToken cancellationToken); + Task> GetStreamAsync(Guid aggregateId, ulong? version, CancellationToken cancellationToken); + Task GetSnapshotAsync(Guid aggregateId, CancellationToken cancellationToken); + IAsyncEnumerable StreamAggregatesId(); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Domain/Abstractions/EventStore/Snapshot.cs b/src/Services/Warehousing/Command/Domain/Abstractions/EventStore/Snapshot.cs new file mode 100644 index 000000000..7154333dd --- /dev/null +++ b/src/Services/Warehousing/Command/Domain/Abstractions/EventStore/Snapshot.cs @@ -0,0 +1,9 @@ +using Domain.Abstractions.Aggregates; + +namespace Domain.Abstractions.EventStore; + +public record Snapshot(Guid AggregateId, string AggregateType, IAggregateRoot Aggregate, ulong Version, DateTimeOffset Timestamp) +{ + public static Snapshot Create(IAggregateRoot aggregate, StoreEvent @event) + => new(aggregate.Id, aggregate.GetType().Name, aggregate, @event.Version, @event.Timestamp); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Domain/Abstractions/EventStore/StoreEvent.cs b/src/Services/Warehousing/Command/Domain/Abstractions/EventStore/StoreEvent.cs new file mode 100644 index 000000000..0f8e5c976 --- /dev/null +++ b/src/Services/Warehousing/Command/Domain/Abstractions/EventStore/StoreEvent.cs @@ -0,0 +1,10 @@ +using Contracts.Abstractions.Messages; +using Domain.Abstractions.Aggregates; + +namespace Domain.Abstractions.EventStore; + +public record StoreEvent(Guid AggregateId, string AggregateType, string EventType, IDomainEvent Event, ulong Version, DateTimeOffset Timestamp) +{ + public static StoreEvent Create(IAggregateRoot aggregate, IDomainEvent @event) + => new(aggregate.Id, aggregate.GetType().Name, @event.GetType().Name, @event, @event.Version, @event.Timestamp); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Domain/Aggregates/Inventory.cs b/src/Services/Warehousing/Command/Domain/Aggregates/Inventory.cs new file mode 100644 index 000000000..62dfb9dc7 --- /dev/null +++ b/src/Services/Warehousing/Command/Domain/Aggregates/Inventory.cs @@ -0,0 +1,118 @@ +using Domain.Abstractions.Aggregates; +using Contracts.Abstractions.Messages; +using Contracts.Boundaries.Warehouse; +using Domain.Entities.Adjustments; +using Domain.Entities.InventoryItems; +using Domain.ValueObjects.Products; +using Newtonsoft.Json; + +namespace Domain.Aggregates; + +public class Inventory : AggregateRoot +{ + [JsonProperty] + private readonly List _items = new(); + + private static DateTimeOffset Expiration => DateTimeOffset.Now.AddMinutes(5); + + public Guid OwnerId { get; private set; } + + public int TotalItems + => Items.Count(); + + public int TotalUnits + => Items.Sum(item => item.Quantity); + + public IEnumerable Items + => _items.AsReadOnly(); + + public override void Handle(ICommand command) + => Handle(command as dynamic); + + public void Handle(Command.CreateInventory cmd) + => RaiseEvent(version => new(Guid.NewGuid(), cmd.OwnerId, version)); + + public void Handle(Command.ReceiveInventoryItem cmd) + { + var item = _items + .Where(inventoryItem => inventoryItem.Product == cmd.Product) + .SingleOrDefault(inventoryItem => inventoryItem.Cost == cmd.Cost); + + RaiseEvent(version => item is { IsDeleted: false } + ? new DomainEvent.InventoryItemIncreased(cmd.InventoryId, item.Id, cmd.Quantity, version) + : new DomainEvent.InventoryItemReceived(cmd.InventoryId, Guid.NewGuid(), cmd.Product, cmd.Cost, cmd.Quantity, FormatSku(cmd.Product), version)); + } + + public void Handle(Command.DecreaseInventoryAdjust cmd) + { + if (_items.SingleOrDefault(inventoryItem => inventoryItem.Id == cmd.ItemId) is not { IsDeleted: false } item) return; + + RaiseEvent(version => item.QuantityAvailable >= cmd.Quantity + ? new DomainEvent.InventoryAdjustmentDecreased(cmd.InventoryId, cmd.ItemId, cmd.Reason, cmd.Quantity, version) + : new DomainEvent.InventoryAdjustmentNotDecreased(cmd.InventoryId, cmd.ItemId, cmd.Reason, cmd.Quantity, item.QuantityAvailable, version)); + } + + public void Handle(Command.IncreaseInventoryAdjust cmd) + { + if (_items.SingleOrDefault(item => item.Id == cmd.ItemId) is not { IsDeleted: false }) return; + RaiseEvent(version => new(cmd.InventoryId, cmd.ItemId, cmd.Reason, cmd.Quantity, version)); + } + + public void Handle(Command.ReserveInventoryItem cmd) + { + if (_items.SingleOrDefault(inventoryItem => inventoryItem.Product == cmd.Product) is not { IsDeleted: false } item) return; + + RaiseEvent(version => item.QuantityAvailable switch + { + < 1 => new DomainEvent.StockDepleted(cmd.InventoryId, item.Id, item.Product, version), + var availability when availability >= cmd.Quantity => new DomainEvent.InventoryReserved(cmd.InventoryId, item.Id, cmd.CatalogId, cmd.CartId, cmd.Product, cmd.Quantity, Expiration, version), + _ => new DomainEvent.InventoryNotReserved(cmd.InventoryId, item.Id, cmd.CartId, cmd.Quantity, item.QuantityAvailable, version) + }); + } + + protected override void Apply(IDomainEvent @event) + => When(@event as dynamic); + + private void When(DomainEvent.InventoryCreated @event) + => (Id, OwnerId, _) = @event; + + private void When(DomainEvent.InventoryItemReceived @event) + => _items.Add(new(@event.ItemId, @event.Cost, @event.Product, @event.Quantity, @event.Sku)); + + private void When(DomainEvent.InventoryItemIncreased @event) + => _items + .Single(item => item.Id == @event.ItemId) + .Increase(@event.Quantity); + + private void When(DomainEvent.InventoryItemDecreased @event) + => _items + .Single(item => item.Id == @event.ItemId) + .Decrease(@event.Quantity); + + private void When(DomainEvent.InventoryAdjustmentIncreased @event) + => _items + .Single(item => item.Id == @event.ItemId) + .Adjust(new IncreaseAdjustment(@event.Reason, @event.Quantity)); + + private void When(DomainEvent.InventoryAdjustmentDecreased @event) + => _items + .Single(item => item.Id == @event.ItemId) + .Adjust(new DecreaseAdjustment(@event.Reason, @event.Quantity)); + + private void When(DomainEvent.InventoryReserved @event) + => _items + .Single(item => item.Id == @event.ItemId) + .Reserve(@event.Quantity, @event.CartId, @event.Expiration); + + private void When(DomainEvent.InventoryNotReserved _) { } + + private string FormatSku(Product product) + { + var count = _items + .Where(item => item.Product.Brand == product.Brand) + .Where(item => item.Product.Category == product.Category) + .Count(item => item.Product.Unit == product.Unit); + + return $"{product.Brand[..2]}{product.Category[..2]}{product.Unit[..2]}{count + 1}".ToUpperInvariant(); + } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Domain/Aggregates/InventoryValidator.cs b/src/Services/Warehousing/Command/Domain/Aggregates/InventoryValidator.cs new file mode 100644 index 000000000..754920146 --- /dev/null +++ b/src/Services/Warehousing/Command/Domain/Aggregates/InventoryValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Domain.Aggregates; + +public class InventoryValidator : AbstractValidator +{ + public InventoryValidator() + { + RuleFor(inventory => inventory.Id) + .NotEmpty(); + } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Domain/Domain.csproj b/src/Services/Warehousing/Command/Domain/Domain.csproj new file mode 100644 index 000000000..32d29577c --- /dev/null +++ b/src/Services/Warehousing/Command/Domain/Domain.csproj @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/Services/Warehousing/Command/Domain/Entities/Adjustments/DecreaseAdjustment.cs b/src/Services/Warehousing/Command/Domain/Entities/Adjustments/DecreaseAdjustment.cs new file mode 100644 index 000000000..a6de57e8e --- /dev/null +++ b/src/Services/Warehousing/Command/Domain/Entities/Adjustments/DecreaseAdjustment.cs @@ -0,0 +1,7 @@ +namespace Domain.Entities.Adjustments; + +public class DecreaseAdjustment(string reason, int quantity) : IAdjustment +{ + public string Reason { get; } = reason; + public int Quantity { get; } = quantity; +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Domain/Entities/Adjustments/IAdjustment.cs b/src/Services/Warehousing/Command/Domain/Entities/Adjustments/IAdjustment.cs new file mode 100644 index 000000000..75447efe5 --- /dev/null +++ b/src/Services/Warehousing/Command/Domain/Entities/Adjustments/IAdjustment.cs @@ -0,0 +1,7 @@ +namespace Domain.Entities.Adjustments; + +public interface IAdjustment +{ + string Reason { get; } + int Quantity { get; } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Domain/Entities/Adjustments/IncreaseAdjustment.cs b/src/Services/Warehousing/Command/Domain/Entities/Adjustments/IncreaseAdjustment.cs new file mode 100644 index 000000000..6968808c0 --- /dev/null +++ b/src/Services/Warehousing/Command/Domain/Entities/Adjustments/IncreaseAdjustment.cs @@ -0,0 +1,7 @@ +namespace Domain.Entities.Adjustments; + +public class IncreaseAdjustment(string reason, int quantity) : IAdjustment +{ + public string Reason { get; } = reason; + public int Quantity { get; } = quantity; +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Domain/Entities/InventoryItems/InventoryItem.cs b/src/Services/Warehousing/Command/Domain/Entities/InventoryItems/InventoryItem.cs new file mode 100644 index 000000000..273d16340 --- /dev/null +++ b/src/Services/Warehousing/Command/Domain/Entities/InventoryItems/InventoryItem.cs @@ -0,0 +1,76 @@ +using Domain.Abstractions.Entities; +using Domain.Entities.Adjustments; +using Domain.ValueObjects.Products; +using Newtonsoft.Json; + +namespace Domain.Entities.InventoryItems; + +public class InventoryItem : Entity +{ + [JsonProperty] + private readonly List _reserves = new(); + + [JsonProperty] + private readonly List _adjustments = new(); + + public InventoryItem(Guid id, decimal cost, Product product, int quantity, string sku) + { + Id = id; + Cost = cost; + Product = product; + Quantity = quantity; + Sku = sku; + } + + public int QuantityAvailable + => Quantity - QuantityReserved; + + public int QuantityReserved + => _reserves.Sum(reserve => reserve.Quantity); + + public int TotalAdjustments + => _adjustments.Sum(adjustment + => adjustment switch + { + IncreaseAdjustment => adjustment.Quantity, + DecreaseAdjustment => adjustment.Quantity * -1, + _ => default + }); + + public IEnumerable Reserves + => _reserves.AsReadOnly(); + + public IEnumerable Adjustments + => _adjustments.AsReadOnly(); + + public Product Product { get; } + public int Quantity { get; private set; } + public decimal Cost { get; } + public string Sku { get; } + + public void Increase(int quantity) + => Quantity += quantity; + + public void Decrease(int quantity) + => Quantity -= quantity; + + public void Adjust(IAdjustment adjustment) + { + _adjustments.Add(adjustment); + + // Quantity = adjustment switch + // { + // IncreaseAdjustment => Quantity += adjustment.Quantity, + // DecreaseAdjustment => Quantity -= adjustment.Quantity, + // _ => Quantity + // }; + } + + public void Reserve(int quantity, Guid cartId, DateTimeOffset expiration) + => _reserves.Add(new() + { + Quantity = quantity, + CartId = cartId, + Expiration = expiration + }); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Domain/Entities/InventoryItems/InventoryItemValidator.cs b/src/Services/Warehousing/Command/Domain/Entities/InventoryItems/InventoryItemValidator.cs new file mode 100644 index 000000000..5fe51f0a3 --- /dev/null +++ b/src/Services/Warehousing/Command/Domain/Entities/InventoryItems/InventoryItemValidator.cs @@ -0,0 +1,5 @@ +using FluentValidation; + +namespace Domain.Entities.InventoryItems; + +public class InventoryItemValidator : AbstractValidator; \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Domain/Entities/Reserve.cs b/src/Services/Warehousing/Command/Domain/Entities/Reserve.cs new file mode 100644 index 000000000..263e6011d --- /dev/null +++ b/src/Services/Warehousing/Command/Domain/Entities/Reserve.cs @@ -0,0 +1,9 @@ +namespace Domain.Entities; + +public class Reserve +{ + public Guid CartId { get; set; } + public int Quantity { get; set; } + public DateTimeOffset ReservedAt { get; } = DateTimeOffset.Now; + public DateTimeOffset Expiration{ get; set; } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Domain/ValueObjects/Products/Product.cs b/src/Services/Warehousing/Command/Domain/ValueObjects/Products/Product.cs new file mode 100644 index 000000000..4e400a4f0 --- /dev/null +++ b/src/Services/Warehousing/Command/Domain/ValueObjects/Products/Product.cs @@ -0,0 +1,18 @@ +using Contracts.DataTransferObjects; + +namespace Domain.ValueObjects.Products; + +public record Product(string Description, string Name, string Brand, string Category, string Unit, string Sku) +{ + public static implicit operator Product(Dto.Product product) + => new(product.Description, product.Name, product.Brand, product.Category, product.Unit, product.Sku); + + public static implicit operator Dto.Product(Product product) + => new(product.Description, product.Name, product.Brand, product.Category, product.Unit, product.Sku); + + public static bool operator ==(Product product, Dto.Product dto) + => dto == (Dto.Product)product; + + public static bool operator !=(Product product, Dto.Product dto) + => dto != (Dto.Product)product; +} diff --git a/src/Services/Warehousing/Command/Domain/ValueObjects/Products/ProductValidator.cs b/src/Services/Warehousing/Command/Domain/ValueObjects/Products/ProductValidator.cs new file mode 100644 index 000000000..823cf14ec --- /dev/null +++ b/src/Services/Warehousing/Command/Domain/ValueObjects/Products/ProductValidator.cs @@ -0,0 +1,5 @@ +using FluentValidation; + +namespace Domain.ValueObjects.Products; + +public class ProductValidator : AbstractValidator; \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Infrastructure.EventStore/Contexts/Configurations/SnapshotConfiguration.cs b/src/Services/Warehousing/Command/Infrastructure.EventStore/Contexts/Configurations/SnapshotConfiguration.cs new file mode 100644 index 000000000..8e6472319 --- /dev/null +++ b/src/Services/Warehousing/Command/Infrastructure.EventStore/Contexts/Configurations/SnapshotConfiguration.cs @@ -0,0 +1,35 @@ +using Domain.Abstractions.EventStore; +using Infrastructure.EventStore.Contexts.Converters; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Infrastructure.EventStore.Contexts.Configurations; + +public class SnapshotConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(snapshot => new { snapshot.Version, snapshot.AggregateId }); + + builder + .Property(snapshot => snapshot.Version) + .IsRequired(); + + builder + .Property(snapshot => snapshot.AggregateId) + .IsRequired(); + + builder + .Property(snapshot => snapshot.AggregateType) + .HasMaxLength(30) + .IsRequired(); + + builder.Property(snapshot => snapshot.Timestamp) + .IsRequired(); + + builder + .Property(snapshot => snapshot.Aggregate) + .HasConversion() + .IsRequired(); + } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Infrastructure.EventStore/Contexts/Configurations/StoreEventConfiguration.cs b/src/Services/Warehousing/Command/Infrastructure.EventStore/Contexts/Configurations/StoreEventConfiguration.cs new file mode 100644 index 000000000..bdeff9b0e --- /dev/null +++ b/src/Services/Warehousing/Command/Infrastructure.EventStore/Contexts/Configurations/StoreEventConfiguration.cs @@ -0,0 +1,41 @@ +using Domain.Abstractions.EventStore; +using Infrastructure.EventStore.Contexts.Converters; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Infrastructure.EventStore.Contexts.Configurations; + +public class StoreEventConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(storeEvent => new { storeEvent.Version, storeEvent.AggregateId }); + + builder + .Property(storeEvent => storeEvent.Version) + .IsRequired(); + + builder + .Property(storeEvent => storeEvent.AggregateId) + .IsRequired(); + + builder + .Property(storeEvent => storeEvent.AggregateType) + .HasMaxLength(30) + .IsRequired(); + + builder + .Property(storeEvent => storeEvent.EventType) + .HasMaxLength(50) + .IsRequired(); + + builder + .Property(storeEvent => storeEvent.Timestamp) + .IsRequired(); + + builder + .Property(storeEvent => storeEvent.Event) + .HasConversion() + .IsRequired(); + } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Infrastructure.EventStore/Contexts/Converters/AggregateConverter.cs b/src/Services/Warehousing/Command/Infrastructure.EventStore/Contexts/Converters/AggregateConverter.cs new file mode 100644 index 000000000..634eaf786 --- /dev/null +++ b/src/Services/Warehousing/Command/Infrastructure.EventStore/Contexts/Converters/AggregateConverter.cs @@ -0,0 +1,38 @@ +using Contracts.JsonConverters; +using Domain.Abstractions.Aggregates; +using JsonNet.ContractResolvers; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Newtonsoft.Json; + +namespace Infrastructure.EventStore.Contexts.Converters; + +public class AggregateConverter() : ValueConverter(@event => JsonConvert.SerializeObject(@event, typeof(IAggregateRoot), SerializerSettings()), + jsonString => JsonConvert.DeserializeObject(jsonString, DeserializerSettings())) +{ + private static JsonSerializerSettings SerializerSettings() + { + JsonSerializerSettings jsonSerializerSettings = new() + { + TypeNameHandling = TypeNameHandling.Auto + }; + + jsonSerializerSettings.Converters.Add(new DateOnlyJsonConverter()); + jsonSerializerSettings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + + return jsonSerializerSettings; + } + + private static JsonSerializerSettings DeserializerSettings() + { + JsonSerializerSettings jsonDeserializerSettings = new() + { + TypeNameHandling = TypeNameHandling.Auto, + ContractResolver = new PrivateSetterContractResolver() + }; + + jsonDeserializerSettings.Converters.Add(new DateOnlyJsonConverter()); + jsonDeserializerSettings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + + return jsonDeserializerSettings; + } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Infrastructure.EventStore/Contexts/Converters/EventConverter.cs b/src/Services/Warehousing/Command/Infrastructure.EventStore/Contexts/Converters/EventConverter.cs new file mode 100644 index 000000000..bfdbdb50d --- /dev/null +++ b/src/Services/Warehousing/Command/Infrastructure.EventStore/Contexts/Converters/EventConverter.cs @@ -0,0 +1,38 @@ +using Contracts.Abstractions.Messages; +using Contracts.JsonConverters; +using JsonNet.ContractResolvers; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Newtonsoft.Json; + +namespace Infrastructure.EventStore.Contexts.Converters; + +public class EventConverter() : ValueConverter(@event => JsonConvert.SerializeObject(@event, typeof(IDomainEvent), SerializerSettings()), + jsonString => JsonConvert.DeserializeObject(jsonString, DeserializerSettings())) +{ + private static JsonSerializerSettings SerializerSettings() + { + JsonSerializerSettings jsonSerializerSettings = new() + { + TypeNameHandling = TypeNameHandling.Auto + }; + + jsonSerializerSettings.Converters.Add(new DateOnlyJsonConverter()); + jsonSerializerSettings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + + return jsonSerializerSettings; + } + + private static JsonSerializerSettings DeserializerSettings() + { + JsonSerializerSettings jsonDeserializerSettings = new() + { + TypeNameHandling = TypeNameHandling.Auto, + ContractResolver = new PrivateSetterContractResolver() + }; + + jsonDeserializerSettings.Converters.Add(new DateOnlyJsonConverter()); + jsonDeserializerSettings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + + return jsonDeserializerSettings; + } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Infrastructure.EventStore/Contexts/EventStoreDbContext.cs b/src/Services/Warehousing/Command/Infrastructure.EventStore/Contexts/EventStoreDbContext.cs new file mode 100644 index 000000000..ae9033a8d --- /dev/null +++ b/src/Services/Warehousing/Command/Infrastructure.EventStore/Contexts/EventStoreDbContext.cs @@ -0,0 +1,19 @@ +using Domain.Abstractions.EventStore; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.EventStore.Contexts; + +public class EventStoreDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet? Events { get; set; } + public DbSet? Snapshots { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.ApplyConfigurationsFromAssembly(typeof(EventStoreDbContext).Assembly); + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + => configurationBuilder + .Properties() + .AreUnicode(false) + .HaveMaxLength(1024); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Infrastructure.EventStore/DependencyInjection/Extensions/ServiceCollectionExtensions.cs b/src/Services/Warehousing/Command/Infrastructure.EventStore/DependencyInjection/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..b70bef01f --- /dev/null +++ b/src/Services/Warehousing/Command/Infrastructure.EventStore/DependencyInjection/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,55 @@ +using Application.Abstractions; +using Application.Abstractions.Gateways; +using Domain.Abstractions.EventStore; +using Infrastructure.EventStore.Contexts; +using Infrastructure.EventStore.DependencyInjection.Options; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Infrastructure.EventStore.DependencyInjection.Extensions; + +public static class ServiceCollectionExtensions +{ + public static void AddEventStore(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddDbContextPool((provider, builder) => + { + var configuration = provider.GetRequiredService(); + var options = provider.GetRequiredService>().Value; + + builder + .EnableDetailedErrors() + .EnableSensitiveDataLogging() + .UseSqlServer( + connectionString: configuration.GetConnectionString("EventStore"), + sqlServerOptionsAction: optionsBuilder + => optionsBuilder.ExecutionStrategy( + dependencies => new SqlServerRetryingExecutionStrategy( + dependencies: dependencies, + maxRetryCount: options.MaxRetryCount, + maxRetryDelay: options.MaxRetryDelay, + errorNumbersToAdd: options.ErrorNumbersToAdd)) + .MigrationsAssembly(typeof(EventStoreDbContext).Assembly.GetName().Name)); + }); + } + + public static OptionsBuilder ConfigureSqlServerRetryOptions(this IServiceCollection services, IConfigurationSection section) + => services + .AddOptions() + .Bind(section) + .ValidateDataAnnotations() + .ValidateOnStart(); + + public static OptionsBuilder ConfigureEventStoreOptions(this IServiceCollection services, IConfigurationSection section) + => services + .AddOptions() + .Bind(section) + .ValidateDataAnnotations() + .ValidateOnStart(); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Infrastructure.EventStore/DependencyInjection/Options/EventStoreOptions.cs b/src/Services/Warehousing/Command/Infrastructure.EventStore/DependencyInjection/Options/EventStoreOptions.cs new file mode 100644 index 000000000..c4cd43fa3 --- /dev/null +++ b/src/Services/Warehousing/Command/Infrastructure.EventStore/DependencyInjection/Options/EventStoreOptions.cs @@ -0,0 +1,8 @@ +using System.ComponentModel.DataAnnotations; + +namespace Infrastructure.EventStore.DependencyInjection.Options; + +public record EventStoreOptions +{ + [Required, Range(3, 100)] public ulong SnapshotInterval { get; init; } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Infrastructure.EventStore/DependencyInjection/Options/SqlServerRetryingOptions.cs b/src/Services/Warehousing/Command/Infrastructure.EventStore/DependencyInjection/Options/SqlServerRetryingOptions.cs new file mode 100644 index 000000000..c69326451 --- /dev/null +++ b/src/Services/Warehousing/Command/Infrastructure.EventStore/DependencyInjection/Options/SqlServerRetryingOptions.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace Infrastructure.EventStore.DependencyInjection.Options; + +public record SqlServerRetryOptions +{ + [Required, Range(5, 20)] public int MaxRetryCount { get; init; } + [Required, Timestamp] public TimeSpan MaxRetryDelay { get; init; } + public int[]? ErrorNumbersToAdd { get; init; } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Infrastructure.EventStore/EventStoreGateway.cs b/src/Services/Warehousing/Command/Infrastructure.EventStore/EventStoreGateway.cs new file mode 100644 index 000000000..4f3075bd5 --- /dev/null +++ b/src/Services/Warehousing/Command/Infrastructure.EventStore/EventStoreGateway.cs @@ -0,0 +1,44 @@ +using Application.Abstractions.Gateways; +using Domain.Abstractions.Aggregates; +using Domain.Abstractions.EventStore; +using Infrastructure.EventStore.DependencyInjection.Options; +using Infrastructure.EventStore.Exceptions; +using Microsoft.Extensions.Options; + +namespace Infrastructure.EventStore; + +public class EventStoreGateway(IEventStoreRepository repository, IOptions options) + : IEventStoreGateway +{ + private readonly EventStoreOptions _options = options.Value; + + public async Task AppendEventsAsync(IAggregateRoot aggregate, CancellationToken cancellationToken) + { + foreach (var @event in aggregate.UncommittedEvents.Select(@event => StoreEvent.Create(aggregate, @event))) + { + await repository.AppendEventAsync(@event, cancellationToken); + + if (@event.Version % _options.SnapshotInterval is 0) + await repository.AppendSnapshotAsync(Snapshot.Create(aggregate, @event), cancellationToken); + } + } + + public async Task LoadAggregateAsync(Guid aggregateId, CancellationToken cancellationToken) + where TAggregate : IAggregateRoot, new() + { + var snapshot = await repository.GetSnapshotAsync(aggregateId, cancellationToken); + var events = await repository.GetStreamAsync(aggregateId, snapshot?.Version, cancellationToken); + + if (snapshot is null && events is { Count: 0 }) + throw new AggregateNotFoundException(aggregateId, typeof(TAggregate)); + + var aggregate = snapshot?.Aggregate ?? new TAggregate(); + + aggregate.LoadFromHistory(events); + + return (TAggregate)aggregate; + } + + public IAsyncEnumerable StreamAggregatesId() + => repository.StreamAggregatesId(); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Infrastructure.EventStore/EventStoreRepository.cs b/src/Services/Warehousing/Command/Infrastructure.EventStore/EventStoreRepository.cs new file mode 100644 index 000000000..40f1ab848 --- /dev/null +++ b/src/Services/Warehousing/Command/Infrastructure.EventStore/EventStoreRepository.cs @@ -0,0 +1,43 @@ +using Contracts.Abstractions.Messages; +using Domain.Abstractions.EventStore; +using Infrastructure.EventStore.Contexts; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.EventStore; + +public class EventStoreRepository(EventStoreDbContext dbContext) : IEventStoreRepository +{ + public async Task AppendEventAsync(StoreEvent storeEvent, CancellationToken cancellationToken) + { + await dbContext.Set().AddAsync(storeEvent, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task AppendSnapshotAsync(Snapshot snapshot, CancellationToken cancellationToken) + { + await dbContext.Set().AddAsync(snapshot, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } + + public Task> GetStreamAsync(Guid aggregateId, ulong? version, CancellationToken cancellationToken) + => dbContext.Set() + .AsNoTracking() + .Where(@event => @event.AggregateId.Equals(aggregateId)) + .Where(@event => @event.Version > (version ?? 0)) + .Select(@event => @event.Event) + .ToListAsync(cancellationToken); + + public Task GetSnapshotAsync(Guid aggregateId, CancellationToken cancellationToken) + => dbContext.Set() + .AsNoTracking() + .Where(snapshot => snapshot.AggregateId.Equals(aggregateId)) + .OrderByDescending(snapshot => snapshot.Version) + .FirstOrDefaultAsync(cancellationToken); + + public IAsyncEnumerable StreamAggregatesId() + => dbContext.Set() + .AsNoTracking() + .Select(@event => @event.AggregateId) + .Distinct() + .AsAsyncEnumerable(); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Infrastructure.EventStore/Exceptions/AggregateNotFoundException.cs b/src/Services/Warehousing/Command/Infrastructure.EventStore/Exceptions/AggregateNotFoundException.cs new file mode 100644 index 000000000..63d32a051 --- /dev/null +++ b/src/Services/Warehousing/Command/Infrastructure.EventStore/Exceptions/AggregateNotFoundException.cs @@ -0,0 +1,5 @@ +using System.Reflection; + +namespace Infrastructure.EventStore.Exceptions; + +public class AggregateNotFoundException(Guid aggregateId, MemberInfo aggregateType) : Exception($"{aggregateType.Name} with id {aggregateId} not found"); \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Infrastructure.EventStore/Infrastructure.EventStore.csproj b/src/Services/Warehousing/Command/Infrastructure.EventStore/Infrastructure.EventStore.csproj new file mode 100644 index 000000000..7d36ab483 --- /dev/null +++ b/src/Services/Warehousing/Command/Infrastructure.EventStore/Infrastructure.EventStore.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Infrastructure.EventStore/Migrations/20230213214413_First Migration.Designer.cs b/src/Services/Warehousing/Command/Infrastructure.EventStore/Migrations/20230213214413_First Migration.Designer.cs new file mode 100644 index 000000000..09ac2cf36 --- /dev/null +++ b/src/Services/Warehousing/Command/Infrastructure.EventStore/Migrations/20230213214413_First Migration.Designer.cs @@ -0,0 +1,88 @@ +// +using System; +using Infrastructure.EventStore.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Infrastructure.EventStore.Migrations +{ + [DbContext(typeof(EventStoreDbContext))] + [Migration("20230213214413_First Migration")] + partial class FirstMigration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Domain.Abstractions.EventStore.Snapshot", b => + { + b.Property("Version") + .HasColumnType("bigint"); + + b.Property("AggregateId") + .HasColumnType("uniqueidentifier"); + + b.Property("Aggregate") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(30) + .IsUnicode(false) + .HasColumnType("varchar(30)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Version", "AggregateId"); + + b.ToTable("Snapshots"); + }); + + modelBuilder.Entity("Domain.Abstractions.EventStore.StoreEvent", b => + { + b.Property("Version") + .HasColumnType("bigint"); + + b.Property("AggregateId") + .HasColumnType("uniqueidentifier"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(30) + .IsUnicode(false) + .HasColumnType("varchar(30)"); + + b.Property("Event") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Version", "AggregateId"); + + b.ToTable("Events"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Warehousing/Command/Infrastructure.EventStore/Migrations/20230213214413_First Migration.cs b/src/Services/Warehousing/Command/Infrastructure.EventStore/Migrations/20230213214413_First Migration.cs new file mode 100644 index 000000000..3bdb1b85f --- /dev/null +++ b/src/Services/Warehousing/Command/Infrastructure.EventStore/Migrations/20230213214413_First Migration.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.EventStore.Migrations +{ + /// + public partial class FirstMigration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Events", + columns: table => new + { + AggregateId = table.Column(type: "uniqueidentifier", nullable: false), + Version = table.Column(type: "bigint", nullable: false), + AggregateType = table.Column(type: "varchar(30)", unicode: false, maxLength: 30, nullable: false), + EventType = table.Column(type: "varchar(50)", unicode: false, maxLength: 50, nullable: false), + Event = table.Column(type: "nvarchar(max)", nullable: false), + Timestamp = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Events", x => new { x.Version, x.AggregateId }); + }); + + migrationBuilder.CreateTable( + name: "Snapshots", + columns: table => new + { + AggregateId = table.Column(type: "uniqueidentifier", nullable: false), + Version = table.Column(type: "bigint", nullable: false), + AggregateType = table.Column(type: "varchar(30)", unicode: false, maxLength: 30, nullable: false), + Aggregate = table.Column(type: "nvarchar(max)", nullable: false), + Timestamp = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Snapshots", x => new { x.Version, x.AggregateId }); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Events"); + + migrationBuilder.DropTable( + name: "Snapshots"); + } + } +} diff --git a/src/Services/Warehousing/Command/Infrastructure.EventStore/Migrations/20230213214427_Quartz Migration.Designer.cs b/src/Services/Warehousing/Command/Infrastructure.EventStore/Migrations/20230213214427_Quartz Migration.Designer.cs new file mode 100644 index 000000000..e166e9a35 --- /dev/null +++ b/src/Services/Warehousing/Command/Infrastructure.EventStore/Migrations/20230213214427_Quartz Migration.Designer.cs @@ -0,0 +1,88 @@ +// +using System; +using Infrastructure.EventStore.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Infrastructure.EventStore.Migrations +{ + [DbContext(typeof(EventStoreDbContext))] + [Migration("20230213214427_Quartz Migration")] + partial class QuartzMigration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Domain.Abstractions.EventStore.Snapshot", b => + { + b.Property("Version") + .HasColumnType("bigint"); + + b.Property("AggregateId") + .HasColumnType("uniqueidentifier"); + + b.Property("Aggregate") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(30) + .IsUnicode(false) + .HasColumnType("varchar(30)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Version", "AggregateId"); + + b.ToTable("Snapshots"); + }); + + modelBuilder.Entity("Domain.Abstractions.EventStore.StoreEvent", b => + { + b.Property("Version") + .HasColumnType("bigint"); + + b.Property("AggregateId") + .HasColumnType("uniqueidentifier"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(30) + .IsUnicode(false) + .HasColumnType("varchar(30)"); + + b.Property("Event") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Version", "AggregateId"); + + b.ToTable("Events"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Warehousing/Command/Infrastructure.EventStore/Migrations/20230213214427_Quartz Migration.cs b/src/Services/Warehousing/Command/Infrastructure.EventStore/Migrations/20230213214427_Quartz Migration.cs new file mode 100644 index 000000000..c165d23e8 --- /dev/null +++ b/src/Services/Warehousing/Command/Infrastructure.EventStore/Migrations/20230213214427_Quartz Migration.cs @@ -0,0 +1,319 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.EventStore.Migrations +{ + /// + public partial class QuartzMigration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@" +IF db_id(N'Quartz') IS NULL +CREATE DATABASE [Quartz] COLLATE SQL_Latin1_General_CP1_CS_AS; +GO + +IF db_id(N'Quartz') IS NOT NULL +USE [Quartz]; +GO + +IF OBJECT_ID(N'[dbo].[QRTZ_CALENDARS]', N'U') IS NULL + BEGIN + + CREATE TABLE [dbo].[QRTZ_CALENDARS] + ( + [SCHED_NAME] nvarchar(120) NOT NULL, + [CALENDAR_NAME] nvarchar(200) NOT NULL, + [CALENDAR] varbinary(max) NOT NULL + ); + + CREATE TABLE [dbo].[QRTZ_CRON_TRIGGERS] + ( + [SCHED_NAME] nvarchar(120) NOT NULL, + [TRIGGER_NAME] nvarchar(150) NOT NULL, + [TRIGGER_GROUP] nvarchar(150) NOT NULL, + [CRON_EXPRESSION] nvarchar(120) NOT NULL, + [TIME_ZONE_ID] nvarchar(80) + ); + + CREATE TABLE [dbo].[QRTZ_FIRED_TRIGGERS] + ( + [SCHED_NAME] nvarchar(120) NOT NULL, + [ENTRY_ID] nvarchar(140) NOT NULL, + [TRIGGER_NAME] nvarchar(150) NOT NULL, + [TRIGGER_GROUP] nvarchar(150) NOT NULL, + [INSTANCE_NAME] nvarchar(200) NOT NULL, + [FIRED_TIME] bigint NOT NULL, + [SCHED_TIME] bigint NOT NULL, + [PRIORITY] int NOT NULL, + [STATE] nvarchar(16) NOT NULL, + [JOB_NAME] nvarchar(150) NULL, + [JOB_GROUP] nvarchar(150) NULL, + [IS_NONCONCURRENT] bit NULL, + [REQUESTS_RECOVERY] bit NULL + ); + + CREATE TABLE [dbo].[QRTZ_PAUSED_TRIGGER_GRPS] + ( + [SCHED_NAME] nvarchar(120) NOT NULL, + [TRIGGER_GROUP] nvarchar(150) NOT NULL + ); + + CREATE TABLE [dbo].[QRTZ_SCHEDULER_STATE] + ( + [SCHED_NAME] nvarchar(120) NOT NULL, + [INSTANCE_NAME] nvarchar(200) NOT NULL, + [LAST_CHECKIN_TIME] bigint NOT NULL, + [CHECKIN_INTERVAL] bigint NOT NULL + ); + + CREATE TABLE [dbo].[QRTZ_LOCKS] + ( + [SCHED_NAME] nvarchar(120) NOT NULL, + [LOCK_NAME] nvarchar(40) NOT NULL + ); + + CREATE TABLE [dbo].[QRTZ_JOB_DETAILS] + ( + [SCHED_NAME] nvarchar(120) NOT NULL, + [JOB_NAME] nvarchar(150) NOT NULL, + [JOB_GROUP] nvarchar(150) NOT NULL, + [DESCRIPTION] nvarchar(250) NULL, + [JOB_CLASS_NAME] nvarchar(250) NOT NULL, + [IS_DURABLE] bit NOT NULL, + [IS_NONCONCURRENT] bit NOT NULL, + [IS_UPDATE_DATA] bit NOT NULL, + [REQUESTS_RECOVERY] bit NOT NULL, + [JOB_DATA] varbinary(max) NULL + ); + + CREATE TABLE [dbo].[QRTZ_SIMPLE_TRIGGERS] + ( + [SCHED_NAME] nvarchar(120) NOT NULL, + [TRIGGER_NAME] nvarchar(150) NOT NULL, + [TRIGGER_GROUP] nvarchar(150) NOT NULL, + [REPEAT_COUNT] int NOT NULL, + [REPEAT_INTERVAL] bigint NOT NULL, + [TIMES_TRIGGERED] int NOT NULL + ); + + CREATE TABLE [dbo].[QRTZ_SIMPROP_TRIGGERS] + ( + [SCHED_NAME] nvarchar(120) NOT NULL, + [TRIGGER_NAME] nvarchar(150) NOT NULL, + [TRIGGER_GROUP] nvarchar(150) NOT NULL, + [STR_PROP_1] nvarchar(512) NULL, + [STR_PROP_2] nvarchar(512) NULL, + [STR_PROP_3] nvarchar(512) NULL, + [INT_PROP_1] int NULL, + [INT_PROP_2] int NULL, + [LONG_PROP_1] bigint NULL, + [LONG_PROP_2] bigint NULL, + [DEC_PROP_1] numeric(13, 4) NULL, + [DEC_PROP_2] numeric(13, 4) NULL, + [BOOL_PROP_1] bit NULL, + [BOOL_PROP_2] bit NULL, + [TIME_ZONE_ID] nvarchar(80) NULL + ); + + CREATE TABLE [dbo].[QRTZ_BLOB_TRIGGERS] + ( + [SCHED_NAME] nvarchar(120) NOT NULL, + [TRIGGER_NAME] nvarchar(150) NOT NULL, + [TRIGGER_GROUP] nvarchar(150) NOT NULL, + [BLOB_DATA] varbinary(max) NULL + ); + + CREATE TABLE [dbo].[QRTZ_TRIGGERS] + ( + [SCHED_NAME] nvarchar(120) NOT NULL, + [TRIGGER_NAME] nvarchar(150) NOT NULL, + [TRIGGER_GROUP] nvarchar(150) NOT NULL, + [JOB_NAME] nvarchar(150) NOT NULL, + [JOB_GROUP] nvarchar(150) NOT NULL, + [DESCRIPTION] nvarchar(250) NULL, + [NEXT_FIRE_TIME] bigint NULL, + [PREV_FIRE_TIME] bigint NULL, + [PRIORITY] int NULL, + [TRIGGER_STATE] nvarchar(16) NOT NULL, + [TRIGGER_TYPE] nvarchar(8) NOT NULL, + [START_TIME] bigint NOT NULL, + [END_TIME] bigint NULL, + [CALENDAR_NAME] nvarchar(200) NULL, + [MISFIRE_INSTR] int NULL, + [JOB_DATA] varbinary(max) NULL + ); + + ALTER TABLE [dbo].[QRTZ_CALENDARS] + WITH NOCHECK ADD + CONSTRAINT [PK_QRTZ_CALENDARS] PRIMARY KEY CLUSTERED + ( + [SCHED_NAME], + [CALENDAR_NAME] + ); + + ALTER TABLE [dbo].[QRTZ_CRON_TRIGGERS] + WITH NOCHECK ADD + CONSTRAINT [PK_QRTZ_CRON_TRIGGERS] PRIMARY KEY CLUSTERED + ( + [SCHED_NAME], + [TRIGGER_NAME], + [TRIGGER_GROUP] + ); + + ALTER TABLE [dbo].[QRTZ_FIRED_TRIGGERS] + WITH NOCHECK ADD + CONSTRAINT [PK_QRTZ_FIRED_TRIGGERS] PRIMARY KEY CLUSTERED + ( + [SCHED_NAME], + [ENTRY_ID] + ); + + ALTER TABLE [dbo].[QRTZ_PAUSED_TRIGGER_GRPS] + WITH NOCHECK ADD + CONSTRAINT [PK_QRTZ_PAUSED_TRIGGER_GRPS] PRIMARY KEY CLUSTERED + ( + [SCHED_NAME], + [TRIGGER_GROUP] + ); + + ALTER TABLE [dbo].[QRTZ_SCHEDULER_STATE] + WITH NOCHECK ADD + CONSTRAINT [PK_QRTZ_SCHEDULER_STATE] PRIMARY KEY CLUSTERED + ( + [SCHED_NAME], + [INSTANCE_NAME] + ); + + ALTER TABLE [dbo].[QRTZ_LOCKS] + WITH NOCHECK ADD + CONSTRAINT [PK_QRTZ_LOCKS] PRIMARY KEY CLUSTERED + ( + [SCHED_NAME], + [LOCK_NAME] + ); + + ALTER TABLE [dbo].[QRTZ_JOB_DETAILS] + WITH NOCHECK ADD + CONSTRAINT [PK_QRTZ_JOB_DETAILS] PRIMARY KEY CLUSTERED + ( + [SCHED_NAME], + [JOB_NAME], + [JOB_GROUP] + ); + + ALTER TABLE [dbo].[QRTZ_SIMPLE_TRIGGERS] + WITH NOCHECK ADD + CONSTRAINT [PK_QRTZ_SIMPLE_TRIGGERS] PRIMARY KEY CLUSTERED + ( + [SCHED_NAME], + [TRIGGER_NAME], + [TRIGGER_GROUP] + ); + + ALTER TABLE [dbo].[QRTZ_SIMPROP_TRIGGERS] + WITH NOCHECK ADD + CONSTRAINT [PK_QRTZ_SIMPROP_TRIGGERS] PRIMARY KEY CLUSTERED + ( + [SCHED_NAME], + [TRIGGER_NAME], + [TRIGGER_GROUP] + ); + + ALTER TABLE [dbo].[QRTZ_TRIGGERS] + WITH NOCHECK ADD + CONSTRAINT [PK_QRTZ_TRIGGERS] PRIMARY KEY CLUSTERED + ( + [SCHED_NAME], + [TRIGGER_NAME], + [TRIGGER_GROUP] + ); + + + ALTER TABLE [dbo].[QRTZ_BLOB_TRIGGERS] + WITH NOCHECK ADD + CONSTRAINT [PK_QRTZ_BLOB_TRIGGERS] PRIMARY KEY CLUSTERED + ( + [SCHED_NAME], + [TRIGGER_NAME], + [TRIGGER_GROUP] + ); + + ALTER TABLE [dbo].[QRTZ_CRON_TRIGGERS] + ADD + CONSTRAINT [FK_QRTZ_CRON_TRIGGERS_QRTZ_TRIGGERS] FOREIGN KEY + ( + [SCHED_NAME], + [TRIGGER_NAME], + [TRIGGER_GROUP] + ) REFERENCES [dbo].[QRTZ_TRIGGERS] ( + [SCHED_NAME], + [TRIGGER_NAME], + [TRIGGER_GROUP] + ) ON DELETE CASCADE; + + ALTER TABLE [dbo].[QRTZ_SIMPLE_TRIGGERS] + ADD + CONSTRAINT [FK_QRTZ_SIMPLE_TRIGGERS_QRTZ_TRIGGERS] FOREIGN KEY + ( + [SCHED_NAME], + [TRIGGER_NAME], + [TRIGGER_GROUP] + ) REFERENCES [dbo].[QRTZ_TRIGGERS] ( + [SCHED_NAME], + [TRIGGER_NAME], + [TRIGGER_GROUP] + ) ON DELETE CASCADE; + + ALTER TABLE [dbo].[QRTZ_SIMPROP_TRIGGERS] + ADD + CONSTRAINT [FK_QRTZ_SIMPROP_TRIGGERS_QRTZ_TRIGGERS] FOREIGN KEY + ( + [SCHED_NAME], + [TRIGGER_NAME], + [TRIGGER_GROUP] + ) REFERENCES [dbo].[QRTZ_TRIGGERS] ( + [SCHED_NAME], + [TRIGGER_NAME], + [TRIGGER_GROUP] + ) ON DELETE CASCADE; + + ALTER TABLE [dbo].[QRTZ_TRIGGERS] + ADD + CONSTRAINT [FK_QRTZ_TRIGGERS_QRTZ_JOB_DETAILS] FOREIGN KEY + ( + [SCHED_NAME], + [JOB_NAME], + [JOB_GROUP] + ) REFERENCES [dbo].[QRTZ_JOB_DETAILS] ( + [SCHED_NAME], + [JOB_NAME], + [JOB_GROUP] + ); + + CREATE INDEX [IDX_QRTZ_T_G_J] ON [dbo].[QRTZ_TRIGGERS] (SCHED_NAME, JOB_GROUP, JOB_NAME); + CREATE INDEX [IDX_QRTZ_T_C] ON [dbo].[QRTZ_TRIGGERS] (SCHED_NAME, CALENDAR_NAME); + CREATE INDEX [IDX_QRTZ_T_N_G_STATE] ON [dbo].[QRTZ_TRIGGERS] (SCHED_NAME, TRIGGER_GROUP, TRIGGER_STATE); + CREATE INDEX [IDX_QRTZ_T_STATE] ON [dbo].[QRTZ_TRIGGERS] (SCHED_NAME, TRIGGER_STATE); + CREATE INDEX [IDX_QRTZ_T_N_STATE] ON [dbo].[QRTZ_TRIGGERS] (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP, TRIGGER_STATE); + CREATE INDEX [IDX_QRTZ_T_NEXT_FIRE_TIME] ON [dbo].[QRTZ_TRIGGERS] (SCHED_NAME, NEXT_FIRE_TIME); + CREATE INDEX [IDX_QRTZ_T_NFT_ST] ON [dbo].[QRTZ_TRIGGERS] (SCHED_NAME, TRIGGER_STATE, NEXT_FIRE_TIME); + CREATE INDEX [IDX_QRTZ_T_NFT_ST_MISFIRE] ON [dbo].[QRTZ_TRIGGERS] (SCHED_NAME, MISFIRE_INSTR, NEXT_FIRE_TIME, TRIGGER_STATE); + CREATE INDEX [IDX_QRTZ_T_NFT_ST_MISFIRE_GRP] ON [dbo].[QRTZ_TRIGGERS] (SCHED_NAME, MISFIRE_INSTR,NEXT_FIRE_TIME, TRIGGER_GROUP, TRIGGER_STATE); + CREATE INDEX [IDX_QRTZ_FT_INST_JOB_REQ_RCVRY] ON [dbo].[QRTZ_FIRED_TRIGGERS] (SCHED_NAME, INSTANCE_NAME, REQUESTS_RECOVERY); + CREATE INDEX [IDX_QRTZ_FT_G_J] ON [dbo].[QRTZ_FIRED_TRIGGERS] (SCHED_NAME, JOB_GROUP, JOB_NAME); + CREATE INDEX [IDX_QRTZ_FT_G_T] ON [dbo].[QRTZ_FIRED_TRIGGERS] (SCHED_NAME, TRIGGER_GROUP, TRIGGER_NAME); + END +GO + +USE [WarehouseEventStore];", true); + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + => migrationBuilder.DropTable(name: "Quartz"); + } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/Infrastructure.EventStore/Migrations/EventStoreDbContextModelSnapshot.cs b/src/Services/Warehousing/Command/Infrastructure.EventStore/Migrations/EventStoreDbContextModelSnapshot.cs new file mode 100644 index 000000000..a2111ce74 --- /dev/null +++ b/src/Services/Warehousing/Command/Infrastructure.EventStore/Migrations/EventStoreDbContextModelSnapshot.cs @@ -0,0 +1,85 @@ +// +using System; +using Infrastructure.EventStore.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Infrastructure.EventStore.Migrations +{ + [DbContext(typeof(EventStoreDbContext))] + partial class EventStoreDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Domain.Abstractions.EventStore.Snapshot", b => + { + b.Property("Version") + .HasColumnType("bigint"); + + b.Property("AggregateId") + .HasColumnType("uniqueidentifier"); + + b.Property("Aggregate") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(30) + .IsUnicode(false) + .HasColumnType("varchar(30)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Version", "AggregateId"); + + b.ToTable("Snapshots"); + }); + + modelBuilder.Entity("Domain.Abstractions.EventStore.StoreEvent", b => + { + b.Property("Version") + .HasColumnType("bigint"); + + b.Property("AggregateId") + .HasColumnType("uniqueidentifier"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(30) + .IsUnicode(false) + .HasColumnType("varchar(30)"); + + b.Property("Event") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnType("varchar(50)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Version", "AggregateId"); + + b.ToTable("Events"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Warehousing/Command/Infrastructure.EventStore/UnitOfWork.cs b/src/Services/Warehousing/Command/Infrastructure.EventStore/UnitOfWork.cs new file mode 100644 index 000000000..89f994e3a --- /dev/null +++ b/src/Services/Warehousing/Command/Infrastructure.EventStore/UnitOfWork.cs @@ -0,0 +1,20 @@ +using Application.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Infrastructure.EventStore; + +public class UnitOfWork(DbContext dbContext) : IUnitOfWork +{ + private readonly DatabaseFacade _database = dbContext.Database; + + public Task ExecuteAsync(Func operationAsync, CancellationToken cancellationToken) + => _database.CreateExecutionStrategy().ExecuteAsync(ct => ExecuteTransactionAsync(operationAsync, ct), cancellationToken); + + private async Task ExecuteTransactionAsync(Func operationAsync, CancellationToken cancellationToken) + { + await using var transaction = await _database.BeginTransactionAsync(cancellationToken); + await operationAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/WorkerService/.dockerignore b/src/Services/Warehousing/Command/WorkerService/.dockerignore new file mode 100644 index 000000000..cd967fc3a --- /dev/null +++ b/src/Services/Warehousing/Command/WorkerService/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/src/Services/Warehousing/Command/WorkerService/Dockerfile b/src/Services/Warehousing/Command/WorkerService/Dockerfile new file mode 100644 index 000000000..7c8866c91 --- /dev/null +++ b/src/Services/Warehousing/Command/WorkerService/Dockerfile @@ -0,0 +1,42 @@ +ARG ASPNET_VERSION="8.0-preview" +ARG SDK_VERSION="8.0-preview" +ARG BASE_ADRESS="mcr.microsoft.com/dotnet" + +FROM $BASE_ADRESS/aspnet:$ASPNET_VERSION AS base +WORKDIR /app + +FROM $BASE_ADRESS/sdk:$SDK_VERSION AS build + +COPY ./global.json ./ +COPY ./nuget.config ./ +COPY ./Directory.Build.props ./ + +WORKDIR /src + +COPY ./src/Services/Warehouse/Command/Application/*.csproj ./Services/Warehouse/Command/Application/ +COPY ./src/Services/Warehouse/Command/Domain/*.csproj ./Services/Warehouse/Command/Domain/ +COPY ./src/Services/Warehouse/Command/Infrastructure.EventStore/*.csproj ./Services/Warehouse/Command/Infrastructure.EventStore/ +COPY ./src/Services/Warehouse/Command/Infrastructure.MessageBus/*.csproj ./Services/Warehouse/Command/Infrastructure.MessageBus/ +COPY ./src/Services/Warehouse/Command/WorkerService/*.csproj ./Services/Warehouse/Command/WorkerService/ +COPY ./src/Contracts/*.csproj ./Contracts/ + +RUN dotnet restore -v m ./Services/Warehouse/Command/WorkerService + +COPY ./src/Services/Warehouse/Command/Application/. ./Services/Warehouse/Command/Application/ +COPY ./src/Services/Warehouse/Command/Domain/. ./Services/Warehouse/Command/Domain/ +COPY ./src/Services/Warehouse/Command/Infrastructure.EventStore/. ./Services/Warehouse/Command/Infrastructure.EventStore/ +COPY ./src/Services/Warehouse/Command/Infrastructure.MessageBus/. ./Services/Warehouse/Command/Infrastructure.MessageBus/ +COPY ./src/Services/Warehouse/Command/WorkerService/. ./Services/Warehouse/Command/WorkerService/ +COPY ./src/Contracts/. ./Contracts/ + +WORKDIR /src/Services/Warehouse/Command/WorkerService + +RUN dotnet build -c Release --no-restore -v m -o /app/build + +FROM build AS publish +RUN dotnet publish -c Release --no-restore -v m -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "WorkerService.dll"] \ No newline at end of file diff --git a/src/Services/Warehousing/Command/WorkerService/Program.cs b/src/Services/Warehousing/Command/WorkerService/Program.cs new file mode 100644 index 000000000..a7861e8cb --- /dev/null +++ b/src/Services/Warehousing/Command/WorkerService/Program.cs @@ -0,0 +1,86 @@ +using Application.DependencyInjection.Extensions; +using Infrastructure.EventBus.DependencyInjection.Extensions; +using Infrastructure.EventBus.DependencyInjection.Options; +using Infrastructure.EventStore.Contexts; +using Infrastructure.EventStore.DependencyInjection.Extensions; +using Infrastructure.EventStore.DependencyInjection.Options; +using MassTransit; +using Microsoft.EntityFrameworkCore; +using Quartz; +using Serilog; + +var builder = Host.CreateDefaultBuilder(args); + +builder.UseDefaultServiceProvider((context, provider) => +{ + provider.ValidateScopes = + provider.ValidateOnBuild = + context.HostingEnvironment.IsDevelopment(); +}); + +builder.ConfigureAppConfiguration(configuration => +{ + configuration + .AddUserSecrets() + .AddEnvironmentVariables(); +}); + +builder.ConfigureLogging(logging + => logging.ClearProviders().AddSerilog()); + +builder.UseSerilog((context, cfg) + => cfg.ReadFrom.Configuration(context.Configuration)); + +builder.ConfigureServices((context, services) => +{ + services.AddEventStore(); + services.AddMessageBus(); + services.AddEventBusGateway(); + services.AddApplicationServices(); + services.AddCommandInteractors(); + services.AddEventInteractors(); + services.AddMessageValidators(); + + services.ConfigureEventStoreOptions( + context.Configuration.GetSection(nameof(EventStoreOptions))); + + services.ConfigureSqlServerRetryOptions( + context.Configuration.GetSection(nameof(SqlServerRetryOptions))); + + services.ConfigureEventBusOptions( + context.Configuration.GetSection(nameof(EventBusOptions))); + + services.ConfigureMassTransitHostOptions( + context.Configuration.GetSection(nameof(MassTransitHostOptions))); + + services.ConfigureQuartzOptions( + context.Configuration.GetSection(nameof(QuartzOptions))); +}); + +using var host = builder.Build(); + +try +{ + var environment = host.Services.GetRequiredService(); + + if (environment.IsDevelopment() || environment.IsStaging()) + { + await using var scope = host.Services.CreateAsyncScope(); + await using var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(); + await dbContext.Database.EnsureCreatedAsync(); + } + + await host.RunAsync(); + Log.Information("Stopped cleanly"); +} +catch (Exception ex) +{ + Log.Fatal(ex, "An unhandled exception occured during bootstrapping"); + await host.StopAsync(); +} +finally +{ + Log.CloseAndFlush(); + host.Dispose(); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/WorkerService/Properties/launchSettings.json b/src/Services/Warehousing/Command/WorkerService/Properties/launchSettings.json new file mode 100644 index 000000000..aad1241c4 --- /dev/null +++ b/src/Services/Warehousing/Command/WorkerService/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "Warehouse.WorkerService": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Warehousing/Command/WorkerService/WorkerService.csproj b/src/Services/Warehousing/Command/WorkerService/WorkerService.csproj new file mode 100644 index 000000000..e9f294344 --- /dev/null +++ b/src/Services/Warehousing/Command/WorkerService/WorkerService.csproj @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/Services/Warehousing/Command/WorkerService/appsettings.Development.json b/src/Services/Warehousing/Command/WorkerService/appsettings.Development.json new file mode 100644 index 000000000..a77434771 --- /dev/null +++ b/src/Services/Warehousing/Command/WorkerService/appsettings.Development.json @@ -0,0 +1,11 @@ +{ + "ConnectionStrings": { + "EventStore": "Server=127.0.0.1,1433;Database=WarehouseEventStore;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@127.0.0.1:5672/eventual-shop" + }, + "QuartzOptions": { + "quartz.dataSource.default.connectionString": "Server=127.0.0.1,1433;Database=Quartz;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/WorkerService/appsettings.Production.json b/src/Services/Warehousing/Command/WorkerService/appsettings.Production.json new file mode 100644 index 000000000..b6f5e8b55 --- /dev/null +++ b/src/Services/Warehousing/Command/WorkerService/appsettings.Production.json @@ -0,0 +1,11 @@ +{ + "ConnectionStrings": { + "EventStore": "Server=mssql;Database=WarehouseEventStore;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@rabbitmq:5672/eventual-shop" + }, + "QuartzOptions": { + "quartz.dataSource.default.connectionString": "Server=mssql;Database=Quartz;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/WorkerService/appsettings.Staging.json b/src/Services/Warehousing/Command/WorkerService/appsettings.Staging.json new file mode 100644 index 000000000..b6f5e8b55 --- /dev/null +++ b/src/Services/Warehousing/Command/WorkerService/appsettings.Staging.json @@ -0,0 +1,11 @@ +{ + "ConnectionStrings": { + "EventStore": "Server=mssql;Database=WarehouseEventStore;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@rabbitmq:5672/eventual-shop" + }, + "QuartzOptions": { + "quartz.dataSource.default.connectionString": "Server=mssql;Database=Quartz;User=sa;Password=!MyStrongPassword;trustServerCertificate=true" + } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Command/WorkerService/appsettings.json b/src/Services/Warehousing/Command/WorkerService/appsettings.json new file mode 100644 index 000000000..c22c1a476 --- /dev/null +++ b/src/Services/Warehousing/Command/WorkerService/appsettings.json @@ -0,0 +1,52 @@ +{ + "HostOptions": { + "ShutdownTimeout": "00:00:25" + }, + "MassTransitHostOptions": { + "WaitUntilStarted": true, + "StartTimeout": "00:00:30", + "StopTimeout": "00:00:15" + }, + "EventBusOptions": { + "ConnectionName": "Warehouse", + "SchedulerQueueName": "scheduler", + "retryLimit": 3, + "initialInterval": "00:00:05", + "intervalIncrement": "00:00:10" + }, + "SqlServerRetryOptions": { + "MaxRetryCount": 5, + "MaxRetryDelay": "00:00:05", + "ErrorNumbersToAdd": [] + }, + "EventStoreOptions": { + "SnapshotInterval": 5 + }, + "QuartzOptions": { + "quartz.scheduler.instanceName": "Warehouse", + "quartz.scheduler.instanceId": "AUTO", + "quartz.jobStore.dataSource": "default", + "quartz.dataSource.default.provider": "SqlServer", + "quartz.serializer.type": "json", + "quartz.jobStore.type": "Quartz.Impl.AdoJobStore.JobStoreTX, Quartz", + "quartz.jobStore.clustered": true, + "quartz.jobStore.driverDelegateType": "Quartz.Impl.AdoJobStore.SqlServerDelegate, Quartz" + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "MassTransit": "Information", + "Quartz": "Information", + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console" + } + ] + } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Application/Abstractions/IInteractor.cs b/src/Services/Warehousing/Query/Application/Abstractions/IInteractor.cs new file mode 100644 index 000000000..2369fd771 --- /dev/null +++ b/src/Services/Warehousing/Query/Application/Abstractions/IInteractor.cs @@ -0,0 +1,25 @@ +using Contracts.Abstractions; +using Contracts.Abstractions.Messages; +using Contracts.Abstractions.Paging; + +namespace Application.Abstractions; + +public interface IInteractor + where TEvent : IEvent +{ + Task InteractAsync(TEvent @event, CancellationToken cancellationToken); +} + +public interface IInteractor + where TQuery : IQuery + where TProjection : IProjection +{ + Task InteractAsync(TQuery query, CancellationToken cancellationToken); +} + +public interface IPagedInteractor + where TQuery : IQuery + where TProjection : IProjection +{ + ValueTask> InteractAsync(TQuery query, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Application/Abstractions/IProjectionGateway.cs b/src/Services/Warehousing/Query/Application/Abstractions/IProjectionGateway.cs new file mode 100644 index 000000000..565180f64 --- /dev/null +++ b/src/Services/Warehousing/Query/Application/Abstractions/IProjectionGateway.cs @@ -0,0 +1,19 @@ +using System.Linq.Expressions; +using Contracts.Abstractions; +using Contracts.Abstractions.Paging; + +namespace Application.Abstractions; + +public interface IProjectionGateway + where TProjection : IProjection +{ + Task FindAsync(Expression> predicate, CancellationToken cancellationToken); + Task GetAsync(TId id, CancellationToken cancellationToken) where TId : struct; + ValueTask> ListAsync(Paging paging, Expression> predicate, CancellationToken cancellationToken); + ValueTask> ListAsync(Paging paging, CancellationToken cancellationToken); + ValueTask ReplaceInsertAsync(TProjection replacement, CancellationToken cancellationToken); + ValueTask RebuildInsertAsync(TProjection replacement, CancellationToken cancellationToken); + Task DeleteAsync(Expression> filter, CancellationToken cancellationToken); + Task DeleteAsync(TId id, CancellationToken cancellationToken) where TId : struct; + Task UpdateFieldAsync(TId id, ulong version, Expression> field, TField value, CancellationToken cancellationToken) where TId : struct; +} \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Application/Application.csproj b/src/Services/Warehousing/Query/Application/Application.csproj new file mode 100644 index 000000000..13f1ac4a4 --- /dev/null +++ b/src/Services/Warehousing/Query/Application/Application.csproj @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Application/DependencyInjection/ServiceCollectionExtensions.cs b/src/Services/Warehousing/Query/Application/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..8abc2b7fe --- /dev/null +++ b/src/Services/Warehousing/Query/Application/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,25 @@ +using Application.Abstractions; +using Application.UseCases.Events; +using Application.UseCases.Queries; +using Contracts.Boundaries.Warehouse; +using Microsoft.Extensions.DependencyInjection; + +namespace Application.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddInteractors(this IServiceCollection services) + => services + .AddEventInteractors() + .AddQueryInteractors(); + + private static IServiceCollection AddEventInteractors(this IServiceCollection services) + => services + .AddScoped() + .AddScoped(); + + private static IServiceCollection AddQueryInteractors(this IServiceCollection services) + => services + .AddScoped, ListInventoriesGridItemInteractor>() + .AddScoped, ListInventoryItemsListItemsInteractor>(); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Application/UseCases/Events/ProjectInventoryGridItemWhenInventoryChangedInteractor.cs b/src/Services/Warehousing/Query/Application/UseCases/Events/ProjectInventoryGridItemWhenInventoryChangedInteractor.cs new file mode 100644 index 000000000..99ada0ddd --- /dev/null +++ b/src/Services/Warehousing/Query/Application/UseCases/Events/ProjectInventoryGridItemWhenInventoryChangedInteractor.cs @@ -0,0 +1,21 @@ +using Application.Abstractions; +using Contracts.Boundaries.Warehouse; + +namespace Application.UseCases.Events; + +public interface IProjectInventoryGridItemWhenInventoryChangedInteractor : IInteractor { } + +public class ProjectInventoryGridItemWhenInventoryChangedInteractor(IProjectionGateway projectionGateway) + : IProjectInventoryGridItemWhenInventoryChangedInteractor +{ + public async Task InteractAsync(DomainEvent.InventoryCreated @event, CancellationToken cancellationToken) + { + Projection.InventoryGridItem card = new( + @event.InventoryId, + @event.OwnerId, + false, + @event.Version); + + await projectionGateway.ReplaceInsertAsync(card, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Application/UseCases/Events/ProjectInventoryItemListItemWhenInventoryItemChangedInteractor.cs b/src/Services/Warehousing/Query/Application/UseCases/Events/ProjectInventoryItemListItemWhenInventoryItemChangedInteractor.cs new file mode 100644 index 000000000..bc936841c --- /dev/null +++ b/src/Services/Warehousing/Query/Application/UseCases/Events/ProjectInventoryItemListItemWhenInventoryItemChangedInteractor.cs @@ -0,0 +1,52 @@ +using Application.Abstractions; +using Contracts.Boundaries.Warehouse; + +namespace Application.UseCases.Events; + +public interface IProjectInventoryItemListItemWhenInventoryItemChangedInteractor : + IInteractor, + IInteractor, + IInteractor, + IInteractor { } + +public class ProjectInventoryItemListItemWhenInventoryItemChangedInteractor(IProjectionGateway projectionGateway) + : IProjectInventoryItemListItemWhenInventoryItemChangedInteractor +{ + public async Task InteractAsync(DomainEvent.InventoryAdjustmentDecreased @event, CancellationToken cancellationToken) + => await projectionGateway.UpdateFieldAsync( + id: @event.ItemId, + version: @event.Version, + field: item => item.Quantity, + value: @event.Quantity * -1, // TODO: This is a hack, should be fixed in the domain event + cancellationToken: cancellationToken); + + public async Task InteractAsync(DomainEvent.InventoryAdjustmentIncreased @event, CancellationToken cancellationToken) + => await projectionGateway.UpdateFieldAsync( + id: @event.ItemId, + version: @event.Version, + field: item => item.Quantity, + value: @event.Quantity, + cancellationToken: cancellationToken); + + public async Task InteractAsync(DomainEvent.InventoryItemIncreased @event, CancellationToken cancellationToken) + => await projectionGateway.UpdateFieldAsync( + id: @event.ItemId, + version: @event.Version, + field: item => item.Quantity, + value: @event.Quantity, + cancellationToken: cancellationToken); + + public async Task InteractAsync(DomainEvent.InventoryItemReceived @event, CancellationToken cancellationToken) + { + Projection.InventoryItemListItem inventoryItemListItem = new( + @event.ItemId, + @event.InventoryId, + @event.Product, + @event.Quantity, + @event.Sku, + false, + @event.Version); + + await projectionGateway.ReplaceInsertAsync(inventoryItemListItem, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Application/UseCases/Queries/ListInventoriesGridItemInteractor.cs b/src/Services/Warehousing/Query/Application/UseCases/Queries/ListInventoriesGridItemInteractor.cs new file mode 100644 index 000000000..d20bb7ea6 --- /dev/null +++ b/src/Services/Warehousing/Query/Application/UseCases/Queries/ListInventoriesGridItemInteractor.cs @@ -0,0 +1,12 @@ +using Application.Abstractions; +using Contracts.Abstractions.Paging; +using Contracts.Boundaries.Warehouse; + +namespace Application.UseCases.Queries; + +public class ListInventoriesGridItemInteractor(IProjectionGateway projectionGateway) + : IPagedInteractor +{ + public ValueTask> InteractAsync(Query.ListInventoryGridItems query, CancellationToken cancellationToken) + => projectionGateway.ListAsync(query.Paging, cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Application/UseCases/Queries/ListInventoryItemsListItemsInteractor.cs b/src/Services/Warehousing/Query/Application/UseCases/Queries/ListInventoryItemsListItemsInteractor.cs new file mode 100644 index 000000000..037c599db --- /dev/null +++ b/src/Services/Warehousing/Query/Application/UseCases/Queries/ListInventoryItemsListItemsInteractor.cs @@ -0,0 +1,12 @@ +using Application.Abstractions; +using Contracts.Abstractions.Paging; +using Contracts.Boundaries.Warehouse; + +namespace Application.UseCases.Queries; + +public class ListInventoryItemsListItemsInteractor(IProjectionGateway projectionGateway) + : IPagedInteractor +{ + public ValueTask> InteractAsync(Query.ListInventoryItemsListItems query, CancellationToken cancellationToken) + => projectionGateway.ListAsync(query.Paging, item => item.InventoryId == query.InventoryId, cancellationToken); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Query/GrpcService/.dockerignore b/src/Services/Warehousing/Query/GrpcService/.dockerignore new file mode 100644 index 000000000..cd967fc3a --- /dev/null +++ b/src/Services/Warehousing/Query/GrpcService/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/src/Services/Warehousing/Query/GrpcService/Dockerfile b/src/Services/Warehousing/Query/GrpcService/Dockerfile new file mode 100644 index 000000000..db7052543 --- /dev/null +++ b/src/Services/Warehousing/Query/GrpcService/Dockerfile @@ -0,0 +1,42 @@ +ARG ASPNET_VERSION="8.0-preview" +ARG SDK_VERSION="8.0-preview" +ARG BASE_ADRESS="mcr.microsoft.com/dotnet" + +FROM $BASE_ADRESS/aspnet:$ASPNET_VERSION AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 + +FROM $BASE_ADRESS/sdk:$SDK_VERSION AS build + +COPY ./global.json ./ +COPY ./nuget.config ./ +COPY ./Directory.Build.props ./ + +WORKDIR /src + +COPY ./src/Services/Warehouse/Query/Application/*.csproj ./Services/Warehouse/Query/Application/ +COPY ./src/Services/Warehouse/Query/GrpcService/*.csproj ./Services/Warehouse/Query/GrpcService/ +COPY ./src/Services/Warehouse/Query/Infrastructure.EventBus/*.csproj ./Services/Warehouse/Query/Infrastructure.EventBus/ +COPY ./src/Services/Warehouse/Query/Infrastructure.Projections/*.csproj ./Services/Warehouse/Query/Infrastructure.Projections/ +COPY ./src/Contracts/*.csproj ./Contracts/ + +RUN dotnet restore -v m ./Services/Warehouse/Query/GrpcService + +COPY ./src/Services/Warehouse/Query/Application/. ./Services/Warehouse/Query/Application/ +COPY ./src/Services/Warehouse/Query/GrpcService/. ./Services/Warehouse/Query/GrpcService/ +COPY ./src/Services/Warehouse/Query/Infrastructure.EventBus/. ./Services/Warehouse/Query/Infrastructure.EventBus/ +COPY ./src/Services/Warehouse/Query/Infrastructure.Projections/. ./Services/Warehouse/Query/Infrastructure.Projections/ +COPY ./src/Contracts/. ./Contracts/ + +WORKDIR /src/Services/Warehouse/Query/GrpcService + +RUN dotnet build -c Release --no-restore -v m -o /app/build + +FROM build AS publish +RUN dotnet publish -c Release --no-restore -v m -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "GrpcService.dll"] diff --git a/src/Services/Warehousing/Query/GrpcService/GrpcService.csproj b/src/Services/Warehousing/Query/GrpcService/GrpcService.csproj new file mode 100644 index 000000000..73b4a732a --- /dev/null +++ b/src/Services/Warehousing/Query/GrpcService/GrpcService.csproj @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/Services/Warehousing/Query/GrpcService/Program.cs b/src/Services/Warehousing/Query/GrpcService/Program.cs new file mode 100644 index 000000000..bc1de61a6 --- /dev/null +++ b/src/Services/Warehousing/Query/GrpcService/Program.cs @@ -0,0 +1,74 @@ +using System.Reflection; +using Application.DependencyInjection; +using GrpcService; +using Infrastructure.EventBus.DependencyInjection.Extensions; +using Infrastructure.EventBus.DependencyInjection.Options; +using Infrastructure.Projections.DependencyInjection; +using MassTransit; +using Microsoft.AspNetCore.HttpLogging; +using Serilog; + +var builder = WebApplication.CreateBuilder(args); + +builder.Host.UseDefaultServiceProvider((context, provider) => +{ + provider.ValidateScopes = + provider.ValidateOnBuild = + context.HostingEnvironment.IsDevelopment(); +}); + +builder.Configuration + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .AddEnvironmentVariables(); + +builder.Logging.ClearProviders().AddSerilog(); + +builder.Host.UseSerilog((context, cfg) + => cfg.ReadFrom.Configuration(context.Configuration)); + +builder.Host.ConfigureServices((context, services) => +{ + services.AddCors(options + => options.AddDefaultPolicy(policyBuilder + => policyBuilder + .AllowAnyOrigin() + .AllowAnyHeader() + .AllowAnyMethod())); + + services.AddGrpc(); + services.AddEventBus(); + services.AddMessageValidators(); + services.AddProjections(); + services.AddInteractors(); + + services.ConfigureEventBusOptions( + context.Configuration.GetSection(nameof(EventBusOptions))); + + services.ConfigureMassTransitHostOptions( + context.Configuration.GetSection(nameof(MassTransitHostOptions))); + + services.AddHttpLogging(options + => options.LoggingFields = HttpLoggingFields.All); +}); + +var app = builder.Build(); + +app.UseCors(); +app.UseSerilogRequestLogging(); +app.MapGrpcService(); + +try +{ + await app.RunAsync(); + Log.Information("Stopped cleanly"); +} +catch (Exception ex) +{ + Log.Fatal(ex, "An unhandled exception occured during bootstrapping"); + await app.StopAsync(); +} +finally +{ + Log.CloseAndFlush(); + await app.DisposeAsync(); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Query/GrpcService/Properties/launchSettings.json b/src/Services/Warehousing/Query/GrpcService/Properties/launchSettings.json new file mode 100644 index 000000000..222e6c8f0 --- /dev/null +++ b/src/Services/Warehousing/Query/GrpcService/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "Warehouse.GrpcService": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7138;http://localhost:5138", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Warehousing/Query/GrpcService/WarehouseGrpcService.cs b/src/Services/Warehousing/Query/GrpcService/WarehouseGrpcService.cs new file mode 100644 index 000000000..2c07e2391 --- /dev/null +++ b/src/Services/Warehousing/Query/GrpcService/WarehouseGrpcService.cs @@ -0,0 +1,45 @@ +using Application.Abstractions; +using Contracts.Abstractions.Protobuf; +using Contracts.Boundaries.Warehouse; +using Contracts.Services.Warehouse.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; + +namespace GrpcService; + +public class WarehouseGrpcService(IPagedInteractor listInventoryGridItemsInteractor, + IPagedInteractor listInventoriesItemsCardsInteractor) + : WarehouseService.WarehouseServiceBase +{ + public override async Task ListInventoryItems(ListInventoryItemsListItemsRequest request, ServerCallContext context) + { + var pagedResult = await listInventoryGridItemsInteractor.InteractAsync(request, context.CancellationToken); + + return pagedResult.Items.Any() + ? new() + { + PagedResult = new() + { + Projections = { pagedResult.Items.Select(item => Any.Pack((InventoryItemListItem)item)) }, + Page = pagedResult.Page + } + } + : new() { NoContent = new() }; + } + + public override async Task ListInventoryGridItems(ListInventoryGridItemsRequest request, ServerCallContext context) + { + var pagedResult = await listInventoriesItemsCardsInteractor.InteractAsync(request, context.CancellationToken); + + return pagedResult.Items.Any() + ? new() + { + PagedResult = new() + { + Projections = { pagedResult.Items.Select(item => Any.Pack((InventoryGridItem)item)) }, + Page = pagedResult.Page + } + } + : new() { NoContent = new() }; + } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Query/GrpcService/appsettings.Development.json b/src/Services/Warehousing/Query/GrpcService/appsettings.Development.json new file mode 100644 index 000000000..f1ff816e7 --- /dev/null +++ b/src/Services/Warehousing/Query/GrpcService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "ConnectionStrings": { + "Projections": "mongodb://mongoadmin:secret@127.0.0.1:27017/WarehouseProjections/?authSource=admin" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@127.0.0.1:5672/eventual-shop" + } +} diff --git a/src/Services/Warehousing/Query/GrpcService/appsettings.Production.json b/src/Services/Warehousing/Query/GrpcService/appsettings.Production.json new file mode 100644 index 000000000..88172c941 --- /dev/null +++ b/src/Services/Warehousing/Query/GrpcService/appsettings.Production.json @@ -0,0 +1,8 @@ +{ + "ConnectionStrings": { + "Projections": "mongodb://mongoadmin:secret@mongodb/WarehouseProjections/?authSource=admin" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@rabbitmq:5672/eventual-shop" + } +} diff --git a/src/Services/Warehousing/Query/GrpcService/appsettings.Staging.json b/src/Services/Warehousing/Query/GrpcService/appsettings.Staging.json new file mode 100644 index 000000000..88172c941 --- /dev/null +++ b/src/Services/Warehousing/Query/GrpcService/appsettings.Staging.json @@ -0,0 +1,8 @@ +{ + "ConnectionStrings": { + "Projections": "mongodb://mongoadmin:secret@mongodb/WarehouseProjections/?authSource=admin" + }, + "EventBusOptions": { + "ConnectionString": "amqp://guest:guest@rabbitmq:5672/eventual-shop" + } +} diff --git a/src/Services/Warehousing/Query/GrpcService/appsettings.json b/src/Services/Warehousing/Query/GrpcService/appsettings.json new file mode 100644 index 000000000..302ee35af --- /dev/null +++ b/src/Services/Warehousing/Query/GrpcService/appsettings.json @@ -0,0 +1,40 @@ +{ + "HostOptions": { + "ShutdownTimeout": "00:00:25" + }, + "MassTransitHostOptions": { + "WaitUntilStarted": true, + "StartTimeout": "00:00:30", + "StopTimeout": "00:00:15" + }, + "EventBusOptions": { + "ConnectionName": "Warehouse", + "retryLimit": 3, + "initialInterval": "00:00:05", + "intervalIncrement": "00:00:10" + }, + "Kestrel": { + "EndpointDefaults": { + "Protocols": "Http2" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "MassTransit": "Information", + "Microsoft": "Information" + } + }, + "WriteTo": [ + { + "Name": "Console" + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId" + ] + } +} diff --git a/src/Services/Warehousing/Query/Infrastructure.EventBus/Abstractions/Consumer.cs b/src/Services/Warehousing/Query/Infrastructure.EventBus/Abstractions/Consumer.cs new file mode 100644 index 000000000..084685cda --- /dev/null +++ b/src/Services/Warehousing/Query/Infrastructure.EventBus/Abstractions/Consumer.cs @@ -0,0 +1,12 @@ +using Application.Abstractions; +using Contracts.Abstractions.Messages; +using MassTransit; + +namespace Infrastructure.EventBus.Abstractions; + +public abstract class Consumer(IInteractor interactor) : IConsumer + where TMessage : class, IEvent +{ + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Infrastructure.EventBus/Consumers/Events/ProjectInventoryGridItemWhenInventoryChangedConsumer.cs b/src/Services/Warehousing/Query/Infrastructure.EventBus/Consumers/Events/ProjectInventoryGridItemWhenInventoryChangedConsumer.cs new file mode 100644 index 000000000..ba5f25fef --- /dev/null +++ b/src/Services/Warehousing/Query/Infrastructure.EventBus/Consumers/Events/ProjectInventoryGridItemWhenInventoryChangedConsumer.cs @@ -0,0 +1,12 @@ +using Application.UseCases.Events; +using Contracts.Boundaries.Warehouse; +using MassTransit; + +namespace Infrastructure.EventBus.Consumers.Events; + +public class ProjectInventoryGridItemWhenInventoryChangedConsumer(IProjectInventoryGridItemWhenInventoryChangedInteractor interactor) + : IConsumer +{ + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Infrastructure.EventBus/Consumers/Events/ProjectInventoryItemListItemWhenInventoryItemChangedConsumer.cs b/src/Services/Warehousing/Query/Infrastructure.EventBus/Consumers/Events/ProjectInventoryItemListItemWhenInventoryItemChangedConsumer.cs new file mode 100644 index 000000000..8057844af --- /dev/null +++ b/src/Services/Warehousing/Query/Infrastructure.EventBus/Consumers/Events/ProjectInventoryItemListItemWhenInventoryItemChangedConsumer.cs @@ -0,0 +1,25 @@ +using Application.UseCases.Events; +using Contracts.Boundaries.Warehouse; +using MassTransit; + +namespace Infrastructure.EventBus.Consumers.Events; + +public class ProjectInventoryItemListItemWhenInventoryItemChangedConsumer(IProjectInventoryItemListItemWhenInventoryItemChangedInteractor interactor) + : + IConsumer, + IConsumer, + IConsumer, + IConsumer +{ + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); + + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); + + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); + + public Task Consume(ConsumeContext context) + => interactor.InteractAsync(context.Message, context.CancellationToken); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Infrastructure.EventBus/DependencyInjection/Extensions/NameFormatterExtensions.cs b/src/Services/Warehousing/Query/Infrastructure.EventBus/DependencyInjection/Extensions/NameFormatterExtensions.cs new file mode 100644 index 000000000..fd45af74e --- /dev/null +++ b/src/Services/Warehousing/Query/Infrastructure.EventBus/DependencyInjection/Extensions/NameFormatterExtensions.cs @@ -0,0 +1,16 @@ +using System.Reflection; +using MassTransit; + +namespace Infrastructure.EventBus.DependencyInjection.Extensions; + +internal static class NameFormatterExtensions +{ + public static string ToKebabCaseString(this MemberInfo member) + => KebabCaseEndpointNameFormatter.Instance.SanitizeName(member.Name); +} + +internal class KebabCaseEntityNameFormatter : IEntityNameFormatter +{ + public string FormatEntityName() + => typeof(T).ToKebabCaseString(); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Infrastructure.EventBus/DependencyInjection/Extensions/RabbitMqBusFactoryConfiguratorExtensions.cs b/src/Services/Warehousing/Query/Infrastructure.EventBus/DependencyInjection/Extensions/RabbitMqBusFactoryConfiguratorExtensions.cs new file mode 100644 index 000000000..1444243ff --- /dev/null +++ b/src/Services/Warehousing/Query/Infrastructure.EventBus/DependencyInjection/Extensions/RabbitMqBusFactoryConfiguratorExtensions.cs @@ -0,0 +1,31 @@ +using Contracts.Abstractions.Messages; +using Contracts.Boundaries.Warehouse; +using Infrastructure.EventBus.Consumers.Events; +using MassTransit; + +namespace Infrastructure.EventBus.DependencyInjection.Extensions; + +internal static class RabbitMqBusFactoryConfiguratorExtensions +{ + public static void ConfigureEventReceiveEndpoints(this IRabbitMqBusFactoryConfigurator cfg, IRegistrationContext context) + { + cfg.ConfigureEventReceiveEndpoint(context); + + cfg.ConfigureEventReceiveEndpoint(context); + cfg.ConfigureEventReceiveEndpoint(context); + cfg.ConfigureEventReceiveEndpoint(context); + cfg.ConfigureEventReceiveEndpoint(context); + } + + private static void ConfigureEventReceiveEndpoint(this IRabbitMqBusFactoryConfigurator bus, IRegistrationContext context) + where TConsumer : class, IConsumer + where TEvent : class, IEvent + => bus.ReceiveEndpoint( + queueName: $"warehousing.query.{typeof(TConsumer).ToKebabCaseString()}.{typeof(TEvent).ToKebabCaseString()}", + configureEndpoint: endpoint => + { + endpoint.ConfigureConsumeTopology = false; + endpoint.Bind(); + endpoint.ConfigureConsumer(context); + }); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Infrastructure.EventBus/DependencyInjection/Extensions/ServiceCollectionExtensions.cs b/src/Services/Warehousing/Query/Infrastructure.EventBus/DependencyInjection/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..a41918a0e --- /dev/null +++ b/src/Services/Warehousing/Query/Infrastructure.EventBus/DependencyInjection/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,81 @@ +using System.Reflection; +using Contracts.Abstractions.Messages; +using Contracts.JsonConverters; +using FluentValidation; +using Infrastructure.EventBus.DependencyInjection.Options; +using Infrastructure.EventBus.PipeFilters; +using Infrastructure.EventBus.PipeObservers; +using MassTransit; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; + +namespace Infrastructure.EventBus.DependencyInjection.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddEventBus(this IServiceCollection services) + => services.AddMassTransit(cfg => + { + cfg.SetKebabCaseEndpointNameFormatter(); + cfg.AddConsumers(Assembly.GetExecutingAssembly()); + + cfg.UsingRabbitMq((context, bus) => + { + var options = context.GetRequiredService>().CurrentValue; + + bus.Host( + hostAddress: options.ConnectionString, + connectionName: $"{options.ConnectionName}.{AppDomain.CurrentDomain.FriendlyName}"); + + bus.UseMessageRetry(retry + => retry.Incremental( + retryLimit: options.RetryLimit, + initialInterval: options.InitialInterval, + intervalIncrement: options.IntervalIncrement)); + + bus.UseNewtonsoftJsonSerializer(); + + bus.ConfigureNewtonsoftJsonSerializer(settings => + { + settings.Converters.Add(new TypeNameHandlingConverter(TypeNameHandling.Objects)); + settings.Converters.Add(new DateOnlyJsonConverter()); + settings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + return settings; + }); + + bus.ConfigureNewtonsoftJsonDeserializer(settings => + { + settings.Converters.Add(new TypeNameHandlingConverter(TypeNameHandling.Objects)); + settings.Converters.Add(new DateOnlyJsonConverter()); + settings.Converters.Add(new ExpirationDateOnlyJsonConverter()); + return settings; + }); + + bus.MessageTopology.SetEntityNameFormatter(new KebabCaseEntityNameFormatter()); + bus.UseConsumeFilter(typeof(ContractValidatorFilter<>), context); + bus.ConnectReceiveObserver(new LoggingReceiveObserver()); + bus.ConnectConsumeObserver(new LoggingConsumeObserver()); + bus.ConfigureEventReceiveEndpoints(context); + bus.ConfigureEndpoints(context); + }); + }); + + public static IServiceCollection AddMessageValidators(this IServiceCollection services) + => services.AddValidatorsFromAssemblyContaining(typeof(IMessage)); + + public static OptionsBuilder ConfigureEventBusOptions(this IServiceCollection services, IConfigurationSection section) + => services + .AddOptions() + .Bind(section) + .ValidateDataAnnotations() + .ValidateOnStart(); + + public static OptionsBuilder ConfigureMassTransitHostOptions(this IServiceCollection services, IConfigurationSection section) + => services + .AddOptions() + .Bind(section) + .ValidateDataAnnotations() + .ValidateOnStart(); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Infrastructure.EventBus/DependencyInjection/Options/EventBusOptions.cs b/src/Services/Warehousing/Query/Infrastructure.EventBus/DependencyInjection/Options/EventBusOptions.cs new file mode 100644 index 000000000..783e591c9 --- /dev/null +++ b/src/Services/Warehousing/Query/Infrastructure.EventBus/DependencyInjection/Options/EventBusOptions.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Infrastructure.EventBus.DependencyInjection.Options; + +public record EventBusOptions +{ + [Required] public required string ConnectionName { get; init; } + [Required] public required Uri ConnectionString { get; init; } + [Required, Range(1, 10)] public int RetryLimit { get; init; } + [Required, Timestamp] public TimeSpan InitialInterval { get; init; } + [Required, Timestamp] public TimeSpan IntervalIncrement { get; init; } + [Required, MinLength(5)] public required string SchedulerQueueName { get; init; } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Infrastructure.EventBus/Infrastructure.EventBus.csproj b/src/Services/Warehousing/Query/Infrastructure.EventBus/Infrastructure.EventBus.csproj new file mode 100644 index 000000000..e941a792b --- /dev/null +++ b/src/Services/Warehousing/Query/Infrastructure.EventBus/Infrastructure.EventBus.csproj @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Infrastructure.EventBus/PipeFilters/ContractValidatorFilter.cs b/src/Services/Warehousing/Query/Infrastructure.EventBus/PipeFilters/ContractValidatorFilter.cs new file mode 100644 index 000000000..d2eb0dce4 --- /dev/null +++ b/src/Services/Warehousing/Query/Infrastructure.EventBus/PipeFilters/ContractValidatorFilter.cs @@ -0,0 +1,36 @@ +using Contracts.Abstractions.Validations; +using FluentValidation; +using MassTransit; +using Serilog; + +namespace Infrastructure.EventBus.PipeFilters; + +public class ContractValidatorFilter(IValidator? validator = default) : IFilter> + where T : class +{ + public async Task Send(ConsumeContext context, IPipe> next) + { + if (validator is null) + { + await next.Send(context); + return; + } + + var validationResult = await validator.ValidateAsync(context.Message, context.CancellationToken); + + if (validationResult.IsValid) + { + await next.Send(context); + return; + } + + Log.Error("Contract validation errors: {Errors}", validationResult.Errors); + + await context.Send( + destinationAddress: new($"queue:warehouse.{KebabCaseEndpointNameFormatter.Instance.SanitizeName(typeof(T).Name)}.contract-errors"), + message: new ContractValidationResult(context.Message, validationResult.Errors.Select(failure => failure.ErrorMessage))); + } + + public void Probe(ProbeContext context) + => context.CreateFilterScope("Contract validation"); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Infrastructure.EventBus/PipeObservers/LoggingConsumeObserver.cs b/src/Services/Warehousing/Query/Infrastructure.EventBus/PipeObservers/LoggingConsumeObserver.cs new file mode 100644 index 000000000..8c8c0818c --- /dev/null +++ b/src/Services/Warehousing/Query/Infrastructure.EventBus/PipeObservers/LoggingConsumeObserver.cs @@ -0,0 +1,24 @@ +using MassTransit; +using Serilog; + +namespace Infrastructure.EventBus.PipeObservers; + +public class LoggingConsumeObserver : IConsumeObserver +{ + public async Task PreConsume(ConsumeContext context) + where T : class + { + await Task.Yield(); + + Log.Information("Consuming {Message} message from {Namespace}, CorrelationId: {CorrelationId}", + typeof(T).Name, typeof(T).Namespace, context.CorrelationId); + } + + public Task PostConsume(ConsumeContext context) + where T : class + => Task.CompletedTask; + + public Task ConsumeFault(ConsumeContext context, Exception exception) + where T : class + => Task.CompletedTask; +} \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Infrastructure.EventBus/PipeObservers/LoggingReceiveObserver.cs b/src/Services/Warehousing/Query/Infrastructure.EventBus/PipeObservers/LoggingReceiveObserver.cs new file mode 100644 index 000000000..7a6a8c42f --- /dev/null +++ b/src/Services/Warehousing/Query/Infrastructure.EventBus/PipeObservers/LoggingReceiveObserver.cs @@ -0,0 +1,51 @@ +using MassTransit; +using Serilog; + +namespace Infrastructure.EventBus.PipeObservers; + +public class LoggingReceiveObserver : IReceiveObserver +{ + private const string ExchangeKey = "RabbitMQ-ExchangeName"; + + public async Task PreReceive(ReceiveContext context) + { + await Task.Yield(); + + Log.Debug("Receiving message from exchange {Exchange}, Redelivered: {Redelivered}, CorrelationId: {CorrelationId}", + context.TransportHeaders.Get(ExchangeKey), context.Redelivered, context.GetCorrelationId()); + } + + public async Task PostReceive(ReceiveContext context) + { + await Task.Yield(); + + Log.Debug("Message was received from exchange {Exchange}, Redelivered: {Redelivered}, CorrelationId: {CorrelationId}", + context.TransportHeaders.Get(ExchangeKey), context.Redelivered, context.GetCorrelationId()); + } + + public async Task PostConsume(ConsumeContext context, TimeSpan duration, string consumerType) + where T : class + { + await Task.Yield(); + + Log.Debug("{Message} message from {Namespace} was consumed by {ConsumerType}, Duration: {Duration}s, CorrelationId: {CorrelationId}", + typeof(T).Name, typeof(T).Namespace, consumerType, duration.TotalSeconds, context.CorrelationId); + } + + public async Task ConsumeFault(ConsumeContext context, TimeSpan duration, string consumerType, Exception exception) + where T : class + { + await Task.Yield(); + + Log.Error("Fault on consuming message {Message} from {Namespace} by {ConsumerType}, Duration: {Duration}s, Error: {Error}, CorrelationId: {CorrelationId}", + typeof(T).Name, typeof(T).Namespace, consumerType, duration.TotalSeconds, exception.Message, context.CorrelationId); + } + + public async Task ReceiveFault(ReceiveContext context, Exception exception) + { + await Task.Yield(); + + Log.Error("Fault on receiving message from exchange {Exchange}, Redelivered: {Redelivered}, Error: {Error}, CorrelationId: {CorrelationId}", + context.TransportHeaders.Get(ExchangeKey), context.Redelivered, exception.Message, context.GetCorrelationId() ?? new()); + } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Infrastructure.Projections/Abstractions/IMongoDbContext.cs b/src/Services/Warehousing/Query/Infrastructure.Projections/Abstractions/IMongoDbContext.cs new file mode 100644 index 000000000..9f626b6de --- /dev/null +++ b/src/Services/Warehousing/Query/Infrastructure.Projections/Abstractions/IMongoDbContext.cs @@ -0,0 +1,8 @@ +using MongoDB.Driver; + +namespace Infrastructure.Projections.Abstractions; + +public interface IMongoDbContext +{ + IMongoCollection GetCollection(); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Infrastructure.Projections/Abstractions/MongoDbContext.cs b/src/Services/Warehousing/Query/Infrastructure.Projections/Abstractions/MongoDbContext.cs new file mode 100644 index 000000000..60397e62b --- /dev/null +++ b/src/Services/Warehousing/Query/Infrastructure.Projections/Abstractions/MongoDbContext.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Configuration; +using MongoDB.Driver; + +namespace Infrastructure.Projections.Abstractions; + +public abstract class MongoDbContext : IMongoDbContext +{ + private readonly IMongoDatabase _database; + + protected MongoDbContext(IConfiguration configuration) + { + MongoUrl mongoUrl = new(configuration.GetConnectionString("Projections")); + _database = new MongoClient(mongoUrl).GetDatabase(mongoUrl.DatabaseName); + } + + public IMongoCollection GetCollection() + => _database.GetCollection(typeof(T).Name); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Infrastructure.Projections/DependencyInjection/ServiceCollectionExtensions.cs b/src/Services/Warehousing/Query/Infrastructure.Projections/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..c20aeb3e0 --- /dev/null +++ b/src/Services/Warehousing/Query/Infrastructure.Projections/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using Application.Abstractions; +using Infrastructure.Projections.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; + +namespace Infrastructure.Projections.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static void AddProjections(this IServiceCollection services) + { + services.AddScoped(typeof(IProjectionGateway<>), typeof(ProjectionGateway<>)); + services.AddScoped(); + BsonSerializer.RegisterSerializer(new GuidSerializer(BsonType.String)); + } +} \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Infrastructure.Projections/Infrastructure.Projections.csproj b/src/Services/Warehousing/Query/Infrastructure.Projections/Infrastructure.Projections.csproj new file mode 100644 index 000000000..1e2b801e3 --- /dev/null +++ b/src/Services/Warehousing/Query/Infrastructure.Projections/Infrastructure.Projections.csproj @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Infrastructure.Projections/Pagination/PagedResult.cs b/src/Services/Warehousing/Query/Infrastructure.Projections/Pagination/PagedResult.cs new file mode 100644 index 000000000..a231de95e --- /dev/null +++ b/src/Services/Warehousing/Query/Infrastructure.Projections/Pagination/PagedResult.cs @@ -0,0 +1,30 @@ +using Contracts.Abstractions; +using Contracts.Abstractions.Paging; +using MongoDB.Driver; +using MongoDB.Driver.Linq; + +namespace Infrastructure.Projections.Pagination; + +public record PagedResult(IReadOnlyCollection Projections, Paging Paging) : IPagedResult + where TProjection : IProjection +{ + public IReadOnlyCollection Items + => Page.HasNext ? Projections.Take(Paging.Limit).ToList() : Projections; + + public Page Page => new() + { + Current = Paging.Offset + 1, + Size = Paging.Limit, + HasNext = Paging.Limit < Projections.Count, + HasPrevious = Paging.Offset > 0 + }; + + public static async ValueTask> CreateAsync(Paging paging, IQueryable source, CancellationToken cancellationToken) + { + var projections = await ApplyPagination(paging, source).ToListAsync(cancellationToken); + return new PagedResult(projections, paging); + } + + private static IMongoQueryable ApplyPagination(Paging paging, IQueryable source) + => (IMongoQueryable)source.Skip(paging.Limit * paging.Offset).Take(paging.Limit + 1); +} \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Infrastructure.Projections/ProjectionDbContext.cs b/src/Services/Warehousing/Query/Infrastructure.Projections/ProjectionDbContext.cs new file mode 100644 index 000000000..f935de000 --- /dev/null +++ b/src/Services/Warehousing/Query/Infrastructure.Projections/ProjectionDbContext.cs @@ -0,0 +1,6 @@ +using Infrastructure.Projections.Abstractions; +using Microsoft.Extensions.Configuration; + +namespace Infrastructure.Projections; + +public class ProjectionDbContext(IConfiguration configuration) : MongoDbContext(configuration); \ No newline at end of file diff --git a/src/Services/Warehousing/Query/Infrastructure.Projections/ProjectionGateway.cs b/src/Services/Warehousing/Query/Infrastructure.Projections/ProjectionGateway.cs new file mode 100644 index 000000000..d05cf6db6 --- /dev/null +++ b/src/Services/Warehousing/Query/Infrastructure.Projections/ProjectionGateway.cs @@ -0,0 +1,61 @@ +using System.Linq.Expressions; +using Application.Abstractions; +using Contracts.Abstractions; +using Contracts.Abstractions.Paging; +using Infrastructure.Projections.Abstractions; +using Infrastructure.Projections.Pagination; +using MongoDB.Driver; +using MongoDB.Driver.Linq; +using Serilog; + +namespace Infrastructure.Projections; + +public class ProjectionGateway(IMongoDbContext context) : IProjectionGateway + where TProjection : IProjection +{ + private readonly IMongoCollection _collection = context.GetCollection(); + + public Task GetAsync(TId id, CancellationToken cancellationToken) where TId : struct + => FindAsync(projection => projection.Id.Equals(id), cancellationToken); + + public Task FindAsync(Expression> predicate, CancellationToken cancellationToken) + => _collection.AsQueryable().Where(predicate).FirstOrDefaultAsync(cancellationToken)!; + + public ValueTask> ListAsync(Paging paging, Expression> predicate, CancellationToken cancellationToken) + => PagedResult.CreateAsync(paging, _collection.AsQueryable().Where(predicate), cancellationToken); + + public ValueTask> ListAsync(Paging paging, CancellationToken cancellationToken) + => PagedResult.CreateAsync(paging, _collection.AsQueryable(), cancellationToken); + + public Task DeleteAsync(Expression> filter, CancellationToken cancellationToken) + => _collection.DeleteManyAsync(filter, cancellationToken); + + public Task DeleteAsync(TId id, CancellationToken cancellationToken) where TId : struct + => _collection.DeleteOneAsync(projection => projection.Id.Equals(id), cancellationToken); + + public Task UpdateFieldAsync(TId id, ulong version, Expression> field, TField value, CancellationToken cancellationToken) where TId : struct + => _collection.UpdateOneAsync( + filter: projection => projection.Id.Equals(id) && projection.Version < version, + update: new ObjectUpdateDefinition(new()).Set(field, value), + cancellationToken: cancellationToken); + + public ValueTask ReplaceInsertAsync(TProjection replacement, CancellationToken cancellationToken) + => OnReplaceAsync(replacement, projection => projection.Id == replacement.Id && projection.Version < replacement.Version, cancellationToken); + + public ValueTask RebuildInsertAsync(TProjection replacement, CancellationToken cancellationToken) + => OnReplaceAsync(replacement, projection => projection.Id == replacement.Id && projection.Version <= replacement.Version, cancellationToken); + + private async ValueTask OnReplaceAsync(TProjection replacement, Expression> filter, CancellationToken cancellationToken) + { + try + { + await _collection.ReplaceOneAsync(filter, replacement, new ReplaceOptions { IsUpsert = true }, cancellationToken); + } + catch (MongoWriteException e) when (e.WriteError.Category is ServerErrorCategory.DuplicateKey) + { + Log.Warning( + "By passing Duplicate Key when inserting {ProjectionType} with Id {Id}", + typeof(TProjection).Name, replacement.Id); + } + } +} \ No newline at end of file diff --git a/src/Web/WebAPI/DependencyInjection/Extensions/WebApplicationExtensions.cs b/src/Web/WebAPI/DependencyInjection/Extensions/WebApplicationExtensions.cs new file mode 100644 index 000000000..166be5bd8 --- /dev/null +++ b/src/Web/WebAPI/DependencyInjection/Extensions/WebApplicationExtensions.cs @@ -0,0 +1,28 @@ +using Swashbuckle.AspNetCore.SwaggerUI; + +namespace WebAPI.DependencyInjection.Extensions; + +public static class WebApplicationExtensions +{ + public static void ConfigureSwagger(this WebApplication webApp) + { + webApp.UseSwagger(); + + webApp.UseSwaggerUI(options => + { + foreach (var version in webApp.DescribeApiVersions().Select(version => version.GroupName)) + options.SwaggerEndpoint($"/swagger/{version}/swagger.json", version); + + options.DisplayRequestDuration(); + options.EnableDeepLinking(); + options.EnableFilter(); + options.EnableValidator(); + options.EnableTryItOutByDefault(); + options.DocExpansion(DocExpansion.None); + }); + + webApp + .MapGet("/", () => Results.Redirect("/swagger/index.html")) + .WithTags(string.Empty); + } +} \ No newline at end of file diff --git a/src/Web/WebAPI/DependencyInjection/Options/GrpcClientOptions.cs b/src/Web/WebAPI/DependencyInjection/Options/GrpcClientOptions.cs new file mode 100644 index 000000000..1eeea2fd8 --- /dev/null +++ b/src/Web/WebAPI/DependencyInjection/Options/GrpcClientOptions.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace WebAPI.DependencyInjection.Options; + +public abstract record GrpcClientOptions +{ + [Required, Url] + public required string BaseAddress { get; init; } +} + +public record AccountGrpcClientOptions : GrpcClientOptions; +public record CatalogingCommandGrpcClientOptions : GrpcClientOptions; +public record CatalogingQueryGrpcClientOptions : GrpcClientOptions; +public record CommunicationGrpcClientOptions : GrpcClientOptions; +public record IdentityGrpcClientOptions : GrpcClientOptions; +public record PaymentGrpcClientOptions : GrpcClientOptions; +public record ShoppingCartGrpcClientOptions : GrpcClientOptions; +public record ShoppingCartCommandGrpcClientOptions : GrpcClientOptions; +public record WarehouseGrpcClientOptions : GrpcClientOptions; \ No newline at end of file