From 803f510d07190f43d10cf15f976481fadd946809 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sat, 17 Jan 2026 21:28:29 +0700 Subject: [PATCH] feat: Add initial PromotionService solution structure including domain, API, and test projects. --- deployments/local/docker-compose.yml | 51 ++ .../Directory.Build.props | 22 + services/promotion-service-net/Dockerfile | 66 +++ .../PromotionService.slnx | 11 + .../promotion-service-net/docker-compose.yml | 72 +++ services/promotion-service-net/global.json | 7 + .../Application/Behaviors/LoggingBehavior.cs | 58 +++ .../Behaviors/TransactionBehavior.cs | 84 ++++ .../Behaviors/ValidatorBehavior.cs | 63 +++ .../Commands/ChangeSampleStatusCommand.cs | 14 + .../ChangeSampleStatusCommandHandler.cs | 70 +++ .../Commands/CreateSampleCommand.cs | 21 + .../Commands/CreateSampleCommandHandler.cs | 46 ++ .../Commands/DeleteSampleCommand.cs | 10 + .../Commands/DeleteSampleCommandHandler.cs | 54 +++ .../Commands/UpdateSampleCommand.cs | 16 + .../Commands/UpdateSampleCommandHandler.cs | 54 +++ .../Application/Queries/GetSampleQuery.cs | 23 + .../Queries/GetSampleQueryHandler.cs | 39 ++ .../Application/Queries/GetSamplesQuery.cs | 9 + .../Queries/GetSamplesQueryHandler.cs | 34 ++ .../CreateSampleCommandValidator.cs | 25 + .../UpdateSampleCommandValidator.cs | 29 ++ .../Controllers/SamplesController.cs | 200 ++++++++ .../src/PromotionService.API/Program.cs | 144 ++++++ .../PromotionService.API.csproj | 43 ++ .../Properties/launchSettings.json | 15 + .../appsettings.Development.json | 19 + .../src/PromotionService.API/appsettings.json | 46 ++ .../CampaignAggregate/AcquisitionType.cs | 31 ++ .../CampaignAggregate/AssetType.cs | 25 + .../CampaignAggregate/Campaign.cs | 434 ++++++++++++++++++ .../CampaignAggregate/CampaignStatus.cs | 43 ++ .../CampaignAggregate/ICampaignRepository.cs | 52 +++ .../CampaignAggregate/Voucher.cs | 199 ++++++++ .../CampaignAggregate/VoucherStatus.cs | 43 ++ .../IRedemptionRepository.cs | 34 ++ .../RedemptionAggregate/Redemption.cs | 92 ++++ .../SampleAggregate/ISampleRepository.cs | 61 +++ .../AggregatesModel/SampleAggregate/Sample.cs | 158 +++++++ .../SampleAggregate/SampleStatus.cs | 77 ++++ .../Events/PromotionDomainEvents.cs | 50 ++ .../Events/SampleCreatedDomainEvent.cs | 22 + .../Events/SampleStatusChangedDomainEvent.cs | 39 ++ .../Exceptions/DomainException.cs | 21 + .../Exceptions/PromotionExceptions.cs | 80 ++++ .../Exceptions/SampleDomainException.cs | 21 + .../PromotionService.Domain.csproj | 14 + .../SeedWork/Entity.cs | 102 ++++ .../SeedWork/Enumeration.cs | 95 ++++ .../SeedWork/IAggregateRoot.cs | 15 + .../SeedWork/IRepository.cs | 15 + .../SeedWork/IUnitOfWork.cs | 30 ++ .../SeedWork/ValueObject.cs | 53 +++ .../DependencyInjection.cs | 57 +++ .../SampleEntityTypeConfiguration.cs | 61 +++ .../SampleStatusEntityTypeConfiguration.cs | 39 ++ .../Idempotency/ClientRequest.cs | 26 ++ .../Idempotency/IRequestManager.cs | 24 + .../Idempotency/RequestManager.cs | 45 ++ .../MyServiceContext.cs | 160 +++++++ .../PromotionService.Infrastructure.csproj | 36 ++ .../Repositories/SampleRepository.cs | 72 +++ .../Controllers/SamplesControllerTests.cs | 80 ++++ .../CustomWebApplicationFactory.cs | 56 +++ .../PromotionService.FunctionalTests.csproj | 38 ++ .../CreateSampleCommandHandlerTests.cs | 65 +++ .../Domain/SampleAggregateTests.cs | 151 ++++++ .../PromotionService.UnitTests.csproj | 35 ++ 69 files changed, 4096 insertions(+) create mode 100644 services/promotion-service-net/Directory.Build.props create mode 100644 services/promotion-service-net/Dockerfile create mode 100644 services/promotion-service-net/PromotionService.slnx create mode 100644 services/promotion-service-net/docker-compose.yml create mode 100644 services/promotion-service-net/global.json create mode 100644 services/promotion-service-net/src/PromotionService.API/Application/Behaviors/LoggingBehavior.cs create mode 100644 services/promotion-service-net/src/PromotionService.API/Application/Behaviors/TransactionBehavior.cs create mode 100644 services/promotion-service-net/src/PromotionService.API/Application/Behaviors/ValidatorBehavior.cs create mode 100644 services/promotion-service-net/src/PromotionService.API/Application/Commands/ChangeSampleStatusCommand.cs create mode 100644 services/promotion-service-net/src/PromotionService.API/Application/Commands/ChangeSampleStatusCommandHandler.cs create mode 100644 services/promotion-service-net/src/PromotionService.API/Application/Commands/CreateSampleCommand.cs create mode 100644 services/promotion-service-net/src/PromotionService.API/Application/Commands/CreateSampleCommandHandler.cs create mode 100644 services/promotion-service-net/src/PromotionService.API/Application/Commands/DeleteSampleCommand.cs create mode 100644 services/promotion-service-net/src/PromotionService.API/Application/Commands/DeleteSampleCommandHandler.cs create mode 100644 services/promotion-service-net/src/PromotionService.API/Application/Commands/UpdateSampleCommand.cs create mode 100644 services/promotion-service-net/src/PromotionService.API/Application/Commands/UpdateSampleCommandHandler.cs create mode 100644 services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSampleQuery.cs create mode 100644 services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSampleQueryHandler.cs create mode 100644 services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSamplesQuery.cs create mode 100644 services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSamplesQueryHandler.cs create mode 100644 services/promotion-service-net/src/PromotionService.API/Application/Validations/CreateSampleCommandValidator.cs create mode 100644 services/promotion-service-net/src/PromotionService.API/Application/Validations/UpdateSampleCommandValidator.cs create mode 100644 services/promotion-service-net/src/PromotionService.API/Controllers/SamplesController.cs create mode 100644 services/promotion-service-net/src/PromotionService.API/Program.cs create mode 100644 services/promotion-service-net/src/PromotionService.API/PromotionService.API.csproj create mode 100644 services/promotion-service-net/src/PromotionService.API/Properties/launchSettings.json create mode 100644 services/promotion-service-net/src/PromotionService.API/appsettings.Development.json create mode 100644 services/promotion-service-net/src/PromotionService.API/appsettings.json create mode 100644 services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/AcquisitionType.cs create mode 100644 services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/AssetType.cs create mode 100644 services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/Campaign.cs create mode 100644 services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/CampaignStatus.cs create mode 100644 services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/ICampaignRepository.cs create mode 100644 services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/Voucher.cs create mode 100644 services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/VoucherStatus.cs create mode 100644 services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/RedemptionAggregate/IRedemptionRepository.cs create mode 100644 services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/RedemptionAggregate/Redemption.cs create mode 100644 services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/ISampleRepository.cs create mode 100644 services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/Sample.cs create mode 100644 services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/SampleStatus.cs create mode 100644 services/promotion-service-net/src/PromotionService.Domain/Events/PromotionDomainEvents.cs create mode 100644 services/promotion-service-net/src/PromotionService.Domain/Events/SampleCreatedDomainEvent.cs create mode 100644 services/promotion-service-net/src/PromotionService.Domain/Events/SampleStatusChangedDomainEvent.cs create mode 100644 services/promotion-service-net/src/PromotionService.Domain/Exceptions/DomainException.cs create mode 100644 services/promotion-service-net/src/PromotionService.Domain/Exceptions/PromotionExceptions.cs create mode 100644 services/promotion-service-net/src/PromotionService.Domain/Exceptions/SampleDomainException.cs create mode 100644 services/promotion-service-net/src/PromotionService.Domain/PromotionService.Domain.csproj create mode 100644 services/promotion-service-net/src/PromotionService.Domain/SeedWork/Entity.cs create mode 100644 services/promotion-service-net/src/PromotionService.Domain/SeedWork/Enumeration.cs create mode 100644 services/promotion-service-net/src/PromotionService.Domain/SeedWork/IAggregateRoot.cs create mode 100644 services/promotion-service-net/src/PromotionService.Domain/SeedWork/IRepository.cs create mode 100644 services/promotion-service-net/src/PromotionService.Domain/SeedWork/IUnitOfWork.cs create mode 100644 services/promotion-service-net/src/PromotionService.Domain/SeedWork/ValueObject.cs create mode 100644 services/promotion-service-net/src/PromotionService.Infrastructure/DependencyInjection.cs create mode 100644 services/promotion-service-net/src/PromotionService.Infrastructure/EntityConfigurations/SampleEntityTypeConfiguration.cs create mode 100644 services/promotion-service-net/src/PromotionService.Infrastructure/EntityConfigurations/SampleStatusEntityTypeConfiguration.cs create mode 100644 services/promotion-service-net/src/PromotionService.Infrastructure/Idempotency/ClientRequest.cs create mode 100644 services/promotion-service-net/src/PromotionService.Infrastructure/Idempotency/IRequestManager.cs create mode 100644 services/promotion-service-net/src/PromotionService.Infrastructure/Idempotency/RequestManager.cs create mode 100644 services/promotion-service-net/src/PromotionService.Infrastructure/MyServiceContext.cs create mode 100644 services/promotion-service-net/src/PromotionService.Infrastructure/PromotionService.Infrastructure.csproj create mode 100644 services/promotion-service-net/src/PromotionService.Infrastructure/Repositories/SampleRepository.cs create mode 100644 services/promotion-service-net/tests/PromotionService.FunctionalTests/Controllers/SamplesControllerTests.cs create mode 100644 services/promotion-service-net/tests/PromotionService.FunctionalTests/CustomWebApplicationFactory.cs create mode 100644 services/promotion-service-net/tests/PromotionService.FunctionalTests/PromotionService.FunctionalTests.csproj create mode 100644 services/promotion-service-net/tests/PromotionService.UnitTests/Application/CreateSampleCommandHandlerTests.cs create mode 100644 services/promotion-service-net/tests/PromotionService.UnitTests/Domain/SampleAggregateTests.cs create mode 100644 services/promotion-service-net/tests/PromotionService.UnitTests/PromotionService.UnitTests.csproj diff --git a/deployments/local/docker-compose.yml b/deployments/local/docker-compose.yml index 938fbb8a..b76e755d 100644 --- a/deployments/local/docker-compose.yml +++ b/deployments/local/docker-compose.yml @@ -529,6 +529,57 @@ services: - "traefik.http.services.mission-service.loadbalancer.healthcheck.path=/health/live" - "traefik.http.services.mission-service.loadbalancer.healthcheck.interval=10s" + # Promotion Service .NET - Voucher, Gift Card, Campaign Management + promotion-service-net: + build: + context: ../../services/promotion-service-net + dockerfile: Dockerfile + image: goodgo/promotion-service-net:latest + container_name: promotion-service-net-local + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_URLS=http://+:8080 + # EN: Database - Neon PostgreSQL + # VI: Cơ sở dữ liệu - Neon PostgreSQL + - ConnectionStrings__DefaultConnection=Host=ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech;Port=5432;Database=promotion_service;Username=neondb_owner;Password=npg_Ssfy6HKO0cXI;SSL Mode=Require + # EN: IAM Service Communication + # VI: Giao tiếp IAM Service + - IamService__BaseUrl=http://iam-service-net:8080 + - IamService__ServiceName=promotion-service + # EN: Wallet Service Communication + # VI: Giao tiếp Wallet Service + - WalletService__BaseUrl=http://wallet-service-net:8080 + # EN: JWT Configuration + # VI: Cấu hình JWT + - Jwt__Authority=http://iam-service-net:8080 + - Jwt__Audience=goodgo-api + - Jwt__RequireHttpsMetadata=false + ports: + - "5008:8080" + depends_on: + iam-service-net: + condition: service_healthy + wallet-service-net: + condition: service_healthy + traefik: + condition: service_started + networks: + - microservices-network + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + labels: + - "traefik.enable=true" + - "traefik.http.routers.promotion-service.rule=PathPrefix(`/api/v1/campaigns`) || PathPrefix(`/api/v1/vouchers`) || PathPrefix(`/api/v1/admin/campaigns`) || PathPrefix(`/api/v1/admin/vouchers`)" + - "traefik.http.routers.promotion-service.entrypoints=web" + - "traefik.http.services.promotion-service.loadbalancer.server.port=8080" + - "traefik.http.services.promotion-service.loadbalancer.healthcheck.path=/health/live" + - "traefik.http.services.promotion-service.loadbalancer.healthcheck.interval=10s" + # Jaeger - Distributed Tracing # jaeger: diff --git a/services/promotion-service-net/Directory.Build.props b/services/promotion-service-net/Directory.Build.props new file mode 100644 index 00000000..c3b74373 --- /dev/null +++ b/services/promotion-service-net/Directory.Build.props @@ -0,0 +1,22 @@ + + + net10.0 + 14.0 + enable + enable + true + true + $(NoWarn);1591;CA2017 + + + + GoodGo Team + GoodGo + © 2026 GoodGo. All rights reserved. + git + + + + + + diff --git a/services/promotion-service-net/Dockerfile b/services/promotion-service-net/Dockerfile new file mode 100644 index 00000000..0bf55314 --- /dev/null +++ b/services/promotion-service-net/Dockerfile @@ -0,0 +1,66 @@ +# Build stage / Giai đoạn build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# EN: Copy project files for layer caching +# VI: Sao chép các file project để tận dụng layer caching +COPY ["src/PromotionService.API/PromotionService.API.csproj", "src/PromotionService.API/"] +COPY ["src/PromotionService.Domain/PromotionService.Domain.csproj", "src/PromotionService.Domain/"] +COPY ["src/PromotionService.Infrastructure/PromotionService.Infrastructure.csproj", "src/PromotionService.Infrastructure/"] +COPY ["Directory.Build.props", "./"] + +# EN: Restore dependencies +# VI: Khôi phục dependencies +RUN dotnet restore "src/PromotionService.API/PromotionService.API.csproj" + +# EN: Copy all source code +# VI: Sao chép toàn bộ source code +COPY src/ ./src/ + +# EN: Build the application +# VI: Build ứng dụng +WORKDIR "/src/src/PromotionService.API" +RUN dotnet build "PromotionService.API.csproj" -c Release -o /app/build --no-restore + +# Publish stage / Giai đoạn publish +FROM build AS publish +RUN dotnet publish "PromotionService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore + +# Runtime stage / Giai đoạn runtime +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app + +# EN: Create non-root user for security +# VI: Tạo user non-root cho bảo mật +RUN groupadd -g 1001 dotnetuser && \ + useradd -u 1001 -g dotnetuser -s /bin/sh dotnetuser + +# EN: Copy published application +# VI: Sao chép ứng dụng đã publish +COPY --from=publish /app/publish . + +# EN: Change ownership to non-root user +# VI: Thay đổi quyền sở hữu sang user non-root +RUN chown -R dotnetuser:dotnetuser /app + +# EN: Switch to non-root user +# VI: Chuyển sang user non-root +USER dotnetuser + +# EN: Expose port +# VI: Mở cổng +EXPOSE 8080 + +# EN: Set environment variables +# VI: Thiết lập biến môi trường +ENV ASPNETCORE_URLS=http://+:8080 +ENV ASPNETCORE_ENVIRONMENT=Production + +# EN: Health check +# VI: Kiểm tra health +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:8080/health/live || exit 1 + +# EN: Start the application +# VI: Khởi động ứng dụng +ENTRYPOINT ["dotnet", "PromotionService.API.dll"] diff --git a/services/promotion-service-net/PromotionService.slnx b/services/promotion-service-net/PromotionService.slnx new file mode 100644 index 00000000..caedc0bf --- /dev/null +++ b/services/promotion-service-net/PromotionService.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/services/promotion-service-net/docker-compose.yml b/services/promotion-service-net/docker-compose.yml new file mode 100644 index 00000000..254ceb12 --- /dev/null +++ b/services/promotion-service-net/docker-compose.yml @@ -0,0 +1,72 @@ +version: '3.8' + +# EN: Docker Compose for local development +# VI: Docker Compose cho phát triển local + +services: + myservice-api: + build: + context: . + dockerfile: Dockerfile + container_name: myservice-api + ports: + - "5000:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - DATABASE_URL=Host=postgres;Port=5432;Database=myservice_db;Username=postgres;Password=postgres + - REDIS_URL=redis:6379 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - myservice-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + postgres: + image: postgres:16-alpine + container_name: myservice-postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: myservice_db + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - myservice-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: myservice-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - myservice-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres_data: + redis_data: + +networks: + myservice-network: + driver: bridge diff --git a/services/promotion-service-net/global.json b/services/promotion-service-net/global.json new file mode 100644 index 00000000..f78eeaf4 --- /dev/null +++ b/services/promotion-service-net/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.101", + "rollForward": "latestMinor", + "allowPrerelease": false + } +} \ No newline at end of file diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Behaviors/LoggingBehavior.cs b/services/promotion-service-net/src/PromotionService.API/Application/Behaviors/LoggingBehavior.cs new file mode 100644 index 00000000..c3798178 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.API/Application/Behaviors/LoggingBehavior.cs @@ -0,0 +1,58 @@ +using System.Diagnostics; +using MediatR; + +namespace PromotionService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for logging request handling. +/// VI: MediatR behavior để logging việc xử lý request. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class LoggingBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly ILogger> _logger; + + public LoggingBehavior(ILogger> logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + _logger.LogInformation( + "Handling {RequestName} / Đang xử lý {RequestName}", + requestName); + + var stopwatch = Stopwatch.StartNew(); + + try + { + var response = await next(); + + stopwatch.Stop(); + + _logger.LogInformation( + "Handled {RequestName} in {ElapsedMs}ms / Đã xử lý {RequestName} trong {ElapsedMs}ms", + requestName, stopwatch.ElapsedMilliseconds); + + return response; + } + catch (Exception ex) + { + stopwatch.Stop(); + + _logger.LogError(ex, + "Error handling {RequestName} after {ElapsedMs}ms / Lỗi xử lý {RequestName} sau {ElapsedMs}ms", + requestName, stopwatch.ElapsedMilliseconds); + + throw; + } + } +} diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Behaviors/TransactionBehavior.cs b/services/promotion-service-net/src/PromotionService.API/Application/Behaviors/TransactionBehavior.cs new file mode 100644 index 00000000..51720ee7 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.API/Application/Behaviors/TransactionBehavior.cs @@ -0,0 +1,84 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using PromotionService.Infrastructure; + +namespace PromotionService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for handling database transactions. +/// VI: MediatR behavior để xử lý database transactions. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class TransactionBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly PromotionServiceContext _dbContext; + private readonly ILogger> _logger; + + public TransactionBehavior( + PromotionServiceContext dbContext, + ILogger> logger) + { + _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + // EN: Skip transaction for queries (read operations) + // VI: Bỏ qua transaction cho queries (các thao tác đọc) + if (requestName.EndsWith("Query")) + { + return await next(); + } + + // EN: Skip if already in a transaction + // VI: Bỏ qua nếu đã trong transaction + if (_dbContext.HasActiveTransaction) + { + return await next(); + } + + var strategy = _dbContext.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await _dbContext.BeginTransactionAsync(); + + _logger.LogInformation( + "Begin transaction {TransactionId} for {RequestName} / Bắt đầu transaction {TransactionId} cho {RequestName}", + transaction?.TransactionId, requestName); + + try + { + var response = await next(); + + if (transaction != null) + { + await _dbContext.CommitTransactionAsync(transaction); + + _logger.LogInformation( + "Committed transaction {TransactionId} for {RequestName} / Đã commit transaction {TransactionId} cho {RequestName}", + transaction.TransactionId, requestName); + } + + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error during transaction {TransactionId} for {RequestName} / Lỗi trong transaction {TransactionId} cho {RequestName}", + transaction?.TransactionId, requestName); + + _dbContext.RollbackTransaction(); + throw; + } + }); + } +} diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Behaviors/ValidatorBehavior.cs b/services/promotion-service-net/src/PromotionService.API/Application/Behaviors/ValidatorBehavior.cs new file mode 100644 index 00000000..e31a4554 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.API/Application/Behaviors/ValidatorBehavior.cs @@ -0,0 +1,63 @@ +using FluentValidation; +using MediatR; + +namespace PromotionService.API.Application.Behaviors; + +/// +/// EN: MediatR behavior for FluentValidation integration. +/// VI: MediatR behavior để tích hợp FluentValidation. +/// +/// EN: Request type / VI: Loại request +/// EN: Response type / VI: Loại response +public class ValidatorBehavior : IPipelineBehavior + where TRequest : IRequest +{ + private readonly IEnumerable> _validators; + private readonly ILogger> _logger; + + public ValidatorBehavior( + IEnumerable> validators, + ILogger> logger) + { + _validators = validators ?? throw new ArgumentNullException(nameof(validators)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var requestName = typeof(TRequest).Name; + + if (!_validators.Any()) + { + return await next(); + } + + _logger.LogDebug( + "Validating {RequestName} / Đang validate {RequestName}", + requestName); + + var context = new ValidationContext(request); + + var validationResults = await Task.WhenAll( + _validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + var failures = validationResults + .SelectMany(r => r.Errors) + .Where(f => f != null) + .ToList(); + + if (failures.Count != 0) + { + _logger.LogWarning( + "Validation failed for {RequestName} with {ErrorCount} errors / Validation thất bại cho {RequestName} với {ErrorCount} lỗi", + requestName, failures.Count); + + throw new ValidationException(failures); + } + + return await next(); + } +} diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Commands/ChangeSampleStatusCommand.cs b/services/promotion-service-net/src/PromotionService.API/Application/Commands/ChangeSampleStatusCommand.cs new file mode 100644 index 00000000..3058d610 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.API/Application/Commands/ChangeSampleStatusCommand.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace PromotionService.API.Application.Commands; + +/// +/// EN: Command to change status of a Sample. +/// VI: Command để thay đổi trạng thái của Sample. +/// +/// EN: Sample ID / VI: ID sample +/// EN: New status (activate, complete, cancel) / VI: Trạng thái mới (activate, complete, cancel) +public record ChangeSampleStatusCommand( + Guid SampleId, + string NewStatus +) : IRequest; diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Commands/ChangeSampleStatusCommandHandler.cs b/services/promotion-service-net/src/PromotionService.API/Application/Commands/ChangeSampleStatusCommandHandler.cs new file mode 100644 index 00000000..a3b62f3c --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.API/Application/Commands/ChangeSampleStatusCommandHandler.cs @@ -0,0 +1,70 @@ +using MediatR; +using PromotionService.Domain.AggregatesModel.SampleAggregate; + +namespace PromotionService.API.Application.Commands; + +/// +/// EN: Handler for ChangeSampleStatusCommand. +/// VI: Handler cho ChangeSampleStatusCommand. +/// +public class ChangeSampleStatusCommandHandler : IRequestHandler +{ + private readonly ISampleRepository _sampleRepository; + private readonly ILogger _logger; + + public ChangeSampleStatusCommandHandler( + ISampleRepository sampleRepository, + ILogger logger) + { + _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + ChangeSampleStatusCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Changing status of sample {SampleId} to {NewStatus} / Thay đổi trạng thái sample {SampleId} thành {NewStatus}", + request.SampleId, request.NewStatus); + + // EN: Get existing sample / VI: Lấy sample đã tồn tại + var sample = await _sampleRepository.GetAsync(request.SampleId); + + if (sample is null) + { + _logger.LogWarning( + "Sample {SampleId} not found / Sample {SampleId} không tìm thấy", + request.SampleId); + return false; + } + + // EN: Change status based on action / VI: Thay đổi trạng thái dựa trên action + switch (request.NewStatus.ToLowerInvariant()) + { + case "activate": + sample.Activate(); + break; + case "complete": + sample.Complete(); + break; + case "cancel": + sample.Cancel(); + break; + default: + _logger.LogWarning( + "Invalid status action: {NewStatus} / Action trạng thái không hợp lệ: {NewStatus}", + request.NewStatus); + return false; + } + + // EN: Save changes / VI: Lưu thay đổi + await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Sample {SampleId} status changed to {NewStatus} / Trạng thái sample {SampleId} đã đổi thành {NewStatus}", + request.SampleId, request.NewStatus); + + return true; + } +} diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Commands/CreateSampleCommand.cs b/services/promotion-service-net/src/PromotionService.API/Application/Commands/CreateSampleCommand.cs new file mode 100644 index 00000000..eb5f9405 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.API/Application/Commands/CreateSampleCommand.cs @@ -0,0 +1,21 @@ +using MediatR; + +namespace PromotionService.API.Application.Commands; + +/// +/// EN: Command to create a new Sample. +/// VI: Command để tạo một Sample mới. +/// +/// EN: Sample name / VI: Tên sample +/// EN: Optional description / VI: Mô tả tùy chọn +public record CreateSampleCommand( + string Name, + string? Description +) : IRequest; + +/// +/// EN: Result of CreateSampleCommand. +/// VI: Kết quả của CreateSampleCommand. +/// +/// EN: Created sample ID / VI: ID sample đã tạo +public record CreateSampleCommandResult(Guid Id); diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Commands/CreateSampleCommandHandler.cs b/services/promotion-service-net/src/PromotionService.API/Application/Commands/CreateSampleCommandHandler.cs new file mode 100644 index 00000000..32877a24 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.API/Application/Commands/CreateSampleCommandHandler.cs @@ -0,0 +1,46 @@ +using MediatR; +using PromotionService.Domain.AggregatesModel.SampleAggregate; + +namespace PromotionService.API.Application.Commands; + +/// +/// EN: Handler for CreateSampleCommand. +/// VI: Handler cho CreateSampleCommand. +/// +public class CreateSampleCommandHandler : IRequestHandler +{ + private readonly ISampleRepository _sampleRepository; + private readonly ILogger _logger; + + public CreateSampleCommandHandler( + ISampleRepository sampleRepository, + ILogger logger) + { + _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + CreateSampleCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Creating new sample with name: {Name} / Tạo sample mới với tên: {Name}", + request.Name); + + // EN: Create domain entity / VI: Tạo domain entity + var sample = new Sample(request.Name, request.Description); + + // EN: Add to repository / VI: Thêm vào repository + _sampleRepository.Add(sample); + + // EN: Save changes (dispatches domain events) / VI: Lưu thay đổi (dispatch domain events) + await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Sample created successfully with ID: {SampleId} / Sample đã tạo thành công với ID: {SampleId}", + sample.Id); + + return new CreateSampleCommandResult(sample.Id); + } +} diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Commands/DeleteSampleCommand.cs b/services/promotion-service-net/src/PromotionService.API/Application/Commands/DeleteSampleCommand.cs new file mode 100644 index 00000000..687b6323 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.API/Application/Commands/DeleteSampleCommand.cs @@ -0,0 +1,10 @@ +using MediatR; + +namespace PromotionService.API.Application.Commands; + +/// +/// EN: Command to delete a Sample. +/// VI: Command để xóa một Sample. +/// +/// EN: Sample ID to delete / VI: ID sample cần xóa +public record DeleteSampleCommand(Guid SampleId) : IRequest; diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Commands/DeleteSampleCommandHandler.cs b/services/promotion-service-net/src/PromotionService.API/Application/Commands/DeleteSampleCommandHandler.cs new file mode 100644 index 00000000..8688808e --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.API/Application/Commands/DeleteSampleCommandHandler.cs @@ -0,0 +1,54 @@ +using MediatR; +using PromotionService.Domain.AggregatesModel.SampleAggregate; + +namespace PromotionService.API.Application.Commands; + +/// +/// EN: Handler for DeleteSampleCommand. +/// VI: Handler cho DeleteSampleCommand. +/// +public class DeleteSampleCommandHandler : IRequestHandler +{ + private readonly ISampleRepository _sampleRepository; + private readonly ILogger _logger; + + public DeleteSampleCommandHandler( + ISampleRepository sampleRepository, + ILogger logger) + { + _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + DeleteSampleCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Deleting sample {SampleId} / Xóa sample {SampleId}", + request.SampleId); + + // EN: Get existing sample / VI: Lấy sample đã tồn tại + var sample = await _sampleRepository.GetAsync(request.SampleId); + + if (sample is null) + { + _logger.LogWarning( + "Sample {SampleId} not found / Sample {SampleId} không tìm thấy", + request.SampleId); + return false; + } + + // EN: Delete sample / VI: Xóa sample + _sampleRepository.Delete(sample); + + // EN: Save changes / VI: Lưu thay đổi + await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Sample {SampleId} deleted successfully / Sample {SampleId} đã xóa thành công", + request.SampleId); + + return true; + } +} diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Commands/UpdateSampleCommand.cs b/services/promotion-service-net/src/PromotionService.API/Application/Commands/UpdateSampleCommand.cs new file mode 100644 index 00000000..c9125e91 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.API/Application/Commands/UpdateSampleCommand.cs @@ -0,0 +1,16 @@ +using MediatR; + +namespace PromotionService.API.Application.Commands; + +/// +/// EN: Command to update an existing Sample. +/// VI: Command để cập nhật một Sample đã tồn tại. +/// +/// EN: Sample ID to update / VI: ID sample cần cập nhật +/// EN: New name / VI: Tên mới +/// EN: New description / VI: Mô tả mới +public record UpdateSampleCommand( + Guid SampleId, + string Name, + string? Description +) : IRequest; diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Commands/UpdateSampleCommandHandler.cs b/services/promotion-service-net/src/PromotionService.API/Application/Commands/UpdateSampleCommandHandler.cs new file mode 100644 index 00000000..5dd0e84b --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.API/Application/Commands/UpdateSampleCommandHandler.cs @@ -0,0 +1,54 @@ +using MediatR; +using PromotionService.Domain.AggregatesModel.SampleAggregate; + +namespace PromotionService.API.Application.Commands; + +/// +/// EN: Handler for UpdateSampleCommand. +/// VI: Handler cho UpdateSampleCommand. +/// +public class UpdateSampleCommandHandler : IRequestHandler +{ + private readonly ISampleRepository _sampleRepository; + private readonly ILogger _logger; + + public UpdateSampleCommandHandler( + ISampleRepository sampleRepository, + ILogger logger) + { + _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle( + UpdateSampleCommand request, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Updating sample {SampleId} / Cập nhật sample {SampleId}", + request.SampleId); + + // EN: Get existing sample / VI: Lấy sample đã tồn tại + var sample = await _sampleRepository.GetAsync(request.SampleId); + + if (sample is null) + { + _logger.LogWarning( + "Sample {SampleId} not found / Sample {SampleId} không tìm thấy", + request.SampleId); + return false; + } + + // EN: Update sample using domain method / VI: Cập nhật sample sử dụng domain method + sample.Update(request.Name, request.Description); + + // EN: Save changes / VI: Lưu thay đổi + await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Sample {SampleId} updated successfully / Sample {SampleId} đã cập nhật thành công", + request.SampleId); + + return true; + } +} diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSampleQuery.cs b/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSampleQuery.cs new file mode 100644 index 00000000..fcf57b5d --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSampleQuery.cs @@ -0,0 +1,23 @@ +using MediatR; + +namespace PromotionService.API.Application.Queries; + +/// +/// EN: Query to get a Sample by ID. +/// VI: Query để lấy một Sample theo ID. +/// +/// EN: Sample ID / VI: ID sample +public record GetSampleQuery(Guid SampleId) : IRequest; + +/// +/// EN: Sample view model for API responses. +/// VI: Sample view model cho API responses. +/// +public record SampleViewModel( + Guid Id, + string Name, + string? Description, + string Status, + DateTime CreatedAt, + DateTime? UpdatedAt +); diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSampleQueryHandler.cs b/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSampleQueryHandler.cs new file mode 100644 index 00000000..a5d87a7f --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSampleQueryHandler.cs @@ -0,0 +1,39 @@ +using MediatR; +using PromotionService.Domain.AggregatesModel.SampleAggregate; + +namespace PromotionService.API.Application.Queries; + +/// +/// EN: Handler for GetSampleQuery. +/// VI: Handler cho GetSampleQuery. +/// +public class GetSampleQueryHandler : IRequestHandler +{ + private readonly ISampleRepository _sampleRepository; + + public GetSampleQueryHandler(ISampleRepository sampleRepository) + { + _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository)); + } + + public async Task Handle( + GetSampleQuery request, + CancellationToken cancellationToken) + { + var sample = await _sampleRepository.GetAsync(request.SampleId); + + if (sample is null) + { + return null; + } + + return new SampleViewModel( + sample.Id, + sample.Name, + sample.Description, + sample.Status.Name, + sample.CreatedAt, + sample.UpdatedAt + ); + } +} diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSamplesQuery.cs b/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSamplesQuery.cs new file mode 100644 index 00000000..63988838 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSamplesQuery.cs @@ -0,0 +1,9 @@ +using MediatR; + +namespace PromotionService.API.Application.Queries; + +/// +/// EN: Query to get all Samples. +/// VI: Query để lấy tất cả Samples. +/// +public record GetSamplesQuery : IRequest>; diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSamplesQueryHandler.cs b/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSamplesQueryHandler.cs new file mode 100644 index 00000000..059f9176 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.API/Application/Queries/GetSamplesQueryHandler.cs @@ -0,0 +1,34 @@ +using MediatR; +using PromotionService.Domain.AggregatesModel.SampleAggregate; + +namespace PromotionService.API.Application.Queries; + +/// +/// EN: Handler for GetSamplesQuery. +/// VI: Handler cho GetSamplesQuery. +/// +public class GetSamplesQueryHandler : IRequestHandler> +{ + private readonly ISampleRepository _sampleRepository; + + public GetSamplesQueryHandler(ISampleRepository sampleRepository) + { + _sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository)); + } + + public async Task> Handle( + GetSamplesQuery request, + CancellationToken cancellationToken) + { + var samples = await _sampleRepository.GetAllAsync(); + + return samples.Select(sample => new SampleViewModel( + sample.Id, + sample.Name, + sample.Description, + sample.Status.Name, + sample.CreatedAt, + sample.UpdatedAt + )); + } +} diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Validations/CreateSampleCommandValidator.cs b/services/promotion-service-net/src/PromotionService.API/Application/Validations/CreateSampleCommandValidator.cs new file mode 100644 index 00000000..43259527 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.API/Application/Validations/CreateSampleCommandValidator.cs @@ -0,0 +1,25 @@ +using FluentValidation; +using PromotionService.API.Application.Commands; + +namespace PromotionService.API.Application.Validations; + +/// +/// EN: Validator for CreateSampleCommand. +/// VI: Validator cho CreateSampleCommand. +/// +public class CreateSampleCommandValidator : AbstractValidator +{ + public CreateSampleCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Name is required / Tên là bắt buộc") + .MaximumLength(200) + .WithMessage("Name must be less than 200 characters / Tên phải ít hơn 200 ký tự"); + + RuleFor(x => x.Description) + .MaximumLength(1000) + .WithMessage("Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự") + .When(x => x.Description != null); + } +} diff --git a/services/promotion-service-net/src/PromotionService.API/Application/Validations/UpdateSampleCommandValidator.cs b/services/promotion-service-net/src/PromotionService.API/Application/Validations/UpdateSampleCommandValidator.cs new file mode 100644 index 00000000..ed428869 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.API/Application/Validations/UpdateSampleCommandValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using PromotionService.API.Application.Commands; + +namespace PromotionService.API.Application.Validations; + +/// +/// EN: Validator for UpdateSampleCommand. +/// VI: Validator cho UpdateSampleCommand. +/// +public class UpdateSampleCommandValidator : AbstractValidator +{ + public UpdateSampleCommandValidator() + { + RuleFor(x => x.SampleId) + .NotEmpty() + .WithMessage("Sample ID is required / ID sample là bắt buộc"); + + RuleFor(x => x.Name) + .NotEmpty() + .WithMessage("Name is required / Tên là bắt buộc") + .MaximumLength(200) + .WithMessage("Name must be less than 200 characters / Tên phải ít hơn 200 ký tự"); + + RuleFor(x => x.Description) + .MaximumLength(1000) + .WithMessage("Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự") + .When(x => x.Description != null); + } +} diff --git a/services/promotion-service-net/src/PromotionService.API/Controllers/SamplesController.cs b/services/promotion-service-net/src/PromotionService.API/Controllers/SamplesController.cs new file mode 100644 index 00000000..8a7150c7 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.API/Controllers/SamplesController.cs @@ -0,0 +1,200 @@ +using Asp.Versioning; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using PromotionService.API.Application.Commands; +using PromotionService.API.Application.Queries; + +namespace PromotionService.API.Controllers; + +/// +/// EN: Controller for Sample CRUD operations using CQRS pattern. +/// VI: Controller cho các thao tác CRUD Sample sử dụng pattern CQRS. +/// +[ApiController] +[ApiVersion("1.0")] +[Route("api/v{version:apiVersion}/[controller]")] +[Produces("application/json")] +public class SamplesController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public SamplesController(IMediator mediator, ILogger logger) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// EN: Get all samples. + /// VI: Lấy tất cả samples. + /// + /// EN: List of samples / VI: Danh sách samples + [HttpGet] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task GetSamples() + { + var samples = await _mediator.Send(new GetSamplesQuery()); + return Ok(new { success = true, data = samples }); + } + + /// + /// EN: Get a sample by ID. + /// VI: Lấy một sample theo ID. + /// + /// EN: Sample ID / VI: ID sample + /// EN: Sample details / VI: Chi tiết sample + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(SampleViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetSample(Guid id) + { + var sample = await _mediator.Send(new GetSampleQuery(id)); + + if (sample is null) + { + return NotFound(new + { + success = false, + error = new + { + code = "SAMPLE_NOT_FOUND", + message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy" + } + }); + } + + return Ok(new { success = true, data = sample }); + } + + /// + /// EN: Create a new sample. + /// VI: Tạo một sample mới. + /// + /// EN: Create request / VI: Request tạo + /// EN: Created sample ID / VI: ID sample đã tạo + [HttpPost] + [ProducesResponseType(typeof(CreateSampleCommandResult), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task CreateSample([FromBody] CreateSampleRequest request) + { + var command = new CreateSampleCommand(request.Name, request.Description); + var result = await _mediator.Send(command); + + return CreatedAtAction( + nameof(GetSample), + new { id = result.Id }, + new { success = true, data = result }); + } + + /// + /// EN: Update an existing sample. + /// VI: Cập nhật một sample đã tồn tại. + /// + /// EN: Sample ID / VI: ID sample + /// EN: Update request / VI: Request cập nhật + /// EN: Success status / VI: Trạng thái thành công + [HttpPut("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdateSample(Guid id, [FromBody] UpdateSampleRequest request) + { + var command = new UpdateSampleCommand(id, request.Name, request.Description); + var result = await _mediator.Send(command); + + if (!result) + { + return NotFound(new + { + success = false, + error = new + { + code = "SAMPLE_NOT_FOUND", + message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy" + } + }); + } + + return Ok(new { success = true, message = "Sample updated successfully / Sample đã cập nhật thành công" }); + } + + /// + /// EN: Delete a sample. + /// VI: Xóa một sample. + /// + /// EN: Sample ID / VI: ID sample + /// EN: Success status / VI: Trạng thái thành công + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteSample(Guid id) + { + var command = new DeleteSampleCommand(id); + var result = await _mediator.Send(command); + + if (!result) + { + return NotFound(new + { + success = false, + error = new + { + code = "SAMPLE_NOT_FOUND", + message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy" + } + }); + } + + return NoContent(); + } + + /// + /// EN: Change sample status. + /// VI: Thay đổi trạng thái sample. + /// + /// EN: Sample ID / VI: ID sample + /// EN: Status change request / VI: Request thay đổi trạng thái + /// EN: Success status / VI: Trạng thái thành công + [HttpPatch("{id:guid}/status")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ChangeSampleStatus(Guid id, [FromBody] ChangeStatusRequest request) + { + var command = new ChangeSampleStatusCommand(id, request.Status); + var result = await _mediator.Send(command); + + if (!result) + { + return BadRequest(new + { + success = false, + error = new + { + code = "STATUS_CHANGE_FAILED", + message = "Failed to change sample status / Thay đổi trạng thái sample thất bại" + } + }); + } + + return Ok(new { success = true, message = "Sample status changed successfully / Trạng thái sample đã thay đổi thành công" }); + } +} + +/// +/// EN: Request model for creating a sample. +/// VI: Model request để tạo sample. +/// +public record CreateSampleRequest(string Name, string? Description); + +/// +/// EN: Request model for updating a sample. +/// VI: Model request để cập nhật sample. +/// +public record UpdateSampleRequest(string Name, string? Description); + +/// +/// EN: Request model for changing sample status. +/// VI: Model request để thay đổi trạng thái sample. +/// +public record ChangeStatusRequest(string Status); diff --git a/services/promotion-service-net/src/PromotionService.API/Program.cs b/services/promotion-service-net/src/PromotionService.API/Program.cs new file mode 100644 index 00000000..a9424716 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.API/Program.cs @@ -0,0 +1,144 @@ +using Asp.Versioning; +using FluentValidation; +using Hellang.Middleware.ProblemDetails; +using PromotionService.API.Application.Behaviors; +using PromotionService.Infrastructure; +using Serilog; + +// EN: Configure Serilog early / VI: Cấu hình Serilog sớm +Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .CreateBootstrapLogger(); + +try +{ + Log.Information("Starting PromotionService API / Khởi động PromotionService API"); + + var builder = WebApplication.CreateBuilder(args); + + // EN: Configure Serilog / VI: Cấu hình Serilog + builder.Host.UseSerilog((context, services, configuration) => configuration + .ReadFrom.Configuration(context.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext() + .WriteTo.Console()); + + // EN: Add Infrastructure services / VI: Thêm Infrastructure services + builder.Services.AddInfrastructure(builder.Configuration); + + // EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors + builder.Services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssemblyContaining(); + cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)); + cfg.AddOpenBehavior(typeof(ValidatorBehavior<,>)); + cfg.AddOpenBehavior(typeof(TransactionBehavior<,>)); + }); + + // EN: Add FluentValidation / VI: Thêm FluentValidation + builder.Services.AddValidatorsFromAssemblyContaining(); + + // EN: Add API versioning / VI: Thêm API versioning + builder.Services.AddApiVersioning(options => + { + options.DefaultApiVersion = new ApiVersion(1, 0); + options.AssumeDefaultVersionWhenUnspecified = true; + options.ReportApiVersions = true; + options.ApiVersionReader = ApiVersionReader.Combine( + new UrlSegmentApiVersionReader(), + new HeaderApiVersionReader("X-Api-Version")); + }) + .AddApiExplorer(options => + { + options.GroupNameFormat = "'v'VVV"; + options.SubstituteApiVersionInUrl = true; + }); + + // EN: Add controllers / VI: Thêm controllers + builder.Services.AddControllers(); + + // EN: Add ProblemDetails middleware (RFC 7807) / VI: Thêm ProblemDetails middleware + builder.Services.AddProblemDetails(options => + { + options.IncludeExceptionDetails = (ctx, ex) => + builder.Environment.IsDevelopment(); + }); + + // EN: Add Swagger / VI: Thêm Swagger + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(options => + { + options.SwaggerDoc("v1", new() + { + Title = "PromotionService API", + Version = "v1", + Description = "PromotionService microservice API / API microservice PromotionService" + }); + }); + + // EN: Add health checks / VI: Thêm health checks + builder.Services.AddHealthChecks() + .AddNpgSql( + builder.Configuration.GetConnectionString("DefaultConnection") + ?? builder.Configuration["DATABASE_URL"] + ?? "", + name: "postgresql", + tags: ["db", "postgresql"]); + + // EN: Add CORS / VI: Thêm CORS + builder.Services.AddCors(options => + { + options.AddDefaultPolicy(policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); + }); + + var app = builder.Build(); + + // EN: Configure middleware pipeline / VI: Cấu hình middleware pipeline + app.UseSerilogRequestLogging(); + app.UseProblemDetails(); + + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "PromotionService API v1"); + c.RoutePrefix = "swagger"; + }); + } + + app.UseCors(); + app.UseRouting(); + + // EN: Map health check endpoints / VI: Map health check endpoints + app.MapHealthChecks("/health"); + app.MapHealthChecks("/health/live", new() + { + Predicate = _ => false // EN: Just checks app is running / VI: Chỉ kiểm tra app đang chạy + }); + app.MapHealthChecks("/health/ready"); + + // EN: Map controllers / VI: Map controllers + app.MapControllers(); + + // EN: Run the application / VI: Chạy ứng dụng + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly / Ứng dụng kết thúc bất ngờ"); + throw; +} +finally +{ + Log.CloseAndFlush(); +} + +// EN: Make Program class accessible for integration tests +// VI: Làm cho class Program có thể truy cập cho integration tests +public partial class Program { } diff --git a/services/promotion-service-net/src/PromotionService.API/PromotionService.API.csproj b/services/promotion-service-net/src/PromotionService.API/PromotionService.API.csproj new file mode 100644 index 00000000..85409bb8 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.API/PromotionService.API.csproj @@ -0,0 +1,43 @@ + + + + PromotionService.API + PromotionService.API + Web API layer with CQRS pattern + myservice-api + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/promotion-service-net/src/PromotionService.API/Properties/launchSettings.json b/services/promotion-service-net/src/PromotionService.API/Properties/launchSettings.json new file mode 100644 index 00000000..6355d40b --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.API/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/services/promotion-service-net/src/PromotionService.API/appsettings.Development.json b/services/promotion-service-net/src/PromotionService.API/appsettings.Development.json new file mode 100644 index 00000000..e407ac85 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.API/appsettings.Development.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information", + "System": "Information" + } + } + } +} \ No newline at end of file diff --git a/services/promotion-service-net/src/PromotionService.API/appsettings.json b/services/promotion-service-net/src/PromotionService.API/appsettings.json new file mode 100644 index 00000000..523dc0fc --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.API/appsettings.json @@ -0,0 +1,46 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}" + } + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId" + ] + }, + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres" + }, + "Redis": { + "ConnectionString": "localhost:6379" + }, + "Jwt": { + "Secret": "your-super-secret-key-min-32-characters", + "Issuer": "goodgo-platform", + "Audience": "goodgo-services", + "AccessTokenExpiryMinutes": 15, + "RefreshTokenExpiryDays": 7 + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/AcquisitionType.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/AcquisitionType.cs new file mode 100644 index 00000000..d551bd50 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/AcquisitionType.cs @@ -0,0 +1,31 @@ +namespace PromotionService.Domain.AggregatesModel.CampaignAggregate; + +using PromotionService.Domain.SeedWork; + +/// +/// EN: How the user acquires the voucher. +/// VI: Cách người dùng nhận voucher. +/// +public class AcquisitionType : Enumeration +{ + /// + /// EN: Free voucher (giveaway). + /// VI: Voucher miễn phí (tặng). + /// + public static readonly AcquisitionType Free = new(1, nameof(Free)); + + /// + /// EN: Exchange with loyalty points. + /// VI: Đổi bằng điểm thưởng. + /// + public static readonly AcquisitionType ExchangePoints = new(2, nameof(ExchangePoints)); + + /// + /// EN: Purchase with currency. + /// VI: Mua bằng tiền tệ. + /// + public static readonly AcquisitionType Purchase = new(3, nameof(Purchase)); + + protected AcquisitionType() : base(0, "Unknown") { } + public AcquisitionType(int id, string name) : base(id, name) { } +} diff --git a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/AssetType.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/AssetType.cs new file mode 100644 index 00000000..5237a6ea --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/AssetType.cs @@ -0,0 +1,25 @@ +namespace PromotionService.Domain.AggregatesModel.CampaignAggregate; + +using PromotionService.Domain.SeedWork; + +/// +/// EN: Type of backing asset for the voucher. +/// VI: Loại tài sản đảm bảo cho voucher. +/// +public class AssetType : Enumeration +{ + /// + /// EN: Currency (VND, USD, etc.) + /// VI: Tiền tệ (VND, USD, v.v.) + /// + public static readonly AssetType Currency = new(1, nameof(Currency)); + + /// + /// EN: Points (PPoint). + /// VI: Điểm (PPoint). + /// + public static readonly AssetType Point = new(2, nameof(Point)); + + protected AssetType() : base(0, "Unknown") { } + public AssetType(int id, string name) : base(id, name) { } +} diff --git a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/Campaign.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/Campaign.cs new file mode 100644 index 00000000..f71538fe --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/Campaign.cs @@ -0,0 +1,434 @@ +namespace PromotionService.Domain.AggregatesModel.CampaignAggregate; + +using PromotionService.Domain.Events; +using PromotionService.Domain.Exceptions; +using PromotionService.Domain.SeedWork; + +/// +/// EN: Campaign Aggregate Root - represents a marketing campaign with vouchers. +/// VI: Campaign Aggregate Root - đại diện cho một chiến dịch marketing với voucher. +/// +public class Campaign : Entity, IAggregateRoot +{ + private readonly List _vouchers = new(); + + /// + /// EN: ID of the merchant who owns the campaign. + /// VI: ID của merchant sở hữu chiến dịch. + /// + public Guid MerchantId { get; private set; } + + /// + /// EN: Campaign name. + /// VI: Tên chiến dịch. + /// + public string Name { get; private set; } = null!; + + /// + /// EN: Campaign description. + /// VI: Mô tả chiến dịch. + /// + public string? Description { get; private set; } + + #region Backing Asset (What the voucher contains) + + /// + /// EN: Type of backing asset (Currency or Point). + /// VI: Loại tài sản đảm bảo (Tiền tệ hoặc Điểm). + /// + public AssetType BackingAssetType { get; private set; } = null!; + public int BackingAssetTypeId { get; private set; } + + /// + /// EN: Currency/Point code (e.g., VND, USD, PPoint). + /// VI: Mã tiền tệ/điểm (ví dụ: VND, USD, PPoint). + /// + public string BackingAssetCode { get; private set; } = null!; + + /// + /// EN: Face value of each voucher. + /// VI: Mệnh giá của mỗi voucher. + /// + public decimal FaceValue { get; private set; } + + #endregion + + #region Acquisition (How user gets the voucher) + + /// + /// EN: How user acquires the voucher (Free, ExchangePoints, Purchase). + /// VI: Cách người dùng nhận voucher (Miễn phí, Đổi điểm, Mua). + /// + public AcquisitionType AcquisitionType { get; private set; } = null!; + public int AcquisitionTypeId { get; private set; } + + /// + /// EN: Price to acquire (points or currency amount). + /// VI: Giá để nhận (số điểm hoặc số tiền). + /// + public decimal AcquisitionPrice { get; private set; } + + #endregion + + #region Escrow (Wallet Service Hold) + + /// + /// EN: Hold ID from Wallet Service for escrow. + /// VI: ID giữ từ Wallet Service cho ký quỹ. + /// + public Guid? EscrowHoldId { get; private set; } + + /// + /// EN: Wallet ID that provides the escrow. + /// VI: ID ví cung cấp ký quỹ. + /// + public Guid? EscrowWalletId { get; private set; } + + /// + /// EN: Total amount held in escrow. + /// VI: Tổng số tiền ký quỹ. + /// + public decimal EscrowAmount { get; private set; } + + #endregion + + #region Campaign Limits + + /// + /// EN: Total number of vouchers to issue. + /// VI: Tổng số voucher phát hành. + /// + public int TotalVouchers { get; private set; } + + /// + /// EN: Number of vouchers already issued (claimed). + /// VI: Số voucher đã phát hành (đã nhận). + /// + public int IssuedVouchers { get; private set; } + + /// + /// EN: Maximum vouchers per user (0 = unlimited). + /// VI: Số voucher tối đa mỗi người dùng (0 = không giới hạn). + /// + public int MaxPerUser { get; private set; } + + /// + /// EN: Campaign start date. + /// VI: Ngày bắt đầu chiến dịch. + /// + public DateTime StartDate { get; private set; } + + /// + /// EN: Campaign end date. + /// VI: Ngày kết thúc chiến dịch. + /// + public DateTime EndDate { get; private set; } + + /// + /// EN: Voucher validity days after claiming. + /// VI: Số ngày voucher có hiệu lực sau khi nhận. + /// + public int VoucherValidityDays { get; private set; } + + #endregion + + #region Status + + /// + /// EN: Current campaign status. + /// VI: Trạng thái chiến dịch hiện tại. + /// + public CampaignStatus Status { get; private set; } = null!; + public int StatusId { get; private set; } + + /// + /// EN: Timestamp of creation. + /// VI: Thời điểm tạo. + /// + public DateTime CreatedAt { get; private set; } + + /// + /// EN: Timestamp of last update. + /// VI: Thời điểm cập nhật cuối. + /// + public DateTime UpdatedAt { get; private set; } + + #endregion + + /// + /// EN: Collection of vouchers in this campaign. + /// VI: Danh sách voucher trong chiến dịch này. + /// + public IReadOnlyCollection Vouchers => _vouchers.AsReadOnly(); + + // EF Core constructor + protected Campaign() { } + + /// + /// EN: Create a new campaign. + /// VI: Tạo chiến dịch mới. + /// + public Campaign( + Guid merchantId, + string name, + string? description, + AssetType backingAssetType, + string backingAssetCode, + decimal faceValue, + AcquisitionType acquisitionType, + decimal acquisitionPrice, + int totalVouchers, + DateTime startDate, + DateTime endDate, + int voucherValidityDays = 30, + int maxPerUser = 1) + { + if (string.IsNullOrWhiteSpace(name)) + throw new PromotionDomainException("Campaign name is required"); + if (faceValue <= 0) + throw new PromotionDomainException("Face value must be greater than zero"); + if (totalVouchers <= 0) + throw new PromotionDomainException("Total vouchers must be greater than zero"); + if (endDate <= startDate) + throw new PromotionDomainException("End date must be after start date"); + if (acquisitionType != AcquisitionType.Free && acquisitionPrice <= 0) + throw new PromotionDomainException("Acquisition price is required for non-free campaigns"); + + Id = Guid.NewGuid(); + MerchantId = merchantId; + Name = name; + Description = description; + BackingAssetType = backingAssetType; + BackingAssetTypeId = backingAssetType.Id; + BackingAssetCode = backingAssetCode; + FaceValue = faceValue; + AcquisitionType = acquisitionType; + AcquisitionTypeId = acquisitionType.Id; + AcquisitionPrice = acquisitionPrice; + TotalVouchers = totalVouchers; + IssuedVouchers = 0; + MaxPerUser = maxPerUser; + StartDate = startDate; + EndDate = endDate; + VoucherValidityDays = voucherValidityDays; + Status = CampaignStatus.Draft; + StatusId = Status.Id; + CreatedAt = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + + // Calculate escrow amount + EscrowAmount = faceValue * totalVouchers; + + AddDomainEvent(new CampaignCreatedDomainEvent(Id, merchantId, name)); + } + + #region Escrow Management + + /// + /// EN: Set the escrow hold ID after Wallet Service confirms the hold. + /// VI: Đặt ID giữ ký quỹ sau khi Wallet Service xác nhận giữ. + /// + public void SetEscrowHold(Guid walletId, Guid holdId) + { + if (Status != CampaignStatus.Draft) + throw new PromotionDomainException("Can only set escrow on draft campaigns"); + + EscrowWalletId = walletId; + EscrowHoldId = holdId; + UpdatedAt = DateTime.UtcNow; + } + + #endregion + + #region Lifecycle Methods + + /// + /// EN: Activate the campaign (make it live). + /// VI: Kích hoạt chiến dịch (đưa vào hoạt động). + /// + public void Activate() + { + if (Status != CampaignStatus.Draft && Status != CampaignStatus.Paused) + throw new PromotionDomainException($"Cannot activate campaign in {Status.Name} status"); + + if (!EscrowHoldId.HasValue) + throw new PromotionDomainException("Escrow must be set before activating"); + + Status = CampaignStatus.Active; + StatusId = Status.Id; + UpdatedAt = DateTime.UtcNow; + + AddDomainEvent(new CampaignActivatedDomainEvent(Id, MerchantId)); + } + + /// + /// EN: Pause the campaign (temporarily stop). + /// VI: Tạm dừng chiến dịch (dừng tạm thời). + /// + public void Pause() + { + if (Status != CampaignStatus.Active) + throw new PromotionDomainException("Can only pause active campaigns"); + + Status = CampaignStatus.Paused; + StatusId = Status.Id; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// EN: Cancel the campaign (release escrow). + /// VI: Hủy chiến dịch (giải phóng ký quỹ). + /// + public void Cancel() + { + if (Status == CampaignStatus.Completed || Status == CampaignStatus.Cancelled) + throw new PromotionDomainException($"Cannot cancel campaign in {Status.Name} status"); + + Status = CampaignStatus.Cancelled; + StatusId = Status.Id; + UpdatedAt = DateTime.UtcNow; + + AddDomainEvent(new CampaignCancelledDomainEvent(Id, MerchantId, EscrowHoldId)); + } + + /// + /// EN: Complete the campaign (normal end). + /// VI: Hoàn thành chiến dịch (kết thúc bình thường). + /// + public void Complete() + { + if (Status != CampaignStatus.Active && Status != CampaignStatus.Paused) + throw new PromotionDomainException($"Cannot complete campaign in {Status.Name} status"); + + Status = CampaignStatus.Completed; + StatusId = Status.Id; + UpdatedAt = DateTime.UtcNow; + } + + #endregion + + #region Voucher Management + + /// + /// EN: Generate voucher codes for this campaign. + /// VI: Tạo mã voucher cho chiến dịch này. + /// + public void GenerateVouchers(int count) + { + if (Status != CampaignStatus.Draft) + throw new PromotionDomainException("Can only generate vouchers for draft campaigns"); + + if (_vouchers.Count + count > TotalVouchers) + throw new PromotionDomainException($"Cannot generate {count} vouchers. Maximum is {TotalVouchers}"); + + for (int i = 0; i < count; i++) + { + var code = GenerateVoucherCode(); + var voucher = new Voucher(Id, code, FaceValue, VoucherValidityDays); + _vouchers.Add(voucher); + } + + UpdatedAt = DateTime.UtcNow; + } + + /// + /// EN: Issue a voucher to a user (claim). + /// VI: Phát voucher cho người dùng (nhận). + /// + public Voucher IssueVoucher(Guid userId) + { + if (Status != CampaignStatus.Active) + throw new CampaignNotActiveException(Id); + + if (DateTime.UtcNow < StartDate || DateTime.UtcNow > EndDate) + throw new PromotionDomainException("Campaign is not within active date range"); + + // Check max per user + if (MaxPerUser > 0) + { + var userVoucherCount = _vouchers.Count(v => v.OwnerId == userId); + if (userVoucherCount >= MaxPerUser) + throw new PromotionDomainException($"User already has maximum {MaxPerUser} vouchers from this campaign"); + } + + // Find available voucher + var voucher = _vouchers.FirstOrDefault(v => v.Status == VoucherStatus.Available); + if (voucher == null) + throw new PromotionDomainException("No available vouchers in this campaign"); + + voucher.Claim(userId, VoucherValidityDays); + IssuedVouchers++; + UpdatedAt = DateTime.UtcNow; + + AddDomainEvent(new VoucherClaimedDomainEvent(voucher.Id, Id, userId, voucher.Code)); + + return voucher; + } + + /// + /// EN: Get a specific voucher by code. + /// VI: Lấy voucher theo mã. + /// + public Voucher? GetVoucherByCode(string code) + { + return _vouchers.FirstOrDefault(v => v.Code.Equals(code, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// EN: Get vouchers for a specific user. + /// VI: Lấy voucher của người dùng. + /// + public IEnumerable GetUserVouchers(Guid userId) + { + return _vouchers.Where(v => v.OwnerId == userId); + } + + private string GenerateVoucherCode() + { + // Format: PREFIX-RANDOM (e.g., CAMP-A1B2C3) + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + var random = new Random(); + var code = new string(Enumerable.Repeat(chars, 6) + .Select(s => s[random.Next(s.Length)]).ToArray()); + + // Ensure uniqueness + while (_vouchers.Any(v => v.Code == $"V{code}")) + { + code = new string(Enumerable.Repeat(chars, 6) + .Select(s => s[random.Next(s.Length)]).ToArray()); + } + + return $"V{code}"; + } + + #endregion + + #region Statistics + + /// + /// EN: Get available voucher count. + /// VI: Lấy số voucher còn lại. + /// + public int AvailableVoucherCount => _vouchers.Count(v => v.Status == VoucherStatus.Available); + + /// + /// EN: Get claimed voucher count. + /// VI: Lấy số voucher đã nhận. + /// + public int ClaimedVoucherCount => _vouchers.Count(v => v.Status != VoucherStatus.Available); + + /// + /// EN: Get redeemed voucher count. + /// VI: Lấy số voucher đã sử dụng. + /// + public int RedeemedVoucherCount => _vouchers.Count(v => + v.Status == VoucherStatus.PartiallyRedeemed || v.Status == VoucherStatus.FullyRedeemed); + + /// + /// EN: Get total redeemed value. + /// VI: Lấy tổng giá trị đã sử dụng. + /// + public decimal TotalRedeemedValue => _vouchers.Sum(v => v.FaceValue - v.RemainingValue); + + #endregion +} diff --git a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/CampaignStatus.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/CampaignStatus.cs new file mode 100644 index 00000000..c8d676be --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/CampaignStatus.cs @@ -0,0 +1,43 @@ +namespace PromotionService.Domain.AggregatesModel.CampaignAggregate; + +using PromotionService.Domain.SeedWork; + +/// +/// EN: Status of the marketing campaign. +/// VI: Trạng thái của chiến dịch marketing. +/// +public class CampaignStatus : Enumeration +{ + /// + /// EN: Draft - campaign is being prepared. + /// VI: Nháp - chiến dịch đang được chuẩn bị. + /// + public static readonly CampaignStatus Draft = new(1, nameof(Draft)); + + /// + /// EN: Active - campaign is running, vouchers can be claimed. + /// VI: Hoạt động - chiến dịch đang chạy, voucher có thể được nhận. + /// + public static readonly CampaignStatus Active = new(2, nameof(Active)); + + /// + /// EN: Paused - campaign is temporarily stopped. + /// VI: Tạm dừng - chiến dịch tạm thời dừng. + /// + public static readonly CampaignStatus Paused = new(3, nameof(Paused)); + + /// + /// EN: Completed - campaign has ended normally. + /// VI: Hoàn thành - chiến dịch đã kết thúc bình thường. + /// + public static readonly CampaignStatus Completed = new(4, nameof(Completed)); + + /// + /// EN: Cancelled - campaign was cancelled, escrow released. + /// VI: Hủy bỏ - chiến dịch bị hủy, escrow được giải phóng. + /// + public static readonly CampaignStatus Cancelled = new(5, nameof(Cancelled)); + + protected CampaignStatus() : base(0, "Unknown") { } + public CampaignStatus(int id, string name) : base(id, name) { } +} diff --git a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/ICampaignRepository.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/ICampaignRepository.cs new file mode 100644 index 00000000..6d17bae6 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/ICampaignRepository.cs @@ -0,0 +1,52 @@ +namespace PromotionService.Domain.AggregatesModel.CampaignAggregate; + +using PromotionService.Domain.SeedWork; + +/// +/// EN: Repository interface for Campaign aggregate. +/// VI: Interface repository cho Campaign aggregate. +/// +public interface ICampaignRepository : IRepository +{ + /// + /// EN: Get campaign by ID with vouchers. + /// VI: Lấy chiến dịch theo ID với voucher. + /// + Task GetByIdAsync(Guid id); + + /// + /// EN: Get campaigns by merchant ID. + /// VI: Lấy chiến dịch theo ID merchant. + /// + Task> GetByMerchantIdAsync(Guid merchantId); + + /// + /// EN: Get active campaigns. + /// VI: Lấy các chiến dịch đang hoạt động. + /// + Task> GetActiveAsync(); + + /// + /// EN: Get voucher by code across all campaigns. + /// VI: Lấy voucher theo mã trên tất cả chiến dịch. + /// + Task GetVoucherByCodeAsync(string code); + + /// + /// EN: Get vouchers owned by a user. + /// VI: Lấy voucher thuộc về người dùng. + /// + Task> GetUserVouchersAsync(Guid userId); + + /// + /// EN: Add a new campaign. + /// VI: Thêm chiến dịch mới. + /// + Campaign Add(Campaign campaign); + + /// + /// EN: Update an existing campaign. + /// VI: Cập nhật chiến dịch hiện có. + /// + void Update(Campaign campaign); +} diff --git a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/Voucher.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/Voucher.cs new file mode 100644 index 00000000..e3dc5846 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/Voucher.cs @@ -0,0 +1,199 @@ +namespace PromotionService.Domain.AggregatesModel.CampaignAggregate; + +using PromotionService.Domain.Exceptions; +using PromotionService.Domain.SeedWork; + +/// +/// EN: Voucher entity representing a single voucher in a campaign. +/// VI: Entity voucher đại diện cho một voucher trong chiến dịch. +/// +public class Voucher : Entity +{ + /// + /// EN: Campaign ID this voucher belongs to. + /// VI: ID chiến dịch voucher này thuộc về. + /// + public Guid CampaignId { get; private set; } + + /// + /// EN: Unique voucher code. + /// VI: Mã voucher duy nhất. + /// + public string Code { get; private set; } = null!; + + /// + /// EN: User ID who owns this voucher (null if unclaimed). + /// VI: ID người dùng sở hữu voucher này (null nếu chưa nhận). + /// + public Guid? OwnerId { get; private set; } + + /// + /// EN: Original face value of voucher. + /// VI: Mệnh giá gốc của voucher. + /// + public decimal FaceValue { get; private set; } + + /// + /// EN: Remaining value (after partial redemptions). + /// VI: Giá trị còn lại (sau khi sử dụng một phần). + /// + public decimal RemainingValue { get; private set; } + + /// + /// EN: Voucher status. + /// VI: Trạng thái voucher. + /// + public VoucherStatus Status { get; private set; } = null!; + public int StatusId { get; private set; } + + /// + /// EN: When the voucher was claimed by a user. + /// VI: Thời điểm voucher được nhận bởi người dùng. + /// + public DateTime? ClaimedAt { get; private set; } + + /// + /// EN: When the voucher expires. + /// VI: Thời điểm voucher hết hạn. + /// + public DateTime? ExpiresAt { get; private set; } + + /// + /// EN: When the voucher was last redeemed. + /// VI: Thời điểm voucher được sử dụng lần cuối. + /// + public DateTime? RedeemedAt { get; private set; } + + /// + /// EN: Creation timestamp. + /// VI: Thời điểm tạo. + /// + public DateTime CreatedAt { get; private set; } + + /// + /// EN: Last update timestamp. + /// VI: Thời điểm cập nhật cuối. + /// + public DateTime UpdatedAt { get; private set; } + + // EF Core constructor + protected Voucher() { } + + /// + /// EN: Create a new voucher. + /// VI: Tạo voucher mới. + /// + public Voucher(Guid campaignId, string code, decimal faceValue, int validityDays) + { + if (string.IsNullOrWhiteSpace(code)) + throw new PromotionDomainException("Voucher code is required"); + if (faceValue <= 0) + throw new PromotionDomainException("Face value must be greater than zero"); + + Id = Guid.NewGuid(); + CampaignId = campaignId; + Code = code; + FaceValue = faceValue; + RemainingValue = faceValue; + Status = VoucherStatus.Available; + StatusId = Status.Id; + CreatedAt = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// EN: Claim the voucher for a user. + /// VI: Nhận voucher cho người dùng. + /// + public void Claim(Guid userId, int validityDays) + { + if (Status != VoucherStatus.Available) + throw new VoucherAlreadyClaimedException(Id, Code); + + OwnerId = userId; + Status = VoucherStatus.Claimed; + StatusId = Status.Id; + ClaimedAt = DateTime.UtcNow; + ExpiresAt = DateTime.UtcNow.AddDays(validityDays); + UpdatedAt = DateTime.UtcNow; + } + + /// + /// EN: Redeem a portion of the voucher value. + /// VI: Sử dụng một phần giá trị voucher. + /// + /// Amount to redeem / Số tiền sử dụng + /// Actual amount deducted / Số tiền thực tế bị trừ + public decimal Redeem(decimal amount) + { + ValidateCanRedeem(); + + if (amount <= 0) + throw new PromotionDomainException("Redeem amount must be greater than zero"); + + // Calculate actual amount (cannot exceed remaining) + var actualAmount = Math.Min(amount, RemainingValue); + + RemainingValue -= actualAmount; + RedeemedAt = DateTime.UtcNow; + UpdatedAt = DateTime.UtcNow; + + // Update status + Status = RemainingValue == 0 ? VoucherStatus.FullyRedeemed : VoucherStatus.PartiallyRedeemed; + StatusId = Status.Id; + + return actualAmount; + } + + /// + /// EN: Mark voucher as expired. + /// VI: Đánh dấu voucher hết hạn. + /// + public void Expire() + { + if (Status == VoucherStatus.FullyRedeemed) + return; // Already fully used + + Status = VoucherStatus.Expired; + StatusId = Status.Id; + UpdatedAt = DateTime.UtcNow; + } + + /// + /// EN: Check if voucher is valid for redemption. + /// VI: Kiểm tra voucher có hợp lệ để sử dụng. + /// + public bool IsValidForRedemption() + { + return (Status == VoucherStatus.Claimed || Status == VoucherStatus.PartiallyRedeemed) + && RemainingValue > 0 + && (!ExpiresAt.HasValue || ExpiresAt.Value > DateTime.UtcNow); + } + + /// + /// EN: Check if voucher has expired. + /// VI: Kiểm tra voucher đã hết hạn chưa. + /// + public bool IsExpired => ExpiresAt.HasValue && DateTime.UtcNow > ExpiresAt.Value; + + private void ValidateCanRedeem() + { + if (Status == VoucherStatus.Available) + throw new PromotionDomainException("Voucher has not been claimed yet"); + + if (Status == VoucherStatus.FullyRedeemed) + throw new PromotionDomainException("Voucher has already been fully redeemed"); + + if (Status == VoucherStatus.Expired) + throw new VoucherExpiredException(Id, Code); + + if (IsExpired) + { + Expire(); + throw new VoucherExpiredException(Id, Code); + } + + if (RemainingValue <= 0) + throw new InsufficientVoucherValueException(Id, 0, 0); + } +} diff --git a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/VoucherStatus.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/VoucherStatus.cs new file mode 100644 index 00000000..178b8843 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/CampaignAggregate/VoucherStatus.cs @@ -0,0 +1,43 @@ +namespace PromotionService.Domain.AggregatesModel.CampaignAggregate; + +using PromotionService.Domain.SeedWork; + +/// +/// EN: Status of a voucher in its lifecycle. +/// VI: Trạng thái của voucher trong vòng đời. +/// +public class VoucherStatus : Enumeration +{ + /// + /// EN: Available - voucher is not claimed yet. + /// VI: Sẵn sàng - voucher chưa được nhận. + /// + public static readonly VoucherStatus Available = new(1, nameof(Available)); + + /// + /// EN: Claimed - voucher is owned by a user. + /// VI: Đã nhận - voucher thuộc về người dùng. + /// + public static readonly VoucherStatus Claimed = new(2, nameof(Claimed)); + + /// + /// EN: Partially redeemed - some value has been used. + /// VI: Đã dùng một phần - một phần giá trị đã được sử dụng. + /// + public static readonly VoucherStatus PartiallyRedeemed = new(3, nameof(PartiallyRedeemed)); + + /// + /// EN: Fully redeemed - all value has been used. + /// VI: Đã dùng hết - toàn bộ giá trị đã được sử dụng. + /// + public static readonly VoucherStatus FullyRedeemed = new(4, nameof(FullyRedeemed)); + + /// + /// EN: Expired - voucher has expired without full use. + /// VI: Hết hạn - voucher đã hết hạn mà chưa dùng hết. + /// + public static readonly VoucherStatus Expired = new(5, nameof(Expired)); + + protected VoucherStatus() : base(0, "Unknown") { } + public VoucherStatus(int id, string name) : base(id, name) { } +} diff --git a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/RedemptionAggregate/IRedemptionRepository.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/RedemptionAggregate/IRedemptionRepository.cs new file mode 100644 index 00000000..e77fe6d5 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/RedemptionAggregate/IRedemptionRepository.cs @@ -0,0 +1,34 @@ +namespace PromotionService.Domain.AggregatesModel.RedemptionAggregate; + +using PromotionService.Domain.SeedWork; + +/// +/// EN: Repository interface for Redemption aggregate. +/// VI: Interface repository cho Redemption aggregate. +/// +public interface IRedemptionRepository : IRepository +{ + /// + /// EN: Get redemptions by voucher ID. + /// VI: Lấy các lần sử dụng theo ID voucher. + /// + Task> GetByVoucherIdAsync(Guid voucherId); + + /// + /// EN: Get redemptions by user ID. + /// VI: Lấy các lần sử dụng theo ID người dùng. + /// + Task> GetByUserIdAsync(Guid userId); + + /// + /// EN: Get redemptions by campaign ID. + /// VI: Lấy các lần sử dụng theo ID chiến dịch. + /// + Task> GetByCampaignIdAsync(Guid campaignId); + + /// + /// EN: Add a new redemption. + /// VI: Thêm lần sử dụng mới. + /// + Redemption Add(Redemption redemption); +} diff --git a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/RedemptionAggregate/Redemption.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/RedemptionAggregate/Redemption.cs new file mode 100644 index 00000000..4fccdbfa --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/RedemptionAggregate/Redemption.cs @@ -0,0 +1,92 @@ +namespace PromotionService.Domain.AggregatesModel.RedemptionAggregate; + +using PromotionService.Domain.SeedWork; + +/// +/// EN: Redemption Aggregate Root - records voucher redemption history. +/// VI: Redemption Aggregate Root - ghi lại lịch sử sử dụng voucher. +/// +public class Redemption : Entity, IAggregateRoot +{ + /// + /// EN: ID of the voucher that was redeemed. + /// VI: ID của voucher được sử dụng. + /// + public Guid VoucherId { get; private set; } + + /// + /// EN: ID of the campaign. + /// VI: ID của chiến dịch. + /// + public Guid CampaignId { get; private set; } + + /// + /// EN: ID of the user who redeemed. + /// VI: ID của người dùng sử dụng. + /// + public Guid UserId { get; private set; } + + /// + /// EN: Order ID if linked to an order. + /// VI: ID đơn hàng nếu liên kết với đơn hàng. + /// + public Guid? OrderId { get; private set; } + + /// + /// EN: Amount used from the voucher. + /// VI: Số tiền sử dụng từ voucher. + /// + public decimal AmountUsed { get; private set; } + + /// + /// EN: Amount refunded to merchant (surplus). + /// VI: Số tiền hoàn lại cho merchant (dư). + /// + public decimal AmountRefunded { get; private set; } + + /// + /// EN: Execution reference from Wallet Service. + /// VI: Tham chiếu thực thi từ Wallet Service. + /// + public string? ExecutionReference { get; private set; } + + /// + /// EN: When the redemption occurred. + /// VI: Thời điểm sử dụng. + /// + public DateTime RedeemedAt { get; private set; } + + /// + /// EN: Creation timestamp. + /// VI: Thời điểm tạo. + /// + public DateTime CreatedAt { get; private set; } + + // EF Core constructor + protected Redemption() { } + + /// + /// EN: Create a redemption record. + /// VI: Tạo bản ghi sử dụng. + /// + public Redemption( + Guid voucherId, + Guid campaignId, + Guid userId, + Guid? orderId, + decimal amountUsed, + decimal amountRefunded, + string? executionReference = null) + { + Id = Guid.NewGuid(); + VoucherId = voucherId; + CampaignId = campaignId; + UserId = userId; + OrderId = orderId; + AmountUsed = amountUsed; + AmountRefunded = amountRefunded; + ExecutionReference = executionReference; + RedeemedAt = DateTime.UtcNow; + CreatedAt = DateTime.UtcNow; + } +} diff --git a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/ISampleRepository.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/ISampleRepository.cs new file mode 100644 index 00000000..b8cf88d1 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/ISampleRepository.cs @@ -0,0 +1,61 @@ +using PromotionService.Domain.SeedWork; + +namespace PromotionService.Domain.AggregatesModel.SampleAggregate; + +/// +/// EN: Repository interface for Sample aggregate. +/// VI: Interface repository cho Sample aggregate. +/// +/// +/// EN: Following repository pattern, this interface defines the contract +/// for data access operations on Sample aggregate. +/// VI: Theo pattern repository, interface này định nghĩa contract +/// cho các thao tác truy cập dữ liệu trên Sample aggregate. +/// +public interface ISampleRepository : IRepository +{ + /// + /// EN: Get a sample by its ID. + /// VI: Lấy một sample theo ID. + /// + /// EN: The sample ID / VI: ID của sample + /// EN: The sample or null if not found / VI: Sample hoặc null nếu không tìm thấy + Task GetAsync(Guid sampleId); + + /// + /// EN: Get all samples. + /// VI: Lấy tất cả samples. + /// + /// EN: List of samples / VI: Danh sách samples + Task> GetAllAsync(); + + /// + /// EN: Add a new sample. + /// VI: Thêm một sample mới. + /// + /// EN: The sample to add / VI: Sample cần thêm + /// EN: The added sample / VI: Sample đã thêm + Sample Add(Sample sample); + + /// + /// EN: Update an existing sample. + /// VI: Cập nhật một sample đã tồn tại. + /// + /// EN: The sample to update / VI: Sample cần cập nhật + void Update(Sample sample); + + /// + /// EN: Delete a sample. + /// VI: Xóa một sample. + /// + /// EN: The sample to delete / VI: Sample cần xóa + void Delete(Sample sample); + + /// + /// EN: Get samples by status. + /// VI: Lấy samples theo trạng thái. + /// + /// EN: The status ID / VI: ID trạng thái + /// EN: List of samples with given status / VI: Danh sách samples với trạng thái cho trước + Task> GetByStatusAsync(int statusId); +} diff --git a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/Sample.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/Sample.cs new file mode 100644 index 00000000..e9522849 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/Sample.cs @@ -0,0 +1,158 @@ +using PromotionService.Domain.Events; +using PromotionService.Domain.Exceptions; +using PromotionService.Domain.SeedWork; + +namespace PromotionService.Domain.AggregatesModel.SampleAggregate; + +/// +/// EN: Sample aggregate root demonstrating DDD patterns. +/// VI: Sample aggregate root minh họa các pattern DDD. +/// +public class Sample : Entity, IAggregateRoot +{ + // EN: Private fields for encapsulation + // VI: Fields private để đóng gói + private string _name = null!; + private string? _description; + private SampleStatus _status = null!; + private DateTime _createdAt; + private DateTime? _updatedAt; + + /// + /// EN: Sample name (required). + /// VI: Tên sample (bắt buộc). + /// + public string Name => _name; + + /// + /// EN: Optional description. + /// VI: Mô tả tùy chọn. + /// + public string? Description => _description; + + /// + /// EN: Current status. + /// VI: Trạng thái hiện tại. + /// + public SampleStatus Status => _status; + + /// + /// EN: Status ID for EF Core mapping. + /// VI: ID trạng thái cho EF Core mapping. + /// + public int StatusId { get; private set; } + + /// + /// EN: Creation timestamp. + /// VI: Thời gian tạo. + /// + public DateTime CreatedAt => _createdAt; + + /// + /// EN: Last update timestamp. + /// VI: Thời gian cập nhật cuối. + /// + public DateTime? UpdatedAt => _updatedAt; + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + protected Sample() + { + } + + /// + /// EN: Create a new Sample with required information. + /// VI: Tạo một Sample mới với thông tin bắt buộc. + /// + /// EN: Sample name / VI: Tên sample + /// EN: Optional description / VI: Mô tả tùy chọn + public Sample(string name, string? description = null) : this() + { + if (string.IsNullOrWhiteSpace(name)) + throw new SampleDomainException("Sample name cannot be empty"); + + Id = Guid.NewGuid(); + _name = name; + _description = description; + _status = SampleStatus.Draft; + StatusId = SampleStatus.Draft.Id; + _createdAt = DateTime.UtcNow; + + // EN: Add domain event for creation + // VI: Thêm domain event cho việc tạo + AddDomainEvent(new SampleCreatedDomainEvent(this)); + } + + /// + /// EN: Update sample information. + /// VI: Cập nhật thông tin sample. + /// + public void Update(string name, string? description) + { + if (string.IsNullOrWhiteSpace(name)) + throw new SampleDomainException("Sample name cannot be empty"); + + if (_status == SampleStatus.Cancelled) + throw new SampleDomainException("Cannot update a cancelled sample"); + + _name = name; + _description = description; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Activate the sample. + /// VI: Kích hoạt sample. + /// + public void Activate() + { + if (_status != SampleStatus.Draft) + throw new SampleDomainException("Only draft samples can be activated"); + + var previousStatus = _status; + _status = SampleStatus.Active; + StatusId = SampleStatus.Active.Id; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status)); + } + + /// + /// EN: Complete the sample. + /// VI: Hoàn thành sample. + /// + public void Complete() + { + if (_status != SampleStatus.Active) + throw new SampleDomainException("Only active samples can be completed"); + + var previousStatus = _status; + _status = SampleStatus.Completed; + StatusId = SampleStatus.Completed.Id; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status)); + } + + /// + /// EN: Cancel the sample. + /// VI: Hủy sample. + /// + public void Cancel() + { + if (_status == SampleStatus.Completed) + throw new SampleDomainException("Cannot cancel a completed sample"); + + if (_status == SampleStatus.Cancelled) + throw new SampleDomainException("Sample is already cancelled"); + + var previousStatus = _status; + _status = SampleStatus.Cancelled; + StatusId = SampleStatus.Cancelled.Id; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status)); + } +} diff --git a/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/SampleStatus.cs b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/SampleStatus.cs new file mode 100644 index 00000000..4f370b3c --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/AggregatesModel/SampleAggregate/SampleStatus.cs @@ -0,0 +1,77 @@ +using PromotionService.Domain.SeedWork; + +namespace PromotionService.Domain.AggregatesModel.SampleAggregate; + +/// +/// EN: Sample status enumeration following type-safe enum pattern. +/// VI: Enumeration trạng thái Sample theo pattern enum an toàn kiểu. +/// +public class SampleStatus : Enumeration +{ + /// + /// EN: Draft status - initial state + /// VI: Trạng thái nháp - trạng thái ban đầu + /// + public static SampleStatus Draft = new(1, nameof(Draft)); + + /// + /// EN: Active status - ready for use + /// VI: Trạng thái hoạt động - sẵn sàng sử dụng + /// + public static SampleStatus Active = new(2, nameof(Active)); + + /// + /// EN: Completed status - finished processing + /// VI: Trạng thái hoàn thành - đã xử lý xong + /// + public static SampleStatus Completed = new(3, nameof(Completed)); + + /// + /// EN: Cancelled status - cancelled by user + /// VI: Trạng thái đã hủy - bị hủy bởi người dùng + /// + public static SampleStatus Cancelled = new(4, nameof(Cancelled)); + + public SampleStatus(int id, string name) : base(id, name) + { + } + + /// + /// EN: Get all available statuses. + /// VI: Lấy tất cả các trạng thái có sẵn. + /// + public static IEnumerable List() => GetAll(); + + /// + /// EN: Parse status from name. + /// VI: Parse trạng thái từ tên. + /// + public static SampleStatus FromName(string name) + { + var status = List().SingleOrDefault(s => + string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase)); + + if (status is null) + { + throw new ArgumentException($"Possible values for SampleStatus: {string.Join(",", List().Select(s => s.Name))}"); + } + + return status; + } + + /// + /// EN: Parse status from ID. + /// VI: Parse trạng thái từ ID. + /// + public static SampleStatus From(int id) + { + var status = List().SingleOrDefault(s => s.Id == id); + + if (status is null) + { + throw new ArgumentException($"Possible values for SampleStatus: {string.Join(",", List().Select(s => s.Name))}"); + } + + return status; + } +} diff --git a/services/promotion-service-net/src/PromotionService.Domain/Events/PromotionDomainEvents.cs b/services/promotion-service-net/src/PromotionService.Domain/Events/PromotionDomainEvents.cs new file mode 100644 index 00000000..d92c35f3 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/Events/PromotionDomainEvents.cs @@ -0,0 +1,50 @@ +namespace PromotionService.Domain.Events; + +using MediatR; + +/// +/// EN: Event raised when a new campaign is created. +/// VI: Sự kiện được phát ra khi chiến dịch mới được tạo. +/// +public record CampaignCreatedDomainEvent( + Guid CampaignId, + Guid MerchantId, + string CampaignName) : INotification; + +/// +/// EN: Event raised when a campaign is activated. +/// VI: Sự kiện được phát ra khi chiến dịch được kích hoạt. +/// +public record CampaignActivatedDomainEvent( + Guid CampaignId, + Guid MerchantId) : INotification; + +/// +/// EN: Event raised when a campaign is cancelled. +/// VI: Sự kiện được phát ra khi chiến dịch bị hủy. +/// +public record CampaignCancelledDomainEvent( + Guid CampaignId, + Guid MerchantId, + Guid? EscrowHoldId) : INotification; + +/// +/// EN: Event raised when a voucher is claimed by a user. +/// VI: Sự kiện được phát ra khi voucher được nhận bởi người dùng. +/// +public record VoucherClaimedDomainEvent( + Guid VoucherId, + Guid CampaignId, + Guid UserId, + string VoucherCode) : INotification; + +/// +/// EN: Event raised when a voucher is redeemed. +/// VI: Sự kiện được phát ra khi voucher được sử dụng. +/// +public record VoucherRedeemedDomainEvent( + Guid VoucherId, + Guid CampaignId, + Guid UserId, + decimal AmountUsed, + decimal AmountRefunded) : INotification; diff --git a/services/promotion-service-net/src/PromotionService.Domain/Events/SampleCreatedDomainEvent.cs b/services/promotion-service-net/src/PromotionService.Domain/Events/SampleCreatedDomainEvent.cs new file mode 100644 index 00000000..06a99e02 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/Events/SampleCreatedDomainEvent.cs @@ -0,0 +1,22 @@ +using MediatR; +using PromotionService.Domain.AggregatesModel.SampleAggregate; + +namespace PromotionService.Domain.Events; + +/// +/// EN: Domain event raised when a new Sample is created. +/// VI: Domain event được phát ra khi một Sample mới được tạo. +/// +public class SampleCreatedDomainEvent : INotification +{ + /// + /// EN: The newly created sample. + /// VI: Sample mới được tạo. + /// + public Sample Sample { get; } + + public SampleCreatedDomainEvent(Sample sample) + { + Sample = sample; + } +} diff --git a/services/promotion-service-net/src/PromotionService.Domain/Events/SampleStatusChangedDomainEvent.cs b/services/promotion-service-net/src/PromotionService.Domain/Events/SampleStatusChangedDomainEvent.cs new file mode 100644 index 00000000..a6129c3b --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/Events/SampleStatusChangedDomainEvent.cs @@ -0,0 +1,39 @@ +using MediatR; +using PromotionService.Domain.AggregatesModel.SampleAggregate; + +namespace PromotionService.Domain.Events; + +/// +/// EN: Domain event raised when Sample status changes. +/// VI: Domain event được phát ra khi trạng thái Sample thay đổi. +/// +public class SampleStatusChangedDomainEvent : INotification +{ + /// + /// EN: The sample ID. + /// VI: ID của sample. + /// + public Guid SampleId { get; } + + /// + /// EN: Previous status before the change. + /// VI: Trạng thái trước khi thay đổi. + /// + public SampleStatus PreviousStatus { get; } + + /// + /// EN: New status after the change. + /// VI: Trạng thái mới sau khi thay đổi. + /// + public SampleStatus NewStatus { get; } + + public SampleStatusChangedDomainEvent( + Guid sampleId, + SampleStatus previousStatus, + SampleStatus newStatus) + { + SampleId = sampleId; + PreviousStatus = previousStatus; + NewStatus = newStatus; + } +} diff --git a/services/promotion-service-net/src/PromotionService.Domain/Exceptions/DomainException.cs b/services/promotion-service-net/src/PromotionService.Domain/Exceptions/DomainException.cs new file mode 100644 index 00000000..64c4a352 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/Exceptions/DomainException.cs @@ -0,0 +1,21 @@ +namespace PromotionService.Domain.Exceptions; + +/// +/// EN: Base exception for domain errors. +/// VI: Exception cơ sở cho các lỗi domain. +/// +public class DomainException : Exception +{ + public DomainException() + { + } + + public DomainException(string message) : base(message) + { + } + + public DomainException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/services/promotion-service-net/src/PromotionService.Domain/Exceptions/PromotionExceptions.cs b/services/promotion-service-net/src/PromotionService.Domain/Exceptions/PromotionExceptions.cs new file mode 100644 index 00000000..8b79c47b --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/Exceptions/PromotionExceptions.cs @@ -0,0 +1,80 @@ +namespace PromotionService.Domain.Exceptions; + +/// +/// EN: Base exception for Promotion domain errors. +/// VI: Exception cơ sở cho lỗi domain Promotion. +/// +public class PromotionDomainException : Exception +{ + public PromotionDomainException() { } + public PromotionDomainException(string message) : base(message) { } + public PromotionDomainException(string message, Exception innerException) : base(message, innerException) { } +} + +/// +/// EN: Exception when trying to operate on a non-active campaign. +/// VI: Exception khi cố thao tác trên chiến dịch không hoạt động. +/// +public class CampaignNotActiveException : PromotionDomainException +{ + public Guid CampaignId { get; } + + public CampaignNotActiveException(Guid campaignId) + : base($"Campaign {campaignId} is not active") + { + CampaignId = campaignId; + } +} + +/// +/// EN: Exception when a voucher has already been claimed. +/// VI: Exception khi voucher đã được nhận. +/// +public class VoucherAlreadyClaimedException : PromotionDomainException +{ + public Guid VoucherId { get; } + public string VoucherCode { get; } + + public VoucherAlreadyClaimedException(Guid voucherId, string code) + : base($"Voucher {code} has already been claimed") + { + VoucherId = voucherId; + VoucherCode = code; + } +} + +/// +/// EN: Exception when a voucher has expired. +/// VI: Exception khi voucher đã hết hạn. +/// +public class VoucherExpiredException : PromotionDomainException +{ + public Guid VoucherId { get; } + public string VoucherCode { get; } + + public VoucherExpiredException(Guid voucherId, string code) + : base($"Voucher {code} has expired") + { + VoucherId = voucherId; + VoucherCode = code; + } +} + +/// +/// EN: Exception when voucher has insufficient value for redemption. +/// VI: Exception khi voucher không đủ giá trị để sử dụng. +/// +public class InsufficientVoucherValueException : PromotionDomainException +{ + public Guid VoucherId { get; } + public decimal RemainingValue { get; } + public decimal RequestedAmount { get; } + + public InsufficientVoucherValueException(Guid voucherId, decimal remaining, decimal requested) + : base($"Voucher has insufficient value. Remaining: {remaining}, Requested: {requested}") + { + VoucherId = voucherId; + RemainingValue = remaining; + RequestedAmount = requested; + } +} diff --git a/services/promotion-service-net/src/PromotionService.Domain/Exceptions/SampleDomainException.cs b/services/promotion-service-net/src/PromotionService.Domain/Exceptions/SampleDomainException.cs new file mode 100644 index 00000000..7009e045 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/Exceptions/SampleDomainException.cs @@ -0,0 +1,21 @@ +namespace PromotionService.Domain.Exceptions; + +/// +/// EN: Exception for Sample aggregate domain errors. +/// VI: Exception cho các lỗi domain của Sample aggregate. +/// +public class SampleDomainException : DomainException +{ + public SampleDomainException() + { + } + + public SampleDomainException(string message) : base(message) + { + } + + public SampleDomainException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/services/promotion-service-net/src/PromotionService.Domain/PromotionService.Domain.csproj b/services/promotion-service-net/src/PromotionService.Domain/PromotionService.Domain.csproj new file mode 100644 index 00000000..f3ebeca4 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/PromotionService.Domain.csproj @@ -0,0 +1,14 @@ + + + + PromotionService.Domain + PromotionService.Domain + Domain layer containing core business logic and entities + + + + + + + + diff --git a/services/promotion-service-net/src/PromotionService.Domain/SeedWork/Entity.cs b/services/promotion-service-net/src/PromotionService.Domain/SeedWork/Entity.cs new file mode 100644 index 00000000..c36c2679 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/SeedWork/Entity.cs @@ -0,0 +1,102 @@ +using MediatR; + +namespace PromotionService.Domain.SeedWork; + +/// +/// EN: Base class for all domain entities. +/// VI: Lớp cơ sở cho tất cả các entity trong domain. +/// +public abstract class Entity +{ + private int? _requestedHashCode; + private Guid _id; + private List _domainEvents = new(); + + /// + /// EN: Unique identifier for the entity. + /// VI: Định danh duy nhất cho entity. + /// + public virtual Guid Id + { + get => _id; + protected set => _id = value; + } + + /// + /// EN: Domain events raised by this entity. + /// VI: Các domain event được phát ra bởi entity này. + /// + public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); + + /// + /// EN: Add a domain event to be dispatched. + /// VI: Thêm một domain event để dispatch. + /// + public void AddDomainEvent(INotification eventItem) + { + _domainEvents.Add(eventItem); + } + + /// + /// EN: Remove a domain event. + /// VI: Xóa một domain event. + /// + public void RemoveDomainEvent(INotification eventItem) + { + _domainEvents.Remove(eventItem); + } + + /// + /// EN: Clear all domain events. + /// VI: Xóa tất cả domain events. + /// + public void ClearDomainEvents() + { + _domainEvents.Clear(); + } + + /// + /// EN: Check if entity is transient (not persisted yet). + /// VI: Kiểm tra xem entity có phải là transient (chưa lưu) không. + /// + public bool IsTransient() + { + return Id == default; + } + + public override bool Equals(object? obj) + { + if (obj is not Entity item) + return false; + + if (ReferenceEquals(this, item)) + return true; + + if (GetType() != item.GetType()) + return false; + + if (item.IsTransient() || IsTransient()) + return false; + + return item.Id == Id; + } + + public override int GetHashCode() + { + if (IsTransient()) + return base.GetHashCode(); + + _requestedHashCode ??= Id.GetHashCode() ^ 31; + return _requestedHashCode.Value; + } + + public static bool operator ==(Entity? left, Entity? right) + { + return left?.Equals(right) ?? right is null; + } + + public static bool operator !=(Entity? left, Entity? right) + { + return !(left == right); + } +} diff --git a/services/promotion-service-net/src/PromotionService.Domain/SeedWork/Enumeration.cs b/services/promotion-service-net/src/PromotionService.Domain/SeedWork/Enumeration.cs new file mode 100644 index 00000000..610905f8 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/SeedWork/Enumeration.cs @@ -0,0 +1,95 @@ +using System.Reflection; + +namespace PromotionService.Domain.SeedWork; + +/// +/// EN: Base class for enumeration classes (type-safe enum pattern). +/// VI: Lớp cơ sở cho các lớp enumeration (pattern enum an toàn kiểu). +/// +/// +/// EN: This provides a type-safe alternative to enums with additional functionality +/// like validation, parsing, and rich behavior. +/// VI: Cung cấp một thay thế an toàn kiểu cho enums với các chức năng bổ sung +/// như validation, parsing, và hành vi phong phú. +/// +public abstract class Enumeration : IComparable +{ + /// + /// EN: The name of the enumeration value. + /// VI: Tên của giá trị enumeration. + /// + public string Name { get; private set; } + + /// + /// EN: The unique identifier of the enumeration value. + /// VI: Định danh duy nhất của giá trị enumeration. + /// + public int Id { get; private set; } + + protected Enumeration(int id, string name) => (Id, Name) = (id, name); + + public override string ToString() => Name; + + /// + /// EN: Get all enumeration values of a given type. + /// VI: Lấy tất cả các giá trị enumeration của một kiểu cho trước. + /// + public static IEnumerable GetAll() where T : Enumeration => + typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Select(f => f.GetValue(null)) + .Cast(); + + public override bool Equals(object? obj) + { + if (obj is not Enumeration otherValue) + return false; + + var typeMatches = GetType() == obj.GetType(); + var valueMatches = Id.Equals(otherValue.Id); + + return typeMatches && valueMatches; + } + + public override int GetHashCode() => Id.GetHashCode(); + + /// + /// EN: Get absolute difference between two enumeration values. + /// VI: Lấy sự khác biệt tuyệt đối giữa hai giá trị enumeration. + /// + public static int AbsoluteDifference(Enumeration firstValue, Enumeration secondValue) + { + return Math.Abs(firstValue.Id - secondValue.Id); + } + + /// + /// EN: Parse an integer ID to the corresponding enumeration value. + /// VI: Parse một ID integer thành giá trị enumeration tương ứng. + /// + public static T FromValue(int value) where T : Enumeration + { + var matchingItem = Parse(value, "value", item => item.Id == value); + return matchingItem; + } + + /// + /// EN: Parse a display name to the corresponding enumeration value. + /// VI: Parse một tên hiển thị thành giá trị enumeration tương ứng. + /// + public static T FromDisplayName(string displayName) where T : Enumeration + { + var matchingItem = Parse(displayName, "display name", item => item.Name == displayName); + return matchingItem; + } + + private static T Parse(TValue value, string description, Func predicate) where T : Enumeration + { + var matchingItem = GetAll().FirstOrDefault(predicate); + + if (matchingItem is null) + throw new InvalidOperationException($"'{value}' is not a valid {description} in {typeof(T)}"); + + return matchingItem; + } + + public int CompareTo(object? other) => Id.CompareTo(((Enumeration)other!).Id); +} diff --git a/services/promotion-service-net/src/PromotionService.Domain/SeedWork/IAggregateRoot.cs b/services/promotion-service-net/src/PromotionService.Domain/SeedWork/IAggregateRoot.cs new file mode 100644 index 00000000..c7844a53 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/SeedWork/IAggregateRoot.cs @@ -0,0 +1,15 @@ +namespace PromotionService.Domain.SeedWork; + +/// +/// EN: Marker interface for aggregate roots. +/// VI: Interface đánh dấu cho aggregate roots. +/// +/// +/// EN: Aggregate roots are the entry points to aggregates and are the only objects +/// that outside code should hold references to. +/// VI: Aggregate roots là điểm vào của aggregates và là đối tượng duy nhất +/// mà code bên ngoài nên giữ tham chiếu đến. +/// +public interface IAggregateRoot +{ +} diff --git a/services/promotion-service-net/src/PromotionService.Domain/SeedWork/IRepository.cs b/services/promotion-service-net/src/PromotionService.Domain/SeedWork/IRepository.cs new file mode 100644 index 00000000..635b2e53 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/SeedWork/IRepository.cs @@ -0,0 +1,15 @@ +namespace PromotionService.Domain.SeedWork; + +/// +/// EN: Generic repository interface for aggregate roots. +/// VI: Interface repository generic cho aggregate roots. +/// +/// EN: The aggregate root type / VI: Kiểu aggregate root +public interface IRepository where T : IAggregateRoot +{ + /// + /// EN: The unit of work for this repository. + /// VI: Unit of work cho repository này. + /// + IUnitOfWork UnitOfWork { get; } +} diff --git a/services/promotion-service-net/src/PromotionService.Domain/SeedWork/IUnitOfWork.cs b/services/promotion-service-net/src/PromotionService.Domain/SeedWork/IUnitOfWork.cs new file mode 100644 index 00000000..6a2217da --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/SeedWork/IUnitOfWork.cs @@ -0,0 +1,30 @@ +namespace PromotionService.Domain.SeedWork; + +/// +/// EN: Unit of Work pattern interface. +/// VI: Interface cho Unit of Work pattern. +/// +/// +/// EN: Maintains a list of objects affected by a business transaction +/// and coordinates the writing out of changes. +/// VI: Duy trì danh sách các đối tượng bị ảnh hưởng bởi một transaction nghiệp vụ +/// và điều phối việc ghi các thay đổi. +/// +public interface IUnitOfWork : IDisposable +{ + /// + /// EN: Save all changes made in this unit of work. + /// VI: Lưu tất cả các thay đổi được thực hiện trong unit of work này. + /// + /// EN: Cancellation token / VI: Token hủy + /// EN: Number of entities written / VI: Số entity đã ghi + Task SaveChangesAsync(CancellationToken cancellationToken = default); + + /// + /// EN: Save all changes and dispatch domain events. + /// VI: Lưu tất cả thay đổi và dispatch domain events. + /// + /// EN: Cancellation token / VI: Token hủy + /// EN: True if successful / VI: True nếu thành công + Task SaveEntitiesAsync(CancellationToken cancellationToken = default); +} diff --git a/services/promotion-service-net/src/PromotionService.Domain/SeedWork/ValueObject.cs b/services/promotion-service-net/src/PromotionService.Domain/SeedWork/ValueObject.cs new file mode 100644 index 00000000..fd714e0c --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Domain/SeedWork/ValueObject.cs @@ -0,0 +1,53 @@ +namespace PromotionService.Domain.SeedWork; + +/// +/// EN: Base class for Value Objects following DDD patterns. +/// VI: Lớp cơ sở cho Value Objects theo mẫu DDD. +/// +/// +/// EN: Value objects are immutable and compared by their values, not identity. +/// VI: Value objects là bất biến và được so sánh theo giá trị, không phải định danh. +/// +public abstract class ValueObject +{ + /// + /// EN: Get the atomic values that make up this value object. + /// VI: Lấy các giá trị nguyên tử tạo nên value object này. + /// + protected abstract IEnumerable GetEqualityComponents(); + + public override bool Equals(object? obj) + { + if (obj is null || obj.GetType() != GetType()) + return false; + + var other = (ValueObject)obj; + return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents()); + } + + public override int GetHashCode() + { + return GetEqualityComponents() + .Select(x => x?.GetHashCode() ?? 0) + .Aggregate((x, y) => x ^ y); + } + + public static bool operator ==(ValueObject? left, ValueObject? right) + { + return left?.Equals(right) ?? right is null; + } + + public static bool operator !=(ValueObject? left, ValueObject? right) + { + return !(left == right); + } + + /// + /// EN: Create a copy of this value object with modifications. + /// VI: Tạo bản sao của value object này với các thay đổi. + /// + protected ValueObject GetCopy() + { + return (ValueObject)MemberwiseClone(); + } +} diff --git a/services/promotion-service-net/src/PromotionService.Infrastructure/DependencyInjection.cs b/services/promotion-service-net/src/PromotionService.Infrastructure/DependencyInjection.cs new file mode 100644 index 00000000..d1c42a77 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Infrastructure/DependencyInjection.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using PromotionService.Domain.AggregatesModel.SampleAggregate; +using PromotionService.Infrastructure.Idempotency; +using PromotionService.Infrastructure.Repositories; + +namespace PromotionService.Infrastructure; + +/// +/// EN: Dependency injection extensions for Infrastructure layer. +/// VI: Extensions dependency injection cho lớp Infrastructure. +/// +public static class DependencyInjection +{ + /// + /// EN: Add infrastructure services to the DI container. + /// VI: Thêm các services infrastructure vào DI container. + /// + public static IServiceCollection AddInfrastructure( + this IServiceCollection services, + IConfiguration configuration) + { + // EN: Add DbContext with PostgreSQL / VI: Thêm DbContext với PostgreSQL + services.AddDbContext(options => + { + var connectionString = configuration.GetConnectionString("DefaultConnection") + ?? configuration["DATABASE_URL"] + ?? throw new InvalidOperationException("Connection string not configured"); + + options.UseNpgsql(connectionString, npgsqlOptions => + { + npgsqlOptions.MigrationsAssembly(typeof(PromotionServiceContext).Assembly.FullName); + npgsqlOptions.EnableRetryOnFailure( + maxRetryCount: 5, + maxRetryDelay: TimeSpan.FromSeconds(30), + errorCodesToAdd: null); + }); + + // EN: Enable sensitive data logging in development only + // VI: Chỉ bật sensitive data logging trong development + if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development") + { + options.EnableSensitiveDataLogging(); + options.EnableDetailedErrors(); + } + }); + + // EN: Register repositories / VI: Đăng ký repositories + services.AddScoped(); + + // EN: Register idempotency services / VI: Đăng ký idempotency services + services.AddScoped(); + + return services; + } +} diff --git a/services/promotion-service-net/src/PromotionService.Infrastructure/EntityConfigurations/SampleEntityTypeConfiguration.cs b/services/promotion-service-net/src/PromotionService.Infrastructure/EntityConfigurations/SampleEntityTypeConfiguration.cs new file mode 100644 index 00000000..44de9559 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Infrastructure/EntityConfigurations/SampleEntityTypeConfiguration.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PromotionService.Domain.AggregatesModel.SampleAggregate; + +namespace PromotionService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core configuration for Sample entity. +/// VI: Cấu hình EF Core cho entity Sample. +/// +public class SampleEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // EN: Table name / VI: Tên bảng + builder.ToTable("samples"); + + // EN: Primary key / VI: Khóa chính + builder.HasKey(s => s.Id); + + // EN: Ignore domain events (not persisted) + // VI: Bỏ qua domain events (không lưu) + builder.Ignore(s => s.DomainEvents); + + // EN: Properties / VI: Các thuộc tính + builder.Property(s => s.Id) + .HasColumnName("id") + .IsRequired(); + + builder.Property("_name") + .HasColumnName("name") + .HasMaxLength(200) + .IsRequired(); + + builder.Property("_description") + .HasColumnName("description") + .HasMaxLength(1000); + + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + builder.Property("_updatedAt") + .HasColumnName("updated_at"); + + // EN: Status relationship / VI: Quan hệ với Status + builder.Property(s => s.StatusId) + .HasColumnName("status_id") + .IsRequired(); + + builder.HasOne(s => s.Status) + .WithMany() + .HasForeignKey(s => s.StatusId) + .OnDelete(DeleteBehavior.Restrict); + + // EN: Indexes / VI: Các index + builder.HasIndex("_name"); + builder.HasIndex(s => s.StatusId); + builder.HasIndex("_createdAt"); + } +} diff --git a/services/promotion-service-net/src/PromotionService.Infrastructure/EntityConfigurations/SampleStatusEntityTypeConfiguration.cs b/services/promotion-service-net/src/PromotionService.Infrastructure/EntityConfigurations/SampleStatusEntityTypeConfiguration.cs new file mode 100644 index 00000000..0c446fb6 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Infrastructure/EntityConfigurations/SampleStatusEntityTypeConfiguration.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PromotionService.Domain.AggregatesModel.SampleAggregate; + +namespace PromotionService.Infrastructure.EntityConfigurations; + +/// +/// EN: EF Core configuration for SampleStatus enumeration. +/// VI: Cấu hình EF Core cho enumeration SampleStatus. +/// +public class SampleStatusEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // EN: Table name / VI: Tên bảng + builder.ToTable("sample_statuses"); + + // EN: Primary key / VI: Khóa chính + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id) + .HasColumnName("id") + .ValueGeneratedNever() + .IsRequired(); + + builder.Property(s => s.Name) + .HasColumnName("name") + .HasMaxLength(50) + .IsRequired(); + + // EN: Seed initial data / VI: Seed dữ liệu ban đầu + builder.HasData( + SampleStatus.Draft, + SampleStatus.Active, + SampleStatus.Completed, + SampleStatus.Cancelled + ); + } +} diff --git a/services/promotion-service-net/src/PromotionService.Infrastructure/Idempotency/ClientRequest.cs b/services/promotion-service-net/src/PromotionService.Infrastructure/Idempotency/ClientRequest.cs new file mode 100644 index 00000000..0fe2fde7 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Infrastructure/Idempotency/ClientRequest.cs @@ -0,0 +1,26 @@ +namespace PromotionService.Infrastructure.Idempotency; + +/// +/// EN: Entity for tracking client requests to ensure idempotency. +/// VI: Entity để theo dõi các requests từ client đảm bảo idempotency. +/// +public class ClientRequest +{ + /// + /// EN: Unique request identifier. + /// VI: Định danh request duy nhất. + /// + public Guid Id { get; set; } + + /// + /// EN: Name of the command/request type. + /// VI: Tên của loại command/request. + /// + public string Name { get; set; } = null!; + + /// + /// EN: Timestamp when the request was received. + /// VI: Thời điểm request được nhận. + /// + public DateTime Time { get; set; } +} diff --git a/services/promotion-service-net/src/PromotionService.Infrastructure/Idempotency/IRequestManager.cs b/services/promotion-service-net/src/PromotionService.Infrastructure/Idempotency/IRequestManager.cs new file mode 100644 index 00000000..3c4eea0b --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Infrastructure/Idempotency/IRequestManager.cs @@ -0,0 +1,24 @@ +namespace PromotionService.Infrastructure.Idempotency; + +/// +/// EN: Interface for managing client request idempotency. +/// VI: Interface để quản lý idempotency của client requests. +/// +public interface IRequestManager +{ + /// + /// EN: Check if a request with the given ID exists. + /// VI: Kiểm tra xem request với ID cho trước có tồn tại không. + /// + /// EN: Request ID / VI: ID của request + /// EN: True if exists / VI: True nếu tồn tại + Task ExistAsync(Guid id); + + /// + /// EN: Create a new request record for tracking. + /// VI: Tạo bản ghi request mới để theo dõi. + /// + /// EN: Command type / VI: Loại command + /// EN: Request ID / VI: ID của request + Task CreateRequestForCommandAsync(Guid id); +} diff --git a/services/promotion-service-net/src/PromotionService.Infrastructure/Idempotency/RequestManager.cs b/services/promotion-service-net/src/PromotionService.Infrastructure/Idempotency/RequestManager.cs new file mode 100644 index 00000000..ad802d43 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Infrastructure/Idempotency/RequestManager.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; + +namespace PromotionService.Infrastructure.Idempotency; + +/// +/// EN: Implementation of request manager for idempotency. +/// VI: Triển khai request manager cho idempotency. +/// +public class RequestManager : IRequestManager +{ + private readonly PromotionServiceContext _context; + + public RequestManager(PromotionServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public async Task ExistAsync(Guid id) + { + var request = await _context + .FindAsync(id); + + return request != null; + } + + /// + public async Task CreateRequestForCommandAsync(Guid id) + { + var exists = await ExistAsync(id); + + var request = exists + ? throw new InvalidOperationException($"Request with {id} already exists") + : new ClientRequest + { + Id = id, + Name = typeof(T).Name, + Time = DateTime.UtcNow + }; + + _context.Add(request); + + await _context.SaveChangesAsync(); + } +} diff --git a/services/promotion-service-net/src/PromotionService.Infrastructure/MyServiceContext.cs b/services/promotion-service-net/src/PromotionService.Infrastructure/MyServiceContext.cs new file mode 100644 index 00000000..8ec15936 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Infrastructure/MyServiceContext.cs @@ -0,0 +1,160 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using PromotionService.Domain.AggregatesModel.SampleAggregate; +using PromotionService.Domain.SeedWork; +using PromotionService.Infrastructure.EntityConfigurations; + +namespace PromotionService.Infrastructure; + +/// +/// EN: EF Core DbContext for PromotionService. +/// VI: EF Core DbContext cho PromotionService. +/// +public class PromotionServiceContext : DbContext, IUnitOfWork +{ + private readonly IMediator _mediator; + private IDbContextTransaction? _currentTransaction; + + /// + /// EN: Samples table. + /// VI: Bảng Samples. + /// + public DbSet Samples => Set(); + + /// + /// EN: Read-only access to current transaction. + /// VI: Truy cập chỉ đọc đến transaction hiện tại. + /// + public IDbContextTransaction? CurrentTransaction => _currentTransaction; + + /// + /// EN: Check if there is an active transaction. + /// VI: Kiểm tra xem có transaction đang hoạt động không. + /// + public bool HasActiveTransaction => _currentTransaction != null; + + public PromotionServiceContext(DbContextOptions options) : base(options) + { + _mediator = null!; + } + + public PromotionServiceContext(DbContextOptions options, IMediator mediator) : base(options) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + + System.Diagnostics.Debug.WriteLine("PromotionServiceContext::ctor - " + GetHashCode()); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // EN: Apply entity configurations + // VI: Áp dụng các cấu hình entity + modelBuilder.ApplyConfiguration(new SampleEntityTypeConfiguration()); + modelBuilder.ApplyConfiguration(new SampleStatusEntityTypeConfiguration()); + } + + /// + /// EN: Save entities and dispatch domain events. + /// VI: Lưu entities và dispatch domain events. + /// + public async Task SaveEntitiesAsync(CancellationToken cancellationToken = default) + { + // EN: Dispatch domain events before saving (side effects) + // VI: Dispatch domain events trước khi lưu (side effects) + await DispatchDomainEventsAsync(); + + // EN: Save changes to database + // VI: Lưu thay đổi vào database + await base.SaveChangesAsync(cancellationToken); + + return true; + } + + /// + /// EN: Begin a new transaction if none is active. + /// VI: Bắt đầu một transaction mới nếu không có transaction nào đang hoạt động. + /// + public async Task BeginTransactionAsync() + { + if (_currentTransaction != null) return null; + + _currentTransaction = await Database.BeginTransactionAsync(System.Data.IsolationLevel.ReadCommitted); + + return _currentTransaction; + } + + /// + /// EN: Commit the current transaction. + /// VI: Commit transaction hiện tại. + /// + public async Task CommitTransactionAsync(IDbContextTransaction transaction) + { + ArgumentNullException.ThrowIfNull(transaction); + + if (transaction != _currentTransaction) + throw new InvalidOperationException($"Transaction {transaction.TransactionId} is not current"); + + try + { + await SaveChangesAsync(); + await transaction.CommitAsync(); + } + catch + { + RollbackTransaction(); + throw; + } + finally + { + if (_currentTransaction != null) + { + _currentTransaction.Dispose(); + _currentTransaction = null; + } + } + } + + /// + /// EN: Rollback the current transaction. + /// VI: Rollback transaction hiện tại. + /// + public void RollbackTransaction() + { + try + { + _currentTransaction?.Rollback(); + } + finally + { + if (_currentTransaction != null) + { + _currentTransaction.Dispose(); + _currentTransaction = null; + } + } + } + + /// + /// EN: Dispatch all domain events from tracked entities. + /// VI: Dispatch tất cả domain events từ các entities đang được track. + /// + private async Task DispatchDomainEventsAsync() + { + var domainEntities = ChangeTracker + .Entries() + .Where(x => x.Entity.DomainEvents.Any()) + .ToList(); + + var domainEvents = domainEntities + .SelectMany(x => x.Entity.DomainEvents) + .ToList(); + + domainEntities.ForEach(entity => entity.Entity.ClearDomainEvents()); + + foreach (var domainEvent in domainEvents) + { + await _mediator.Publish(domainEvent); + } + } +} diff --git a/services/promotion-service-net/src/PromotionService.Infrastructure/PromotionService.Infrastructure.csproj b/services/promotion-service-net/src/PromotionService.Infrastructure/PromotionService.Infrastructure.csproj new file mode 100644 index 00000000..9fc1c41c --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Infrastructure/PromotionService.Infrastructure.csproj @@ -0,0 +1,36 @@ + + + + PromotionService.Infrastructure + PromotionService.Infrastructure + Infrastructure layer for data access and external services + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + diff --git a/services/promotion-service-net/src/PromotionService.Infrastructure/Repositories/SampleRepository.cs b/services/promotion-service-net/src/PromotionService.Infrastructure/Repositories/SampleRepository.cs new file mode 100644 index 00000000..215dd436 --- /dev/null +++ b/services/promotion-service-net/src/PromotionService.Infrastructure/Repositories/SampleRepository.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore; +using PromotionService.Domain.AggregatesModel.SampleAggregate; +using PromotionService.Domain.SeedWork; + +namespace PromotionService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for Sample aggregate. +/// VI: Triển khai repository cho Sample aggregate. +/// +public class SampleRepository : ISampleRepository +{ + private readonly PromotionServiceContext _context; + + /// + /// EN: Unit of work for transaction management. + /// VI: Unit of work cho quản lý transaction. + /// + public IUnitOfWork UnitOfWork => _context; + + public SampleRepository(PromotionServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public async Task GetAsync(Guid sampleId) + { + var sample = await _context.Samples + .Include(s => s.Status) + .FirstOrDefaultAsync(s => s.Id == sampleId); + + return sample; + } + + /// + public async Task> GetAllAsync() + { + return await _context.Samples + .Include(s => s.Status) + .OrderByDescending(s => s.CreatedAt) + .ToListAsync(); + } + + /// + public Sample Add(Sample sample) + { + return _context.Samples.Add(sample).Entity; + } + + /// + public void Update(Sample sample) + { + _context.Entry(sample).State = EntityState.Modified; + } + + /// + public void Delete(Sample sample) + { + _context.Samples.Remove(sample); + } + + /// + public async Task> GetByStatusAsync(int statusId) + { + return await _context.Samples + .Include(s => s.Status) + .Where(s => s.StatusId == statusId) + .OrderByDescending(s => s.CreatedAt) + .ToListAsync(); + } +} diff --git a/services/promotion-service-net/tests/PromotionService.FunctionalTests/Controllers/SamplesControllerTests.cs b/services/promotion-service-net/tests/PromotionService.FunctionalTests/Controllers/SamplesControllerTests.cs new file mode 100644 index 00000000..aad48206 --- /dev/null +++ b/services/promotion-service-net/tests/PromotionService.FunctionalTests/Controllers/SamplesControllerTests.cs @@ -0,0 +1,80 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace PromotionService.FunctionalTests.Controllers; + +/// +/// EN: Functional tests for Samples API endpoints. +/// VI: Functional tests cho các endpoints API Samples. +/// +public class SamplesControllerTests : IClassFixture +{ + private readonly HttpClient _client; + + public SamplesControllerTests(CustomWebApplicationFactory factory) + { + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + [Fact] + public async Task GetSamples_ShouldReturnOkWithEmptyList() + { + // Act + var response = await _client.GetAsync("/api/v1/samples"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadFromJsonAsync>>(); + content?.Success.Should().BeTrue(); + } + + [Fact] + public async Task CreateSample_WithValidData_ShouldReturnCreated() + { + // Arrange + var request = new { Name = "Test Sample", Description = "Test Description" }; + + // Act + var response = await _client.PostAsJsonAsync("/api/v1/samples", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + var content = await response.Content.ReadFromJsonAsync>(); + content?.Success.Should().BeTrue(); + content?.Data?.Id.Should().NotBeEmpty(); + } + + [Fact] + public async Task GetSample_WithInvalidId_ShouldReturnNotFound() + { + // Arrange + var invalidId = Guid.NewGuid(); + + // Act + var response = await _client.GetAsync($"/api/v1/samples/{invalidId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task HealthCheck_ShouldReturnHealthy() + { + // Act + var response = await _client.GetAsync("/health/live"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + // EN: Helper DTOs for deserialization + // VI: Helper DTOs để deserialize + private record ApiResponse(bool Success, T? Data); + private record CreateSampleResult(Guid Id); +} diff --git a/services/promotion-service-net/tests/PromotionService.FunctionalTests/CustomWebApplicationFactory.cs b/services/promotion-service-net/tests/PromotionService.FunctionalTests/CustomWebApplicationFactory.cs new file mode 100644 index 00000000..e86c82f0 --- /dev/null +++ b/services/promotion-service-net/tests/PromotionService.FunctionalTests/CustomWebApplicationFactory.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using PromotionService.Infrastructure; + +namespace PromotionService.FunctionalTests; + +/// +/// EN: Custom WebApplicationFactory for functional tests. +/// VI: WebApplicationFactory tùy chỉnh cho functional tests. +/// +public class CustomWebApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + builder.ConfigureServices(services => + { + // EN: Remove the existing DbContext registration + // VI: Xóa đăng ký DbContext hiện tại + var descriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbContextOptions)); + + if (descriptor != null) + { + services.Remove(descriptor); + } + + // EN: Remove DbContext service + // VI: Xóa DbContext service + var dbContextDescriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(PromotionServiceContext)); + + if (dbContextDescriptor != null) + { + services.Remove(dbContextDescriptor); + } + + // EN: Add in-memory database for testing + // VI: Thêm in-memory database để test + services.AddDbContext(options => + { + options.UseInMemoryDatabase("TestDatabase_" + Guid.NewGuid().ToString()); + }); + + // EN: Ensure database is created with seed data + // VI: Đảm bảo database được tạo với seed data + var sp = services.BuildServiceProvider(); + using var scope = sp.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + }); + } +} diff --git a/services/promotion-service-net/tests/PromotionService.FunctionalTests/PromotionService.FunctionalTests.csproj b/services/promotion-service-net/tests/PromotionService.FunctionalTests/PromotionService.FunctionalTests.csproj new file mode 100644 index 00000000..7c8a1d1d --- /dev/null +++ b/services/promotion-service-net/tests/PromotionService.FunctionalTests/PromotionService.FunctionalTests.csproj @@ -0,0 +1,38 @@ + + + + PromotionService.FunctionalTests + PromotionService.FunctionalTests + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/services/promotion-service-net/tests/PromotionService.UnitTests/Application/CreateSampleCommandHandlerTests.cs b/services/promotion-service-net/tests/PromotionService.UnitTests/Application/CreateSampleCommandHandlerTests.cs new file mode 100644 index 00000000..22d602de --- /dev/null +++ b/services/promotion-service-net/tests/PromotionService.UnitTests/Application/CreateSampleCommandHandlerTests.cs @@ -0,0 +1,65 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using PromotionService.API.Application.Commands; +using PromotionService.Domain.AggregatesModel.SampleAggregate; +using PromotionService.Domain.SeedWork; +using Xunit; + +namespace PromotionService.UnitTests.Application; + +/// +/// EN: Unit tests for CreateSampleCommandHandler. +/// VI: Unit tests cho CreateSampleCommandHandler. +/// +public class CreateSampleCommandHandlerTests +{ + private readonly Mock _mockRepository; + private readonly Mock> _mockLogger; + private readonly CreateSampleCommandHandler _handler; + + public CreateSampleCommandHandlerTests() + { + _mockRepository = new Mock(); + _mockLogger = new Mock>(); + + var mockUnitOfWork = new Mock(); + mockUnitOfWork.Setup(u => u.SaveEntitiesAsync(It.IsAny())) + .ReturnsAsync(true); + + _mockRepository.SetupGet(r => r.UnitOfWork).Returns(mockUnitOfWork.Object); + + _handler = new CreateSampleCommandHandler(_mockRepository.Object, _mockLogger.Object); + } + + [Fact] + public async Task Handle_WithValidCommand_ShouldCreateSampleAndReturnId() + { + // Arrange + var command = new CreateSampleCommand("Test Sample", "Test Description"); + + _mockRepository.Setup(r => r.Add(It.IsAny())) + .Returns((Sample s) => s); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.Id.Should().NotBeEmpty(); + _mockRepository.Verify(r => r.Add(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithValidCommand_ShouldCallSaveEntities() + { + // Arrange + var command = new CreateSampleCommand("Test Sample", null); + + // Act + await _handler.Handle(command, CancellationToken.None); + + // Assert + _mockRepository.Verify(r => r.UnitOfWork.SaveEntitiesAsync(It.IsAny()), Times.Once); + } +} diff --git a/services/promotion-service-net/tests/PromotionService.UnitTests/Domain/SampleAggregateTests.cs b/services/promotion-service-net/tests/PromotionService.UnitTests/Domain/SampleAggregateTests.cs new file mode 100644 index 00000000..d73caa4f --- /dev/null +++ b/services/promotion-service-net/tests/PromotionService.UnitTests/Domain/SampleAggregateTests.cs @@ -0,0 +1,151 @@ +using FluentAssertions; +using PromotionService.Domain.AggregatesModel.SampleAggregate; +using PromotionService.Domain.Exceptions; +using Xunit; + +namespace PromotionService.UnitTests.Domain; + +/// +/// EN: Unit tests for Sample aggregate. +/// VI: Unit tests cho Sample aggregate. +/// +public class SampleAggregateTests +{ + [Fact] + public void CreateSample_WithValidName_ShouldCreateWithDraftStatus() + { + // Arrange + var name = "Test Sample"; + var description = "Test Description"; + + // Act + var sample = new Sample(name, description); + + // Assert + sample.Name.Should().Be(name); + sample.Description.Should().Be(description); + sample.Status.Should().Be(SampleStatus.Draft); + sample.Id.Should().NotBeEmpty(); + sample.DomainEvents.Should().ContainSingle(); // SampleCreatedDomainEvent + } + + [Fact] + public void CreateSample_WithEmptyName_ShouldThrowException() + { + // Arrange + var name = ""; + + // Act + var act = () => new Sample(name); + + // Assert + act.Should().Throw() + .WithMessage("Sample name cannot be empty"); + } + + [Fact] + public void Activate_WhenDraft_ShouldChangeToActive() + { + // Arrange + var sample = new Sample("Test Sample"); + sample.ClearDomainEvents(); + + // Act + sample.Activate(); + + // Assert + sample.Status.Should().Be(SampleStatus.Active); + sample.DomainEvents.Should().ContainSingle(); // SampleStatusChangedDomainEvent + } + + [Fact] + public void Activate_WhenNotDraft_ShouldThrowException() + { + // Arrange + var sample = new Sample("Test Sample"); + sample.Activate(); + + // Act + var act = () => sample.Activate(); + + // Assert + act.Should().Throw() + .WithMessage("Only draft samples can be activated"); + } + + [Fact] + public void Complete_WhenActive_ShouldChangeToCompleted() + { + // Arrange + var sample = new Sample("Test Sample"); + sample.Activate(); + sample.ClearDomainEvents(); + + // Act + sample.Complete(); + + // Assert + sample.Status.Should().Be(SampleStatus.Completed); + } + + [Fact] + public void Cancel_WhenDraftOrActive_ShouldChangeToCancelled() + { + // Arrange + var sample = new Sample("Test Sample"); + + // Act + sample.Cancel(); + + // Assert + sample.Status.Should().Be(SampleStatus.Cancelled); + } + + [Fact] + public void Cancel_WhenCompleted_ShouldThrowException() + { + // Arrange + var sample = new Sample("Test Sample"); + sample.Activate(); + sample.Complete(); + + // Act + var act = () => sample.Cancel(); + + // Assert + act.Should().Throw() + .WithMessage("Cannot cancel a completed sample"); + } + + [Fact] + public void Update_WhenNotCancelled_ShouldUpdateNameAndDescription() + { + // Arrange + var sample = new Sample("Original Name", "Original Description"); + var newName = "Updated Name"; + var newDescription = "Updated Description"; + + // Act + sample.Update(newName, newDescription); + + // Assert + sample.Name.Should().Be(newName); + sample.Description.Should().Be(newDescription); + sample.UpdatedAt.Should().NotBeNull(); + } + + [Fact] + public void Update_WhenCancelled_ShouldThrowException() + { + // Arrange + var sample = new Sample("Test Sample"); + sample.Cancel(); + + // Act + var act = () => sample.Update("New Name", null); + + // Assert + act.Should().Throw() + .WithMessage("Cannot update a cancelled sample"); + } +} diff --git a/services/promotion-service-net/tests/PromotionService.UnitTests/PromotionService.UnitTests.csproj b/services/promotion-service-net/tests/PromotionService.UnitTests/PromotionService.UnitTests.csproj new file mode 100644 index 00000000..f53c5ba1 --- /dev/null +++ b/services/promotion-service-net/tests/PromotionService.UnitTests/PromotionService.UnitTests.csproj @@ -0,0 +1,35 @@ + + + + PromotionService.UnitTests + PromotionService.UnitTests + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + +