diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..5ab7272 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,208 @@ +# ProductReview Microservice — Roadmap + +Task.md'e göre yapılacaklar sıralaması. Her aşama tamamlandığında commit atılacak. +Namespace: `MuhammedTask.Services.ProductReviewService.*` + +--- + +## Aşama 0 — Hazırlık +- [x] Task.md okundu ve analiz edildi +- [x] ProductService ve CategoryService pattern'leri incelendi +- [x] ROADMAP.md ve RULES.md oluşturuldu + +**Commit:** `docs: add ROADMAP.md and RULES.md` + +--- + +## Aşama 1 — Proje İskeleti +- [ ] `MuhammedTask.Services.ProductReviewService.Domain` csproj +- [ ] `MuhammedTask.Services.ProductReviewService.Application` csproj +- [ ] `MuhammedTask.Services.ProductReviewService.Persistence` csproj +- [ ] `MuhammedTask.Services.ProductReviewService.WebApi` csproj +- [ ] Solution'a ekleme (`MuhammedTask.sln`) +- [ ] `ServiceKeys.cs`'e `ProductReviewService` ve `pg-productreviewservice` ekleme + +**Commit:** `feat(productreview): add project skeleton and solution references` + +--- + +## Aşama 2 — Domain Layer +Dosya yolu: `source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/` + +### 2.1 Value Objects (Fields/) +- [ ] `ProductReviewId` — readonly record struct, `New()`, `From()`, `Empty` +- [ ] `ProductId` — readonly record struct (dış aggregate referansı) +- [ ] `UserId` — readonly record struct +- [ ] `ReviewRating` — 1–5 enforce, `Create()` → `Result` +- [ ] `ReviewComment` — opsiyonel, max 1000 karakter, `Create()` → `Result` + +### 2.2 Parameters +- [ ] `ProductReviewCreateParameters` — sealed record +- [ ] `ProductReviewUpdateParameters` — sealed record + +### 2.3 Errors +- [ ] `ProductReviewErrors` static sınıfı — NotFound, AlreadyReviewed, nested Rating ve Comment hataları + +### 2.4 Domain Events (Events/) +- [ ] `ProductReviewCreatedDomainEvent` +- [ ] `ProductReviewUpdatedDomainEvent` +- [ ] `ProductReviewDeletedDomainEvent` + +### 2.5 Repository Interfaces (Repositories/) +- [ ] `IProductReviewCommandRepository` +- [ ] `IProductReviewQueryRepository` + +### 2.6 ReadModel +- [ ] `ProductReviewReadModel` — ISoftDeletableBase, primitive tipler + +### 2.7 Aggregate Root +- [ ] `ProductReview` — SoftDeletableEntityBase + - `Create()` → `Result` (validation dahil) + - `Update()` → `Result` + - `Delete()` → domain event raise + - Business rule: aynı user aynı product'a 2. review yapamaz + +### 2.8 Assembly Reference +- [ ] `DomainAssemblyReference.cs` + +**Commit:** `feat(productreview): implement domain layer — entities, value objects, events, errors` + +--- + +## Aşama 3 — Application Layer +Dosya yolu: `source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/` + +### 3.1 Commands +- [ ] `CreateProductReviewCommand` + `Handler` + `Validator` +- [ ] `UpdateProductReviewCommand` + `Handler` + `Validator` +- [ ] `DeleteProductReviewCommand` + `Handler` + +### 3.2 Queries +- [ ] `GetProductReviewByIdQuery` + `Handler` + `Validator` +- [ ] `GetProductReviewListQuery` (ProductId filtreli, paginated) + `Handler` + `Validator` +- [ ] `GetProductAverageRatingQuery` + `Handler` + +### 3.3 View Models +- [ ] `ProductReviewViewModel` — readonly record struct +- [ ] `ProductAverageRatingViewModel` — readonly record struct + +### 3.4 Domain Event Handler +- [ ] `ProductReviewCreatedDomainEventHandler` — cache invalidate + +### 3.5 DI Registration +- [ ] `ApplicationAssemblyReference.cs` +- [ ] `ServiceRegistrations/DependencyInjection.cs` + +**Commit:** `feat(productreview): implement application layer — commands, queries, event handlers` + +--- + +## Aşama 4 — Persistence Layer +Dosya yolu: `source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/` + +### 4.1 DbContexts +- [ ] `ApplicationWriteDbContext` — WriteDbContextBase, ApplySoftDeleteQueryFilter +- [ ] `ApplicationReadDbContext` — ReadDbContextBase, NoTracking, ApplySoftDeleteQueryFilter + +### 4.2 EF Configurations +- [ ] `Write/ProductReviewConfiguration` — SoftDeletableEntityBaseMap, HasConversion, OptimisticConcurrencyVersionMap +- [ ] `Read/ProductReviewReadModelConfiguration` — HasKey + +### 4.3 Repositories +- [ ] `EfProductReviewCommandRepository` — Create, Update, Delete +- [ ] `EfProductReviewQueryRepository` — GetById (Maybe), GetList (array), GetAverageRating + +### 4.4 Integration Event +- [ ] `ProductReviewCreatedIntegrationEvent` — Payload: ProductId, NewAverageRating, ReviewCount +- [ ] MassTransit publisher (domain event handler'dan tetiklenir) + +### 4.5 DI Registration +- [ ] `PersistenceAssemblyReference.cs` +- [ ] `ServiceRegistrations/DependencyInjection.cs` +- [ ] `ServiceRegistrations/RepositoryRegistrations.cs` +- [ ] `ServiceRegistrations/ServicesRegistrations.cs` + +**Commit:** `feat(productreview): implement persistence layer — dbcontext, ef configs, repositories` + +--- + +## Aşama 5 — WebApi Layer +Dosya yolu: `source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/` + +### 5.1 Carter Endpoints +- [ ] `POST /api/v1/productreviews` — CreateEndpoint +- [ ] `GET /api/v1/productreviews/{id}` — GetEndpoint +- [ ] `GET /api/v1/products/{productId}/reviews` — ListEndpoint (paginated) +- [ ] `GET /api/v1/products/{productId}/average-rating` — AverageRatingEndpoint +- [ ] `PATCH /api/v1/productreviews/{id}` — UpdateEndpoint +- [ ] `DELETE /api/v1/productreviews/{id}` — DeleteEndpoint + +### 5.2 Altyapı +- [ ] `Tags.cs` +- [ ] `Program.cs` +- [ ] `ServiceRegistrations/DependencyInjection.cs` +- [ ] `ServiceRegistrations/OpenTelemetryDependencyInjection.cs` +- [ ] `migration.sh` — ProductService pattern'i ile aynı + +**Commit:** `feat(productreview): implement webapi layer — carter endpoints, swagger, versioning` + +--- + +## Aşama 6 — AppHost & YARP Entegrasyonu + +- [ ] `ServiceKeys.cs` güncelleme — `ProductReviewService`, `PostgresProductReviewService` +- [ ] `AppHost/Program.cs` — builder.AddProject, WaitFor pattern +- [ ] `Yarp.ProxyService/appsettings.json` — productreviews route ve cluster + +**Commit:** `feat(productreview): wire up apphost and yarp proxy routing` + +--- + +## Aşama 7 — Migration + +- [ ] `./migration.sh InitialProductReview` çalıştır +- [ ] Migration dosyalarını kontrol et + +**Commit:** `feat(productreview): add initial EF Core migration` + +--- + +## Aşama 8 — Unit Tests + +- [ ] Domain value object testleri (ReviewRating, ReviewComment) +- [ ] Domain aggregate root testleri (Create, Update, Delete) +- [ ] En az 3 test — `dotnet test` yeşil + +**Commit:** `test(productreview): add unit tests for domain layer` + +--- + +## Aşama 9 — README Güncellemesi + +- [ ] README.md'e tasarım kararları bölümü ekle + +**Commit:** `docs: update README with design decisions` + +--- + +## Bonus (Zorunlu Değil) + +- [ ] Redis caching — `GetProductAverageRatingQuery` için +- [ ] Cache invalidation domain event handler'dan +- [ ] XML doc comments — Swagger açıklamaları +- [ ] Pagination wrapper — PageNumber, PageSize, TotalCount, Items +- [ ] Integration test — WebApplicationFactory + +**Commit:** `feat(productreview): add bonus features — caching, pagination, xml docs` + +--- + +## Teslim Kontrol Listesi (PR Öncesi) + +- [ ] Fork edilmiş GitHub repo URL'i PR description'da +- [ ] `dotnet run` → Aspire dashboard hatasız başlıyor +- [ ] Tüm endpoint'ler Swagger'dan test edildi +- [ ] `./migration.sh InitialProductReview` çalışıyor +- [ ] `dotnet test` en az 3 test ile yeşil dönüyor +- [ ] README'de tasarım kararları bölümü var +- [ ] PR açıklaması: yaklaşım ve trade-off'lar diff --git a/RULES.md b/RULES.md new file mode 100644 index 0000000..d9aa07c --- /dev/null +++ b/RULES.md @@ -0,0 +1,372 @@ +# Kodlama Kuralları — ProductReview Microservice + +Bu dosya Task.md'deki gereksinimleri ve mevcut ProductService/CategoryService pattern'lerini +referans alarak hazırlanmıştır. Kod yazarken bu kurallara eksiksiz uyulacaktır. + +--- + +## 1. Genel Prensipler + +### 1.1 Pattern Taklit Zorunluluğu +- Mevcut `MuhammedTask.Services.ProductService.*` pattern'lerini **birebir** taklit et. +- Yeni mimari, yeni kütüphane, yeni kalıp **icat etme**. +- Namespace: `MuhammedTask.Services.ProductReviewService.*` + +### 1.2 Exception Yasağı +```csharp +// YASAK +throw new Exception("..."); +throw new InvalidOperationException("..."); + +// DOĞRU +return ProductReviewErrors.NotFoundError(id); +return Result.Failure(ProductReviewErrors.AlreadyReviewedError); +``` + +### 1.3 Async Kuralları +```csharp +// YASAK — deadlock riski +var result = someTask.Result; +someTask.Wait(); + +// DOĞRU +var result = await someTask; +``` + +--- + +## 2. Domain Layer Kuralları + +### 2.1 Value Objects +- **Tüm** ID'ler ve anlamlı alanlar value object olacak: `ProductReviewId`, `ProductId`, `UserId`, `ReviewRating`, `ReviewComment` +- `string userId` gibi primitive obsession **yasak** +- Her value object: `readonly record struct`, private ctor, `From()` + `Create()` factory method, `Empty` static field + +```csharp +// DOĞRU +public readonly record struct ReviewRating +{ + public const int MinValue = 1; + public const int MaxValue = 5; + private ReviewRating(int value) => Value = value; + public int Value { get; } + public static ReviewRating From(int value) => new(value); + public static Result Create(int? value) + { + if (value is null) return ProductReviewErrors.Rating.EmptyError; + if (value < MinValue || value > MaxValue) return ProductReviewErrors.Rating.OutOfRangeError(value.Value); + return new ReviewRating(value.Value); + } +} +``` + +### 2.2 Aggregate Root — Rich Domain (Anemic Domain Yasak) +- Business logic **entity'de** olacak, handler'da değil +- `ProductReview.Create()` → validation + domain event raise içerir +- `ProductReview.Update()` → validation + domain event raise içerir +- `ProductReview.Delete()` → domain event raise eder + +```csharp +// YASAK — Anemic Domain +public class ProductReview { public int Rating { get; set; } } +// Handler'da: productReview.Rating = newRating; // Bu anti-pattern + +// DOĞRU — Rich Domain +public sealed class ProductReview : SoftDeletableEntityBase +{ + public static Result Create(ProductReviewCreateParameters parameters) { /* validation */ } + public Result Update(ProductReviewUpdateParameters parameters) { /* validation + Raise(event) */ } + public void Delete() => Raise(new ProductReviewDeletedDomainEvent(Id)); +} +``` + +### 2.3 Domain Events +- Domain event'ler sadece aggregate içinde `Raise()` ile publish edilir +- Handler dışında `IPublisher` ile publish etmek **yasak** +- Interceptor (`PublishDomainEventsInterceptor`) SaveChanges sırasında otomatik publish eder + +### 2.4 Errors +```csharp +// ProductReviewErrors static sınıfı — nested class'lar ile organize edilir +public static class ProductReviewErrors +{ + public static Error NotFoundError(ProductReviewId id) => Error.NotFound(...); + public static readonly Error AlreadyReviewedError = Error.Conflict(...); + + public static class Rating + { + public static readonly Error EmptyError = Error.Validation(...); + public static Error OutOfRangeError(int value) => Error.Validation(...); + } + public static class Comment + { + public static Error TooLongError(int length) => Error.Validation(...); + } +} +``` + +### 2.5 İş Kuralları +- Aynı kullanıcı aynı `ProductId` için 2. review **yapamaz** → `AlreadyReviewedError` +- Kullanıcı yalnızca **kendi** review'ını update/delete edebilir → `UserId` match check + +--- + +## 3. Application Layer Kuralları + +### 3.1 Command Pattern +```csharp +// Command: ICommand implement eder +public sealed record CreateProductReviewCommand( + Guid ProductId, + Guid UserId, + int? Rating, + string? Comment) : ICommand +{ + public ProductReviewCreateParameters ToParameters() => + new(ProductId.From(ProductId), UserId.From(UserId), Rating, Comment); +} + +// Handler: internal sealed class, primary constructor +internal sealed class CreateProductReviewCommandHandler( + IProductReviewCommandRepository repository) : ICommandHandler +{ + public Task> Handle(...) { ... } +} +``` + +### 3.2 Query Pattern +```csharp +// Query: ICachedQuery implement eder — CacheKey, Tags, Expiration zorunlu +public sealed record GetProductReviewByIdQuery(Guid ProductReviewId) : ICachedQuery +{ + public bool BypassCache => false; + public bool CacheFailures => true; + public string CacheKey => $"productreview:{ProductReviewId}"; + public TimeSpan Expiration => TimeSpan.FromMinutes(10); + public string[] Tags => []; +} + +// Handler: ICachedQueryHandler implement eder +internal sealed class GetProductReviewByIdQueryHandler( + IProductReviewQueryRepository repository) : ICachedQueryHandler +{ + public async Task> Handle(...) { ... } +} +``` + +### 3.3 Validator +```csharp +// internal sealed class, AbstractValidator +internal sealed class CreateProductReviewCommandValidator : AbstractValidator +{ + public CreateProductReviewCommandValidator() + { + RuleFor(x => x.ProductId).NotEmpty(); + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.Rating).NotNull().InclusiveBetween(ReviewRating.MinValue, ReviewRating.MaxValue); + RuleFor(x => x.Comment).MaximumLength(ReviewComment.MaxLength); + } +} +``` + +### 3.4 ViewModel +```csharp +// readonly record struct, private ctor, Create factory +public readonly record struct ProductReviewViewModel +{ + private ProductReviewViewModel(ProductReviewReadModel r) { /* field assignment */ } + public static ProductReviewViewModel Create(ProductReviewReadModel r) => new(r); + public static ProductReviewViewModel[] Create(ProductReviewReadModel[] rs) => [.. rs.Select(Create)]; +} +``` + +### 3.5 Domain Event Handler +```csharp +// INotificationHandler, cache invalidate +internal sealed class ProductReviewCreatedDomainEventHandler( + ILogger<...> logger, + ICacheService cacheService) : INotificationHandler +{ + private const string Tag = "productreviews"; + public async Task Handle(...) => await cacheService.InvalidateTagAsync(Tag); +} +``` + +--- + +## 4. Persistence Layer Kuralları + +### 4.1 DbContext +- Write: `WriteDbContextBase`, `ApplySoftDeleteQueryFilter()` +- Read: `ReadDbContextBase`, `ApplySoftDeleteQueryFilter()`, `QueryTrackingBehavior.NoTracking` + +### 4.2 EF Configuration +```csharp +// Write configuration +builder.SoftDeletableEntityBaseMap(); +builder.Property(p => p.Id).HasConversion(id => id.Value, id => ProductReviewId.From(id)); +builder.Property(p => p.Rating).HasConversion(r => r.Value, r => ReviewRating.From(r)); +builder.Property(p => p.Comment).HasConversion(c => c.Value, c => ReviewComment.From(c)).HasMaxLength(ReviewComment.MaxLength); +builder.OptimisticConcurrencyVersionMap(); + +// Read configuration +builder.HasKey(x => x.Id); +``` + +### 4.3 Repository Implementasyonu +```csharp +// YASAK — endpoint'ten direkt DbContext kullanmak +// DOĞRU — Repository pattern + +// Command: context inject eder, ProductReview.Create() çağırır +internal sealed class EfProductReviewCommandRepository(ApplicationWriteDbContext context) + : IProductReviewCommandRepository { ... } + +// Query: context inject eder, read model döner +internal sealed class EfProductReviewQueryRepository(ApplicationReadDbContext context) + : IProductReviewQueryRepository { ... } +``` + +### 4.4 Soft Delete +- `IsDeleted = true` olan kayıtlar query filter ile otomatik gizlenir +- `ApplySoftDeleteQueryFilter()` her iki context'e de uygulanır + +### 4.5 Audit Fields +- `CreatedAt` ve `UpdatedAt` EF Core interceptor (`AuditableInterceptor`) ile otomatik doldurulur +- Hardcode etme, interceptor'a bırak + +### 4.6 Migration Script +```bash +# Dosya: WebApi klasöründe migration.sh +# Çalıştırma: ./migration.sh InitialProductReview +dotnet ef migrations add $MIGRATION_NAME \ + --project ../MuhammedTask.Services.ProductReviewService.Persistence \ + --context ApplicationWriteDbContext \ + --output-dir EntityFrameworkCore/Migrations/ApplicationWrite +``` + +### 4.7 Integration Event +```csharp +// MassTransit IPublisher ile domain event handler'dan yayınlanır +// Payload: ProductId, NewAverageRating, ReviewCount +namespace MuhammedTask.IntegrationEvents.ProductReviews; +public sealed record ProductReviewCreatedIntegrationEvent( + Guid ProductId, + double NewAverageRating, + int ReviewCount); +``` + +--- + +## 5. WebApi Layer Kuralları + +### 5.1 Carter Module Pattern +```csharp +public sealed class ProductReviewCreateEndpoint : CarterModule +{ + public sealed record ProductReviewCreateRequest(Guid ProductId, Guid UserId, int? Rating, string? Comment) + { + public CreateProductReviewCommand ToCommand() => new(ProductId, UserId, Rating, Comment); + } + + public override void AddRoutes(IEndpointRouteBuilder app) + { + RouteGroupBuilder routeGroup = app + .CreateVersionedGroup(Tags.ProductReviews) + .RequireAuthorization(); + + routeGroup.MapPost(string.Empty, CreateProductReview) + .Produces(HttpCodes.Created) + .ProducesProblem() + .WithDescription("Create product review") + .WithName(nameof(CreateProductReview)); + } + + private static Task CreateProductReview( + [FromBody] ProductReviewCreateRequest request, + ISender sender, + CancellationToken cancellationToken = default) => + sender + .Send(request.ToCommand(), cancellationToken) + .Match( + id => TypedResults.Created($"/{Tags.ProductReviews}/{id.Value}", id.Value), + errors => errors.ToProblemResult(), + cancellationToken); +} +``` + +### 5.2 Endpoint Route'ları +``` +POST /api/v1/productreviews +GET /api/v1/productreviews/{id} +GET /api/v1/products/{productId}/reviews +GET /api/v1/products/{productId}/average-rating +PATCH /api/v1/productreviews/{id} +DELETE /api/v1/productreviews/{id} +``` + +### 5.3 Result → ProblemDetails +- Tüm endpoint'ler `errors.ToProblemResult()` kullanır +- Endpoint içinde direkt exception fırlatmak **yasak** + +### 5.4 Hardcoded Değer Yasağı +```csharp +// YASAK +.AddKeycloakJwtBearer(keycloakServiceId: "keycloak", realm: "productreviews") +// DOĞRU — ServiceKeys sabitinden oku +.AddKeycloakJwtBearer(keycloakServiceId: ServiceKeys.Keycloak, realm: "productreviews") +``` + +--- + +## 6. AppHost Kuralları + +```csharp +// WaitFor pattern — postgres ve rabbitmq hazır olmadan servis başlamasın +IResourceBuilder productReviewService = builder + .AddProject(productReviewServiceKey) + .WithHttpHealthCheck("/health") + .WithReference(seq) + .WithReference(cache) + .WithReference(rabbitmq) + .WithReference(productReviewServicePostgresDatabase) + .WaitFor(productReviewServicePostgresDatabase) // zorunlu + .WaitFor(rabbitmq) // zorunlu + .WithReference(keycloak) + .WaitFor(keycloak); +``` + +--- + +## 7. Commit Kuralları + +- **Tek devasa commit yasak** — her aşama ayrı commit +- Commit mesajı formatı: `feat(productreview): ...` / `test(productreview): ...` / `docs: ...` +- Her commit çalışır durumda olacak (build kırmayacak) +- Migration ayrı commit olacak + +### 7.1 Commit Öncesi Zorunlu Kontrol Listesi + +Her commit atmadan önce aşağıdaki 3 kontrol yapılacak: + +1. **Task.md kontrolü** — İstenen özellik tam ve eksiksiz mi? +2. **RULES.md kontrolü** — Hiçbir anti-pattern yok mu? (exception, primitive obsession, anemic domain, .Result/.Wait(), direkt DbContext, hardcode, domain event yanlış publish, migration atlanmış) +3. **ROADMAP.md kontrolü** — İlgili aşamanın tüm maddeleri tamamlandı mı? + +Kontrol sonuçları `yapılanlar/commit-log.md` dosyasına yazılır (bu klasör .gitignore'da, repo'ya gitmez). + +--- + +## 8. Anti-Pattern Özeti (Task.md'den) + +| Anti-Pattern | Sonuç | +|---|---| +| `throw new Exception(...)` | Puan kırma | +| `string userId` primitive obsession | Puan kırma | +| Handler'da business logic | Puan kırma | +| `.Result` / `.Wait()` async'te | Puan kırma | +| Endpoint'ten direkt DbContext | Puan kırma | +| Hardcoded değerler | Puan kırma | +| Domain event'i handler dışında publish | Puan kırma | +| Migration commit'i atlamak | Puan kırma | +| Tek büyük commit | Puan kırma | diff --git a/Task.md b/Task.md new file mode 100644 index 0000000..0e83789 --- /dev/null +++ b/Task.md @@ -0,0 +1,131 @@ +1. Genel Bilgi +Bu görev, Medarvia ekibine katılmak için başvuran Yazılımcı Yardımcısı (Part Time) adaylarının kod +yazma stilini, mimari yetkinliğini ve karar verme yaklaşımını değerlendirmek üzere hazırlanmıştır. +Aşağıdaki gereksinimleri yerine getirerek bize çalışma stilinizi gösterin; bizim de doğru kararı +verebilmemiz için yeterli bağlamı sunun. + +• Tahmini efor: 4–6 saat +• Teslim süresi: Görevi aldığınız andan itibaren 7 gün +• Teslim biçimi: Public GitHub fork üzerinde PR açın; PR linkini iletişim adresine gönderin +• Hedef: Clean Architecture + CQRS + Domain-Driven Design pattern'lerinde nasıl kod yazdığınızı +ve mimari kararlar verirken neyi önemsediğinizi görmek +2. Başlangıç Adımları +Aşağıdaki repo, görevin temel iskeletini oluşturuyor. Aynı pattern'leri taklit ederek yeni servisi +ekleyeceksiniz: + +# 1. Repo'yu fork edin +https://github.com/senrecep/Aspire +# 2. Template'i kurun +dotnet new install SenRecep.Aspire +# 3. Yeni proje yaratın (kendi adınızla) +mkdir candidate-task && cd candidate-task +dotnet new aspire-microservice-starter -n CandidateTask +# 4. Aspire host ile çalıştırın +cd source/src/Host/CandidateTask.AppHost +dotnet run + +Aspire dashboard'da Postgres, Redis, RabbitMQ, Seq ve Keycloak container'larının ayağa kalktığını +doğrulayın. ProductService ve CategoryService endpoint'lerinin Swagger üzerinden eriştiğinizi test +edin. Yeni servisinizi yazmadan önce bu iki servisin nasıl yapılandırıldığını dikkatle inceleyin. + +Hatırlatma: +Mevcut ProductService'in pattern'lerini birebir taklit edin. Yeni bir mimari, kütüphane veya kalıp icat +etmeyin. Amaç ekibe uyumlu kod yazabildiğinizi göstermek; özgün tasarım değerlendirilmez. + +3. Görev — ProductReview Microservice +Yeni bir mikroservis ekleyeceksiniz: ProductReview. Kullanıcılar bir ürünü 1–5 yıldız ile +değerlendirebilir, isteğe bağlı bir yorum ekleyebilir. Aşağıdaki katman gereksinimlerini eksiksiz +uygulayın. +3.1 Domain Layer +• ProductReview aggregate root: Id, ProductId, UserId, Rating, Comment, CreatedAt, UpdatedAt, +IsDeleted (soft delete) +• Value Objects: +◦ ProductReviewId (StronglyTypedId) +◦ ProductId (StronglyTypedId) — referans alınan dış aggregate +◦ UserId (StronglyTypedId) +◦ ReviewRating — 1–5 aralığını enforce eden factory method +◦ ReviewComment — opsiyonel, max 1000 karakter +• Parameters: ProductReviewCreateParameters, ProductReviewUpdateParameters +• ReadModel: ProductReviewReadModel (query projection) +• Repository arayüzleri: IProductReviewCommandRepository, IProductReviewQueryRepository +• Domain Event'ler: ProductReviewCreatedDomainEvent, ProductReviewUpdatedDomainEvent, +ProductReviewDeletedDomainEvent +• Errors: ProductReviewErrors static sınıfı (NotFound, AlreadyReviewed, InvalidRating) +• Factory method: ProductReview.Create() — Result döner, tüm validation +içerir +• Update method: ProductReview.Update() — Result döner +3.2 Application Layer +Commands +• CreateProductReviewCommand + Handler + Validator (FluentValidation) +• UpdateProductReviewCommand + Handler + Validator +• DeleteProductReviewCommand + Handler +Queries +• GetProductReviewByIdQuery + Handler +• GetProductReviewListQuery — paginated, ProductId ile filtrelenebilir +• GetProductAverageRatingQuery — tek bir ürünün ortalama puanını döner +Domain Event Handler +• ProductReviewCreatedDomainEventHandler — ilgili ürünün ortalama puan cache key'ini +invalidate eder + +İş Kuralları +• Aynı kullanıcı aynı ProductId için ikinci bir değerlendirme yazamaz (AlreadyReviewed error +döner) +• Bir kullanıcı yalnızca kendi değerlendirmesini update veya delete edebilir (UserId match check) +3.3 IntegrationEvent Layer +• ProductReviewCreatedIntegrationEvent — MassTransit üzerinden RabbitMQ'ya yayınlanır +• Payload: ProductId, NewAverageRating, ReviewCount +Bu event'i kim tüketecek diye düşünmenize gerek yok; yayın tarafı yeterli. +3.4 Persistence Layer +• ApplicationWriteDbContext ve ApplicationReadDbContext (CQRS ayrımı) +• EF Core konfigürasyonları: Configurations/Read ve Configurations/Write klasörleri +• EfProductReviewCommandRepository, EfProductReviewQueryRepository +• Initial migration: ProductService'in migration.sh script pattern'ini taklit ederek üretin +• Soft delete query filter — IsDeleted = true olan kayıtlar query sonuçlarında yer almasın +• Audit fields (CreatedAt, UpdatedAt) bir EF Core interceptor ile otomatik doldurulsun +3.5 WebApi Layer +Carter modülleri ile RESTful endpoint'ler yazın: + +POST /api/v1/productreviews +GET /api/v1/productreviews/{id} +GET /api/v1/products/{productId}/reviews (paginated) +GET /api/v1/products/{productId}/average-rating +PATCH /api/v1/productreviews/{id} +DELETE /api/v1/productreviews/{id} + +• Result pattern → ProblemDetails dönüşümü +• Swagger UI'da görünür ve test edilebilir olsun +• API versioning (/v1/ prefix) +• YARP Proxy üzerinden routing eklenmiş olsun +3.6 Aspire AppHost +• ProductReview servisini AppHost'a builder.AddProject ile ekleyin +• Kendi Postgres database'i: pg-productreviewservice +• RabbitMQ ve Redis WithReference ile bağlayın +• WaitFor pattern'i doğru kullanın (postgres ve rabbitmq hazır olmadan servis ayağa kalkmamalı) + +4. Teslim Kontrol Listesi +PR açmadan önce aşağıdaki maddelerin tamamını doğrulayın: +☐ Fork ettiğin GitHub repo URL'i PR description'da var +☐ dotnet run ile Aspire dashboard hatasız başlıyor +☐ Tüm endpoint'ler Swagger üzerinden manuel test edildi +☐ Migration script ile DB schema'sı yaratılıyor: ./migration.sh InitialProductReview +☐ dotnet test komutu en az 3 unit test ile yeşil dönüyor +☐ README'de tasarım kararlarını açıklayan kısa bir bölüm var +☐ PR açıklaması: yaklaşımını ve trade-off'larını anlatan 1–2 paragraf içeriyor + +6. Anti-pattern'ler +Aşağıdaki uygulamalar otomatik olarak puan kıracaktır. Bunlardan kaçının: +• throw new Exception(...) kullanımı (Result pattern var, exception fırlatmayın) +• Primitive obsession: string userId yerine UserId value object kullanın +• Anemic domain: business logic'i handler'a yazıp entity'i salt veri konteyneri yapmak +• Async method içinde .Result veya .Wait() çağırmak (deadlock riski) +• Endpoint içinde doğrudan DbContext kullanmak (Repository pattern'i bypass etmek) +• Hardcoded değerler — config'den okunmalı +• Domain event'i handler dışında IPublisher ile publish etmek +• Migration commit'i atlama veya migration.sh pattern'i dışına çıkmak +• Tek devasa commit — anlamlı, küçük commit'ler bekleniyor +7. Bonus (zorunlu değil, ekstra puan) +• WebApplicationFactory ile en az 1 endpoint için integration test +• GetProductAverageRatingQuery için Redis caching uygulaması (cache invalidate domain event +handler ile) +• XML doc comments — Swagger üzerinden endpoint açıklamaları okunabilir olsun +• Pagination response wrapper'ı (PageNumber, PageSize, TotalCount, Items) diff --git a/muhammed-task/.gitignore b/muhammed-task/.gitignore new file mode 100644 index 0000000..200d648 --- /dev/null +++ b/muhammed-task/.gitignore @@ -0,0 +1,1063 @@ +# Created by https://www.toptal.com/developers/gitignore/api/visualstudio,visualstudiocode,jetbrains,rider,webstorm,vim,windows,linux,macos,csharp,node +# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudio,visualstudiocode,jetbrains,rider,webstorm,vim,windows,linux,macos,csharp,node + +### Csharp ### +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml + +### JetBrains ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### JetBrains Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### Linux ### + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Node ### +# Logs +logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache + +# SvelteKit build / generate output +.svelte-kit + +### Rider ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff + +# AWS User-specific + +# Generated files + +# Sensitive or high-churn files + +# Gradle + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake + +# Mongo Explorer plugin + +# File-based project format + +# IntelliJ + +# mpeltonen/sbt-idea plugin + +# JIRA plugin + +# Cursive Clojure plugin + +# SonarLint plugin + +# Crashlytics plugin (for Android Studio and IntelliJ) + +# Editor-based Rest Client + +# Android studio 3.1+ serialized cache file + +### Vim ### +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +### VisualStudioCode ### +!.vscode/*.code-snippets + +# Local History for Visual Studio Code + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### WebStorm ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff + +# AWS User-specific + +# Generated files + +# Sensitive or high-churn files + +# Gradle + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake + +# Mongo Explorer plugin + +# File-based project format + +# IntelliJ + +# mpeltonen/sbt-idea plugin + +# JIRA plugin + +# Cursive Clojure plugin + +# SonarLint plugin + +# Crashlytics plugin (for Android Studio and IntelliJ) + +# Editor-based Rest Client + +# Android studio 3.1+ serialized cache file + +### WebStorm Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files + +# Windows shortcuts +*.lnk + +### VisualStudio ### + +# User-specific files + +# User-specific files (MonoDevelop/Xamarin Studio) + +# Mono auto generated files + +# Build results + +# Visual Studio 2015/2017 cache/options directory +# Uncomment if you have tasks that create the project's static files in wwwroot + +# Visual Studio 2017 auto generated files + +# MSTest test Results + +# NUnit + +# Build Results of an ATL Project + +# Benchmark Results + +# .NET Core + +# ASP.NET Scaffolding + +# StyleCop + +# Files built by Visual Studio + +# Chutzpah Test files + +# Visual C++ cache files + +# Visual Studio profiler + +# Visual Studio Trace Files + +# TFS 2012 Local Workspace + +# Guidance Automation Toolkit + +# ReSharper is a .NET coding add-in + +# TeamCity is a build add-in + +# DotCover is a Code Coverage Tool + +# AxoCover is a Code Coverage Tool + +# Coverlet is a free, cross platform Code Coverage Tool + +# Visual Studio code coverage results + +# NCrunch + +# MightyMoose + +# Web workbench (sass) + +# Installshield output folder + +# DocProject is a documentation generator add-in + +# Click-Once directory + +# Publish Web Output +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted + +# NuGet Packages +# NuGet Symbol Packages +# The packages folder can be ignored because of Package Restore +# except build/, which is used as an MSBuild target. +# Uncomment if necessary however generally it will be regenerated when needed +# NuGet v3's project.json files produces more ignorable files + +# Microsoft Azure Build Output + +# Microsoft Azure Emulator + +# Windows Store app package directories and files + +# Visual Studio cache files +# files ending in .cache can be ignored +# but keep track of directories ending in .cache + +# Others + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) + +# RIA/Silverlight projects + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) + +# SQL Server files + +# Business Intelligence projects + +# Microsoft Fakes + +# GhostDoc plugin setting file + +# Node.js Tools for Visual Studio + +# Visual Studio 6 build log + +# Visual Studio 6 workspace options file + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) + +# Visual Studio 6 technical files + +# Visual Studio LightSwitch build output + +# Paket dependency manager + +# FAKE - F# Make + +# CodeRush personal settings + +# Python Tools for Visual Studio (PTVS) + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio + +# Telerik's JustMock configuration file + +# BizTalk build output + +# OpenCover UI analysis results + +# Azure Stream Analytics local run output + +# MSBuild Binary and Structured Log + +# NVidia Nsight GPU debugger configuration file + +# MFractors (Xamarin productivity tool) working folder + +# Local History for Visual Studio + +# Visual Studio History (VSHistory) files + +# BeatPulse healthcheck temp database + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 + +# Ionide (cross platform F# VS Code tools) working folder + +# Fody - auto-generated XML schema + +# VS Code files for those working on multiple tools + +# Local History for Visual Studio Code + +# Windows Installer files from build outputs + +# JetBrains Rider + +### VisualStudio Patch ### +# Additional files built by Visual Studio + +# End of https://www.toptal.com/developers/gitignore/api/visualstudio,visualstudiocode,jetbrains,rider,webstorm,vim,windows,linux,macos,csharp,node + +# Internal dev notes — not for repo +yapılanlar/ + diff --git a/muhammed-task/.signature.p7s b/muhammed-task/.signature.p7s new file mode 100644 index 0000000..dd91fc6 Binary files /dev/null and b/muhammed-task/.signature.p7s differ diff --git a/muhammed-task/.vscode/settings.json b/muhammed-task/.vscode/settings.json new file mode 100644 index 0000000..dc4062b --- /dev/null +++ b/muhammed-task/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "cSpell.words": [ + "Loggable", + "MuhammedTask", + "npgsql", + "Otlp", + "postgres" + ] +} \ No newline at end of file diff --git a/muhammed-task/CODE_OF_CONDUCT.md b/muhammed-task/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..2d86d42 --- /dev/null +++ b/muhammed-task/CODE_OF_CONDUCT.md @@ -0,0 +1,53 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes +- Focusing on what is best for the overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate + +## Enforcement Responsibilities + +Project maintainers are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the project maintainers responsible for enforcement. +All complaints will be reviewed and investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. diff --git a/muhammed-task/CONTRIBUTING.md b/muhammed-task/CONTRIBUTING.md new file mode 100644 index 0000000..1e6e5cc --- /dev/null +++ b/muhammed-task/CONTRIBUTING.md @@ -0,0 +1,77 @@ +# Contributing to Aspire Microservice Starter + +First off, thank you for considering contributing to Aspire Microservice Starter! It's people like you that make it such a great tool. + +## Code of Conduct + +This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. + +## How Can I Contribute? + +### Reporting Bugs + +Before creating bug reports, please check the issue list as you might find out that you don't need to create one. When you are creating a bug report, please include as many details as possible: + +- Use a clear and descriptive title +- Describe the exact steps which reproduce the problem +- Provide specific examples to demonstrate the steps +- Describe the behavior you observed after following the steps +- Explain which behavior you expected to see instead and why +- Include screenshots if possible + +### Suggesting Enhancements + +Enhancement suggestions are tracked as GitHub issues. When you are creating an enhancement suggestion, please include: + +- Use a clear and descriptive title +- Provide a step-by-step description of the suggested enhancement +- Provide specific examples to demonstrate the steps +- Describe the current behavior and explain which behavior you expected to see instead +- Explain why this enhancement would be useful + +### Pull Requests + +- Fork the repo and create your branch from `main` +- If you've added code that should be tested, add tests +- Ensure the test suite passes +- Make sure your code follows the existing code style +- Write a convincing description of your PR and why we should land it + +## Development Process + +1. Fork the repository +2. Create a new branch for your feature/fix +3. Write your code +4. Write or update tests if needed +5. Run the test suite +6. Push your branch and submit a pull request + +## Styleguides + +### Git Commit Messages + +- Use the present tense ("Add feature" not "Added feature") +- Use the imperative mood ("Move cursor to..." not "Moves cursor to...") +- Limit the first line to 72 characters or less +- Reference issues and pull requests liberally after the first line + +### C# Styleguide + +- Follow the [C# Coding Conventions](https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/inside-a-program/coding-conventions) +- Use PascalCase for class names and method names +- Use camelCase for method arguments and local variables +- Use meaningful names for variables and methods +- Add comments to explain complex logic + +## Additional Notes + +### Issue and Pull Request Labels + +This section lists the labels we use to help us track and manage issues and pull requests. + +- `bug` - Issues that are bugs +- `documentation` - Issues for improving or updating documentation +- `enhancement` - Issues for new features or improvements +- `good first issue` - Good for newcomers +- `help wanted` - Extra attention is needed +- `question` - Further information is requested diff --git a/muhammed-task/LICENCE b/muhammed-task/LICENCE new file mode 100644 index 0000000..f6d526e --- /dev/null +++ b/muhammed-task/LICENCE @@ -0,0 +1,7 @@ +Copyright 2025 @senrecep + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/muhammed-task/README.MD b/muhammed-task/README.MD new file mode 100644 index 0000000..f4fd0f6 --- /dev/null +++ b/muhammed-task/README.MD @@ -0,0 +1,206 @@ +# Aspire Microservice Starter + +[![NuGet](https://img.shields.io/nuget/v/SenRecep.Aspire.svg)](https://www.nuget.org/packages/SenRecep.Aspire) +[![NuGet Downloads](https://img.shields.io/nuget/dt/SenRecep.Aspire.svg)](https://www.nuget.org/packages/SenRecep.Aspire) +[![GitHub](https://img.shields.io/github/license/SenRecep/Aspire)](https://github.com/SenRecep/Aspire) +[![GitHub Stars](https://img.shields.io/github/stars/SenRecep/Aspire)](https://github.com/SenRecep/Aspire) + +A modern, production-ready microservices template built with .NET Aspire 9.0. This template provides a robust foundation for building scalable, maintainable, and cloud-ready microservices applications. + +## Features + +- **.NET Aspire Integration**: Built on .NET 9.0 with full Aspire support for cloud-native development +- **Microservices Architecture**: + - Product Service + - Category Service + - API Gateway (YARP Proxy) +- **Building Blocks**: + - Domain-Driven Design (DDD) structure + - Clean Architecture implementation + - Shared infrastructure components +- **Infrastructure Components**: + - PostgreSQL database integration + - Redis caching + - RabbitMQ message broker + - Seq logging + - Keycloak authentication +- **Development Features**: + - Centralized package management + - Code analysis and quality tools + - Entity Framework Core with PostgreSQL + - MassTransit for message handling + - OpenTelemetry integration + +## Project Structure + +``` +source/ +├── src/ +│ ├── BuildingBlocks/ # Shared components and utilities +│ │ ├── Caching/ # Redis and base caching implementations +│ │ ├── Database/ # Database access and migrations +│ │ ├── Logging/ # Serilog implementation +│ │ ├── MessageBrokers/ # MassTransit and message broker implementations +│ │ └── OpenTelemetry/ # Telemetry and monitoring +│ ├── Services/ # Microservices +│ │ ├── ProductService/ # Product management service +│ │ ├── CategoryService/ # Category management service +│ │ └── ProxyService/ # YARP API Gateway +│ └── Host/ # Aspire host and configuration +└── tests/ # Test projects +``` + +## Prerequisites + +- .NET 9.0 SDK +- Docker Desktop +- Visual Studio 2022 or later (recommended) + +## Installation + +1. Install the template: + +```bash +dotnet new install SenRecep.Aspire +``` + +2. Create a new project: + +```bash +mkdir YourProjectName +cd YourProjectName +dotnet new aspire-microservice-starter -n YourProjectName +``` + +## Getting Started + +1. Clone the repository +2. Navigate to the solution directory +3. Run the application: + +```bash +cd source/src/Host/YourProjectName.AppHost +dotnet run +``` + +## Development Environment + +The template uses .NET Aspire's orchestration to manage: + +- PostgreSQL databases for each service +- Redis cache +- RabbitMQ message broker +- Seq for centralized logging +- Keycloak for authentication + +## Architecture + +This template follows Clean Architecture principles with a domain-driven design approach: + +- **Domain Layer**: Core business logic and entities +- **Application Layer**: Use cases and business rules +- **Infrastructure Layer**: External concerns and implementations +- **Presentation Layer**: API endpoints and controllers + +## Configuration + +The template uses centralized package management through `Directory.Packages.props` and shared build properties in `Directory.Build.props`. + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. + +## 🔍 Related Projects + +### CSharpEssentials + +[CSharpEssentials](https://github.com/SenRecep/CSharpEssentials) is a comprehensive library that enhances C#'s functional programming capabilities. It provides a robust set of tools and utilities designed to make your C# applications more maintainable, testable, and aligned with functional programming principles. + +Key features: + +- Functional Programming Essentials (Maybe Monad, Result Pattern, Discriminated Unions) +- Domain Modeling Tools (Rule Engine, Error Types, Entity Base) +- ASP.NET Core Integration +- Entity Framework Core Integration +- Request/Response Logging +- Enhanced JSON capabilities +- Time Management utilities + +This library complements the Aspire Microservice Starter by providing additional functional programming patterns and utilities that can be used to build more robust and maintainable microservices. + +--- + +## ProductReviewService — Design Decisions + +This section documents the key design decisions made while implementing the `ProductReviewService` as part of the take-home task. The service follows the same patterns as `ProductService` and `CategoryService`. + +### Domain Layer + +**`ReviewComment` as a nullable value object (`ReviewComment?`)** + +`ReviewComment` is a `readonly record struct`. Because structs cannot be `null` in C#, making the EF column optional required declaring the domain property as `ReviewComment?` (nullable struct) rather than `ReviewComment`. This allowed using an explicit `ValueConverter` in the EF configuration, which correctly handles `null` values in both directions. + +**Business rules enforced inside the aggregate** + +`ProductReview.Update()` and `ProductReview.Delete()` both receive the calling `UserId` and check it against the stored `UserId` before proceeding. If they don't match, they return `ProductReviewErrors.UnauthorizedError` immediately — no service-layer authorization check needed. This keeps ownership logic inside the domain where it belongs. + +**Domain events raised on every state change** + +`ProductReviewCreatedDomainEvent`, `ProductReviewUpdatedDomainEvent`, and `ProductReviewDeletedDomainEvent` are raised via `Raise()` inside the aggregate methods. They are published by the `PublishDomainEventsInterceptor` after `SaveChangesAsync`, keeping the domain free of any `IPublisher` dependency. + +### Application Layer + +**AlreadyReviewed check in the command handler** + +The `CreateProductReviewCommandHandler` calls `IProductReviewQueryRepository.ExistsAsync(productId, userId)` before attempting to create. If a review already exists, it returns `ProductReviewErrors.AlreadyReviewedError`. This is a pre-condition check — the uniqueness is also enforced at the database level (see below) as a safety net. + +**Cache invalidation via domain event handler** + +`ProductReviewCreatedDomainEventHandler` invalidates both the `"reviews"` and `"average-rating"` cache tags so that list queries and average-rating queries both reflect the new state immediately. This decouples cache invalidation from the command handler and follows the same pattern used by `ProductService`. + +**Three separate cached queries** + +- `GetProductReviewByIdQuery` — cached by `"productreview:{id}"` +- `GetProductReviewListQuery` — cached with tags `["reviews", "product:{productId}"]` +- `GetProductAverageRatingQuery` — cached by `"product:{productId}:average-rating"` + +Separate cache keys allow fine-grained invalidation without clearing unrelated entries. + +### Persistence Layer + +**Write/Read DbContext separation** + +`ApplicationWriteDbContext` (tracking, domain events interceptor, auditable interceptor) and `ApplicationReadDbContext` (no-tracking, interceptors disabled) are registered as separate pooled DbContexts pointing to the same PostgreSQL database. This enforces the CQRS boundary at the infrastructure level. + +**Unique index as a database-level guard for AlreadyReviewed** + +A unique composite index on `(product_id, user_id)` is created in the migration. Even if the application-level check in the command handler were bypassed under concurrent writes, the database would reject the duplicate with a unique constraint violation, which `EntityFrameworkCore.Exceptions.PostgreSQL` maps to a conflict error. + +**`IDesignTimeDbContextFactory` for EF CLI** + +The `AddPooledPostgresDbContext` helper in `BuildingBlocks` (which applies `UseSnakeCaseNamingConvention()`) is not available at design time. A dedicated `ApplicationWriteDbContextFactory` reads the connection string from an environment variable and registers only the minimal services needed for EF CLI commands (`dotnet ef migrations add`, `dotnet ef database update`). + +**`PendingModelChangesWarning` suppressed** + +`UseSnakeCaseNamingConvention()` is applied at runtime by the BuildingBlocks helper but is not reflected in the EF model snapshot (which is generated by the design-time factory without it). This causes EF to report a model/snapshot mismatch warning on every startup. The warning is suppressed via `ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))` and the migration is written manually with the correct snake_case column and index names. + +### WebApi Layer + +**No inter-service HTTP clients** + +`ProductReviewService` does not call any other service over HTTP, so there is no `ConfigureHttpClients()` registration — unlike `ProductService`, which references `CategoryService`. All data it needs is self-contained within its own database. + +**Six endpoints via Carter modules** + +| Method | Route | Purpose | +|--------|-------|---------| +| POST | `/api/v1/productreviews` | Create a review | +| PATCH | `/api/v1/productreviews/{reviewId}` | Update a review | +| DELETE | `/api/v1/productreviews/{reviewId}` | Delete a review | +| GET | `/api/v1/productreviews/{reviewId}` | Get a single review | +| GET | `/api/v1/products/{productId}/reviews` | List reviews for a product | +| GET | `/api/v1/products/{productId}/average-rating` | Get average rating | diff --git a/muhammed-task/SenRecep.Aspire.nuspec b/muhammed-task/SenRecep.Aspire.nuspec new file mode 100644 index 0000000..e3acd29 --- /dev/null +++ b/muhammed-task/SenRecep.Aspire.nuspec @@ -0,0 +1,23 @@ + + + + SenRecep.Aspire + 1.0.11 + Modern .NET Aspire Microservice Starter Template + SenRecep + false + MIT + https://licenses.nuget.org/MIT + icon.png + README.MD + https://github.com/SenRecep/Aspire + A modern, production-ready microservices template built with .NET Aspire 9.0. Features include Domain-Driven Design (DDD), Clean Architecture, PostgreSQL, Redis, RabbitMQ, Keycloak, OpenTelemetry, and comprehensive development tools for building scalable cloud-native applications. + Comprehensive microservice template with .NET Aspire 9.0, featuring DDD, Clean Architecture, and modern cloud-native infrastructure components. + en-US + dotnet aspire microservice template ddd clean-architecture postgresql redis rabbitmq keycloak opentelemetry cloud-native + + + + + + \ No newline at end of file diff --git a/muhammed-task/[Content_Types].xml b/muhammed-task/[Content_Types].xml new file mode 100644 index 0000000..219ba5e --- /dev/null +++ b/muhammed-task/[Content_Types].xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/muhammed-task/_rels/.rels b/muhammed-task/_rels/.rels new file mode 100644 index 0000000..386ef7c --- /dev/null +++ b/muhammed-task/_rels/.rels @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/muhammed-task/docs/notes.md b/muhammed-task/docs/notes.md new file mode 100644 index 0000000..1158e41 --- /dev/null +++ b/muhammed-task/docs/notes.md @@ -0,0 +1 @@ +Some notes \ No newline at end of file diff --git a/muhammed-task/icon.png b/muhammed-task/icon.png new file mode 100644 index 0000000..1089d89 Binary files /dev/null and b/muhammed-task/icon.png differ diff --git a/muhammed-task/package/services/metadata/core-properties/6ebfba5631e04eebb3c8a89f19acc217.psmdcp b/muhammed-task/package/services/metadata/core-properties/6ebfba5631e04eebb3c8a89f19acc217.psmdcp new file mode 100644 index 0000000..0d9f440 --- /dev/null +++ b/muhammed-task/package/services/metadata/core-properties/6ebfba5631e04eebb3c8a89f19acc217.psmdcp @@ -0,0 +1,9 @@ + + + SenRecep + A modern, production-ready microservices template built with .NET Aspire 9.0. Features include Domain-Driven Design (DDD), Clean Architecture, PostgreSQL, Redis, RabbitMQ, Keycloak, OpenTelemetry, and comprehensive development tools for building scalable cloud-native applications. + SenRecep.Aspire + 1.0.11 + dotnet aspire microservice template ddd clean-architecture postgresql redis rabbitmq keycloak opentelemetry cloud-native + NuGet, Version=6.12.1.1, Culture=neutral, PublicKeyToken=31bf3856ad364e35;Unix 24.1.0.0;.NET Framework 4.7.2 + \ No newline at end of file diff --git a/muhammed-task/source/.editorconfig b/muhammed-task/source/.editorconfig new file mode 100644 index 0000000..0a565d9 --- /dev/null +++ b/muhammed-task/source/.editorconfig @@ -0,0 +1,406 @@ +root = true + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +indent_style = space +tab_width = 4 + +# New line preferences +end_of_line = crlf +insert_final_newline = true + +#### .NET Coding Conventions #### + +# Organize usings +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = true + +# this. and Me. preferences +dotnet_style_qualification_for_event = false:error +dotnet_style_qualification_for_field = false:error +dotnet_style_qualification_for_method = false:error +dotnet_style_qualification_for_property = false:error + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:error +dotnet_style_predefined_type_for_member_access = true:error + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:error +dotnet_style_parentheses_in_other_binary_operators = never_if_unnecessary:error +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:error +dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:error + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:error + +# Expression-level preferences +dotnet_style_coalesce_expression = true:error +dotnet_style_collection_initializer = true:error +dotnet_style_explicit_tuple_names = true:error +dotnet_style_null_propagation = true:error +dotnet_style_object_initializer = true:error +dotnet_style_prefer_auto_properties = true:warning +dotnet_style_prefer_compound_assignment = true:error +dotnet_style_prefer_conditional_expression_over_assignment = true:error +dotnet_style_prefer_conditional_expression_over_return = true:none +dotnet_style_prefer_inferred_anonymous_type_member_names = true:error +dotnet_style_prefer_inferred_tuple_names = true:error +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:error +csharp_indent_labels = one_less_than_current +csharp_using_directive_placement = outside_namespace:error +csharp_prefer_simple_using_statement = true:error +csharp_prefer_braces = false +csharp_style_namespace_declarations = file_scoped:error +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent +csharp_style_prefer_primary_constructors = true:none +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = true +csharp_style_expression_bodied_operators = true:error +csharp_style_expression_bodied_properties = true:error +csharp_style_expression_bodied_indexers = true:error +csharp_style_expression_bodied_accessors = true:error +csharp_style_expression_bodied_lambdas = true:error +csharp_style_expression_bodied_local_functions = true:error + +# IDE0320: Make anonymous function static +csharp_prefer_static_anonymous_function = true + +[*.{cs,vb}] +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion +dotnet_style_namespace_match_folder = false + +# Field preferences +dotnet_style_readonly_field = true:error + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:error + +#### C# Coding Conventions #### + +# Namespace preferences +csharp_style_namespace_declarations= file_scoped:error + +# var preferences +csharp_style_var_elsewhere = false:error +csharp_style_var_for_built_in_types = false:error +csharp_style_var_when_type_is_apparent = true:error + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:error +csharp_style_expression_bodied_constructors = true +csharp_style_expression_bodied_indexers = true:error +csharp_style_expression_bodied_lambdas = true:error +csharp_style_expression_bodied_local_functions = true:error +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = true:error +csharp_style_expression_bodied_properties = true:error + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:error +csharp_style_pattern_matching_over_is_with_cast_check = true:error +csharp_style_prefer_switch_expression = true:error + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:error + +# Modifier preferences +csharp_prefer_static_local_function = true:error +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async + +# Code-block preferences +csharp_prefer_braces = false +csharp_prefer_simple_using_statement = true:error + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:error +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_inlined_variable_declaration = true:error +csharp_style_pattern_local_over_anonymous_function = true:error +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:silent +csharp_style_unused_value_expression_statement_preference = discard_variable +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:error + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch =true +csharp_new_line_before_else =true +csharp_new_line_before_finally =true +csharp_new_line_before_members_in_anonymous_types = false, +csharp_new_line_before_members_in_object_initializers = false, +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = false + +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +# Custom Rules - configure these as required + +# .NET Code Analyzers rules + +# CA1000: Do not declare static members on generic types +dotnet_diagnostic.CA1000.severity = none + +# CA1002: Do not expose generic lists +dotnet_diagnostic.CA1002.severity = none + +# CA1008: Enums should have zero value +dotnet_diagnostic.CA1008.severity = none + +# CA1019: Define accessors for attribute arguments +dotnet_diagnostic.CA1019.severity = none + +# CA1024: Use properties where appropriate +dotnet_diagnostic.CA1024.severity = none + +# CA1030: Use events where appropriate +dotnet_diagnostic.CA1030.severity = none + +# CA1031: Do not catch general exception types +dotnet_diagnostic.CA1031.severity = none + +# CA1032: Implement standard exception constructors +dotnet_diagnostic.CA1032.severity = none + +# CA1034: Nested types should not be visible +dotnet_diagnostic.CA1034.severity = none + +# CA1040: Avoid empty interfaces +dotnet_diagnostic.CA1040.severity = none + +# CA1051: Do not declare visible instance fields +dotnet_diagnostic.CA1051.severity = none + +# CA1056: URI-like properties should not be strings +dotnet_diagnostic.CA1056.severity = none + +# CA1062: Validate arguments of public methods +dotnet_diagnostic.CA1062.severity = none + +# CA1063: Implement IDisposable Correctly +dotnet_diagnostic.CA1063.severity = none + +# CA1307: Specify StringComparison for clarity +dotnet_diagnostic.CA1307.severity = none + +# CA1700: Do not name enum values 'Reserved' +dotnet_diagnostic.CA1700.severity = none + +# CA1707: Identifiers should not contain underscores +dotnet_diagnostic.CA1707.severity = none + +# CA1711: Identifiers should not have incorrect suffix +dotnet_diagnostic.CA1711.severity = none + +# CA1716: Identifiers should not match keywords +dotnet_diagnostic.CA1716.severity = none + +# CA1724: Type names should not match namespaces +dotnet_diagnostic.CA1724.severity = none + +# CA1725: Parameter names should match base declaration +dotnet_diagnostic.CA1725.severity = none + +# CA1812: Avoid uninstantiated internal classes +dotnet_diagnostic.CA1812.severity = none + +# CA1816: Dispose methods should call SuppressFinalize +dotnet_diagnostic.CA1816.severity = none + +# CA1819: Properties should not return arrays +dotnet_diagnostic.CA1819.severity = none + +# CA1822: Mark members as static +dotnet_diagnostic.CA1822.severity = none + +# CA1848: Use the LoggerMessage delegates +dotnet_diagnostic.CA1848.severity = none + +# CA1860: Avoid using 'Enumerable.Any()' extension method +dotnet_diagnostic.CA1860.severity = none + +# CA2007: Consider calling ConfigureAwait on the awaited task +dotnet_diagnostic.CA2007.severity = none + +# CA2201: Do not raise reserved exception types +dotnet_diagnostic.CA2201.severity = none + +# CA2211: Non-constant fields should not be visible +dotnet_diagnostic.CA2211.severity = none + +# CA2213: Disposable fields should be disposed +dotnet_diagnostic.CA2213.severity = none + +# CA2225: Operator overloads have named alternates +dotnet_diagnostic.CA2225.severity = none + +# CA2227: Collection properties should be read only +dotnet_diagnostic.CA2227.severity = none + +# CA2234: Pass system uri objects instead of strings +dotnet_diagnostic.CA2234.severity = none + +# CA2326: Do not use TypeNameHandling values other than None +dotnet_diagnostic.CA2326.severity = none + +# CA2326: Do not use insecure JsonSerializerSettings +dotnet_diagnostic.CA2327.severity = none + +# CS8600: Converting null literal or possible null value to non-nullable type. +dotnet_diagnostic.CS8600.severity = none + +# CS8603: Possible null reference return. +dotnet_diagnostic.CS8603.severity = none + +# CS8618: Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. +dotnet_diagnostic.CS8618.severity = none + +# IDE Code Analyzers rules + +# IDE0046: Convert to conditional expression +dotnet_diagnostic.IDE0046.severity = none + +# IDE0290: Use primary constructor +dotnet_diagnostic.IDE0290.severity = none + +# SonarAnalyzer.CSharp rules + +# S112: General or reserved exceptions should never be thrown +dotnet_diagnostic.S112.severity = none + +# S125: Sections of code should not be commented out +dotnet_diagnostic.S125.severity = none + +# S1135: Track uses of "TODO" tags +dotnet_diagnostic.S1135.severity = none + +# S2094: Utility classes should not have public constructors +dotnet_diagnostic.S1118.severity = none + +# S2094: Classes should not be empty +dotnet_diagnostic.S2094.severity = none + +# S2325: Methods and properties that don't access instance data should be static +dotnet_diagnostic.S2325.severity = none + +# S2365: Properties should not make collection or array copies +dotnet_diagnostic.S2365.severity = none + +# S3267: Loops should be simplified with "LINQ" expressions +dotnet_diagnostic.S3267.severity = none + +# S3881: "IDisposable" should be implemented correctly +dotnet_diagnostic.S3881.severity = none + +# S4136: Method overloads should be grouped together +dotnet_diagnostic.S4136.severity = none + +# S4158: Empty collections should not be accessed or iterated +dotnet_diagnostic.S4158.severity = none + +# S6605: Collection-specific "Exists" method should be used instead of the "Any" extension +dotnet_diagnostic.S6605.severity = none + +# S6781: JWT secret keys should not be disclosed +dotnet_diagnostic.S6781.severity = none + +# IDE0058: Remove unnecessary expression value +dotnet_diagnostic.IDE0058.severity = none + +# Suppress "cannot convert from X to Y" errors (CS1503) +dotnet_diagnostic.CS1503.severity = none + + +dotnet_diagnostic.IDE0022.severity = none + +dotnet_diagnostic.CA1515.severity = none diff --git a/muhammed-task/source/Directory.Build.props b/muhammed-task/source/Directory.Build.props new file mode 100644 index 0000000..e60abf6 --- /dev/null +++ b/muhammed-task/source/Directory.Build.props @@ -0,0 +1,45 @@ + + + MuhammedTask + $(Company) + Copyright © $(Company) $([System.DateTime]::Now.Year) + $(Company)™ + $(Company) Projects + + + net9.0 + enable + enable + + latest + All + true + true + true + true + 1591 + true + true + true + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + $([MSBuild]::GetPathOfFileAbove('.editorconfig', + $(MSBuildProjectDirectory))) + + + + + + + \ No newline at end of file diff --git a/muhammed-task/source/Directory.Packages.props b/muhammed-task/source/Directory.Packages.props new file mode 100644 index 0000000..396e9af --- /dev/null +++ b/muhammed-task/source/Directory.Packages.props @@ -0,0 +1,110 @@ + + + true + true + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/muhammed-task/source/MuhammedTask.sln b/muhammed-task/source/MuhammedTask.sln new file mode 100644 index 0000000..bff2b76 --- /dev/null +++ b/muhammed-task/source/MuhammedTask.sln @@ -0,0 +1,314 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.12.35521.163 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{E3294BE8-AD8C-419F-A994-4A19EA4949CE}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{EAFA75E5-B3C2-4562-8C25-88ABE4FF0D0D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{FF23B914-6FBC-4C61-A016-398E9D78FEC9}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + Directory.Build.props = Directory.Build.props + Directory.Packages.props = Directory.Packages.props + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "BuildingBlocks", "BuildingBlocks", "{954C90CA-E050-4E86-AF66-585FA49C2384}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Layers", "Layers", "{73DA2DBA-C06C-48BB-9C8F-028B858CCEF0}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Domain", "Domain", "{67D02D2C-86F1-4C05-A51C-E60697F344F4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Application", "Application", "{F33B5CB0-CA97-487A-8B88-59AE780549FF}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Presentation", "Presentation", "{58571F76-5D55-4EF7-A0A8-C6B19D95FC96}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Infrastructure", "Infrastructure", "{BA1EE3EF-3E5D-4F89-8CFD-3748C31A8E77}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Host", "Host", "{AA0ED03E-FF42-45D4-BD95-2E1A1805F5E5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Services", "Services", "{FD78D588-DF0A-40FD-A573-D1AD00A9B15F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Caching", "Caching", "{1D2B60BB-9951-4B8E-AA28-D4F3DF5396CF}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MessageBrokers", "MessageBrokers", "{8346BFAC-E7F9-42E0-A551-5D4D8FB768B3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.BuildingBlocks.Caching.Base", "src\BuildingBlocks\Caching\MuhammedTask.BuildingBlocks.Caching.Base\MuhammedTask.BuildingBlocks.Caching.Base.csproj", "{AE9C61C8-29C5-4F3D-8A20-15D83C3BF13C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.BuildingBlocks.Caching.Redis", "src\BuildingBlocks\Caching\MuhammedTask.BuildingBlocks.Caching.Redis\MuhammedTask.BuildingBlocks.Caching.Redis.csproj", "{397C0BD7-A54D-47DF-8AED-C8B0E3039F8A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.BuildingBlocks.Domain", "src\BuildingBlocks\Layers\Domain\MuhammedTask.BuildingBlocks.Domain\MuhammedTask.BuildingBlocks.Domain.csproj", "{C342DBC3-1569-4D7C-86D4-97F52CCD39E2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.BuildingBlocks.Application.Shared", "src\BuildingBlocks\Layers\Application\MuhammedTask.BuildingBlocks.Application.Shared\MuhammedTask.BuildingBlocks.Application.Shared.csproj", "{C3A715BF-8FE0-4115-811B-11029D7993A0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.BuildingBlocks.Application", "src\BuildingBlocks\Layers\Application\MuhammedTask.BuildingBlocks.Application\MuhammedTask.BuildingBlocks.Application.csproj", "{C57C5B54-AB51-45C0-80CE-5BB8D413B845}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.BuildingBlocks.Persistence", "src\BuildingBlocks\Layers\Infrastructure\MuhammedTask.BuildingBlocks.Persistence\MuhammedTask.BuildingBlocks.Persistence.csproj", "{7380373D-32C7-4BED-8DEA-41428F7A4693}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.BuildingBlocks.Presentation", "src\BuildingBlocks\Layers\Presentation\MuhammedTask.BuildingBlocks.Presentation\MuhammedTask.BuildingBlocks.Presentation.csproj", "{62568A68-ED85-4BE2-9DAA-2BFB74B32E74}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.BuildingBlocks.MessageBrokers.Base", "src\BuildingBlocks\MessageBrokers\MuhammedTask.BuildingBlocks.MessageBrokers.Base\MuhammedTask.BuildingBlocks.MessageBrokers.Base.csproj", "{2176879B-2B58-42FE-B9F1-75CECC62D073}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit", "src\BuildingBlocks\MessageBrokers\MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit\MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.csproj", "{1625FFAD-94BE-41F0-A417-8022034D0719}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.EntityFrameworkCore", "src\BuildingBlocks\MessageBrokers\MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.EntityFrameworkCore\MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.EntityFrameworkCore.csproj", "{178BAC2C-F566-4FC9-B272-BC1B65E00DDA}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Database", "Database", "{D4BD6051-F87F-4CBB-A3FC-FC146FE3F2A5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.BuildingBlocks.Database.Base", "src\BuildingBlocks\Database\MuhammedTask.BuildingBlocks.Database.Base\MuhammedTask.BuildingBlocks.Database.Base.csproj", "{FBC137EE-7EEB-4C04-A8D1-C46FE3DC74AF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.BuildingBlocks.Database.PostgreSQL", "src\BuildingBlocks\Database\MuhammedTask.BuildingBlocks.Database.PostgreSQL\MuhammedTask.BuildingBlocks.Database.PostgreSQL.csproj", "{E4799F09-8856-412A-BD44-892637857749}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "OpenTelemetry", "OpenTelemetry", "{4BD01D01-B597-40AA-B1C6-1C3D6EA4E039}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.BuildingBlocks.OpenTelemetry.Base", "src\BuildingBlocks\OpenTelemetry\MuhammedTask.BuildingBlocks.OpenTelemetry.Base\MuhammedTask.BuildingBlocks.OpenTelemetry.Base.csproj", "{7E48BEEF-5B00-4BB0-85C3-4DA892A9B742}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "BackgroundJobs", "BackgroundJobs", "{A5BC5781-F3E0-4BC0-8543-C125D0C956F8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.BuildingBlocks.BackgroundJobs.Coravel", "src\BuildingBlocks\BackgroundJobs\MuhammedTask.BuildingBlocks.BackgroundJobs.Coravel\MuhammedTask.BuildingBlocks.BackgroundJobs.Coravel.csproj", "{43092A7A-57D6-49F3-8C0D-DC1EB63D7E07}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.BuildingBlocks.BackgroundJobs.Quartz", "src\BuildingBlocks\BackgroundJobs\MuhammedTask.BuildingBlocks.BackgroundJobs.Quartz\MuhammedTask.BuildingBlocks.BackgroundJobs.Quartz.csproj", "{8B4BB9C9-BD7F-4656-93A5-6053DF9B6A47}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.AppHost", "src\Host\MuhammedTask.AppHost\MuhammedTask.AppHost.csproj", "{277E84A6-CFDA-4363-B40D-000DAE66DD64}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ProductService", "ProductService", "{5FDD6D01-8D9E-4574-9610-D678F0397359}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.Services.ProductService.WebApi", "src\Services\ProductService\MuhammedTask.Services.ProductService.WebApi\MuhammedTask.Services.ProductService.WebApi.csproj", "{9E9BE3BD-EFEB-4924-A362-357ED7460722}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.Services.ProductService.Domain", "src\Services\ProductService\MuhammedTask.Services.ProductService.Domain\MuhammedTask.Services.ProductService.Domain.csproj", "{51BEF736-A71C-4E7B-A8AF-6456715D63BC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.Services.ProductService.Application", "src\Services\ProductService\MuhammedTask.Services.ProductService.Application\MuhammedTask.Services.ProductService.Application.csproj", "{0A35E8A2-23B3-4323-A724-8166D381C706}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.Services.ProductService.Persistence", "src\Services\ProductService\MuhammedTask.Services.ProductService.Persistence\MuhammedTask.Services.ProductService.Persistence.csproj", "{FC4B9746-9286-4DCD-BFE7-4C7750715D8E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Logging", "Logging", "{510FC23E-833C-4B5A-AE5B-9DB6396FAB79}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.BuildingBlocks.Database.Migrator", "src\BuildingBlocks\Database\MuhammedTask.BuildingBlocks.Database.Migrator\MuhammedTask.BuildingBlocks.Database.Migrator.csproj", "{A90F58FD-FBC7-4FB8-B3E2-4BE25A04C12A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ProxyService", "ProxyService", "{4431548D-C268-4D6E-80D7-FB369E229E08}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yarp.ProxyService", "src\Services\ProxyService\Yarp.ProxyService\Yarp.ProxyService.csproj", "{53C823DD-9C94-49F2-ABB8-385BFA00DE7F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.Services.Info", "src\Host\MuhammedTask.Services.Info\MuhammedTask.Services.Info.csproj", "{21E396E2-CD28-4420-8DB8-7A99D244825D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.BuildingBlocks.Logging.Serilog", "src\BuildingBlocks\Logging\MuhammedTask.BuildingBlocks.Logging.Serilog\MuhammedTask.BuildingBlocks.Logging.Serilog.csproj", "{83C93C0F-2C64-4080-A371-E658683198AD}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CategoryService", "CategoryService", "{A0CE3505-A5F5-4FB2-A0F5-C779F5D41EC1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.Services.CategoryService.Domain", "src\Services\CategoryService\MuhammedTask.Services.CategoryService.Domain\MuhammedTask.Services.CategoryService.Domain.csproj", "{265F0AB6-C3D0-42F0-8916-545A3D94376F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.Services.CategoryService.Application", "src\Services\CategoryService\MuhammedTask.Services.CategoryService.Application\MuhammedTask.Services.CategoryService.Application.csproj", "{61979BE5-6EEF-44CE-B89D-D38158E4F3D7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.Services.CategoryService.Persistence", "src\Services\CategoryService\MuhammedTask.Services.CategoryService.Persistence\MuhammedTask.Services.CategoryService.Persistence.csproj", "{B321A3E2-C080-4791-A346-2406719B6F08}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.Services.CategoryService.WebApi", "src\Services\CategoryService\MuhammedTask.Services.CategoryService.WebApi\MuhammedTask.Services.CategoryService.WebApi.csproj", "{8EDD8538-371C-4539-B635-23A9873768B8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ProductReviewService", "ProductReviewService", "{7DCB9785-0B3B-4030-9A66-5914AD611C60}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.Services.ProductReviewService.Domain", "src\Services\ProductReviewService\MuhammedTask.Services.ProductReviewService.Domain\MuhammedTask.Services.ProductReviewService.Domain.csproj", "{CE0017F4-8C8A-445B-A1F0-2425FB31AC46}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.Services.ProductReviewService.Application", "src\Services\ProductReviewService\MuhammedTask.Services.ProductReviewService.Application\MuhammedTask.Services.ProductReviewService.Application.csproj", "{BF1F61F9-E941-4EBC-A48E-404CFF16FB86}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.Services.ProductReviewService.Persistence", "src\Services\ProductReviewService\MuhammedTask.Services.ProductReviewService.Persistence\MuhammedTask.Services.ProductReviewService.Persistence.csproj", "{C7D9C3D8-8AB4-4500-AB3C-125CA750818D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.Services.ProductReviewService.WebApi", "src\Services\ProductReviewService\MuhammedTask.Services.ProductReviewService.WebApi\MuhammedTask.Services.ProductReviewService.WebApi.csproj", "{1EA19F49-655E-411D-83BD-1EB1E7D519BC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MuhammedTask.Services.ProductReviewService.Domain.Tests", "src\Services\ProductReviewService\MuhammedTask.Services.ProductReviewService.Domain.Tests\MuhammedTask.Services.ProductReviewService.Domain.Tests.csproj", "{0C831AB6-3E01-4614-BAF0-7DF5DEEC0DAC}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {AE9C61C8-29C5-4F3D-8A20-15D83C3BF13C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AE9C61C8-29C5-4F3D-8A20-15D83C3BF13C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AE9C61C8-29C5-4F3D-8A20-15D83C3BF13C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AE9C61C8-29C5-4F3D-8A20-15D83C3BF13C}.Release|Any CPU.Build.0 = Release|Any CPU + {397C0BD7-A54D-47DF-8AED-C8B0E3039F8A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {397C0BD7-A54D-47DF-8AED-C8B0E3039F8A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {397C0BD7-A54D-47DF-8AED-C8B0E3039F8A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {397C0BD7-A54D-47DF-8AED-C8B0E3039F8A}.Release|Any CPU.Build.0 = Release|Any CPU + {C342DBC3-1569-4D7C-86D4-97F52CCD39E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C342DBC3-1569-4D7C-86D4-97F52CCD39E2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C342DBC3-1569-4D7C-86D4-97F52CCD39E2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C342DBC3-1569-4D7C-86D4-97F52CCD39E2}.Release|Any CPU.Build.0 = Release|Any CPU + {C3A715BF-8FE0-4115-811B-11029D7993A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3A715BF-8FE0-4115-811B-11029D7993A0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3A715BF-8FE0-4115-811B-11029D7993A0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3A715BF-8FE0-4115-811B-11029D7993A0}.Release|Any CPU.Build.0 = Release|Any CPU + {C57C5B54-AB51-45C0-80CE-5BB8D413B845}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C57C5B54-AB51-45C0-80CE-5BB8D413B845}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C57C5B54-AB51-45C0-80CE-5BB8D413B845}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C57C5B54-AB51-45C0-80CE-5BB8D413B845}.Release|Any CPU.Build.0 = Release|Any CPU + {7380373D-32C7-4BED-8DEA-41428F7A4693}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7380373D-32C7-4BED-8DEA-41428F7A4693}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7380373D-32C7-4BED-8DEA-41428F7A4693}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7380373D-32C7-4BED-8DEA-41428F7A4693}.Release|Any CPU.Build.0 = Release|Any CPU + {62568A68-ED85-4BE2-9DAA-2BFB74B32E74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {62568A68-ED85-4BE2-9DAA-2BFB74B32E74}.Debug|Any CPU.Build.0 = Debug|Any CPU + {62568A68-ED85-4BE2-9DAA-2BFB74B32E74}.Release|Any CPU.ActiveCfg = Release|Any CPU + {62568A68-ED85-4BE2-9DAA-2BFB74B32E74}.Release|Any CPU.Build.0 = Release|Any CPU + {2176879B-2B58-42FE-B9F1-75CECC62D073}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2176879B-2B58-42FE-B9F1-75CECC62D073}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2176879B-2B58-42FE-B9F1-75CECC62D073}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2176879B-2B58-42FE-B9F1-75CECC62D073}.Release|Any CPU.Build.0 = Release|Any CPU + {1625FFAD-94BE-41F0-A417-8022034D0719}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1625FFAD-94BE-41F0-A417-8022034D0719}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1625FFAD-94BE-41F0-A417-8022034D0719}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1625FFAD-94BE-41F0-A417-8022034D0719}.Release|Any CPU.Build.0 = Release|Any CPU + {178BAC2C-F566-4FC9-B272-BC1B65E00DDA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {178BAC2C-F566-4FC9-B272-BC1B65E00DDA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {178BAC2C-F566-4FC9-B272-BC1B65E00DDA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {178BAC2C-F566-4FC9-B272-BC1B65E00DDA}.Release|Any CPU.Build.0 = Release|Any CPU + {FBC137EE-7EEB-4C04-A8D1-C46FE3DC74AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FBC137EE-7EEB-4C04-A8D1-C46FE3DC74AF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FBC137EE-7EEB-4C04-A8D1-C46FE3DC74AF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FBC137EE-7EEB-4C04-A8D1-C46FE3DC74AF}.Release|Any CPU.Build.0 = Release|Any CPU + {E4799F09-8856-412A-BD44-892637857749}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E4799F09-8856-412A-BD44-892637857749}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E4799F09-8856-412A-BD44-892637857749}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E4799F09-8856-412A-BD44-892637857749}.Release|Any CPU.Build.0 = Release|Any CPU + {7E48BEEF-5B00-4BB0-85C3-4DA892A9B742}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7E48BEEF-5B00-4BB0-85C3-4DA892A9B742}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E48BEEF-5B00-4BB0-85C3-4DA892A9B742}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7E48BEEF-5B00-4BB0-85C3-4DA892A9B742}.Release|Any CPU.Build.0 = Release|Any CPU + {43092A7A-57D6-49F3-8C0D-DC1EB63D7E07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {43092A7A-57D6-49F3-8C0D-DC1EB63D7E07}.Debug|Any CPU.Build.0 = Debug|Any CPU + {43092A7A-57D6-49F3-8C0D-DC1EB63D7E07}.Release|Any CPU.ActiveCfg = Release|Any CPU + {43092A7A-57D6-49F3-8C0D-DC1EB63D7E07}.Release|Any CPU.Build.0 = Release|Any CPU + {8B4BB9C9-BD7F-4656-93A5-6053DF9B6A47}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8B4BB9C9-BD7F-4656-93A5-6053DF9B6A47}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8B4BB9C9-BD7F-4656-93A5-6053DF9B6A47}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8B4BB9C9-BD7F-4656-93A5-6053DF9B6A47}.Release|Any CPU.Build.0 = Release|Any CPU + {277E84A6-CFDA-4363-B40D-000DAE66DD64}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {277E84A6-CFDA-4363-B40D-000DAE66DD64}.Debug|Any CPU.Build.0 = Debug|Any CPU + {277E84A6-CFDA-4363-B40D-000DAE66DD64}.Release|Any CPU.ActiveCfg = Release|Any CPU + {277E84A6-CFDA-4363-B40D-000DAE66DD64}.Release|Any CPU.Build.0 = Release|Any CPU + {9E9BE3BD-EFEB-4924-A362-357ED7460722}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9E9BE3BD-EFEB-4924-A362-357ED7460722}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9E9BE3BD-EFEB-4924-A362-357ED7460722}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9E9BE3BD-EFEB-4924-A362-357ED7460722}.Release|Any CPU.Build.0 = Release|Any CPU + {51BEF736-A71C-4E7B-A8AF-6456715D63BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {51BEF736-A71C-4E7B-A8AF-6456715D63BC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {51BEF736-A71C-4E7B-A8AF-6456715D63BC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {51BEF736-A71C-4E7B-A8AF-6456715D63BC}.Release|Any CPU.Build.0 = Release|Any CPU + {0A35E8A2-23B3-4323-A724-8166D381C706}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0A35E8A2-23B3-4323-A724-8166D381C706}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0A35E8A2-23B3-4323-A724-8166D381C706}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0A35E8A2-23B3-4323-A724-8166D381C706}.Release|Any CPU.Build.0 = Release|Any CPU + {FC4B9746-9286-4DCD-BFE7-4C7750715D8E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FC4B9746-9286-4DCD-BFE7-4C7750715D8E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FC4B9746-9286-4DCD-BFE7-4C7750715D8E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FC4B9746-9286-4DCD-BFE7-4C7750715D8E}.Release|Any CPU.Build.0 = Release|Any CPU + {A90F58FD-FBC7-4FB8-B3E2-4BE25A04C12A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A90F58FD-FBC7-4FB8-B3E2-4BE25A04C12A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A90F58FD-FBC7-4FB8-B3E2-4BE25A04C12A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A90F58FD-FBC7-4FB8-B3E2-4BE25A04C12A}.Release|Any CPU.Build.0 = Release|Any CPU + {53C823DD-9C94-49F2-ABB8-385BFA00DE7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {53C823DD-9C94-49F2-ABB8-385BFA00DE7F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {53C823DD-9C94-49F2-ABB8-385BFA00DE7F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {53C823DD-9C94-49F2-ABB8-385BFA00DE7F}.Release|Any CPU.Build.0 = Release|Any CPU + {21E396E2-CD28-4420-8DB8-7A99D244825D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {21E396E2-CD28-4420-8DB8-7A99D244825D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {21E396E2-CD28-4420-8DB8-7A99D244825D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {21E396E2-CD28-4420-8DB8-7A99D244825D}.Release|Any CPU.Build.0 = Release|Any CPU + {83C93C0F-2C64-4080-A371-E658683198AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {83C93C0F-2C64-4080-A371-E658683198AD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {83C93C0F-2C64-4080-A371-E658683198AD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {83C93C0F-2C64-4080-A371-E658683198AD}.Release|Any CPU.Build.0 = Release|Any CPU + {265F0AB6-C3D0-42F0-8916-545A3D94376F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {265F0AB6-C3D0-42F0-8916-545A3D94376F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {265F0AB6-C3D0-42F0-8916-545A3D94376F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {265F0AB6-C3D0-42F0-8916-545A3D94376F}.Release|Any CPU.Build.0 = Release|Any CPU + {61979BE5-6EEF-44CE-B89D-D38158E4F3D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {61979BE5-6EEF-44CE-B89D-D38158E4F3D7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {61979BE5-6EEF-44CE-B89D-D38158E4F3D7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {61979BE5-6EEF-44CE-B89D-D38158E4F3D7}.Release|Any CPU.Build.0 = Release|Any CPU + {B321A3E2-C080-4791-A346-2406719B6F08}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B321A3E2-C080-4791-A346-2406719B6F08}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B321A3E2-C080-4791-A346-2406719B6F08}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B321A3E2-C080-4791-A346-2406719B6F08}.Release|Any CPU.Build.0 = Release|Any CPU + {8EDD8538-371C-4539-B635-23A9873768B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8EDD8538-371C-4539-B635-23A9873768B8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8EDD8538-371C-4539-B635-23A9873768B8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8EDD8538-371C-4539-B635-23A9873768B8}.Release|Any CPU.Build.0 = Release|Any CPU + {CE0017F4-8C8A-445B-A1F0-2425FB31AC46}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CE0017F4-8C8A-445B-A1F0-2425FB31AC46}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE0017F4-8C8A-445B-A1F0-2425FB31AC46}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CE0017F4-8C8A-445B-A1F0-2425FB31AC46}.Release|Any CPU.Build.0 = Release|Any CPU + {BF1F61F9-E941-4EBC-A48E-404CFF16FB86}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF1F61F9-E941-4EBC-A48E-404CFF16FB86}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF1F61F9-E941-4EBC-A48E-404CFF16FB86}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF1F61F9-E941-4EBC-A48E-404CFF16FB86}.Release|Any CPU.Build.0 = Release|Any CPU + {C7D9C3D8-8AB4-4500-AB3C-125CA750818D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C7D9C3D8-8AB4-4500-AB3C-125CA750818D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C7D9C3D8-8AB4-4500-AB3C-125CA750818D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C7D9C3D8-8AB4-4500-AB3C-125CA750818D}.Release|Any CPU.Build.0 = Release|Any CPU + {1EA19F49-655E-411D-83BD-1EB1E7D519BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1EA19F49-655E-411D-83BD-1EB1E7D519BC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1EA19F49-655E-411D-83BD-1EB1E7D519BC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1EA19F49-655E-411D-83BD-1EB1E7D519BC}.Release|Any CPU.Build.0 = Release|Any CPU + {0C831AB6-3E01-4614-BAF0-7DF5DEEC0DAC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0C831AB6-3E01-4614-BAF0-7DF5DEEC0DAC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0C831AB6-3E01-4614-BAF0-7DF5DEEC0DAC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0C831AB6-3E01-4614-BAF0-7DF5DEEC0DAC}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {954C90CA-E050-4E86-AF66-585FA49C2384} = {E3294BE8-AD8C-419F-A994-4A19EA4949CE} + {73DA2DBA-C06C-48BB-9C8F-028B858CCEF0} = {954C90CA-E050-4E86-AF66-585FA49C2384} + {67D02D2C-86F1-4C05-A51C-E60697F344F4} = {73DA2DBA-C06C-48BB-9C8F-028B858CCEF0} + {F33B5CB0-CA97-487A-8B88-59AE780549FF} = {73DA2DBA-C06C-48BB-9C8F-028B858CCEF0} + {58571F76-5D55-4EF7-A0A8-C6B19D95FC96} = {73DA2DBA-C06C-48BB-9C8F-028B858CCEF0} + {BA1EE3EF-3E5D-4F89-8CFD-3748C31A8E77} = {73DA2DBA-C06C-48BB-9C8F-028B858CCEF0} + {AA0ED03E-FF42-45D4-BD95-2E1A1805F5E5} = {E3294BE8-AD8C-419F-A994-4A19EA4949CE} + {FD78D588-DF0A-40FD-A573-D1AD00A9B15F} = {E3294BE8-AD8C-419F-A994-4A19EA4949CE} + {1D2B60BB-9951-4B8E-AA28-D4F3DF5396CF} = {954C90CA-E050-4E86-AF66-585FA49C2384} + {8346BFAC-E7F9-42E0-A551-5D4D8FB768B3} = {954C90CA-E050-4E86-AF66-585FA49C2384} + {AE9C61C8-29C5-4F3D-8A20-15D83C3BF13C} = {1D2B60BB-9951-4B8E-AA28-D4F3DF5396CF} + {397C0BD7-A54D-47DF-8AED-C8B0E3039F8A} = {1D2B60BB-9951-4B8E-AA28-D4F3DF5396CF} + {C342DBC3-1569-4D7C-86D4-97F52CCD39E2} = {67D02D2C-86F1-4C05-A51C-E60697F344F4} + {C3A715BF-8FE0-4115-811B-11029D7993A0} = {F33B5CB0-CA97-487A-8B88-59AE780549FF} + {C57C5B54-AB51-45C0-80CE-5BB8D413B845} = {F33B5CB0-CA97-487A-8B88-59AE780549FF} + {7380373D-32C7-4BED-8DEA-41428F7A4693} = {BA1EE3EF-3E5D-4F89-8CFD-3748C31A8E77} + {62568A68-ED85-4BE2-9DAA-2BFB74B32E74} = {58571F76-5D55-4EF7-A0A8-C6B19D95FC96} + {2176879B-2B58-42FE-B9F1-75CECC62D073} = {8346BFAC-E7F9-42E0-A551-5D4D8FB768B3} + {1625FFAD-94BE-41F0-A417-8022034D0719} = {8346BFAC-E7F9-42E0-A551-5D4D8FB768B3} + {178BAC2C-F566-4FC9-B272-BC1B65E00DDA} = {8346BFAC-E7F9-42E0-A551-5D4D8FB768B3} + {D4BD6051-F87F-4CBB-A3FC-FC146FE3F2A5} = {954C90CA-E050-4E86-AF66-585FA49C2384} + {FBC137EE-7EEB-4C04-A8D1-C46FE3DC74AF} = {D4BD6051-F87F-4CBB-A3FC-FC146FE3F2A5} + {E4799F09-8856-412A-BD44-892637857749} = {D4BD6051-F87F-4CBB-A3FC-FC146FE3F2A5} + {4BD01D01-B597-40AA-B1C6-1C3D6EA4E039} = {954C90CA-E050-4E86-AF66-585FA49C2384} + {7E48BEEF-5B00-4BB0-85C3-4DA892A9B742} = {4BD01D01-B597-40AA-B1C6-1C3D6EA4E039} + {A5BC5781-F3E0-4BC0-8543-C125D0C956F8} = {954C90CA-E050-4E86-AF66-585FA49C2384} + {43092A7A-57D6-49F3-8C0D-DC1EB63D7E07} = {A5BC5781-F3E0-4BC0-8543-C125D0C956F8} + {8B4BB9C9-BD7F-4656-93A5-6053DF9B6A47} = {A5BC5781-F3E0-4BC0-8543-C125D0C956F8} + {277E84A6-CFDA-4363-B40D-000DAE66DD64} = {AA0ED03E-FF42-45D4-BD95-2E1A1805F5E5} + {5FDD6D01-8D9E-4574-9610-D678F0397359} = {FD78D588-DF0A-40FD-A573-D1AD00A9B15F} + {9E9BE3BD-EFEB-4924-A362-357ED7460722} = {5FDD6D01-8D9E-4574-9610-D678F0397359} + {51BEF736-A71C-4E7B-A8AF-6456715D63BC} = {5FDD6D01-8D9E-4574-9610-D678F0397359} + {0A35E8A2-23B3-4323-A724-8166D381C706} = {5FDD6D01-8D9E-4574-9610-D678F0397359} + {FC4B9746-9286-4DCD-BFE7-4C7750715D8E} = {5FDD6D01-8D9E-4574-9610-D678F0397359} + {510FC23E-833C-4B5A-AE5B-9DB6396FAB79} = {954C90CA-E050-4E86-AF66-585FA49C2384} + {A90F58FD-FBC7-4FB8-B3E2-4BE25A04C12A} = {D4BD6051-F87F-4CBB-A3FC-FC146FE3F2A5} + {4431548D-C268-4D6E-80D7-FB369E229E08} = {FD78D588-DF0A-40FD-A573-D1AD00A9B15F} + {53C823DD-9C94-49F2-ABB8-385BFA00DE7F} = {4431548D-C268-4D6E-80D7-FB369E229E08} + {21E396E2-CD28-4420-8DB8-7A99D244825D} = {AA0ED03E-FF42-45D4-BD95-2E1A1805F5E5} + {83C93C0F-2C64-4080-A371-E658683198AD} = {510FC23E-833C-4B5A-AE5B-9DB6396FAB79} + {A0CE3505-A5F5-4FB2-A0F5-C779F5D41EC1} = {FD78D588-DF0A-40FD-A573-D1AD00A9B15F} + {265F0AB6-C3D0-42F0-8916-545A3D94376F} = {A0CE3505-A5F5-4FB2-A0F5-C779F5D41EC1} + {61979BE5-6EEF-44CE-B89D-D38158E4F3D7} = {A0CE3505-A5F5-4FB2-A0F5-C779F5D41EC1} + {B321A3E2-C080-4791-A346-2406719B6F08} = {A0CE3505-A5F5-4FB2-A0F5-C779F5D41EC1} + {8EDD8538-371C-4539-B635-23A9873768B8} = {A0CE3505-A5F5-4FB2-A0F5-C779F5D41EC1} + {7DCB9785-0B3B-4030-9A66-5914AD611C60} = {FD78D588-DF0A-40FD-A573-D1AD00A9B15F} + {CE0017F4-8C8A-445B-A1F0-2425FB31AC46} = {7DCB9785-0B3B-4030-9A66-5914AD611C60} + {BF1F61F9-E941-4EBC-A48E-404CFF16FB86} = {7DCB9785-0B3B-4030-9A66-5914AD611C60} + {C7D9C3D8-8AB4-4500-AB3C-125CA750818D} = {7DCB9785-0B3B-4030-9A66-5914AD611C60} + {1EA19F49-655E-411D-83BD-1EB1E7D519BC} = {7DCB9785-0B3B-4030-9A66-5914AD611C60} + {0C831AB6-3E01-4614-BAF0-7DF5DEEC0DAC} = {7DCB9785-0B3B-4030-9A66-5914AD611C60} + EndGlobalSection +EndGlobal diff --git a/muhammed-task/source/global.json b/muhammed-task/source/global.json new file mode 100644 index 0000000..394a392 --- /dev/null +++ b/muhammed-task/source/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "9.0.0", + "rollForward": "latestFeature", + "allowPrerelease": false + } +} diff --git a/muhammed-task/source/requests/example.http b/muhammed-task/source/requests/example.http new file mode 100644 index 0000000..e69de29 diff --git a/muhammed-task/source/src/BuildingBlocks/BackgroundJobs/MuhammedTask.BuildingBlocks.BackgroundJobs.Coravel/DependencyInjection.cs b/muhammed-task/source/src/BuildingBlocks/BackgroundJobs/MuhammedTask.BuildingBlocks.BackgroundJobs.Coravel/DependencyInjection.cs new file mode 100644 index 0000000..8692594 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/BackgroundJobs/MuhammedTask.BuildingBlocks.BackgroundJobs.Coravel/DependencyInjection.cs @@ -0,0 +1,4 @@ +namespace MuhammedTask.BuildingBlocks.BackgroundJobs.Coravel; +public static class DependencyInjection +{ +} diff --git a/muhammed-task/source/src/BuildingBlocks/BackgroundJobs/MuhammedTask.BuildingBlocks.BackgroundJobs.Coravel/MuhammedTask.BuildingBlocks.BackgroundJobs.Coravel.csproj b/muhammed-task/source/src/BuildingBlocks/BackgroundJobs/MuhammedTask.BuildingBlocks.BackgroundJobs.Coravel/MuhammedTask.BuildingBlocks.BackgroundJobs.Coravel.csproj new file mode 100644 index 0000000..347215a --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/BackgroundJobs/MuhammedTask.BuildingBlocks.BackgroundJobs.Coravel/MuhammedTask.BuildingBlocks.BackgroundJobs.Coravel.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/muhammed-task/source/src/BuildingBlocks/BackgroundJobs/MuhammedTask.BuildingBlocks.BackgroundJobs.Quartz/DependencyInjection.cs b/muhammed-task/source/src/BuildingBlocks/BackgroundJobs/MuhammedTask.BuildingBlocks.BackgroundJobs.Quartz/DependencyInjection.cs new file mode 100644 index 0000000..8c30546 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/BackgroundJobs/MuhammedTask.BuildingBlocks.BackgroundJobs.Quartz/DependencyInjection.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.DependencyInjection; +using Quartz; + +namespace MuhammedTask.BuildingBlocks.BackgroundJobs.Quartz; +public static class DependencyInjection +{ + public static IServiceCollection AddQuartzWithHostedService( + this IServiceCollection services, + Action? configurator = null, + Action? options = null) + { + services.AddQuartz(configurator); + + services.AddQuartzHostedService(configure => + { + configure.WaitForJobsToComplete = true; + options?.Invoke(configure); + }); + return services; + } +} diff --git a/muhammed-task/source/src/BuildingBlocks/BackgroundJobs/MuhammedTask.BuildingBlocks.BackgroundJobs.Quartz/MuhammedTask.BuildingBlocks.BackgroundJobs.Quartz.csproj b/muhammed-task/source/src/BuildingBlocks/BackgroundJobs/MuhammedTask.BuildingBlocks.BackgroundJobs.Quartz/MuhammedTask.BuildingBlocks.BackgroundJobs.Quartz.csproj new file mode 100644 index 0000000..ba89a69 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/BackgroundJobs/MuhammedTask.BuildingBlocks.BackgroundJobs.Quartz/MuhammedTask.BuildingBlocks.BackgroundJobs.Quartz.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Base/ICacheService.cs b/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Base/ICacheService.cs new file mode 100644 index 0000000..299bcd4 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Base/ICacheService.cs @@ -0,0 +1,30 @@ +using CSharpEssentials; + +namespace MuhammedTask.BuildingBlocks.Caching.Base; +public interface ICacheService +{ + bool IsAvailable { get; } + bool TryGet(string key, out Maybe value); + Maybe Get(string key); + T GetOrCreate(string key, Func valueFactory, TimeSpan? expiration, params string[] tags); + Task GetOrCreateAsync(string key, Func> valueFactory, TimeSpan? expiration, params string[] tags); + Task GetKeysByPatternAsync(string pattern); + + T Set(string key, T value, TimeSpan? expiration, params string[] tags); + + void Remove(string key); + void RemoveKeys(string[] keys); + Task RemoveByPatternAsync(string pattern); + + void InvalidateTag(string tag); + void InvalidateTags(params string[] tags); + + Task InvalidateTagAsync(string tag); + Task InvalidateTagsAsync(params string[] tags); + + bool Lock(string key, TimeSpan expiration); + Task LockAsync(string key, TimeSpan expiration); + + bool Unlock(string key); + Task UnlockAsync(string key); +} diff --git a/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Base/MuhammedTask.BuildingBlocks.Caching.Base.csproj b/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Base/MuhammedTask.BuildingBlocks.Caching.Base.csproj new file mode 100644 index 0000000..2f3f890 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Base/MuhammedTask.BuildingBlocks.Caching.Base.csproj @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Redis/CacheOptions.cs b/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Redis/CacheOptions.cs new file mode 100644 index 0000000..f8e3fd3 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Redis/CacheOptions.cs @@ -0,0 +1,2 @@ +namespace MuhammedTask.BuildingBlocks.Caching.Redis; +internal sealed record CacheOptions(string ServiceName); diff --git a/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Redis/CacheService.cs b/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Redis/CacheService.cs new file mode 100644 index 0000000..b8e2345 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Redis/CacheService.cs @@ -0,0 +1,69 @@ +using CSharpEssentials; +using MuhammedTask.BuildingBlocks.Caching.Base; +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.BuildingBlocks.Caching.Redis; +internal sealed class CacheService( + [FromKeyedServices(CacheServiceType.Redis)] ICacheService redisCacheService, + [FromKeyedServices(CacheServiceType.Memory)] ICacheService memoryCacheService) : ICacheService +{ + public bool IsAvailable => redisCacheService.IsAvailable || memoryCacheService.IsAvailable; + + private ICacheService GetAvailableService() + { + if (redisCacheService.IsAvailable) + return redisCacheService; + + return memoryCacheService; + } + + public Maybe Get(string key) => + GetAvailableService().Get(key); + + public Task GetKeysByPatternAsync(string pattern) => + GetAvailableService().GetKeysByPatternAsync(pattern); + public T GetOrCreate(string key, Func valueFactory, TimeSpan? expiration, params string[] tags) => + GetAvailableService().GetOrCreate(key, valueFactory, expiration, tags); + + public Task GetOrCreateAsync(string key, Func> valueFactory, TimeSpan? expiration, params string[] tags) => + GetAvailableService().GetOrCreateAsync(key, valueFactory, expiration, tags); + + public void InvalidateTag(string tag) => + GetAvailableService().InvalidateTag(tag); + + public Task InvalidateTagAsync(string tag) => + GetAvailableService().InvalidateTagAsync(tag); + + public void InvalidateTags(params string[] tags) => + GetAvailableService().InvalidateTags(tags); + + public Task InvalidateTagsAsync(params string[] tags) => + GetAvailableService().InvalidateTagsAsync(tags); + + public bool Lock(string key, TimeSpan expiration) => + GetAvailableService().Lock(key, expiration); + + public Task LockAsync(string key, TimeSpan expiration) => + GetAvailableService().LockAsync(key, expiration); + + public void Remove(string key) => + GetAvailableService().Remove(key); + + public Task RemoveByPatternAsync(string pattern) => + GetAvailableService().RemoveByPatternAsync(pattern); + + public void RemoveKeys(string[] keys) => + GetAvailableService().RemoveKeys(keys); + + public T Set(string key, T value, TimeSpan? expiration, params string[] tags) => + GetAvailableService().Set(key, value, expiration, tags); + + public bool TryGet(string key, out Maybe value) => + GetAvailableService().TryGet(key, out value); + + public bool Unlock(string key) => + GetAvailableService().Unlock(key); + + public Task UnlockAsync(string key) => + GetAvailableService().UnlockAsync(key); +} diff --git a/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Redis/CacheServiceType.cs b/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Redis/CacheServiceType.cs new file mode 100644 index 0000000..a872c7a --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Redis/CacheServiceType.cs @@ -0,0 +1,6 @@ +namespace MuhammedTask.BuildingBlocks.Caching.Redis; +internal enum CacheServiceType +{ + Redis, + Memory +} diff --git a/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Redis/DependencyInjection.cs b/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Redis/DependencyInjection.cs new file mode 100644 index 0000000..482c67a --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Redis/DependencyInjection.cs @@ -0,0 +1,29 @@ +using MuhammedTask.BuildingBlocks.Caching.Base; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Caching.StackExchangeRedis; +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry.Trace; + +namespace MuhammedTask.BuildingBlocks.Caching.Redis; +public static class DependencyInjection +{ + public static IServiceCollection AddCacheServices( + this IServiceCollection services, + string serviceName, + Action redisSetupAction, + Action memorySetupAction) + { + services.AddStackExchangeRedisCache(redisSetupAction); + services.AddMemoryCache(memorySetupAction); + services.AddSingleton(new CacheOptions(serviceName)); + services.AddKeyedSingleton(CacheServiceType.Redis); + services.AddKeyedSingleton(CacheServiceType.Memory); + services.AddSingleton(); + return services; + } + + public static IServiceCollection ConfigureCacheServiceTelemetry( + this IServiceCollection services) => + services + .ConfigureOpenTelemetryTracerProvider(configure => configure.AddRedisInstrumentation()); +} diff --git a/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Redis/MemoryCacheService.cs b/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Redis/MemoryCacheService.cs new file mode 100644 index 0000000..2dc469b --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Redis/MemoryCacheService.cs @@ -0,0 +1,171 @@ +using System.Collections.Concurrent; +using System.Text.RegularExpressions; +using CSharpEssentials; +using MuhammedTask.BuildingBlocks.Caching.Base; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace MuhammedTask.BuildingBlocks.Caching.Redis; +internal sealed class MemoryCacheService( + ILogger logger, + IMemoryCache memoryCache) : ICacheService +{ + private static readonly TimeSpan _defaultExpiration = TimeSpan.FromMinutes(5); + private readonly IMemoryCache _memoryCache = memoryCache; + private readonly ConcurrentDictionary> _tagsMap = []; + private readonly ConcurrentDictionary _options = []; + + public bool IsAvailable => true; + + private string ConvertPatternToRegex(string pattern) + { + return "^" + Regex.Escape(pattern) + .Replace("\\*", ".*") + .Replace("\\?", ".") + + "$"; + } + public Maybe Get(string key) => + _memoryCache.Get(key); + + public Task GetKeysByPatternAsync(string pattern) + { + string regexPattern = ConvertPatternToRegex(pattern); + var regex = new Regex(regexPattern, RegexOptions.Compiled | RegexOptions.IgnoreCase); + + string[] matchingKeys = [.. _tagsMap.Values + .SelectMany(keys => keys) + .Where(key => regex.IsMatch(key)) + .SelectMany(key => _tagsMap[key]) + .Distinct()]; + + return matchingKeys.AsTask(); + } + + public T GetOrCreate(string key, Func valueFactory, TimeSpan? expiration, params string[] tags) + { + return Get(key).Match( + some: value => value, + none: () => Set(key, valueFactory(), expiration, tags)); + } + + public Task GetOrCreateAsync(string key, Func> valueFactory, TimeSpan? expiration, params string[] tags) + { + return Get(key).Match( + some: value => value.AsTask(), + none: async () => Set(key, await valueFactory(), expiration, tags)); + } + + public void InvalidateTag(string tag) + { + if (_tagsMap.TryRemove(tag, out HashSet? keys)) + { + foreach (string key in keys) + Remove(key); + } + } + + public Task InvalidateTagAsync(string tag) + { + InvalidateTag(tag); + return Task.CompletedTask; + } + + public void InvalidateTags(params string[] tags) + { + foreach (string tag in tags) + InvalidateTag(tag); + } + + public Task InvalidateTagsAsync(params string[] tags) + { + InvalidateTags(tags); + return Task.CompletedTask; + } + + private string CreateLockKey(string key) => $"lock:{key}"; + public bool Lock(string key, TimeSpan expiration) + { + string lockKey = CreateLockKey(key); + if (TryGet(lockKey, out Maybe _)) + { + logger.LogInformation("Lock is already acquired for key {Key}", key); + return false; + } + + Set(lockKey, true, expiration); + logger.LogInformation("Lock is acquired for key {Key}", key); + return true; + } + + public Task LockAsync(string key, TimeSpan expiration) + { + return Lock(key, expiration).AsTask(); + } + + public void Remove(string key) + { + _memoryCache.Remove(key); + + foreach (HashSet keys in _tagsMap.Values) + keys.Remove(key); + } + + public async Task RemoveByPatternAsync(string pattern) + { + string[] keys = await GetKeysByPatternAsync(pattern); + RemoveKeys(keys); + } + + public void RemoveKeys(string[] keys) + { + foreach (string key in keys) + Remove(key); + } + + public T Set(string key, T value, TimeSpan? expiration, params string[] tags) + { + _memoryCache.Set(key, value, GetMemoryCacheEntryOptions(expiration)); + foreach (string tag in tags) + _tagsMap.AddOrUpdate(tag, [key], (_, keys) => + { + keys.Add(key); + return keys; + }); + return value; + } + + public bool TryGet(string key, out Maybe value) + { + bool isExist = _memoryCache.TryGetValue(key, out T? result); + value = isExist ? Maybe.From(result) : Maybe.None; + return isExist; + } + + public bool Unlock(string key) + { + string lockKey = CreateLockKey(key); + if (TryGet(lockKey, out Maybe _)) + { + Remove(lockKey); + logger.LogInformation("Lock is released for key {Key}", key); + return true; + } + + logger.LogInformation("Lock is not acquired for key {Key}", key); + return false; + } + + public Task UnlockAsync(string key) + { + return Unlock(key).AsTask(); + } + + private MemoryCacheEntryOptions GetMemoryCacheEntryOptions(TimeSpan? expiration) + { + return _options.GetOrAdd(expiration ?? _defaultExpiration, (ex) => new() + { + AbsoluteExpirationRelativeToNow = ex, + SlidingExpiration = ex / 2 + }); + } +} diff --git a/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Redis/MuhammedTask.BuildingBlocks.Caching.Redis.csproj b/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Redis/MuhammedTask.BuildingBlocks.Caching.Redis.csproj new file mode 100644 index 0000000..b5eaac3 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Redis/MuhammedTask.BuildingBlocks.Caching.Redis.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Redis/RedisCacheService.cs b/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Redis/RedisCacheService.cs new file mode 100644 index 0000000..1c27360 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Caching/MuhammedTask.BuildingBlocks.Caching.Redis/RedisCacheService.cs @@ -0,0 +1,180 @@ +using System.Collections.Concurrent; +using CSharpEssentials; +using CSharpEssentials.Json; +using MuhammedTask.BuildingBlocks.Caching.Base; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; + +namespace MuhammedTask.BuildingBlocks.Caching.Redis; +internal sealed class RedisCacheService( + ILogger logger, + IConnectionMultiplexer connectionMultiplexer, + CacheOptions cacheOptions) : ICacheService +{ + private readonly IConnectionMultiplexer _connectionMultiplexer = connectionMultiplexer; + private readonly IDatabase _database = connectionMultiplexer.GetDatabase(); + private readonly string _serviceName = cacheOptions.ServiceName ?? nameof(MuhammedTask); + + public bool IsAvailable => _connectionMultiplexer.IsConnected; + + public Maybe Get(string key) + { + RedisValue data = _database.StringGet(CreateKey(key)); + if (data.IsNullOrEmpty) + return Maybe.None; + T value = data.ToString().ConvertFromJson(); + return value; + } + + public async Task GetKeysByPatternAsync(string pattern) + { + pattern = CreateKey(pattern); + IServer[] servers = _connectionMultiplexer.GetServers(); + var allKeys = new ConcurrentDictionary(); + + IEnumerable tasks = servers.Select(async server => + { + await foreach (RedisKey key in server.KeysAsync(pattern: pattern)) + allKeys.TryAdd(key.ToString(), true); + }); + + await Task.WhenAll(tasks); + + string[] keys = [.. allKeys.Keys]; + return keys; + } + + public T GetOrCreate(string key, Func valueFactory, TimeSpan? expiration, params string[] tags) + { + return Get(key).Match( + some: value => value, + none: () => Set(key, valueFactory(), expiration, tags)); + } + + public Task GetOrCreateAsync(string key, Func> valueFactory, TimeSpan? expiration, params string[] tags) + { + return Get(key).Match( + some: value => value.AsTask(), + none: async () => Set(key, await valueFactory(), expiration, tags)); + } + + public void InvalidateTag(string tag) + { + string tagKey = CreateTagKey(tag); + RedisValue[] members = _database.SetMembers(tagKey); + RedisKey[] keys = [.. members.Select(key => (RedisKey)key.ToString())]; + _database.KeyDelete(keys); + _database.KeyDelete(tagKey); + } + + public async Task InvalidateTagAsync(string tag) + { + string tagKey = CreateTagKey(tag); + RedisValue[] members = await _database.SetMembersAsync(tagKey); + RedisKey[] keys = [.. members.Select(key => (RedisKey)key.ToString())]; + await _database.KeyDeleteAsync(keys); + await _database.KeyDeleteAsync(tagKey); + } + + public void InvalidateTags(params string[] tags) + { + foreach (string tag in tags) + InvalidateTag(tag); + } + + public Task InvalidateTagsAsync(params string[] tags) + { + return Task.WhenAll(tags.Select(InvalidateTagAsync)); + } + + public bool Lock(string key, TimeSpan expiration) + { + string lockKey = CreateLockKey(key); + bool isLocked = _database.LockTake(lockKey, lockKey, expiration); + if (isLocked) + { + logger.LogInformation("Lock is acquired for key {Key}", key); + return true; + } + + logger.LogInformation("Lock is already acquired for key {Key}", key); + return false; + } + + public async Task LockAsync(string key, TimeSpan expiration) + { + string lockKey = CreateLockKey(key); + bool isLocked = await _database.LockTakeAsync(lockKey, lockKey, expiration); + if (isLocked) + { + logger.LogInformation("Lock is acquired for key {Key}", key); + return true; + } + + logger.LogInformation("Lock is already acquired for key {Key}", key); + return false; + } + + public void Remove(string key) + { + _database.KeyDelete(CreateKey(key)); + } + + public async Task RemoveByPatternAsync(string pattern) + { + string[] keys = await GetKeysByPatternAsync(pattern); + RedisKey[] redisKeys = [.. keys.Select(key => (RedisKey)key)]; + await _database.KeyDeleteAsync(redisKeys); + } + + public void RemoveKeys(string[] keys) + { + RedisKey[] redisKeys = [.. keys.Select(key => (RedisKey)CreateKey(key))]; + _database.KeyDelete(redisKeys); + } + + public T Set(string key, T value, TimeSpan? expiration, params string[] tags) + { + key = CreateKey(key); + string json = value.ConvertToJson(); + _database.StringSet(key, json, expiration); + if (tags.Length == 0) + return value; + + foreach (string tag in tags) + { + string tagKey = CreateTagKey(tag); + _database.SetAdd(tagKey, key); + + if (expiration.HasValue) + _database.KeyExpire(tagKey, expiration); + } + + return value; + } + + public bool TryGet(string key, out Maybe value) + { + Maybe maybe = Get(key); + value = maybe; + return maybe.HasValue; + } + + public bool Unlock(string key) + { + string lockKey = CreateLockKey(key); + return _database.LockRelease(lockKey, lockKey); + } + + public Task UnlockAsync(string key) + { + string lockKey = CreateLockKey(key); + return _database.LockReleaseAsync(lockKey, lockKey); + } + + private string CreateTagKey(string tag) => CreateKey(nameof(tag), tag); + private string CreateLockKey(string key) => CreateKey(nameof(key), key); + private string CreateKey(string prefix, string key) => $"{_serviceName}:{prefix}:{key}"; + private string CreateKey(string key) => $"{_serviceName}:{key}"; + +} diff --git a/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.Base/Abstractions/IReadOnlyConnectionFactory.cs b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.Base/Abstractions/IReadOnlyConnectionFactory.cs new file mode 100644 index 0000000..56af195 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.Base/Abstractions/IReadOnlyConnectionFactory.cs @@ -0,0 +1,3 @@ +namespace MuhammedTask.BuildingBlocks.Database.Base.Abstractions; + +public interface IReadOnlyConnectionFactory : ISqlConnectionFactory; diff --git a/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.Base/Abstractions/ISqlConnectionFactory.cs b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.Base/Abstractions/ISqlConnectionFactory.cs new file mode 100644 index 0000000..3026b8a --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.Base/Abstractions/ISqlConnectionFactory.cs @@ -0,0 +1,7 @@ +using System.Data; + +namespace MuhammedTask.BuildingBlocks.Database.Base.Abstractions; +public interface ISqlConnectionFactory +{ + IDbConnection Create(); +} diff --git a/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.Base/MuhammedTask.BuildingBlocks.Database.Base.csproj b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.Base/MuhammedTask.BuildingBlocks.Database.Base.csproj new file mode 100644 index 0000000..1b70e73 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.Base/MuhammedTask.BuildingBlocks.Database.Base.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.Migrator/Extensions.cs b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.Migrator/Extensions.cs new file mode 100644 index 0000000..fc72f32 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.Migrator/Extensions.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MuhammedTask.BuildingBlocks.Database.Migrator; +public static class Extensions +{ + public static async Task MigrateAsync( + this IHost host) + where TDbContext : DbContext + { + using IServiceScope serviceScope = host.Services.CreateScope(); + await using TDbContext dbContext = serviceScope.ServiceProvider.GetRequiredService(); + await EnsureDatabaseAsync(dbContext); + await RunMigrationsAsync(dbContext); + } + + private static async Task EnsureDatabaseAsync(TDbContext dbContext) + where TDbContext : DbContext + { + IRelationalDatabaseCreator dbCreator = dbContext.GetService(); + IExecutionStrategy strategy = dbContext.Database.CreateExecutionStrategy(); + await strategy.ExecuteAsync(async () => + { + bool isExist = await dbCreator.ExistsAsync(); + if (isExist) + return; + await dbCreator.CreateAsync(); + }); + } + + private static async Task RunMigrationsAsync(TDbContext dbContext) + where TDbContext : DbContext + { + IExecutionStrategy strategy = dbContext.Database.CreateExecutionStrategy(); + await strategy.ExecuteAsync(() => dbContext.Database.MigrateAsync()); + } +} diff --git a/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.Migrator/MuhammedTask.BuildingBlocks.Database.Migrator.csproj b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.Migrator/MuhammedTask.BuildingBlocks.Database.Migrator.csproj new file mode 100644 index 0000000..9b33c1f --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.Migrator/MuhammedTask.BuildingBlocks.Database.Migrator.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/Contexts/ReadDbContextBase.cs b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/Contexts/ReadDbContextBase.cs new file mode 100644 index 0000000..0d60cfe --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/Contexts/ReadDbContextBase.cs @@ -0,0 +1,40 @@ +using CSharpEssentials.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.BuildingBlocks.Database.PostgreSQL.Contexts; +public abstract class ReadDbContextBase : BaseDbContext where TContext : DbContext +{ + protected ReadDbContextBase( + DbContextOptions options, + IServiceScopeFactory serviceScopeFactory) : base(options, serviceScopeFactory) + { + } + + public override int SaveChanges() + { + throw new InvalidOperationException("This context is read-only."); + } + + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + throw new InvalidOperationException("This context is read-only."); + } + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + base.ConfigureConventions(configurationBuilder); + configurationBuilder.ConfigureEnumConventions(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.ApplyConfigurationsFromAssembly( + typeof(TContext).Assembly, + ConfigurationsFilter); + } + + private static bool ConfigurationsFilter(Type type) => + type.FullName?.Contains("Configurations.Read") ?? false; +} diff --git a/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/Contexts/WriteDbContextBase.cs b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/Contexts/WriteDbContextBase.cs new file mode 100644 index 0000000..c5b560b --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/Contexts/WriteDbContextBase.cs @@ -0,0 +1,28 @@ +using CSharpEssentials.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.BuildingBlocks.Database.PostgreSQL.Contexts; + +public abstract class WriteDbContextBase : BaseDbContext where TContext : DbContext +{ + protected WriteDbContextBase( + DbContextOptions options, + IServiceScopeFactory serviceScopeFactory) : base(options, serviceScopeFactory) + { + } + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + base.ConfigureConventions(configurationBuilder); + configurationBuilder.ConfigureEnumConventions(); + } + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.ApplyConfigurationsFromAssembly( + typeof(TContext).Assembly, + ConfigurationsFilter); + } + private static bool ConfigurationsFilter(Type type) => + type.FullName?.Contains("Configurations.Write") ?? false; +} diff --git a/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/DependencyInjection.cs b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/DependencyInjection.cs new file mode 100644 index 0000000..2561c07 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/DependencyInjection.cs @@ -0,0 +1,142 @@ +using EntityFramework.Exceptions.PostgreSQL; +using MuhammedTask.BuildingBlocks.Application.Shared.Abstractions; +using MuhammedTask.BuildingBlocks.Database.Base.Abstractions; +using MuhammedTask.BuildingBlocks.Database.PostgreSQL.Interceptors; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using OpenTelemetry.Trace; + +namespace MuhammedTask.BuildingBlocks.Database.PostgreSQL; +public static class DependencyInjection +{ + public static IServiceCollection AddPostgresDbContext( + this IServiceCollection services, + string connectionString, + Action? options = null, + Action? configureOptions = null) + where TDbContext : DbContext + { + var postgresOptions = new PostgresDbContextOptions(); + options?.Invoke(postgresOptions); + + if (postgresOptions.QueryTrackingBehavior == QueryTrackingBehavior.NoTracking) + services.AddSingleton(_ => new SqlReadOnlyConnectionFactory(connectionString)); + else + { + services.AddScoped>(); + services.AddSingleton(_ => new SqlConnectionFactory(connectionString)); + } + + //services.AddDbContextFactory(CreateDbContextOptionsBuilder( + // services, + // connectionString, + // postgresOptions, + // configureOptions)); + + services.AddDbContext(CreateDbContextOptionsBuilder( + services, + connectionString, + postgresOptions, + configureOptions)); + return services; + } + + + public static IServiceCollection AddPooledPostgresDbContext( + this IServiceCollection services, + string connectionString, + Action? options = null, + Action? configureOptions = null) + where TDbContext : DbContext + { + var postgresOptions = new PostgresDbContextOptions() + { + PoolOptions = new PostgresDbContextOptions.PostgresPoolOptions() + }; + options?.Invoke(postgresOptions); + if (postgresOptions.PoolOptions.HasNoValue) + postgresOptions.PoolOptions = new PostgresDbContextOptions.PostgresPoolOptions(); + + if (postgresOptions.QueryTrackingBehavior == QueryTrackingBehavior.NoTracking) + services.AddSingleton(_ => new SqlReadOnlyConnectionFactory(connectionString)); + else + { + services.AddScoped>(); + services.AddSingleton(_ => new SqlConnectionFactory(connectionString)); + } + + //services.AddPooledDbContextFactory(CreateDbContextOptionsBuilder( + // services, + // connectionString, + // postgresOptions, + // configureOptions), + // postgresOptions.PoolOptions.Value.MaxPoolSize); + + services.AddDbContextPool(CreateDbContextOptionsBuilder( + services, + connectionString, + postgresOptions, + configureOptions), + postgresOptions.PoolOptions.Value.MaxPoolSize); + + return services; + } + + public static IServiceCollection ConfigurePostgresSqlTelemetry( + this IServiceCollection services) => + services.ConfigureOpenTelemetryTracerProvider(configure => + { + configure.AddEntityFrameworkCoreInstrumentation(cnf => + { + cnf.SetDbStatementForText = true; + cnf.SetDbStatementForStoredProcedure = true; + }); + configure.AddNpgsql(); + }); + + + private static Action CreateDbContextOptionsBuilder( + IServiceCollection services, + string connectionString, + PostgresDbContextOptions postgresOptions, + Action? configureOptions = null) + { + if (postgresOptions.EnableAuditableInterceptor) + services.AddSingleton(); + + if (postgresOptions.EnablePublishDomainEventsInterceptor) + services.AddSingleton(); + + return (serviceProvider, options) => + { + options + .UseNpgsql(connectionString, npgsqlOptions => + { + postgresOptions.MigrationsAssembly.Execute(migrationsAssembly => + npgsqlOptions.MigrationsAssembly(migrationsAssembly)); + + postgresOptions.RetryOptions.Execute(retryOptions => + npgsqlOptions.EnableRetryOnFailure(retryOptions.MaxRetryCount, retryOptions.MaxRetryDelay, retryOptions.AdditionalErrorCodes)); + + npgsqlOptions.UseQuerySplittingBehavior(postgresOptions.QuerySplittingBehavior); + + }) + .EnableDetailedErrors(postgresOptions.EnableDetailedErrors) + .EnableSensitiveDataLogging(postgresOptions.EnableSensitiveDataLogging) + .UseAllCheckConstraints() + .UseSnakeCaseNamingConvention() + .UseExceptionProcessor(); + + if (postgresOptions.EnableAuditableInterceptor) + options.AddInterceptors(serviceProvider.GetRequiredService()); + + if (postgresOptions.EnablePublishDomainEventsInterceptor) + options.AddInterceptors(serviceProvider.GetRequiredService()); + + options.UseQueryTrackingBehavior(postgresOptions.QueryTrackingBehavior); + + configureOptions?.Invoke(serviceProvider, options); + }; + } +} diff --git a/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/EfUnitOfWork.cs b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/EfUnitOfWork.cs new file mode 100644 index 0000000..4cf4aca --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/EfUnitOfWork.cs @@ -0,0 +1,100 @@ +using CSharpEssentials; +using CSharpEssentials.Json; +using MuhammedTask.BuildingBlocks.Application.Shared.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Logging; +using System.Data; + +namespace MuhammedTask.BuildingBlocks.Database.PostgreSQL; +public sealed class EfUnitOfWork( + TContext context, + ILogger> logger) : IUnitOfWork where TContext : DbContext +{ + public IDbTransaction BeginTransaction() + { + IDbContextTransaction transaction = context.Database.BeginTransaction(); + return transaction.GetDbTransaction(); + } + + public async Task ExecuteTransactionAsync(Func func, Func? rollbackFunc = null) + { + IExecutionStrategy strategy = context.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync(async () => + { + IDbContextTransaction transaction = await context.Database.BeginTransactionAsync(); + try + { + await func(); + await transaction.CommitAsync(); + return Result.Success(); + } + catch (Exception ex) + { + logger.LogError(ex, "Transaction Error"); + await transaction.RollbackAsync(); + + if (rollbackFunc is null) + return Error.Exception(ex); + + try + { + await rollbackFunc(); + } + catch (Exception rollbackEx) + { + logger.LogError(rollbackEx, "Rollback Error"); + return Error.Exception(rollbackEx); + } + + return Error.Exception(ex); + } + }); + } + + public async Task> ExecuteTransactionAsync(Func>> func, Func? rollbackFunc = null) + { + IExecutionStrategy strategy = context.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync(async () => + { + IDbContextTransaction transaction = await context.Database.BeginTransactionAsync(); + try + { + Result result = await func(); + if (result.IsFailure) + { + logger.LogWarning("Task result is failure. Rolling back transaction. {Result}", result.ConvertToJson()); + await transaction.RollbackAsync(); + return result; + } + await transaction.CommitAsync(); + return result; + } + catch (Exception ex) + { + logger.LogError(ex, "Transaction Error"); + await transaction.RollbackAsync(); + + if (rollbackFunc is null) + return Error.Exception(ex); + + try + { + await rollbackFunc(); + } + catch (Exception rollbackEx) + { + logger.LogError(rollbackEx, "Rollback Error"); + return Error.Exception(rollbackEx); + } + + return Error.Exception(ex); + } + }); + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => + context.SaveChangesAsync(cancellationToken); +} diff --git a/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/Interceptors/AuditableInterceptor.cs b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/Interceptors/AuditableInterceptor.cs new file mode 100644 index 0000000..af00e5a --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/Interceptors/AuditableInterceptor.cs @@ -0,0 +1,51 @@ +using CSharpEssentials; +using CSharpEssentials.Interfaces; +using CSharpEssentials.Time; +using MuhammedTask.BuildingBlocks.Application.Shared.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.BuildingBlocks.Database.PostgreSQL.Interceptors; + +internal sealed class AuditableInterceptor( + IDateTimeProvider dateTimeProvider, + IServiceScopeFactory serviceScopeFactory) : SaveChangesInterceptor +{ + public override ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + if (eventData.Context is not null) + UpdateAuditableEntities(eventData.Context); + + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private string GetUserId(IUserContext userContext) + { + if (userContext.UserId.HasValue) + return userContext.UserId.Value; + return "system"; + } + private void UpdateAuditableEntities(DbContext context) + { + IServiceProvider provider = serviceScopeFactory.CreateScope().ServiceProvider; + IUserContext userContext = provider.GetRequiredService(); + string userId = GetUserId(userContext); + DateTimeOffset now = dateTimeProvider.UtcNow; + foreach (EntityEntry entry in context.ChangeTracker.Entries()) + { + if (entry.State == EntityState.Added && entry.Entity is ICreationAudit creationAudit) + creationAudit.SetCreatedInfo(now, userId); + + if (entry.State == EntityState.Modified && entry.Entity is IModificationAudit modificationAudit) + modificationAudit.SetUpdatedInfo(now, userId); + + if (entry.State == EntityState.Deleted && entry.Entity is ISoftDeletable deletionAudit && deletionAudit.IsHardDeleted.IsFalse()) + { + deletionAudit.MarkAsDeleted(now, userId); + entry.State = EntityState.Modified; + } + } + } +} diff --git a/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/Interceptors/PublishDomainEventsInterceptor.cs b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/Interceptors/PublishDomainEventsInterceptor.cs new file mode 100644 index 0000000..9a656b8 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/Interceptors/PublishDomainEventsInterceptor.cs @@ -0,0 +1,51 @@ +using CSharpEssentials; +using CSharpEssentials.Interfaces; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace MuhammedTask.BuildingBlocks.Database.PostgreSQL.Interceptors; + +internal sealed class PublishDomainEventsInterceptor( + ILogger logger, + IServiceScopeFactory serviceScopeFactory) : SaveChangesInterceptor +{ + + public override async ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + IDomainEvent[] domainEvents = eventData.Context.IsNotNull() ? GetEvents(eventData.Context) : []; + InterceptionResult returnValue = await base.SavingChangesAsync(eventData, result, cancellationToken); + await PublishDomainEventsAsync(domainEvents, cancellationToken); + return returnValue; + } + + private IDomainEvent[] GetEvents(DbContext context) + { + return [.. context + .ChangeTracker + .Entries() + .Select(entry => entry.Entity) + .SelectMany(entity => + { + IReadOnlyList domainEvents = entity.DomainEvents; + entity.ClearDomainEvents(); + return domainEvents; + })]; + } + + private async Task PublishDomainEventsAsync(IDomainEvent[] domainEvents, CancellationToken cancellationToken) + { + IServiceProvider serviceProvider = serviceScopeFactory.CreateScope().ServiceProvider; + IPublisher publisher = serviceProvider.GetRequiredService(); + logger.LogInformation("Publishing {Count} domain events", domainEvents.Length); + + foreach (IDomainEvent domainEvent in domainEvents) + { + await publisher.Publish(domainEvent, cancellationToken); + } + + logger.LogInformation("Published {Count} domain events", domainEvents.Length); + } +} diff --git a/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/MuhammedTask.BuildingBlocks.Database.PostgreSQL.csproj b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/MuhammedTask.BuildingBlocks.Database.PostgreSQL.csproj new file mode 100644 index 0000000..2411057 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/MuhammedTask.BuildingBlocks.Database.PostgreSQL.csproj @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/PostgresDbContextOptions.cs b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/PostgresDbContextOptions.cs new file mode 100644 index 0000000..fb383cd --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/PostgresDbContextOptions.cs @@ -0,0 +1,31 @@ + +using CSharpEssentials; +using Microsoft.EntityFrameworkCore; + +namespace MuhammedTask.BuildingBlocks.Database.PostgreSQL; + +public sealed class PostgresDbContextOptions +{ + public QuerySplittingBehavior QuerySplittingBehavior { get; set; } = QuerySplittingBehavior.SplitQuery; + public QueryTrackingBehavior QueryTrackingBehavior { get; set; } = QueryTrackingBehavior.TrackAll; + public bool EnableDetailedErrors { get; set; } = true; + public bool EnableSensitiveDataLogging { get; set; } = true; + public bool EnableAuditableInterceptor { get; set; } = true; + public bool EnablePublishDomainEventsInterceptor { get; set; } = true; + + public Maybe PoolOptions { get; set; } + public Maybe RetryOptions { get; set; } = new PostgresRetryOptions(); + public Maybe MigrationsAssembly { get; set; } + + public sealed class PostgresRetryOptions + { + public int MaxRetryCount { get; set; } = 5; + public TimeSpan MaxRetryDelay { get; set; } = TimeSpan.FromSeconds(30); + public ICollection? AdditionalErrorCodes { get; set; } + } + + public sealed class PostgresPoolOptions + { + public int MaxPoolSize { get; set; } = 1024; + } +} diff --git a/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/SqlConnectionFactory.cs b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/SqlConnectionFactory.cs new file mode 100644 index 0000000..0a9c015 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/SqlConnectionFactory.cs @@ -0,0 +1,10 @@ +using System.Data; +using MuhammedTask.BuildingBlocks.Database.Base.Abstractions; +using Npgsql; + +namespace MuhammedTask.BuildingBlocks.Database.PostgreSQL; +internal sealed class SqlConnectionFactory + (string connectionString) : ISqlConnectionFactory +{ + public IDbConnection Create() => new NpgsqlConnection(connectionString); +} diff --git a/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/SqlReadOnlyConnectionFactory.cs b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/SqlReadOnlyConnectionFactory.cs new file mode 100644 index 0000000..e24e93f --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Database/MuhammedTask.BuildingBlocks.Database.PostgreSQL/SqlReadOnlyConnectionFactory.cs @@ -0,0 +1,15 @@ +using System.Data; +using MuhammedTask.BuildingBlocks.Database.Base.Abstractions; +using Npgsql; + +namespace MuhammedTask.BuildingBlocks.Database.PostgreSQL; + +/// +/// Factory for creating read-only connections to the database. +/// +/// +internal sealed class SqlReadOnlyConnectionFactory + (string connectionString) : IReadOnlyConnectionFactory +{ + public IDbConnection Create() => new NpgsqlConnection(connectionString); +} diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application.Shared/Abstractions/ISessionContext.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application.Shared/Abstractions/ISessionContext.cs new file mode 100644 index 0000000..b619fb1 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application.Shared/Abstractions/ISessionContext.cs @@ -0,0 +1,17 @@ +using CSharpEssentials; + +namespace MuhammedTask.BuildingBlocks.Application.Shared.Abstractions; + +public interface IUserContext +{ + bool IsAuthenticated { get; } + Maybe UserId { get; } + Maybe AccessToken { get; } +} + +public interface ITenantContext +{ + Maybe TenantId { get; } +} + +public interface ISessionContext : IUserContext, ITenantContext; diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application.Shared/Abstractions/IUnitOfWork.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application.Shared/Abstractions/IUnitOfWork.cs new file mode 100644 index 0000000..0b7e4df --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application.Shared/Abstractions/IUnitOfWork.cs @@ -0,0 +1,15 @@ +using System.Data; +using CSharpEssentials; + +namespace MuhammedTask.BuildingBlocks.Application.Shared.Abstractions; +public interface IUnitOfWork +{ + IDbTransaction BeginTransaction(); + Task ExecuteTransactionAsync( + Func func, + Func? rollbackFunc = null); + Task> ExecuteTransactionAsync( + Func>> func, + Func? rollbackFunc = null); + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application.Shared/Constants/CustomHeaderNames.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application.Shared/Constants/CustomHeaderNames.cs new file mode 100644 index 0000000..a4a5559 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application.Shared/Constants/CustomHeaderNames.cs @@ -0,0 +1,11 @@ +namespace MuhammedTask.BuildingBlocks.Application.Shared.Constants; + +public static class CustomHeaderNames +{ + public const string TenantId = "X-Tenant-Id"; +} + +public static class CustomClaimTypes +{ + public const string TenantId = "tenant_id"; +} \ No newline at end of file diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application.Shared/MuhammedTask.BuildingBlocks.Application.Shared.csproj b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application.Shared/MuhammedTask.BuildingBlocks.Application.Shared.csproj new file mode 100644 index 0000000..ecb7d42 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application.Shared/MuhammedTask.BuildingBlocks.Application.Shared.csproj @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/ICacheable.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/ICacheable.cs new file mode 100644 index 0000000..84dba55 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/ICacheable.cs @@ -0,0 +1,10 @@ +namespace MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; + +public interface ICacheable +{ + bool BypassCache { get; } + bool CacheFailures { get; } + string CacheKey { get; } + TimeSpan Expiration { get; } + string[] Tags { get; } +} diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/ICachedQuery.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/ICachedQuery.cs new file mode 100644 index 0000000..5f90d8b --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/ICachedQuery.cs @@ -0,0 +1,3 @@ +namespace MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; + +public interface ICachedQuery : IQuery, ICacheable; diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/ICachedQueryHandler.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/ICachedQueryHandler.cs new file mode 100644 index 0000000..f629852 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/ICachedQueryHandler.cs @@ -0,0 +1,3 @@ +namespace MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; + +public interface ICachedQueryHandler : IQueryHandler where TQuery : ICachedQuery; diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/ICommand.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/ICommand.cs new file mode 100644 index 0000000..163c020 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/ICommand.cs @@ -0,0 +1,8 @@ +using CSharpEssentials; +using MediatR; + +namespace MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; + +public interface ICommand : IRequest; + +public interface ICommand : IRequest>; diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/ICommandHandler.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/ICommandHandler.cs new file mode 100644 index 0000000..8637580 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/ICommandHandler.cs @@ -0,0 +1,8 @@ +using CSharpEssentials; +using MediatR; + +namespace MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; + +public interface ICommandHandler : IRequestHandler where TCommand : ICommand; + +public interface ICommandHandler : IRequestHandler> where TCommand : ICommand; diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/ILoggableRequest.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/ILoggableRequest.cs new file mode 100644 index 0000000..5adee5f --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/ILoggableRequest.cs @@ -0,0 +1,6 @@ +namespace MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +public interface ILoggableRequest; +public interface IRequestLoggable : ILoggableRequest; +public interface IResponseLoggable : ILoggableRequest; +public interface IRequestResponseLoggable : IRequestLoggable, IResponseLoggable; + diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/IQuery.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/IQuery.cs new file mode 100644 index 0000000..bae7ee0 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/IQuery.cs @@ -0,0 +1,7 @@ +using CSharpEssentials; +using MediatR; + +namespace MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; + +public interface IQuery : IRequest; +public interface IQuery : IRequest>; diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/IQueryHandler.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/IQueryHandler.cs new file mode 100644 index 0000000..d078021 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/IQueryHandler.cs @@ -0,0 +1,8 @@ +using CSharpEssentials; +using MediatR; + +namespace MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; + +public interface IQueryHandler : IRequestHandler where TQuery : IQuery; + +public interface IQueryHandler : IRequestHandler> where TQuery : IQuery; diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/ITransactionalRequest.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/ITransactionalRequest.cs new file mode 100644 index 0000000..69e8b76 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/Abstractions/Contracts/ITransactionalRequest.cs @@ -0,0 +1,3 @@ +namespace MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +public interface ITransactionalRequest; + diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/DependencyInjections/DateTimeProviderDependencyInjection.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/DependencyInjections/DateTimeProviderDependencyInjection.cs new file mode 100644 index 0000000..445a082 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/DependencyInjections/DateTimeProviderDependencyInjection.cs @@ -0,0 +1,11 @@ +using CSharpEssentials.Time; +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.BuildingBlocks.Application.DependencyInjections; +public static class DateTimeProviderDependencyInjection +{ + public static IServiceCollection AddDateTimeProvider( + this IServiceCollection services) => services + .AddSingleton(TimeProvider.System) + .AddSingleton(); +} diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/DependencyInjections/PipelineBehaviorIDbContextTransaction.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/DependencyInjections/PipelineBehaviorIDbContextTransaction.cs new file mode 100644 index 0000000..490e11f --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/DependencyInjections/PipelineBehaviorIDbContextTransaction.cs @@ -0,0 +1,46 @@ +using MuhammedTask.BuildingBlocks.Application.PipelineBehaviors; +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.BuildingBlocks.Application.DependencyInjections; +public static class PipelineBehaviorIDbContextTransaction +{ + public static MediatRServiceConfiguration AddCachingBehavior(this MediatRServiceConfiguration configuration) => + configuration.AddOpenBehavior(typeof(CachingBehavior<,>)); + public static MediatRServiceConfiguration AddTransactionBehavior(this MediatRServiceConfiguration configuration) => + configuration.AddOpenBehavior(typeof(TransactionScopeBehavior<,>)); + public static MediatRServiceConfiguration AddLoggingBehavior(this MediatRServiceConfiguration configuration) => + configuration.AddOpenBehavior(typeof(LoggingBehavior<,>)); + public static MediatRServiceConfiguration AddValidationBehavior(this MediatRServiceConfiguration configuration) => + configuration.AddOpenBehavior(typeof(ValidationBehavior<,>)); + public static MediatRServiceConfiguration AddDefaultBehaviors(this MediatRServiceConfiguration configuration) => + configuration + .AddLoggingBehavior() + .AddValidationBehavior() + .AddCachingBehavior() + .AddTransactionBehavior(); + + public static MediatRServiceConfiguration AddBehaviors(this MediatRServiceConfiguration configuration, IEnumerable pipelines) + { + foreach (PipelineBehaviorType pipeline in pipelines) + { + switch (pipeline) + { + case PipelineBehaviorType.Logging: + configuration.AddLoggingBehavior(); + break; + case PipelineBehaviorType.Validation: + configuration.AddValidationBehavior(); + break; + case PipelineBehaviorType.Caching: + configuration.AddCachingBehavior(); + break; + case PipelineBehaviorType.Transaction: + configuration.AddTransactionBehavior(); + break; + default: + throw new NotImplementedException(); + } + } + return configuration; + } +} diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/MuhammedTask.BuildingBlocks.Application.csproj b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/MuhammedTask.BuildingBlocks.Application.csproj new file mode 100644 index 0000000..8af60c7 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/MuhammedTask.BuildingBlocks.Application.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/PipelineBehaviors/CachingBehavior.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/PipelineBehaviors/CachingBehavior.cs new file mode 100644 index 0000000..92f9f6f --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/PipelineBehaviors/CachingBehavior.cs @@ -0,0 +1,32 @@ +using CSharpEssentials; +using CSharpEssentials.Interfaces; +using MediatR; +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.BuildingBlocks.Caching.Base; +using Microsoft.Extensions.Logging; + +namespace MuhammedTask.BuildingBlocks.Application.PipelineBehaviors; +public sealed class CachingBehavior + (ILogger> logger, + ICacheService cacheService) : IPipelineBehavior + where TRequest : ICacheable +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (request.BypassCache) + return await next(); + if (cacheService.TryGet(request.CacheKey, out Maybe response)) + { + logger.LogInformation("Cache hit for key {CacheKey}", request.CacheKey); + return response.Value; + } + + logger.LogInformation("Cache miss for key {CacheKey}", request.CacheKey); + TResponse result = await next(); + if (request.CacheFailures.IsFalse() && result is IResultBase r && r.IsFailure) + return result; + cacheService.Set(request.CacheKey, result, request.Expiration, request.Tags); + logger.LogInformation("Cache set for key {CacheKey}", request.CacheKey); + return result; + } +} diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/PipelineBehaviors/LoggingBegaviors.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/PipelineBehaviors/LoggingBegaviors.cs new file mode 100644 index 0000000..01cf91a --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/PipelineBehaviors/LoggingBegaviors.cs @@ -0,0 +1,46 @@ +using System.Diagnostics; +using CSharpEssentials.Json; +using MediatR; +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using Microsoft.Extensions.Logging; + +namespace MuhammedTask.BuildingBlocks.Application.PipelineBehaviors; +public sealed class LoggingBehavior( + ILogger> logger) : IPipelineBehavior + where TRequest : ILoggableRequest +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + string requestName = typeof(TRequest).Name; + string responseName = typeof(TResponse).Name; + long startTime = Stopwatch.GetTimestamp(); + LogRequest(requestName, request); + TResponse response = await next(); + LogResponse(responseName, response); + TimeSpan elapsedTime = Stopwatch.GetElapsedTime(startTime); + logger.LogInformation("Request {RequestName} took {ElapsedTime}", requestName, elapsedTime); + return response; + } + + private void LogRequest(string requestName, TRequest request) + { + if (request is not IRequestLoggable) + { + logger.LogInformation("Handling {RequestName} request", requestName); + return; + } + string requestJson = request.ConvertToJson(); + logger.LogInformation("Handling {RequestName} request: {Request}", requestName, requestJson); + } + + private void LogResponse(string responseName, TResponse response) + { + if (response is not IResponseLoggable) + { + logger.LogInformation("Handled {ResponseName} response", responseName); + return; + } + string responseJson = response.ConvertToJson(); + logger.LogInformation("Handled {ResponseName} response: {Response}", responseName, responseJson); + } +} diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/PipelineBehaviors/PipelineBehaviorOptions.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/PipelineBehaviors/PipelineBehaviorOptions.cs new file mode 100644 index 0000000..6a97aad --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/PipelineBehaviors/PipelineBehaviorOptions.cs @@ -0,0 +1,8 @@ +namespace MuhammedTask.BuildingBlocks.Application.PipelineBehaviors; +public enum PipelineBehaviorType +{ + Logging, + Validation, + Caching, + Transaction, +} diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/PipelineBehaviors/TransactionScopeBehavior.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/PipelineBehaviors/TransactionScopeBehavior.cs new file mode 100644 index 0000000..5f9a2e4 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/PipelineBehaviors/TransactionScopeBehavior.cs @@ -0,0 +1,16 @@ +using MediatR; +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using System.Transactions; + +namespace MuhammedTask.BuildingBlocks.Application.PipelineBehaviors; +public sealed class TransactionScopeBehavior : IPipelineBehavior + where TRequest : ITransactionalRequest +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + using TransactionScope transactionScope = new(TransactionScopeAsyncFlowOption.Enabled); + TResponse response = await next(); + transactionScope.Complete(); + return response; + } +} diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/PipelineBehaviors/ValidationBehavior.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/PipelineBehaviors/ValidationBehavior.cs new file mode 100644 index 0000000..4030fbc --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Application/MuhammedTask.BuildingBlocks.Application/PipelineBehaviors/ValidationBehavior.cs @@ -0,0 +1,55 @@ +using System.Reflection; + +using CSharpEssentials; +using CSharpEssentials.Exceptions; +using FluentValidation; + +using MediatR; + +namespace MuhammedTask.BuildingBlocks.Application.PipelineBehaviors; +public sealed class ValidationBehavior(IEnumerable> validators) : IPipelineBehavior + where TRequest : IRequest +{ + private static readonly Type resultType = typeof(Result); + private static readonly Type genericResultType = typeof(Result<>); + private readonly Type responseType = typeof(TResponse); + + private readonly IValidator[] validatorArray = [.. validators]; + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (validatorArray.Length == 0) + return await next(); + + var context = new ValidationContext(request); + FluentValidation.Results.ValidationResult[] validationFailures = await Task.WhenAll( + validatorArray.Select(validator => validator.ValidateAsync(context, cancellationToken))); + Error[] errors = [.. validationFailures + .Where(validationResult => !validationResult.IsValid) + .SelectMany(validationResult => validationResult.Errors) + .Select(CreateErrorFromValidationFailure) + .Distinct()]; + + if (errors.Length == 0) + return await next(); + + if (responseType == resultType) + return (TResponse)(object)Result.Failure(errors); + + if (responseType == genericResultType) + return CreateResponse(errors); + + throw new EnhancedValidationException(errors); + } + private TResponse CreateResponse(Error[] errors) + { + Type genericType = genericResultType.MakeGenericType(responseType.GenericTypeArguments[0]); + MethodInfo factoryMethod = genericType.GetMethod(nameof(Result.Failure))!; + object result = factoryMethod.Invoke(null, [errors]); + return (TResponse)result; + } + + private static Error CreateErrorFromValidationFailure(FluentValidation.Results.ValidationFailure validationResult) => Error.Validation( + code: validationResult.ErrorCode, + description: validationResult.ErrorMessage, + metadata: new ErrorMetadata(nameof(validationResult.PropertyName), validationResult.PropertyName)); +} diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Domain/MuhammedTask.BuildingBlocks.Domain/MuhammedTask.BuildingBlocks.Domain.csproj b/muhammed-task/source/src/BuildingBlocks/Layers/Domain/MuhammedTask.BuildingBlocks.Domain/MuhammedTask.BuildingBlocks.Domain.csproj new file mode 100644 index 0000000..5d3929d --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Domain/MuhammedTask.BuildingBlocks.Domain/MuhammedTask.BuildingBlocks.Domain.csproj @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Infrastructure/MuhammedTask.BuildingBlocks.Persistence/Extensions/HttpClientBuilderExtensionMethods.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Infrastructure/MuhammedTask.BuildingBlocks.Persistence/Extensions/HttpClientBuilderExtensionMethods.cs new file mode 100644 index 0000000..9737969 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Infrastructure/MuhammedTask.BuildingBlocks.Persistence/Extensions/HttpClientBuilderExtensionMethods.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.BuildingBlocks.Persistence.Extensions; + +public static class HttpClientBuilderExtensionMethods +{ + public static IHttpClientBuilder ConfigureHttpClientBaseUrl(this IHttpClientBuilder builder, string baseUrl) => + builder.ConfigureHttpClientBaseUrl(new Uri(baseUrl)); + + public static IHttpClientBuilder ConfigureHttpClientWithServiceName(this IHttpClientBuilder builder, string serviceName) => + builder.ConfigureHttpClientBaseUrl(new Uri($"https+http://{serviceName}")); + public static IHttpClientBuilder ConfigureHttpClientBaseUrl(this IHttpClientBuilder builder, Uri baseUrl) => + builder.ConfigureHttpClient(client => client.BaseAddress = baseUrl); +} diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Infrastructure/MuhammedTask.BuildingBlocks.Persistence/Extensions/HttpClientExtensionMethods.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Infrastructure/MuhammedTask.BuildingBlocks.Persistence/Extensions/HttpClientExtensionMethods.cs new file mode 100644 index 0000000..4572c22 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Infrastructure/MuhammedTask.BuildingBlocks.Persistence/Extensions/HttpClientExtensionMethods.cs @@ -0,0 +1,41 @@ +namespace MuhammedTask.BuildingBlocks.Persistence.Extensions; + +public static class HttpClientExtensionMethods +{ + public static async Task SendFallowingAsync(this HttpClient client, HttpRequestMessage request, + int maxRedirections = 5, + CancellationToken cancellationToken = default) + { + while (maxRedirections-- > 0) + { + HttpResponseMessage response = await client.SendAsync(request, cancellationToken); + if ((int)response.StatusCode / 100 != 3) + return response; + Uri? redirectUrl = response.Headers.Location; + HttpRequestMessage newRequest = await CloneHttpRequestMessage(request); + newRequest.RequestUri = redirectUrl; + request = newRequest; + } + + throw new RedirectionLimitExceededException($"Too many redirects to follow url: {request.RequestUri}"); + } + + private static async Task CloneHttpRequestMessage(HttpRequestMessage request) + { + var clone = new HttpRequestMessage(request.Method, request.RequestUri); + + foreach (KeyValuePair> header in request.Headers) + clone.Headers.TryAddWithoutValidation(header.Key, header.Value); + + clone.Version = request.Version; + + if (request.Content == null) + return clone; + clone.Content = new StreamContent(await request.Content.ReadAsStreamAsync().ConfigureAwait(false)); + foreach (KeyValuePair> contentHeader in request.Content.Headers) + clone.Content.Headers.TryAddWithoutValidation(contentHeader.Key, contentHeader.Value); + + return clone; + } +} +public class RedirectionLimitExceededException(string message) : Exception(message); diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Infrastructure/MuhammedTask.BuildingBlocks.Persistence/HttpHandlers/AuthHeaderHandler.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Infrastructure/MuhammedTask.BuildingBlocks.Persistence/HttpHandlers/AuthHeaderHandler.cs new file mode 100644 index 0000000..c2eb7f4 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Infrastructure/MuhammedTask.BuildingBlocks.Persistence/HttpHandlers/AuthHeaderHandler.cs @@ -0,0 +1,20 @@ +using System.Net.Http.Headers; +using CSharpEssentials; +using MuhammedTask.BuildingBlocks.Application.Shared.Abstractions; +using MuhammedTask.BuildingBlocks.Application.Shared.Constants; + +namespace MuhammedTask.BuildingBlocks.Persistence.HttpHandlers; +public sealed class AuthHeaderHandler( + ISessionContext sessionContext) : DelegatingHandler +{ + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Maybe accessToken = sessionContext.AccessToken; + Maybe tenantId = sessionContext.TenantId; + + accessToken.Execute(token => request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token)); + tenantId.Execute(tenantId => request.Headers.Add(CustomHeaderNames.TenantId, tenantId)); + + return base.SendAsync(request, cancellationToken); + } +} diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Infrastructure/MuhammedTask.BuildingBlocks.Persistence/MuhammedTask.BuildingBlocks.Persistence.csproj b/muhammed-task/source/src/BuildingBlocks/Layers/Infrastructure/MuhammedTask.BuildingBlocks.Persistence/MuhammedTask.BuildingBlocks.Persistence.csproj new file mode 100644 index 0000000..a94ced4 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Infrastructure/MuhammedTask.BuildingBlocks.Persistence/MuhammedTask.BuildingBlocks.Persistence.csproj @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Presentation/MuhammedTask.BuildingBlocks.Presentation/Authentication/DependencyInjection.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Presentation/MuhammedTask.BuildingBlocks.Presentation/Authentication/DependencyInjection.cs new file mode 100644 index 0000000..7f6d76e --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Presentation/MuhammedTask.BuildingBlocks.Presentation/Authentication/DependencyInjection.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.BuildingBlocks.Presentation.Authentication; +public static class DependencyInjection +{ + public static IServiceCollection AddKeycloakJwtBearer( + this IServiceCollection services, + string keycloakServiceId = "keycloak", + string realm = "MuhammedTask") + { + services + .AddAuthorization() + .AddAuthentication() + .AddKeycloakJwtBearer(keycloakServiceId, realm, options => + { + options.RequireHttpsMetadata = false; + options.Audience = "account"; + }); + + return services; + } +} diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Presentation/MuhammedTask.BuildingBlocks.Presentation/Cors/DependencyInjection.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Presentation/MuhammedTask.BuildingBlocks.Presentation/Cors/DependencyInjection.cs new file mode 100644 index 0000000..e777080 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Presentation/MuhammedTask.BuildingBlocks.Presentation/Cors/DependencyInjection.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.BuildingBlocks.Presentation.Cors; +public static class DependencyInjection +{ + public static IServiceCollection AddAllAcceptCors(this IServiceCollection services, string policyName = "all") + { + return services.AddCors(options => + options.AddPolicy(policyName, builder => + builder.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader() + ) + ); + } + +} diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Presentation/MuhammedTask.BuildingBlocks.Presentation/Endpoints/EndpointExtensions.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Presentation/MuhammedTask.BuildingBlocks.Presentation/Endpoints/EndpointExtensions.cs new file mode 100644 index 0000000..bf03fc4 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Presentation/MuhammedTask.BuildingBlocks.Presentation/Endpoints/EndpointExtensions.cs @@ -0,0 +1,29 @@ +using Asp.Versioning.Builder; +using Asp.Versioning; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +namespace MuhammedTask.BuildingBlocks.Presentation.Endpoints; +public static class EndpointExtensions +{ + public static ApiVersionSet CreateVersionSet(this IEndpointRouteBuilder app, int version = 1) + { + return app.NewApiVersionSet() + .HasApiVersion(new ApiVersion(version)) + .ReportApiVersions() + .Build(); + } + public static RouteGroupBuilder CreateVersionedGroup(this IEndpointRouteBuilder app, string route, int version = 1) + { + ApiVersionSet apiVersionSet = app.CreateVersionSet(version); + + RouteGroupBuilder versionedGroup = app + .MapGroup($"v{{version:apiVersion}}/{route}") + .WithApiVersionSet(apiVersionSet) + .WithTags(route); + + return versionedGroup; + + } +} diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Presentation/MuhammedTask.BuildingBlocks.Presentation/HealthChecks/DependencyInjection.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Presentation/MuhammedTask.BuildingBlocks.Presentation/HealthChecks/DependencyInjection.cs new file mode 100644 index 0000000..038e513 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Presentation/MuhammedTask.BuildingBlocks.Presentation/HealthChecks/DependencyInjection.cs @@ -0,0 +1,29 @@ +using HealthChecks.UI.Client; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.BuildingBlocks.Presentation.HealthChecks; + +public static class DependencyInjection +{ + public static IServiceCollection ConfigureHttpClients( + this IServiceCollection services) + { + return services + .AddHttpClient() + .AddServiceDiscovery() + .ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + } + + public static IApplicationBuilder UseDefaultHealthChecks(this IApplicationBuilder app, string path = "/health") => + app.UseHealthChecks(path, new HealthCheckOptions() + { + Predicate = _ => true, + ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse + }); +} diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Presentation/MuhammedTask.BuildingBlocks.Presentation/MuhammedTask.BuildingBlocks.Presentation.csproj b/muhammed-task/source/src/BuildingBlocks/Layers/Presentation/MuhammedTask.BuildingBlocks.Presentation/MuhammedTask.BuildingBlocks.Presentation.csproj new file mode 100644 index 0000000..ffed57f --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Presentation/MuhammedTask.BuildingBlocks.Presentation/MuhammedTask.BuildingBlocks.Presentation.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Presentation/MuhammedTask.BuildingBlocks.Presentation/SessionContexts/DefaultSessionContext.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Presentation/MuhammedTask.BuildingBlocks.Presentation/SessionContexts/DefaultSessionContext.cs new file mode 100644 index 0000000..d9cae9c --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Presentation/MuhammedTask.BuildingBlocks.Presentation/SessionContexts/DefaultSessionContext.cs @@ -0,0 +1,5 @@ +using Microsoft.AspNetCore.Http; + +namespace MuhammedTask.BuildingBlocks.Presentation.SessionContexts; + +public sealed class DefaultSessionContext(IHttpContextAccessor httpContextAccessor) : SessionContext(httpContextAccessor); diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Presentation/MuhammedTask.BuildingBlocks.Presentation/SessionContexts/SessionContext.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Presentation/MuhammedTask.BuildingBlocks.Presentation/SessionContexts/SessionContext.cs new file mode 100644 index 0000000..c2c9487 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Presentation/MuhammedTask.BuildingBlocks.Presentation/SessionContexts/SessionContext.cs @@ -0,0 +1,53 @@ +using System.Security.Claims; +using CSharpEssentials; +using MuhammedTask.BuildingBlocks.Application.Shared.Abstractions; +using MuhammedTask.BuildingBlocks.Application.Shared.Constants; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; + +namespace MuhammedTask.BuildingBlocks.Presentation.SessionContexts; + +public abstract class SessionContext(IHttpContextAccessor httpContextAccessor) : ISessionContext +{ + private HttpContext? Context => httpContextAccessor.HttpContext; + + private bool CheckClaimValue(string type) => + Context?.User.HasClaim(x => x.Type == type) ?? false; + + private bool CheckHeaderValue(string key) => + Context?.Request.Headers.ContainsKey(key) ?? false; + + private Maybe GetClaimValue(string type) => + Context?.User.FindFirst(x => x.Type == type)?.Value; + + private Maybe GetHeaderValue(string key) => Context?.Request.Headers[key].ToString(); + + + public bool IsAuthenticated => Context?.User.Identity?.IsAuthenticated ?? false; + + public Maybe UserId => CheckClaimValue(ClaimTypes.NameIdentifier) ? GetClaimValue(ClaimTypes.NameIdentifier) : Maybe.None; + + public Maybe TenantId => GetTenantId(); + + public Maybe AccessToken => Context? + .Request + .Headers[HeaderNames.Authorization] + .ToString() + .Replace($"{JwtBearerDefaults.AuthenticationScheme} ", ""); + + private Maybe GetTenantId() + { + if (CheckHeaderValue(CustomHeaderNames.TenantId)) + { + return GetHeaderValue(CustomHeaderNames.TenantId); + } + + if (CheckClaimValue(CustomClaimTypes.TenantId)) + { + return GetClaimValue(CustomClaimTypes.TenantId); + } + + return Maybe.None; + } +} diff --git a/muhammed-task/source/src/BuildingBlocks/Layers/Presentation/MuhammedTask.BuildingBlocks.Presentation/SessionContexts/SessionContextDependencyInjection.cs b/muhammed-task/source/src/BuildingBlocks/Layers/Presentation/MuhammedTask.BuildingBlocks.Presentation/SessionContexts/SessionContextDependencyInjection.cs new file mode 100644 index 0000000..393af46 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Layers/Presentation/MuhammedTask.BuildingBlocks.Presentation/SessionContexts/SessionContextDependencyInjection.cs @@ -0,0 +1,20 @@ +using MuhammedTask.BuildingBlocks.Application.Shared.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.BuildingBlocks.Presentation.SessionContexts; +public static class SessionContextDependencyInjection +{ + public static IServiceCollection AddSessionContext(this IServiceCollection services) => + services.AddSessionContext(); + + public static IServiceCollection AddSessionContext + (this IServiceCollection services) + where TISessionContext : ISessionContext + where TSessionContext : class, TISessionContext + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/muhammed-task/source/src/BuildingBlocks/Logging/MuhammedTask.BuildingBlocks.Logging.Serilog/MuhammedTask.BuildingBlocks.Logging.Serilog.csproj b/muhammed-task/source/src/BuildingBlocks/Logging/MuhammedTask.BuildingBlocks.Logging.Serilog/MuhammedTask.BuildingBlocks.Logging.Serilog.csproj new file mode 100644 index 0000000..91e7a12 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/Logging/MuhammedTask.BuildingBlocks.Logging.Serilog/MuhammedTask.BuildingBlocks.Logging.Serilog.csproj @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.Base/IEventBus.cs b/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.Base/IEventBus.cs new file mode 100644 index 0000000..95a0bec --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.Base/IEventBus.cs @@ -0,0 +1,6 @@ +namespace MuhammedTask.BuildingBlocks.MessageBrokers.Base; +public interface IEventBus +{ + Task PublishAsync(TMessage message, bool isTransactional = true, CancellationToken cancellationToken = default) + where TMessage : class; +} diff --git a/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.Base/MuhammedTask.BuildingBlocks.MessageBrokers.Base.csproj b/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.Base/MuhammedTask.BuildingBlocks.MessageBrokers.Base.csproj new file mode 100644 index 0000000..1b70e73 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.Base/MuhammedTask.BuildingBlocks.MessageBrokers.Base.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.EntityFrameworkCore/DependencyInjection.cs b/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.EntityFrameworkCore/DependencyInjection.cs new file mode 100644 index 0000000..e212c49 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.EntityFrameworkCore/DependencyInjection.cs @@ -0,0 +1,32 @@ +using MassTransit; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.EntityFrameworkCore; + +namespace MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.EntityFrameworkCore; +public static class DependencyInjection +{ + public static IServiceCollection AddEventBus( + this IServiceCollection services, + Action? configureAction = null, + Action? entityFrameworkOutboxConfigurator = null, + bool consumeObserver = true, + bool publishObserver = true, + params Assembly[] assemblies) + where TDbContext : DbContext + { + return services.AddEventBus(configure => + { + configure.AddEntityFrameworkOutbox(entityConfigure => + { + entityConfigure.DuplicateDetectionWindow = TimeSpan.FromSeconds(30); + entityFrameworkOutboxConfigurator?.Invoke(entityConfigure); + }); + configureAction?.Invoke(configure); + }, + consumeObserver: consumeObserver, + publishObserver: publishObserver, + isOutboxEnabled: true, + assemblies: assemblies); + } +} diff --git a/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.EntityFrameworkCore/Extensions/EfCoreDbContextExtensions.cs b/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.EntityFrameworkCore/Extensions/EfCoreDbContextExtensions.cs new file mode 100644 index 0000000..749ecfb --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.EntityFrameworkCore/Extensions/EfCoreDbContextExtensions.cs @@ -0,0 +1,15 @@ +using MassTransit; +using Microsoft.EntityFrameworkCore; + +namespace MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.EntityFrameworkCore.Extensions; +public static class EfCoreDbContextExtensions +{ + public static ModelBuilder AddMassTransitOutboxEntities(this ModelBuilder modelBuilder) + { + modelBuilder.AddInboxStateEntity(); + modelBuilder.AddOutboxStateEntity(); + modelBuilder.AddOutboxMessageEntity(); + + return modelBuilder; + } +} diff --git a/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.EntityFrameworkCore/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.EntityFrameworkCore.csproj b/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.EntityFrameworkCore/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.EntityFrameworkCore.csproj new file mode 100644 index 0000000..702ef31 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.EntityFrameworkCore/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.EntityFrameworkCore.csproj @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit/DependencyInjection.cs b/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit/DependencyInjection.cs new file mode 100644 index 0000000..9d91229 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit/DependencyInjection.cs @@ -0,0 +1,50 @@ +using System.Reflection; +using MassTransit; +using MuhammedTask.BuildingBlocks.MessageBrokers.Base; +using MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.Observers; +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry.Trace; + +namespace MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit; +public static class DependencyInjection +{ + public static IServiceCollection AddEventBus( + this IServiceCollection services, + Action? configureAction = null, + bool consumeObserver = true, + bool publishObserver = true, + bool isOutboxEnabled = false, + params Assembly[] assemblies) + { + if (assemblies.Length == 0) + assemblies = [Assembly.GetCallingAssembly()]; + services.AddTransient(sp => + { + IBus bus = sp.GetRequiredService(); + IPublishEndpoint publishEndpoint = sp.GetRequiredService(); + return new EventBus(publishEndpoint, bus, isOutboxEnabled); + }); + + services.AddMassTransit(configure => + { + configure.SetKebabCaseEndpointNameFormatter(); + configure.AddConsumers(assemblies); + configure.AddActivities(assemblies); + + if (consumeObserver) + configure.AddConsumeObserver(); + if (publishObserver) + configure.AddPublishObserver(); + + configureAction?.Invoke(configure); + }); + + return services; + } + + public static IServiceCollection ConfigureEventBusTelemetry( + this IServiceCollection services) => + services + .ConfigureOpenTelemetryTracerProvider(configure => + configure.AddSource(global::MassTransit.Logging.DiagnosticHeaders.DefaultListenerName)); +} diff --git a/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit/EventBus.cs b/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit/EventBus.cs new file mode 100644 index 0000000..ea2b5c3 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit/EventBus.cs @@ -0,0 +1,25 @@ +using MassTransit; +using MuhammedTask.BuildingBlocks.MessageBrokers.Base; + +namespace MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit; +internal sealed class EventBus( + IPublishEndpoint publishEndpoint, + IBus bus, + bool isOutboxEnabled) : IEventBus +{ + private Task PublishToOutbox(TMessage message, CancellationToken cancellationToken) + where TMessage : class => + publishEndpoint.Publish(message, cancellationToken); + + private Task PublishDirectly(TMessage message, CancellationToken cancellationToken) + where TMessage : class => + bus.Publish(message, cancellationToken); + + public async Task PublishAsync(TMessage message, bool isTransactional = true, CancellationToken cancellationToken = default) where TMessage : class + { + if (!isOutboxEnabled || isTransactional) + await PublishToOutbox(message, cancellationToken); + else + await PublishDirectly(message, cancellationToken); + } +} diff --git a/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.csproj b/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.csproj new file mode 100644 index 0000000..d236d79 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + diff --git a/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit/Observers/ConsumeLoggerObserver.cs b/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit/Observers/ConsumeLoggerObserver.cs new file mode 100644 index 0000000..b42939d --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit/Observers/ConsumeLoggerObserver.cs @@ -0,0 +1,26 @@ +using MassTransit; +using Microsoft.Extensions.Logging; + +namespace MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.Observers; + +public sealed class ConsumeLoggerObserver( + ILogger logger) : IConsumeObserver +{ + public Task ConsumeFault(ConsumeContext context, Exception exception) where T : class + { + logger.LogError(exception, "Error consuming message: {@Message}", context.Message); + throw new NotImplementedException(); + } + + public Task PostConsume(ConsumeContext context) where T : class + { + logger.LogInformation("Consumed message: {@Message}", context.Message); + return Task.CompletedTask; + } + + public Task PreConsume(ConsumeContext context) where T : class + { + logger.LogInformation("Consuming message: {@Message}", context.Message); + return Task.CompletedTask; + } +} diff --git a/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit/Observers/PublishLoggerObserver.cs b/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit/Observers/PublishLoggerObserver.cs new file mode 100644 index 0000000..7fd3dd5 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/MessageBrokers/MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit/Observers/PublishLoggerObserver.cs @@ -0,0 +1,25 @@ +using MassTransit; +using Microsoft.Extensions.Logging; + +namespace MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.Observers; +public sealed class PublishLoggerObserver( + ILogger logger) : IPublishObserver +{ + public Task PostPublish(PublishContext context) where T : class + { + logger.LogInformation("Published message: {@Message}", context.Message); + return Task.CompletedTask; + } + + public Task PrePublish(PublishContext context) where T : class + { + logger.LogInformation("Publishing message: {@Message}", context.Message); + return Task.CompletedTask; + } + + public Task PublishFault(PublishContext context, Exception exception) where T : class + { + logger.LogError(exception, "Error publishing message: {@Message}", context.Message); + return Task.CompletedTask; + } +} diff --git a/muhammed-task/source/src/BuildingBlocks/OpenTelemetry/MuhammedTask.BuildingBlocks.OpenTelemetry.Base/DependencyInjection.cs b/muhammed-task/source/src/BuildingBlocks/OpenTelemetry/MuhammedTask.BuildingBlocks.OpenTelemetry.Base/DependencyInjection.cs new file mode 100644 index 0000000..f1844fb --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/OpenTelemetry/MuhammedTask.BuildingBlocks.OpenTelemetry.Base/DependencyInjection.cs @@ -0,0 +1,80 @@ +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; +using OpenTelemetry; +using OpenTelemetry.Resources; + +namespace MuhammedTask.BuildingBlocks.OpenTelemetry.Base; +public static class DependencyInjection +{ + public static IServiceCollection ConfigureOpenTelemetry( + this IServiceCollection services, + string serviceName, + string? serviceNamespace = nameof(MuhammedTask), + Action? configure = null, + Action? configureLoggerOptions = null, + Action? configureMeter = null, + Action? configureTrace = null) + { + OpenTelemetryBuilder openTelemetryBuilder = services.AddOpenTelemetry(); + + openTelemetryBuilder.ConfigureResource(resource => + { + resource.AddService(serviceName, serviceNamespace); + resource.AddTelemetrySdk(); + }); + + configure?.Invoke(openTelemetryBuilder); + + services.Configure(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + logging.ParseStateValues = true; + configureLoggerOptions?.Invoke(logging); + }) + .ConfigureOpenTelemetryMeterProvider(metrics => + { + metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddProcessInstrumentation() + .AddRuntimeInstrumentation() + .AddEventCountersInstrumentation(); + + configureMeter?.Invoke(metrics); + }) + .ConfigureOpenTelemetryTracerProvider(tracing => + { + tracing + .SetErrorStatusOnException() + .AddAspNetCoreInstrumentation(options => + { + options.RecordException = true; + options.EnrichWithException = (activity, exception) => + { + activity.SetTag("exceptionType", exception.GetType().ToString()); + activity.SetTag("stackTrace", exception.StackTrace); + }; + }) + .AddHttpClientInstrumentation(options => options.RecordException = true) + .AddGrpcClientInstrumentation(); + + configureTrace?.Invoke(tracing); + }); + + services.AddMetrics(); + services.AddOpenTelemetry(); + + return services; + } + + public static IOpenTelemetryBuilder AddOtlpExporter( + this IServiceCollection services) + { + return services + .AddOpenTelemetry() + .UseOtlpExporter(); + } +} diff --git a/muhammed-task/source/src/BuildingBlocks/OpenTelemetry/MuhammedTask.BuildingBlocks.OpenTelemetry.Base/MuhammedTask.BuildingBlocks.OpenTelemetry.Base.csproj b/muhammed-task/source/src/BuildingBlocks/OpenTelemetry/MuhammedTask.BuildingBlocks.OpenTelemetry.Base/MuhammedTask.BuildingBlocks.OpenTelemetry.Base.csproj new file mode 100644 index 0000000..9b3b341 --- /dev/null +++ b/muhammed-task/source/src/BuildingBlocks/OpenTelemetry/MuhammedTask.BuildingBlocks.OpenTelemetry.Base/MuhammedTask.BuildingBlocks.OpenTelemetry.Base.csproj @@ -0,0 +1,15 @@ + + + false + + + + + + + + + + + + diff --git a/muhammed-task/source/src/Host/MuhammedTask.AppHost/Extensions.cs b/muhammed-task/source/src/Host/MuhammedTask.AppHost/Extensions.cs new file mode 100644 index 0000000..17e998d --- /dev/null +++ b/muhammed-task/source/src/Host/MuhammedTask.AppHost/Extensions.cs @@ -0,0 +1,136 @@ +using HealthChecks.NpgSql; +using HealthChecks.Redis; +using HealthChecks.Uris; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace MuhammedTask.AppHost; +public static class Extensions +{ + public static IResourceBuilder WithHealthCheck(this IResourceBuilder builder) + { + // WithManagementPlugin() registers HealthCheckAnnotation(key: "{name}_check") and also + // calls AddHealthChecks().AddRabbitMQ() which uses RabbitMQ.Client 6.x API. + // Since Directory.Packages.props pins RabbitMQ.Client to 7.x (required by MassTransit 8.x), + // the built-in check crashes at runtime. We replace it with an HTTP-based check against + // the management API, which is version-agnostic. + string key = $"{builder.Resource.Name}_check"; + + // Remove the broken check registered by WithManagementPlugin and replace it. + builder.ApplicationBuilder.Services + .Configure(opts => + { + HealthCheckRegistration? existing = opts.Registrations + .FirstOrDefault(r => r.Name == key); + if (existing is not null) + opts.Registrations.Remove(existing); + + opts.Registrations.Add(new HealthCheckRegistration(key, sp => new RabbitMQManagementHealthCheck(builder.Resource), null, null)); + }); + + return builder; + } + + public static IResourceBuilder WithHealthCheck(this IResourceBuilder builder) + { + return builder.WithAnnotation(HealthCheckAnnotation.Create(cs => new RedisHealthCheck(cs))); + } + + public static IResourceBuilder WithHealthCheck(this IResourceBuilder builder) + { + return builder.WithAnnotation(HealthCheckAnnotation.Create(cs => new NpgSqlHealthCheck(new NpgSqlHealthCheckOptions(cs)))); + } + + public static IResourceBuilder WithHealthCheck( + this IResourceBuilder builder, + string? endpointName = null, + string path = "health", + Action? configure = null) + where T : IResourceWithEndpoints + { + return builder.WithAnnotation(new HealthCheckAnnotation((resource, ct) => + { + if (resource is not IResourceWithEndpoints resourceWithEndpoints) + { + return Task.FromResult(null); + } + + EndpointReference? endpoint = endpointName is null + ? resourceWithEndpoints.GetEndpoints().FirstOrDefault(e => e.Scheme is "http" or "https") + : resourceWithEndpoints.GetEndpoint(endpointName); + + string? url = endpoint?.Url; + + if (url is null) + { + return Task.FromResult(null); + } + + var options = new UriHealthCheckOptions(); + + options.AddUri(new(new(url), path)); + + configure?.Invoke(options); + + var client = new HttpClient(); + return Task.FromResult(new UriHealthCheck(options, () => client)); + })); + } +} + +internal sealed class RabbitMQManagementHealthCheck(RabbitMQServerResource resource) : IHealthCheck +{ + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + EndpointReference? endpoint = resource.GetEndpoints() + .FirstOrDefault(e => e.Scheme is "http" or "https"); + + if (endpoint?.Url is not string baseUrl) + return HealthCheckResult.Unhealthy("RabbitMQ management endpoint not available."); + + string? password = resource.PasswordParameter is IValueProvider p + ? await p.GetValueAsync(cancellationToken) + : null; + + string credentials = Convert.ToBase64String( + System.Text.Encoding.ASCII.GetBytes($"guest:{password}")); + + using HttpClient client = new(); + client.DefaultRequestHeaders.Add("Authorization", $"Basic {credentials}"); + try + { + HttpResponseMessage response = await client.GetAsync( + new Uri(new Uri(baseUrl), "api/health/checks/alarms"), cancellationToken); + return response.IsSuccessStatusCode + ? HealthCheckResult.Healthy() + : HealthCheckResult.Unhealthy($"HTTP {(int)response.StatusCode}"); + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy(ex.Message); + } + } +} + +public class HealthCheckAnnotation(Func> healthCheckFactory) : IResourceAnnotation +{ + public Func> HealthCheckFactory { get; } = healthCheckFactory; + + public static HealthCheckAnnotation Create(Func connectionStringFactory) + { + return new(async (resource, token) => + { + if (resource is not IResourceWithConnectionString c) + { + return null; + } + + if (await c.GetConnectionStringAsync(token) is not string cs) + { + return null; + } + + return connectionStringFactory(cs); + }); + } +} diff --git a/muhammed-task/source/src/Host/MuhammedTask.AppHost/MuhammedTask.AppHost.csproj b/muhammed-task/source/src/Host/MuhammedTask.AppHost/MuhammedTask.AppHost.csproj new file mode 100644 index 0000000..4e17dc4 --- /dev/null +++ b/muhammed-task/source/src/Host/MuhammedTask.AppHost/MuhammedTask.AppHost.csproj @@ -0,0 +1,27 @@ + + + + + + Exe + true + 48046519-421c-4741-af4b-b85e94ed7609 + + + + + + + + + + + + + + + + + + + diff --git a/muhammed-task/source/src/Host/MuhammedTask.AppHost/Program.cs b/muhammed-task/source/src/Host/MuhammedTask.AppHost/Program.cs new file mode 100644 index 0000000..2739cef --- /dev/null +++ b/muhammed-task/source/src/Host/MuhammedTask.AppHost/Program.cs @@ -0,0 +1,104 @@ +using MuhammedTask.AppHost; + +IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args); + +const string + yarpKey = "yarp", + keycloakKey = "keycloak", + seqKey = "seq", + pgServerKey = "postgres-server", + redisKey = "redis", + rabbitmqKey = "rabbitmq", + pgProductServiceDb = "pg-productservice", + pgCategoryServiceDb = "pg-categoryservice", + pgProductReviewServiceDb = "pg-productreviewservice", + productServiceKey = "productservice", + categoryServiceKey = "categoryservice", + productReviewServiceKey = "productreviewservice"; + +IResourceBuilder keycloak = builder + .AddKeycloak(keycloakKey) + .WithLifetime(ContainerLifetime.Persistent) + .WithHealthCheck() + .WithDataVolume(); + + +IResourceBuilder seq = builder + .AddSeq(seqKey) + .WithDataVolume() + .WithLifetime(ContainerLifetime.Persistent) + .ExcludeFromManifest(); + +IResourceBuilder postgres = builder + .AddPostgres(pgServerKey) + .WithLifetime(ContainerLifetime.Persistent) + .WithDataVolume() + .WithHealthCheck() + .WithPgAdmin(); + +IResourceBuilder cache = builder + .AddRedis(redisKey) + .WithLifetime(ContainerLifetime.Persistent) + .WithDataVolume() + .WithHealthCheck() + .WithRedisInsight(); + +IResourceBuilder rabbitmq = builder + .AddRabbitMQ(rabbitmqKey) + .WithLifetime(ContainerLifetime.Persistent) + .WithHealthCheck() + .WithDataVolume() + .WithManagementPlugin(); + + +IResourceBuilder productServicePostgresDatabase = postgres.AddDatabase(pgProductServiceDb); +IResourceBuilder productService = builder + .AddProject(productServiceKey) + .WithHttpHealthCheck("/health") + .WithReference(seq) + .WithReference(cache) + .WithReference(rabbitmq) + .WithReference(productServicePostgresDatabase) + .WaitFor(productServicePostgresDatabase) + .WithReference(keycloak) + .WaitFor(keycloak); + +IResourceBuilder categoryServicePostgresDatabase = postgres.AddDatabase(pgCategoryServiceDb); +IResourceBuilder categoryService = builder + .AddProject(categoryServiceKey) + .WithHttpHealthCheck("/health") + .WithReference(seq) + .WithReference(cache) + .WithReference(rabbitmq) + .WithReference(categoryServicePostgresDatabase) + .WaitFor(categoryServicePostgresDatabase) + .WithReference(keycloak) + .WaitFor(keycloak); + + +productService + .WithReference(categoryService) + .WaitFor(categoryService); + +IResourceBuilder productReviewServicePostgresDatabase = postgres.AddDatabase(pgProductReviewServiceDb); +IResourceBuilder productReviewService = builder + .AddProject(productReviewServiceKey) + .WithHttpHealthCheck("/health") + .WithReference(seq) + .WithReference(cache) + .WithReference(rabbitmq) + .WithReference(productReviewServicePostgresDatabase) + .WaitFor(productReviewServicePostgresDatabase) + .WaitFor(rabbitmq) + .WithReference(keycloak) + .WaitFor(keycloak); + +builder.AddProject(yarpKey) + .WithReference(productService) + .WithReference(categoryService) + .WithReference(productReviewService) + .WithReference(keycloak) + .WithReference(seq) + .WithExternalHttpEndpoints(); + +await builder.Build().RunAsync(); diff --git a/muhammed-task/source/src/Host/MuhammedTask.AppHost/Properties/launchSettings.json b/muhammed-task/source/src/Host/MuhammedTask.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000..caa00bf --- /dev/null +++ b/muhammed-task/source/src/Host/MuhammedTask.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17220;http://localhost:15155", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21143", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22050" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15155", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19035", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20084" + } + } + } +} diff --git a/muhammed-task/source/src/Host/MuhammedTask.AppHost/appsettings.Development.json b/muhammed-task/source/src/Host/MuhammedTask.AppHost/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/muhammed-task/source/src/Host/MuhammedTask.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/muhammed-task/source/src/Host/MuhammedTask.AppHost/appsettings.json b/muhammed-task/source/src/Host/MuhammedTask.AppHost/appsettings.json new file mode 100644 index 0000000..31c092a --- /dev/null +++ b/muhammed-task/source/src/Host/MuhammedTask.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/muhammed-task/source/src/Host/MuhammedTask.Services.Info/MuhammedTask.Services.Info.csproj b/muhammed-task/source/src/Host/MuhammedTask.Services.Info/MuhammedTask.Services.Info.csproj new file mode 100644 index 0000000..1b70e73 --- /dev/null +++ b/muhammed-task/source/src/Host/MuhammedTask.Services.Info/MuhammedTask.Services.Info.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/muhammed-task/source/src/Host/MuhammedTask.Services.Info/ServiceKeys.cs b/muhammed-task/source/src/Host/MuhammedTask.Services.Info/ServiceKeys.cs new file mode 100644 index 0000000..fc8c41c --- /dev/null +++ b/muhammed-task/source/src/Host/MuhammedTask.Services.Info/ServiceKeys.cs @@ -0,0 +1,22 @@ +namespace MuhammedTask.Services.Info; + +public static class ServiceKeys +{ + public const string Yarp = "yarp"; + public const string Seq = "seq"; + public const string PostgresServer = "postgres-server"; + public const string Redis = "redis"; + public const string RabbitMQ = "rabbitmq"; + + public const string Keycloak = "keycloak"; + public const string ProductService = "productservice"; + public const string CategoryService = "categoryservice"; + public const string ProductReviewService = "productreviewservice"; + + public static class Database + { + public const string PostgresProductService = "pg-productservice"; + public const string PostgresCategoryService = "pg-categoryservice"; + public const string PostgresProductReviewService = "pg-productreviewservice"; + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/ApplicationAssemblyReference.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/ApplicationAssemblyReference.cs new file mode 100644 index 0000000..2751fac --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/ApplicationAssemblyReference.cs @@ -0,0 +1,7 @@ +using System.Reflection; + +namespace MuhammedTask.Services.CategoryService.Application; +public static class ApplicationAssemblyReference +{ + public static Assembly Assembly => typeof(ApplicationAssemblyReference).Assembly; +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Commands/Create/CategoryCreateCommand.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Commands/Create/CategoryCreateCommand.cs new file mode 100644 index 0000000..2274f36 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Commands/Create/CategoryCreateCommand.cs @@ -0,0 +1,12 @@ +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.CategoryService.Domain.Categories.Fields; +using MuhammedTask.Services.CategoryService.Domain.Categories.Parameters; + +namespace MuhammedTask.Services.CategoryService.Application.Categories.v1.Commands.Create; + +public sealed record CategoryCreateCommand( + string? Name) : ICommand +{ + public CategoryCreateParameters ToParameters() => + new(Name); +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Commands/Create/CategoryCreateCommandHandler.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Commands/Create/CategoryCreateCommandHandler.cs new file mode 100644 index 0000000..5bdbf18 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Commands/Create/CategoryCreateCommandHandler.cs @@ -0,0 +1,16 @@ +using CSharpEssentials; +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.CategoryService.Domain.Categories.Fields; +using MuhammedTask.Services.CategoryService.Domain.Categories.Parameters; +using MuhammedTask.Services.CategoryService.Domain.Categories.Repositories; +namespace MuhammedTask.Services.CategoryService.Application.Categories.v1.Commands.Create; + +internal sealed class CategoryCreateCommandHandler( + ICategoryCommandRepository repository) : ICommandHandler +{ + public Task> Handle(CategoryCreateCommand request, CancellationToken cancellationToken) + { + CategoryCreateParameters parameters = request.ToParameters(); + return repository.CreateCategoryAsync(parameters, cancellationToken); + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Commands/Create/CategoryCreateCommandValidator.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Commands/Create/CategoryCreateCommandValidator.cs new file mode 100644 index 0000000..7072798 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Commands/Create/CategoryCreateCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using MuhammedTask.Services.CategoryService.Domain.Categories.Fields; + +namespace MuhammedTask.Services.CategoryService.Application.Categories.v1.Commands.Create; + +internal sealed class CategoryCreateCommandValidator : AbstractValidator +{ + public CategoryCreateCommandValidator() => + RuleFor(x => x.Name) + .NotEmpty() + .MinimumLength(CategoryName.MinLength) + .MaximumLength(CategoryName.MaxLength); +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Commands/Delete/CategoryDeleteCommand.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Commands/Delete/CategoryDeleteCommand.cs new file mode 100644 index 0000000..5a784e2 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Commands/Delete/CategoryDeleteCommand.cs @@ -0,0 +1,4 @@ +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; + +namespace MuhammedTask.Services.CategoryService.Application.Categories.v1.Commands.Delete; +public sealed record CategoryDeleteCommand(Guid CategoryId) : ICommand; diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Commands/Delete/CategoryDeleteCommandHandler.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Commands/Delete/CategoryDeleteCommandHandler.cs new file mode 100644 index 0000000..77b5c72 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Commands/Delete/CategoryDeleteCommandHandler.cs @@ -0,0 +1,16 @@ +using CSharpEssentials; +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.CategoryService.Domain.Categories.Fields; +using MuhammedTask.Services.CategoryService.Domain.Categories.Repositories; + +namespace MuhammedTask.Services.CategoryService.Application.Categories.v1.Commands.Delete; + +internal sealed class CategoryDeleteCommandHandler( + ICategoryCommandRepository repository) : ICommandHandler +{ + public Task Handle(CategoryDeleteCommand request, CancellationToken cancellationToken) + { + var productId = CategoryId.From(request.CategoryId); + return repository.DeleteCategoryAsync(productId, cancellationToken); + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Commands/Delete/CategoryDeleteCommandValidator.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Commands/Delete/CategoryDeleteCommandValidator.cs new file mode 100644 index 0000000..8b1c318 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Commands/Delete/CategoryDeleteCommandValidator.cs @@ -0,0 +1,9 @@ +using FluentValidation; + +namespace MuhammedTask.Services.CategoryService.Application.Categories.v1.Commands.Delete; + +internal sealed class CategoryDeleteCommandValidator : AbstractValidator +{ + public CategoryDeleteCommandValidator() => RuleFor(x => x.CategoryId).NotEmpty(); +} + diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/DomainEventHandlers/CategoryDeletedDomainEventHandler.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/DomainEventHandlers/CategoryDeletedDomainEventHandler.cs new file mode 100644 index 0000000..2787050 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/DomainEventHandlers/CategoryDeletedDomainEventHandler.cs @@ -0,0 +1,22 @@ +using MediatR; +using MuhammedTask.BuildingBlocks.Caching.Base; +using MuhammedTask.BuildingBlocks.MessageBrokers.Base; +using MuhammedTask.IntegrationEvents.Categories; +using MuhammedTask.Services.CategoryService.Domain.Categories.Events; +using Microsoft.Extensions.Logging; + +namespace MuhammedTask.Services.CategoryService.Application.Categories.v1.DomainEventHandlers; + +internal sealed class CategoryDeletedDomainEventHandler( + ILogger logger, + IEventBus eventBus, + ICacheService cacheService) : INotificationHandler +{ + public async Task Handle(CategoryDeletedDomainEvent notification, CancellationToken cancellationToken) + { + logger.LogInformation("CategoryDeletedDomainEvent handled"); + string CategoryIdCache = $"category:{notification.Id}"; + cacheService.Remove(CategoryIdCache); + await eventBus.PublishAsync(new CategoryDeletedIntegrationEvent(notification.Id.Value), isTransactional: false, cancellationToken); + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/IntegrationEvents/CategoryDeletedIntegrationEvent.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/IntegrationEvents/CategoryDeletedIntegrationEvent.cs new file mode 100644 index 0000000..40afd7c --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/IntegrationEvents/CategoryDeletedIntegrationEvent.cs @@ -0,0 +1,2 @@ +namespace MuhammedTask.IntegrationEvents.Categories; +public sealed record CategoryDeletedIntegrationEvent(Guid CategoryId); diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Models/CategoryViewModel.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Models/CategoryViewModel.cs new file mode 100644 index 0000000..43b4055 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Models/CategoryViewModel.cs @@ -0,0 +1,19 @@ +using MuhammedTask.Services.CategoryService.Domain.Categories.ReadModels; + +namespace MuhammedTask.Services.CategoryService.Application.Products.v1.Models; +public readonly record struct CategoryViewModel +{ + private CategoryViewModel(CategoryReadModel categoy) + { + Id = categoy.Id; + Name = categoy.Name; + CreatedAt = categoy.CreatedAt; + } + + public readonly Guid Id { get; init; } + public readonly string Name { get; init; } + public readonly DateTimeOffset CreatedAt { get; init; } + + public static CategoryViewModel Create(CategoryReadModel categoy) => new(categoy); + public static CategoryViewModel[] Create(CategoryReadModel[] categories) => [.. categories.Select(Create)]; +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Queries/Exist/CategoryExistQuery.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Queries/Exist/CategoryExistQuery.cs new file mode 100644 index 0000000..5dd7db3 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Queries/Exist/CategoryExistQuery.cs @@ -0,0 +1,4 @@ +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; + +namespace MuhammedTask.Services.CategoryService.Application.Categories.v1.Queries.Exist; +public sealed record CategoryExistQuery(Guid CategoryId) : IQuery; diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Queries/Exist/CategoryExistQueryHandler.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Queries/Exist/CategoryExistQueryHandler.cs new file mode 100644 index 0000000..7e1fada --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Queries/Exist/CategoryExistQueryHandler.cs @@ -0,0 +1,16 @@ +using CSharpEssentials; +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.CategoryService.Domain.Categories.Fields; +using MuhammedTask.Services.CategoryService.Domain.Categories.Repositories; + +namespace MuhammedTask.Services.CategoryService.Application.Categories.v1.Queries.Exist; + +internal sealed class CategoryExistQueryHandler( + ICategoryQueryRepository repository) : IQueryHandler +{ + public async Task> Handle(CategoryExistQuery request, CancellationToken cancellationToken) + { + bool isExist = await repository.ExistsAsync(CategoryId.From(request.CategoryId), cancellationToken); + return Result.Success(isExist); + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Queries/Exist/CategoryExistQueryValidator.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Queries/Exist/CategoryExistQueryValidator.cs new file mode 100644 index 0000000..a177720 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Queries/Exist/CategoryExistQueryValidator.cs @@ -0,0 +1,8 @@ +using FluentValidation; + +namespace MuhammedTask.Services.CategoryService.Application.Categories.v1.Queries.Exist; + +internal sealed class CategoryExistQueryValidator : AbstractValidator +{ + public CategoryExistQueryValidator() => RuleFor(x => x.CategoryId).NotEmpty(); +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Queries/Get/GetCategoryQuery.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Queries/Get/GetCategoryQuery.cs new file mode 100644 index 0000000..c6741df --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Queries/Get/GetCategoryQuery.cs @@ -0,0 +1,16 @@ +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.CategoryService.Application.Products.v1.Models; + +namespace MuhammedTask.Services.CategoryService.Application.Categories.v1.Queries.Get; +public sealed record GetCategoryQuery(Guid CategoryId) : ICachedQuery +{ + public bool BypassCache => false; + + public bool CacheFailures => true; + + public string CacheKey => $"category:{CategoryId}"; + + public TimeSpan Expiration => TimeSpan.FromMinutes(10); + + public string[] Tags => []; +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Queries/Get/GetCategoryQueryHandler.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Queries/Get/GetCategoryQueryHandler.cs new file mode 100644 index 0000000..ec07835 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Queries/Get/GetCategoryQueryHandler.cs @@ -0,0 +1,23 @@ +using CSharpEssentials; +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.CategoryService.Application.Products.v1.Models; +using MuhammedTask.Services.CategoryService.Domain.Categories; +using MuhammedTask.Services.CategoryService.Domain.Categories.Fields; +using MuhammedTask.Services.CategoryService.Domain.Categories.ReadModels; +using MuhammedTask.Services.CategoryService.Domain.Categories.Repositories; + +namespace MuhammedTask.Services.CategoryService.Application.Categories.v1.Queries.Get; + +internal sealed class GetCategoryQueryHandler( + ICategoryQueryRepository categoryQueryRepository) : ICachedQueryHandler +{ + public async Task> Handle(GetCategoryQuery request, CancellationToken cancellationToken) + { + var categoryId = CategoryId.From(request.CategoryId); + Maybe Category = await categoryQueryRepository.GetCategoryByIdAsync(categoryId, cancellationToken); + + return Category.Match>( + value => CategoryViewModel.Create(value), + () => CategoryErrors.CategoryDoesNotExistError(categoryId)); + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Queries/Get/GetCategoryQueryValidator.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Queries/Get/GetCategoryQueryValidator.cs new file mode 100644 index 0000000..cf25d6c --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Queries/Get/GetCategoryQueryValidator.cs @@ -0,0 +1,8 @@ +using FluentValidation; + +namespace MuhammedTask.Services.CategoryService.Application.Categories.v1.Queries.Get; + +internal sealed class GetCategoryQueryValidator : AbstractValidator +{ + public GetCategoryQueryValidator() => RuleFor(x => x.CategoryId).NotEmpty(); +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Queries/List/GetCategoryListQuery.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Queries/List/GetCategoryListQuery.cs new file mode 100644 index 0000000..c1a6a4a --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Queries/List/GetCategoryListQuery.cs @@ -0,0 +1,17 @@ +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.CategoryService.Application.Products.v1.Models; + +namespace MuhammedTask.Services.CategoryService.Application.Categories.v1.Queries.List; +public sealed record GetCategoryListQuery() : + ICachedQuery +{ + public bool BypassCache => false; + + public bool CacheFailures => true; + + public string CacheKey => $"categories"; + + public TimeSpan Expiration => TimeSpan.FromMinutes(10); + + public string[] Tags => []; +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Queries/List/GetCategoryListQueryHandler.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Queries/List/GetCategoryListQueryHandler.cs new file mode 100644 index 0000000..be8af86 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/Categories/v1/Queries/List/GetCategoryListQueryHandler.cs @@ -0,0 +1,19 @@ +using CSharpEssentials; +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.CategoryService.Application.Products.v1.Models; +using MuhammedTask.Services.CategoryService.Domain.Categories.ReadModels; +using MuhammedTask.Services.CategoryService.Domain.Categories.Repositories; + +namespace MuhammedTask.Services.CategoryService.Application.Categories.v1.Queries.List; + +internal sealed class GetCategoryListQueryHandler( + ICategoryQueryRepository repository) : ICachedQueryHandler +{ + public async Task> Handle(GetCategoryListQuery request, CancellationToken cancellationToken) + { + CategoryReadModel[] categories = await repository.GetCategories(cancellationToken); + CategoryViewModel[] models = CategoryViewModel.Create(categories); + return models; + + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/MuhammedTask.Services.CategoryService.Application.csproj b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/MuhammedTask.Services.CategoryService.Application.csproj new file mode 100644 index 0000000..4022355 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/MuhammedTask.Services.CategoryService.Application.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/ServiceRegistrations/DependencyInjection.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/ServiceRegistrations/DependencyInjection.cs new file mode 100644 index 0000000..58c18c5 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Application/ServiceRegistrations/DependencyInjection.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using MuhammedTask.BuildingBlocks.Application.DependencyInjections; +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.Services.CategoryService.Application.ServiceRegistrations; +public static class DependencyInjection +{ + public static IServiceCollection AddApplicationServices( + this IServiceCollection services) + { + return services + .AddDateTimeProvider() + .AddMediatR(configure => + { + configure.RegisterServicesFromAssembly(ApplicationAssemblyReference.Assembly); + configure.AddDefaultBehaviors(); + }) + .AddValidatorsFromAssembly(ApplicationAssemblyReference.Assembly, includeInternalTypes: true); + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/Category.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/Category.cs new file mode 100644 index 0000000..6340278 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/Category.cs @@ -0,0 +1,31 @@ +using CSharpEssentials; +using CSharpEssentials.Entity; +using MuhammedTask.Services.CategoryService.Domain.Categories.Events; +using MuhammedTask.Services.CategoryService.Domain.Categories.Fields; +using MuhammedTask.Services.CategoryService.Domain.Categories.Parameters; + +namespace MuhammedTask.Services.CategoryService.Domain.Categories; +public sealed class Category : EntityBase +{ + private Category() { } + private Category(CategoryId productId, CategoryName name) + { + Id = productId; + Name = name; + } + public CategoryName Name { get; private set; } + + public static Result Create( + CategoryCreateParameters parameters) + { + Result name = CategoryName.Create(parameters.Name); + + if (name.IsFailure) + return name.Errors; + + + return new Category(CategoryId.New(), name.Value); + } + + public void Delete() => Raise(new CategoryDeletedDomainEvent(Id)); +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/CategoryErrors.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/CategoryErrors.cs new file mode 100644 index 0000000..81e93ff --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/CategoryErrors.cs @@ -0,0 +1,17 @@ +using CSharpEssentials; +using MuhammedTask.Services.CategoryService.Domain.Categories.Fields; + +namespace MuhammedTask.Services.CategoryService.Domain.Categories; +public static class CategoryErrors +{ + public static Error CategoryDoesNotExistError(CategoryId category) => Error.NotFound(code: "Category.DoesNotExist", description: $"Category does not exist: {category.Value}"); + public static class Name + { + public static readonly Error EmptyError = + Error.Validation(code: "Category.Name.Empty", description: "Category name is required"); + public static Error TooShortError(int length) => + Error.Validation(code: "Category.Name.TooShort", description: $"Category name is too short length: {length}"); + public static Error TooLongError(int length) => + Error.Validation(code: "Category.Name.TooLong", description: $"Category name is too long length: {length}"); + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/Events/CategoryDeletedDomainEvent.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/Events/CategoryDeletedDomainEvent.cs new file mode 100644 index 0000000..0ec5c8c --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/Events/CategoryDeletedDomainEvent.cs @@ -0,0 +1,8 @@ +using CSharpEssentials.Interfaces; +using MuhammedTask.Services.CategoryService.Domain.Categories.Fields; + +namespace MuhammedTask.Services.CategoryService.Domain.Categories.Events; + +public sealed record CategoryDeletedDomainEvent(CategoryId Id) : IDomainEvent; + + diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/Fields/CategoryId.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/Fields/CategoryId.cs new file mode 100644 index 0000000..205bc71 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/Fields/CategoryId.cs @@ -0,0 +1,13 @@ +using CSharpEssentials; + +namespace MuhammedTask.Services.CategoryService.Domain.Categories.Fields; + +public readonly record struct CategoryId +{ + private CategoryId(Guid value) => Value = value; + public Guid Value { get; } + public static CategoryId New() => new(Guider.NewGuid()); + public static CategoryId From(Guid value) => new(value); + + public static readonly CategoryId Empty = new(Guid.Empty); +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/Fields/CategoryName.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/Fields/CategoryName.cs new file mode 100644 index 0000000..002b02a --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/Fields/CategoryName.cs @@ -0,0 +1,25 @@ +using CSharpEssentials; + +namespace MuhammedTask.Services.CategoryService.Domain.Categories.Fields; + +public readonly record struct CategoryName +{ + public const int MinLength = 2; + public const int MaxLength = 100; + public string Value { get; } + private CategoryName(string value) => Value = value; + public static CategoryName From(string value) => new(value); + public static Result Create(string? value) + { + if (value.IsEmpty()) + return CategoryErrors.Name.EmptyError; + + if (value.Length < MinLength) + return CategoryErrors.Name.TooShortError(value.Length); + + if (value.Length > MaxLength) + return CategoryErrors.Name.TooLongError(value.Length); + + return new CategoryName(value); + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/Parameters/CategoryCreateParameters.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/Parameters/CategoryCreateParameters.cs new file mode 100644 index 0000000..117329d --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/Parameters/CategoryCreateParameters.cs @@ -0,0 +1,2 @@ +namespace MuhammedTask.Services.CategoryService.Domain.Categories.Parameters; +public record CategoryCreateParameters(string? Name); diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/ReadModels/CategoryReadModel.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/ReadModels/CategoryReadModel.cs new file mode 100644 index 0000000..d187fab --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/ReadModels/CategoryReadModel.cs @@ -0,0 +1,7 @@ +namespace MuhammedTask.Services.CategoryService.Domain.Categories.ReadModels; +public sealed class CategoryReadModel +{ + public Guid Id { get; set; } + public string Name { get; set; } + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/Repositories/ICategoryCommandRepository.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/Repositories/ICategoryCommandRepository.cs new file mode 100644 index 0000000..26263ae --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/Repositories/ICategoryCommandRepository.cs @@ -0,0 +1,10 @@ +using CSharpEssentials; +using MuhammedTask.Services.CategoryService.Domain.Categories.Fields; +using MuhammedTask.Services.CategoryService.Domain.Categories.Parameters; + +namespace MuhammedTask.Services.CategoryService.Domain.Categories.Repositories; +public interface ICategoryCommandRepository +{ + Task> CreateCategoryAsync(CategoryCreateParameters parameters, CancellationToken cancellationToken = default); + Task DeleteCategoryAsync(CategoryId categoryId, CancellationToken cancellationToken = default); +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/Repositories/ICategoryQueryRepository.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/Repositories/ICategoryQueryRepository.cs new file mode 100644 index 0000000..15efef8 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/Categories/Repositories/ICategoryQueryRepository.cs @@ -0,0 +1,11 @@ +using CSharpEssentials; +using MuhammedTask.Services.CategoryService.Domain.Categories.Fields; +using MuhammedTask.Services.CategoryService.Domain.Categories.ReadModels; + +namespace MuhammedTask.Services.CategoryService.Domain.Categories.Repositories; +public interface ICategoryQueryRepository +{ + Task> GetCategoryByIdAsync(CategoryId categoryId, CancellationToken cancellationToken = default); + Task GetCategories(CancellationToken cancellationToken = default); + Task ExistsAsync(CategoryId categoryId, CancellationToken cancellationToken = default); +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/DomainAssemblyReference.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/DomainAssemblyReference.cs new file mode 100644 index 0000000..dd03d77 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/DomainAssemblyReference.cs @@ -0,0 +1,7 @@ +using System.Reflection; + +namespace MuhammedTask.Services.CategoryService.Domain; +public static class DomainAssemblyReference +{ + public static Assembly Assembly => typeof(DomainAssemblyReference).Assembly; +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/MuhammedTask.Services.CategoryService.Domain.csproj b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/MuhammedTask.Services.CategoryService.Domain.csproj new file mode 100644 index 0000000..a03f472 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Domain/MuhammedTask.Services.CategoryService.Domain.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Configurations/Read/CategoryReadModelConfigurations.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Configurations/Read/CategoryReadModelConfigurations.cs new file mode 100644 index 0000000..9535f38 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Configurations/Read/CategoryReadModelConfigurations.cs @@ -0,0 +1,12 @@ +using MuhammedTask.Services.CategoryService.Domain.Categories.ReadModels; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MuhammedTask.Services.CategoryService.Persistence.EntityFrameworkCore.Configurations.Read; +internal sealed class CategoryReadModelConfigurations : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Configurations/Write/CategoryConfiguration.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Configurations/Write/CategoryConfiguration.cs new file mode 100644 index 0000000..15cb9d3 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Configurations/Write/CategoryConfiguration.cs @@ -0,0 +1,27 @@ +using CSharpEssentials.EntityFrameworkCore; +using MuhammedTask.Services.CategoryService.Domain.Categories; +using MuhammedTask.Services.CategoryService.Domain.Categories.Fields; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MuhammedTask.Services.CategoryService.Persistence.EntityFrameworkCore.Configurations.Write; +internal sealed class CategoryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.EntityBaseMap(); + + builder + .Property(p => p.Id) + .HasConversion(id => id.Value, id => CategoryId.From(id)); + + builder + .Property(p => p.Name) + .HasConversion(name => name.Value, name => CategoryName.From(name)) + .HasMaxLength(CategoryName.MaxLength); + + builder.OptimisticConcurrencyVersionMap(); + + builder.HasIndex(x => x.CreatedAt); + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Contexts/ApplicationReadDbContext.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Contexts/ApplicationReadDbContext.cs new file mode 100644 index 0000000..3fbc27d --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Contexts/ApplicationReadDbContext.cs @@ -0,0 +1,21 @@ +using CSharpEssentials.EntityFrameworkCore; +using MuhammedTask.BuildingBlocks.Database.PostgreSQL.Contexts; +using MuhammedTask.Services.CategoryService.Domain.Categories.ReadModels; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.Services.CategoryService.Persistence.EntityFrameworkCore.Contexts; +public sealed class ApplicationReadDbContext : ReadDbContextBase +{ + public ApplicationReadDbContext( + DbContextOptions options, + IServiceScopeFactory serviceScopeFactory) : base(options, serviceScopeFactory) + { + } + public DbSet Categories => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.ApplySoftDeleteQueryFilter(); + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Contexts/ApplicationWriteDbContext.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Contexts/ApplicationWriteDbContext.cs new file mode 100644 index 0000000..109af12 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Contexts/ApplicationWriteDbContext.cs @@ -0,0 +1,24 @@ +using CSharpEssentials.EntityFrameworkCore; +using MuhammedTask.BuildingBlocks.Database.PostgreSQL.Contexts; +using MuhammedTask.Services.CategoryService.Domain.Categories; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.EntityFrameworkCore.Extensions; + +namespace MuhammedTask.Services.CategoryService.Persistence.EntityFrameworkCore.Contexts; +public sealed class ApplicationWriteDbContext : WriteDbContextBase +{ + public ApplicationWriteDbContext( + DbContextOptions options, + IServiceScopeFactory serviceScopeFactory) : base(options, serviceScopeFactory) + { + } + + public DbSet Categories => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.ApplySoftDeleteQueryFilter(); + modelBuilder.AddMassTransitOutboxEntities(); + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/20241219104032_Init.Designer.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/20241219104032_Init.Designer.cs new file mode 100644 index 0000000..f737108 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/20241219104032_Init.Designer.cs @@ -0,0 +1,308 @@ +// +using System; +using MuhammedTask.Services.CategoryService.Persistence.EntityFrameworkCore.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MuhammedTask.Services.CategoryService.Persistence.EntityFrameworkCore.Migrations.ApplicationWrite +{ + [DbContext(typeof(ApplicationWriteDbContext))] + [Migration("20241219104032_Init")] + partial class Init + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MuhammedTask.Services.CategoryService.Domain.Categories.Category", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("row_version"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_categories"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_categories_created_at"); + + b.ToTable("categories", (string)null); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone") + .HasColumnName("consumed"); + + b.Property("ConsumerId") + .HasColumnType("uuid") + .HasColumnName("consumer_id"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone") + .HasColumnName("delivered"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_time"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint") + .HasColumnName("last_sequence_number"); + + b.Property("LockId") + .HasColumnType("uuid") + .HasColumnName("lock_id"); + + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("ReceiveCount") + .HasColumnType("integer") + .HasColumnName("receive_count"); + + b.Property("Received") + .HasColumnType("timestamp with time zone") + .HasColumnName("received"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_inbox_state"); + + b.HasAlternateKey("MessageId", "ConsumerId") + .HasName("ak_inbox_state_message_id_consumer_id"); + + b.HasIndex("Delivered") + .HasDatabaseName("ix_inbox_state_delivered"); + + b.ToTable("inbox_state", (string)null); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("sequence_number"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text") + .HasColumnName("body"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("content_type"); + + b.Property("ConversationId") + .HasColumnType("uuid") + .HasColumnName("conversation_id"); + + b.Property("CorrelationId") + .HasColumnType("uuid") + .HasColumnName("correlation_id"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("destination_address"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("enqueue_time"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_time"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("fault_address"); + + b.Property("Headers") + .HasColumnType("text") + .HasColumnName("headers"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid") + .HasColumnName("inbox_consumer_id"); + + b.Property("InboxMessageId") + .HasColumnType("uuid") + .HasColumnName("inbox_message_id"); + + b.Property("InitiatorId") + .HasColumnType("uuid") + .HasColumnName("initiator_id"); + + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message_type"); + + b.Property("OutboxId") + .HasColumnType("uuid") + .HasColumnName("outbox_id"); + + b.Property("Properties") + .HasColumnType("text") + .HasColumnName("properties"); + + b.Property("RequestId") + .HasColumnType("uuid") + .HasColumnName("request_id"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("response_address"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_time"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("source_address"); + + b.HasKey("SequenceNumber") + .HasName("pk_outbox_message"); + + b.HasIndex("EnqueueTime") + .HasDatabaseName("ix_outbox_message_enqueue_time"); + + b.HasIndex("ExpirationTime") + .HasDatabaseName("ix_outbox_message_expiration_time"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique() + .HasDatabaseName("ix_outbox_message_outbox_id_sequence_number"); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique() + .HasDatabaseName("ix_outbox_message_inbox_message_id_inbox_consumer_id_sequence_"); + + b.ToTable("outbox_message", (string)null); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("outbox_id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone") + .HasColumnName("delivered"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint") + .HasColumnName("last_sequence_number"); + + b.Property("LockId") + .HasColumnType("uuid") + .HasColumnName("lock_id"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("row_version"); + + b.HasKey("OutboxId") + .HasName("pk_outbox_state"); + + b.HasIndex("Created") + .HasDatabaseName("ix_outbox_state_created"); + + b.ToTable("outbox_state", (string)null); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId") + .HasConstraintName("fk_outbox_message_outbox_state_outbox_id"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId") + .HasConstraintName("fk_outbox_message_inbox_state_inbox_message_id_inbox_consumer_"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/20241219104032_Init.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/20241219104032_Init.cs new file mode 100644 index 0000000..e536a9c --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/20241219104032_Init.cs @@ -0,0 +1,168 @@ +// +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MuhammedTask.Services.CategoryService.Persistence.EntityFrameworkCore.Migrations.ApplicationWrite +{ + /// + public partial class Init : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "categories", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + row_version = table.Column(type: "bytea", rowVersion: true, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(40)", maxLength: 40, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_categories", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "inbox_state", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + message_id = table.Column(type: "uuid", nullable: false), + consumer_id = table.Column(type: "uuid", nullable: false), + lock_id = table.Column(type: "uuid", nullable: false), + row_version = table.Column(type: "bytea", rowVersion: true, nullable: true), + received = table.Column(type: "timestamp with time zone", nullable: false), + receive_count = table.Column(type: "integer", nullable: false), + expiration_time = table.Column(type: "timestamp with time zone", nullable: true), + consumed = table.Column(type: "timestamp with time zone", nullable: true), + delivered = table.Column(type: "timestamp with time zone", nullable: true), + last_sequence_number = table.Column(type: "bigint", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_inbox_state", x => x.id); + table.UniqueConstraint("ak_inbox_state_message_id_consumer_id", x => new { x.message_id, x.consumer_id }); + }); + + migrationBuilder.CreateTable( + name: "outbox_state", + columns: table => new + { + outbox_id = table.Column(type: "uuid", nullable: false), + lock_id = table.Column(type: "uuid", nullable: false), + row_version = table.Column(type: "bytea", rowVersion: true, nullable: true), + created = table.Column(type: "timestamp with time zone", nullable: false), + delivered = table.Column(type: "timestamp with time zone", nullable: true), + last_sequence_number = table.Column(type: "bigint", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_outbox_state", x => x.outbox_id); + }); + + migrationBuilder.CreateTable( + name: "outbox_message", + columns: table => new + { + sequence_number = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + enqueue_time = table.Column(type: "timestamp with time zone", nullable: true), + sent_time = table.Column(type: "timestamp with time zone", nullable: false), + headers = table.Column(type: "text", nullable: true), + properties = table.Column(type: "text", nullable: true), + inbox_message_id = table.Column(type: "uuid", nullable: true), + inbox_consumer_id = table.Column(type: "uuid", nullable: true), + outbox_id = table.Column(type: "uuid", nullable: true), + message_id = table.Column(type: "uuid", nullable: false), + content_type = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + message_type = table.Column(type: "text", nullable: false), + body = table.Column(type: "text", nullable: false), + conversation_id = table.Column(type: "uuid", nullable: true), + correlation_id = table.Column(type: "uuid", nullable: true), + initiator_id = table.Column(type: "uuid", nullable: true), + request_id = table.Column(type: "uuid", nullable: true), + source_address = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + destination_address = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + response_address = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + fault_address = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + expiration_time = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_outbox_message", x => x.sequence_number); + table.ForeignKey( + name: "fk_outbox_message_inbox_state_inbox_message_id_inbox_consumer_", + columns: x => new { x.inbox_message_id, x.inbox_consumer_id }, + principalTable: "inbox_state", + principalColumns: new[] { "message_id", "consumer_id" }); + table.ForeignKey( + name: "fk_outbox_message_outbox_state_outbox_id", + column: x => x.outbox_id, + principalTable: "outbox_state", + principalColumn: "outbox_id"); + }); + + migrationBuilder.CreateIndex( + name: "ix_categories_created_at", + table: "categories", + column: "created_at"); + + migrationBuilder.CreateIndex( + name: "ix_inbox_state_delivered", + table: "inbox_state", + column: "delivered"); + + migrationBuilder.CreateIndex( + name: "ix_outbox_message_enqueue_time", + table: "outbox_message", + column: "enqueue_time"); + + migrationBuilder.CreateIndex( + name: "ix_outbox_message_expiration_time", + table: "outbox_message", + column: "expiration_time"); + + migrationBuilder.CreateIndex( + name: "ix_outbox_message_inbox_message_id_inbox_consumer_id_sequence_", + table: "outbox_message", + columns: new[] { "inbox_message_id", "inbox_consumer_id", "sequence_number" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_outbox_message_outbox_id_sequence_number", + table: "outbox_message", + columns: new[] { "outbox_id", "sequence_number" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_outbox_state_created", + table: "outbox_state", + column: "created"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "categories"); + + migrationBuilder.DropTable( + name: "outbox_message"); + + migrationBuilder.DropTable( + name: "inbox_state"); + + migrationBuilder.DropTable( + name: "outbox_state"); + } + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/ApplicationWriteDbContextModelSnapshot.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/ApplicationWriteDbContextModelSnapshot.cs new file mode 100644 index 0000000..ec8df33 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/ApplicationWriteDbContextModelSnapshot.cs @@ -0,0 +1,305 @@ +// +using System; +using MuhammedTask.Services.CategoryService.Persistence.EntityFrameworkCore.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MuhammedTask.Services.CategoryService.Persistence.EntityFrameworkCore.Migrations.ApplicationWrite +{ + [DbContext(typeof(ApplicationWriteDbContext))] + partial class ApplicationWriteDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MuhammedTask.Services.CategoryService.Domain.Categories.Category", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("created_by"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("row_version"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_categories"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_categories_created_at"); + + b.ToTable("categories", (string)null); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone") + .HasColumnName("consumed"); + + b.Property("ConsumerId") + .HasColumnType("uuid") + .HasColumnName("consumer_id"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone") + .HasColumnName("delivered"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_time"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint") + .HasColumnName("last_sequence_number"); + + b.Property("LockId") + .HasColumnType("uuid") + .HasColumnName("lock_id"); + + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("ReceiveCount") + .HasColumnType("integer") + .HasColumnName("receive_count"); + + b.Property("Received") + .HasColumnType("timestamp with time zone") + .HasColumnName("received"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_inbox_state"); + + b.HasAlternateKey("MessageId", "ConsumerId") + .HasName("ak_inbox_state_message_id_consumer_id"); + + b.HasIndex("Delivered") + .HasDatabaseName("ix_inbox_state_delivered"); + + b.ToTable("inbox_state", (string)null); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("sequence_number"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text") + .HasColumnName("body"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("content_type"); + + b.Property("ConversationId") + .HasColumnType("uuid") + .HasColumnName("conversation_id"); + + b.Property("CorrelationId") + .HasColumnType("uuid") + .HasColumnName("correlation_id"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("destination_address"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("enqueue_time"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_time"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("fault_address"); + + b.Property("Headers") + .HasColumnType("text") + .HasColumnName("headers"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid") + .HasColumnName("inbox_consumer_id"); + + b.Property("InboxMessageId") + .HasColumnType("uuid") + .HasColumnName("inbox_message_id"); + + b.Property("InitiatorId") + .HasColumnType("uuid") + .HasColumnName("initiator_id"); + + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message_type"); + + b.Property("OutboxId") + .HasColumnType("uuid") + .HasColumnName("outbox_id"); + + b.Property("Properties") + .HasColumnType("text") + .HasColumnName("properties"); + + b.Property("RequestId") + .HasColumnType("uuid") + .HasColumnName("request_id"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("response_address"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_time"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("source_address"); + + b.HasKey("SequenceNumber") + .HasName("pk_outbox_message"); + + b.HasIndex("EnqueueTime") + .HasDatabaseName("ix_outbox_message_enqueue_time"); + + b.HasIndex("ExpirationTime") + .HasDatabaseName("ix_outbox_message_expiration_time"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique() + .HasDatabaseName("ix_outbox_message_outbox_id_sequence_number"); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique() + .HasDatabaseName("ix_outbox_message_inbox_message_id_inbox_consumer_id_sequence_"); + + b.ToTable("outbox_message", (string)null); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("outbox_id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone") + .HasColumnName("delivered"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint") + .HasColumnName("last_sequence_number"); + + b.Property("LockId") + .HasColumnType("uuid") + .HasColumnName("lock_id"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("row_version"); + + b.HasKey("OutboxId") + .HasName("pk_outbox_state"); + + b.HasIndex("Created") + .HasDatabaseName("ix_outbox_state_created"); + + b.ToTable("outbox_state", (string)null); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId") + .HasConstraintName("fk_outbox_message_outbox_state_outbox_id"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId") + .HasConstraintName("fk_outbox_message_inbox_state_inbox_message_id_inbox_consumer_"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Repositories/Categories/EfCategoryCommandRepository.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Repositories/Categories/EfCategoryCommandRepository.cs new file mode 100644 index 0000000..d9ced66 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Repositories/Categories/EfCategoryCommandRepository.cs @@ -0,0 +1,43 @@ +using CSharpEssentials; +using MuhammedTask.Services.CategoryService.Domain.Categories.Fields; +using MuhammedTask.Services.CategoryService.Persistence.EntityFrameworkCore.Contexts; +using MuhammedTask.Services.CategoryService.Domain.Categories.Repositories; +using MuhammedTask.Services.CategoryService.Domain.Categories.Parameters; +using MuhammedTask.Services.CategoryService.Domain.Categories; +using Microsoft.EntityFrameworkCore; + +namespace MuhammedTask.Services.CategoryService.Persistence.EntityFrameworkCore.Repositories.Categories; + +internal sealed class EfCategoryCommandRepository( + ApplicationWriteDbContext context) : ICategoryCommandRepository +{ + public async Task> CreateCategoryAsync(CategoryCreateParameters parameters, CancellationToken cancellationToken = default) + { + Result categoryResult = Category.Create(parameters); + if (categoryResult.IsFailure) + return categoryResult.Errors; + + Category category = categoryResult.Value; + + await context.Categories.AddAsync(category, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + + return category.Id; + } + + public async Task DeleteCategoryAsync(CategoryId categoryId, CancellationToken cancellationToken = default) + { + Category? found = await context.Categories + .Where(Category => Category.Id == categoryId) + .FirstOrDefaultAsync(cancellationToken); + + if (found is null) + return CategoryErrors.CategoryDoesNotExistError(categoryId); + + found.Delete(); + context.Categories.Remove(found); + await context.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Repositories/Categories/EfCategoryQueryRepository.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Repositories/Categories/EfCategoryQueryRepository.cs new file mode 100644 index 0000000..0e9b1c4 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/EntityFrameworkCore/Repositories/Categories/EfCategoryQueryRepository.cs @@ -0,0 +1,31 @@ +using CSharpEssentials; +using MuhammedTask.Services.CategoryService.Domain.Categories.Fields; +using MuhammedTask.Services.CategoryService.Domain.Categories.ReadModels; +using MuhammedTask.Services.CategoryService.Domain.Categories.Repositories; +using MuhammedTask.Services.CategoryService.Persistence.EntityFrameworkCore.Contexts; +using Microsoft.EntityFrameworkCore; + +namespace MuhammedTask.Services.CategoryService.Persistence.EntityFrameworkCore.Repositories.Categories; +internal sealed class EfCategoryQueryRepository( + ApplicationReadDbContext context) : ICategoryQueryRepository +{ + public Task ExistsAsync(CategoryId categoryId, CancellationToken cancellationToken = default) + { + return context.Categories + .AnyAsync(Category => Category.Id == categoryId.Value, cancellationToken); + } + + public Task GetCategories(CancellationToken cancellationToken = default) + { + return context.Categories + .OrderByDescending(Category => Category.CreatedAt) + .ToArrayAsync(cancellationToken); + } + + public async Task> GetCategoryByIdAsync(CategoryId categoryId, CancellationToken cancellationToken = default) + { + return await context.Categories + .Where(Category => Category.Id == categoryId.Value) + .FirstOrDefaultAsync(cancellationToken); + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/Jobs/PeriodicMessagePublisher/PeriodicIntegrationEvent.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/Jobs/PeriodicMessagePublisher/PeriodicIntegrationEvent.cs new file mode 100644 index 0000000..fd23db3 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/Jobs/PeriodicMessagePublisher/PeriodicIntegrationEvent.cs @@ -0,0 +1,3 @@ +namespace MuhammedTask.IntegrationEvents.Jobs; + +public sealed record PeriodicIntegrationEvent(string JobInstanceId, DateTime Timestamp); \ No newline at end of file diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/Jobs/PeriodicMessagePublisher/PeriodicMessagePublisherJob.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/Jobs/PeriodicMessagePublisher/PeriodicMessagePublisherJob.cs new file mode 100644 index 0000000..1d086da --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/Jobs/PeriodicMessagePublisher/PeriodicMessagePublisherJob.cs @@ -0,0 +1,20 @@ +using CSharpEssentials.Time; +using MuhammedTask.BuildingBlocks.MessageBrokers.Base; +using MuhammedTask.IntegrationEvents.Jobs; +using Microsoft.Extensions.Logging; +using Quartz; + +namespace MuhammedTask.Services.CategoryService.Persistence.Jobs.PeriodicMessagePublisher; + +public sealed class PeriodicMessagePublisherJob( + IEventBus eventBus, + ILogger logger, + IDateTimeProvider dateTimeProvider +) : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + logger.LogInformation("Periodic message publisher job is running {InstanceId}", context.FireInstanceId); + await eventBus.PublishAsync(new PeriodicIntegrationEvent(context.FireInstanceId, dateTimeProvider.UtcNowDateTime), isTransactional: false); + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/Jobs/PeriodicMessagePublisher/PeriodicMessagePublisherJobSetup.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/Jobs/PeriodicMessagePublisher/PeriodicMessagePublisherJobSetup.cs new file mode 100644 index 0000000..67f1d6c --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/Jobs/PeriodicMessagePublisher/PeriodicMessagePublisherJobSetup.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.Options; +using Quartz; + +namespace MuhammedTask.Services.CategoryService.Persistence.Jobs.PeriodicMessagePublisher; + +public class PeriodicMessagePublisherJobSetup : IConfigureOptions +{ + public void Configure(QuartzOptions options) + { + var jobKey = JobKey.Create(nameof(PeriodicMessagePublisherJob)); + options + .AddJob(jobBuilder => jobBuilder.WithIdentity(jobKey)) + .AddTrigger(trigger => + trigger + .ForJob(jobKey) + .WithSimpleSchedule(schedule => + schedule.WithInterval(TimeSpan.FromSeconds(15)).RepeatForever())); + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/MuhammedTask.Services.CategoryService.Persistence.csproj b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/MuhammedTask.Services.CategoryService.Persistence.csproj new file mode 100644 index 0000000..5a84d19 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/MuhammedTask.Services.CategoryService.Persistence.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/PersistenceAssemblyReference.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/PersistenceAssemblyReference.cs new file mode 100644 index 0000000..c49e72d --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/PersistenceAssemblyReference.cs @@ -0,0 +1,7 @@ +using System.Reflection; + +namespace MuhammedTask.Services.CategoryService.Persistence; +public static class PersistenceAssemblyReference +{ + public static Assembly Assembly => typeof(PersistenceAssemblyReference).Assembly; +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/ServiceRegistrations/DependencyInjection.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/ServiceRegistrations/DependencyInjection.cs new file mode 100644 index 0000000..717de6c --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/ServiceRegistrations/DependencyInjection.cs @@ -0,0 +1,80 @@ +using CSharpEssentials.EntityFrameworkCore.Interceptors; +using MassTransit; +using MuhammedTask.BuildingBlocks.BackgroundJobs.Quartz; +using MuhammedTask.BuildingBlocks.Caching.Redis; +using MuhammedTask.BuildingBlocks.Database.PostgreSQL; +using MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit.EntityFrameworkCore; +using MuhammedTask.Services.CategoryService.Persistence.EntityFrameworkCore.Contexts; +using MuhammedTask.Services.Info; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using StackExchange.Redis; + +namespace MuhammedTask.Services.CategoryService.Persistence.ServiceRegistrations; +public static class DependencyInjection +{ + public static IServiceCollection AddPersistenceServices( + this IServiceCollection services, + IHostEnvironment environment, + IConfiguration configuration) + { + return services + .ConfigureCacheServices(environment, configuration) + .AddQuartzWithHostedService() + .ConfigureJobOptions() + .AddDatabaseServices(configuration) + .AddEventBus( + configure => configure.UsingRabbitMq((context, cfg) => + { + cfg.Host(configuration.GetConnectionString(ServiceKeys.RabbitMQ)); + cfg.ConfigureEndpoints(context); + }), + entityConfigure => entityConfigure.UsePostgres().UseBusOutbox(), + assemblies: PersistenceAssemblyReference.Assembly) + .RegisterRepositories() + .RegisterHttpServices() + .RegisterServices(); + } + private static IServiceCollection ConfigureCacheServices( + this IServiceCollection services, + IHostEnvironment environment, + IConfiguration configuration) + { + return services + .AddSingleton(sp => + { + string connectionString = configuration.GetConnectionString(ServiceKeys.Redis); + var configurationOptions = ConfigurationOptions.Parse(connectionString!, true); + configurationOptions.AbortOnConnectFail = false; + return ConnectionMultiplexer.Connect(configurationOptions); + }) + .AddCacheServices( + environment.ApplicationName, + redisConfigurations => redisConfigurations.Configuration = configuration.GetConnectionString(ServiceKeys.Redis), + memoryConfiguration => + { + }); + } + private static IServiceCollection AddDatabaseServices( + this IServiceCollection services, + IConfiguration configuration) + { + string migrationsAssembly = typeof(PersistenceAssemblyReference).Namespace; + return services + .AddSingleton() + .AddPooledPostgresDbContext( + configuration.GetConnectionString(ServiceKeys.Database.PostgresCategoryService)!, + options => options.MigrationsAssembly = migrationsAssembly) + .AddPooledPostgresDbContext( + configuration.GetConnectionString(ServiceKeys.Database.PostgresCategoryService)!, + options => + { + options.MigrationsAssembly = migrationsAssembly; + options.EnablePublishDomainEventsInterceptor = false; + options.EnableAuditableInterceptor = false; + options.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + }); + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/ServiceRegistrations/HttpServicesRegistrations.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/ServiceRegistrations/HttpServicesRegistrations.cs new file mode 100644 index 0000000..571bc09 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/ServiceRegistrations/HttpServicesRegistrations.cs @@ -0,0 +1,13 @@ +using MuhammedTask.BuildingBlocks.Persistence.HttpHandlers; +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.Services.CategoryService.Persistence.ServiceRegistrations; +internal static class HttpServicesRegistrations +{ + public static IServiceCollection RegisterHttpServices(this IServiceCollection services) + { + services.AddTransient(); + + return services; + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/ServiceRegistrations/JobOptionsRegistrations.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/ServiceRegistrations/JobOptionsRegistrations.cs new file mode 100644 index 0000000..934710e --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/ServiceRegistrations/JobOptionsRegistrations.cs @@ -0,0 +1,17 @@ +using MuhammedTask.Services.CategoryService.Persistence.Jobs.PeriodicMessagePublisher; +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.Services.CategoryService.Persistence.ServiceRegistrations; + +public static class JobOptionsRegistrations +{ + + public static IServiceCollection ConfigureJobOptions( + this IServiceCollection services + ) + { + services.ConfigureOptions(); + return services; + } + +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/ServiceRegistrations/RepositoryRegistrations.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/ServiceRegistrations/RepositoryRegistrations.cs new file mode 100644 index 0000000..0d7e36d --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/ServiceRegistrations/RepositoryRegistrations.cs @@ -0,0 +1,12 @@ +using MuhammedTask.Services.CategoryService.Domain.Categories.Repositories; +using MuhammedTask.Services.CategoryService.Persistence.EntityFrameworkCore.Repositories.Categories; +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.Services.CategoryService.Persistence.ServiceRegistrations; +internal static class RepositoryRegistrations +{ + public static IServiceCollection RegisterRepositories(this IServiceCollection services) => + services + .AddScoped() + .AddScoped(); +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/ServiceRegistrations/ServicesRegistrations.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/ServiceRegistrations/ServicesRegistrations.cs new file mode 100644 index 0000000..958fd80 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.Persistence/ServiceRegistrations/ServicesRegistrations.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.Services.CategoryService.Persistence.ServiceRegistrations; + +internal static class ServicesRegistrations +{ + public static IServiceCollection RegisterServices(this IServiceCollection services) + { + return services; + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/Endpoints/Categories/v1/Create/CategoryCreateEndpoint.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/Endpoints/Categories/v1/Create/CategoryCreateEndpoint.cs new file mode 100644 index 0000000..c1b5be9 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/Endpoints/Categories/v1/Create/CategoryCreateEndpoint.cs @@ -0,0 +1,36 @@ +using Carter; +using CSharpEssentials; +using MediatR; +using MuhammedTask.BuildingBlocks.Presentation.Endpoints; +using MuhammedTask.Services.CategoryService.Application.Categories.v1.Commands.Create; +using Microsoft.AspNetCore.Mvc; + +namespace MuhammedTask.Services.CategoryService.WebApi.Endpoints.Categories.v1.Create; +public sealed class CategoryCreateEndpoint : CarterModule +{ + public sealed record CategoryCreateRequest(string? Name) + { + public CategoryCreateCommand ToCommand() => new(Name); + } + + public override void AddRoutes(IEndpointRouteBuilder app) + { + RouteGroupBuilder routeGroup = app + .CreateVersionedGroup(Tags.Categories) + .RequireAuthorization(); + + routeGroup.MapPost(string.Empty, CreateCategory) + .Produces(HttpCodes.Created) + .ProducesProblem() + .WithDescription("Create Category") + .WithName(nameof(CreateCategory)); + } + + private static Task CreateCategory([FromBody] CategoryCreateRequest request, ISender sender, CancellationToken cancellationToken = default) => + sender + .Send(request.ToCommand(), cancellationToken) + .Match( + category => TypedResults.Created($"/{Tags.Categories}/{category.Value}", category.Value), + errors => errors.ToProblemResult(), + cancellationToken); +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/Endpoints/Categories/v1/Delete/CategoryDeleteEndpoint.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/Endpoints/Categories/v1/Delete/CategoryDeleteEndpoint.cs new file mode 100644 index 0000000..91103f8 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/Endpoints/Categories/v1/Delete/CategoryDeleteEndpoint.cs @@ -0,0 +1,31 @@ +using Carter; +using CSharpEssentials; +using MediatR; +using MuhammedTask.BuildingBlocks.Presentation.Endpoints; +using MuhammedTask.Services.CategoryService.Application.Categories.v1.Commands.Delete; + +namespace MuhammedTask.Services.CategoryService.WebApi.Endpoints.Categories.v1.Delete; + +public sealed class CategoryDeleteEndpoint : CarterModule +{ + public override void AddRoutes(IEndpointRouteBuilder app) + { + RouteGroupBuilder routeGroup = app + .CreateVersionedGroup(Tags.Categories) + .RequireAuthorization(); + + routeGroup.MapDelete("{categoryId:guid}", DeleteCategory) + .Produces(HttpCodes.NoContent) + .ProducesProblem() + .WithDescription("Delete Category by id") + .WithName(nameof(DeleteCategory)); + } + + private static Task DeleteCategory(Guid categoryId, ISender sender, CancellationToken cancellationToken = default) => + sender + .Send(new CategoryDeleteCommand(categoryId), cancellationToken) + .Match( + TypedResults.NoContent, + errors => errors.ToProblemResult(), + cancellationToken); +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/Endpoints/Categories/v1/Exist/CategoryExistEndpoint.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/Endpoints/Categories/v1/Exist/CategoryExistEndpoint.cs new file mode 100644 index 0000000..c29f158 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/Endpoints/Categories/v1/Exist/CategoryExistEndpoint.cs @@ -0,0 +1,31 @@ +using Carter; +using CSharpEssentials; +using MediatR; +using MuhammedTask.BuildingBlocks.Presentation.Endpoints; +using MuhammedTask.Services.CategoryService.Application.Categories.v1.Queries.Exist; + +namespace MuhammedTask.Services.CategoryService.WebApi.Endpoints.Categories.v1.Exist; + +public sealed class CategoryExistEndpoint : CarterModule +{ + public override void AddRoutes(IEndpointRouteBuilder app) + { + RouteGroupBuilder routeGroup = app + .CreateVersionedGroup(Tags.Exist) + .RequireAuthorization(); + + routeGroup.MapGet("{categoryId:guid}", ExistCategory) + .Produces() + .ProducesProblem() + .WithDescription("Check if Category exists by id") + .WithName(nameof(ExistCategory)); + } + + private static Task ExistCategory(Guid categoryId, ISender sender, CancellationToken cancellationToken = default) => + sender + .Send(new CategoryExistQuery(categoryId), cancellationToken) + .Match( + exist => TypedResults.Ok(exist), + errors => errors.ToProblemResult(), + cancellationToken); +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/Endpoints/Categories/v1/Get/CategoryGetEndpoint.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/Endpoints/Categories/v1/Get/CategoryGetEndpoint.cs new file mode 100644 index 0000000..698ea55 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/Endpoints/Categories/v1/Get/CategoryGetEndpoint.cs @@ -0,0 +1,32 @@ +using Carter; +using CSharpEssentials; +using MediatR; +using MuhammedTask.BuildingBlocks.Presentation.Endpoints; +using MuhammedTask.Services.CategoryService.Application.Categories.v1.Queries.Get; +using MuhammedTask.Services.CategoryService.Application.Products.v1.Models; + +namespace MuhammedTask.Services.CategoryService.WebApi.Endpoints.Categories.v1.Get; + +public sealed class CategoryGetEndpoint : CarterModule +{ + public override void AddRoutes(IEndpointRouteBuilder app) + { + RouteGroupBuilder routeGroup = app + .CreateVersionedGroup(Tags.Categories) + .RequireAuthorization(); + + routeGroup.MapGet("{categoryId:guid}", GetCategory) + .Produces() + .ProducesProblem() + .WithDescription("Get Category by id") + .WithName(nameof(GetCategory)); + } + + private static Task GetCategory(Guid categoryId, ISender sender, CancellationToken cancellationToken = default) => + sender + .Send(new GetCategoryQuery(categoryId), cancellationToken) + .Match( + Category => TypedResults.Ok(Category), + errors => errors.ToProblemResult(), + cancellationToken); +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/Endpoints/Categories/v1/List/CategoryListEndpoint.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/Endpoints/Categories/v1/List/CategoryListEndpoint.cs new file mode 100644 index 0000000..bf3257a --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/Endpoints/Categories/v1/List/CategoryListEndpoint.cs @@ -0,0 +1,32 @@ +using Carter; +using CSharpEssentials; +using MediatR; +using MuhammedTask.BuildingBlocks.Presentation.Endpoints; +using MuhammedTask.Services.CategoryService.Application.Categories.v1.Queries.List; +using MuhammedTask.Services.CategoryService.Application.Products.v1.Models; + +namespace MuhammedTask.Services.CategoryService.WebApi.Endpoints.Categories.v1.List; + +public sealed class CategoryListEndpoint : CarterModule +{ + public override void AddRoutes(IEndpointRouteBuilder app) + { + RouteGroupBuilder routeGroup = app + .CreateVersionedGroup(Tags.Categories) + .RequireAuthorization(); + + routeGroup.MapGet(string.Empty, GetCategories) + .Produces() + .ProducesProblem() + .WithDescription("Get Categories") + .WithName(nameof(GetCategories)); + } + + private static Task GetCategories(ISender sender, CancellationToken cancellationToken = default) => + sender + .Send(new GetCategoryListQuery(), cancellationToken) + .Match( + Categorys => TypedResults.Ok(Categorys), + errors => errors.ToProblemResult(), + cancellationToken); +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/Endpoints/Tags.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/Endpoints/Tags.cs new file mode 100644 index 0000000..6257e5c --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/Endpoints/Tags.cs @@ -0,0 +1,7 @@ +namespace MuhammedTask.Services.CategoryService.WebApi.Endpoints; + +public static class Tags +{ + public const string Categories = "categories"; + public const string Exist = "exist"; +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/MuhammedTask.Services.CategoryService.WebApi.csproj b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/MuhammedTask.Services.CategoryService.WebApi.csproj new file mode 100644 index 0000000..36fbba7 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/MuhammedTask.Services.CategoryService.WebApi.csproj @@ -0,0 +1,15 @@ + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/Program.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/Program.cs new file mode 100644 index 0000000..dc128d1 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/Program.cs @@ -0,0 +1,18 @@ +using MuhammedTask.BuildingBlocks.Database.Migrator; +using MuhammedTask.Services.CategoryService.Persistence.EntityFrameworkCore.Contexts; +using MuhammedTask.Services.CategoryService.WebApi.ServiceRegistrations; +using MuhammedTask.Services.Info; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.Services.AddServiceRegistrations(builder.Environment, builder.Configuration); +builder.AddSeqEndpoint(ServiceKeys.Seq); + +WebApplication app = builder.Build(); + +if (app.Environment.IsDevelopment()) + await app.MigrateAsync(); + +app.UseServices(); + +await app.RunAsync(); diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/Properties/launchSettings.json b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/Properties/launchSettings.json new file mode 100644 index 0000000..460fcfa --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5153", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7113;http://localhost:5153", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/ServiceRegistrations/DependencyInjection.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/ServiceRegistrations/DependencyInjection.cs new file mode 100644 index 0000000..349afdb --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/ServiceRegistrations/DependencyInjection.cs @@ -0,0 +1,71 @@ +using System.Reflection; +using Carter; +using CSharpEssentials; +using CSharpEssentials.AspNetCore; +using CSharpEssentials.RequestResponseLogging; +using MuhammedTask.BuildingBlocks.Application.Shared.Constants; +using MuhammedTask.BuildingBlocks.Presentation.Authentication; +using MuhammedTask.BuildingBlocks.Presentation.Cors; +using MuhammedTask.BuildingBlocks.Presentation.HealthChecks; +using MuhammedTask.BuildingBlocks.Presentation.SessionContexts; +using MuhammedTask.Services.CategoryService.Application.ServiceRegistrations; +using MuhammedTask.Services.CategoryService.Persistence.ServiceRegistrations; +using MuhammedTask.Services.Info; +using Microsoft.Net.Http.Headers; + +namespace MuhammedTask.Services.CategoryService.WebApi.ServiceRegistrations; + +internal static class DependencyInjection +{ + internal static IServiceCollection AddServiceRegistrations( + this IServiceCollection services, + IHostEnvironment hostEnvironment, + IConfiguration configuration) + { + //services.AddControllers(); + //services.AddRouting(); + services.AddCarter(); + services.AddHealthChecks(); + + return services + .AddAllAcceptCors() + .AddHttpContextAccessor() + .AddApplicationServices() + .AddPersistenceServices(hostEnvironment, configuration) + .AddSessionContext() + .ConfigureHttpClients() + .AddExceptionHandler() + .ConfigureModelValidatorResponse() + .ConfigureSystemTextJson() + .AddEnhancedProblemDetails() + .AddAndConfigureApiVersioning() + .AddSwagger(SecuritySchemes.JwtBearerTokenSecurity, Assembly.GetExecutingAssembly()) + .AddKeycloakJwtBearer(keycloakServiceId: ServiceKeys.Keycloak, realm: "products") + .ConfigureTelemetries(hostEnvironment); + } + + internal static WebApplication UseServices( + this WebApplication app) + { + app.UseVersionableSwagger(); + app.AddRequestResponseLogging(opt => + { + opt.IgnorePaths("/health"); + var loggingOptions = LoggingOptions.CreateAllFields(); + loggingOptions.HeaderKeys.Add(HeaderNames.AcceptLanguage); + loggingOptions.HeaderKeys.Add(CustomHeaderNames.TenantId); + opt.UseLogger(app.Services.GetRequiredService(), loggingOptions); + }); + app.UseExceptionHandler(); + app.UseStatusCodePages(); + app.UseCors("all"); + //app.UseRouting(); + //app.MapControllers(); + app.MapCarter(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseDefaultHealthChecks(); + + return app; + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/ServiceRegistrations/OpenTelemetryDependencyInjection.cs b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/ServiceRegistrations/OpenTelemetryDependencyInjection.cs new file mode 100644 index 0000000..afc927e --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/ServiceRegistrations/OpenTelemetryDependencyInjection.cs @@ -0,0 +1,20 @@ +using MuhammedTask.BuildingBlocks.Caching.Redis; +using MuhammedTask.BuildingBlocks.Database.PostgreSQL; +using MuhammedTask.BuildingBlocks.OpenTelemetry.Base; + +namespace MuhammedTask.Services.CategoryService.WebApi.ServiceRegistrations; + +internal static class OpenTelemetryDependencyInjection +{ + internal static IServiceCollection ConfigureTelemetries( + this IServiceCollection services, + IHostEnvironment hostEnvironment) + { + services + .ConfigureOpenTelemetry(hostEnvironment.ApplicationName) + .ConfigurePostgresSqlTelemetry() + .ConfigureCacheServiceTelemetry() + .AddOtlpExporter(); + return services; + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/appsettings.Development.json b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/appsettings.json b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/appsettings.json new file mode 100644 index 0000000..bfd9e23 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Swagger": { + "Title": "Category Serice", + "Description": "MuhammedTask Category Service", + "License": "MIT", + "LicenseUrl": "https://opensource.org/licenses/MIT" + } +} diff --git a/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/migration.sh b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/migration.sh new file mode 100644 index 0000000..6ca4456 --- /dev/null +++ b/muhammed-task/source/src/Services/CategoryService/MuhammedTask.Services.CategoryService.WebApi/migration.sh @@ -0,0 +1,15 @@ +#!/bin/bash +MIGRATION_NAME=$1 + +if [ -z "$MIGRATION_NAME" ]; then + echo "Error: Migration name is required" + exit 1 +fi + +dotnet ef migrations add $MIGRATION_NAME --project ../MuhammedTask.Services.CategoryService.Persistence --context ApplicationWriteDbContext --output-dir EntityFrameworkCore/Migrations/ApplicationWrite + +if [ $? -eq 0 ]; then + echo "Migration '$MIGRATION_NAME' added successfully." +else + echo "Failed to add migration '$MIGRATION_NAME'." +fi diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ApplicationAssemblyReference.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ApplicationAssemblyReference.cs new file mode 100644 index 0000000..c65e509 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ApplicationAssemblyReference.cs @@ -0,0 +1,8 @@ +using System.Reflection; + +namespace MuhammedTask.Services.ProductReviewService.Application; + +public static class ApplicationAssemblyReference +{ + public static readonly Assembly Assembly = typeof(ApplicationAssemblyReference).Assembly; +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/MuhammedTask.Services.ProductReviewService.Application.csproj b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/MuhammedTask.Services.ProductReviewService.Application.csproj new file mode 100644 index 0000000..3ea959a --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/MuhammedTask.Services.ProductReviewService.Application.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Create/CreateProductReviewCommand.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Create/CreateProductReviewCommand.cs new file mode 100644 index 0000000..af2211a --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Create/CreateProductReviewCommand.cs @@ -0,0 +1,15 @@ +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Parameters; + +namespace MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Commands.Create; + +public sealed record CreateProductReviewCommand( + Guid Product, + Guid User, + int? Rating, + string? Comment) : ICommand +{ + public ProductReviewCreateParameters ToParameters() => + new(ProductId.From(Product), UserId.From(User), Rating, Comment); +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Create/CreateProductReviewCommandHandler.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Create/CreateProductReviewCommandHandler.cs new file mode 100644 index 0000000..94aa252 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Create/CreateProductReviewCommandHandler.cs @@ -0,0 +1,23 @@ +using CSharpEssentials; +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Repositories; + +namespace MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Commands.Create; + +internal sealed class CreateProductReviewCommandHandler( + IProductReviewCommandRepository repository, + IProductReviewQueryRepository queryRepository) : ICommandHandler +{ + public async Task> Handle(CreateProductReviewCommand request, CancellationToken cancellationToken) + { + var productId = ProductId.From(request.Product); + var userId = UserId.From(request.User); + + bool alreadyReviewed = await queryRepository.ExistsAsync(productId, userId, cancellationToken); + if (alreadyReviewed) + return Domain.ProductReviews.ProductReviewErrors.AlreadyReviewedError; + + return await repository.CreateProductReviewAsync(request.ToParameters(), cancellationToken); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Create/CreateProductReviewCommandValidator.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Create/CreateProductReviewCommandValidator.cs new file mode 100644 index 0000000..b6d085a --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Create/CreateProductReviewCommandValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; + +namespace MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Commands.Create; + +internal sealed class CreateProductReviewCommandValidator : AbstractValidator +{ + public CreateProductReviewCommandValidator() + { + RuleFor(x => x.Product).NotEmpty(); + RuleFor(x => x.User).NotEmpty(); + RuleFor(x => x.Rating) + .NotNull() + .InclusiveBetween(ReviewRating.MinValue, ReviewRating.MaxValue); + RuleFor(x => x.Comment) + .MaximumLength(ReviewComment.MaxLength) + .When(x => x.Comment is not null); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Delete/DeleteProductReviewCommand.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Delete/DeleteProductReviewCommand.cs new file mode 100644 index 0000000..aced196 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Delete/DeleteProductReviewCommand.cs @@ -0,0 +1,5 @@ +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; + +namespace MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Commands.Delete; + +public sealed record DeleteProductReviewCommand(Guid Id, Guid UserId) : ICommand; diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Delete/DeleteProductReviewCommandHandler.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Delete/DeleteProductReviewCommandHandler.cs new file mode 100644 index 0000000..57bd7e7 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Delete/DeleteProductReviewCommandHandler.cs @@ -0,0 +1,16 @@ +using CSharpEssentials; +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Repositories; + +namespace MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Commands.Delete; + +internal sealed class DeleteProductReviewCommandHandler( + IProductReviewCommandRepository repository) : ICommandHandler +{ + public Task Handle(DeleteProductReviewCommand request, CancellationToken cancellationToken) => + repository.DeleteProductReviewAsync( + ProductReviewId.From(request.Id), + UserId.From(request.UserId), + cancellationToken); +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Delete/DeleteProductReviewCommandValidator.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Delete/DeleteProductReviewCommandValidator.cs new file mode 100644 index 0000000..c22872f --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Delete/DeleteProductReviewCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Commands.Delete; + +internal sealed class DeleteProductReviewCommandValidator : AbstractValidator +{ + public DeleteProductReviewCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.UserId).NotEmpty(); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Update/UpdateProductReviewCommand.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Update/UpdateProductReviewCommand.cs new file mode 100644 index 0000000..6b089fa --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Update/UpdateProductReviewCommand.cs @@ -0,0 +1,15 @@ +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Parameters; + +namespace MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Commands.Update; + +public sealed record UpdateProductReviewCommand( + Guid Id, + Guid User, + int? Rating, + string? Comment) : ICommand +{ + public ProductReviewUpdateParameters ToParameters() => + new(ProductReviewId.From(Id), UserId.From(User), Rating, Comment); +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Update/UpdateProductReviewCommandHandler.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Update/UpdateProductReviewCommandHandler.cs new file mode 100644 index 0000000..5c7726d --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Update/UpdateProductReviewCommandHandler.cs @@ -0,0 +1,12 @@ +using CSharpEssentials; +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Repositories; + +namespace MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Commands.Update; + +internal sealed class UpdateProductReviewCommandHandler( + IProductReviewCommandRepository repository) : ICommandHandler +{ + public Task Handle(UpdateProductReviewCommand request, CancellationToken cancellationToken) => + repository.UpdateProductReviewAsync(request.ToParameters(), cancellationToken); +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Update/UpdateProductReviewCommandValidator.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Update/UpdateProductReviewCommandValidator.cs new file mode 100644 index 0000000..fcdd977 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Commands/Update/UpdateProductReviewCommandValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; + +namespace MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Commands.Update; + +internal sealed class UpdateProductReviewCommandValidator : AbstractValidator +{ + public UpdateProductReviewCommandValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.User).NotEmpty(); + RuleFor(x => x.Rating) + .NotNull() + .InclusiveBetween(ReviewRating.MinValue, ReviewRating.MaxValue); + RuleFor(x => x.Comment) + .MaximumLength(ReviewComment.MaxLength) + .When(x => x.Comment is not null); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Events/ProductReviewCreatedDomainEventHandler.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Events/ProductReviewCreatedDomainEventHandler.cs new file mode 100644 index 0000000..74d975f --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Events/ProductReviewCreatedDomainEventHandler.cs @@ -0,0 +1,32 @@ +using MediatR; +using MuhammedTask.BuildingBlocks.Caching.Base; +using MuhammedTask.BuildingBlocks.MessageBrokers.Base; +using MuhammedTask.IntegrationEvents.ProductReviews; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Events; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Repositories; +using Microsoft.Extensions.Logging; + +namespace MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Events; + +internal sealed class ProductReviewCreatedDomainEventHandler( + ILogger logger, + IEventBus eventBus, + ICacheService cacheService, + IProductReviewQueryRepository queryRepository) : INotificationHandler +{ + public async Task Handle(ProductReviewCreatedDomainEvent notification, CancellationToken cancellationToken) + { + logger.LogInformation("ProductReviewCreatedDomainEvent handled for review {ReviewId}", notification.Id.Value); + + await cacheService.InvalidateTagAsync("reviews"); + await cacheService.InvalidateTagAsync("average-rating"); + + double averageRating = await queryRepository.GetAverageRatingByProductIdAsync(notification.ProductId, cancellationToken); + int reviewCount = await queryRepository.GetReviewCountByProductIdAsync(notification.ProductId, cancellationToken); + + await eventBus.PublishAsync( + new ProductReviewCreatedIntegrationEvent(notification.ProductId.Value, averageRating, reviewCount), + isTransactional: false, + cancellationToken); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/IntegrationEvents/ProductReviewCreatedIntegrationEvent.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/IntegrationEvents/ProductReviewCreatedIntegrationEvent.cs new file mode 100644 index 0000000..8a3cad3 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/IntegrationEvents/ProductReviewCreatedIntegrationEvent.cs @@ -0,0 +1,6 @@ +namespace MuhammedTask.IntegrationEvents.ProductReviews; + +public sealed record ProductReviewCreatedIntegrationEvent( + Guid ProductId, + double NewAverageRating, + int ReviewCount); diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Models/PaginatedResponse.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Models/PaginatedResponse.cs new file mode 100644 index 0000000..489f7d6 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Models/PaginatedResponse.cs @@ -0,0 +1,20 @@ +namespace MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Models; + +public readonly record struct PaginatedResponse +{ + private PaginatedResponse(int pageNumber, int pageSize, int totalCount, T[] items) + { + PageNumber = pageNumber; + PageSize = pageSize; + TotalCount = totalCount; + Items = items; + } + + public readonly int PageNumber { get; init; } + public readonly int PageSize { get; init; } + public readonly int TotalCount { get; init; } + public readonly T[] Items { get; init; } + + public static PaginatedResponse Create(int pageNumber, int pageSize, int totalCount, T[] items) => + new(pageNumber, pageSize, totalCount, items); +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Models/ProductAverageRatingViewModel.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Models/ProductAverageRatingViewModel.cs new file mode 100644 index 0000000..ea2c63c --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Models/ProductAverageRatingViewModel.cs @@ -0,0 +1,18 @@ +namespace MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Models; + +public readonly record struct ProductAverageRatingViewModel +{ + private ProductAverageRatingViewModel(Guid productId, double averageRating, int reviewCount) + { + ProductId = productId; + AverageRating = averageRating; + ReviewCount = reviewCount; + } + + public readonly Guid ProductId { get; init; } + public readonly double AverageRating { get; init; } + public readonly int ReviewCount { get; init; } + + public static ProductAverageRatingViewModel Create(Guid productId, double averageRating, int reviewCount) => + new(productId, averageRating, reviewCount); +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Models/ProductReviewViewModel.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Models/ProductReviewViewModel.cs new file mode 100644 index 0000000..5f4c8ea --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Models/ProductReviewViewModel.cs @@ -0,0 +1,28 @@ +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.ReadModels; + +namespace MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Models; + +public readonly record struct ProductReviewViewModel +{ + private ProductReviewViewModel(ProductReviewReadModel r) + { + Id = r.Id; + ProductId = r.ProductId; + UserId = r.UserId; + Rating = r.Rating; + Comment = r.Comment; + CreatedAt = r.CreatedAt; + UpdatedAt = r.UpdatedAt; + } + + public readonly Guid Id { get; init; } + public readonly Guid ProductId { get; init; } + public readonly Guid UserId { get; init; } + public readonly int Rating { get; init; } + public readonly string? Comment { get; init; } + public readonly DateTimeOffset CreatedAt { get; init; } + public readonly DateTimeOffset? UpdatedAt { get; init; } + + public static ProductReviewViewModel Create(ProductReviewReadModel r) => new(r); + public static ProductReviewViewModel[] Create(ProductReviewReadModel[] rs) => [.. rs.Select(Create)]; +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetAverageRating/GetProductAverageRatingQuery.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetAverageRating/GetProductAverageRatingQuery.cs new file mode 100644 index 0000000..61f37c2 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetAverageRating/GetProductAverageRatingQuery.cs @@ -0,0 +1,13 @@ +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Models; + +namespace MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Queries.GetAverageRating; + +public sealed record GetProductAverageRatingQuery(Guid ProductId) : ICachedQuery +{ + public bool BypassCache => false; + public bool CacheFailures => true; + public string CacheKey => $"product:{ProductId}:average-rating"; + public TimeSpan Expiration => TimeSpan.FromMinutes(10); + public string[] Tags => [$"product:{ProductId}", "average-rating"]; +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetAverageRating/GetProductAverageRatingQueryHandler.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetAverageRating/GetProductAverageRatingQueryHandler.cs new file mode 100644 index 0000000..77b88d8 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetAverageRating/GetProductAverageRatingQueryHandler.cs @@ -0,0 +1,19 @@ +using CSharpEssentials; +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Models; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Repositories; + +namespace MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Queries.GetAverageRating; + +internal sealed class GetProductAverageRatingQueryHandler( + IProductReviewQueryRepository repository) : ICachedQueryHandler +{ + public async Task> Handle(GetProductAverageRatingQuery request, CancellationToken cancellationToken) + { + var productId = ProductId.From(request.ProductId); + double averageRating = await repository.GetAverageRatingByProductIdAsync(productId, cancellationToken); + int reviewCount = await repository.GetReviewCountByProductIdAsync(productId, cancellationToken); + return ProductAverageRatingViewModel.Create(request.ProductId, averageRating, reviewCount); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetAverageRating/GetProductAverageRatingQueryValidator.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetAverageRating/GetProductAverageRatingQueryValidator.cs new file mode 100644 index 0000000..fc21e35 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetAverageRating/GetProductAverageRatingQueryValidator.cs @@ -0,0 +1,9 @@ +using FluentValidation; + +namespace MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Queries.GetAverageRating; + +internal sealed class GetProductAverageRatingQueryValidator : AbstractValidator +{ + public GetProductAverageRatingQueryValidator() => + RuleFor(x => x.ProductId).NotEmpty(); +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetById/GetProductReviewByIdQuery.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetById/GetProductReviewByIdQuery.cs new file mode 100644 index 0000000..a627e2b --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetById/GetProductReviewByIdQuery.cs @@ -0,0 +1,13 @@ +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Models; + +namespace MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Queries.GetById; + +public sealed record GetProductReviewByIdQuery(Guid ProductReviewId) : ICachedQuery +{ + public bool BypassCache => false; + public bool CacheFailures => true; + public string CacheKey => $"productreview:{ProductReviewId}"; + public TimeSpan Expiration => TimeSpan.FromMinutes(10); + public string[] Tags => []; +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetById/GetProductReviewByIdQueryHandler.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetById/GetProductReviewByIdQueryHandler.cs new file mode 100644 index 0000000..d008491 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetById/GetProductReviewByIdQueryHandler.cs @@ -0,0 +1,23 @@ +using CSharpEssentials; +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Models; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.ReadModels; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Repositories; + +namespace MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Queries.GetById; + +internal sealed class GetProductReviewByIdQueryHandler( + IProductReviewQueryRepository repository) : ICachedQueryHandler +{ + public async Task> Handle(GetProductReviewByIdQuery request, CancellationToken cancellationToken) + { + var productReviewId = ProductReviewId.From(request.ProductReviewId); + Maybe review = await repository.GetProductReviewByIdAsync(productReviewId, cancellationToken); + + return review.Match>( + value => ProductReviewViewModel.Create(value), + () => ProductReviewErrors.NotFoundError(productReviewId)); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetById/GetProductReviewByIdQueryValidator.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetById/GetProductReviewByIdQueryValidator.cs new file mode 100644 index 0000000..3e8af9f --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetById/GetProductReviewByIdQueryValidator.cs @@ -0,0 +1,9 @@ +using FluentValidation; + +namespace MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Queries.GetById; + +internal sealed class GetProductReviewByIdQueryValidator : AbstractValidator +{ + public GetProductReviewByIdQueryValidator() => + RuleFor(x => x.ProductReviewId).NotEmpty(); +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetList/GetProductReviewListQuery.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetList/GetProductReviewListQuery.cs new file mode 100644 index 0000000..8da2e81 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetList/GetProductReviewListQuery.cs @@ -0,0 +1,13 @@ +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Models; + +namespace MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Queries.GetList; + +public sealed record GetProductReviewListQuery(Guid ProductId, int PageNumber = 1, int PageSize = 10) : ICachedQuery> +{ + public bool BypassCache => false; + public bool CacheFailures => true; + public string CacheKey => $"product:{ProductId}:reviews:page:{PageNumber}:size:{PageSize}"; + public TimeSpan Expiration => TimeSpan.FromMinutes(10); + public string[] Tags => [$"product:{ProductId}", "reviews"]; +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetList/GetProductReviewListQueryHandler.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetList/GetProductReviewListQueryHandler.cs new file mode 100644 index 0000000..e01852d --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetList/GetProductReviewListQueryHandler.cs @@ -0,0 +1,19 @@ +using CSharpEssentials; +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Models; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.ReadModels; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Repositories; + +namespace MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Queries.GetList; + +internal sealed class GetProductReviewListQueryHandler( + IProductReviewQueryRepository repository) : ICachedQueryHandler> +{ + public async Task>> Handle(GetProductReviewListQuery request, CancellationToken cancellationToken) + { + var productId = ProductId.From(request.ProductId); + (ProductReviewReadModel[] items, int totalCount) = await repository.GetProductReviewsByProductIdAsync(productId, request.PageNumber, request.PageSize, cancellationToken); + return PaginatedResponse.Create(request.PageNumber, request.PageSize, totalCount, ProductReviewViewModel.Create(items)); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetList/GetProductReviewListQueryValidator.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetList/GetProductReviewListQueryValidator.cs new file mode 100644 index 0000000..1e002d0 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ProductReviews/v1/Queries/GetList/GetProductReviewListQueryValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Queries.GetList; + +internal sealed class GetProductReviewListQueryValidator : AbstractValidator +{ + public GetProductReviewListQueryValidator() + { + RuleFor(x => x.ProductId).NotEmpty(); + RuleFor(x => x.PageNumber).GreaterThanOrEqualTo(1); + RuleFor(x => x.PageSize).InclusiveBetween(1, 100); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ServiceRegistrations/DependencyInjection.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ServiceRegistrations/DependencyInjection.cs new file mode 100644 index 0000000..b369956 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Application/ServiceRegistrations/DependencyInjection.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using MuhammedTask.BuildingBlocks.Application.DependencyInjections; +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.Services.ProductReviewService.Application.ServiceRegistrations; + +public static class DependencyInjection +{ + public static IServiceCollection AddApplicationServices(this IServiceCollection services) + { + return services + .AddDateTimeProvider() + .AddMediatR(configure => + { + configure.RegisterServicesFromAssembly(ApplicationAssemblyReference.Assembly); + configure.AddDefaultBehaviors(); + }) + .AddValidatorsFromAssembly(ApplicationAssemblyReference.Assembly, includeInternalTypes: true); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain.Tests/MuhammedTask.Services.ProductReviewService.Domain.Tests.csproj b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain.Tests/MuhammedTask.Services.ProductReviewService.Domain.Tests.csproj new file mode 100644 index 0000000..8e8e635 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain.Tests/MuhammedTask.Services.ProductReviewService.Domain.Tests.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain.Tests/ProductReviews/ProductReviewTests.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain.Tests/ProductReviews/ProductReviewTests.cs new file mode 100644 index 0000000..8c93779 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain.Tests/ProductReviews/ProductReviewTests.cs @@ -0,0 +1,131 @@ +using CSharpEssentials; +using FluentAssertions; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Events; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Parameters; + +namespace MuhammedTask.Services.ProductReviewService.Domain.Tests.ProductReviews; + +public sealed class ProductReviewTests +{ + private static ProductReviewCreateParameters ValidCreateParameters( + ProductId? productId = null, + UserId? userId = null, + int? rating = 4, + string? comment = "Good product") => + new( + productId ?? ProductId.From(Guid.NewGuid()), + userId ?? UserId.From(Guid.NewGuid()), + rating, + comment); + + [Fact] + public void Create_WithValidParameters_Succeeds() + { + Result result = ProductReview.Create(ValidCreateParameters()); + + result.IsSuccess.Should().BeTrue(); + result.Value.Rating.Value.Should().Be(4); + result.Value.Comment!.Value.Value.Should().Be("Good product"); + } + + [Fact] + public void Create_WithNullComment_Succeeds() + { + Result result = ProductReview.Create(ValidCreateParameters(comment: null)); + + result.IsSuccess.Should().BeTrue(); + result.Value.Comment!.Value.Value.Should().BeNull(); + } + + [Fact] + public void Create_WithEmptyProductId_ReturnsFailure() + { + Result result = ProductReview.Create(ValidCreateParameters(productId: ProductId.Empty)); + + result.IsFailure.Should().BeTrue(); + result.Errors.Should().ContainSingle(e => e.Code == "ProductReview.ProductId.Empty"); + } + + [Fact] + public void Create_WithEmptyUserId_ReturnsFailure() + { + Result result = ProductReview.Create(ValidCreateParameters(userId: UserId.Empty)); + + result.IsFailure.Should().BeTrue(); + result.Errors.Should().ContainSingle(e => e.Code == "ProductReview.UserId.Empty"); + } + + [Fact] + public void Create_WithInvalidRating_ReturnsFailure() + { + Result result = ProductReview.Create(ValidCreateParameters(rating: 6)); + + result.IsFailure.Should().BeTrue(); + result.Errors.Should().ContainSingle(e => e.Code == "ProductReview.Rating.OutOfRange"); + } + + [Fact] + public void Create_RaisesProductReviewCreatedDomainEvent() + { + Result result = ProductReview.Create(ValidCreateParameters()); + + result.IsSuccess.Should().BeTrue(); + result.Value.DomainEvents.Should().ContainSingle(e => + e.GetType().Name == "ProductReviewCreatedDomainEvent"); + } + + [Fact] + public void Update_ByOwner_Succeeds() + { + var ownerId = UserId.From(Guid.NewGuid()); + ProductReview review = ProductReview.Create(ValidCreateParameters(userId: ownerId)).Value; + review.ClearDomainEvents(); + + var updateParams = new ProductReviewUpdateParameters(review.Id, ownerId, 5, "Updated comment"); + Result result = review.Update(updateParams); + + result.IsSuccess.Should().BeTrue(); + review.Rating.Value.Should().Be(5); + review.Comment!.Value.Value.Should().Be("Updated comment"); + review.DomainEvents.Should().ContainSingle(e => e is ProductReviewUpdatedDomainEvent); + } + + [Fact] + public void Update_ByNonOwner_ReturnsUnauthorized() + { + ProductReview review = ProductReview.Create(ValidCreateParameters()).Value; + + ProductReviewUpdateParameters updateParams = new(review.Id, UserId.From(Guid.NewGuid()), 5, null); + Result result = review.Update(updateParams); + + result.IsFailure.Should().BeTrue(); + result.Errors.Should().ContainSingle(e => e.Code == "ProductReview.Unauthorized"); + } + + [Fact] + public void Delete_ByOwner_Succeeds() + { + var ownerId = UserId.From(Guid.NewGuid()); + ProductReview review = ProductReview.Create(ValidCreateParameters(userId: ownerId)).Value; + review.ClearDomainEvents(); + + Result result = review.Delete(ownerId); + + result.IsSuccess.Should().BeTrue(); + review.DomainEvents.Should().ContainSingle(e => + e.GetType().Name == "ProductReviewDeletedDomainEvent"); + } + + [Fact] + public void Delete_ByNonOwner_ReturnsUnauthorized() + { + ProductReview review = ProductReview.Create(ValidCreateParameters()).Value; + + Result result = review.Delete(UserId.From(Guid.NewGuid())); + + result.IsFailure.Should().BeTrue(); + result.Errors.Should().ContainSingle(e => e.Code == "ProductReview.Unauthorized"); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain.Tests/ProductReviews/ReviewCommentTests.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain.Tests/ProductReviews/ReviewCommentTests.cs new file mode 100644 index 0000000..f1fc7a1 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain.Tests/ProductReviews/ReviewCommentTests.cs @@ -0,0 +1,47 @@ +using CSharpEssentials; +using FluentAssertions; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; + +namespace MuhammedTask.Services.ProductReviewService.Domain.Tests.ProductReviews; + +public sealed class ReviewCommentTests +{ + [Fact] + public void Create_WithNull_Succeeds() + { + Result result = ReviewComment.Create(null); + + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().BeNull(); + } + + [Fact] + public void Create_WithValidText_Succeeds() + { + Result result = ReviewComment.Create("Great product!"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be("Great product!"); + } + + [Fact] + public void Create_WithTooLongText_ReturnsFailure() + { + string longComment = new('x', ReviewComment.MaxLength + 1); + + Result result = ReviewComment.Create(longComment); + + result.IsFailure.Should().BeTrue(); + result.Errors.Should().ContainSingle(e => e.Code == "ProductReview.Comment.TooLong"); + } + + [Fact] + public void Create_WithExactMaxLength_Succeeds() + { + string comment = new('x', ReviewComment.MaxLength); + + Result result = ReviewComment.Create(comment); + + result.IsSuccess.Should().BeTrue(); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain.Tests/ProductReviews/ReviewRatingTests.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain.Tests/ProductReviews/ReviewRatingTests.cs new file mode 100644 index 0000000..2a372ec --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain.Tests/ProductReviews/ReviewRatingTests.cs @@ -0,0 +1,41 @@ +using CSharpEssentials; +using FluentAssertions; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; + +namespace MuhammedTask.Services.ProductReviewService.Domain.Tests.ProductReviews; + +public sealed class ReviewRatingTests +{ + [Theory] + [InlineData(1)] + [InlineData(3)] + [InlineData(5)] + public void Create_WithValidValue_ReturnsRating(int value) + { + Result result = ReviewRating.Create(value); + + result.IsSuccess.Should().BeTrue(); + result.Value.Value.Should().Be(value); + } + + [Fact] + public void Create_WithNull_ReturnsFailure() + { + Result result = ReviewRating.Create(null); + + result.IsFailure.Should().BeTrue(); + result.Errors.Should().ContainSingle(e => e.Code == "ProductReview.Rating.Empty"); + } + + [Theory] + [InlineData(0)] + [InlineData(6)] + [InlineData(-1)] + public void Create_WithOutOfRangeValue_ReturnsFailure(int value) + { + Result result = ReviewRating.Create(value); + + result.IsFailure.Should().BeTrue(); + result.Errors.Should().ContainSingle(e => e.Code == "ProductReview.Rating.OutOfRange"); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/DomainAssemblyReference.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/DomainAssemblyReference.cs new file mode 100644 index 0000000..0dc622b --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/DomainAssemblyReference.cs @@ -0,0 +1,8 @@ +using System.Reflection; + +namespace MuhammedTask.Services.ProductReviewService.Domain; + +public static class DomainAssemblyReference +{ + public static Assembly Assembly => typeof(DomainAssemblyReference).Assembly; +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/MuhammedTask.Services.ProductReviewService.Domain.csproj b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/MuhammedTask.Services.ProductReviewService.Domain.csproj new file mode 100644 index 0000000..f6e0213 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/MuhammedTask.Services.ProductReviewService.Domain.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Events/ProductReviewCreatedDomainEvent.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Events/ProductReviewCreatedDomainEvent.cs new file mode 100644 index 0000000..d843f97 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Events/ProductReviewCreatedDomainEvent.cs @@ -0,0 +1,6 @@ +using CSharpEssentials.Interfaces; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; + +namespace MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Events; + +public sealed record ProductReviewCreatedDomainEvent(ProductReviewId Id, ProductId ProductId) : IDomainEvent; diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Events/ProductReviewDeletedDomainEvent.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Events/ProductReviewDeletedDomainEvent.cs new file mode 100644 index 0000000..792373b --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Events/ProductReviewDeletedDomainEvent.cs @@ -0,0 +1,6 @@ +using CSharpEssentials.Interfaces; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; + +namespace MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Events; + +public sealed record ProductReviewDeletedDomainEvent(ProductReviewId Id) : IDomainEvent; diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Events/ProductReviewUpdatedDomainEvent.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Events/ProductReviewUpdatedDomainEvent.cs new file mode 100644 index 0000000..def45de --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Events/ProductReviewUpdatedDomainEvent.cs @@ -0,0 +1,6 @@ +using CSharpEssentials.Interfaces; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; + +namespace MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Events; + +public sealed record ProductReviewUpdatedDomainEvent(ProductReviewId Id) : IDomainEvent; diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Fields/ProductId.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Fields/ProductId.cs new file mode 100644 index 0000000..d3ac2b7 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Fields/ProductId.cs @@ -0,0 +1,12 @@ +using CSharpEssentials; + +namespace MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; + +public readonly record struct ProductId +{ + private ProductId(Guid value) => Value = value; + public Guid Value { get; } + public static ProductId New() => new(Guider.NewGuid()); + public static ProductId From(Guid value) => new(value); + public static readonly ProductId Empty = new(Guid.Empty); +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Fields/ProductReviewId.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Fields/ProductReviewId.cs new file mode 100644 index 0000000..ec0b4dd --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Fields/ProductReviewId.cs @@ -0,0 +1,12 @@ +using CSharpEssentials; + +namespace MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; + +public readonly record struct ProductReviewId +{ + private ProductReviewId(Guid value) => Value = value; + public Guid Value { get; } + public static ProductReviewId New() => new(Guider.NewGuid()); + public static ProductReviewId From(Guid value) => new(value); + public static readonly ProductReviewId Empty = new(Guid.Empty); +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Fields/ReviewComment.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Fields/ReviewComment.cs new file mode 100644 index 0000000..5172549 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Fields/ReviewComment.cs @@ -0,0 +1,18 @@ +using CSharpEssentials; + +namespace MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; + +public readonly record struct ReviewComment +{ + public const int MaxLength = 1000; + public string? Value { get; } + private ReviewComment(string? value) => Value = value; + public static ReviewComment From(string? value) => new(value); + public static Result Create(string? value) + { + if (value is not null && value.Length > MaxLength) + return ProductReviewErrors.Comment.TooLongError(value.Length); + + return new ReviewComment(value); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Fields/ReviewRating.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Fields/ReviewRating.cs new file mode 100644 index 0000000..723302e --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Fields/ReviewRating.cs @@ -0,0 +1,22 @@ +using CSharpEssentials; + +namespace MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; + +public readonly record struct ReviewRating +{ + public const int MinValue = 1; + public const int MaxValue = 5; + public int Value { get; } + private ReviewRating(int value) => Value = value; + public static ReviewRating From(int value) => new(value); + public static Result Create(int? value) + { + if (value is null) + return ProductReviewErrors.Rating.EmptyError; + + if (value < MinValue || value > MaxValue) + return ProductReviewErrors.Rating.OutOfRangeError(value.Value); + + return new ReviewRating(value.Value); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Fields/UserId.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Fields/UserId.cs new file mode 100644 index 0000000..a1216f2 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Fields/UserId.cs @@ -0,0 +1,12 @@ +using CSharpEssentials; + +namespace MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; + +public readonly record struct UserId +{ + private UserId(Guid value) => Value = value; + public Guid Value { get; } + public static UserId New() => new(Guider.NewGuid()); + public static UserId From(Guid value) => new(value); + public static readonly UserId Empty = new(Guid.Empty); +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Parameters/ProductReviewCreateParameters.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Parameters/ProductReviewCreateParameters.cs new file mode 100644 index 0000000..aaa924e --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Parameters/ProductReviewCreateParameters.cs @@ -0,0 +1,9 @@ +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; + +namespace MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Parameters; + +public sealed record ProductReviewCreateParameters( + ProductId ProductId, + UserId UserId, + int? Rating, + string? Comment); diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Parameters/ProductReviewUpdateParameters.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Parameters/ProductReviewUpdateParameters.cs new file mode 100644 index 0000000..196b270 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Parameters/ProductReviewUpdateParameters.cs @@ -0,0 +1,9 @@ +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; + +namespace MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Parameters; + +public sealed record ProductReviewUpdateParameters( + ProductReviewId Id, + UserId UserId, + int? Rating, + string? Comment); diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/ProductReview.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/ProductReview.cs new file mode 100644 index 0000000..98de1d9 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/ProductReview.cs @@ -0,0 +1,77 @@ +using CSharpEssentials; +using CSharpEssentials.Entity; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Events; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Parameters; + +namespace MuhammedTask.Services.ProductReviewService.Domain.ProductReviews; + +public sealed class ProductReview : SoftDeletableEntityBase +{ + private ProductReview() { } + private ProductReview(ProductReviewId id, ProductId productId, UserId userId, ReviewRating rating, ReviewComment? comment) + { + Id = id; + ProductId = productId; + UserId = userId; + Rating = rating; + Comment = comment; + Raise(new ProductReviewCreatedDomainEvent(id, productId)); + } + + public ProductId ProductId { get; private set; } + public UserId UserId { get; private set; } + public ReviewRating Rating { get; private set; } + public ReviewComment? Comment { get; private set; } + + public static Result Create(ProductReviewCreateParameters parameters) + { + if (parameters.ProductId == ProductId.Empty) + return ProductReviewErrors.EmptyProductIdError; + + if (parameters.UserId == UserId.Empty) + return ProductReviewErrors.EmptyUserIdError; + + Result rating = ReviewRating.Create(parameters.Rating); + Result comment = ReviewComment.Create(parameters.Comment); + + var result = Result.And(rating, comment); + if (result.IsFailure) + return result.Errors; + + return new ProductReview( + ProductReviewId.New(), + parameters.ProductId, + parameters.UserId, + rating.Value, + comment.Value); + } + + public Result Update(ProductReviewUpdateParameters parameters) + { + if (UserId != parameters.UserId) + return ProductReviewErrors.UnauthorizedError; + + Result rating = ReviewRating.Create(parameters.Rating); + Result comment = ReviewComment.Create(parameters.Comment); + + var result = Result.And(rating, comment); + if (result.IsFailure) + return result.Errors; + + Rating = rating.Value; + Comment = comment.Value; + Raise(new ProductReviewUpdatedDomainEvent(Id)); + + return Result.Success(); + } + + public Result Delete(UserId userId) + { + if (UserId != userId) + return ProductReviewErrors.UnauthorizedError; + + Raise(new ProductReviewDeletedDomainEvent(Id)); + return Result.Success(); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/ProductReviewErrors.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/ProductReviewErrors.cs new file mode 100644 index 0000000..61b27a7 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/ProductReviewErrors.cs @@ -0,0 +1,37 @@ +using CSharpEssentials; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; + +namespace MuhammedTask.Services.ProductReviewService.Domain.ProductReviews; + +public static class ProductReviewErrors +{ + public static Error NotFoundError(ProductReviewId id) => + Error.NotFound(code: "ProductReview.NotFound", description: $"Product review does not exist: {id.Value}"); + + public static readonly Error AlreadyReviewedError = + Error.Conflict(code: "ProductReview.AlreadyReviewed", description: "User has already reviewed this product"); + + public static readonly Error UnauthorizedError = + Error.Forbidden(code: "ProductReview.Unauthorized", description: "User is not authorized to modify this review"); + + public static readonly Error EmptyProductIdError = + Error.Validation(code: "ProductReview.ProductId.Empty", description: "ProductId is required"); + + public static readonly Error EmptyUserIdError = + Error.Validation(code: "ProductReview.UserId.Empty", description: "UserId is required"); + + public static class Rating + { + public static readonly Error EmptyError = + Error.Validation(code: "ProductReview.Rating.Empty", description: "Rating is required"); + + public static Error OutOfRangeError(int value) => + Error.Validation(code: "ProductReview.Rating.OutOfRange", description: $"Rating must be between {ReviewRating.MinValue} and {ReviewRating.MaxValue}, got: {value}"); + } + + public static class Comment + { + public static Error TooLongError(int length) => + Error.Validation(code: "ProductReview.Comment.TooLong", description: $"Comment must be at most {ReviewComment.MaxLength} characters, got: {length}"); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/ReadModels/ProductReviewReadModel.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/ReadModels/ProductReviewReadModel.cs new file mode 100644 index 0000000..0934d73 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/ReadModels/ProductReviewReadModel.cs @@ -0,0 +1,15 @@ +using CSharpEssentials.Interfaces; + +namespace MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.ReadModels; + +public sealed class ProductReviewReadModel : ISoftDeletableBase +{ + public Guid Id { get; set; } + public Guid ProductId { get; set; } + public Guid UserId { get; set; } + public int Rating { get; set; } + public string? Comment { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public bool IsDeleted { get; set; } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Repositories/IProductReviewCommandRepository.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Repositories/IProductReviewCommandRepository.cs new file mode 100644 index 0000000..6bef886 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Repositories/IProductReviewCommandRepository.cs @@ -0,0 +1,12 @@ +using CSharpEssentials; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Parameters; + +namespace MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Repositories; + +public interface IProductReviewCommandRepository +{ + Task> CreateProductReviewAsync(ProductReviewCreateParameters parameters, CancellationToken cancellationToken = default); + Task UpdateProductReviewAsync(ProductReviewUpdateParameters parameters, CancellationToken cancellationToken = default); + Task DeleteProductReviewAsync(ProductReviewId productReviewId, UserId userId, CancellationToken cancellationToken = default); +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Repositories/IProductReviewQueryRepository.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Repositories/IProductReviewQueryRepository.cs new file mode 100644 index 0000000..7851915 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Domain/ProductReviews/Repositories/IProductReviewQueryRepository.cs @@ -0,0 +1,14 @@ +using CSharpEssentials; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.ReadModels; + +namespace MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Repositories; + +public interface IProductReviewQueryRepository +{ + Task> GetProductReviewByIdAsync(ProductReviewId productReviewId, CancellationToken cancellationToken = default); + Task<(ProductReviewReadModel[] Items, int TotalCount)> GetProductReviewsByProductIdAsync(ProductId productId, int pageNumber, int pageSize, CancellationToken cancellationToken = default); + Task GetAverageRatingByProductIdAsync(ProductId productId, CancellationToken cancellationToken = default); + Task GetReviewCountByProductIdAsync(ProductId productId, CancellationToken cancellationToken = default); + Task ExistsAsync(ProductId productId, UserId userId, CancellationToken cancellationToken = default); +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Configurations/Read/ProductReviewReadModelConfiguration.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Configurations/Read/ProductReviewReadModelConfiguration.cs new file mode 100644 index 0000000..2df72af --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Configurations/Read/ProductReviewReadModelConfiguration.cs @@ -0,0 +1,13 @@ +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.ReadModels; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MuhammedTask.Services.ProductReviewService.Persistence.EntityFrameworkCore.Configurations.Read; + +public sealed class ProductReviewReadModelConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Configurations/Write/ProductReviewConfiguration.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Configurations/Write/ProductReviewConfiguration.cs new file mode 100644 index 0000000..6c4c1f2 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Configurations/Write/ProductReviewConfiguration.cs @@ -0,0 +1,48 @@ +using CSharpEssentials.EntityFrameworkCore; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace MuhammedTask.Services.ProductReviewService.Persistence.EntityFrameworkCore.Configurations.Write; + +internal sealed class ProductReviewConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.SoftDeletableEntityBaseMap(); + + builder + .Property(p => p.Id) + .HasConversion(id => id.Value, id => ProductReviewId.From(id)); + + builder + .Property(p => p.ProductId) + .HasConversion(id => id.Value, id => ProductId.From(id)); + + builder + .Property(p => p.UserId) + .HasConversion(id => id.Value, id => UserId.From(id)); + + builder + .Property(p => p.Rating) + .HasConversion(r => r.Value, r => ReviewRating.From(r)); + + ValueConverter commentConverter = new( + c => c == null ? null : c.Value.Value, + s => s == null ? null : ReviewComment.From(s)); + + builder + .Property(p => p.Comment) + .HasConversion(commentConverter) + .HasMaxLength(ReviewComment.MaxLength) + .IsRequired(false); + + builder.OptimisticConcurrencyVersionMap(); + + builder.HasIndex(x => x.ProductId); + builder.HasIndex(x => new { x.ProductId, x.UserId }).IsUnique(); + builder.HasIndex(x => new { x.CreatedAt, x.IsDeleted }); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Contexts/ApplicationReadDbContext.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Contexts/ApplicationReadDbContext.cs new file mode 100644 index 0000000..bee3d16 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Contexts/ApplicationReadDbContext.cs @@ -0,0 +1,24 @@ +using CSharpEssentials.EntityFrameworkCore; +using MuhammedTask.BuildingBlocks.Database.PostgreSQL.Contexts; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.ReadModels; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.Services.ProductReviewService.Persistence.EntityFrameworkCore.Contexts; + +public sealed class ApplicationReadDbContext : ReadDbContextBase +{ + public ApplicationReadDbContext( + DbContextOptions options, + IServiceScopeFactory serviceScopeFactory) : base(options, serviceScopeFactory) + { + } + + public DbSet ProductReviews => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.ApplySoftDeleteQueryFilter(); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Contexts/ApplicationWriteDbContext.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Contexts/ApplicationWriteDbContext.cs new file mode 100644 index 0000000..178b98e --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Contexts/ApplicationWriteDbContext.cs @@ -0,0 +1,24 @@ +using CSharpEssentials.EntityFrameworkCore; +using MuhammedTask.BuildingBlocks.Database.PostgreSQL.Contexts; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.Services.ProductReviewService.Persistence.EntityFrameworkCore.Contexts; + +public sealed class ApplicationWriteDbContext : WriteDbContextBase +{ + public ApplicationWriteDbContext( + DbContextOptions options, + IServiceScopeFactory serviceScopeFactory) : base(options, serviceScopeFactory) + { + } + + public DbSet ProductReviews => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.ApplySoftDeleteQueryFilter(); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Contexts/ApplicationWriteDbContextFactory.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Contexts/ApplicationWriteDbContextFactory.cs new file mode 100644 index 0000000..7e0bdea --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Contexts/ApplicationWriteDbContextFactory.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.Services.ProductReviewService.Persistence.EntityFrameworkCore.Contexts; + +internal sealed class ApplicationWriteDbContextFactory : IDesignTimeDbContextFactory +{ + public ApplicationWriteDbContext CreateDbContext(string[] args) + { + string connectionString = Environment.GetEnvironmentVariable("ConnectionStrings__pg-productreviewservice") + ?? "Host=localhost;Database=productreview;Username=postgres"; + + ServiceCollection services = new(); + services.AddLogging(); + services.AddDbContext(options => options + .UseNpgsql(connectionString) + .UseSnakeCaseNamingConvention()); + + IServiceProvider provider = services.BuildServiceProvider(); + + return provider.GetRequiredService(); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/20260523185446_InitialProductReview.Designer.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/20260523185446_InitialProductReview.Designer.cs new file mode 100644 index 0000000..cf1f89d --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/20260523185446_InitialProductReview.Designer.cs @@ -0,0 +1,90 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MuhammedTask.Services.ProductReviewService.Persistence.EntityFrameworkCore.Contexts; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MuhammedTask.Services.ProductReviewService.Persistence.EntityFrameworkCore.Migrations.ApplicationWrite +{ + [DbContext(typeof(ApplicationWriteDbContext))] + [Migration("20260523185446_InitialProductReview")] + partial class InitialProductReview + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.ProductReview", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("CreatedAt", "IsDeleted"); + + b.HasIndex("ProductId", "UserId") + .IsUnique(); + + b.ToTable("ProductReviews"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/20260523185446_InitialProductReview.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/20260523185446_InitialProductReview.cs new file mode 100644 index 0000000..ace0b67 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/20260523185446_InitialProductReview.cs @@ -0,0 +1,57 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MuhammedTask.Services.ProductReviewService.Persistence.EntityFrameworkCore.Migrations.ApplicationWrite; + +/// +public partial class InitialProductReview : Migration +{ + private static readonly string[] CreatedAtIsDeletedColumns = ["created_at", "is_deleted"]; + private static readonly string[] ProductIdUserIdColumns = ["product_id", "user_id"]; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "product_reviews", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + product_id = table.Column(type: "uuid", nullable: false), + user_id = table.Column(type: "uuid", nullable: false), + rating = table.Column(type: "integer", nullable: false), + comment = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + row_version = table.Column(type: "bytea", rowVersion: true, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(40)", maxLength: 40, nullable: true), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true), + deleted_by = table.Column(type: "character varying(40)", maxLength: 40, nullable: true), + is_deleted = table.Column(type: "boolean", nullable: false) + }, + constraints: table => table.PrimaryKey("pk_product_reviews", x => x.id)); + + migrationBuilder.CreateIndex( + name: "ix_product_reviews_created_at_is_deleted", + table: "product_reviews", + columns: CreatedAtIsDeletedColumns); + + migrationBuilder.CreateIndex( + name: "ix_product_reviews_product_id", + table: "product_reviews", + column: "product_id"); + + migrationBuilder.CreateIndex( + name: "ix_product_reviews_product_id_user_id", + table: "product_reviews", + columns: ProductIdUserIdColumns, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) => + migrationBuilder.DropTable(name: "product_reviews"); +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/ApplicationWriteDbContextModelSnapshot.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/ApplicationWriteDbContextModelSnapshot.cs new file mode 100644 index 0000000..b051011 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/ApplicationWriteDbContextModelSnapshot.cs @@ -0,0 +1,87 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MuhammedTask.Services.ProductReviewService.Persistence.EntityFrameworkCore.Contexts; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MuhammedTask.Services.ProductReviewService.Persistence.EntityFrameworkCore.Migrations.ApplicationWrite +{ + [DbContext(typeof(ApplicationWriteDbContext))] + partial class ApplicationWriteDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.ProductReview", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex("CreatedAt", "IsDeleted"); + + b.HasIndex("ProductId", "UserId") + .IsUnique(); + + b.ToTable("ProductReviews"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Repositories/EfProductReviewCommandRepository.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Repositories/EfProductReviewCommandRepository.cs new file mode 100644 index 0000000..56f7735 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Repositories/EfProductReviewCommandRepository.cs @@ -0,0 +1,61 @@ +using CSharpEssentials; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Parameters; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Repositories; +using MuhammedTask.Services.ProductReviewService.Persistence.EntityFrameworkCore.Contexts; +using Microsoft.EntityFrameworkCore; + +namespace MuhammedTask.Services.ProductReviewService.Persistence.EntityFrameworkCore.Repositories; + +internal sealed class EfProductReviewCommandRepository( + ApplicationWriteDbContext context) : IProductReviewCommandRepository +{ + public async Task> CreateProductReviewAsync(ProductReviewCreateParameters parameters, CancellationToken cancellationToken = default) + { + Result reviewResult = ProductReview.Create(parameters); + if (reviewResult.IsFailure) + return reviewResult.Errors; + ProductReview review = reviewResult.Value; + + await context.ProductReviews.AddAsync(review, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + + return review.Id; + } + + public async Task UpdateProductReviewAsync(ProductReviewUpdateParameters parameters, CancellationToken cancellationToken = default) + { + ProductReview? found = await context.ProductReviews + .Where(r => r.Id == parameters.Id) + .FirstOrDefaultAsync(cancellationToken); + + if (found is null) + return ProductReviewErrors.NotFoundError(parameters.Id); + + Result updateResult = found.Update(parameters); + if (updateResult.IsFailure) + return updateResult.Errors; + + await context.SaveChangesAsync(cancellationToken); + return Result.Success(); + } + + public async Task DeleteProductReviewAsync(ProductReviewId productReviewId, UserId userId, CancellationToken cancellationToken = default) + { + ProductReview? found = await context.ProductReviews + .Where(r => r.Id == productReviewId) + .FirstOrDefaultAsync(cancellationToken); + + if (found is null) + return ProductReviewErrors.NotFoundError(productReviewId); + + Result deleteResult = found.Delete(userId); + if (deleteResult.IsFailure) + return deleteResult.Errors; + + context.ProductReviews.Remove(found); + await context.SaveChangesAsync(cancellationToken); + return Result.Success(); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Repositories/EfProductReviewQueryRepository.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Repositories/EfProductReviewQueryRepository.cs new file mode 100644 index 0000000..87c37da --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/EntityFrameworkCore/Repositories/EfProductReviewQueryRepository.cs @@ -0,0 +1,56 @@ +using CSharpEssentials; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Fields; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.ReadModels; +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Repositories; +using MuhammedTask.Services.ProductReviewService.Persistence.EntityFrameworkCore.Contexts; +using Microsoft.EntityFrameworkCore; + +namespace MuhammedTask.Services.ProductReviewService.Persistence.EntityFrameworkCore.Repositories; + +internal sealed class EfProductReviewQueryRepository( + ApplicationReadDbContext context) : IProductReviewQueryRepository +{ + public async Task> GetProductReviewByIdAsync(ProductReviewId productReviewId, CancellationToken cancellationToken = default) + { + return await context.ProductReviews + .Where(r => r.Id == productReviewId.Value) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task<(ProductReviewReadModel[] Items, int TotalCount)> GetProductReviewsByProductIdAsync(ProductId productId, int pageNumber, int pageSize, CancellationToken cancellationToken = default) + { + IOrderedQueryable query = context.ProductReviews + .Where(r => r.ProductId == productId.Value) + .OrderByDescending(r => r.CreatedAt); + + int totalCount = await query.CountAsync(cancellationToken); + ProductReviewReadModel[] items = await query + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToArrayAsync(cancellationToken); + + return (items, totalCount); + } + + public async Task GetAverageRatingByProductIdAsync(ProductId productId, CancellationToken cancellationToken = default) + { + double? average = await context.ProductReviews + .Where(r => r.ProductId == productId.Value) + .AverageAsync(r => (double?)r.Rating, cancellationToken); + + return average ?? 0.0; + } + + public Task GetReviewCountByProductIdAsync(ProductId productId, CancellationToken cancellationToken = default) + { + return context.ProductReviews + .Where(r => r.ProductId == productId.Value) + .CountAsync(cancellationToken); + } + + public Task ExistsAsync(ProductId productId, UserId userId, CancellationToken cancellationToken = default) + { + return context.ProductReviews + .AnyAsync(r => r.ProductId == productId.Value && r.UserId == userId.Value, cancellationToken); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/Jobs/IntegrationEvents/PeriodicIntegrationEvent.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/Jobs/IntegrationEvents/PeriodicIntegrationEvent.cs new file mode 100644 index 0000000..00bbe5e --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/Jobs/IntegrationEvents/PeriodicIntegrationEvent.cs @@ -0,0 +1,3 @@ +namespace MuhammedTask.IntegrationEvents.Jobs; + +public sealed record PeriodicIntegrationEvent(string JobInstanceId, DateTime Timestamp); diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/Jobs/IntegrationEvents/PeriodicIntegrationEventHandler.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/Jobs/IntegrationEvents/PeriodicIntegrationEventHandler.cs new file mode 100644 index 0000000..d64fc92 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/Jobs/IntegrationEvents/PeriodicIntegrationEventHandler.cs @@ -0,0 +1,15 @@ +using MassTransit; +using MuhammedTask.IntegrationEvents.Jobs; +using Microsoft.Extensions.Logging; + +namespace MuhammedTask.Services.ProductReviewService.Persistence.Jobs.IntegrationEvents; + +public sealed class PeriodicIntegrationEventHandler( + ILogger logger) : IConsumer +{ + public Task Consume(ConsumeContext context) + { + logger.LogInformation("Periodic integration event received {InstanceId}, {Timestamp}", context.Message.JobInstanceId, context.Message.Timestamp); + return Task.CompletedTask; + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/MuhammedTask.Services.ProductReviewService.Persistence.csproj b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/MuhammedTask.Services.ProductReviewService.Persistence.csproj new file mode 100644 index 0000000..06e7542 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/MuhammedTask.Services.ProductReviewService.Persistence.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/PersistenceAssemblyReference.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/PersistenceAssemblyReference.cs new file mode 100644 index 0000000..b2f7e02 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/PersistenceAssemblyReference.cs @@ -0,0 +1,8 @@ +using System.Reflection; + +namespace MuhammedTask.Services.ProductReviewService.Persistence; + +public static class PersistenceAssemblyReference +{ + public static Assembly Assembly => typeof(PersistenceAssemblyReference).Assembly; +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/ServiceRegistrations/DependencyInjection.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/ServiceRegistrations/DependencyInjection.cs new file mode 100644 index 0000000..adc64e4 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/ServiceRegistrations/DependencyInjection.cs @@ -0,0 +1,80 @@ +using CSharpEssentials.EntityFrameworkCore.Interceptors; +using MassTransit; +using MuhammedTask.BuildingBlocks.BackgroundJobs.Quartz; +using MuhammedTask.BuildingBlocks.Caching.Redis; +using MuhammedTask.BuildingBlocks.Database.PostgreSQL; +using MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit; +using MuhammedTask.Services.Info; +using MuhammedTask.Services.ProductReviewService.Persistence.EntityFrameworkCore.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using StackExchange.Redis; + +namespace MuhammedTask.Services.ProductReviewService.Persistence.ServiceRegistrations; + +public static class DependencyInjection +{ + public static IServiceCollection AddPersistenceServices( + this IServiceCollection services, + IHostEnvironment environment, + IConfiguration configuration) + { + return services + .ConfigureCacheServices(environment, configuration) + .AddQuartzWithHostedService() + .AddDatabaseServices(configuration) + .AddEventBus( + configure => configure.UsingRabbitMq((context, cfg) => + { + cfg.Host(configuration.GetConnectionString(ServiceKeys.RabbitMQ)); + cfg.ConfigureEndpoints(context); + }), + assemblies: PersistenceAssemblyReference.Assembly) + .RegisterRepositories(); + } + + private static IServiceCollection ConfigureCacheServices( + this IServiceCollection services, + IHostEnvironment environment, + IConfiguration configuration) + { + return services + .AddSingleton(sp => + { + string connectionString = configuration.GetConnectionString(ServiceKeys.Redis); + var configurationOptions = ConfigurationOptions.Parse(connectionString!, true); + configurationOptions.AbortOnConnectFail = false; + return ConnectionMultiplexer.Connect(configurationOptions); + }) + .AddCacheServices( + environment.ApplicationName, + redisConfigurations => redisConfigurations.Configuration = configuration.GetConnectionString(ServiceKeys.Redis), + memoryConfiguration => + { + }); + } + + private static IServiceCollection AddDatabaseServices( + this IServiceCollection services, + IConfiguration configuration) + { + string migrationsAssembly = typeof(PersistenceAssemblyReference).Namespace; + return services + .AddSingleton() + .AddPooledPostgresDbContext( + configuration.GetConnectionString(ServiceKeys.Database.PostgresProductReviewService)!, + options => options.MigrationsAssembly = migrationsAssembly, + configureOptions: (_, o) => o.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning))) + .AddPooledPostgresDbContext( + configuration.GetConnectionString(ServiceKeys.Database.PostgresProductReviewService)!, + options => + { + options.MigrationsAssembly = migrationsAssembly; + options.EnablePublishDomainEventsInterceptor = false; + options.EnableAuditableInterceptor = false; + options.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + }); + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/ServiceRegistrations/RepositoryRegistrations.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/ServiceRegistrations/RepositoryRegistrations.cs new file mode 100644 index 0000000..6138ca2 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.Persistence/ServiceRegistrations/RepositoryRegistrations.cs @@ -0,0 +1,13 @@ +using MuhammedTask.Services.ProductReviewService.Domain.ProductReviews.Repositories; +using MuhammedTask.Services.ProductReviewService.Persistence.EntityFrameworkCore.Repositories; +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.Services.ProductReviewService.Persistence.ServiceRegistrations; + +internal static class RepositoryRegistrations +{ + public static IServiceCollection RegisterRepositories(this IServiceCollection services) => + services + .AddScoped() + .AddScoped(); +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Endpoints/ProductReviews/v1/AverageRating/ProductReviewAverageRatingEndpoint.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Endpoints/ProductReviews/v1/AverageRating/ProductReviewAverageRatingEndpoint.cs new file mode 100644 index 0000000..4fd878d --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Endpoints/ProductReviews/v1/AverageRating/ProductReviewAverageRatingEndpoint.cs @@ -0,0 +1,32 @@ +using Carter; +using CSharpEssentials; +using MediatR; +using MuhammedTask.BuildingBlocks.Presentation.Endpoints; +using MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Models; +using MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Queries.GetAverageRating; + +namespace MuhammedTask.Services.ProductReviewService.WebApi.Endpoints.ProductReviews.v1.AverageRating; + +public sealed class ProductReviewAverageRatingEndpoint : CarterModule +{ + public override void AddRoutes(IEndpointRouteBuilder app) + { + RouteGroupBuilder routeGroup = app + .CreateVersionedGroup(Tags.Products) + .RequireAuthorization(); + + routeGroup.MapGet("{productId:guid}/average-rating", GetAverageRating) + .Produces() + .ProducesProblem() + .WithDescription("Get average rating by product id") + .WithName(nameof(GetAverageRating)); + } + + private static Task GetAverageRating(Guid productId, ISender sender, CancellationToken cancellationToken = default) => + sender + .Send(new GetProductAverageRatingQuery(productId), cancellationToken) + .Match( + result => TypedResults.Ok(result), + errors => errors.ToProblemResult(), + cancellationToken); +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Endpoints/ProductReviews/v1/Create/ProductReviewCreateEndpoint.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Endpoints/ProductReviews/v1/Create/ProductReviewCreateEndpoint.cs new file mode 100644 index 0000000..ecc20ef --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Endpoints/ProductReviews/v1/Create/ProductReviewCreateEndpoint.cs @@ -0,0 +1,38 @@ +using Carter; +using CSharpEssentials; +using MediatR; +using MuhammedTask.BuildingBlocks.Presentation.Endpoints; +using MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Commands.Create; +using Microsoft.AspNetCore.Mvc; + +namespace MuhammedTask.Services.ProductReviewService.WebApi.Endpoints.ProductReviews.v1.Create; + +public sealed class ProductReviewCreateEndpoint : CarterModule +{ + public sealed record ProductReviewCreateRequest(Guid ProductId, Guid UserId, int? Rating, string? Comment) + { + public CreateProductReviewCommand ToCommand() => + new(ProductId, UserId, Rating, Comment); + } + + public override void AddRoutes(IEndpointRouteBuilder app) + { + RouteGroupBuilder routeGroup = app + .CreateVersionedGroup(Tags.ProductReviews) + .RequireAuthorization(); + + routeGroup.MapPost(string.Empty, CreateProductReview) + .Produces(HttpCodes.Created) + .ProducesProblem() + .WithDescription("Create product review") + .WithName(nameof(CreateProductReview)); + } + + private static Task CreateProductReview([FromBody] ProductReviewCreateRequest request, ISender sender, CancellationToken cancellationToken = default) => + sender + .Send(request.ToCommand(), cancellationToken) + .Match( + review => TypedResults.Created($"/{Tags.ProductReviews}/{review.Value}", review.Value), + errors => errors.ToProblemResult(), + cancellationToken); +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Endpoints/ProductReviews/v1/Delete/ProductReviewDeleteEndpoint.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Endpoints/ProductReviews/v1/Delete/ProductReviewDeleteEndpoint.cs new file mode 100644 index 0000000..c89b1db --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Endpoints/ProductReviews/v1/Delete/ProductReviewDeleteEndpoint.cs @@ -0,0 +1,32 @@ +using Carter; +using CSharpEssentials; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using MuhammedTask.BuildingBlocks.Presentation.Endpoints; +using MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Commands.Delete; + +namespace MuhammedTask.Services.ProductReviewService.WebApi.Endpoints.ProductReviews.v1.Delete; + +public sealed class ProductReviewDeleteEndpoint : CarterModule +{ + public override void AddRoutes(IEndpointRouteBuilder app) + { + RouteGroupBuilder routeGroup = app + .CreateVersionedGroup(Tags.ProductReviews) + .RequireAuthorization(); + + routeGroup.MapDelete("{reviewId:guid}", DeleteProductReview) + .Produces(HttpCodes.NoContent) + .ProducesProblem() + .WithDescription("Delete product review") + .WithName(nameof(DeleteProductReview)); + } + + private static Task DeleteProductReview(Guid reviewId, [FromQuery] Guid userId, ISender sender, CancellationToken cancellationToken = default) => + sender + .Send(new DeleteProductReviewCommand(reviewId, userId), cancellationToken) + .Match( + TypedResults.NoContent, + errors => errors.ToProblemResult(), + cancellationToken); +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Endpoints/ProductReviews/v1/Get/ProductReviewGetEndpoint.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Endpoints/ProductReviews/v1/Get/ProductReviewGetEndpoint.cs new file mode 100644 index 0000000..6ae017a --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Endpoints/ProductReviews/v1/Get/ProductReviewGetEndpoint.cs @@ -0,0 +1,32 @@ +using Carter; +using CSharpEssentials; +using MediatR; +using MuhammedTask.BuildingBlocks.Presentation.Endpoints; +using MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Models; +using MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Queries.GetById; + +namespace MuhammedTask.Services.ProductReviewService.WebApi.Endpoints.ProductReviews.v1.Get; + +public sealed class ProductReviewGetEndpoint : CarterModule +{ + public override void AddRoutes(IEndpointRouteBuilder app) + { + RouteGroupBuilder routeGroup = app + .CreateVersionedGroup(Tags.ProductReviews) + .RequireAuthorization(); + + routeGroup.MapGet("{reviewId:guid}", GetProductReview) + .Produces() + .ProducesProblem() + .WithDescription("Get product review by id") + .WithName(nameof(GetProductReview)); + } + + private static Task GetProductReview(Guid reviewId, ISender sender, CancellationToken cancellationToken = default) => + sender + .Send(new GetProductReviewByIdQuery(reviewId), cancellationToken) + .Match( + review => TypedResults.Ok(review), + errors => errors.ToProblemResult(), + cancellationToken); +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Endpoints/ProductReviews/v1/List/ProductReviewListEndpoint.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Endpoints/ProductReviews/v1/List/ProductReviewListEndpoint.cs new file mode 100644 index 0000000..90c98c8 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Endpoints/ProductReviews/v1/List/ProductReviewListEndpoint.cs @@ -0,0 +1,37 @@ +using Carter; +using CSharpEssentials; +using MediatR; +using MuhammedTask.BuildingBlocks.Presentation.Endpoints; +using MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Models; +using MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Queries.GetList; + +namespace MuhammedTask.Services.ProductReviewService.WebApi.Endpoints.ProductReviews.v1.List; + +public sealed class ProductReviewListEndpoint : CarterModule +{ + public override void AddRoutes(IEndpointRouteBuilder app) + { + RouteGroupBuilder routeGroup = app + .CreateVersionedGroup(Tags.Products) + .RequireAuthorization(); + + routeGroup.MapGet("{productId:guid}/reviews", GetProductReviews) + .Produces>() + .ProducesProblem() + .WithDescription("Get paginated reviews by product id") + .WithName(nameof(GetProductReviews)); + } + + private static Task GetProductReviews( + Guid productId, + ISender sender, + int pageNumber = 1, + int pageSize = 10, + CancellationToken cancellationToken = default) => + sender + .Send(new GetProductReviewListQuery(productId, pageNumber, pageSize), cancellationToken) + .Match( + reviews => TypedResults.Ok(reviews), + errors => errors.ToProblemResult(), + cancellationToken); +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Endpoints/ProductReviews/v1/Update/ProductReviewUpdateEndpoint.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Endpoints/ProductReviews/v1/Update/ProductReviewUpdateEndpoint.cs new file mode 100644 index 0000000..b87a7a7 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Endpoints/ProductReviews/v1/Update/ProductReviewUpdateEndpoint.cs @@ -0,0 +1,38 @@ +using Carter; +using CSharpEssentials; +using MediatR; +using MuhammedTask.BuildingBlocks.Presentation.Endpoints; +using MuhammedTask.Services.ProductReviewService.Application.ProductReviews.v1.Commands.Update; +using Microsoft.AspNetCore.Mvc; + +namespace MuhammedTask.Services.ProductReviewService.WebApi.Endpoints.ProductReviews.v1.Update; + +public sealed class ProductReviewUpdateEndpoint : CarterModule +{ + public sealed record ProductReviewUpdateRequest(Guid UserId, int? Rating, string? Comment) + { + public UpdateProductReviewCommand ToCommand(Guid id) => + new(id, UserId, Rating, Comment); + } + + public override void AddRoutes(IEndpointRouteBuilder app) + { + RouteGroupBuilder routeGroup = app + .CreateVersionedGroup(Tags.ProductReviews) + .RequireAuthorization(); + + routeGroup.MapPatch("{reviewId:guid}", UpdateProductReview) + .Produces(HttpCodes.NoContent) + .ProducesProblem() + .WithDescription("Update product review") + .WithName(nameof(UpdateProductReview)); + } + + private static Task UpdateProductReview(Guid reviewId, [FromBody] ProductReviewUpdateRequest request, ISender sender, CancellationToken cancellationToken = default) => + sender + .Send(request.ToCommand(reviewId), cancellationToken) + .Match( + TypedResults.NoContent, + errors => errors.ToProblemResult(), + cancellationToken); +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Endpoints/Tags.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Endpoints/Tags.cs new file mode 100644 index 0000000..d6b948d --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Endpoints/Tags.cs @@ -0,0 +1,7 @@ +namespace MuhammedTask.Services.ProductReviewService.WebApi.Endpoints; + +public static class Tags +{ + public const string ProductReviews = "productreviews"; + public const string Products = "products"; +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/MuhammedTask.Services.ProductReviewService.WebApi.csproj b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/MuhammedTask.Services.ProductReviewService.WebApi.csproj new file mode 100644 index 0000000..59863a1 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/MuhammedTask.Services.ProductReviewService.WebApi.csproj @@ -0,0 +1,18 @@ + + + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Program.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Program.cs new file mode 100644 index 0000000..fcec8be --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Program.cs @@ -0,0 +1,18 @@ +using MuhammedTask.BuildingBlocks.Database.Migrator; +using MuhammedTask.Services.Info; +using MuhammedTask.Services.ProductReviewService.Persistence.EntityFrameworkCore.Contexts; +using MuhammedTask.Services.ProductReviewService.WebApi.ServiceRegistrations; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.Services.AddServiceRegistrations(builder.Environment, builder.Configuration); +builder.AddSeqEndpoint(ServiceKeys.Seq); + +WebApplication app = builder.Build(); + +if (app.Environment.IsDevelopment()) + await app.MigrateAsync(); + +app.UseServices(); + +await app.RunAsync(); diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Properties/launchSettings.json b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Properties/launchSettings.json new file mode 100644 index 0000000..93a88fc --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5210", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7210;http://localhost:5210", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/ServiceRegistrations/DependencyInjection.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/ServiceRegistrations/DependencyInjection.cs new file mode 100644 index 0000000..0554d7e --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/ServiceRegistrations/DependencyInjection.cs @@ -0,0 +1,65 @@ +using System.Reflection; +using Carter; +using CSharpEssentials; +using CSharpEssentials.AspNetCore; +using CSharpEssentials.RequestResponseLogging; +using MuhammedTask.BuildingBlocks.Application.Shared.Constants; +using MuhammedTask.BuildingBlocks.Presentation.Authentication; +using MuhammedTask.BuildingBlocks.Presentation.Cors; +using MuhammedTask.BuildingBlocks.Presentation.HealthChecks; +using MuhammedTask.BuildingBlocks.Presentation.SessionContexts; +using MuhammedTask.Services.Info; +using MuhammedTask.Services.ProductReviewService.Application.ServiceRegistrations; +using MuhammedTask.Services.ProductReviewService.Persistence.ServiceRegistrations; +using Microsoft.Net.Http.Headers; + +namespace MuhammedTask.Services.ProductReviewService.WebApi.ServiceRegistrations; + +internal static class DependencyInjection +{ + internal static IServiceCollection AddServiceRegistrations( + this IServiceCollection services, + IHostEnvironment hostEnvironment, + IConfiguration configuration) + { + services.AddCarter(); + services.AddHealthChecks(); + + return services + .AddAllAcceptCors() + .AddHttpContextAccessor() + .AddApplicationServices() + .AddPersistenceServices(hostEnvironment, configuration) + .AddSessionContext() + .AddExceptionHandler() + .ConfigureModelValidatorResponse() + .ConfigureSystemTextJson() + .AddEnhancedProblemDetails() + .AddAndConfigureApiVersioning() + .AddSwagger(SecuritySchemes.JwtBearerTokenSecurity, Assembly.GetExecutingAssembly()) + .AddKeycloakJwtBearer(keycloakServiceId: ServiceKeys.Keycloak, realm: "products") + .ConfigureTelemetries(hostEnvironment); + } + + internal static WebApplication UseServices(this WebApplication app) + { + app.UseVersionableSwagger(); + app.AddRequestResponseLogging(opt => + { + opt.IgnorePaths("/health"); + var loggingOptions = LoggingOptions.CreateAllFields(); + loggingOptions.HeaderKeys.Add(HeaderNames.AcceptLanguage); + loggingOptions.HeaderKeys.Add(CustomHeaderNames.TenantId); + opt.UseLogger(app.Services.GetRequiredService(), loggingOptions); + }); + app.UseExceptionHandler(); + app.UseStatusCodePages(); + app.UseCors("all"); + app.MapCarter(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseDefaultHealthChecks(); + + return app; + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/ServiceRegistrations/OpenTelemetryDependencyInjection.cs b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/ServiceRegistrations/OpenTelemetryDependencyInjection.cs new file mode 100644 index 0000000..6c069e5 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/ServiceRegistrations/OpenTelemetryDependencyInjection.cs @@ -0,0 +1,20 @@ +using MuhammedTask.BuildingBlocks.Caching.Redis; +using MuhammedTask.BuildingBlocks.Database.PostgreSQL; +using MuhammedTask.BuildingBlocks.OpenTelemetry.Base; + +namespace MuhammedTask.Services.ProductReviewService.WebApi.ServiceRegistrations; + +internal static class OpenTelemetryDependencyInjection +{ + internal static IServiceCollection ConfigureTelemetries( + this IServiceCollection services, + IHostEnvironment hostEnvironment) + { + services + .ConfigureOpenTelemetry(hostEnvironment.ApplicationName) + .ConfigurePostgresSqlTelemetry() + .ConfigureCacheServiceTelemetry() + .AddOtlpExporter(); + return services; + } +} diff --git a/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/migration.sh b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/migration.sh new file mode 100755 index 0000000..67360d3 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductReviewService/MuhammedTask.Services.ProductReviewService.WebApi/migration.sh @@ -0,0 +1,15 @@ +#!/bin/bash +MIGRATION_NAME=$1 + +if [ -z "$MIGRATION_NAME" ]; then + echo "Error: Migration name is required" + exit 1 +fi + +dotnet ef migrations add $MIGRATION_NAME --project ../MuhammedTask.Services.ProductReviewService.Persistence --context ApplicationWriteDbContext --output-dir EntityFrameworkCore/Migrations/ApplicationWrite + +if [ $? -eq 0 ]; then + echo "Migration '$MIGRATION_NAME' added successfully." +else + echo "Failed to add migration '$MIGRATION_NAME'." +fi diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/ApplicationAssemblyReference.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/ApplicationAssemblyReference.cs new file mode 100644 index 0000000..111a85a --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/ApplicationAssemblyReference.cs @@ -0,0 +1,7 @@ +using System.Reflection; + +namespace MuhammedTask.Services.ProductService.Application; +public static class ApplicationAssemblyReference +{ + public static Assembly Assembly => typeof(ApplicationAssemblyReference).Assembly; +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Categories/v1/Commands/Delete/CategoryDeleteCommand.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Categories/v1/Commands/Delete/CategoryDeleteCommand.cs new file mode 100644 index 0000000..e9ce20c --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Categories/v1/Commands/Delete/CategoryDeleteCommand.cs @@ -0,0 +1,4 @@ +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; + +namespace MuhammedTask.Services.ProductService.Application.Categories.v1.Commands.Delete; +public sealed record CategoryDeleteCommand(Guid CategoryId) : ICommand; diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Categories/v1/Commands/Delete/CategoryDeleteCommandHandler.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Categories/v1/Commands/Delete/CategoryDeleteCommandHandler.cs new file mode 100644 index 0000000..2216f60 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Categories/v1/Commands/Delete/CategoryDeleteCommandHandler.cs @@ -0,0 +1,25 @@ +using CSharpEssentials; +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.BuildingBlocks.Caching.Base; +using MuhammedTask.Services.ProductService.Domain.Categories.Repositories; +using MuhammedTask.Services.ProductService.Domain.Products.Fields; + +namespace MuhammedTask.Services.ProductService.Application.Categories.v1.Commands.Delete; + +internal sealed class CategoryDeleteCommandHandler + (ICategoryCommandRepository repository, + ICacheService cacheService) : ICommandHandler +{ + public async Task Handle(CategoryDeleteCommand request, CancellationToken cancellationToken) + { + var categoryId = CategoryId.From(request.CategoryId); + Result result = await repository.DeleteProductsByCategoryIdAsync(categoryId, cancellationToken); + if (result.IsFailure) + return result; + + string key = $"category:{request.CategoryId}:products"; + cacheService.Remove(key); + + return result; + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Categories/v1/Commands/Delete/CategoryDeleteCommandValidator.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Categories/v1/Commands/Delete/CategoryDeleteCommandValidator.cs new file mode 100644 index 0000000..701f65b --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Categories/v1/Commands/Delete/CategoryDeleteCommandValidator.cs @@ -0,0 +1,8 @@ +using FluentValidation; + +namespace MuhammedTask.Services.ProductService.Application.Categories.v1.Commands.Delete; + +internal sealed class CategoryDeleteCommandValidator : AbstractValidator +{ + public CategoryDeleteCommandValidator() => RuleFor(x => x.CategoryId).NotEmpty(); +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/MuhammedTask.Services.ProductService.Application.csproj b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/MuhammedTask.Services.ProductService.Application.csproj new file mode 100644 index 0000000..76028bd --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/MuhammedTask.Services.ProductService.Application.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Commands/Create/ProductCreateCommand.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Commands/Create/ProductCreateCommand.cs new file mode 100644 index 0000000..c825c5e --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Commands/Create/ProductCreateCommand.cs @@ -0,0 +1,16 @@ +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.ProductService.Domain.Products.Fields; +using MuhammedTask.Services.ProductService.Domain.Products.Parameters; + +namespace MuhammedTask.Services.ProductService.Application.Products.v1.Commands.Create; + +public sealed record ProductCreateCommand( + string? Name, + string? Description, + decimal Price, + Currency Currency, + Guid Category) : ICommand +{ + public ProductCreateParameters ToParameters() => + new(Name, Description, Price, Currency, CategoryId.From(Category)); +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Commands/Create/ProductCreateCommandHandler.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Commands/Create/ProductCreateCommandHandler.cs new file mode 100644 index 0000000..b542bdb --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Commands/Create/ProductCreateCommandHandler.cs @@ -0,0 +1,21 @@ +using CSharpEssentials; +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.ProductService.Domain.Categories.Rules.Exist; +using MuhammedTask.Services.ProductService.Domain.Categories.Services; +using MuhammedTask.Services.ProductService.Domain.Products.Fields; +using MuhammedTask.Services.ProductService.Domain.Products.Parameters; +using MuhammedTask.Services.ProductService.Domain.Products.Repositories; + +namespace MuhammedTask.Services.ProductService.Application.Products.v1.Commands.Create; + +internal sealed class ProductCreateCommandHandler( + IProductCommandRepository repository, + ICategoryService categoryService) : ICommandHandler +{ + public Task> Handle(ProductCreateCommand request, CancellationToken cancellationToken) + { + var rule = new CategoryExistRule(categoryService); + ProductCreateParameters parameters = request.ToParameters(); + return repository.CreateProductAsync(parameters, rule, cancellationToken); + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Commands/Create/ProductCreateCommandValidator.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Commands/Create/ProductCreateCommandValidator.cs new file mode 100644 index 0000000..a7515bb --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Commands/Create/ProductCreateCommandValidator.cs @@ -0,0 +1,22 @@ +using FluentValidation; +using MuhammedTask.Services.ProductService.Domain.Products.Fields; + +namespace MuhammedTask.Services.ProductService.Application.Products.v1.Commands.Create; + +internal sealed class ProductCreateCommandValidator : AbstractValidator +{ + public ProductCreateCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .MinimumLength(ProductName.MinLength) + .MaximumLength(ProductName.MaxLength); + RuleFor(x => x.Description) + .NotEmpty() + .MinimumLength(ProductDescription.MinLength) + .MaximumLength(ProductDescription.MaxLength); + RuleFor(x => x.Price).GreaterThan(0); + RuleFor(x => x.Currency).IsInEnum(); + RuleFor(x => x.Category).NotEmpty(); + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Commands/Delete/ProductDeleteCommand.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Commands/Delete/ProductDeleteCommand.cs new file mode 100644 index 0000000..3ff1633 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Commands/Delete/ProductDeleteCommand.cs @@ -0,0 +1,4 @@ +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; + +namespace MuhammedTask.Services.ProductService.Application.Products.v1.Commands.Delete; +public sealed record ProductDeleteCommand(Guid ProductId) : ICommand; diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Commands/Delete/ProductDeleteCommandHandler.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Commands/Delete/ProductDeleteCommandHandler.cs new file mode 100644 index 0000000..d6cf039 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Commands/Delete/ProductDeleteCommandHandler.cs @@ -0,0 +1,16 @@ +using CSharpEssentials; +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.ProductService.Domain.Products.Fields; +using MuhammedTask.Services.ProductService.Domain.Products.Repositories; + +namespace MuhammedTask.Services.ProductService.Application.Products.v1.Commands.Delete; + +internal sealed class ProductDeleteCommandHandler( + IProductCommandRepository repository) : ICommandHandler +{ + public Task Handle(ProductDeleteCommand request, CancellationToken cancellationToken) + { + var productId = ProductId.From(request.ProductId); + return repository.DeleteProductAsync(productId, cancellationToken); + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Commands/Delete/ProductDeleteCommandValidator.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Commands/Delete/ProductDeleteCommandValidator.cs new file mode 100644 index 0000000..91d35bc --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Commands/Delete/ProductDeleteCommandValidator.cs @@ -0,0 +1,9 @@ +using FluentValidation; + +namespace MuhammedTask.Services.ProductService.Application.Products.v1.Commands.Delete; + +internal sealed class ProductDeleteCommandValidator : AbstractValidator +{ + public ProductDeleteCommandValidator() => RuleFor(x => x.ProductId).NotEmpty(); +} + diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Events/ProductCreatedDomainEventHandler.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Events/ProductCreatedDomainEventHandler.cs new file mode 100644 index 0000000..9ee19d1 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Events/ProductCreatedDomainEventHandler.cs @@ -0,0 +1,18 @@ +using MediatR; +using MuhammedTask.BuildingBlocks.Caching.Base; +using MuhammedTask.Services.ProductService.Domain.Products.Events; +using Microsoft.Extensions.Logging; + +namespace MuhammedTask.Services.ProductService.Application.Products.v1.Events; +internal sealed class ProductCreatedDomainEventHandler( + ILogger logger, + ICacheService cacheService) : INotificationHandler +{ + private const string Tag = "products"; + + public async Task Handle(ProductCreatedDomainEvent notification, CancellationToken cancellationToken) + { + logger.LogInformation("ProductCreatedDomainEvent handled"); + await cacheService.InvalidateTagAsync(Tag); + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Events/ProductDeletedDomainEventHandler.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Events/ProductDeletedDomainEventHandler.cs new file mode 100644 index 0000000..2db253f --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Events/ProductDeletedDomainEventHandler.cs @@ -0,0 +1,20 @@ +using MediatR; +using MuhammedTask.BuildingBlocks.Caching.Base; +using MuhammedTask.Services.ProductService.Domain.Products.Events; +using Microsoft.Extensions.Logging; + +namespace MuhammedTask.Services.ProductService.Application.Products.v1.Events; + +internal sealed class ProductDeletedDomainEventHandler( + ILogger logger, + ICacheService cacheService) : INotificationHandler +{ + private const string Tag = "products:list"; + public Task Handle(ProductDeletedDomainEvent notification, CancellationToken cancellationToken) + { + logger.LogInformation("ProductDeletedDomainEvent handled"); + string productIdCache = $"product:{notification.Id}"; + cacheService.Remove(productIdCache); + return cacheService.InvalidateTagAsync(Tag); + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Models/ProductViewModel.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Models/ProductViewModel.cs new file mode 100644 index 0000000..3a2681c --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Models/ProductViewModel.cs @@ -0,0 +1,28 @@ +using MuhammedTask.Services.ProductService.Domain.Products.Fields; +using MuhammedTask.Services.ProductService.Domain.Products.ReadModels; + +namespace MuhammedTask.Services.ProductService.Application.Products.v1.Models; +public readonly record struct ProductViewModel +{ + private ProductViewModel(ProductReadModel product) + { + Id = product.Id; + Name = product.Name; + Description = product.Description; + Price = product.Price; + Currency = product.Currency; + Category = product.Category; + CreatedAt = product.CreatedAt; + } + + public readonly Guid Id { get; init; } + public readonly string Name { get; init; } + public readonly string Description { get; init; } + public readonly decimal Price { get; init; } + public readonly Currency Currency { get; init; } + public readonly Guid Category { get; init; } + public readonly DateTimeOffset CreatedAt { get; init; } + + public static ProductViewModel Create(ProductReadModel product) => new(product); + public static ProductViewModel[] Create(ProductReadModel[] products) => [.. products.Select(Create)]; +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Queries/Get/GetProductQuery.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Queries/Get/GetProductQuery.cs new file mode 100644 index 0000000..16a75e7 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Queries/Get/GetProductQuery.cs @@ -0,0 +1,16 @@ +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.ProductService.Application.Products.v1.Models; + +namespace MuhammedTask.Services.ProductService.Application.Products.v1.Queries.Get; +public sealed record GetProductQuery(Guid ProductId) : ICachedQuery +{ + public bool BypassCache => false; + + public bool CacheFailures => true; + + public string CacheKey => $"product:{ProductId}"; + + public TimeSpan Expiration => TimeSpan.FromMinutes(10); + + public string[] Tags => []; +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Queries/Get/GetProductQueryHandler.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Queries/Get/GetProductQueryHandler.cs new file mode 100644 index 0000000..37196ee --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Queries/Get/GetProductQueryHandler.cs @@ -0,0 +1,23 @@ +using CSharpEssentials; +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.ProductService.Application.Products.v1.Models; +using MuhammedTask.Services.ProductService.Domain.Products; +using MuhammedTask.Services.ProductService.Domain.Products.Fields; +using MuhammedTask.Services.ProductService.Domain.Products.ReadModels; +using MuhammedTask.Services.ProductService.Domain.Products.Repositories; + +namespace MuhammedTask.Services.ProductService.Application.Products.v1.Queries.Get; + +internal sealed class GetProductQueryHandler( + IProductQueryRepository productQueryRepository) : ICachedQueryHandler +{ + public async Task> Handle(GetProductQuery request, CancellationToken cancellationToken) + { + var productId = ProductId.From(request.ProductId); + Maybe product = await productQueryRepository.GetProductByIdAsync(productId, cancellationToken); + + return product.Match>( + value => ProductViewModel.Create(value), + () => ProductErrors.ProductDoesNotExistError(productId)); + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Queries/Get/GetProductQueryValidator.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Queries/Get/GetProductQueryValidator.cs new file mode 100644 index 0000000..21eaa26 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Queries/Get/GetProductQueryValidator.cs @@ -0,0 +1,8 @@ +using FluentValidation; + +namespace MuhammedTask.Services.ProductService.Application.Products.v1.Queries.Get; + +internal sealed class GetProductQueryValidator : AbstractValidator +{ + public GetProductQueryValidator() => RuleFor(x => x.ProductId).NotEmpty(); +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Queries/List/GetProductListQuery.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Queries/List/GetProductListQuery.cs new file mode 100644 index 0000000..c35d449 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Queries/List/GetProductListQuery.cs @@ -0,0 +1,17 @@ +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.ProductService.Application.Products.v1.Models; + +namespace MuhammedTask.Services.ProductService.Application.Products.v1.Queries.List; +public sealed record GetProductListQuery(Guid CategoryId) : + ICachedQuery +{ + public bool BypassCache => false; + + public bool CacheFailures => true; + + public string CacheKey => $"category:{CategoryId}:products"; + + public TimeSpan Expiration => TimeSpan.FromMinutes(10); + + public string[] Tags => [$"category:{CategoryId}", "products"]; +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Queries/List/GetProductListQueryHandler.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Queries/List/GetProductListQueryHandler.cs new file mode 100644 index 0000000..7f3bb57 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Queries/List/GetProductListQueryHandler.cs @@ -0,0 +1,21 @@ +using CSharpEssentials; +using MuhammedTask.BuildingBlocks.Application.Abstractions.Contracts; +using MuhammedTask.Services.ProductService.Application.Products.v1.Models; +using MuhammedTask.Services.ProductService.Domain.Products.Fields; +using MuhammedTask.Services.ProductService.Domain.Products.ReadModels; +using MuhammedTask.Services.ProductService.Domain.Products.Repositories; + +namespace MuhammedTask.Services.ProductService.Application.Products.v1.Queries.List; + +internal sealed class GetProductListQueryHandler( + IProductQueryRepository repository) : ICachedQueryHandler +{ + public async Task> Handle(GetProductListQuery request, CancellationToken cancellationToken) + { + var categoryId = CategoryId.From(request.CategoryId); + ProductReadModel[] products = await repository.GetProductsByCategoryId(categoryId, cancellationToken); + ProductViewModel[] models = ProductViewModel.Create(products); + return models; + + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Queries/List/GetProductListQueryValidator.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Queries/List/GetProductListQueryValidator.cs new file mode 100644 index 0000000..7497adb --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/Products/v1/Queries/List/GetProductListQueryValidator.cs @@ -0,0 +1,8 @@ +using FluentValidation; + +namespace MuhammedTask.Services.ProductService.Application.Products.v1.Queries.List; + +internal sealed class GetProductListQueryValidator : AbstractValidator +{ + public GetProductListQueryValidator() => RuleFor(x => x.CategoryId).NotEmpty(); +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/ServiceRegistrations/DependencyInjection.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/ServiceRegistrations/DependencyInjection.cs new file mode 100644 index 0000000..ae33bfb --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Application/ServiceRegistrations/DependencyInjection.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using MuhammedTask.BuildingBlocks.Application.DependencyInjections; +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.Services.ProductService.Application.ServiceRegistrations; +public static class DependencyInjection +{ + public static IServiceCollection AddApplicationServices( + this IServiceCollection services) + { + return services + .AddDateTimeProvider() + .AddMediatR(configure => + { + configure.RegisterServicesFromAssembly(ApplicationAssemblyReference.Assembly); + configure.AddDefaultBehaviors(); + }) + .AddValidatorsFromAssembly(ApplicationAssemblyReference.Assembly, includeInternalTypes: true); + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Categories/Repositories/ICategoryCommandRepository.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Categories/Repositories/ICategoryCommandRepository.cs new file mode 100644 index 0000000..e35d0f8 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Categories/Repositories/ICategoryCommandRepository.cs @@ -0,0 +1,8 @@ +using CSharpEssentials; +using MuhammedTask.Services.ProductService.Domain.Products.Fields; + +namespace MuhammedTask.Services.ProductService.Domain.Categories.Repositories; +public interface ICategoryCommandRepository +{ + Task DeleteProductsByCategoryIdAsync(CategoryId categoryId, CancellationToken cancellationToken = default); +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Categories/Rules/Exist/CategoryExistRule.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Categories/Rules/Exist/CategoryExistRule.cs new file mode 100644 index 0000000..0a60cdd --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Categories/Rules/Exist/CategoryExistRule.cs @@ -0,0 +1,22 @@ +using CSharpEssentials; +using MuhammedTask.Services.ProductService.Domain.Categories.Services; +using MuhammedTask.Services.ProductService.Domain.Products; +using MuhammedTask.Services.ProductService.Domain.Products.Parameters; + +namespace MuhammedTask.Services.ProductService.Domain.Categories.Rules.Exist; +public readonly record struct CategoryExistRule + (ICategoryService Service) : IAsyncRule +{ + public async ValueTask EvaluateAsync(ProductCreateParameters context, CancellationToken cancellationToken = default) + { + return await Service.CategoryExistsAsync(context.Category, cancellationToken) + .Match( + onSuccess: isExist => isExist.IsTrue() ? + Result.Success() : + ProductErrors.CategoryDoesNotExistError(context.Category), + onError: errors => errors.ToArray(), + cancellationToken: cancellationToken + ); + + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Categories/Services/ICategoryService.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Categories/Services/ICategoryService.cs new file mode 100644 index 0000000..9718955 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Categories/Services/ICategoryService.cs @@ -0,0 +1,8 @@ +using CSharpEssentials; +using MuhammedTask.Services.ProductService.Domain.Products.Fields; + +namespace MuhammedTask.Services.ProductService.Domain.Categories.Services; +public interface ICategoryService +{ + Task> CategoryExistsAsync(CategoryId categoryId, CancellationToken cancellationToken = default); +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/DomainAssemblyReference.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/DomainAssemblyReference.cs new file mode 100644 index 0000000..b57b042 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/DomainAssemblyReference.cs @@ -0,0 +1,7 @@ +using System.Reflection; + +namespace MuhammedTask.Services.ProductService.Domain; +public static class DomainAssemblyReference +{ + public static Assembly Assembly => typeof(DomainAssemblyReference).Assembly; +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/MuhammedTask.Services.ProductService.Domain.csproj b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/MuhammedTask.Services.ProductService.Domain.csproj new file mode 100644 index 0000000..47c47e2 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/MuhammedTask.Services.ProductService.Domain.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Events/ProductCreatedDomainEvent.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Events/ProductCreatedDomainEvent.cs new file mode 100644 index 0000000..b21297c --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Events/ProductCreatedDomainEvent.cs @@ -0,0 +1,7 @@ +using CSharpEssentials.Interfaces; +using MuhammedTask.Services.ProductService.Domain.Products.Fields; + +namespace MuhammedTask.Services.ProductService.Domain.Products.Events; +public sealed record ProductCreatedDomainEvent(ProductId Id) : IDomainEvent; + + diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Events/ProductDeletedDomainEvent.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Events/ProductDeletedDomainEvent.cs new file mode 100644 index 0000000..f034352 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Events/ProductDeletedDomainEvent.cs @@ -0,0 +1,8 @@ +using CSharpEssentials.Interfaces; +using MuhammedTask.Services.ProductService.Domain.Products.Fields; + +namespace MuhammedTask.Services.ProductService.Domain.Products.Events; + +public sealed record ProductDeletedDomainEvent(ProductId Id) : IDomainEvent; + + diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Fields/CategoryId.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Fields/CategoryId.cs new file mode 100644 index 0000000..f8193f2 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Fields/CategoryId.cs @@ -0,0 +1,13 @@ +using CSharpEssentials; + +namespace MuhammedTask.Services.ProductService.Domain.Products.Fields; + +public readonly record struct CategoryId +{ + private CategoryId(Guid value) => Value = value; + public Guid Value { get; } + public static CategoryId New() => new(Guider.NewGuid()); + public static CategoryId From(Guid value) => new(value); + + public static readonly CategoryId Empty = new(Guid.Empty); +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Fields/Currency.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Fields/Currency.cs new file mode 100644 index 0000000..8434b55 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Fields/Currency.cs @@ -0,0 +1,12 @@ +using CSharpEssentials; + +namespace MuhammedTask.Services.ProductService.Domain.Products.Fields; + +[StringEnum] +public enum Currency +{ + USD, + EUR, + GBP, + TRY +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Fields/Money.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Fields/Money.cs new file mode 100644 index 0000000..58140fc --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Fields/Money.cs @@ -0,0 +1,35 @@ +using System.Globalization; + +namespace MuhammedTask.Services.ProductService.Domain.Products.Fields; + +public readonly record struct Money +{ + private Money(decimal value, Currency currency) => (Value, Currency) = (value, currency); + public readonly decimal Value { get; } + public readonly Currency Currency { get; } + public static Money Zero(Currency currency) => new(0, currency); + public static Money From(decimal value, Currency currency) => new(value, currency); + + public static Money operator +(Money x, Money y) => Calculate(x, y, (a, b) => a + b); + public static Money operator -(Money x, Money y) => Calculate(x, y, (a, b) => a - b); + + + public static Money operator *(Money x, int y) => Calculate(x, y, (a, b) => a * b); + public static Money operator /(Money x, int y) => Calculate(x, y, (a, b) => a / b); + public static Money operator *(Money x, decimal y) => Calculate(x, y, (a, b) => a * b); + public static Money operator /(Money x, decimal y) => Calculate(x, y, (a, b) => a / b); + + public static Money Max(Money x, Money y) => x > y ? x : y; + + public static bool operator >(Money x, Money y) => Compare(x, y, (a, b) => a > b); + public static bool operator <(Money x, Money y) => Compare(x, y, (a, b) => a < b); + public static bool operator >=(Money x, Money y) => Compare(x, y, (a, b) => a >= b); + public static bool operator <=(Money x, Money y) => Compare(x, y, (a, b) => a <= b); + + private static bool Compare(Money x, Money y, Func compare) => compare(x.Value, y.Value); + private static Money Calculate(Money x, T y, Func calculate) => new(calculate(x.Value, y), x.Currency); + private static Money Calculate(Money x, Money y, Func calculate) => new(calculate(x.Value, y.Value), x.Currency); + + public override string ToString() => + $"{Value.ToString("F", CultureInfo.InvariantCulture)} {Currency}"; +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Fields/ProductDescription.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Fields/ProductDescription.cs new file mode 100644 index 0000000..4c59992 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Fields/ProductDescription.cs @@ -0,0 +1,22 @@ +using CSharpEssentials; + +namespace MuhammedTask.Services.ProductService.Domain.Products.Fields; + +public readonly record struct ProductDescription +{ + public const int MinLength = 10; + public const int MaxLength = 1_000; + public string Value { get; } + private ProductDescription(string value) => Value = value; + public static ProductDescription From(string value) => new(value); + public static Result Create(string? value) + { + if (value.IsEmpty()) + return ProductErrors.Description.EmptyError; + if (value.Length < MinLength) + return ProductErrors.Description.TooShortError(value.Length); + if (value.Length > MaxLength) + return ProductErrors.Description.TooLongError(value.Length); + return new ProductDescription(value); + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Fields/ProductId.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Fields/ProductId.cs new file mode 100644 index 0000000..9ec8751 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Fields/ProductId.cs @@ -0,0 +1,13 @@ +using CSharpEssentials; + +namespace MuhammedTask.Services.ProductService.Domain.Products.Fields; + +public readonly record struct ProductId +{ + private ProductId(Guid value) => Value = value; + public Guid Value { get; } + public static ProductId New() => new(Guider.NewGuid()); + public static ProductId From(Guid value) => new(value); + + public static readonly ProductId Empty = new(Guid.Empty); +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Fields/ProductName.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Fields/ProductName.cs new file mode 100644 index 0000000..31ebefb --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Fields/ProductName.cs @@ -0,0 +1,25 @@ +using CSharpEssentials; + +namespace MuhammedTask.Services.ProductService.Domain.Products.Fields; + +public readonly record struct ProductName +{ + public const int MinLength = 2; + public const int MaxLength = 100; + public string Value { get; } + private ProductName(string value) => Value = value; + public static ProductName From(string value) => new(value); + public static Result Create(string? value) + { + if (value.IsEmpty()) + return ProductErrors.Name.EmptyError; + + if (value.Length < MinLength) + return ProductErrors.Name.TooShortError(value.Length); + + if (value.Length > MaxLength) + return ProductErrors.Name.TooLongError(value.Length); + + return new ProductName(value); + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Parameters/ProductCreateParameters.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Parameters/ProductCreateParameters.cs new file mode 100644 index 0000000..76c25c0 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Parameters/ProductCreateParameters.cs @@ -0,0 +1,4 @@ +using MuhammedTask.Services.ProductService.Domain.Products.Fields; + +namespace MuhammedTask.Services.ProductService.Domain.Products.Parameters; +public sealed record ProductCreateParameters(string? Name, string? Description, decimal Price, Currency Currency, CategoryId Category); diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Product.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Product.cs new file mode 100644 index 0000000..e434bef --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Product.cs @@ -0,0 +1,55 @@ +using CSharpEssentials; +using CSharpEssentials.Entity; +using MuhammedTask.Services.ProductService.Domain.Products.Events; +using MuhammedTask.Services.ProductService.Domain.Products.Fields; +using MuhammedTask.Services.ProductService.Domain.Products.Parameters; + +namespace MuhammedTask.Services.ProductService.Domain.Products; +public sealed class Product : SoftDeletableEntityBase +{ + private Product() { } + private Product(ProductId productId, ProductName name, ProductDescription description, CategoryId category, Money money) + { + Id = productId; + Name = name; + Description = description; + Category = category; + Money = money; + Raise(new ProductCreatedDomainEvent(productId)); + } + public ProductName Name { get; private set; } + public ProductDescription Description { get; private set; } + public Money Money { get; private set; } + public CategoryId Category { get; private set; } + + public static Result Create( + ProductCreateParameters parameters) + { + Result productName = ProductName.Create(parameters.Name); + Result productDescription = ProductDescription.Create(parameters.Description); + + var result = Result.And(productName, productDescription); + if (result.IsFailure) + return result.Errors; + + if (parameters.Category == CategoryId.Empty) + return ProductErrors.EmptyCategoryIdError; + + var money = Money.From(parameters.Price, parameters.Currency); + + return new Product(ProductId.New(), productName.Value, productDescription.Value, parameters.Category, money); + } + + public static Result Create( + ProductCreateParameters parameters, + IRuleBase rule, + CancellationToken cancellationToken = default) + { + Result ruleResult = RuleEngine.Evaluate(rule, parameters, cancellationToken); + return ruleResult.Match( + () => Create(parameters), + errors => errors); + } + + public void Delete() => Raise(new ProductDeletedDomainEvent(Id)); +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/ProductErrors.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/ProductErrors.cs new file mode 100644 index 0000000..3320004 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/ProductErrors.cs @@ -0,0 +1,29 @@ +using CSharpEssentials; +using MuhammedTask.Services.ProductService.Domain.Products.Fields; + +namespace MuhammedTask.Services.ProductService.Domain.Products; +public static class ProductErrors +{ + public static readonly Error EmptyCategoryIdError = Error.Validation(code: "Product.Category.Empty", description: "Product category is required"); + public static Error CategoryDoesNotExistError(CategoryId category) => Error.NotFound(code: "Product.Category.DoesNotExist", description: $"Product category does not exist: {category.Value}"); + public static Error ProductDoesNotExistError(ProductId product) => Error.NotFound(code: "Product.DoesNotExist", description: $"Product does not exist: {product.Value}"); + public static class Name + { + public static readonly Error EmptyError = + Error.Validation(code: "Product.Name.Empty", description: "Product name is required"); + public static Error TooShortError(int length) => + Error.Validation(code: "Product.Name.TooShort", description: $"Product name is too short length: {length}"); + public static Error TooLongError(int length) => + Error.Validation(code: "Product.Name.TooLong", description: $"Product name is too long length: {length}"); + } + + public static class Description + { + public static readonly Error EmptyError = + Error.Validation(code: "Product.Description.Empty", description: "Product description is required"); + public static Error TooShortError(int length) => + Error.Validation(code: "Product.Description.TooShort", description: $"Product description is too short length: {length}"); + public static Error TooLongError(int length) => + Error.Validation(code: "Product.Description.TooLong", description: $"Product description is too long length: {length}"); + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/ReadModels/ProductReadModel.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/ReadModels/ProductReadModel.cs new file mode 100644 index 0000000..b5e771e --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/ReadModels/ProductReadModel.cs @@ -0,0 +1,15 @@ +using CSharpEssentials.Interfaces; +using MuhammedTask.Services.ProductService.Domain.Products.Fields; + +namespace MuhammedTask.Services.ProductService.Domain.Products.ReadModels; +public sealed class ProductReadModel : ISoftDeletableBase +{ + public Guid Id { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public decimal Price { get; set; } + public Currency Currency { get; set; } + public Guid Category { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public bool IsDeleted { get; set; } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Repositories/IProductCommandRepository.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Repositories/IProductCommandRepository.cs new file mode 100644 index 0000000..e178f7f --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Repositories/IProductCommandRepository.cs @@ -0,0 +1,11 @@ +using CSharpEssentials; +using MuhammedTask.Services.ProductService.Domain.Products.Fields; +using MuhammedTask.Services.ProductService.Domain.Products.Parameters; + +namespace MuhammedTask.Services.ProductService.Domain.Products.Repositories; +public interface IProductCommandRepository +{ + Task> CreateProductAsync(ProductCreateParameters parameters, CancellationToken cancellationToken = default); + Task> CreateProductAsync(ProductCreateParameters parameters, IRuleBase rule, CancellationToken cancellationToken = default); + Task DeleteProductAsync(ProductId productId, CancellationToken cancellationToken = default); +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Repositories/IProductQueryRepository.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Repositories/IProductQueryRepository.cs new file mode 100644 index 0000000..fb2c07b --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Domain/Products/Repositories/IProductQueryRepository.cs @@ -0,0 +1,10 @@ +using CSharpEssentials; +using MuhammedTask.Services.ProductService.Domain.Products.Fields; +using MuhammedTask.Services.ProductService.Domain.Products.ReadModels; + +namespace MuhammedTask.Services.ProductService.Domain.Products.Repositories; +public interface IProductQueryRepository +{ + Task> GetProductByIdAsync(ProductId productId, CancellationToken cancellationToken = default); + Task GetProductsByCategoryId(CategoryId categoryId, CancellationToken cancellationToken = default); +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/Categories/HttpServices/IHttpCategoryService.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/Categories/HttpServices/IHttpCategoryService.cs new file mode 100644 index 0000000..48ea261 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/Categories/HttpServices/IHttpCategoryService.cs @@ -0,0 +1,8 @@ +using Refit; + +namespace MuhammedTask.Services.ProductService.Persistence.Categories.HttpServices; +public interface IHttpCategoryService +{ + [Get("/v1/exist/{categoryId}")] + public Task ExistAsync(Guid categoryId, CancellationToken cancellationToken = default); +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/Categories/IntegrationEventHandlers/CategoryDeletedIntegrationEventHandler.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/Categories/IntegrationEventHandlers/CategoryDeletedIntegrationEventHandler.cs new file mode 100644 index 0000000..bca062b --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/Categories/IntegrationEventHandlers/CategoryDeletedIntegrationEventHandler.cs @@ -0,0 +1,20 @@ +using CSharpEssentials; +using MassTransit; +using MuhammedTask.IntegrationEvents.Categories; +using MuhammedTask.Services.ProductService.Domain.Categories.Repositories; +using MuhammedTask.Services.ProductService.Domain.Products.Fields; +using Microsoft.Extensions.Logging; + +namespace MuhammedTask.Services.ProductService.Persistence.Categories.IntegrationEventHandlers; + +public sealed class CategoryDeletedIntegrationEventHandler( + ICategoryCommandRepository categoryCommandRepository, + ILogger logger) : IConsumer +{ + public async Task Consume(ConsumeContext context) + { + Guid categoryId = context.Message.CategoryId; + Result result = await categoryCommandRepository.DeleteProductsByCategoryIdAsync(CategoryId.From(categoryId), context.CancellationToken); + logger.LogInformation("Category with id {CategoryId} has been deleted. Result: {Result}", categoryId, result); + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/Categories/IntegrationEvents/CategoryDeletedIntegrationEvent.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/Categories/IntegrationEvents/CategoryDeletedIntegrationEvent.cs new file mode 100644 index 0000000..40afd7c --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/Categories/IntegrationEvents/CategoryDeletedIntegrationEvent.cs @@ -0,0 +1,2 @@ +namespace MuhammedTask.IntegrationEvents.Categories; +public sealed record CategoryDeletedIntegrationEvent(Guid CategoryId); diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/Categories/Services/CategoryService.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/Categories/Services/CategoryService.cs new file mode 100644 index 0000000..145ed68 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/Categories/Services/CategoryService.cs @@ -0,0 +1,21 @@ +using CSharpEssentials; +using MuhammedTask.Services.ProductService.Domain.Categories.Services; +using MuhammedTask.Services.ProductService.Domain.Products.Fields; +using MuhammedTask.Services.ProductService.Persistence.Categories.HttpServices; + +namespace MuhammedTask.Services.ProductService.Persistence.Categories.Services; +internal sealed class CategoryService( + IHttpCategoryService httpCategoryService) : ICategoryService +{ + public async Task> CategoryExistsAsync(CategoryId categoryId, CancellationToken cancellationToken = default) + { + try + { + return await httpCategoryService.ExistAsync(categoryId.Value, cancellationToken); + } + catch (Exception ex) + { + return Error.Exception(ex); + } + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Configurations/Read/ProductReadModelConfiguration.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Configurations/Read/ProductReadModelConfiguration.cs new file mode 100644 index 0000000..a704418 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Configurations/Read/ProductReadModelConfiguration.cs @@ -0,0 +1,14 @@ + +using MuhammedTask.Services.ProductService.Domain.Products.ReadModels; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MuhammedTask.Services.ProductService.Persistence.EntityFrameworkCore.Configurations.Read; + +public sealed class ProductReadModelConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Configurations/Write/ProductConfiguration.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Configurations/Write/ProductConfiguration.cs new file mode 100644 index 0000000..28adcf9 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Configurations/Write/ProductConfiguration.cs @@ -0,0 +1,44 @@ +using CSharpEssentials.EntityFrameworkCore; +using MuhammedTask.Services.ProductService.Domain.Products; +using MuhammedTask.Services.ProductService.Domain.Products.Fields; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MuhammedTask.Services.ProductService.Persistence.EntityFrameworkCore.Configurations.Write; +internal sealed class ProductConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.SoftDeletableEntityBaseMap(); + + builder + .Property(p => p.Id) + .HasConversion(id => id.Value, id => ProductId.From(id)); + + builder + .Property(p => p.Category) + .HasConversion(id => id.Value, id => CategoryId.From(id)); + + builder + .Property(p => p.Name) + .HasConversion(name => name.Value, name => ProductName.From(name)) + .HasMaxLength(ProductName.MaxLength); + + builder + .Property(p => p.Description) + .HasConversion(description => description.Value, description => ProductDescription.From(description)) + .HasMaxLength(ProductDescription.MaxLength); + + builder + .ComplexProperty(p => p.Money, money => + { + money.Property(m => m.Value).HasColumnName("price"); + money.Property(m => m.Currency).HasColumnName("currency"); + }); + + builder.OptimisticConcurrencyVersionMap(); + + builder.HasIndex(x => x.Category); + builder.HasIndex(x => new { x.CreatedAt, x.IsDeleted }); + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Contexts/ApplicationReadDbContext.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Contexts/ApplicationReadDbContext.cs new file mode 100644 index 0000000..454a12a --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Contexts/ApplicationReadDbContext.cs @@ -0,0 +1,23 @@ +using CSharpEssentials.EntityFrameworkCore; +using MuhammedTask.BuildingBlocks.Database.PostgreSQL.Contexts; +using MuhammedTask.Services.ProductService.Domain.Products.ReadModels; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.Services.ProductService.Persistence.EntityFrameworkCore.Contexts; +public sealed class ApplicationReadDbContext : ReadDbContextBase +{ + public ApplicationReadDbContext( + DbContextOptions options, + IServiceScopeFactory serviceScopeFactory) : base(options, serviceScopeFactory) + { + } + + public DbSet Products => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.ApplySoftDeleteQueryFilter(); + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Contexts/ApplicationWriteDbContext.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Contexts/ApplicationWriteDbContext.cs new file mode 100644 index 0000000..a1f9219 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Contexts/ApplicationWriteDbContext.cs @@ -0,0 +1,23 @@ +using CSharpEssentials.EntityFrameworkCore; +using MuhammedTask.BuildingBlocks.Database.PostgreSQL.Contexts; +using MuhammedTask.Services.ProductService.Domain.Products; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.Services.ProductService.Persistence.EntityFrameworkCore.Contexts; +public sealed class ApplicationWriteDbContext : WriteDbContextBase +{ + public ApplicationWriteDbContext( + DbContextOptions options, + IServiceScopeFactory serviceScopeFactory) : base(options, serviceScopeFactory) + { + } + + public DbSet Products => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.ApplySoftDeleteQueryFilter(); + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/20241218182121_Init.Designer.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/20241218182121_Init.Designer.cs new file mode 100644 index 0000000..c146d2d --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/20241218182121_Init.Designer.cs @@ -0,0 +1,119 @@ +// +using System; +using System.Collections.Generic; +using MuhammedTask.Services.ProductService.Persistence.EntityFrameworkCore.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MuhammedTask.Services.ProductService.Persistence.EntityFrameworkCore.Migrations.ApplicationWrite +{ + [DbContext(typeof(ApplicationWriteDbContext))] + [Migration("20241218182121_Init")] + partial class Init + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MuhammedTask.Services.ProductService.Domain.Products.Product", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Category") + .HasColumnType("uuid") + .HasColumnName("category"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("row_version"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("updated_by"); + + b.ComplexProperty>("Money", "MuhammedTask.Services.ProductService.Domain.Products.Product.Money#Money", b1 => + { + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("currency"); + + b1.Property("Value") + .HasColumnType("numeric") + .HasColumnName("price"); + }); + + b.HasKey("Id") + .HasName("pk_products"); + + b.HasIndex("Category") + .HasDatabaseName("ix_products_category"); + + b.HasIndex("CreatedAt", "IsDeleted") + .HasDatabaseName("ix_products_created_at_is_deleted"); + + b.ToTable("products", null, t => + { + t.HasCheckConstraint("CK_products_currency_Enum", "currency IN ('usd', 'eur', 'gbp', 'try')"); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/20241218182121_Init.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/20241218182121_Init.cs new file mode 100644 index 0000000..5e0cb02 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/20241218182121_Init.cs @@ -0,0 +1,58 @@ +// +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MuhammedTask.Services.ProductService.Persistence.EntityFrameworkCore.Migrations.ApplicationWrite +{ + /// + public partial class Init : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "products", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: false), + category = table.Column(type: "uuid", nullable: false), + row_version = table.Column(type: "bytea", rowVersion: true, nullable: true), + currency = table.Column(type: "character varying(3)", maxLength: 3, nullable: false), + price = table.Column(type: "numeric", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "character varying(40)", maxLength: 40, nullable: true), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true), + deleted_by = table.Column(type: "character varying(40)", maxLength: 40, nullable: true), + is_deleted = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_products", x => x.id); + table.CheckConstraint("CK_products_currency_Enum", "currency IN ('usd', 'eur', 'gbp', 'try')"); + }); + + migrationBuilder.CreateIndex( + name: "ix_products_category", + table: "products", + column: "category"); + + migrationBuilder.CreateIndex( + name: "ix_products_created_at_is_deleted", + table: "products", + columns: new[] { "created_at", "is_deleted" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "products"); + } + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/ApplicationWriteDbContextModelSnapshot.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/ApplicationWriteDbContextModelSnapshot.cs new file mode 100644 index 0000000..57076a5 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Migrations/ApplicationWrite/ApplicationWriteDbContextModelSnapshot.cs @@ -0,0 +1,116 @@ +// +using System; +using System.Collections.Generic; +using MuhammedTask.Services.ProductService.Persistence.EntityFrameworkCore.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MuhammedTask.Services.ProductService.Persistence.EntityFrameworkCore.Migrations.ApplicationWrite +{ + [DbContext(typeof(ApplicationWriteDbContext))] + partial class ApplicationWriteDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MuhammedTask.Services.ProductService.Domain.Products.Product", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Category") + .HasColumnType("uuid") + .HasColumnName("category"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("deleted_by"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("row_version"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("updated_by"); + + b.ComplexProperty>("Money", "MuhammedTask.Services.ProductService.Domain.Products.Product.Money#Money", b1 => + { + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("currency"); + + b1.Property("Value") + .HasColumnType("numeric") + .HasColumnName("price"); + }); + + b.HasKey("Id") + .HasName("pk_products"); + + b.HasIndex("Category") + .HasDatabaseName("ix_products_category"); + + b.HasIndex("CreatedAt", "IsDeleted") + .HasDatabaseName("ix_products_created_at_is_deleted"); + + b.ToTable("products", null, t => + { + t.HasCheckConstraint("CK_products_currency_Enum", "currency IN ('usd', 'eur', 'gbp', 'try')"); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Repositories/Categories/EfCategoryCommandRepository.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Repositories/Categories/EfCategoryCommandRepository.cs new file mode 100644 index 0000000..5623a1a --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Repositories/Categories/EfCategoryCommandRepository.cs @@ -0,0 +1,28 @@ +using CSharpEssentials; +using MuhammedTask.Services.ProductService.Domain.Categories.Repositories; +using MuhammedTask.Services.ProductService.Domain.Products; +using MuhammedTask.Services.ProductService.Domain.Products.Fields; +using MuhammedTask.Services.ProductService.Persistence.EntityFrameworkCore.Contexts; +using Microsoft.EntityFrameworkCore; + +namespace MuhammedTask.Services.ProductService.Persistence.EntityFrameworkCore.Repositories.Categories; +internal sealed class EfCategoryCommandRepository( + ApplicationWriteDbContext context) : ICategoryCommandRepository +{ + public async Task DeleteProductsByCategoryIdAsync(CategoryId categoryId, CancellationToken cancellationToken = default) + { + Product[] products = await context.Products + .Where(p => p.Category == categoryId) + .ToArrayAsync(cancellationToken); + + if (products.Length == 0) + return Result.Success(); + + foreach (Product item in products) + item.Delete(); + + context.Products.RemoveRange(products); + await context.SaveChangesAsync(cancellationToken); + return Result.Success(); + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Repositories/Products/EfProductCommandRepository.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Repositories/Products/EfProductCommandRepository.cs new file mode 100644 index 0000000..7895ce7 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Repositories/Products/EfProductCommandRepository.cs @@ -0,0 +1,55 @@ +using CSharpEssentials; +using MuhammedTask.Services.ProductService.Domain.Products; +using MuhammedTask.Services.ProductService.Domain.Products.Fields; +using MuhammedTask.Services.ProductService.Domain.Products.Parameters; +using MuhammedTask.Services.ProductService.Domain.Products.Repositories; +using MuhammedTask.Services.ProductService.Persistence.EntityFrameworkCore.Contexts; +using Microsoft.EntityFrameworkCore; + +namespace MuhammedTask.Services.ProductService.Persistence.EntityFrameworkCore.Repositories.Products; + +internal sealed class EfProductCommandRepository( + ApplicationWriteDbContext context) : IProductCommandRepository +{ + public async Task> CreateProductAsync(ProductCreateParameters parameters, CancellationToken cancellationToken = default) + { + Result productResult = Product.Create(parameters); + if (productResult.IsFailure) + return productResult.Errors; + Product product = productResult.Value; + + await context.Products.AddAsync(product, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + + return product.Id; + } + + public async Task> CreateProductAsync(ProductCreateParameters parameters, IRuleBase rule, CancellationToken cancellationToken = default) + { + Result productResult = Product.Create(parameters, rule, cancellationToken); + if (productResult.IsFailure) + return productResult.Errors; + Product product = productResult.Value; + + await context.Products.AddAsync(product, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + + return product.Id; + } + + public async Task DeleteProductAsync(ProductId productId, CancellationToken cancellationToken = default) + { + Product? found = await context.Products + .Where(product => product.Id == productId) + .FirstOrDefaultAsync(cancellationToken); + + if (found is null) + return ProductErrors.ProductDoesNotExistError(productId); + + found.Delete(); + context.Products.Remove(found); + await context.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Repositories/Products/EfProductQueryRepository.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Repositories/Products/EfProductQueryRepository.cs new file mode 100644 index 0000000..9f22b5f --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/EntityFrameworkCore/Repositories/Products/EfProductQueryRepository.cs @@ -0,0 +1,26 @@ +using CSharpEssentials; +using MuhammedTask.Services.ProductService.Domain.Products.Fields; +using MuhammedTask.Services.ProductService.Domain.Products.ReadModels; +using MuhammedTask.Services.ProductService.Domain.Products.Repositories; +using MuhammedTask.Services.ProductService.Persistence.EntityFrameworkCore.Contexts; +using Microsoft.EntityFrameworkCore; + +namespace MuhammedTask.Services.ProductService.Persistence.EntityFrameworkCore.Repositories.Products; +internal sealed class EfProductQueryRepository( + ApplicationReadDbContext context) : IProductQueryRepository +{ + public async Task> GetProductByIdAsync(ProductId productId, CancellationToken cancellationToken = default) + { + return await context.Products + .Where(product => product.Id == productId.Value) + .FirstOrDefaultAsync(cancellationToken); + } + + public Task GetProductsByCategoryId(CategoryId categoryId, CancellationToken cancellationToken = default) + { + return context.Products + .Where(product => product.Category == categoryId.Value) + .OrderByDescending(product => product.CreatedAt) + .ToArrayAsync(cancellationToken); + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/Jobs/IntegrationEvents/PeriodicIntegrationEvent.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/Jobs/IntegrationEvents/PeriodicIntegrationEvent.cs new file mode 100644 index 0000000..fd23db3 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/Jobs/IntegrationEvents/PeriodicIntegrationEvent.cs @@ -0,0 +1,3 @@ +namespace MuhammedTask.IntegrationEvents.Jobs; + +public sealed record PeriodicIntegrationEvent(string JobInstanceId, DateTime Timestamp); \ No newline at end of file diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/Jobs/IntegrationEvents/PeriodicIntegrationEventHandler.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/Jobs/IntegrationEvents/PeriodicIntegrationEventHandler.cs new file mode 100644 index 0000000..4551f9d --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/Jobs/IntegrationEvents/PeriodicIntegrationEventHandler.cs @@ -0,0 +1,17 @@ + +using MassTransit; +using MuhammedTask.IntegrationEvents.Jobs; +using Microsoft.Extensions.Logging; + +namespace MuhammedTask.Services.ProductService.Persistence.Jobs.IntegrationEventHandlers; + +public sealed class PeriodicIntegrationEventHandler( + ILogger logger +) : IConsumer +{ + public Task Consume(ConsumeContext context) + { + logger.LogInformation("Periodic integration event received {InstanceId}, {Timestamp}", context.Message.JobInstanceId, context.Message.Timestamp); + return Task.CompletedTask; + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/MuhammedTask.Services.ProductService.Persistence.csproj b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/MuhammedTask.Services.ProductService.Persistence.csproj new file mode 100644 index 0000000..4b76bac --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/MuhammedTask.Services.ProductService.Persistence.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/PersistenceAssemblyReference.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/PersistenceAssemblyReference.cs new file mode 100644 index 0000000..93e29ed --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/PersistenceAssemblyReference.cs @@ -0,0 +1,7 @@ +using System.Reflection; + +namespace MuhammedTask.Services.ProductService.Persistence; +public static class PersistenceAssemblyReference +{ + public static Assembly Assembly => typeof(PersistenceAssemblyReference).Assembly; +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/ServiceRegistrations/DependencyInjection.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/ServiceRegistrations/DependencyInjection.cs new file mode 100644 index 0000000..461b720 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/ServiceRegistrations/DependencyInjection.cs @@ -0,0 +1,78 @@ +using CSharpEssentials.EntityFrameworkCore.Interceptors; +using MassTransit; +using MuhammedTask.BuildingBlocks.BackgroundJobs.Quartz; +using MuhammedTask.BuildingBlocks.Caching.Redis; +using MuhammedTask.BuildingBlocks.Database.PostgreSQL; +using MuhammedTask.BuildingBlocks.MessageBrokers.MassTransit; +using MuhammedTask.Services.Info; +using MuhammedTask.Services.ProductService.Persistence.EntityFrameworkCore.Contexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using StackExchange.Redis; + +namespace MuhammedTask.Services.ProductService.Persistence.ServiceRegistrations; +public static class DependencyInjection +{ + public static IServiceCollection AddPersistenceServices( + this IServiceCollection services, + IHostEnvironment environment, + IConfiguration configuration) + { + return services + .ConfigureCacheServices(environment, configuration) + .AddQuartzWithHostedService() + .AddDatabaseServices(configuration) + .AddEventBus( + configure => configure.UsingRabbitMq((context, cfg) => + { + cfg.Host(configuration.GetConnectionString(ServiceKeys.RabbitMQ)); + cfg.ConfigureEndpoints(context); + }), + assemblies: PersistenceAssemblyReference.Assembly) + .RegisterRepositories() + .RegisterHttpServices() + .RegisterServices(); + } + private static IServiceCollection ConfigureCacheServices( + this IServiceCollection services, + IHostEnvironment environment, + IConfiguration configuration) + { + return services + .AddSingleton(sp => + { + string connectionString = configuration.GetConnectionString(ServiceKeys.Redis); + var configurationOptions = ConfigurationOptions.Parse(connectionString!, true); + configurationOptions.AbortOnConnectFail = false; + return ConnectionMultiplexer.Connect(configurationOptions); + }) + .AddCacheServices( + environment.ApplicationName, + redisConfigurations => redisConfigurations.Configuration = configuration.GetConnectionString(ServiceKeys.Redis), + memoryConfiguration => + { + }); + } + private static IServiceCollection AddDatabaseServices( + this IServiceCollection services, + IConfiguration configuration) + { + string migrationsAssembly = typeof(PersistenceAssemblyReference).Namespace; + return services + .AddSingleton() + .AddPooledPostgresDbContext( + configuration.GetConnectionString(ServiceKeys.Database.PostgresProductService)!, + options => options.MigrationsAssembly = migrationsAssembly) + .AddPooledPostgresDbContext( + configuration.GetConnectionString(ServiceKeys.Database.PostgresProductService)!, + options => + { + options.MigrationsAssembly = migrationsAssembly; + options.EnablePublishDomainEventsInterceptor = false; + options.EnableAuditableInterceptor = false; + options.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + }); + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/ServiceRegistrations/HttpServicesRegistrations.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/ServiceRegistrations/HttpServicesRegistrations.cs new file mode 100644 index 0000000..ea10efd --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/ServiceRegistrations/HttpServicesRegistrations.cs @@ -0,0 +1,22 @@ +using MuhammedTask.BuildingBlocks.Persistence.Extensions; +using MuhammedTask.BuildingBlocks.Persistence.HttpHandlers; +using MuhammedTask.Services.Info; +using MuhammedTask.Services.ProductService.Persistence.Categories.HttpServices; +using Microsoft.Extensions.DependencyInjection; +using Refit; + +namespace MuhammedTask.Services.ProductService.Persistence.ServiceRegistrations; +internal static class HttpServicesRegistrations +{ + public static IServiceCollection RegisterHttpServices(this IServiceCollection services) + { + services.AddTransient(); + services + .AddRefitClient() + .ConfigureHttpClientWithServiceName(ServiceKeys.CategoryService) + .AddHttpMessageHandler(); + + + return services; + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/ServiceRegistrations/RepositoryRegistrations.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/ServiceRegistrations/RepositoryRegistrations.cs new file mode 100644 index 0000000..204b4a4 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/ServiceRegistrations/RepositoryRegistrations.cs @@ -0,0 +1,16 @@ +using MuhammedTask.Services.ProductService.Domain.Categories.Repositories; +using MuhammedTask.Services.ProductService.Domain.Products.Repositories; +using MuhammedTask.Services.ProductService.Persistence.EntityFrameworkCore.Repositories.Categories; +using MuhammedTask.Services.ProductService.Persistence.EntityFrameworkCore.Repositories.Products; + +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.Services.ProductService.Persistence.ServiceRegistrations; +internal static class RepositoryRegistrations +{ + public static IServiceCollection RegisterRepositories(this IServiceCollection services) => + services + .AddScoped() + .AddScoped() + .AddScoped(); +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/ServiceRegistrations/ServicesRegistrations.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/ServiceRegistrations/ServicesRegistrations.cs new file mode 100644 index 0000000..cc7be7d --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.Persistence/ServiceRegistrations/ServicesRegistrations.cs @@ -0,0 +1,14 @@ +using MuhammedTask.Services.ProductService.Domain.Categories.Services; +using MuhammedTask.Services.ProductService.Persistence.Categories.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace MuhammedTask.Services.ProductService.Persistence.ServiceRegistrations; + +internal static class ServicesRegistrations +{ + public static IServiceCollection RegisterServices(this IServiceCollection services) + { + services.AddScoped(); + return services; + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/Endpoints/Categories/v1/Delete/CategoryDeleteEndpoint.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/Endpoints/Categories/v1/Delete/CategoryDeleteEndpoint.cs new file mode 100644 index 0000000..778dbde --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/Endpoints/Categories/v1/Delete/CategoryDeleteEndpoint.cs @@ -0,0 +1,31 @@ +using Carter; +using CSharpEssentials; +using MediatR; +using MuhammedTask.BuildingBlocks.Presentation.Endpoints; +using MuhammedTask.Services.ProductService.Application.Categories.v1.Commands.Delete; + +namespace MuhammedTask.Services.ProductService.WebApi.Endpoints.Categories.v1.Delete; + +public sealed class CategoryDeleteEndpoint : CarterModule +{ + public override void AddRoutes(IEndpointRouteBuilder app) + { + RouteGroupBuilder routeGroup = app + .CreateVersionedGroup(Tags.Categories) + .RequireAuthorization(); + + routeGroup.MapDelete("{categoryId:guid}", DeleteCategory) + .Produces(HttpCodes.NoContent) + .ProducesProblem() + .WithDescription("Delete category by id") + .WithName(nameof(DeleteCategory)); + } + + private static Task DeleteCategory(Guid categoryId, ISender sender, CancellationToken cancellationToken = default) => + sender + .Send(new CategoryDeleteCommand(categoryId), cancellationToken) + .Match( + TypedResults.NoContent, + errors => errors.ToProblemResult(), + cancellationToken); +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/Endpoints/Products/v1/Create/ProductCreateEndpoint.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/Endpoints/Products/v1/Create/ProductCreateEndpoint.cs new file mode 100644 index 0000000..9bec20e --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/Endpoints/Products/v1/Create/ProductCreateEndpoint.cs @@ -0,0 +1,38 @@ +using Carter; +using CSharpEssentials; +using MediatR; +using MuhammedTask.BuildingBlocks.Presentation.Endpoints; +using MuhammedTask.Services.ProductService.Application.Products.v1.Commands.Create; +using MuhammedTask.Services.ProductService.Domain.Products.Fields; +using Microsoft.AspNetCore.Mvc; + +namespace MuhammedTask.Services.ProductService.WebApi.Endpoints.Products.v1.Create; +public sealed class ProductCreateEndpoint : CarterModule +{ + public sealed record ProductCreateRequest(string? Name, string? Description, decimal Price, Currency Currency, Guid Category) + { + public ProductCreateCommand ToCommand() => + new(Name, Description, Price, Currency, Category); + } + + public override void AddRoutes(IEndpointRouteBuilder app) + { + RouteGroupBuilder routeGroup = app + .CreateVersionedGroup(Tags.Products) + .RequireAuthorization(); + + routeGroup.MapPost(string.Empty, CreateProduct) + .Produces(HttpCodes.Created) + .ProducesProblem() + .WithDescription("Create product") + .WithName(nameof(CreateProduct)); + } + + private static Task CreateProduct([FromBody] ProductCreateRequest request, ISender sender, CancellationToken cancellationToken = default) => + sender + .Send(request.ToCommand(), cancellationToken) + .Match( + product => TypedResults.Created($"/{Tags.Products}/{product.Value}", product.Value), + errors => errors.ToProblemResult(), + cancellationToken); +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/Endpoints/Products/v1/Delete/ProductDeleteEndpoint.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/Endpoints/Products/v1/Delete/ProductDeleteEndpoint.cs new file mode 100644 index 0000000..c8baaee --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/Endpoints/Products/v1/Delete/ProductDeleteEndpoint.cs @@ -0,0 +1,31 @@ +using Carter; +using CSharpEssentials; +using MediatR; +using MuhammedTask.BuildingBlocks.Presentation.Endpoints; +using MuhammedTask.Services.ProductService.Application.Products.v1.Commands.Delete; + +namespace MuhammedTask.Services.ProductService.WebApi.Endpoints.Products.v1.Delete; + +public sealed class ProductDeleteEndpoint : CarterModule +{ + public override void AddRoutes(IEndpointRouteBuilder app) + { + RouteGroupBuilder routeGroup = app + .CreateVersionedGroup(Tags.Products) + .RequireAuthorization(); + + routeGroup.MapDelete("{productId:guid}", DeleteProduct) + .Produces(HttpCodes.NoContent) + .ProducesProblem() + .WithDescription("Delete product by id") + .WithName(nameof(DeleteProduct)); + } + + private static Task DeleteProduct(Guid productId, ISender sender, CancellationToken cancellationToken = default) => + sender + .Send(new ProductDeleteCommand(productId), cancellationToken) + .Match( + TypedResults.NoContent, + errors => errors.ToProblemResult(), + cancellationToken); +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/Endpoints/Products/v1/Get/ProductGetEndpoint.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/Endpoints/Products/v1/Get/ProductGetEndpoint.cs new file mode 100644 index 0000000..215df17 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/Endpoints/Products/v1/Get/ProductGetEndpoint.cs @@ -0,0 +1,32 @@ +using Carter; +using CSharpEssentials; +using MediatR; +using MuhammedTask.BuildingBlocks.Presentation.Endpoints; +using MuhammedTask.Services.ProductService.Application.Products.v1.Models; +using MuhammedTask.Services.ProductService.Application.Products.v1.Queries.Get; + +namespace MuhammedTask.Services.ProductService.WebApi.Endpoints.Products.v1.Get; + +public sealed class ProductGetEndpoint : CarterModule +{ + public override void AddRoutes(IEndpointRouteBuilder app) + { + RouteGroupBuilder routeGroup = app + .CreateVersionedGroup(Tags.Products) + .RequireAuthorization(); + + routeGroup.MapGet("{productId:guid}", GetProduct) + .Produces() + .ProducesProblem() + .WithDescription("Get product by id") + .WithName(nameof(GetProduct)); + } + + private static Task GetProduct(Guid productId, ISender sender, CancellationToken cancellationToken = default) => + sender + .Send(new GetProductQuery(productId), cancellationToken) + .Match( + product => TypedResults.Ok(product), + errors => errors.ToProblemResult(), + cancellationToken); +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/Endpoints/Products/v1/List/ProductListEndpoint.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/Endpoints/Products/v1/List/ProductListEndpoint.cs new file mode 100644 index 0000000..9b676f5 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/Endpoints/Products/v1/List/ProductListEndpoint.cs @@ -0,0 +1,32 @@ +using Carter; +using CSharpEssentials; +using MediatR; +using MuhammedTask.BuildingBlocks.Presentation.Endpoints; +using MuhammedTask.Services.ProductService.Application.Products.v1.Models; +using MuhammedTask.Services.ProductService.Application.Products.v1.Queries.List; + +namespace MuhammedTask.Services.ProductService.WebApi.Endpoints.Products.v1.List; + +public sealed class ProductListEndpoint : CarterModule +{ + public override void AddRoutes(IEndpointRouteBuilder app) + { + RouteGroupBuilder routeGroup = app + .CreateVersionedGroup(Tags.Products) + .RequireAuthorization(); + + routeGroup.MapGet("category/{categoryId:guid}", GetProductsByCategory) + .Produces() + .ProducesProblem() + .WithDescription("Get products by category") + .WithName(nameof(GetProductsByCategory)); + } + + private static Task GetProductsByCategory(Guid categoryId, ISender sender, CancellationToken cancellationToken = default) => + sender + .Send(new GetProductListQuery(categoryId), cancellationToken) + .Match( + products => TypedResults.Ok(products), + errors => errors.ToProblemResult(), + cancellationToken); +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/Endpoints/Tags.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/Endpoints/Tags.cs new file mode 100644 index 0000000..4fc99ba --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/Endpoints/Tags.cs @@ -0,0 +1,7 @@ +namespace MuhammedTask.Services.ProductService.WebApi.Endpoints; + +public static class Tags +{ + public const string Products = "products"; + public const string Categories = "categories"; +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/MuhammedTask.Services.ProductService.WebApi.csproj b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/MuhammedTask.Services.ProductService.WebApi.csproj new file mode 100644 index 0000000..1a8125f --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/MuhammedTask.Services.ProductService.WebApi.csproj @@ -0,0 +1,15 @@ + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/Program.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/Program.cs new file mode 100644 index 0000000..a0e7d81 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/Program.cs @@ -0,0 +1,18 @@ +using MuhammedTask.BuildingBlocks.Database.Migrator; +using MuhammedTask.Services.Info; +using MuhammedTask.Services.ProductService.Persistence.EntityFrameworkCore.Contexts; +using MuhammedTask.Services.ProductService.WebApi.ServiceRegistrations; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.Services.AddServiceRegistrations(builder.Environment, builder.Configuration); +builder.AddSeqEndpoint(ServiceKeys.Seq); + +WebApplication app = builder.Build(); + +if (app.Environment.IsDevelopment()) + await app.MigrateAsync(); + +app.UseServices(); + +await app.RunAsync(); diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/Properties/launchSettings.json b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/Properties/launchSettings.json new file mode 100644 index 0000000..51dff7e --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5200", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7077;http://localhost:5200", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/ServiceRegistrations/DependencyInjection.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/ServiceRegistrations/DependencyInjection.cs new file mode 100644 index 0000000..398617b --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/ServiceRegistrations/DependencyInjection.cs @@ -0,0 +1,71 @@ +using System.Reflection; +using Carter; +using CSharpEssentials; +using CSharpEssentials.AspNetCore; +using CSharpEssentials.RequestResponseLogging; +using MuhammedTask.BuildingBlocks.Application.Shared.Constants; +using MuhammedTask.BuildingBlocks.Presentation.Authentication; +using MuhammedTask.BuildingBlocks.Presentation.Cors; +using MuhammedTask.BuildingBlocks.Presentation.HealthChecks; +using MuhammedTask.BuildingBlocks.Presentation.SessionContexts; +using MuhammedTask.Services.Info; +using MuhammedTask.Services.ProductService.Application.ServiceRegistrations; +using MuhammedTask.Services.ProductService.Persistence.ServiceRegistrations; +using Microsoft.Net.Http.Headers; + +namespace MuhammedTask.Services.ProductService.WebApi.ServiceRegistrations; + +internal static class DependencyInjection +{ + internal static IServiceCollection AddServiceRegistrations( + this IServiceCollection services, + IHostEnvironment hostEnvironment, + IConfiguration configuration) + { + //services.AddControllers(); + //services.AddRouting(); + services.AddCarter(); + services.AddHealthChecks(); + + return services + .AddAllAcceptCors() + .AddHttpContextAccessor() + .AddApplicationServices() + .AddPersistenceServices(hostEnvironment, configuration) + .AddSessionContext() + .ConfigureHttpClients() + .AddExceptionHandler() + .ConfigureModelValidatorResponse() + .ConfigureSystemTextJson() + .AddEnhancedProblemDetails() + .AddAndConfigureApiVersioning() + .AddSwagger(SecuritySchemes.JwtBearerTokenSecurity, Assembly.GetExecutingAssembly()) + .AddKeycloakJwtBearer(keycloakServiceId: ServiceKeys.Keycloak, realm: "products") + .ConfigureTelemetries(hostEnvironment); + } + + internal static WebApplication UseServices( + this WebApplication app) + { + app.UseVersionableSwagger(); + app.AddRequestResponseLogging(opt => + { + opt.IgnorePaths("/health"); + var loggingOptions = LoggingOptions.CreateAllFields(); + loggingOptions.HeaderKeys.Add(HeaderNames.AcceptLanguage); + loggingOptions.HeaderKeys.Add(CustomHeaderNames.TenantId); + opt.UseLogger(app.Services.GetRequiredService(), loggingOptions); + }); + app.UseExceptionHandler(); + app.UseStatusCodePages(); + app.UseCors("all"); + //app.UseRouting(); + //app.MapControllers(); + app.MapCarter(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseDefaultHealthChecks(); + + return app; + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/ServiceRegistrations/OpenTelemetryDependencyInjection.cs b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/ServiceRegistrations/OpenTelemetryDependencyInjection.cs new file mode 100644 index 0000000..6226740 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/ServiceRegistrations/OpenTelemetryDependencyInjection.cs @@ -0,0 +1,20 @@ +using MuhammedTask.BuildingBlocks.Caching.Redis; +using MuhammedTask.BuildingBlocks.Database.PostgreSQL; +using MuhammedTask.BuildingBlocks.OpenTelemetry.Base; + +namespace MuhammedTask.Services.ProductService.WebApi.ServiceRegistrations; + +internal static class OpenTelemetryDependencyInjection +{ + internal static IServiceCollection ConfigureTelemetries( + this IServiceCollection services, + IHostEnvironment hostEnvironment) + { + services + .ConfigureOpenTelemetry(hostEnvironment.ApplicationName) + .ConfigurePostgresSqlTelemetry() + .ConfigureCacheServiceTelemetry() + .AddOtlpExporter(); + return services; + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/appsettings.Development.json b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/appsettings.json b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/appsettings.json new file mode 100644 index 0000000..8bc98a7 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Swagger": { + "Title": "Product Serice", + "Description": "MuhammedTask Product Service", + "License": "MIT", + "LicenseUrl": "https://opensource.org/licenses/MIT" + } +} diff --git a/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/migration.sh b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/migration.sh new file mode 100644 index 0000000..1704ff4 --- /dev/null +++ b/muhammed-task/source/src/Services/ProductService/MuhammedTask.Services.ProductService.WebApi/migration.sh @@ -0,0 +1,15 @@ +#!/bin/bash +MIGRATION_NAME=$1 + +if [ -z "$MIGRATION_NAME" ]; then + echo "Error: Migration name is required" + exit 1 +fi + +dotnet ef migrations add $MIGRATION_NAME --project ../MuhammedTask.Services.ProductService.Persistence --context ApplicationWriteDbContext --output-dir EntityFrameworkCore/Migrations/ApplicationWrite + +if [ $? -eq 0 ]; then + echo "Migration '$MIGRATION_NAME' added successfully." +else + echo "Failed to add migration '$MIGRATION_NAME'." +fi diff --git a/muhammed-task/source/src/Services/ProxyService/Yarp.ProxyService/Program.cs b/muhammed-task/source/src/Services/ProxyService/Yarp.ProxyService/Program.cs new file mode 100644 index 0000000..a0a460a --- /dev/null +++ b/muhammed-task/source/src/Services/ProxyService/Yarp.ProxyService/Program.cs @@ -0,0 +1,25 @@ +using MuhammedTask.BuildingBlocks.OpenTelemetry.Base; +using MuhammedTask.BuildingBlocks.Presentation.HealthChecks; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.Services.AddServiceDiscovery(); +builder.Services.AddHealthChecks(); +builder.Services + .ConfigureOpenTelemetry(builder.Environment.ApplicationName) + .AddOtlpExporter(); + +builder.Services.AddReverseProxy() + .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")) + .AddServiceDiscoveryDestinationResolver(); + +builder.AddSeqEndpoint("seq"); + + +WebApplication app = builder.Build(); + +app.MapReverseProxy(); + +app.UseDefaultHealthChecks(); + +await app.RunAsync(); diff --git a/muhammed-task/source/src/Services/ProxyService/Yarp.ProxyService/Properties/launchSettings.json b/muhammed-task/source/src/Services/ProxyService/Yarp.ProxyService/Properties/launchSettings.json new file mode 100644 index 0000000..5e20043 --- /dev/null +++ b/muhammed-task/source/src/Services/ProxyService/Yarp.ProxyService/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/muhammed-task/source/src/Services/ProxyService/Yarp.ProxyService/Yarp.ProxyService.csproj b/muhammed-task/source/src/Services/ProxyService/Yarp.ProxyService/Yarp.ProxyService.csproj new file mode 100644 index 0000000..6fab220 --- /dev/null +++ b/muhammed-task/source/src/Services/ProxyService/Yarp.ProxyService/Yarp.ProxyService.csproj @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/muhammed-task/source/src/Services/ProxyService/Yarp.ProxyService/appsettings.Development.json b/muhammed-task/source/src/Services/ProxyService/Yarp.ProxyService/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/muhammed-task/source/src/Services/ProxyService/Yarp.ProxyService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/muhammed-task/source/src/Services/ProxyService/Yarp.ProxyService/appsettings.json b/muhammed-task/source/src/Services/ProxyService/Yarp.ProxyService/appsettings.json new file mode 100644 index 0000000..298b2f4 --- /dev/null +++ b/muhammed-task/source/src/Services/ProxyService/Yarp.ProxyService/appsettings.json @@ -0,0 +1,115 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ReverseProxy": { + "Routes": { + "product-service-routes": { + "ClusterId": "product-service", + "Match": { + "Path": "products/{**catch-all}" + }, + "Transforms": [ + { + "PathPattern": "{**catch-all}" + } + ] + }, + "category-service-routes": { + "ClusterId": "category-service", + "Match": { + "Path": "categories/{**catch-all}" + }, + "Transforms": [ + { + "PathPattern": "{**catch-all}" + } + ] + }, + "product-review-service-routes": { + "ClusterId": "product-review-service", + "Match": { + "Path": "productreviews/{**catch-all}" + }, + "Transforms": [ + { + "PathPattern": "{**catch-all}" + } + ] + }, + "product-review-service-products-routes": { + "ClusterId": "product-review-service", + "Order": 1, + "Match": { + "Path": "products/{productId}/reviews/{**catch-all}" + }, + "Transforms": [ + { + "PathPattern": "products/{productId}/reviews/{**catch-all}" + } + ] + }, + "product-review-service-average-rating-routes": { + "ClusterId": "product-review-service", + "Order": 1, + "Match": { + "Path": "products/{productId}/average-rating" + }, + "Transforms": [ + { + "PathPattern": "products/{productId}/average-rating" + } + ] + }, + "auth-service-routes": { + "ClusterId": "auth-service", + "Match": { + "Path": "auth/{**catch-all}" + }, + "Transforms": [ + { + "PathPattern": "{**catch-all}" + } + ] + } + }, + "Clusters": { + "product-service": { + "LoadBalancingPolicy": "RoundRobin", + "Destinations": { + "destination1": { + "Address": "https+http://productservice" + } + } + }, + "category-service": { + "LoadBalancingPolicy": "RoundRobin", + "Destinations": { + "destination1": { + "Address": "https+http://categoryservice" + } + } + }, + "product-review-service": { + "LoadBalancingPolicy": "RoundRobin", + "Destinations": { + "destination1": { + "Address": "https+http://productreviewservice" + } + } + }, + "auth-service": { + "LoadBalancingPolicy": "RoundRobin", + "Destinations": { + "destination1": { + "Address": "https+http://keycloak" + } + } + } + } + } +} diff --git a/muhammed-task/source/tests/notes.md b/muhammed-task/source/tests/notes.md new file mode 100644 index 0000000..70263ef --- /dev/null +++ b/muhammed-task/source/tests/notes.md @@ -0,0 +1 @@ +Test Projects \ No newline at end of file