From 580e074145b50072465fbbba73a8a77878e2854d Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 15 Jan 2026 18:14:13 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20Th=C3=AAm=20d=E1=BB=8Bch=20v=E1=BB=A5?= =?UTF-8?q?=20MerchantService=20m=E1=BB=9Bi=20v=C3=A0=20c=E1=BA=ADp=20nh?= =?UTF-8?q?=E1=BA=ADt=20c=C3=A1c=20t=E1=BB=87p=20=C4=91i=E1=BB=81u=20khi?= =?UTF-8?q?=E1=BB=83n=20th=C3=A0nh=20vi=C3=AAn=20trong=20MembershipService?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deployments/local/docker-compose.yml | 2 +- infra/traefik/dynamic/routes.yml | 21 +- .../src/IamService.API/Program.cs | 5 + .../Data/DataSeeder.cs | 78 ++++ services/membership-service-net/Dockerfile | 24 +- .../Commands/AddExperienceCommand.cs | 95 ++++ .../Commands/AddExperienceCommandHandler.cs | 94 ++++ .../Commands/ChangeMembershipLevelCommand.cs | 34 -- .../ChangeMembershipLevelCommandHandler.cs | 54 --- .../Commands/CreateMemberCommand.cs | 8 +- .../Commands/CreateMemberCommandHandler.cs | 9 +- .../Queries/GetExperienceHistoryQuery.cs | 82 ++++ .../GetExperienceHistoryQueryHandler.cs | 50 +++ .../Queries/GetLevelDefinitionsQuery.cs | 46 ++ .../GetLevelDefinitionsQueryHandler.cs | 46 ++ .../Application/Queries/GetMemberByIdQuery.cs | 30 +- .../Queries/GetMemberByIdQueryHandler.cs | 8 +- .../Queries/GetMemberProgressQuery.cs | 84 ++++ .../Queries/GetMemberProgressQueryHandler.cs | 80 ++++ .../Queries/GetMembersQueryHandler.cs | 8 +- .../Controllers/LevelsController.cs | 50 +++ .../Controllers/MembersController.cs | 55 ++- .../MembershipService.API.csproj | 4 + .../ExperienceAggregate/ExperienceSource.cs | 64 +++ .../ExperienceTransaction.cs | 128 ++++++ .../IExperienceTransactionRepository.cs | 69 +++ .../ILevelDefinitionRepository.cs | 58 +++ .../LevelAggregate/LevelBenefit.cs | 146 +++++++ .../LevelAggregate/LevelDefinition.cs | 233 ++++++++++ .../AggregatesModel/MemberAggregate/Member.cs | 140 ++++-- .../MemberExperienceAddedDomainEvent.cs | 62 +++ .../Events/MemberLevelUpDomainEvent.cs | 58 +++ .../DependencyInjection.cs | 4 + ...ienceTransactionEntityTypeConfiguration.cs | 84 ++++ .../LevelBenefitEntityTypeConfiguration.cs | 70 +++ .../LevelDefinitionEntityTypeConfiguration.cs | 97 +++++ .../MemberEntityTypeConfiguration.cs | 35 +- .../MembershipServiceContext.cs | 29 +- .../ExperienceTransactionRepository.cs | 133 ++++++ .../Repositories/LevelDefinitionRepository.cs | 103 +++++ .../Repositories/MemberRepository.cs | 13 +- .../Domain/MemberAggregateTests.cs | 74 +++- services/merchant-service-net/.env.example | 40 ++ services/merchant-service-net/.gitignore | 75 ++++ .../Directory.Build.props | 22 + services/merchant-service-net/Dockerfile | 66 +++ .../merchant-service-net/MerchantService.slnx | 11 + .../merchant-service-net/docker-compose.yml | 72 +++ services/merchant-service-net/global.json | 7 + .../Application/Behaviors/LoggingBehavior.cs | 58 +++ .../Behaviors/TransactionBehavior.cs | 84 ++++ .../Behaviors/ValidatorBehavior.cs | 63 +++ .../MerchantService.API.csproj | 43 ++ .../src/MerchantService.API/Program.cs | 144 ++++++ .../Properties/launchSettings.json | 15 + .../appsettings.Development.json | 19 + .../src/MerchantService.API/appsettings.json | 46 ++ .../MerchantAggregate/BankAccount.cs | 50 +++ .../MerchantAggregate/BusinessInfo.cs | 49 +++ .../MerchantAggregate/IMerchantRepository.cs | 55 +++ .../MerchantAggregate/Merchant.cs | 295 +++++++++++++ .../MerchantAggregate/MerchantStatus.cs | 59 +++ .../MerchantAggregate/MerchantType.cs | 29 ++ .../MerchantAggregate/SettlementConfig.cs | 53 +++ .../MerchantAggregate/SettlementCycle.cs | 35 ++ .../MerchantAggregate/VerificationStatus.cs | 41 ++ .../MerchantStaffAggregate/DeviceToken.cs | 121 ++++++ .../IMerchantStaffRepository.cs | 61 +++ .../MerchantStaffAggregate/MerchantStaff.cs | 409 ++++++++++++++++++ .../MerchantStaffAggregate/ShopMember.cs | 156 +++++++ .../StaffEnumerations.cs | 173 ++++++++ .../ShopAggregate/BusinessCategory.cs | 83 ++++ .../ShopAggregate/IShopRepository.cs | 61 +++ .../AggregatesModel/ShopAggregate/Shop.cs | 349 +++++++++++++++ .../ShopAggregate/ShopBranch.cs | 170 ++++++++ .../ShopAggregate/ShopStatus.cs | 53 +++ .../AggregatesModel/ShopAggregate/ShopType.cs | 41 ++ .../ShopAggregate/ShopValueObjects.cs | 194 +++++++++ .../Events/MerchantDomainEvents.cs | 37 ++ .../Events/ShopDomainEvents.cs | 31 ++ .../Events/StaffDomainEvents.cs | 43 ++ .../Exceptions/DomainException.cs | 21 + .../Exceptions/SampleDomainException.cs | 21 + .../MerchantService.Domain.csproj | 14 + .../MerchantService.Domain/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 | 61 +++ .../Idempotency/ClientRequest.cs | 26 ++ .../Idempotency/IRequestManager.cs | 24 + .../Idempotency/RequestManager.cs | 45 ++ .../MerchantService.Infrastructure.csproj | 36 ++ .../MerchantServiceContext.cs | 194 +++++++++ .../Repositories/MerchantRepository.cs | 77 ++++ .../Repositories/MerchantStaffRepository.cs | 86 ++++ .../Repositories/ShopRepository.cs | 85 ++++ .../CustomWebApplicationFactory.cs | 56 +++ .../MerchantService.FunctionalTests.csproj | 38 ++ .../MerchantService.UnitTests.csproj | 35 ++ 102 files changed, 6892 insertions(+), 217 deletions(-) create mode 100644 services/iam-service-net/src/IamService.Infrastructure/Data/DataSeeder.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Commands/AddExperienceCommand.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Commands/AddExperienceCommandHandler.cs delete mode 100644 services/membership-service-net/src/MembershipService.API/Application/Commands/ChangeMembershipLevelCommand.cs delete mode 100644 services/membership-service-net/src/MembershipService.API/Application/Commands/ChangeMembershipLevelCommandHandler.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Queries/GetExperienceHistoryQuery.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Queries/GetExperienceHistoryQueryHandler.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Queries/GetLevelDefinitionsQuery.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Queries/GetLevelDefinitionsQueryHandler.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberProgressQuery.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberProgressQueryHandler.cs create mode 100644 services/membership-service-net/src/MembershipService.API/Controllers/LevelsController.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/AggregatesModel/ExperienceAggregate/ExperienceSource.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/AggregatesModel/ExperienceAggregate/ExperienceTransaction.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/AggregatesModel/ExperienceAggregate/IExperienceTransactionRepository.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/AggregatesModel/LevelAggregate/ILevelDefinitionRepository.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/AggregatesModel/LevelAggregate/LevelBenefit.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/AggregatesModel/LevelAggregate/LevelDefinition.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/Events/MemberExperienceAddedDomainEvent.cs create mode 100644 services/membership-service-net/src/MembershipService.Domain/Events/MemberLevelUpDomainEvent.cs create mode 100644 services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/ExperienceTransactionEntityTypeConfiguration.cs create mode 100644 services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/LevelBenefitEntityTypeConfiguration.cs create mode 100644 services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/LevelDefinitionEntityTypeConfiguration.cs create mode 100644 services/membership-service-net/src/MembershipService.Infrastructure/Repositories/ExperienceTransactionRepository.cs create mode 100644 services/membership-service-net/src/MembershipService.Infrastructure/Repositories/LevelDefinitionRepository.cs create mode 100644 services/merchant-service-net/.env.example create mode 100644 services/merchant-service-net/.gitignore create mode 100644 services/merchant-service-net/Directory.Build.props create mode 100644 services/merchant-service-net/Dockerfile create mode 100644 services/merchant-service-net/MerchantService.slnx create mode 100644 services/merchant-service-net/docker-compose.yml create mode 100644 services/merchant-service-net/global.json create mode 100644 services/merchant-service-net/src/MerchantService.API/Application/Behaviors/LoggingBehavior.cs create mode 100644 services/merchant-service-net/src/MerchantService.API/Application/Behaviors/TransactionBehavior.cs create mode 100644 services/merchant-service-net/src/MerchantService.API/Application/Behaviors/ValidatorBehavior.cs create mode 100644 services/merchant-service-net/src/MerchantService.API/MerchantService.API.csproj create mode 100644 services/merchant-service-net/src/MerchantService.API/Program.cs create mode 100644 services/merchant-service-net/src/MerchantService.API/Properties/launchSettings.json create mode 100644 services/merchant-service-net/src/MerchantService.API/appsettings.Development.json create mode 100644 services/merchant-service-net/src/MerchantService.API/appsettings.json create mode 100644 services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/BankAccount.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/BusinessInfo.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/IMerchantRepository.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/Merchant.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/MerchantStatus.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/MerchantType.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/SettlementConfig.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/SettlementCycle.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/VerificationStatus.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/DeviceToken.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/IMerchantStaffRepository.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/MerchantStaff.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/ShopMember.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/StaffEnumerations.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/BusinessCategory.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/IShopRepository.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/Shop.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/ShopBranch.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/ShopStatus.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/ShopType.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/ShopValueObjects.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/Events/MerchantDomainEvents.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/Events/ShopDomainEvents.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/Events/StaffDomainEvents.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/Exceptions/DomainException.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/Exceptions/SampleDomainException.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/MerchantService.Domain.csproj create mode 100644 services/merchant-service-net/src/MerchantService.Domain/SeedWork/Entity.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/SeedWork/Enumeration.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/SeedWork/IAggregateRoot.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/SeedWork/IRepository.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/SeedWork/IUnitOfWork.cs create mode 100644 services/merchant-service-net/src/MerchantService.Domain/SeedWork/ValueObject.cs create mode 100644 services/merchant-service-net/src/MerchantService.Infrastructure/DependencyInjection.cs create mode 100644 services/merchant-service-net/src/MerchantService.Infrastructure/Idempotency/ClientRequest.cs create mode 100644 services/merchant-service-net/src/MerchantService.Infrastructure/Idempotency/IRequestManager.cs create mode 100644 services/merchant-service-net/src/MerchantService.Infrastructure/Idempotency/RequestManager.cs create mode 100644 services/merchant-service-net/src/MerchantService.Infrastructure/MerchantService.Infrastructure.csproj create mode 100644 services/merchant-service-net/src/MerchantService.Infrastructure/MerchantServiceContext.cs create mode 100644 services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/MerchantRepository.cs create mode 100644 services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/MerchantStaffRepository.cs create mode 100644 services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/ShopRepository.cs create mode 100644 services/merchant-service-net/tests/MerchantService.FunctionalTests/CustomWebApplicationFactory.cs create mode 100644 services/merchant-service-net/tests/MerchantService.FunctionalTests/MerchantService.FunctionalTests.csproj create mode 100644 services/merchant-service-net/tests/MerchantService.UnitTests/MerchantService.UnitTests.csproj diff --git a/deployments/local/docker-compose.yml b/deployments/local/docker-compose.yml index 9dea56ba..6a1fb178 100644 --- a/deployments/local/docker-compose.yml +++ b/deployments/local/docker-compose.yml @@ -152,7 +152,7 @@ services: start_period: 40s labels: - "traefik.enable=true" - - "traefik.http.routers.membership-service.rule=PathPrefix(`/api/v1/memberships`) || PathPrefix(`/api/v1/subscriptions`)" + - "traefik.http.routers.membership-service.rule=PathPrefix(`/api/v1/members`) || PathPrefix(`/api/v1/levels`) || PathPrefix(`/api/v1/memberships`) || PathPrefix(`/api/v1/subscriptions`)" - "traefik.http.routers.membership-service.entrypoints=web" - "traefik.http.services.membership-service.loadbalancer.server.port=8080" - "traefik.http.services.membership-service.loadbalancer.healthcheck.path=/health/live" diff --git a/infra/traefik/dynamic/routes.yml b/infra/traefik/dynamic/routes.yml index d917e651..c53056bb 100644 --- a/infra/traefik/dynamic/routes.yml +++ b/infra/traefik/dynamic/routes.yml @@ -52,6 +52,18 @@ http: entryPoints: - web + # EN: Membership Service - Member & Level Management + # VI: Membership Service - Quản lý Member & Level + membership-service-router: + rule: "PathPrefix(`/api/v1/members`) || PathPrefix(`/api/v1/levels`)" + service: membership-service + priority: 100 + middlewares: + - cors + - secure-headers + entryPoints: + - web + services: iam-service: loadBalancer: @@ -73,4 +85,11 @@ http: storage-service: loadBalancer: servers: - - url: "http://storage-service:8080" \ No newline at end of file + - url: "http://storage-service:8080" + + # EN: Membership Service + # VI: Membership Service + membership-service: + loadBalancer: + servers: + - url: "http://membership-service-net:8080" \ No newline at end of file diff --git a/services/iam-service-net/src/IamService.API/Program.cs b/services/iam-service-net/src/IamService.API/Program.cs index 98a04529..13d4a48a 100644 --- a/services/iam-service-net/src/IamService.API/Program.cs +++ b/services/iam-service-net/src/IamService.API/Program.cs @@ -247,6 +247,11 @@ var app = builder.Build(); var logger = app.Services.GetRequiredService>(); logger.LogInformation("Starting IAM Service API / Khởi động IAM Service API"); +// EN: Seed system roles on startup +// VI: Seed system roles khi khởi động +await IamService.Infrastructure.Data.DataSeeder.SeedRolesAsync(app.Services); + + // EN: Configure middleware pipeline / VI: Cấu hình middleware pipeline app.UseSerilogRequestLogging(); app.UseProblemDetails(); diff --git a/services/iam-service-net/src/IamService.Infrastructure/Data/DataSeeder.cs b/services/iam-service-net/src/IamService.Infrastructure/Data/DataSeeder.cs new file mode 100644 index 00000000..24c3ea24 --- /dev/null +++ b/services/iam-service-net/src/IamService.Infrastructure/Data/DataSeeder.cs @@ -0,0 +1,78 @@ +// EN: Database seeder for initial data. +// VI: Database seeder cho dữ liệu khởi tạo. + +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using IamService.Domain.AggregatesModel.RoleAggregate; + +namespace IamService.Infrastructure.Data; + +/// +/// EN: Database seeder for system roles and initial data. +/// VI: Database seeder cho system roles và dữ liệu khởi tạo. +/// +public static class DataSeeder +{ + /// + /// EN: Seed system roles into the database. + /// VI: Seed system roles vào database. + /// + public static async Task SeedRolesAsync(IServiceProvider serviceProvider) + { + using var scope = serviceProvider.CreateScope(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + var logger = scope.ServiceProvider.GetRequiredService>>(); + + // EN: Define system roles + // VI: Định nghĩa system roles + var systemRoles = new (string Name, string Description)[] + { + // Customer roles + ("User", "Regular user with basic access"), + ("PremiumUser", "Premium subscriber with enhanced access"), + + // Merchant roles + ("Merchant", "Shop owner with full merchant access"), + ("MerchantStaff", "Shop staff with limited access"), + ("MerchantAdmin", "Merchant administrator with management access"), + + // Platform roles + ("Admin", "Platform administrator"), + ("SuperAdmin", "Super administrator with full system access"), + ("Support", "Customer support agent"), + }; + + foreach (var (name, description) in systemRoles) + { + if (!await roleManager.RoleExistsAsync(name)) + { + var role = new ApplicationRole(name, description, isSystemRole: true); + var result = await roleManager.CreateAsync(role); + + if (result.Succeeded) + { + logger.LogInformation( + "EN: Created system role '{RoleName}' / VI: Đã tạo system role '{RoleName}'", + name); + } + else + { + var errors = string.Join(", ", result.Errors.Select(e => e.Description)); + logger.LogWarning( + "EN: Failed to create role '{RoleName}': {Errors} / VI: Không thể tạo role '{RoleName}': {Errors}", + name, errors); + } + } + else + { + logger.LogDebug( + "EN: Role '{RoleName}' already exists / VI: Role '{RoleName}' đã tồn tại", + name); + } + } + + logger.LogInformation( + "EN: System roles seeding completed / VI: Seeding system roles hoàn thành"); + } +} diff --git a/services/membership-service-net/Dockerfile b/services/membership-service-net/Dockerfile index 192106ab..50ea55f5 100644 --- a/services/membership-service-net/Dockerfile +++ b/services/membership-service-net/Dockerfile @@ -2,16 +2,16 @@ 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/MyService.API/MyService.API.csproj", "src/MyService.API/"] -COPY ["src/MyService.Domain/MyService.Domain.csproj", "src/MyService.Domain/"] -COPY ["src/MyService.Infrastructure/MyService.Infrastructure.csproj", "src/MyService.Infrastructure/"] +# EN: Copy solution and project files for layer caching +# VI: Sao chép solution và các file project để tận dụng layer caching COPY ["Directory.Build.props", "./"] +COPY ["src/MembershipService.API/MembershipService.API.csproj", "src/MembershipService.API/"] +COPY ["src/MembershipService.Domain/MembershipService.Domain.csproj", "src/MembershipService.Domain/"] +COPY ["src/MembershipService.Infrastructure/MembershipService.Infrastructure.csproj", "src/MembershipService.Infrastructure/"] # EN: Restore dependencies # VI: Khôi phục dependencies -RUN dotnet restore "src/MyService.API/MyService.API.csproj" +RUN dotnet restore "src/MembershipService.API/MembershipService.API.csproj" # EN: Copy all source code # VI: Sao chép toàn bộ source code @@ -19,17 +19,21 @@ COPY src/ ./src/ # EN: Build the application # VI: Build ứng dụng -WORKDIR "/src/src/MyService.API" -RUN dotnet build "MyService.API.csproj" -c Release -o /app/build --no-restore +WORKDIR "/src/src/MembershipService.API" +RUN dotnet build "MembershipService.API.csproj" -c Release -o /app/build # Publish stage / Giai đoạn publish FROM build AS publish -RUN dotnet publish "MyService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore +RUN dotnet publish "MembershipService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false # Runtime stage / Giai đoạn runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final WORKDIR /app +# EN: Install curl for health checks +# VI: Cài đặt curl cho health checks +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* + # EN: Create non-root user for security # VI: Tạo user non-root cho bảo mật RUN groupadd -g 1001 dotnetuser && \ @@ -63,4 +67,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ # EN: Start the application # VI: Khởi động ứng dụng -ENTRYPOINT ["dotnet", "MyService.API.dll"] +ENTRYPOINT ["dotnet", "MembershipService.API.dll"] diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/AddExperienceCommand.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/AddExperienceCommand.cs new file mode 100644 index 00000000..1786670c --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/AddExperienceCommand.cs @@ -0,0 +1,95 @@ +using MediatR; + +namespace MembershipService.API.Application.Commands; + +/// +/// EN: Command to add experience points to a member. +/// VI: Command để thêm điểm kinh nghiệm cho member. +/// +public class AddExperienceCommand : IRequest +{ + /// + /// EN: Member ID. + /// VI: ID của member. + /// + public Guid MemberId { get; set; } + + /// + /// EN: Amount of EXP points to add. + /// VI: Số điểm EXP cộng thêm. + /// + public int Points { get; set; } + + /// + /// EN: Source of experience (1=Purchase, 2=Referral, 3=Activity, 4=Promotion, 5=Review, 6=CheckIn, 7=Admin). + /// VI: Nguồn EXP (1=Mua hàng, 2=Giới thiệu, 3=Hoạt động, 4=Khuyến mãi, 5=Đánh giá, 6=Check-in, 7=Admin). + /// + public int SourceId { get; set; } + + /// + /// EN: Reference ID (Order ID, Referral Code, etc.). + /// VI: ID tham chiếu (Order ID, Referral Code, etc.). + /// + public string? ReferenceId { get; set; } + + /// + /// EN: Additional metadata as JSON string. + /// VI: Metadata bổ sung dạng chuỗi JSON. + /// + public string? Metadata { get; set; } +} + +/// +/// EN: Result of add experience command. +/// VI: Kết quả của add experience command. +/// +public class AddExperienceResult +{ + /// + /// EN: Member ID. + /// VI: ID của member. + /// + public Guid MemberId { get; set; } + + /// + /// EN: Points added. + /// VI: Điểm đã thêm. + /// + public int PointsAdded { get; set; } + + /// + /// EN: New current EXP. + /// VI: EXP hiện tại mới. + /// + public int CurrentExp { get; set; } + + /// + /// EN: Total EXP earned. + /// VI: Tổng EXP đã kiếm được. + /// + public int TotalExpEarned { get; set; } + + /// + /// EN: Previous level before adding EXP. + /// VI: Level trước khi thêm EXP. + /// + public int PreviousLevel { get; set; } + + /// + /// EN: Current level after adding EXP. + /// VI: Level hiện tại sau khi thêm EXP. + /// + public int CurrentLevel { get; set; } + + /// + /// EN: Whether the member leveled up. + /// VI: Member có lên level không. + /// + public bool LeveledUp { get; set; } + + /// + /// EN: Experience transaction ID. + /// VI: ID của experience transaction. + /// + public Guid TransactionId { get; set; } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/AddExperienceCommandHandler.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/AddExperienceCommandHandler.cs new file mode 100644 index 00000000..7289ce04 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/AddExperienceCommandHandler.cs @@ -0,0 +1,94 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.ExperienceAggregate; +using MembershipService.Domain.AggregatesModel.LevelAggregate; +using MembershipService.Domain.AggregatesModel.MemberAggregate; + +namespace MembershipService.API.Application.Commands; + +/// +/// EN: Handler for adding experience points to a member. +/// VI: Handler để thêm điểm kinh nghiệm cho member. +/// +public class AddExperienceCommandHandler : IRequestHandler +{ + private readonly IMemberRepository _memberRepository; + private readonly ILevelDefinitionRepository _levelDefinitionRepository; + private readonly IExperienceTransactionRepository _experienceTransactionRepository; + private readonly ILogger _logger; + + public AddExperienceCommandHandler( + IMemberRepository memberRepository, + ILevelDefinitionRepository levelDefinitionRepository, + IExperienceTransactionRepository experienceTransactionRepository, + ILogger logger) + { + _memberRepository = memberRepository ?? throw new ArgumentNullException(nameof(memberRepository)); + _levelDefinitionRepository = levelDefinitionRepository ?? throw new ArgumentNullException(nameof(levelDefinitionRepository)); + _experienceTransactionRepository = experienceTransactionRepository ?? throw new ArgumentNullException(nameof(experienceTransactionRepository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task Handle(AddExperienceCommand request, CancellationToken cancellationToken) + { + // EN: Get member + // VI: Lấy member + var member = await _memberRepository.GetByIdAsync(request.MemberId, cancellationToken); + if (member == null) + { + throw new KeyNotFoundException($"Member {request.MemberId} not found"); + } + + // EN: Get active level rules + // VI: Lấy level rules đang active + var levelRules = await _levelDefinitionRepository.GetAllActiveAsync(); + if (!levelRules.Any()) + { + throw new InvalidOperationException("No active level definitions found"); + } + + // EN: Get experience source + // VI: Lấy experience source + var source = ExperienceSource.FromValue(request.SourceId); + + // EN: Store previous level + // VI: Lưu level trước đó + var previousLevel = member.CurrentLevel; + + // EN: Add experience and get transaction + // VI: Thêm experience và lấy transaction + var transaction = member.AddExperience( + request.Points, + source, + levelRules, + request.ReferenceId, + request.Metadata); + + // EN: Save transaction + // VI: Lưu transaction + _experienceTransactionRepository.Add(transaction); + + // EN: Update member + // VI: Cập nhật member + _memberRepository.Update(member); + + // EN: Save all changes + // VI: Lưu tất cả thay đổi + await _memberRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); + + _logger.LogInformation( + "Added {Points} EXP to member {MemberId} from {Source}. Level: {PrevLevel} -> {NewLevel}", + request.Points, request.MemberId, source.Name, previousLevel, member.CurrentLevel); + + return new AddExperienceResult + { + MemberId = member.Id, + PointsAdded = request.Points, + CurrentExp = member.CurrentExp, + TotalExpEarned = member.TotalExpEarned, + PreviousLevel = previousLevel, + CurrentLevel = member.CurrentLevel, + LeveledUp = member.CurrentLevel > previousLevel, + TransactionId = transaction.Id + }; + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/ChangeMembershipLevelCommand.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/ChangeMembershipLevelCommand.cs deleted file mode 100644 index ad44513e..00000000 --- a/services/membership-service-net/src/MembershipService.API/Application/Commands/ChangeMembershipLevelCommand.cs +++ /dev/null @@ -1,34 +0,0 @@ -using MediatR; - -namespace MembershipService.API.Application.Commands; - -/// -/// EN: Command to change membership level. -/// VI: Command để thay đổi cấp thành viên. -/// -public class ChangeMembershipLevelCommand : IRequest -{ - /// - /// EN: Member ID. - /// VI: ID member. - /// - public Guid MemberId { get; set; } - - /// - /// EN: New membership level ID. - /// VI: ID cấp thành viên mới. - /// - public int NewLevelId { get; set; } -} - -/// -/// EN: Result of change membership level command. -/// VI: Kết quả của change membership level command. -/// -public class ChangeMembershipLevelResult -{ - public Guid MemberId { get; set; } - public string OldLevel { get; set; } = null!; - public string NewLevel { get; set; } = null!; - public DateTime ChangedAt { get; set; } -} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/ChangeMembershipLevelCommandHandler.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/ChangeMembershipLevelCommandHandler.cs deleted file mode 100644 index 479ed368..00000000 --- a/services/membership-service-net/src/MembershipService.API/Application/Commands/ChangeMembershipLevelCommandHandler.cs +++ /dev/null @@ -1,54 +0,0 @@ -using MediatR; -using MembershipService.Domain.AggregatesModel.MemberAggregate; - -namespace MembershipService.API.Application.Commands; - -/// -/// EN: Handler for changing membership level. -/// VI: Handler để thay đổi cấp thành viên. -/// -public class ChangeMembershipLevelCommandHandler : IRequestHandler -{ - private readonly IMemberRepository _memberRepository; - private readonly ILogger _logger; - - public ChangeMembershipLevelCommandHandler( - IMemberRepository memberRepository, - ILogger logger) - { - _memberRepository = memberRepository ?? throw new ArgumentNullException(nameof(memberRepository)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public async Task Handle(ChangeMembershipLevelCommand request, CancellationToken cancellationToken) - { - var member = await _memberRepository.GetByIdAsync(request.MemberId, cancellationToken); - if (member == null) - { - throw new KeyNotFoundException($"Member {request.MemberId} not found"); - } - - var newLevel = MembershipLevel.FromValue(request.NewLevelId); - if (newLevel == null) - { - throw new ArgumentException($"Invalid membership level ID: {request.NewLevelId}"); - } - - var oldLevel = member.MembershipLevel; - member.ChangeMembershipLevel(newLevel); - - _memberRepository.Update(member); - await _memberRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken); - - _logger.LogInformation("Changed membership level for member {MemberId} from {OldLevel} to {NewLevel}", - request.MemberId, oldLevel.Name, newLevel.Name); - - return new ChangeMembershipLevelResult - { - MemberId = member.Id, - OldLevel = oldLevel.Name, - NewLevel = newLevel.Name, - ChangedAt = member.UpdatedAt - }; - } -} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateMemberCommand.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateMemberCommand.cs index b2c1f8fd..6c2ad83d 100644 --- a/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateMemberCommand.cs +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateMemberCommand.cs @@ -35,6 +35,12 @@ public class CreateMemberResult { public Guid MemberId { get; set; } public Guid UserId { get; set; } - public string MembershipLevel { get; set; } = null!; + + /// + /// EN: Initial level (always 1 for new members). + /// VI: Level ban đầu (luôn là 1 cho member mới). + /// + public int CurrentLevel { get; set; } + public DateTime CreatedAt { get; set; } } diff --git a/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateMemberCommandHandler.cs b/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateMemberCommandHandler.cs index f0c70509..70bf6389 100644 --- a/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateMemberCommandHandler.cs +++ b/services/membership-service-net/src/MembershipService.API/Application/Commands/CreateMemberCommandHandler.cs @@ -30,20 +30,21 @@ public class CreateMemberCommandHandler : IRequestHandler +/// EN: Query to get experience history for a member. +/// VI: Query để lấy lịch sử EXP của member. +/// +public class GetExperienceHistoryQuery : IRequest +{ + public Guid MemberId { get; set; } + public int PageIndex { get; set; } = 0; + public int PageSize { get; set; } = 20; + + public GetExperienceHistoryQuery(Guid memberId, int pageIndex = 0, int pageSize = 20) + { + MemberId = memberId; + PageIndex = pageIndex; + PageSize = pageSize; + } +} + +/// +/// EN: Experience history result. +/// VI: Kết quả lịch sử EXP. +/// +public class ExperienceHistoryResult +{ + public IEnumerable Transactions { get; set; } = Enumerable.Empty(); + public int TotalCount { get; set; } + public int PageIndex { get; set; } + public int PageSize { get; set; } +} + +/// +/// EN: Experience transaction DTO. +/// VI: DTO giao dịch EXP. +/// +public class ExperienceTransactionDto +{ + /// + /// EN: Transaction ID. + /// VI: ID giao dịch. + /// + public Guid Id { get; set; } + + /// + /// EN: Points earned. + /// VI: Điểm kiếm được. + /// + public int Points { get; set; } + + /// + /// EN: Source name. + /// VI: Tên nguồn. + /// + public string Source { get; set; } = null!; + + /// + /// EN: Source ID. + /// VI: ID nguồn. + /// + public int SourceId { get; set; } + + /// + /// EN: Reference ID. + /// VI: ID tham chiếu. + /// + public string? ReferenceId { get; set; } + + /// + /// EN: Level at time of transaction. + /// VI: Level tại thời điểm giao dịch. + /// + public int LevelAtTime { get; set; } + + /// + /// EN: Creation timestamp. + /// VI: Thời gian tạo. + /// + public DateTime CreatedAt { get; set; } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Queries/GetExperienceHistoryQueryHandler.cs b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetExperienceHistoryQueryHandler.cs new file mode 100644 index 00000000..3d31a04b --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetExperienceHistoryQueryHandler.cs @@ -0,0 +1,50 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.ExperienceAggregate; + +namespace MembershipService.API.Application.Queries; + +/// +/// EN: Handler for getting experience history. +/// VI: Handler để lấy lịch sử EXP. +/// +public class GetExperienceHistoryQueryHandler : IRequestHandler +{ + private readonly IExperienceTransactionRepository _experienceTransactionRepository; + + public GetExperienceHistoryQueryHandler(IExperienceTransactionRepository experienceTransactionRepository) + { + _experienceTransactionRepository = experienceTransactionRepository + ?? throw new ArgumentNullException(nameof(experienceTransactionRepository)); + } + + public async Task Handle(GetExperienceHistoryQuery request, CancellationToken cancellationToken) + { + var skip = request.PageIndex * request.PageSize; + + var transactions = await _experienceTransactionRepository.GetByMemberIdAsync( + request.MemberId, + skip, + request.PageSize); + + var totalCount = await _experienceTransactionRepository.GetCountByMemberIdAsync(request.MemberId); + + var transactionDtos = transactions.Select(t => new ExperienceTransactionDto + { + Id = t.Id, + Points = t.Points, + Source = t.Source?.Name ?? "Unknown", + SourceId = t.SourceId, + ReferenceId = t.ReferenceId, + LevelAtTime = t.LevelAtTime, + CreatedAt = t.CreatedAt + }); + + return new ExperienceHistoryResult + { + Transactions = transactionDtos, + TotalCount = totalCount, + PageIndex = request.PageIndex, + PageSize = request.PageSize + }; + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Queries/GetLevelDefinitionsQuery.cs b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetLevelDefinitionsQuery.cs new file mode 100644 index 00000000..76b08e46 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetLevelDefinitionsQuery.cs @@ -0,0 +1,46 @@ +using MediatR; + +namespace MembershipService.API.Application.Queries; + +/// +/// EN: Query to get all level definitions. +/// VI: Query để lấy tất cả level definitions. +/// +public class GetLevelDefinitionsQuery : IRequest> +{ + /// + /// EN: Whether to include inactive levels. + /// VI: Có bao gồm levels không active không. + /// + public bool IncludeInactive { get; set; } = false; +} + +/// +/// EN: Level definition DTO. +/// VI: DTO level definition. +/// +public class LevelDefinitionDto +{ + public Guid Id { get; set; } + public int LevelNumber { get; set; } + public string Name { get; set; } = null!; + public int RequiredExp { get; set; } + public string? Description { get; set; } + public string? IconUrl { get; set; } + public string? BadgeColor { get; set; } + public bool IsActive { get; set; } + public IEnumerable Benefits { get; set; } = Enumerable.Empty(); +} + +/// +/// EN: Level benefit DTO. +/// VI: DTO level benefit. +/// +public class LevelBenefitDto +{ + public Guid Id { get; set; } + public string BenefitType { get; set; } = null!; + public string BenefitValue { get; set; } = null!; + public string? Description { get; set; } + public bool IsActive { get; set; } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Queries/GetLevelDefinitionsQueryHandler.cs b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetLevelDefinitionsQueryHandler.cs new file mode 100644 index 00000000..28040ba0 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetLevelDefinitionsQueryHandler.cs @@ -0,0 +1,46 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.LevelAggregate; + +namespace MembershipService.API.Application.Queries; + +/// +/// EN: Handler for getting level definitions. +/// VI: Handler để lấy level definitions. +/// +public class GetLevelDefinitionsQueryHandler : IRequestHandler> +{ + private readonly ILevelDefinitionRepository _levelDefinitionRepository; + + public GetLevelDefinitionsQueryHandler(ILevelDefinitionRepository levelDefinitionRepository) + { + _levelDefinitionRepository = levelDefinitionRepository + ?? throw new ArgumentNullException(nameof(levelDefinitionRepository)); + } + + public async Task> Handle(GetLevelDefinitionsQuery request, CancellationToken cancellationToken) + { + var levels = request.IncludeInactive + ? await _levelDefinitionRepository.GetAllAsync() + : await _levelDefinitionRepository.GetAllActiveAsync(); + + return levels.Select(l => new LevelDefinitionDto + { + Id = l.Id, + LevelNumber = l.LevelNumber, + Name = l.Name, + RequiredExp = l.RequiredExp, + Description = l.Description, + IconUrl = l.IconUrl, + BadgeColor = l.BadgeColor, + IsActive = l.IsActive, + Benefits = l.Benefits.Select(b => new LevelBenefitDto + { + Id = b.Id, + BenefitType = b.BenefitType, + BenefitValue = b.BenefitValue, + Description = b.Description, + IsActive = b.IsActive + }) + }); + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberByIdQuery.cs b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberByIdQuery.cs index 693dd211..726bd549 100644 --- a/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberByIdQuery.cs +++ b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberByIdQuery.cs @@ -31,17 +31,25 @@ public class MemberDto public string? Gender { get; set; } public string CountryCode { get; set; } = null!; public string? Preferences { get; set; } - public MembershipLevelDto MembershipLevel { get; set; } = null!; + + /// + /// EN: Current member level (1, 2, 3...). + /// VI: Level hiện tại của member (1, 2, 3...). + /// + public int CurrentLevel { get; set; } + + /// + /// EN: Current experience points. + /// VI: Điểm kinh nghiệm hiện tại. + /// + public int CurrentExp { get; set; } + + /// + /// EN: Total experience points ever earned. + /// VI: Tổng điểm kinh nghiệm đã kiếm được. + /// + public int TotalExpEarned { get; set; } + public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } } - -/// -/// EN: Membership level DTO. -/// VI: DTO cấp thành viên. -/// -public class MembershipLevelDto -{ - public int Id { get; set; } - public string Name { get; set; } = null!; -} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberByIdQueryHandler.cs b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberByIdQueryHandler.cs index 69f955f3..11aaee0b 100644 --- a/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberByIdQueryHandler.cs +++ b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberByIdQueryHandler.cs @@ -36,11 +36,9 @@ public class GetMemberByIdQueryHandler : IRequestHandler +/// EN: Query to get member level progress. +/// VI: Query để lấy tiến độ level của member. +/// +public class GetMemberProgressQuery : IRequest +{ + public Guid MemberId { get; set; } + + public GetMemberProgressQuery(Guid memberId) + { + MemberId = memberId; + } +} + +/// +/// EN: Member progress DTO. +/// VI: DTO tiến độ member. +/// +public class MemberProgressDto +{ + /// + /// EN: Member ID. + /// VI: ID của member. + /// + public Guid MemberId { get; set; } + + /// + /// EN: Current level number. + /// VI: Số level hiện tại. + /// + public int CurrentLevel { get; set; } + + /// + /// EN: Current level name. + /// VI: Tên level hiện tại. + /// + public string CurrentLevelName { get; set; } = null!; + + /// + /// EN: Current experience points. + /// VI: Điểm kinh nghiệm hiện tại. + /// + public int CurrentExp { get; set; } + + /// + /// EN: Total experience points ever earned. + /// VI: Tổng điểm kinh nghiệm đã kiếm được. + /// + public int TotalExpEarned { get; set; } + + /// + /// EN: EXP required for next level. + /// VI: EXP cần để lên level tiếp. + /// + public int ExpToNextLevel { get; set; } + + /// + /// EN: Progress percentage to next level (0-100). + /// VI: Phần trăm tiến độ đến level tiếp (0-100). + /// + public int ProgressPercent { get; set; } + + /// + /// EN: Next level number (null if max level). + /// VI: Số level tiếp theo (null nếu đã max). + /// + public int? NextLevel { get; set; } + + /// + /// EN: Next level name (null if max level). + /// VI: Tên level tiếp theo (null nếu đã max). + /// + public string? NextLevelName { get; set; } + + /// + /// EN: Badge color for current level. + /// VI: Màu badge của level hiện tại. + /// + public string? BadgeColor { get; set; } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberProgressQueryHandler.cs b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberProgressQueryHandler.cs new file mode 100644 index 00000000..4d168065 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMemberProgressQueryHandler.cs @@ -0,0 +1,80 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.LevelAggregate; +using MembershipService.Domain.AggregatesModel.MemberAggregate; + +namespace MembershipService.API.Application.Queries; + +/// +/// EN: Handler for getting member level progress. +/// VI: Handler để lấy tiến độ level của member. +/// +public class GetMemberProgressQueryHandler : IRequestHandler +{ + private readonly IMemberRepository _memberRepository; + private readonly ILevelDefinitionRepository _levelDefinitionRepository; + + public GetMemberProgressQueryHandler( + IMemberRepository memberRepository, + ILevelDefinitionRepository levelDefinitionRepository) + { + _memberRepository = memberRepository ?? throw new ArgumentNullException(nameof(memberRepository)); + _levelDefinitionRepository = levelDefinitionRepository ?? throw new ArgumentNullException(nameof(levelDefinitionRepository)); + } + + public async Task Handle(GetMemberProgressQuery request, CancellationToken cancellationToken) + { + var member = await _memberRepository.GetByIdAsync(request.MemberId, cancellationToken); + if (member == null) + { + return null; + } + + // EN: Get all active level definitions + // VI: Lấy tất cả level definitions đang active + var levelRules = await _levelDefinitionRepository.GetAllActiveAsync(); + if (!levelRules.Any()) + { + return null; + } + + // EN: Find current and next level + // VI: Tìm level hiện tại và level tiếp theo + var currentLevelDef = levelRules.FirstOrDefault(l => l.LevelNumber == member.CurrentLevel); + var nextLevelDef = levelRules + .Where(l => l.LevelNumber > member.CurrentLevel) + .OrderBy(l => l.LevelNumber) + .FirstOrDefault(); + + // EN: Calculate progress + // VI: Tính toán tiến độ + int expToNextLevel = 0; + int progressPercent = 100; + + if (nextLevelDef != null && currentLevelDef != null) + { + var expInCurrentLevel = member.CurrentExp - currentLevelDef.RequiredExp; + var expNeededForNextLevel = nextLevelDef.RequiredExp - currentLevelDef.RequiredExp; + + expToNextLevel = nextLevelDef.RequiredExp - member.CurrentExp; + progressPercent = expNeededForNextLevel > 0 + ? (int)(expInCurrentLevel * 100.0 / expNeededForNextLevel) + : 100; + + progressPercent = Math.Clamp(progressPercent, 0, 100); + } + + return new MemberProgressDto + { + MemberId = member.Id, + CurrentLevel = member.CurrentLevel, + CurrentLevelName = currentLevelDef?.Name ?? $"Level {member.CurrentLevel}", + CurrentExp = member.CurrentExp, + TotalExpEarned = member.TotalExpEarned, + ExpToNextLevel = expToNextLevel, + ProgressPercent = progressPercent, + NextLevel = nextLevelDef?.LevelNumber, + NextLevelName = nextLevelDef?.Name, + BadgeColor = currentLevelDef?.BadgeColor + }; + } +} diff --git a/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMembersQueryHandler.cs b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMembersQueryHandler.cs index d581adae..58e7a0d4 100644 --- a/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMembersQueryHandler.cs +++ b/services/membership-service-net/src/MembershipService.API/Application/Queries/GetMembersQueryHandler.cs @@ -31,11 +31,9 @@ public class GetMembersQueryHandler : IRequestHandler +/// EN: Controller for managing level definitions. +/// VI: Controller để quản lý level definitions. +/// +[ApiController] +[Route("api/v{version:apiVersion}/[controller]")] +[ApiVersion("1.0")] +[Authorize] +[SwaggerTag("Level definitions management endpoints")] +public class LevelsController : ControllerBase +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public LevelsController(IMediator mediator, ILogger logger) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// EN: Get all level definitions. + /// VI: Lấy tất cả level definitions. + /// + [HttpGet] + [AllowAnonymous] + [SwaggerOperation(Summary = "Get level definitions", Description = "Retrieves all level definitions")] + [SwaggerResponse(200, "Level definitions retrieved", typeof(IEnumerable))] + public async Task>> GetAll( + [FromQuery] bool includeInactive = false) + { + var query = new GetLevelDefinitionsQuery { IncludeInactive = includeInactive }; + var levels = await _mediator.Send(query); + return Ok(levels); + } + + // TODO: Add admin endpoints in Phase 6 + // POST /api/v1/levels - Create level definition (Admin) + // PUT /api/v1/levels/{id} - Update level definition (Admin) + // DELETE /api/v1/levels/{id} - Deactivate level (Admin) +} diff --git a/services/membership-service-net/src/MembershipService.API/Controllers/MembersController.cs b/services/membership-service-net/src/MembershipService.API/Controllers/MembersController.cs index 39c04ccc..a5c60a46 100644 --- a/services/membership-service-net/src/MembershipService.API/Controllers/MembersController.cs +++ b/services/membership-service-net/src/MembershipService.API/Controllers/MembersController.cs @@ -144,19 +144,24 @@ public class MembersController : ControllerBase } } + // TODO: Add experience and level endpoints in Phase 4 + // POST /api/v1/members/{id}/experience - Add EXP + // GET /api/v1/members/{id}/progress - Get level progress + // GET /api/v1/members/{id}/experience - Get EXP history + /// - /// EN: Change membership level. - /// VI: Thay đổi cấp thành viên. + /// EN: Add experience points to a member. + /// VI: Thêm điểm kinh nghiệm cho member. /// - [HttpPut("{id:guid}/level")] - [SwaggerOperation(Summary = "Change membership level", Description = "Changes a member's membership level")] - [SwaggerResponse(200, "Level changed", typeof(ChangeMembershipLevelResult))] + [HttpPost("{id:guid}/experience")] + [SwaggerOperation(Summary = "Add experience points", Description = "Adds experience points to a member")] + [SwaggerResponse(200, "Experience added", typeof(AddExperienceResult))] [SwaggerResponse(400, "Invalid request")] [SwaggerResponse(404, "Member not found")] [SwaggerResponse(401, "Unauthorized")] - public async Task> ChangeLevel( + public async Task> AddExperience( Guid id, - [FromBody] ChangeMembershipLevelCommand command) + [FromBody] AddExperienceCommand command) { command.MemberId = id; @@ -175,6 +180,42 @@ public class MembersController : ControllerBase } } + /// + /// EN: Get member's level progress. + /// VI: Lấy tiến độ level của member. + /// + [HttpGet("{id:guid}/progress")] + [SwaggerOperation(Summary = "Get level progress", Description = "Retrieves member's level progress")] + [SwaggerResponse(200, "Progress retrieved", typeof(MemberProgressDto))] + [SwaggerResponse(404, "Member not found")] + [SwaggerResponse(401, "Unauthorized")] + public async Task> GetProgress(Guid id) + { + var progress = await _mediator.Send(new GetMemberProgressQuery(id)); + if (progress == null) + { + return NotFound(new { message = $"Member {id} not found" }); + } + return Ok(progress); + } + + /// + /// EN: Get member's experience history. + /// VI: Lấy lịch sử EXP của member. + /// + [HttpGet("{id:guid}/experience")] + [SwaggerOperation(Summary = "Get experience history", Description = "Retrieves member's experience transaction history")] + [SwaggerResponse(200, "History retrieved", typeof(ExperienceHistoryResult))] + [SwaggerResponse(401, "Unauthorized")] + public async Task> GetExperienceHistory( + Guid id, + [FromQuery] int pageIndex = 0, + [FromQuery] int pageSize = 20) + { + var result = await _mediator.Send(new GetExperienceHistoryQuery(id, pageIndex, pageSize)); + return Ok(result); + } + private Guid? GetCurrentUserId() { var userIdClaim = User.FindFirst("sub")?.Value ?? User.FindFirst("id")?.Value; diff --git a/services/membership-service-net/src/MembershipService.API/MembershipService.API.csproj b/services/membership-service-net/src/MembershipService.API/MembershipService.API.csproj index bbb47f1c..0df9226a 100644 --- a/services/membership-service-net/src/MembershipService.API/MembershipService.API.csproj +++ b/services/membership-service-net/src/MembershipService.API/MembershipService.API.csproj @@ -14,6 +14,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/ExperienceAggregate/ExperienceSource.cs b/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/ExperienceAggregate/ExperienceSource.cs new file mode 100644 index 00000000..8173ae8f --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/ExperienceAggregate/ExperienceSource.cs @@ -0,0 +1,64 @@ +using MembershipService.Domain.SeedWork; + +namespace MembershipService.Domain.AggregatesModel.ExperienceAggregate; + +/// +/// EN: Experience source enumeration - defines where EXP comes from. +/// VI: Experience source enumeration - định nghĩa nguồn gốc EXP. +/// +/// +/// EN: Uses Enumeration pattern for type-safe enum with rich behavior. +/// VI: Sử dụng Enumeration pattern cho enum an toàn kiểu với hành vi phong phú. +/// +public class ExperienceSource : Enumeration +{ + /// + /// EN: EXP from purchase/order completion. + /// VI: EXP từ hoàn thành đơn hàng. + /// + public static readonly ExperienceSource Purchase = new(1, nameof(Purchase)); + + /// + /// EN: EXP from friend referral. + /// VI: EXP từ giới thiệu bạn bè. + /// + public static readonly ExperienceSource Referral = new(2, nameof(Referral)); + + /// + /// EN: EXP from app activity (browsing, engagement). + /// VI: EXP từ hoạt động trên app (duyệt, tương tác). + /// + public static readonly ExperienceSource Activity = new(3, nameof(Activity)); + + /// + /// EN: EXP from promotional campaigns. + /// VI: EXP từ chiến dịch khuyến mãi. + /// + public static readonly ExperienceSource Promotion = new(4, nameof(Promotion)); + + /// + /// EN: EXP from product reviews. + /// VI: EXP từ đánh giá sản phẩm. + /// + public static readonly ExperienceSource Review = new(5, nameof(Review)); + + /// + /// EN: EXP from daily check-in. + /// VI: EXP từ check-in hàng ngày. + /// + public static readonly ExperienceSource CheckIn = new(6, nameof(CheckIn)); + + /// + /// EN: EXP manually granted by admin. + /// VI: EXP được admin cấp thủ công. + /// + public static readonly ExperienceSource Admin = new(7, nameof(Admin)); + + /// + /// EN: Private constructor for Enumeration pattern. + /// VI: Constructor private cho Enumeration pattern. + /// + public ExperienceSource(int id, string name) : base(id, name) + { + } +} diff --git a/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/ExperienceAggregate/ExperienceTransaction.cs b/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/ExperienceAggregate/ExperienceTransaction.cs new file mode 100644 index 00000000..8ea74cc9 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/ExperienceAggregate/ExperienceTransaction.cs @@ -0,0 +1,128 @@ +using MembershipService.Domain.SeedWork; + +namespace MembershipService.Domain.AggregatesModel.ExperienceAggregate; + +/// +/// EN: Experience transaction entity - tracks EXP history and source. +/// VI: Experience transaction entity - theo dõi lịch sử và nguồn gốc EXP. +/// +/// +/// EN: Every time a member receives EXP, a transaction is created for audit trail. +/// This enables analytics on where EXP comes from and member engagement patterns. +/// VI: Mỗi lần member nhận EXP, một transaction được tạo để audit. +/// Điều này cho phép phân tích nguồn gốc EXP và patterns tương tác của member. +/// +public class ExperienceTransaction : Entity +{ + private Guid _memberId; + private int _points; + private int _sourceId; + private ExperienceSource _source = null!; + private string? _referenceId; + private string? _metadata; // JSON + private int _levelAtTime; + private DateTime _createdAt; + + /// + /// EN: Member ID who received the EXP. + /// VI: ID của member nhận EXP. + /// + public Guid MemberId => _memberId; + + /// + /// EN: Amount of EXP points earned. + /// VI: Số điểm EXP kiếm được. + /// + public int Points => _points; + + /// + /// EN: Experience source ID (for EF Core mapping). + /// VI: ID nguồn EXP (cho EF Core mapping). + /// + public int SourceId => _sourceId; + + /// + /// EN: Experience source. + /// VI: Nguồn EXP. + /// + public ExperienceSource Source => _source; + + /// + /// EN: Reference ID (Order ID, Referral Code, etc.). + /// VI: ID tham chiếu (Order ID, Referral Code, etc.). + /// + public string? ReferenceId => _referenceId; + + /// + /// EN: Additional metadata as JSON string. + /// VI: Metadata bổ sung dạng chuỗi JSON. + /// + public string? Metadata => _metadata; + + /// + /// EN: Member's level when this EXP was earned. + /// VI: Level của member khi nhận EXP này. + /// + public int LevelAtTime => _levelAtTime; + + /// + /// EN: Creation timestamp. + /// VI: Thời gian tạo. + /// + public DateTime CreatedAt => _createdAt; + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + protected ExperienceTransaction() + { + } + + /// + /// EN: Create new experience transaction. + /// VI: Tạo experience transaction mới. + /// + /// Member ID / ID của member + /// EXP points / Số điểm EXP + /// Experience source / Nguồn EXP + /// Member's current level / Level hiện tại của member + /// Reference ID (optional) / ID tham chiếu (tùy chọn) + /// Metadata JSON (optional) / Metadata JSON (tùy chọn) + public ExperienceTransaction( + Guid memberId, + int points, + ExperienceSource source, + int levelAtTime, + string? referenceId = null, + string? metadata = null) + { + if (memberId == Guid.Empty) + throw new ArgumentException("Member ID cannot be empty", nameof(memberId)); + if (points <= 0) + throw new ArgumentException("Points must be positive", nameof(points)); + if (source == null) + throw new ArgumentNullException(nameof(source)); + if (levelAtTime < 1) + throw new ArgumentException("Level at time must be at least 1", nameof(levelAtTime)); + + Id = Guid.NewGuid(); + _memberId = memberId; + _points = points; + _sourceId = source.Id; + _source = source; + _levelAtTime = levelAtTime; + _referenceId = referenceId; + _metadata = metadata; + _createdAt = DateTime.UtcNow; + } + + /// + /// EN: Set experience source (for EF Core loading). + /// VI: Set experience source (cho EF Core loading). + /// + public void SetSource(ExperienceSource source) + { + _source = source; + } +} diff --git a/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/ExperienceAggregate/IExperienceTransactionRepository.cs b/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/ExperienceAggregate/IExperienceTransactionRepository.cs new file mode 100644 index 00000000..01a4e641 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/ExperienceAggregate/IExperienceTransactionRepository.cs @@ -0,0 +1,69 @@ +using MembershipService.Domain.SeedWork; + +namespace MembershipService.Domain.AggregatesModel.ExperienceAggregate; + +/// +/// EN: Repository interface for ExperienceTransaction. +/// VI: Interface repository cho ExperienceTransaction. +/// +/// +/// EN: ExperienceTransaction is not an aggregate root, it belongs to Member aggregate. +/// This repository is for convenience of querying transactions. +/// VI: ExperienceTransaction không phải aggregate root, nó thuộc Member aggregate. +/// Repository này để tiện truy vấn transactions. +/// +public interface IExperienceTransactionRepository +{ + /// + /// EN: The unit of work for this repository. + /// VI: Unit of work cho repository này. + /// + IUnitOfWork UnitOfWork { get; } + + /// + /// EN: Get experience transaction by ID. + /// VI: Lấy experience transaction theo ID. + /// + Task GetAsync(Guid id); + + /// + /// EN: Get paginated experience transactions for a member. + /// VI: Lấy experience transactions phân trang cho một member. + /// + Task> GetByMemberIdAsync( + Guid memberId, + int skip = 0, + int take = 20); + + /// + /// EN: Get experience transactions by source for a member. + /// VI: Lấy experience transactions theo nguồn cho một member. + /// + Task> GetBySourceAsync( + Guid memberId, + ExperienceSource source); + + /// + /// EN: Get total EXP points for a member. + /// VI: Lấy tổng điểm EXP của một member. + /// + Task GetTotalPointsByMemberIdAsync(Guid memberId); + + /// + /// EN: Get total EXP points by source for a member. + /// VI: Lấy tổng điểm EXP theo nguồn cho một member. + /// + Task GetTotalPointsBySourceAsync(Guid memberId, ExperienceSource source); + + /// + /// EN: Get transaction count for a member. + /// VI: Lấy số lượng transactions của một member. + /// + Task GetCountByMemberIdAsync(Guid memberId); + + /// + /// EN: Add new experience transaction. + /// VI: Thêm experience transaction mới. + /// + ExperienceTransaction Add(ExperienceTransaction transaction); +} diff --git a/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/LevelAggregate/ILevelDefinitionRepository.cs b/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/LevelAggregate/ILevelDefinitionRepository.cs new file mode 100644 index 00000000..01a72391 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/LevelAggregate/ILevelDefinitionRepository.cs @@ -0,0 +1,58 @@ +using MembershipService.Domain.SeedWork; + +namespace MembershipService.Domain.AggregatesModel.LevelAggregate; + +/// +/// EN: Repository interface for LevelDefinition aggregate. +/// VI: Interface repository cho LevelDefinition aggregate. +/// +public interface ILevelDefinitionRepository : IRepository +{ + /// + /// EN: Get level definition by ID. + /// VI: Lấy level definition theo ID. + /// + Task GetAsync(Guid id); + + /// + /// EN: Get level definition by level number. + /// VI: Lấy level definition theo số thứ tự level. + /// + Task GetByLevelNumberAsync(int levelNumber); + + /// + /// EN: Get level definition with benefits. + /// VI: Lấy level definition kèm benefits. + /// + Task GetWithBenefitsAsync(Guid id); + + /// + /// EN: Get all active level definitions ordered by level number. + /// VI: Lấy tất cả level definitions đang active, sắp xếp theo số thứ tự. + /// + Task> GetAllActiveAsync(); + + /// + /// EN: Get all level definitions (including inactive). + /// VI: Lấy tất cả level definitions (bao gồm cả inactive). + /// + Task> GetAllAsync(); + + /// + /// EN: Check if level number already exists. + /// VI: Kiểm tra xem số thứ tự level đã tồn tại chưa. + /// + Task ExistsByLevelNumberAsync(int levelNumber); + + /// + /// EN: Add new level definition. + /// VI: Thêm level definition mới. + /// + LevelDefinition Add(LevelDefinition levelDefinition); + + /// + /// EN: Update level definition. + /// VI: Cập nhật level definition. + /// + void Update(LevelDefinition levelDefinition); +} diff --git a/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/LevelAggregate/LevelBenefit.cs b/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/LevelAggregate/LevelBenefit.cs new file mode 100644 index 00000000..b788c0fb --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/LevelAggregate/LevelBenefit.cs @@ -0,0 +1,146 @@ +using MembershipService.Domain.SeedWork; + +namespace MembershipService.Domain.AggregatesModel.LevelAggregate; + +/// +/// EN: Level benefit entity - represents a reward/perk for a level. +/// VI: Level benefit entity - đại diện cho reward/perk của một level. +/// +/// +/// EN: Benefits are stored as JSON value for flexibility. Common types: +/// - discount_percent: {"percent": 10} +/// - free_shipping: {"enabled": true} +/// - priority_support: {"enabled": true} +/// - bonus_points: {"multiplier": 1.5} +/// - exclusive_access: {"feature": "early_sale"} +/// VI: Benefits được lưu dạng JSON để linh hoạt. Các loại thường gặp: +/// - discount_percent (giảm giá %) +/// - free_shipping (miễn phí ship) +/// - priority_support (hỗ trợ ưu tiên) +/// - bonus_points (nhân điểm) +/// - exclusive_access (truy cập độc quyền) +/// +public class LevelBenefit : Entity +{ + private Guid _levelDefinitionId; + private string _benefitType; + private string _benefitValue; // JSON + private string? _description; + private bool _isActive; + private DateTime _createdAt; + + /// + /// EN: Foreign key to LevelDefinition. + /// VI: Foreign key đến LevelDefinition. + /// + public Guid LevelDefinitionId => _levelDefinitionId; + + /// + /// EN: Benefit type (discount_percent, free_shipping, etc.). + /// VI: Loại benefit (discount_percent, free_shipping, etc.). + /// + public string BenefitType => _benefitType; + + /// + /// EN: Benefit value as JSON string. + /// VI: Giá trị benefit dạng chuỗi JSON. + /// + public string BenefitValue => _benefitValue; + + /// + /// EN: Benefit description. + /// VI: Mô tả benefit. + /// + public string? Description => _description; + + /// + /// EN: Whether this benefit is active. + /// VI: Benefit có đang active không. + /// + public bool IsActive => _isActive; + + /// + /// EN: Creation timestamp. + /// VI: Thời gian tạo. + /// + public DateTime CreatedAt => _createdAt; + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + protected LevelBenefit() + { + _benefitType = string.Empty; + _benefitValue = "{}"; + } + + /// + /// EN: Create new level benefit. + /// VI: Tạo level benefit mới. + /// + /// Level definition ID / ID của level definition + /// Benefit type / Loại benefit + /// Benefit value as JSON / Giá trị benefit dạng JSON + /// Description / Mô tả + public LevelBenefit( + Guid levelDefinitionId, + string benefitType, + string benefitValue, + string? description = null) : this() + { + if (levelDefinitionId == Guid.Empty) + throw new ArgumentException("Level definition ID cannot be empty", nameof(levelDefinitionId)); + if (string.IsNullOrWhiteSpace(benefitType)) + throw new ArgumentException("Benefit type cannot be empty", nameof(benefitType)); + if (string.IsNullOrWhiteSpace(benefitValue)) + throw new ArgumentException("Benefit value cannot be empty", nameof(benefitValue)); + + Id = Guid.NewGuid(); + _levelDefinitionId = levelDefinitionId; + _benefitType = benefitType; + _benefitValue = benefitValue; + _description = description; + _isActive = true; + _createdAt = DateTime.UtcNow; + } + + /// + /// EN: Update benefit value. + /// VI: Cập nhật giá trị benefit. + /// + public void UpdateBenefitValue(string benefitValue) + { + if (string.IsNullOrWhiteSpace(benefitValue)) + throw new ArgumentException("Benefit value cannot be empty", nameof(benefitValue)); + + _benefitValue = benefitValue; + } + + /// + /// EN: Update description. + /// VI: Cập nhật mô tả. + /// + public void UpdateDescription(string? description) + { + _description = description; + } + + /// + /// EN: Activate this benefit. + /// VI: Kích hoạt benefit này. + /// + public void Activate() + { + _isActive = true; + } + + /// + /// EN: Deactivate this benefit. + /// VI: Vô hiệu hóa benefit này. + /// + public void Deactivate() + { + _isActive = false; + } +} diff --git a/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/LevelAggregate/LevelDefinition.cs b/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/LevelAggregate/LevelDefinition.cs new file mode 100644 index 00000000..cd7f315d --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/LevelAggregate/LevelDefinition.cs @@ -0,0 +1,233 @@ +using MembershipService.Domain.SeedWork; + +namespace MembershipService.Domain.AggregatesModel.LevelAggregate; + +/// +/// EN: Level definition aggregate root - configurable level rules. +/// VI: Level definition aggregate root - cấu hình level có thể tùy chỉnh. +/// +/// +/// EN: Admin can customize level definitions through API. +/// Each level has a required EXP threshold and associated benefits. +/// VI: Admin có thể tùy chỉnh level definitions qua API. +/// Mỗi level có ngưỡng EXP yêu cầu và các benefits đi kèm. +/// +public class LevelDefinition : Entity, IAggregateRoot +{ + private readonly List _benefits = new(); + + private int _levelNumber; + private string _name; + private int _requiredExp; + private string? _description; + private string? _iconUrl; + private string? _badgeColor; + private bool _isActive; + private DateTime _createdAt; + private DateTime _updatedAt; + + /// + /// EN: Level number (1, 2, 3...). + /// VI: Số thứ tự level (1, 2, 3...). + /// + public int LevelNumber => _levelNumber; + + /// + /// EN: Level name (Bronze, Silver, Gold...). + /// VI: Tên level (Bronze, Silver, Gold...). + /// + public string Name => _name; + + /// + /// EN: Required EXP to reach this level. + /// VI: EXP cần thiết để đạt level này. + /// + public int RequiredExp => _requiredExp; + + /// + /// EN: Level description. + /// VI: Mô tả level. + /// + public string? Description => _description; + + /// + /// EN: Icon URL for the level badge. + /// VI: URL icon cho badge level. + /// + public string? IconUrl => _iconUrl; + + /// + /// EN: Badge color in hex format (#CD7F32, #FFD700...). + /// VI: Màu badge dạng hex (#CD7F32, #FFD700...). + /// + public string? BadgeColor => _badgeColor; + + /// + /// EN: Whether this level is active. + /// VI: Level có đang active không. + /// + public bool IsActive => _isActive; + + /// + /// 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: Benefits associated with this level. + /// VI: Các benefits đi kèm với level này. + /// + public IReadOnlyCollection Benefits => _benefits.AsReadOnly(); + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + protected LevelDefinition() + { + _name = string.Empty; + } + + /// + /// EN: Create new level definition. + /// VI: Tạo level definition mới. + /// + /// Level number (1, 2, 3...) / Số thứ tự level + /// Level name / Tên level + /// Required EXP / EXP yêu cầu + /// Description / Mô tả + /// Icon URL / URL icon + /// Badge color (hex) / Màu badge + public LevelDefinition( + int levelNumber, + string name, + int requiredExp, + string? description = null, + string? iconUrl = null, + string? badgeColor = null) : this() + { + if (levelNumber < 1) + throw new ArgumentException("Level number must be positive", nameof(levelNumber)); + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Level name cannot be empty", nameof(name)); + if (requiredExp < 0) + throw new ArgumentException("Required EXP cannot be negative", nameof(requiredExp)); + + Id = Guid.NewGuid(); + _levelNumber = levelNumber; + _name = name; + _requiredExp = requiredExp; + _description = description; + _iconUrl = iconUrl; + _badgeColor = badgeColor; + _isActive = true; + _createdAt = DateTime.UtcNow; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Update level name. + /// VI: Cập nhật tên level. + /// + public void UpdateName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Level name cannot be empty", nameof(name)); + + _name = name; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Update required EXP threshold. + /// VI: Cập nhật ngưỡng EXP yêu cầu. + /// + public void UpdateRequiredExp(int requiredExp) + { + if (requiredExp < 0) + throw new ArgumentException("Required EXP cannot be negative", nameof(requiredExp)); + + _requiredExp = requiredExp; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Update level description. + /// VI: Cập nhật mô tả level. + /// + public void UpdateDescription(string? description) + { + _description = description; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Update icon URL. + /// VI: Cập nhật URL icon. + /// + public void UpdateIconUrl(string? iconUrl) + { + _iconUrl = iconUrl; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Update badge color. + /// VI: Cập nhật màu badge. + /// + public void UpdateBadgeColor(string? badgeColor) + { + _badgeColor = badgeColor; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Add a benefit to this level. + /// VI: Thêm một benefit cho level này. + /// + public void AddBenefit(LevelBenefit benefit) + { + if (benefit == null) + throw new ArgumentNullException(nameof(benefit)); + + _benefits.Add(benefit); + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Remove a benefit from this level. + /// VI: Xóa một benefit khỏi level này. + /// + public void RemoveBenefit(LevelBenefit benefit) + { + _benefits.Remove(benefit); + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Activate this level. + /// VI: Kích hoạt level này. + /// + public void Activate() + { + _isActive = true; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Deactivate this level (soft delete). + /// VI: Vô hiệu hóa level này (xóa mềm). + /// + public void Deactivate() + { + _isActive = false; + _updatedAt = DateTime.UtcNow; + } +} diff --git a/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/MemberAggregate/Member.cs b/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/MemberAggregate/Member.cs index 2bfbc9d6..208a52ff 100644 --- a/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/MemberAggregate/Member.cs +++ b/services/membership-service-net/src/MembershipService.Domain/AggregatesModel/MemberAggregate/Member.cs @@ -1,3 +1,5 @@ +using MembershipService.Domain.AggregatesModel.ExperienceAggregate; +using MembershipService.Domain.AggregatesModel.LevelAggregate; using MembershipService.Domain.Events; using MembershipService.Domain.SeedWork; @@ -9,9 +11,9 @@ namespace MembershipService.Domain.AggregatesModel.MemberAggregate; /// /// /// EN: This entity stores membership-specific data. Profile information (avatar, phone, address, DOB) -/// is managed by IAM Service's UserProfile. This service only handles membership level, gender, and preferences. +/// is managed by IAM Service's UserProfile. This service handles level, experience, and preferences. /// VI: Entity này lưu trữ dữ liệu membership. Thông tin profile (avatar, phone, address, DOB) -/// được quản lý bởi UserProfile của IAM Service. Service này chỉ xử lý membership level, gender, và preferences. +/// được quản lý bởi UserProfile của IAM Service. Service này xử lý level, experience, và preferences. /// public class Member : Entity, IAggregateRoot { @@ -19,8 +21,9 @@ public class Member : Entity, IAggregateRoot // VI: Fields private để đóng gói private string _countryCode; private string? _gender; - private int _membershipLevelId; - private MembershipLevel _membershipLevel = null!; + private int _currentLevel; + private int _currentExp; + private int _totalExpEarned; private string? _preferences; // JSON string private DateTime _createdAt; private DateTime _updatedAt; @@ -45,16 +48,22 @@ public class Member : Entity, IAggregateRoot public string? Gender => _gender; /// - /// EN: Membership level ID for EF Core mapping. - /// VI: Membership level ID cho EF Core mapping. + /// EN: Current member level. + /// VI: Level hiện tại của member. /// - public int MembershipLevelId => _membershipLevelId; + public int CurrentLevel => _currentLevel; /// - /// EN: Current membership level. - /// VI: Cấp thành viên hiện tại. + /// EN: Current experience points. + /// VI: Điểm kinh nghiệm hiện tại. /// - public MembershipLevel MembershipLevel => _membershipLevel; + public int CurrentExp => _currentExp; + + /// + /// EN: Total experience points ever earned. + /// VI: Tổng điểm kinh nghiệm đã kiếm được. + /// + public int TotalExpEarned => _totalExpEarned; /// /// EN: User preferences as JSON string. @@ -104,8 +113,9 @@ public class Member : Entity, IAggregateRoot Id = userId; _countryCode = countryCode; _gender = gender; - _membershipLevelId = MembershipLevel.Free.Id; - _membershipLevel = MembershipLevel.Free; + _currentLevel = 1; // Start at level 1 + _currentExp = 0; + _totalExpEarned = 0; _createdAt = DateTime.UtcNow; _updatedAt = DateTime.UtcNow; @@ -114,6 +124,81 @@ public class Member : Entity, IAggregateRoot AddDomainEvent(new MemberCreatedDomainEvent(this)); } + /// + /// EN: Add experience points and automatically level up if thresholds are met. + /// VI: Thêm điểm kinh nghiệm và tự động lên level nếu đạt ngưỡng. + /// + /// Amount of EXP to add / Số EXP cộng thêm + /// Source of experience / Nguồn EXP + /// Active level definitions / Các level definitions đang active + /// Reference ID (optional) / ID tham chiếu (tùy chọn) + /// Metadata JSON (optional) / Metadata JSON (tùy chọn) + /// ExperienceTransaction for tracking / ExperienceTransaction để tracking + public ExperienceTransaction AddExperience( + int points, + ExperienceSource source, + IReadOnlyList levelRules, + string? referenceId = null, + string? metadata = null) + { + if (points <= 0) + throw new ArgumentException("EXP points must be positive", nameof(points)); + if (source == null) + throw new ArgumentNullException(nameof(source)); + if (levelRules == null || !levelRules.Any()) + throw new ArgumentException("Level rules cannot be empty", nameof(levelRules)); + + int oldLevel = _currentLevel; + + // EN: Create transaction for tracking + // VI: Tạo transaction để tracking + var transaction = new ExperienceTransaction( + memberId: Id, + points: points, + source: source, + levelAtTime: oldLevel, + referenceId: referenceId, + metadata: metadata); + + // EN: Update experience points + // VI: Cập nhật điểm kinh nghiệm + _currentExp += points; + _totalExpEarned += points; + _updatedAt = DateTime.UtcNow; + + // EN: Calculate new level based on rules + // VI: Tính level mới dựa trên rules + var newLevel = CalculateLevel(_currentExp, levelRules); + + // EN: Check if leveled up + // VI: Kiểm tra có lên level không + if (newLevel > oldLevel) + { + _currentLevel = newLevel; + // EN: Raise level up event for side effects (rewards, notifications, etc.) + // VI: Raise level up event cho side effects (rewards, notifications, etc.) + AddDomainEvent(new MemberLevelUpDomainEvent(this, oldLevel, newLevel)); + } + + // EN: Raise experience added event + // VI: Raise event khi thêm experience + AddDomainEvent(new MemberExperienceAddedDomainEvent(this, points, source)); + + return transaction; + } + + /// + /// EN: Calculate level based on current EXP and level rules. + /// VI: Tính level dựa trên EXP hiện tại và level rules. + /// + private static int CalculateLevel(int exp, IReadOnlyList rules) + { + return rules + .Where(r => r.IsActive && r.RequiredExp <= exp) + .OrderByDescending(r => r.LevelNumber) + .FirstOrDefault()?.LevelNumber ?? 1; + } + /// /// EN: Update gender. /// VI: Cập nhật giới tính. @@ -153,28 +238,6 @@ public class Member : Entity, IAggregateRoot AddDomainEvent(new MemberUpdatedDomainEvent(this)); } - /// - /// EN: Change membership level. - /// VI: Thay đổi cấp thành viên. - /// - public void ChangeMembershipLevel(MembershipLevel newLevel) - { - if (newLevel == null) - throw new ArgumentNullException(nameof(newLevel)); - - // EN: Skip if level is the same - // VI: Bỏ qua nếu level giống nhau - if (_membershipLevelId == newLevel.Id) - return; - - var oldLevel = _membershipLevel; - _membershipLevelId = newLevel.Id; - _membershipLevel = newLevel; - _updatedAt = DateTime.UtcNow; - - AddDomainEvent(new MembershipLevelChangedDomainEvent(this, oldLevel, newLevel)); - } - /// /// EN: Mark member as deleted (soft delete). /// VI: Đánh dấu member đã xóa (xóa mềm). @@ -194,13 +257,4 @@ public class Member : Entity, IAggregateRoot _isDeleted = false; _updatedAt = DateTime.UtcNow; } - - /// - /// EN: Set membership level (for EF Core loading). - /// VI: Set membership level (cho EF Core loading). - /// - internal void SetMembershipLevel(MembershipLevel level) - { - _membershipLevel = level; - } } diff --git a/services/membership-service-net/src/MembershipService.Domain/Events/MemberExperienceAddedDomainEvent.cs b/services/membership-service-net/src/MembershipService.Domain/Events/MemberExperienceAddedDomainEvent.cs new file mode 100644 index 00000000..2dc68196 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/Events/MemberExperienceAddedDomainEvent.cs @@ -0,0 +1,62 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.ExperienceAggregate; +using MembershipService.Domain.AggregatesModel.MemberAggregate; + +namespace MembershipService.Domain.Events; + +/// +/// EN: Domain event raised when a member receives experience points. +/// VI: Domain event được phát ra khi member nhận điểm kinh nghiệm. +/// +/// +/// EN: This event is used for logging, analytics, and potential side effects. +/// VI: Event này được dùng cho logging, analytics, và các side effects tiềm năng. +/// +public class MemberExperienceAddedDomainEvent : INotification +{ + /// + /// EN: Event ID. + /// VI: ID của event. + /// + public Guid Id { get; } = Guid.NewGuid(); + + /// + /// EN: When the event occurred. + /// VI: Thời điểm event xảy ra. + /// + public DateTime OccurredOn { get; } = DateTime.UtcNow; + + /// + /// EN: The member who received EXP. + /// VI: Member đã nhận EXP. + /// + public Member Member { get; } + + /// + /// EN: Amount of EXP points received. + /// VI: Số điểm EXP nhận được. + /// + public int Points { get; } + + /// + /// EN: Source of the experience points. + /// VI: Nguồn gốc của điểm kinh nghiệm. + /// + public ExperienceSource Source { get; } + + /// + /// EN: Member ID. + /// VI: ID của member. + /// + public Guid MemberId => Member.Id; + + public MemberExperienceAddedDomainEvent( + Member member, + int points, + ExperienceSource source) + { + Member = member ?? throw new ArgumentNullException(nameof(member)); + Points = points; + Source = source ?? throw new ArgumentNullException(nameof(source)); + } +} diff --git a/services/membership-service-net/src/MembershipService.Domain/Events/MemberLevelUpDomainEvent.cs b/services/membership-service-net/src/MembershipService.Domain/Events/MemberLevelUpDomainEvent.cs new file mode 100644 index 00000000..b1a79c6a --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Domain/Events/MemberLevelUpDomainEvent.cs @@ -0,0 +1,58 @@ +using MediatR; +using MembershipService.Domain.AggregatesModel.MemberAggregate; + +namespace MembershipService.Domain.Events; + +/// +/// EN: Domain event raised when a member levels up. +/// VI: Domain event được phát ra khi member lên level. +/// +/// +/// EN: This event is used to trigger side effects like rewards, notifications, etc. +/// VI: Event này được dùng để trigger các side effects như rewards, notifications, etc. +/// +public class MemberLevelUpDomainEvent : INotification +{ + /// + /// EN: Event ID. + /// VI: ID của event. + /// + public Guid Id { get; } = Guid.NewGuid(); + + /// + /// EN: When the event occurred. + /// VI: Thời điểm event xảy ra. + /// + public DateTime OccurredOn { get; } = DateTime.UtcNow; + + /// + /// EN: The member who leveled up. + /// VI: Member đã lên level. + /// + public Member Member { get; } + + /// + /// EN: Previous level before leveling up. + /// VI: Level trước khi lên. + /// + public int OldLevel { get; } + + /// + /// EN: New level after leveling up. + /// VI: Level mới sau khi lên. + /// + public int NewLevel { get; } + + /// + /// EN: Member ID. + /// VI: ID của member. + /// + public Guid MemberId => Member.Id; + + public MemberLevelUpDomainEvent(Member member, int oldLevel, int newLevel) + { + Member = member ?? throw new ArgumentNullException(nameof(member)); + OldLevel = oldLevel; + NewLevel = newLevel; + } +} diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/DependencyInjection.cs b/services/membership-service-net/src/MembershipService.Infrastructure/DependencyInjection.cs index e521bcf6..ee856b90 100644 --- a/services/membership-service-net/src/MembershipService.Infrastructure/DependencyInjection.cs +++ b/services/membership-service-net/src/MembershipService.Infrastructure/DependencyInjection.cs @@ -1,6 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using MembershipService.Domain.AggregatesModel.ExperienceAggregate; +using MembershipService.Domain.AggregatesModel.LevelAggregate; using MembershipService.Domain.AggregatesModel.MemberAggregate; using MembershipService.Infrastructure.ExternalServices; using MembershipService.Infrastructure.Idempotency; @@ -49,6 +51,8 @@ public static class DependencyInjection // EN: Register repositories / VI: Đăng ký repositories services.AddScoped(); + services.AddScoped(); + services.AddScoped(); // EN: Register idempotency services / VI: Đăng ký idempotency services services.AddScoped(); diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/ExperienceTransactionEntityTypeConfiguration.cs b/services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/ExperienceTransactionEntityTypeConfiguration.cs new file mode 100644 index 00000000..4833deba --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/ExperienceTransactionEntityTypeConfiguration.cs @@ -0,0 +1,84 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MembershipService.Domain.AggregatesModel.ExperienceAggregate; + +namespace MembershipService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for ExperienceTransaction entity. +/// VI: Cấu hình entity cho ExperienceTransaction entity. +/// +public class ExperienceTransactionEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("experience_transactions"); + + // EN: Primary key + // VI: Primary key + builder.HasKey(t => t.Id); + + builder.Property(t => t.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + // EN: Member ID (foreign key) + // VI: ID member (foreign key) + builder.Property("_memberId") + .HasColumnName("member_id") + .IsRequired(); + + builder.HasIndex("_memberId") + .HasDatabaseName("ix_experience_transactions_member_id"); + + // EN: Points + // VI: Điểm + builder.Property("_points") + .HasColumnName("points") + .IsRequired(); + + // EN: Source ID + // VI: ID nguồn + builder.Property("_sourceId") + .HasColumnName("source_id") + .IsRequired(); + + builder.HasIndex("_sourceId") + .HasDatabaseName("ix_experience_transactions_source"); + + // EN: Reference ID (Order ID, etc.) + // VI: ID tham chiếu (Order ID, etc.) + builder.Property("_referenceId") + .HasColumnName("reference_id") + .HasMaxLength(100); + + // EN: Metadata (JSON) + // VI: Metadata (JSON) + builder.Property("_metadata") + .HasColumnName("metadata") + .HasColumnType("jsonb"); + + // EN: Level at time of transaction + // VI: Level tại thời điểm giao dịch + builder.Property("_levelAtTime") + .HasColumnName("level_at_time") + .IsRequired(); + + // EN: Created timestamp + // VI: Thời gian tạo + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + builder.HasIndex("_createdAt") + .HasDatabaseName("ix_experience_transactions_created_at"); + + // EN: Ignore Source navigation property (it's an Enumeration) + // VI: Bỏ qua navigation property Source (nó là Enumeration) + builder.Ignore(t => t.Source); + + // EN: Ignore domain events + // VI: Bỏ qua domain events + builder.Ignore(t => t.DomainEvents); + } +} diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/LevelBenefitEntityTypeConfiguration.cs b/services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/LevelBenefitEntityTypeConfiguration.cs new file mode 100644 index 00000000..e18bbcf5 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/LevelBenefitEntityTypeConfiguration.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MembershipService.Domain.AggregatesModel.LevelAggregate; + +namespace MembershipService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for LevelBenefit entity. +/// VI: Cấu hình entity cho LevelBenefit entity. +/// +public class LevelBenefitEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("level_benefits"); + + // EN: Primary key + // VI: Primary key + builder.HasKey(b => b.Id); + + builder.Property(b => b.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + // EN: Foreign key to LevelDefinition + // VI: Foreign key đến LevelDefinition + builder.Property("_levelDefinitionId") + .HasColumnName("level_definition_id") + .IsRequired(); + + builder.HasIndex("_levelDefinitionId") + .HasDatabaseName("ix_level_benefits_level_definition_id"); + + // EN: Benefit type + // VI: Loại benefit + builder.Property("_benefitType") + .HasColumnName("benefit_type") + .HasMaxLength(50) + .IsRequired(); + + // EN: Benefit value (JSON) + // VI: Giá trị benefit (JSON) + builder.Property("_benefitValue") + .HasColumnName("benefit_value") + .HasColumnType("jsonb") + .IsRequired(); + + // EN: Description + // VI: Mô tả + builder.Property("_description") + .HasColumnName("description"); + + // EN: Active status + // VI: Trạng thái active + builder.Property("_isActive") + .HasColumnName("is_active") + .IsRequired() + .HasDefaultValue(true); + + // EN: Timestamps + // VI: Timestamps + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + // EN: Ignore domain events + // VI: Bỏ qua domain events + builder.Ignore(b => b.DomainEvents); + } +} diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/LevelDefinitionEntityTypeConfiguration.cs b/services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/LevelDefinitionEntityTypeConfiguration.cs new file mode 100644 index 00000000..835e2a0a --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/LevelDefinitionEntityTypeConfiguration.cs @@ -0,0 +1,97 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MembershipService.Domain.AggregatesModel.LevelAggregate; + +namespace MembershipService.Infrastructure.EntityConfigurations; + +/// +/// EN: Entity configuration for LevelDefinition aggregate. +/// VI: Cấu hình entity cho LevelDefinition aggregate. +/// +public class LevelDefinitionEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("level_definitions"); + + // EN: Primary key + // VI: Primary key + builder.HasKey(l => l.Id); + + builder.Property(l => l.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + + // EN: Level number (unique) + // VI: Số thứ tự level (unique) + builder.Property("_levelNumber") + .HasColumnName("level_number") + .IsRequired(); + + builder.HasIndex("_levelNumber") + .IsUnique() + .HasDatabaseName("ix_level_definitions_level_number"); + + // EN: Level name + // VI: Tên level + builder.Property("_name") + .HasColumnName("name") + .HasMaxLength(100) + .IsRequired(); + + // EN: Required EXP + // VI: EXP yêu cầu + builder.Property("_requiredExp") + .HasColumnName("required_exp") + .IsRequired() + .HasDefaultValue(0); + + // EN: Description + // VI: Mô tả + builder.Property("_description") + .HasColumnName("description"); + + // EN: Icon URL + // VI: URL icon + builder.Property("_iconUrl") + .HasColumnName("icon_url") + .HasMaxLength(500); + + // EN: Badge color + // VI: Màu badge + builder.Property("_badgeColor") + .HasColumnName("badge_color") + .HasMaxLength(20); + + // EN: Active status + // VI: Trạng thái active + builder.Property("_isActive") + .HasColumnName("is_active") + .IsRequired() + .HasDefaultValue(true); + + builder.HasIndex("_isActive") + .HasDatabaseName("ix_level_definitions_is_active"); + + // EN: Timestamps + // VI: Timestamps + builder.Property("_createdAt") + .HasColumnName("created_at") + .IsRequired(); + + builder.Property("_updatedAt") + .HasColumnName("updated_at") + .IsRequired(); + + // EN: Relationship with benefits + // VI: Quan hệ với benefits + builder.HasMany(l => l.Benefits) + .WithOne() + .HasForeignKey("_levelDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + // EN: Ignore domain events + // VI: Bỏ qua domain events + builder.Ignore(l => l.DomainEvents); + } +} diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/MemberEntityTypeConfiguration.cs b/services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/MemberEntityTypeConfiguration.cs index b3d91245..ab49c8b5 100644 --- a/services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/MemberEntityTypeConfiguration.cs +++ b/services/membership-service-net/src/MembershipService.Infrastructure/EntityConfigurations/MemberEntityTypeConfiguration.cs @@ -35,16 +35,26 @@ public class MemberEntityTypeConfiguration : IEntityTypeConfiguration .HasColumnName("gender") .HasMaxLength(10); - // EN: Membership level - // VI: Cấp thành viên - builder.Property("_membershipLevelId") - .HasColumnName("membership_level_id") - .IsRequired(); + // EN: Current level + // VI: Level hiện tại + builder.Property("_currentLevel") + .HasColumnName("current_level") + .IsRequired() + .HasDefaultValue(1); - builder.HasOne(m => m.MembershipLevel) - .WithMany() - .HasForeignKey("_membershipLevelId") - .OnDelete(DeleteBehavior.Restrict); + // EN: Current experience points + // VI: Điểm kinh nghiệm hiện tại + builder.Property("_currentExp") + .HasColumnName("current_exp") + .IsRequired() + .HasDefaultValue(0); + + // EN: Total experience points ever earned + // VI: Tổng điểm kinh nghiệm đã kiếm được + builder.Property("_totalExpEarned") + .HasColumnName("total_exp_earned") + .IsRequired() + .HasDefaultValue(0); // EN: Preferences (JSON) // VI: Preferences (JSON) @@ -77,8 +87,11 @@ public class MemberEntityTypeConfiguration : IEntityTypeConfiguration builder.HasIndex("_createdAt") .HasDatabaseName("ix_members_created_at"); - builder.HasIndex("_membershipLevelId") - .HasDatabaseName("ix_members_membership_level"); + builder.HasIndex("_currentLevel") + .HasDatabaseName("ix_members_current_level"); + + builder.HasIndex("_currentExp") + .HasDatabaseName("ix_members_current_exp"); builder.HasIndex("_isDeleted") .HasDatabaseName("ix_members_is_deleted"); diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/MembershipServiceContext.cs b/services/membership-service-net/src/MembershipService.Infrastructure/MembershipServiceContext.cs index 0c6f6ade..d97d4ca9 100644 --- a/services/membership-service-net/src/MembershipService.Infrastructure/MembershipServiceContext.cs +++ b/services/membership-service-net/src/MembershipService.Infrastructure/MembershipServiceContext.cs @@ -1,6 +1,8 @@ using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; +using MembershipService.Domain.AggregatesModel.ExperienceAggregate; +using MembershipService.Domain.AggregatesModel.LevelAggregate; using MembershipService.Domain.AggregatesModel.MemberAggregate; using MembershipService.Domain.SeedWork; @@ -34,10 +36,22 @@ public class MembershipServiceContext : DbContext, IUnitOfWork public DbSet Members { get; set; } = null!; /// - /// EN: Membership levels table. - /// VI: Bảng cấp thành viên. + /// EN: Level definitions table. + /// VI: Bảng level definitions. /// - public DbSet MembershipLevels { get; set; } = null!; + public DbSet LevelDefinitions { get; set; } = null!; + + /// + /// EN: Level benefits table. + /// VI: Bảng level benefits. + /// + public DbSet LevelBenefits { get; set; } = null!; + + /// + /// EN: Experience transactions table. + /// VI: Bảng experience transactions. + /// + public DbSet ExperienceTransactions { get; set; } = null!; /// /// EN: Check if there's an active transaction. @@ -52,15 +66,6 @@ public class MembershipServiceContext : DbContext, IUnitOfWork // EN: Apply entity configurations // VI: Áp dụng entity configurations modelBuilder.ApplyConfigurationsFromAssembly(typeof(MembershipServiceContext).Assembly); - - // EN: Seed MembershipLevel enumeration - // VI: Seed MembershipLevel enumeration - modelBuilder.Entity().ToTable("membership_levels"); - modelBuilder.Entity().HasData( - MembershipLevel.Free, - MembershipLevel.Basic, - MembershipLevel.Premium - ); } /// diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/Repositories/ExperienceTransactionRepository.cs b/services/membership-service-net/src/MembershipService.Infrastructure/Repositories/ExperienceTransactionRepository.cs new file mode 100644 index 00000000..3f094eb6 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Infrastructure/Repositories/ExperienceTransactionRepository.cs @@ -0,0 +1,133 @@ +using Microsoft.EntityFrameworkCore; +using MembershipService.Domain.AggregatesModel.ExperienceAggregate; +using MembershipService.Domain.SeedWork; + +namespace MembershipService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for ExperienceTransaction. +/// VI: Repository implementation cho ExperienceTransaction. +/// +public class ExperienceTransactionRepository : IExperienceTransactionRepository +{ + private readonly MembershipServiceContext _context; + + public ExperienceTransactionRepository(MembershipServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public IUnitOfWork UnitOfWork => _context; + + /// + /// EN: Get experience transaction by ID. + /// VI: Lấy experience transaction theo ID. + /// + public async Task GetAsync(Guid id) + { + var transaction = await _context.ExperienceTransactions + .FirstOrDefaultAsync(t => t.Id == id); + + if (transaction != null) + { + // EN: Set the Source enumeration based on SourceId + // VI: Set Source enumeration dựa trên SourceId + var source = ExperienceSource.FromValue(transaction.SourceId); + transaction.SetSource(source); + } + + return transaction; + } + + /// + /// EN: Get paginated experience transactions for a member. + /// VI: Lấy experience transactions phân trang cho một member. + /// + public async Task> GetByMemberIdAsync( + Guid memberId, + int skip = 0, + int take = 20) + { + var transactions = await _context.ExperienceTransactions + .Where(t => EF.Property(t, "_memberId") == memberId) + .OrderByDescending(t => EF.Property(t, "_createdAt")) + .Skip(skip) + .Take(take) + .ToListAsync(); + + // EN: Set Source for each transaction + // VI: Set Source cho mỗi transaction + foreach (var transaction in transactions) + { + var source = ExperienceSource.FromValue(transaction.SourceId); + transaction.SetSource(source); + } + + return transactions; + } + + /// + /// EN: Get experience transactions by source for a member. + /// VI: Lấy experience transactions theo nguồn cho một member. + /// + public async Task> GetBySourceAsync( + Guid memberId, + ExperienceSource source) + { + var transactions = await _context.ExperienceTransactions + .Where(t => EF.Property(t, "_memberId") == memberId && + EF.Property(t, "_sourceId") == source.Id) + .OrderByDescending(t => EF.Property(t, "_createdAt")) + .ToListAsync(); + + foreach (var transaction in transactions) + { + transaction.SetSource(source); + } + + return transactions; + } + + /// + /// EN: Get total EXP points for a member. + /// VI: Lấy tổng điểm EXP của một member. + /// + public async Task GetTotalPointsByMemberIdAsync(Guid memberId) + { + return await _context.ExperienceTransactions + .Where(t => EF.Property(t, "_memberId") == memberId) + .SumAsync(t => EF.Property(t, "_points")); + } + + /// + /// EN: Get total EXP points by source for a member. + /// VI: Lấy tổng điểm EXP theo nguồn cho một member. + /// + public async Task GetTotalPointsBySourceAsync(Guid memberId, ExperienceSource source) + { + return await _context.ExperienceTransactions + .Where(t => EF.Property(t, "_memberId") == memberId && + EF.Property(t, "_sourceId") == source.Id) + .SumAsync(t => EF.Property(t, "_points")); + } + + /// + /// EN: Get transaction count for a member. + /// VI: Lấy số lượng transactions của một member. + /// + public async Task GetCountByMemberIdAsync(Guid memberId) + { + return await _context.ExperienceTransactions + .Where(t => EF.Property(t, "_memberId") == memberId) + .CountAsync(); + } + + /// + /// EN: Add new experience transaction. + /// VI: Thêm experience transaction mới. + /// + public ExperienceTransaction Add(ExperienceTransaction transaction) + { + return _context.ExperienceTransactions.Add(transaction).Entity; + } +} diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/Repositories/LevelDefinitionRepository.cs b/services/membership-service-net/src/MembershipService.Infrastructure/Repositories/LevelDefinitionRepository.cs new file mode 100644 index 00000000..81506dc9 --- /dev/null +++ b/services/membership-service-net/src/MembershipService.Infrastructure/Repositories/LevelDefinitionRepository.cs @@ -0,0 +1,103 @@ +using Microsoft.EntityFrameworkCore; +using MembershipService.Domain.AggregatesModel.LevelAggregate; +using MembershipService.Domain.SeedWork; + +namespace MembershipService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for LevelDefinition aggregate. +/// VI: Repository implementation cho LevelDefinition aggregate. +/// +public class LevelDefinitionRepository : ILevelDefinitionRepository +{ + private readonly MembershipServiceContext _context; + + public LevelDefinitionRepository(MembershipServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + public IUnitOfWork UnitOfWork => _context; + + /// + /// EN: Get level definition by ID. + /// VI: Lấy level definition theo ID. + /// + public async Task GetAsync(Guid id) + { + return await _context.LevelDefinitions + .FirstOrDefaultAsync(l => l.Id == id); + } + + /// + /// EN: Get level definition by level number. + /// VI: Lấy level definition theo số thứ tự level. + /// + public async Task GetByLevelNumberAsync(int levelNumber) + { + return await _context.LevelDefinitions + .FirstOrDefaultAsync(l => EF.Property(l, "_levelNumber") == levelNumber); + } + + /// + /// EN: Get level definition with benefits. + /// VI: Lấy level definition kèm benefits. + /// + public async Task GetWithBenefitsAsync(Guid id) + { + return await _context.LevelDefinitions + .Include(l => l.Benefits) + .FirstOrDefaultAsync(l => l.Id == id); + } + + /// + /// EN: Get all active level definitions ordered by level number. + /// VI: Lấy tất cả level definitions đang active, sắp xếp theo số thứ tự. + /// + public async Task> GetAllActiveAsync() + { + return await _context.LevelDefinitions + .Where(l => EF.Property(l, "_isActive")) + .OrderBy(l => EF.Property(l, "_levelNumber")) + .ToListAsync(); + } + + /// + /// EN: Get all level definitions (including inactive). + /// VI: Lấy tất cả level definitions (bao gồm cả inactive). + /// + public async Task> GetAllAsync() + { + return await _context.LevelDefinitions + .OrderBy(l => EF.Property(l, "_levelNumber")) + .ToListAsync(); + } + + /// + /// EN: Check if level number already exists. + /// VI: Kiểm tra xem số thứ tự level đã tồn tại chưa. + /// + public async Task ExistsByLevelNumberAsync(int levelNumber) + { + return await _context.LevelDefinitions + .AnyAsync(l => EF.Property(l, "_levelNumber") == levelNumber); + } + + /// + /// EN: Add new level definition. + /// VI: Thêm level definition mới. + /// + public LevelDefinition Add(LevelDefinition levelDefinition) + { + return _context.LevelDefinitions.Add(levelDefinition).Entity; + } + + /// + /// EN: Update level definition. + /// VI: Cập nhật level definition. + /// + public void Update(LevelDefinition levelDefinition) + { + _context.Entry(levelDefinition).State = EntityState.Modified; + } +} diff --git a/services/membership-service-net/src/MembershipService.Infrastructure/Repositories/MemberRepository.cs b/services/membership-service-net/src/MembershipService.Infrastructure/Repositories/MemberRepository.cs index b64823c0..e7a37af7 100644 --- a/services/membership-service-net/src/MembershipService.Infrastructure/Repositories/MemberRepository.cs +++ b/services/membership-service-net/src/MembershipService.Infrastructure/Repositories/MemberRepository.cs @@ -26,7 +26,6 @@ public class MemberRepository : IMemberRepository public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { return await _context.Members - .Include(m => m.MembershipLevel) .FirstOrDefaultAsync(m => m.Id == id, cancellationToken); } @@ -51,17 +50,15 @@ public class MemberRepository : IMemberRepository string? searchTerm = null, CancellationToken cancellationToken = default) { - var query = _context.Members - .Include(m => m.MembershipLevel) - .AsQueryable(); + var query = _context.Members.AsQueryable(); - // EN: Apply search filter if provided - // VI: Áp dụng filter tìm kiếm nếu có + // EN: Apply search filter if provided (search by country code or gender) + // VI: Áp dụng filter tìm kiếm nếu có (tìm theo country code hoặc gender) if (!string.IsNullOrWhiteSpace(searchTerm)) { query = query.Where(m => - EF.Property(m, "_phoneNumber") != null && - EF.Property(m, "_phoneNumber").Contains(searchTerm)); + m.CountryCode.Contains(searchTerm) || + (m.Gender != null && m.Gender.Contains(searchTerm))); } var totalCount = await query.CountAsync(cancellationToken); diff --git a/services/membership-service-net/tests/MembershipService.UnitTests/Domain/MemberAggregateTests.cs b/services/membership-service-net/tests/MembershipService.UnitTests/Domain/MemberAggregateTests.cs index 1bd94353..d8034aac 100644 --- a/services/membership-service-net/tests/MembershipService.UnitTests/Domain/MemberAggregateTests.cs +++ b/services/membership-service-net/tests/MembershipService.UnitTests/Domain/MemberAggregateTests.cs @@ -1,5 +1,8 @@ using FluentAssertions; +using MembershipService.Domain.AggregatesModel.ExperienceAggregate; +using MembershipService.Domain.AggregatesModel.LevelAggregate; using MembershipService.Domain.AggregatesModel.MemberAggregate; +using MembershipService.Domain.Events; using Xunit; namespace MembershipService.UnitTests.Domain; @@ -11,7 +14,7 @@ namespace MembershipService.UnitTests.Domain; public class MemberAggregateTests { [Fact] - public void CreateMember_WithValidUserId_ShouldCreateMemberWithFreeLevel() + public void CreateMember_WithValidUserId_ShouldCreateMemberWithLevel1() { // Arrange var userId = Guid.NewGuid(); @@ -22,7 +25,9 @@ public class MemberAggregateTests // Assert member.Id.Should().Be(userId); member.UserId.Should().Be(userId); - member.MembershipLevel.Should().Be(MembershipLevel.Free); + member.CurrentLevel.Should().Be(1); + member.CurrentExp.Should().Be(0); + member.TotalExpEarned.Should().Be(0); member.CountryCode.Should().Be("VN"); member.IsDeleted.Should().BeFalse(); member.DomainEvents.Should().ContainSingle(); @@ -102,33 +107,70 @@ public class MemberAggregateTests } [Fact] - public void ChangeMembershipLevel_ShouldChangeLevel() + public void AddExperience_ShouldAddExpAndRaiseEvent() { // Arrange var member = new Member(Guid.NewGuid()); member.ClearDomainEvents(); + var levelRules = CreateDefaultLevelRules(); // Act - member.ChangeMembershipLevel(MembershipLevel.Premium); + var transaction = member.AddExperience(50, ExperienceSource.Purchase, levelRules, "ORDER-123"); // Assert - member.MembershipLevel.Should().Be(MembershipLevel.Premium); - member.DomainEvents.Should().ContainSingle(); + member.CurrentExp.Should().Be(50); + member.TotalExpEarned.Should().Be(50); + member.CurrentLevel.Should().Be(1); // Not enough for level 2 + transaction.Points.Should().Be(50); + transaction.Source.Should().Be(ExperienceSource.Purchase); + transaction.ReferenceId.Should().Be("ORDER-123"); + member.DomainEvents.Should().ContainSingle(e => e is MemberExperienceAddedDomainEvent); } [Fact] - public void ChangeMembershipLevel_ToSameLevel_ShouldNotRaiseEvent() + public void AddExperience_ShouldLevelUp_WhenThresholdReached() { // Arrange var member = new Member(Guid.NewGuid()); member.ClearDomainEvents(); + var levelRules = CreateDefaultLevelRules(); // Act - member.ChangeMembershipLevel(MembershipLevel.Free); + var transaction = member.AddExperience(150, ExperienceSource.Purchase, levelRules); // Assert - member.MembershipLevel.Should().Be(MembershipLevel.Free); - member.DomainEvents.Should().BeEmpty(); + member.CurrentExp.Should().Be(150); + member.CurrentLevel.Should().Be(2); // Silver at 100 EXP + member.DomainEvents.Should().Contain(e => e is MemberLevelUpDomainEvent); + member.DomainEvents.Should().Contain(e => e is MemberExperienceAddedDomainEvent); + } + + [Fact] + public void AddExperience_ShouldLevelUpMultipleLevels_WhenBigExpGain() + { + // Arrange + var member = new Member(Guid.NewGuid()); + member.ClearDomainEvents(); + var levelRules = CreateDefaultLevelRules(); + + // Act + member.AddExperience(500, ExperienceSource.Admin, levelRules); + + // Assert + member.CurrentExp.Should().Be(500); + member.CurrentLevel.Should().Be(3); // Gold at 300 EXP + } + + [Fact] + public void AddExperience_WithNegativePoints_ShouldThrow() + { + // Arrange + var member = new Member(Guid.NewGuid()); + var levelRules = CreateDefaultLevelRules(); + + // Act & Assert + var act = () => member.AddExperience(-10, ExperienceSource.Purchase, levelRules); + act.Should().Throw().WithMessage("*positive*"); } [Fact] @@ -143,4 +185,16 @@ public class MemberAggregateTests // Assert member.IsDeleted.Should().BeTrue(); } + + private static IReadOnlyList CreateDefaultLevelRules() + { + return new List + { + new LevelDefinition(1, "Bronze", 0, "Starting level"), + new LevelDefinition(2, "Silver", 100, "Reach 100 EXP"), + new LevelDefinition(3, "Gold", 300, "Reach 300 EXP"), + new LevelDefinition(4, "Platinum", 600, "Reach 600 EXP"), + new LevelDefinition(5, "Diamond", 1000, "Reach 1000 EXP") + }; + } } diff --git a/services/merchant-service-net/.env.example b/services/merchant-service-net/.env.example new file mode 100644 index 00000000..f9053bc3 --- /dev/null +++ b/services/merchant-service-net/.env.example @@ -0,0 +1,40 @@ +# Environment / Môi Trường +ASPNETCORE_ENVIRONMENT=Development + +# Database / Cơ Sở Dữ Liệu +# PostgreSQL connection string (Neon or local) +DATABASE_URL=Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres + +# Redis Cache +REDIS_URL=localhost:6379 +REDIS_PASSWORD= + +# JWT Authentication / Xác Thực JWT +JWT_SECRET=your-secret-key-min-32-characters-long-here +JWT_ISSUER=goodgo-platform +JWT_AUDIENCE=goodgo-services +JWT_ACCESS_TOKEN_EXPIRY_MINUTES=15 +JWT_REFRESH_TOKEN_EXPIRY_DAYS=7 + +# API Configuration / Cấu Hình API +API_PORT=5000 +API_BASE_PATH=/api/v1/myservice + +# Observability / Quan Sát +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +OTEL_SERVICE_NAME=myservice + +# Logging +LOG_LEVEL=Information +SEQ_URL=http://localhost:5341 + +# Feature Flags +FEATURE_SWAGGER_ENABLED=true +FEATURE_DETAILED_ERRORS=true + +# Rate Limiting +RATE_LIMIT_PERMITS_PER_MINUTE=100 +RATE_LIMIT_QUEUE_LIMIT=10 + +# Health Checks +HEALTHCHECK_TIMEOUT_SECONDS=5 diff --git a/services/merchant-service-net/.gitignore b/services/merchant-service-net/.gitignore new file mode 100644 index 00000000..84b02a53 --- /dev/null +++ b/services/merchant-service-net/.gitignore @@ -0,0 +1,75 @@ +# Build results +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio +.vs/ +*.user +*.userosscache +*.suo +*.userprefs +*.sln.docstates + +# Rider +.idea/ +*.sln.iml + +# Visual Studio Code +.vscode/ + +# NuGet +*.nupkg +*.snupkg +.nuget/ +packages/ +project.lock.json +project.fragment.lock.json + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# Coverage +TestResults/ +*.coverage +*.coveragexml +coverage*.json +coverage*.xml + +# Publish output +publish/ +out/ + +# Environment files +.env +.env.local +.env.*.local +*.env + +# Secrets +appsettings.*.json +!appsettings.json +!appsettings.Development.json + +# macOS +.DS_Store + +# Windows +Thumbs.db +ehthumbs.db + +# JetBrains +*.resharper + +# dotnet tools +.config/dotnet-tools.json + +# Migration scripts (only keep structure) +Migrations/ + +# Temp files +*.tmp +*.temp +~$* diff --git a/services/merchant-service-net/Directory.Build.props b/services/merchant-service-net/Directory.Build.props new file mode 100644 index 00000000..c3b74373 --- /dev/null +++ b/services/merchant-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/merchant-service-net/Dockerfile b/services/merchant-service-net/Dockerfile new file mode 100644 index 00000000..785f0e3e --- /dev/null +++ b/services/merchant-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/MerchantService.API/MerchantService.API.csproj", "src/MerchantService.API/"] +COPY ["src/MerchantService.Domain/MerchantService.Domain.csproj", "src/MerchantService.Domain/"] +COPY ["src/MerchantService.Infrastructure/MerchantService.Infrastructure.csproj", "src/MerchantService.Infrastructure/"] +COPY ["Directory.Build.props", "./"] + +# EN: Restore dependencies +# VI: Khôi phục dependencies +RUN dotnet restore "src/MerchantService.API/MerchantService.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/MerchantService.API" +RUN dotnet build "MerchantService.API.csproj" -c Release -o /app/build --no-restore + +# Publish stage / Giai đoạn publish +FROM build AS publish +RUN dotnet publish "MerchantService.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", "MerchantService.API.dll"] diff --git a/services/merchant-service-net/MerchantService.slnx b/services/merchant-service-net/MerchantService.slnx new file mode 100644 index 00000000..a51c0e7e --- /dev/null +++ b/services/merchant-service-net/MerchantService.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/services/merchant-service-net/docker-compose.yml b/services/merchant-service-net/docker-compose.yml new file mode 100644 index 00000000..254ceb12 --- /dev/null +++ b/services/merchant-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/merchant-service-net/global.json b/services/merchant-service-net/global.json new file mode 100644 index 00000000..f78eeaf4 --- /dev/null +++ b/services/merchant-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/merchant-service-net/src/MerchantService.API/Application/Behaviors/LoggingBehavior.cs b/services/merchant-service-net/src/MerchantService.API/Application/Behaviors/LoggingBehavior.cs new file mode 100644 index 00000000..6ea34aa1 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Behaviors/LoggingBehavior.cs @@ -0,0 +1,58 @@ +using System.Diagnostics; +using MediatR; + +namespace MerchantService.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/merchant-service-net/src/MerchantService.API/Application/Behaviors/TransactionBehavior.cs b/services/merchant-service-net/src/MerchantService.API/Application/Behaviors/TransactionBehavior.cs new file mode 100644 index 00000000..d7f2aa53 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Behaviors/TransactionBehavior.cs @@ -0,0 +1,84 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using MerchantService.Infrastructure; + +namespace MerchantService.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 MerchantServiceContext _dbContext; + private readonly ILogger> _logger; + + public TransactionBehavior( + MerchantServiceContext 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/merchant-service-net/src/MerchantService.API/Application/Behaviors/ValidatorBehavior.cs b/services/merchant-service-net/src/MerchantService.API/Application/Behaviors/ValidatorBehavior.cs new file mode 100644 index 00000000..8bd37a4b --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Application/Behaviors/ValidatorBehavior.cs @@ -0,0 +1,63 @@ +using FluentValidation; +using MediatR; + +namespace MerchantService.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/merchant-service-net/src/MerchantService.API/MerchantService.API.csproj b/services/merchant-service-net/src/MerchantService.API/MerchantService.API.csproj new file mode 100644 index 00000000..7efebdf5 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/MerchantService.API.csproj @@ -0,0 +1,43 @@ + + + + MerchantService.API + MerchantService.API + Web API layer with CQRS pattern + myservice-api + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/merchant-service-net/src/MerchantService.API/Program.cs b/services/merchant-service-net/src/MerchantService.API/Program.cs new file mode 100644 index 00000000..c6d19d9e --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.API/Program.cs @@ -0,0 +1,144 @@ +using Asp.Versioning; +using FluentValidation; +using Hellang.Middleware.ProblemDetails; +using MerchantService.API.Application.Behaviors; +using MerchantService.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 MerchantService API / Khởi động MerchantService 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 = "MerchantService API", + Version = "v1", + Description = "MerchantService microservice API / API microservice MerchantService" + }); + }); + + // 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", "MerchantService 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/merchant-service-net/src/MerchantService.API/Properties/launchSettings.json b/services/merchant-service-net/src/MerchantService.API/Properties/launchSettings.json new file mode 100644 index 00000000..6355d40b --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.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/merchant-service-net/src/MerchantService.API/appsettings.Development.json b/services/merchant-service-net/src/MerchantService.API/appsettings.Development.json new file mode 100644 index 00000000..e407ac85 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.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/merchant-service-net/src/MerchantService.API/appsettings.json b/services/merchant-service-net/src/MerchantService.API/appsettings.json new file mode 100644 index 00000000..523dc0fc --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.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/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/BankAccount.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/BankAccount.cs new file mode 100644 index 00000000..1dcd5fe1 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/BankAccount.cs @@ -0,0 +1,50 @@ +// EN: Bank account value object. +// VI: Value object tài khoản ngân hàng. + +namespace MerchantService.Domain.AggregatesModel.MerchantAggregate; + +/// +/// EN: Value object representing a bank account for settlement. +/// VI: Value object đại diện cho tài khoản ngân hàng để thanh toán. +/// +public record BankAccount +{ + /// + /// EN: Bank code (e.g., "VCB", "TCB"). + /// VI: Mã ngân hàng (ví dụ: "VCB", "TCB"). + /// + public string BankCode { get; init; } = string.Empty; + + /// + /// EN: Full bank name. + /// VI: Tên đầy đủ ngân hàng. + /// + public string BankName { get; init; } = string.Empty; + + /// + /// EN: Account number. + /// VI: Số tài khoản. + /// + public string AccountNumber { get; init; } = string.Empty; + + /// + /// EN: Account holder name (must match business name). + /// VI: Tên chủ tài khoản (phải trùng với tên doanh nghiệp). + /// + public string AccountHolderName { get; init; } = string.Empty; + + /// + /// EN: Creates empty bank account. + /// VI: Tạo tài khoản ngân hàng rỗng. + /// + public static BankAccount Empty => new(); + + /// + /// EN: Check if bank account is valid for settlement. + /// VI: Kiểm tra tài khoản ngân hàng hợp lệ để thanh toán. + /// + public bool IsValid => + !string.IsNullOrWhiteSpace(BankCode) && + !string.IsNullOrWhiteSpace(AccountNumber) && + !string.IsNullOrWhiteSpace(AccountHolderName); +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/BusinessInfo.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/BusinessInfo.cs new file mode 100644 index 00000000..3c7e8a67 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/BusinessInfo.cs @@ -0,0 +1,49 @@ +// EN: Business information value object. +// VI: Value object thông tin doanh nghiệp. + +namespace MerchantService.Domain.AggregatesModel.MerchantAggregate; + +/// +/// EN: Value object representing business information for verification. +/// VI: Value object đại diện cho thông tin doanh nghiệp để xác minh. +/// +public record BusinessInfo +{ + /// + /// EN: Tax identification number. + /// VI: Mã số thuế. + /// + public string? TaxId { get; init; } + + /// + /// EN: Business license number. + /// VI: Số giấy phép kinh doanh. + /// + public string? BusinessLicenseNumber { get; init; } + + /// + /// EN: Company registration number. + /// VI: Số đăng ký công ty. + /// + public string? CompanyRegistrationNumber { get; init; } + + /// + /// EN: Date when the business was established. + /// VI: Ngày thành lập doanh nghiệp. + /// + public DateTime? EstablishedDate { get; init; } + + /// + /// EN: Creates empty business info. + /// VI: Tạo thông tin doanh nghiệp rỗng. + /// + public static BusinessInfo Empty => new(); + + /// + /// EN: Check if business info is complete for verification. + /// VI: Kiểm tra thông tin doanh nghiệp đã đủ để xác minh chưa. + /// + public bool IsCompleteForVerification => + !string.IsNullOrWhiteSpace(TaxId) && + !string.IsNullOrWhiteSpace(BusinessLicenseNumber); +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/IMerchantRepository.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/IMerchantRepository.cs new file mode 100644 index 00000000..b1ca3c00 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/IMerchantRepository.cs @@ -0,0 +1,55 @@ +// EN: Merchant repository interface. +// VI: Interface repository cho Merchant. + +using MerchantService.Domain.SeedWork; + +namespace MerchantService.Domain.AggregatesModel.MerchantAggregate; + +/// +/// EN: Repository interface for Merchant aggregate. +/// VI: Interface repository cho Merchant aggregate. +/// +public interface IMerchantRepository : IRepository +{ + /// + /// EN: Get merchant by ID. + /// VI: Lấy merchant theo ID. + /// + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// EN: Get merchant by user ID (IAM User). + /// VI: Lấy merchant theo user ID (IAM User). + /// + Task GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default); + + /// + /// EN: Check if user already has a merchant account. + /// VI: Kiểm tra user đã có tài khoản merchant chưa. + /// + Task ExistsByUserIdAsync(Guid userId, CancellationToken cancellationToken = default); + + /// + /// EN: Get all merchants with pagination. + /// VI: Lấy tất cả merchants với phân trang. + /// + Task> GetAllAsync(int pageNumber, int pageSize, CancellationToken cancellationToken = default); + + /// + /// EN: Get merchants by status. + /// VI: Lấy merchants theo trạng thái. + /// + Task> GetByStatusAsync(MerchantStatus status, CancellationToken cancellationToken = default); + + /// + /// EN: Add a new merchant. + /// VI: Thêm merchant mới. + /// + Merchant Add(Merchant merchant); + + /// + /// EN: Update a merchant. + /// VI: Cập nhật merchant. + /// + void Update(Merchant merchant); +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/Merchant.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/Merchant.cs new file mode 100644 index 00000000..9ed131fc --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/Merchant.cs @@ -0,0 +1,295 @@ +// EN: Merchant aggregate root. +// VI: Aggregate root Merchant. + +using MerchantService.Domain.Events; +using MerchantService.Domain.Exceptions; +using MerchantService.Domain.SeedWork; + +namespace MerchantService.Domain.AggregatesModel.MerchantAggregate; + +/// +/// EN: Merchant aggregate root - represents a shop owner who can own multiple shops. +/// VI: Aggregate root Merchant - đại diện cho chủ shop có thể sở hữu nhiều shop. +/// +public class Merchant : Entity, IAggregateRoot +{ + // EN: Private fields for encapsulation + // VI: Fields private để đóng gói + private Guid _userId; + private string _businessName = null!; + private MerchantType _type = null!; + private MerchantStatus _status = null!; + private VerificationStatus _verificationStatus = null!; + private BusinessInfo _businessInfo = null!; + private SettlementConfig _settlementConfig = null!; + private DateTime _createdAt; + private DateTime? _updatedAt; + private bool _isDeleted; + + /// + /// EN: Reference to IAM User (Owner). + /// VI: Tham chiếu đến IAM User (Chủ sở hữu). + /// + public Guid UserId => _userId; + + /// + /// EN: Business/Company name. + /// VI: Tên doanh nghiệp/công ty. + /// + public string BusinessName => _businessName; + + /// + /// EN: Merchant type (Individual or Company). + /// VI: Loại merchant (Cá nhân hoặc Doanh nghiệp). + /// + public MerchantType Type => _type; + + /// + /// EN: Merchant type ID for EF Core mapping. + /// VI: Type ID cho EF Core mapping. + /// + public int TypeId { get; private set; } + + /// + /// EN: Current merchant status. + /// VI: Trạng thái merchant hiện tại. + /// + public MerchantStatus Status => _status; + + /// + /// EN: Status ID for EF Core mapping. + /// VI: Status ID cho EF Core mapping. + /// + public int StatusId { get; private set; } + + /// + /// EN: Verification status. + /// VI: Trạng thái xác minh. + /// + public VerificationStatus VerificationStatus => _verificationStatus; + + /// + /// EN: Verification status ID for EF Core mapping. + /// VI: Verification status ID cho EF Core mapping. + /// + public int VerificationStatusId { get; private set; } + + /// + /// EN: Business information for verification. + /// VI: Thông tin doanh nghiệp để xác minh. + /// + public BusinessInfo BusinessInfo => _businessInfo; + + /// + /// EN: Settlement configuration. + /// VI: Cấu hình thanh toán. + /// + public SettlementConfig SettlementConfig => _settlementConfig; + + /// + /// EN: When merchant was verified. + /// VI: Thời điểm merchant được xác minh. + /// + public DateTime? VerifiedAt { get; private set; } + + /// + /// EN: Who verified the merchant. + /// VI: Ai đã xác minh merchant. + /// + public Guid? VerifiedBy { 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: Soft delete flag. + /// VI: Cờ xóa mềm. + /// + public bool IsDeleted => _isDeleted; + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + protected Merchant() + { + _businessInfo = BusinessInfo.Empty; + _settlementConfig = SettlementConfig.Default; + } + + /// + /// EN: Factory method to register a new merchant. + /// VI: Factory method để đăng ký merchant mới. + /// + /// IAM User ID / ID người dùng IAM + /// Business name / Tên doanh nghiệp + /// Merchant type / Loại merchant + /// New Merchant instance / Instance Merchant mới + public static Merchant Register(Guid userId, string businessName, MerchantType type) + { + if (userId == Guid.Empty) + throw new DomainException("User ID cannot be empty"); + if (string.IsNullOrWhiteSpace(businessName)) + throw new DomainException("Business name cannot be empty"); + ArgumentNullException.ThrowIfNull(type, nameof(type)); + + var merchant = new Merchant + { + Id = Guid.NewGuid(), + _userId = userId, + _businessName = businessName.Trim(), + _type = type, + TypeId = type.Id, + _status = MerchantStatus.PendingApproval, + StatusId = MerchantStatus.PendingApproval.Id, + _verificationStatus = VerificationStatus.Unverified, + VerificationStatusId = VerificationStatus.Unverified.Id, + _businessInfo = BusinessInfo.Empty, + _settlementConfig = SettlementConfig.Default, + _createdAt = DateTime.UtcNow + }; + + merchant.AddDomainEvent(new MerchantRegisteredDomainEvent(merchant)); + return merchant; + } + + /// + /// EN: Update business name. + /// VI: Cập nhật tên doanh nghiệp. + /// + public void UpdateBusinessName(string businessName) + { + if (string.IsNullOrWhiteSpace(businessName)) + throw new DomainException("Business name cannot be empty"); + + _businessName = businessName.Trim(); + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Update business information. + /// VI: Cập nhật thông tin doanh nghiệp. + /// + public void UpdateBusinessInfo(BusinessInfo businessInfo) + { + _businessInfo = businessInfo ?? BusinessInfo.Empty; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Update settlement configuration. + /// VI: Cập nhật cấu hình thanh toán. + /// + public void UpdateSettlementConfig(SettlementConfig config) + { + _settlementConfig = config ?? SettlementConfig.Default; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Submit verification documents. + /// VI: Nộp tài liệu xác minh. + /// + public void SubmitForVerification() + { + if (!_businessInfo.IsCompleteForVerification) + throw new DomainException("Business information is incomplete for verification"); + + _verificationStatus = VerificationStatus.Pending; + VerificationStatusId = VerificationStatus.Pending.Id; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new MerchantVerificationSubmittedDomainEvent(this)); + } + + /// + /// EN: Approve merchant and activate account. + /// VI: Phê duyệt merchant và kích hoạt tài khoản. + /// + /// Admin user ID who approved / ID admin phê duyệt + public void Approve(Guid approvedBy) + { + if (!_status.CanBeActivated) + throw new DomainException($"Cannot approve merchant with status {_status.Name}"); + + _status = MerchantStatus.Active; + StatusId = MerchantStatus.Active.Id; + _verificationStatus = VerificationStatus.Verified; + VerificationStatusId = VerificationStatus.Verified.Id; + VerifiedAt = DateTime.UtcNow; + VerifiedBy = approvedBy; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new MerchantApprovedDomainEvent(this, approvedBy)); + } + + /// + /// EN: Suspend merchant. + /// VI: Tạm ngưng merchant. + /// + /// Reason for suspension / Lý do tạm ngưng + public void Suspend(string reason) + { + if (!_status.CanBeSuspended) + throw new DomainException($"Cannot suspend merchant with status {_status.Name}"); + + _status = MerchantStatus.Suspended; + StatusId = MerchantStatus.Suspended.Id; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new MerchantSuspendedDomainEvent(this, reason)); + } + + /// + /// EN: Reactivate suspended merchant. + /// VI: Kích hoạt lại merchant bị tạm ngưng. + /// + public void Reactivate() + { + if (_status != MerchantStatus.Suspended) + throw new DomainException("Can only reactivate suspended merchants"); + + _status = MerchantStatus.Active; + StatusId = MerchantStatus.Active.Id; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Ban merchant permanently. + /// VI: Cấm merchant vĩnh viễn. + /// + /// Reason for ban / Lý do cấm + public void Ban(string reason) + { + if (_status == MerchantStatus.Banned) + throw new DomainException("Merchant is already banned"); + + _status = MerchantStatus.Banned; + StatusId = MerchantStatus.Banned.Id; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new MerchantBannedDomainEvent(this, reason)); + } + + /// + /// EN: Soft delete merchant. + /// VI: Xóa mềm merchant. + /// + public void Delete() + { + if (_isDeleted) + throw new DomainException("Merchant is already deleted"); + + _isDeleted = true; + _updatedAt = DateTime.UtcNow; + } +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/MerchantStatus.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/MerchantStatus.cs new file mode 100644 index 00000000..296f8162 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/MerchantStatus.cs @@ -0,0 +1,59 @@ +// EN: Merchant status enumeration. +// VI: Enumeration trạng thái Merchant. + +using MerchantService.Domain.SeedWork; + +namespace MerchantService.Domain.AggregatesModel.MerchantAggregate; + +/// +/// EN: Represents the status of a merchant. +/// VI: Đại diện cho trạng thái của merchant. +/// +public class MerchantStatus : Enumeration +{ + /// + /// EN: Merchant is pending admin approval. + /// VI: Merchant đang chờ admin phê duyệt. + /// + public static readonly MerchantStatus PendingApproval = new(1, nameof(PendingApproval)); + + /// + /// EN: Merchant is active and can operate. + /// VI: Merchant đang hoạt động và có thể kinh doanh. + /// + public static readonly MerchantStatus Active = new(2, nameof(Active)); + + /// + /// EN: Merchant is temporarily suspended. + /// VI: Merchant tạm thời bị đình chỉ. + /// + public static readonly MerchantStatus Suspended = new(3, nameof(Suspended)); + + /// + /// EN: Merchant is permanently banned. + /// VI: Merchant bị cấm vĩnh viễn. + /// + public static readonly MerchantStatus Banned = new(4, nameof(Banned)); + + public MerchantStatus(int id, string name) : base(id, name) + { + } + + /// + /// EN: Check if merchant can create shops. + /// VI: Kiểm tra merchant có thể tạo shop không. + /// + public bool CanCreateShop => this == Active; + + /// + /// EN: Check if merchant can be suspended. + /// VI: Kiểm tra merchant có thể bị đình chỉ không. + /// + public bool CanBeSuspended => this == Active; + + /// + /// EN: Check if merchant can be activated. + /// VI: Kiểm tra merchant có thể được kích hoạt không. + /// + public bool CanBeActivated => this == PendingApproval || this == Suspended; +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/MerchantType.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/MerchantType.cs new file mode 100644 index 00000000..53ff3b1c --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/MerchantType.cs @@ -0,0 +1,29 @@ +// EN: Merchant type enumeration. +// VI: Enumeration loại Merchant. + +using MerchantService.Domain.SeedWork; + +namespace MerchantService.Domain.AggregatesModel.MerchantAggregate; + +/// +/// EN: Represents the type of merchant (Individual or Company). +/// VI: Đại diện cho loại merchant (Cá nhân hoặc Doanh nghiệp). +/// +public class MerchantType : Enumeration +{ + /// + /// EN: Individual merchant (sole proprietor). + /// VI: Merchant cá nhân (hộ kinh doanh). + /// + public static readonly MerchantType Individual = new(1, nameof(Individual)); + + /// + /// EN: Company/Business merchant. + /// VI: Merchant doanh nghiệp. + /// + public static readonly MerchantType Company = new(2, nameof(Company)); + + public MerchantType(int id, string name) : base(id, name) + { + } +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/SettlementConfig.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/SettlementConfig.cs new file mode 100644 index 00000000..1ecff12f --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/SettlementConfig.cs @@ -0,0 +1,53 @@ +// EN: Settlement configuration value object. +// VI: Value object cấu hình thanh toán. + +namespace MerchantService.Domain.AggregatesModel.MerchantAggregate; + +/// +/// EN: Value object representing settlement configuration for merchant payouts. +/// VI: Value object đại diện cho cấu hình thanh toán cho merchant. +/// +public record SettlementConfig +{ + /// + /// EN: Bank account for receiving settlements. + /// VI: Tài khoản ngân hàng để nhận thanh toán. + /// + public BankAccount BankAccount { get; init; } = BankAccount.Empty; + + /// + /// EN: Platform commission rate (percentage, e.g., 5.0 = 5%). + /// VI: Tỷ lệ hoa hồng platform (phần trăm, ví dụ: 5.0 = 5%). + /// + public decimal CommissionRate { get; init; } = 0m; + + /// + /// EN: Settlement cycle (Daily, Weekly, Monthly). + /// VI: Chu kỳ thanh toán (Hàng ngày, Hàng tuần, Hàng tháng). + /// + public int SettlementCycleId { get; init; } = SettlementCycle.Monthly.Id; + + /// + /// EN: Whether to automatically settle or require manual trigger. + /// VI: Có tự động thanh toán hay yêu cầu kích hoạt thủ công. + /// + public bool AutoSettlement { get; init; } = true; + + /// + /// EN: Creates default settlement config. + /// VI: Tạo cấu hình thanh toán mặc định. + /// + public static SettlementConfig Default => new() + { + BankAccount = BankAccount.Empty, + CommissionRate = 0m, + SettlementCycleId = SettlementCycle.Monthly.Id, + AutoSettlement = true + }; + + /// + /// EN: Check if settlement config is complete. + /// VI: Kiểm tra cấu hình thanh toán đã đầy đủ chưa. + /// + public bool IsComplete => BankAccount.IsValid; +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/SettlementCycle.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/SettlementCycle.cs new file mode 100644 index 00000000..aeba386e --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/SettlementCycle.cs @@ -0,0 +1,35 @@ +// EN: Settlement cycle enumeration. +// VI: Enumeration chu kỳ thanh toán. + +using MerchantService.Domain.SeedWork; + +namespace MerchantService.Domain.AggregatesModel.MerchantAggregate; + +/// +/// EN: Represents the settlement cycle for merchant payouts. +/// VI: Đại diện cho chu kỳ thanh toán cho merchant. +/// +public class SettlementCycle : Enumeration +{ + /// + /// EN: Daily settlement. + /// VI: Thanh toán hàng ngày. + /// + public static readonly SettlementCycle Daily = new(1, nameof(Daily)); + + /// + /// EN: Weekly settlement. + /// VI: Thanh toán hàng tuần. + /// + public static readonly SettlementCycle Weekly = new(2, nameof(Weekly)); + + /// + /// EN: Monthly settlement. + /// VI: Thanh toán hàng tháng. + /// + public static readonly SettlementCycle Monthly = new(3, nameof(Monthly)); + + public SettlementCycle(int id, string name) : base(id, name) + { + } +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/VerificationStatus.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/VerificationStatus.cs new file mode 100644 index 00000000..5ceffafd --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantAggregate/VerificationStatus.cs @@ -0,0 +1,41 @@ +// EN: Verification status enumeration. +// VI: Enumeration trạng thái xác minh. + +using MerchantService.Domain.SeedWork; + +namespace MerchantService.Domain.AggregatesModel.MerchantAggregate; + +/// +/// EN: Represents the verification status of a merchant. +/// VI: Đại diện cho trạng thái xác minh của merchant. +/// +public class VerificationStatus : Enumeration +{ + /// + /// EN: Merchant has not submitted verification documents. + /// VI: Merchant chưa nộp tài liệu xác minh. + /// + public static readonly VerificationStatus Unverified = new(1, nameof(Unverified)); + + /// + /// EN: Verification documents are pending review. + /// VI: Tài liệu xác minh đang chờ xem xét. + /// + public static readonly VerificationStatus Pending = new(2, nameof(Pending)); + + /// + /// EN: Merchant is verified. + /// VI: Merchant đã được xác minh. + /// + public static readonly VerificationStatus Verified = new(3, nameof(Verified)); + + /// + /// EN: Verification was rejected. + /// VI: Xác minh bị từ chối. + /// + public static readonly VerificationStatus Rejected = new(4, nameof(Rejected)); + + public VerificationStatus(int id, string name) : base(id, name) + { + } +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/DeviceToken.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/DeviceToken.cs new file mode 100644 index 00000000..7233de0d --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/DeviceToken.cs @@ -0,0 +1,121 @@ +// EN: Device token entity for POS and push notifications. +// VI: Entity device token cho POS và push notifications. + +using MerchantService.Domain.Exceptions; +using MerchantService.Domain.SeedWork; + +namespace MerchantService.Domain.AggregatesModel.MerchantStaffAggregate; + +/// +/// EN: Represents a registered device for staff (POS, Mobile). +/// VI: Đại diện cho thiết bị đã đăng ký của nhân viên (POS, Mobile). +/// +public class DeviceToken : Entity +{ + private Guid _staffId; + private string _deviceId = null!; + private string? _deviceName; + private string? _fcmToken; + private string _platform = null!; + private DateTime? _lastUsedAt; + private DateTime _createdAt; + + /// + /// EN: Staff ID this device belongs to. + /// VI: ID nhân viên sở hữu thiết bị này. + /// + public Guid StaffId => _staffId; + + /// + /// EN: Unique device identifier. + /// VI: Định danh thiết bị duy nhất. + /// + public string DeviceId => _deviceId; + + /// + /// EN: Human-readable device name. + /// VI: Tên thiết bị dễ đọc. + /// + public string? DeviceName => _deviceName; + + /// + /// EN: Firebase Cloud Messaging token for push notifications. + /// VI: Token Firebase Cloud Messaging cho push notifications. + /// + public string? FcmToken => _fcmToken; + + /// + /// EN: Platform (ios, android, pos). + /// VI: Nền tảng (ios, android, pos). + /// + public string Platform => _platform; + + /// + /// EN: Last time this device was used. + /// VI: Lần cuối thiết bị này được sử dụng. + /// + public DateTime? LastUsedAt => _lastUsedAt; + + /// + /// EN: When the device was registered. + /// VI: Thời điểm thiết bị được đăng ký. + /// + public DateTime CreatedAt => _createdAt; + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + protected DeviceToken() + { + } + + /// + /// EN: Create a new device token. + /// VI: Tạo device token mới. + /// + public DeviceToken(Guid staffId, string deviceId, string? deviceName, string? fcmToken, string platform) + { + if (staffId == Guid.Empty) + throw new DomainException("Staff ID cannot be empty"); + if (string.IsNullOrWhiteSpace(deviceId)) + throw new DomainException("Device ID cannot be empty"); + if (string.IsNullOrWhiteSpace(platform)) + throw new DomainException("Platform cannot be empty"); + + Id = Guid.NewGuid(); + _staffId = staffId; + _deviceId = deviceId; + _deviceName = deviceName; + _fcmToken = fcmToken; + _platform = platform.ToLowerInvariant(); + _createdAt = DateTime.UtcNow; + } + + /// + /// EN: Update FCM token. + /// VI: Cập nhật FCM token. + /// + public void UpdateFcmToken(string? fcmToken) + { + _fcmToken = fcmToken; + } + + /// + /// EN: Update device name. + /// VI: Cập nhật tên thiết bị. + /// + public void UpdateDeviceName(string? deviceName) + { + _deviceName = deviceName; + } + + /// + /// EN: Mark device as used now. + /// VI: Đánh dấu thiết bị vừa được sử dụng. + /// + public void MarkUsed() + { + _lastUsedAt = DateTime.UtcNow; + } +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/IMerchantStaffRepository.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/IMerchantStaffRepository.cs new file mode 100644 index 00000000..a0eda013 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/IMerchantStaffRepository.cs @@ -0,0 +1,61 @@ +// EN: MerchantStaff repository interface. +// VI: Interface repository cho MerchantStaff. + +using MerchantService.Domain.SeedWork; + +namespace MerchantService.Domain.AggregatesModel.MerchantStaffAggregate; + +/// +/// EN: Repository interface for MerchantStaff aggregate. +/// VI: Interface repository cho MerchantStaff aggregate. +/// +public interface IMerchantStaffRepository : IRepository +{ + /// + /// EN: Get staff by ID. + /// VI: Lấy nhân viên theo ID. + /// + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// EN: Get staff by user ID (IAM User). + /// VI: Lấy nhân viên theo user ID (IAM User). + /// + Task GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default); + + /// + /// EN: Get staff by user ID and merchant ID. + /// VI: Lấy nhân viên theo user ID và merchant ID. + /// + Task GetByUserIdAndMerchantIdAsync(Guid userId, Guid merchantId, CancellationToken cancellationToken = default); + + /// + /// EN: Get all staff by merchant ID. + /// VI: Lấy tất cả nhân viên theo merchant ID. + /// + Task> GetByMerchantIdAsync(Guid merchantId, CancellationToken cancellationToken = default); + + /// + /// EN: Get staff by shop ID (through ShopMember). + /// VI: Lấy nhân viên theo shop ID (thông qua ShopMember). + /// + Task> GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default); + + /// + /// EN: Check if user is already staff of merchant. + /// VI: Kiểm tra user đã là nhân viên của merchant chưa. + /// + Task ExistsByUserIdAndMerchantIdAsync(Guid userId, Guid merchantId, CancellationToken cancellationToken = default); + + /// + /// EN: Add a new staff member. + /// VI: Thêm nhân viên mới. + /// + MerchantStaff Add(MerchantStaff staff); + + /// + /// EN: Update a staff member. + /// VI: Cập nhật nhân viên. + /// + void Update(MerchantStaff staff); +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/MerchantStaff.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/MerchantStaff.cs new file mode 100644 index 00000000..5ed216e2 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/MerchantStaff.cs @@ -0,0 +1,409 @@ +// EN: MerchantStaff aggregate root. +// VI: Aggregate root MerchantStaff. + +using System.Security.Cryptography; +using System.Text; +using MerchantService.Domain.AggregatesModel.ShopAggregate; +using MerchantService.Domain.Events; +using MerchantService.Domain.Exceptions; +using MerchantService.Domain.SeedWork; + +namespace MerchantService.Domain.AggregatesModel.MerchantStaffAggregate; + +/// +/// EN: MerchantStaff aggregate root - represents an employee of a merchant. +/// VI: Aggregate root MerchantStaff - đại diện cho nhân viên của merchant. +/// +public class MerchantStaff : Entity, IAggregateRoot +{ + // EN: Private fields for encapsulation + // VI: Fields private để đóng gói + private Guid _userId; + private Guid _merchantId; + private string? _employeeCode; + private StaffRole _role = null!; + private StaffStatus _status = null!; + private StaffPermissions _permissions; + private string? _phone; + private string? _email; + private string? _pinCodeHash; + private DateTime _joinedAt; + private DateTime? _terminatedAt; + private DateTime _createdAt; + private DateTime? _updatedAt; + + private readonly List _deviceTokens = new(); + private readonly List _shopAssignments = new(); + + /// + /// EN: Reference to IAM User. + /// VI: Tham chiếu đến IAM User. + /// + public Guid UserId => _userId; + + /// + /// EN: Merchant ID (employer). + /// VI: ID merchant (chủ lao động). + /// + public Guid MerchantId => _merchantId; + + /// + /// EN: Employee code for internal reference. + /// VI: Mã nhân viên để tham chiếu nội bộ. + /// + public string? EmployeeCode => _employeeCode; + + /// + /// EN: Staff role. + /// VI: Vai trò nhân viên. + /// + public StaffRole Role => _role; + + /// + /// EN: Role ID for EF Core mapping. + /// VI: Role ID cho EF Core mapping. + /// + public int RoleId { get; private set; } + + /// + /// EN: Staff status. + /// VI: Trạng thái nhân viên. + /// + public StaffStatus Status => _status; + + /// + /// EN: Status ID for EF Core mapping. + /// VI: Status ID cho EF Core mapping. + /// + public int StatusId { get; private set; } + + /// + /// EN: Staff permissions bitmask. + /// VI: Bitmask quyền hạn nhân viên. + /// + public StaffPermissions Permissions => _permissions; + + /// + /// EN: Staff phone number. + /// VI: Số điện thoại nhân viên. + /// + public string? Phone => _phone; + + /// + /// EN: Staff email. + /// VI: Email nhân viên. + /// + public string? Email => _email; + + /// + /// EN: Hashed PIN code for POS authentication. + /// VI: Mã PIN đã hash cho xác thực POS. + /// + public string? PinCodeHash => _pinCodeHash; + + /// + /// EN: Registered devices for this staff. + /// VI: Các thiết bị đã đăng ký của nhân viên này. + /// + public IReadOnlyCollection DeviceTokens => _deviceTokens.AsReadOnly(); + + /// + /// EN: Shop assignments for this staff. + /// VI: Các shop được gán cho nhân viên này. + /// + public IReadOnlyCollection ShopAssignments => _shopAssignments.AsReadOnly(); + + /// + /// EN: When staff joined. + /// VI: Thời điểm nhân viên gia nhập. + /// + public DateTime JoinedAt => _joinedAt; + + /// + /// EN: When employment was terminated. + /// VI: Thời điểm chấm dứt hợp đồng. + /// + public DateTime? TerminatedAt => _terminatedAt; + + /// + /// 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 MerchantStaff() + { + } + + /// + /// EN: Invite a new staff member. + /// VI: Mời nhân viên mới. + /// + public static MerchantStaff Invite( + Guid merchantId, + string email, + StaffRole role, + StaffPermissions permissions = StaffPermissions.None) + { + if (merchantId == Guid.Empty) + throw new DomainException("Merchant ID cannot be empty"); + if (string.IsNullOrWhiteSpace(email)) + throw new DomainException("Email cannot be empty"); + ArgumentNullException.ThrowIfNull(role, nameof(role)); + + var staff = new MerchantStaff + { + Id = Guid.NewGuid(), + _userId = Guid.Empty, // Will be set when invitation is accepted + _merchantId = merchantId, + _email = email.Trim().ToLowerInvariant(), + _role = role, + RoleId = role.Id, + _status = StaffStatus.Invited, + StatusId = StaffStatus.Invited.Id, + _permissions = permissions, + _createdAt = DateTime.UtcNow + }; + + staff.AddDomainEvent(new StaffInvitedDomainEvent(staff)); + return staff; + } + + /// + /// EN: Accept invitation and link to IAM user. + /// VI: Chấp nhận lời mời và liên kết với IAM user. + /// + public void AcceptInvitation(Guid userId) + { + if (_status != StaffStatus.Invited) + throw new DomainException("Can only accept pending invitations"); + if (userId == Guid.Empty) + throw new DomainException("User ID cannot be empty"); + + _userId = userId; + _status = StaffStatus.Active; + StatusId = StaffStatus.Active.Id; + _joinedAt = DateTime.UtcNow; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new StaffJoinedDomainEvent(this)); + } + + /// + /// EN: Update staff information. + /// VI: Cập nhật thông tin nhân viên. + /// + public void Update(string? employeeCode, string? phone) + { + _employeeCode = employeeCode?.Trim(); + _phone = phone?.Trim(); + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Update staff role. + /// VI: Cập nhật vai trò nhân viên. + /// + public void UpdateRole(StaffRole role) + { + ArgumentNullException.ThrowIfNull(role, nameof(role)); + _role = role; + RoleId = role.Id; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Update staff permissions. + /// VI: Cập nhật quyền hạn nhân viên. + /// + public void UpdatePermissions(StaffPermissions permissions) + { + _permissions = permissions; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Set PIN code for POS authentication. + /// VI: Đặt mã PIN cho xác thực POS. + /// + public void SetPinCode(string pin) + { + if (string.IsNullOrWhiteSpace(pin)) + throw new DomainException("PIN cannot be empty"); + if (pin.Length < 4 || pin.Length > 6) + throw new DomainException("PIN must be 4-6 digits"); + if (!pin.All(char.IsDigit)) + throw new DomainException("PIN must contain only digits"); + + _pinCodeHash = HashPin(pin); + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new StaffPinCodeSetDomainEvent(this)); + } + + /// + /// EN: Verify PIN code for POS login. + /// VI: Xác minh mã PIN cho đăng nhập POS. + /// + public bool VerifyPinCode(string pin) + { + if (string.IsNullOrEmpty(_pinCodeHash)) + return false; + + var hash = HashPin(pin); + return hash == _pinCodeHash; + } + + /// + /// EN: Register a device for POS/push notifications. + /// VI: Đăng ký thiết bị cho POS/push notifications. + /// + public DeviceToken RegisterDevice(string deviceId, string? deviceName, string? fcmToken, string platform) + { + var existingToken = _deviceTokens.FirstOrDefault(t => t.DeviceId == deviceId); + if (existingToken != null) + { + existingToken.UpdateFcmToken(fcmToken); + existingToken.UpdateDeviceName(deviceName); + existingToken.MarkUsed(); + return existingToken; + } + + var token = new DeviceToken(Id, deviceId, deviceName, fcmToken, platform); + _deviceTokens.Add(token); + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new StaffDeviceRegisteredDomainEvent(this, token)); + return token; + } + + /// + /// EN: Remove a registered device. + /// VI: Xóa thiết bị đã đăng ký. + /// + public void RemoveDevice(string deviceId) + { + var token = _deviceTokens.FirstOrDefault(t => t.DeviceId == deviceId); + if (token != null) + { + _deviceTokens.Remove(token); + _updatedAt = DateTime.UtcNow; + } + } + + /// + /// EN: Assign staff to a shop. + /// VI: Gán nhân viên vào shop. + /// + public ShopMember AssignToShop(Guid shopId, ShopRole role, Guid? branchId = null, bool isPrimary = false) + { + if (_shopAssignments.Any(a => a.ShopId == shopId)) + throw new DomainException("Staff is already assigned to this shop"); + + // If setting as primary, unset other primaries + if (isPrimary) + { + foreach (var assignment in _shopAssignments) + { + assignment.UnsetAsPrimary(); + } + } + + var shopMember = new ShopMember(Id, shopId, role, branchId, isPrimary); + _shopAssignments.Add(shopMember); + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new StaffAssignedToShopDomainEvent(this, shopMember)); + return shopMember; + } + + /// + /// EN: Remove shop assignment. + /// VI: Xóa gán shop. + /// + public void RemoveFromShop(Guid shopId) + { + var assignment = _shopAssignments.FirstOrDefault(a => a.ShopId == shopId); + if (assignment != null) + { + _shopAssignments.Remove(assignment); + _updatedAt = DateTime.UtcNow; + } + } + + /// + /// EN: Deactivate staff. + /// VI: Vô hiệu hóa nhân viên. + /// + public void Deactivate() + { + if (_status == StaffStatus.Terminated) + throw new DomainException("Cannot deactivate terminated staff"); + + _status = StaffStatus.Inactive; + StatusId = StaffStatus.Inactive.Id; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Reactivate staff. + /// VI: Kích hoạt lại nhân viên. + /// + public void Reactivate() + { + if (_status != StaffStatus.Inactive) + throw new DomainException("Can only reactivate inactive staff"); + + _status = StaffStatus.Active; + StatusId = StaffStatus.Active.Id; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Terminate employment. + /// VI: Chấm dứt hợp đồng. + /// + public void Terminate() + { + if (_status == StaffStatus.Terminated) + throw new DomainException("Staff is already terminated"); + + _status = StaffStatus.Terminated; + StatusId = StaffStatus.Terminated.Id; + _terminatedAt = DateTime.UtcNow; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new StaffTerminatedDomainEvent(this)); + } + + /// + /// EN: Check if staff has a specific permission. + /// VI: Kiểm tra nhân viên có quyền cụ thể không. + /// + public bool HasPermission(StaffPermissions permission) + { + if (_permissions == StaffPermissions.All) + return true; + return (_permissions & permission) == permission; + } + + /// + /// EN: Hash PIN code. + /// VI: Hash mã PIN. + /// + private static string HashPin(string pin) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(pin)); + return Convert.ToBase64String(bytes); + } +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/ShopMember.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/ShopMember.cs new file mode 100644 index 00000000..f7c0a2fc --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/ShopMember.cs @@ -0,0 +1,156 @@ +// EN: Shop member entity - staff assignment to shop. +// VI: Entity shop member - gán nhân viên vào shop. + +using MerchantService.Domain.Exceptions; +using MerchantService.Domain.SeedWork; + +namespace MerchantService.Domain.AggregatesModel.MerchantStaffAggregate; + +/// +/// EN: Represents a staff member's assignment to a shop. +/// VI: Đại diện cho việc gán nhân viên vào shop. +/// +public class ShopMember : Entity +{ + private Guid _staffId; + private Guid _shopId; + private Guid? _branchId; + private ShopRole _role = null!; + private StaffPermissions? _customPermissions; + private bool _isPrimary; + private DateTime _assignedAt; + + /// + /// EN: Staff ID. + /// VI: ID nhân viên. + /// + public Guid StaffId => _staffId; + + /// + /// EN: Shop ID. + /// VI: ID shop. + /// + public Guid ShopId => _shopId; + + /// + /// EN: Specific branch ID (null = all branches). + /// VI: ID chi nhánh cụ thể (null = tất cả chi nhánh). + /// + public Guid? BranchId => _branchId; + + /// + /// EN: Role at this shop. + /// VI: Vai trò tại shop này. + /// + public ShopRole Role => _role; + + /// + /// EN: Role ID for EF Core mapping. + /// VI: Role ID cho EF Core mapping. + /// + public int RoleId { get; private set; } + + /// + /// EN: Custom permissions override for this shop. + /// VI: Quyền tùy chỉnh ghi đè cho shop này. + /// + public StaffPermissions? CustomPermissions => _customPermissions; + + /// + /// EN: Whether this is the staff's primary shop. + /// VI: Đây có phải shop chính của nhân viên không. + /// + public bool IsPrimary => _isPrimary; + + /// + /// EN: When the staff was assigned to this shop. + /// VI: Thời điểm nhân viên được gán vào shop này. + /// + public DateTime AssignedAt => _assignedAt; + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + protected ShopMember() + { + } + + /// + /// EN: Create a new shop member assignment. + /// VI: Tạo việc gán shop member mới. + /// + public ShopMember(Guid staffId, Guid shopId, ShopRole role, Guid? branchId = null, bool isPrimary = false) + { + if (staffId == Guid.Empty) + throw new DomainException("Staff ID cannot be empty"); + if (shopId == Guid.Empty) + throw new DomainException("Shop ID cannot be empty"); + ArgumentNullException.ThrowIfNull(role, nameof(role)); + + Id = Guid.NewGuid(); + _staffId = staffId; + _shopId = shopId; + _branchId = branchId; + _role = role; + RoleId = role.Id; + _isPrimary = isPrimary; + _assignedAt = DateTime.UtcNow; + } + + /// + /// EN: Update role at this shop. + /// VI: Cập nhật vai trò tại shop này. + /// + public void UpdateRole(ShopRole role) + { + ArgumentNullException.ThrowIfNull(role, nameof(role)); + _role = role; + RoleId = role.Id; + } + + /// + /// EN: Set custom permissions for this shop. + /// VI: Đặt quyền tùy chỉnh cho shop này. + /// + public void SetCustomPermissions(StaffPermissions? permissions) + { + _customPermissions = permissions; + } + + /// + /// EN: Set as primary shop. + /// VI: Đặt làm shop chính. + /// + public void SetAsPrimary() + { + _isPrimary = true; + } + + /// + /// EN: Unset as primary shop. + /// VI: Bỏ đặt làm shop chính. + /// + public void UnsetAsPrimary() + { + _isPrimary = false; + } + + /// + /// EN: Assign to specific branch. + /// VI: Gán vào chi nhánh cụ thể. + /// + public void AssignToBranch(Guid branchId) + { + _branchId = branchId; + } + + /// + /// EN: Remove branch assignment (assign to all branches). + /// VI: Xóa gán chi nhánh (gán cho tất cả chi nhánh). + /// + public void AssignToAllBranches() + { + _branchId = null; + } +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/StaffEnumerations.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/StaffEnumerations.cs new file mode 100644 index 00000000..0afd0708 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/MerchantStaffAggregate/StaffEnumerations.cs @@ -0,0 +1,173 @@ +// EN: Staff role and status enumerations. +// VI: Enumeration vai trò và trạng thái nhân viên. + +using MerchantService.Domain.SeedWork; + +namespace MerchantService.Domain.AggregatesModel.MerchantStaffAggregate; + +/// +/// EN: Represents staff roles within a merchant organization. +/// VI: Đại diện cho các vai trò nhân viên trong tổ chức merchant. +/// +public class StaffRole : Enumeration +{ + /// + /// EN: Cashier - handles payments. + /// VI: Thu ngân - xử lý thanh toán. + /// + public static readonly StaffRole Cashier = new(1, nameof(Cashier)); + + /// + /// EN: Waiter/Server - serves customers. + /// VI: Phục vụ - phục vụ khách hàng. + /// + public static readonly StaffRole Waiter = new(2, nameof(Waiter)); + + /// + /// EN: Manager - manages staff and operations. + /// VI: Quản lý - quản lý nhân viên và hoạt động. + /// + public static readonly StaffRole Manager = new(3, nameof(Manager)); + + /// + /// EN: Admin - full access to merchant features. + /// VI: Admin - toàn quyền truy cập tính năng merchant. + /// + public static readonly StaffRole Admin = new(4, nameof(Admin)); + + public StaffRole(int id, string name) : base(id, name) + { + } +} + +/// +/// EN: Represents the status of a staff member. +/// VI: Đại diện cho trạng thái của nhân viên. +/// +public class StaffStatus : Enumeration +{ + /// + /// EN: Invited but not yet accepted. + /// VI: Đã mời nhưng chưa chấp nhận. + /// + public static readonly StaffStatus Invited = new(1, nameof(Invited)); + + /// + /// EN: Active staff member. + /// VI: Nhân viên đang hoạt động. + /// + public static readonly StaffStatus Active = new(2, nameof(Active)); + + /// + /// EN: Temporarily inactive. + /// VI: Tạm thời không hoạt động. + /// + public static readonly StaffStatus Inactive = new(3, nameof(Inactive)); + + /// + /// EN: Employment terminated. + /// VI: Đã chấm dứt hợp đồng. + /// + public static readonly StaffStatus Terminated = new(4, nameof(Terminated)); + + public StaffStatus(int id, string name) : base(id, name) + { + } +} + +/// +/// EN: Staff permissions bitmask. +/// VI: Bitmask quyền hạn nhân viên. +/// +[Flags] +public enum StaffPermissions +{ + /// + /// EN: No permissions. + /// VI: Không có quyền. + /// + None = 0, + + /// + /// EN: View sales data. + /// VI: Xem dữ liệu bán hàng. + /// + ViewSales = 1, + + /// + /// EN: Process payment transactions. + /// VI: Xử lý giao dịch thanh toán. + /// + ProcessPayment = 2, + + /// + /// EN: Process refunds. + /// VI: Xử lý hoàn tiền. + /// + RefundOrder = 4, + + /// + /// EN: Manage inventory. + /// VI: Quản lý kho. + /// + ManageInventory = 8, + + /// + /// EN: View reports and analytics. + /// VI: Xem báo cáo và phân tích. + /// + ViewReports = 16, + + /// + /// EN: Manage other staff members. + /// VI: Quản lý nhân viên khác. + /// + ManageStaff = 32, + + /// + /// EN: Manage shop settings. + /// VI: Quản lý cài đặt shop. + /// + ManageSettings = 64, + + /// + /// EN: All permissions. + /// VI: Tất cả quyền. + /// + All = int.MaxValue +} + +/// +/// EN: Shop role for staff assignment at shop level. +/// VI: Vai trò tại shop cho việc gán nhân viên ở cấp shop. +/// +public class ShopRole : Enumeration +{ + /// + /// EN: Cashier role at shop level. + /// VI: Vai trò thu ngân ở cấp shop. + /// + public static readonly ShopRole Cashier = new(1, nameof(Cashier)); + + /// + /// EN: Waiter role at shop level. + /// VI: Vai trò phục vụ ở cấp shop. + /// + public static readonly ShopRole Waiter = new(2, nameof(Waiter)); + + /// + /// EN: Manager role at shop level. + /// VI: Vai trò quản lý ở cấp shop. + /// + public static readonly ShopRole Manager = new(3, nameof(Manager)); + + /// + /// EN: Owner role at shop level (merchant themselves). + /// VI: Vai trò chủ sở hữu ở cấp shop (chính merchant). + /// + public static readonly ShopRole Owner = new(4, nameof(Owner)); + + public ShopRole(int id, string name) : base(id, name) + { + } +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/BusinessCategory.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/BusinessCategory.cs new file mode 100644 index 00000000..a6f96b47 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/BusinessCategory.cs @@ -0,0 +1,83 @@ +// EN: Business category enumeration. +// VI: Enumeration ngành nghề kinh doanh. + +using MerchantService.Domain.SeedWork; + +namespace MerchantService.Domain.AggregatesModel.ShopAggregate; + +/// +/// EN: Represents business categories for shops. +/// VI: Đại diện cho ngành nghề kinh doanh của shop. +/// +public class BusinessCategory : Enumeration +{ + /// + /// EN: Food and Beverage. + /// VI: Ẩm thực và Đồ uống. + /// + public static readonly BusinessCategory FoodBeverage = new(1, nameof(FoodBeverage)); + + /// + /// EN: Fashion and Apparel. + /// VI: Thời trang và May mặc. + /// + public static readonly BusinessCategory Fashion = new(2, nameof(Fashion)); + + /// + /// EN: Electronics and Technology. + /// VI: Điện tử và Công nghệ. + /// + public static readonly BusinessCategory Electronics = new(3, nameof(Electronics)); + + /// + /// EN: Healthcare and Pharmacy. + /// VI: Chăm sóc sức khỏe và Dược phẩm. + /// + public static readonly BusinessCategory Healthcare = new(4, nameof(Healthcare)); + + /// + /// EN: Beauty and Personal Care. + /// VI: Làm đẹp và Chăm sóc cá nhân. + /// + public static readonly BusinessCategory Beauty = new(5, nameof(Beauty)); + + /// + /// EN: Education and Training. + /// VI: Giáo dục và Đào tạo. + /// + public static readonly BusinessCategory Education = new(6, nameof(Education)); + + /// + /// EN: Entertainment and Leisure. + /// VI: Giải trí và Nghỉ dưỡng. + /// + public static readonly BusinessCategory Entertainment = new(7, nameof(Entertainment)); + + /// + /// EN: Professional Services. + /// VI: Dịch vụ chuyên nghiệp. + /// + public static readonly BusinessCategory Services = new(8, nameof(Services)); + + /// + /// EN: Grocery and Supermarket. + /// VI: Tạp hóa và Siêu thị. + /// + public static readonly BusinessCategory Grocery = new(9, nameof(Grocery)); + + /// + /// EN: Home and Furniture. + /// VI: Nhà cửa và Nội thất. + /// + public static readonly BusinessCategory HomeFurniture = new(10, nameof(HomeFurniture)); + + /// + /// EN: Other categories. + /// VI: Các ngành nghề khác. + /// + public static readonly BusinessCategory Other = new(99, nameof(Other)); + + public BusinessCategory(int id, string name) : base(id, name) + { + } +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/IShopRepository.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/IShopRepository.cs new file mode 100644 index 00000000..1602dcec --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/IShopRepository.cs @@ -0,0 +1,61 @@ +// EN: Shop repository interface. +// VI: Interface repository cho Shop. + +using MerchantService.Domain.SeedWork; + +namespace MerchantService.Domain.AggregatesModel.ShopAggregate; + +/// +/// EN: Repository interface for Shop aggregate. +/// VI: Interface repository cho Shop aggregate. +/// +public interface IShopRepository : IRepository +{ + /// + /// EN: Get shop by ID. + /// VI: Lấy shop theo ID. + /// + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// EN: Get shop by ID with branches loaded. + /// VI: Lấy shop theo ID kèm theo các chi nhánh. + /// + Task GetByIdWithBranchesAsync(Guid id, CancellationToken cancellationToken = default); + + /// + /// EN: Get shop by slug (URL-friendly identifier). + /// VI: Lấy shop theo slug (định danh thân thiện URL). + /// + Task GetBySlugAsync(string slug, CancellationToken cancellationToken = default); + + /// + /// EN: Check if slug is already taken. + /// VI: Kiểm tra slug đã được sử dụng chưa. + /// + Task SlugExistsAsync(string slug, CancellationToken cancellationToken = default); + + /// + /// EN: Get all shops by merchant ID. + /// VI: Lấy tất cả shops theo merchant ID. + /// + Task> GetByMerchantIdAsync(Guid merchantId, CancellationToken cancellationToken = default); + + /// + /// EN: Get shops by category. + /// VI: Lấy shops theo ngành nghề. + /// + Task> GetByCategoryAsync(BusinessCategory category, int pageNumber, int pageSize, CancellationToken cancellationToken = default); + + /// + /// EN: Add a new shop. + /// VI: Thêm shop mới. + /// + Shop Add(Shop shop); + + /// + /// EN: Update a shop. + /// VI: Cập nhật shop. + /// + void Update(Shop shop); +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/Shop.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/Shop.cs new file mode 100644 index 00000000..e5bbecd9 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/Shop.cs @@ -0,0 +1,349 @@ +// EN: Shop aggregate root. +// VI: Aggregate root Shop. + +using System.Text.RegularExpressions; +using MerchantService.Domain.Events; +using MerchantService.Domain.Exceptions; +using MerchantService.Domain.SeedWork; + +namespace MerchantService.Domain.AggregatesModel.ShopAggregate; + +/// +/// EN: Shop aggregate root - represents a store owned by a merchant. +/// VI: Aggregate root Shop - đại diện cho cửa hàng thuộc sở hữu của merchant. +/// +public partial class Shop : Entity, IAggregateRoot +{ + // EN: Private fields for encapsulation + // VI: Fields private để đóng gói + private Guid _merchantId; + private string _name = null!; + private string _slug = null!; + private ShopType _type = null!; + private BusinessCategory _category = null!; + private ShopStatus _status = null!; + private ContactInfo _contactInfo = null!; + private OperatingHours? _operatingHours; + private string? _description; + private string? _logoUrl; + private string? _coverImageUrl; + private DateTime _createdAt; + private DateTime? _updatedAt; + private bool _isDeleted; + + private readonly List _branches = new(); + + /// + /// EN: Parent merchant ID. + /// VI: ID merchant cha. + /// + public Guid MerchantId => _merchantId; + + /// + /// EN: Shop name. + /// VI: Tên shop. + /// + public string Name => _name; + + /// + /// EN: URL-friendly unique identifier. + /// VI: Định danh duy nhất thân thiện URL. + /// + public string Slug => _slug; + + /// + /// EN: Shop type (Online, Physical, Hybrid). + /// VI: Loại shop (Online, Cửa hàng vật lý, Hybrid). + /// + public ShopType Type => _type; + + /// + /// EN: Type ID for EF Core mapping. + /// VI: Type ID cho EF Core mapping. + /// + public int TypeId { get; private set; } + + /// + /// EN: Business category. + /// VI: Ngành nghề kinh doanh. + /// + public BusinessCategory Category => _category; + + /// + /// EN: Category ID for EF Core mapping. + /// VI: Category ID cho EF Core mapping. + /// + public int CategoryId { get; private set; } + + /// + /// EN: Current shop status. + /// VI: Trạng thái shop hiện tại. + /// + public ShopStatus Status => _status; + + /// + /// EN: Status ID for EF Core mapping. + /// VI: Status ID cho EF Core mapping. + /// + public int StatusId { get; private set; } + + /// + /// EN: Contact information. + /// VI: Thông tin liên hệ. + /// + public ContactInfo ContactInfo => _contactInfo; + + /// + /// EN: Operating hours. + /// VI: Giờ hoạt động. + /// + public OperatingHours? OperatingHours => _operatingHours; + + /// + /// EN: Shop description. + /// VI: Mô tả shop. + /// + public string? Description => _description; + + /// + /// EN: Shop logo URL. + /// VI: URL logo shop. + /// + public string? LogoUrl => _logoUrl; + + /// + /// EN: Cover image URL. + /// VI: URL ảnh bìa. + /// + public string? CoverImageUrl => _coverImageUrl; + + /// + /// EN: Physical branches of the shop. + /// VI: Các chi nhánh vật lý của shop. + /// + public IReadOnlyCollection Branches => _branches.AsReadOnly(); + + /// + /// 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: Soft delete flag. + /// VI: Cờ xóa mềm. + /// + public bool IsDeleted => _isDeleted; + + /// + /// EN: Private constructor for EF Core. + /// VI: Constructor private cho EF Core. + /// + protected Shop() + { + _contactInfo = ContactInfo.Empty; + } + + /// + /// EN: Create a new shop. + /// VI: Tạo shop mới. + /// + public Shop( + Guid merchantId, + string name, + string slug, + ShopType type, + BusinessCategory category) + { + if (merchantId == Guid.Empty) + throw new DomainException("Merchant ID cannot be empty"); + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException("Shop name cannot be empty"); + if (string.IsNullOrWhiteSpace(slug)) + throw new DomainException("Shop slug cannot be empty"); + if (!IsValidSlug(slug)) + throw new DomainException("Slug must contain only lowercase letters, numbers, and hyphens"); + ArgumentNullException.ThrowIfNull(type, nameof(type)); + ArgumentNullException.ThrowIfNull(category, nameof(category)); + + Id = Guid.NewGuid(); + _merchantId = merchantId; + _name = name.Trim(); + _slug = slug.ToLowerInvariant().Trim(); + _type = type; + TypeId = type.Id; + _category = category; + CategoryId = category.Id; + _status = ShopStatus.Draft; + StatusId = ShopStatus.Draft.Id; + _contactInfo = ContactInfo.Empty; + _createdAt = DateTime.UtcNow; + + AddDomainEvent(new ShopCreatedDomainEvent(this)); + } + + /// + /// EN: Update basic shop information. + /// VI: Cập nhật thông tin cơ bản của shop. + /// + public void UpdateInfo(string name, string? description) + { + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException("Shop name cannot be empty"); + + _name = name.Trim(); + _description = description?.Trim(); + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Update shop slug. + /// VI: Cập nhật slug của shop. + /// + public void UpdateSlug(string slug) + { + if (string.IsNullOrWhiteSpace(slug)) + throw new DomainException("Shop slug cannot be empty"); + if (!IsValidSlug(slug)) + throw new DomainException("Slug must contain only lowercase letters, numbers, and hyphens"); + + _slug = slug.ToLowerInvariant().Trim(); + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Update contact information. + /// VI: Cập nhật thông tin liên hệ. + /// + public void UpdateContactInfo(ContactInfo contactInfo) + { + _contactInfo = contactInfo ?? ContactInfo.Empty; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Update operating hours. + /// VI: Cập nhật giờ hoạt động. + /// + public void UpdateOperatingHours(OperatingHours? hours) + { + _operatingHours = hours; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Update shop images. + /// VI: Cập nhật hình ảnh shop. + /// + public void UpdateImages(string? logoUrl, string? coverImageUrl) + { + _logoUrl = logoUrl; + _coverImageUrl = coverImageUrl; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Publish shop (make it visible to customers). + /// VI: Công khai shop (hiển thị với khách hàng). + /// + public void Publish() + { + if (_status != ShopStatus.Draft && _status != ShopStatus.Inactive) + throw new DomainException($"Cannot publish shop with status {_status.Name}"); + + _status = ShopStatus.Active; + StatusId = ShopStatus.Active.Id; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new ShopPublishedDomainEvent(this)); + } + + /// + /// EN: Set shop to inactive. + /// VI: Đặt shop thành không hoạt động. + /// + public void SetInactive() + { + if (_status == ShopStatus.Closed) + throw new DomainException("Cannot change status of closed shop"); + + _status = ShopStatus.Inactive; + StatusId = ShopStatus.Inactive.Id; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Close shop permanently. + /// VI: Đóng cửa shop vĩnh viễn. + /// + public void Close() + { + _status = ShopStatus.Closed; + StatusId = ShopStatus.Closed.Id; + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new ShopClosedDomainEvent(this)); + } + + /// + /// EN: Add a physical branch to the shop. + /// VI: Thêm chi nhánh vật lý cho shop. + /// + public ShopBranch AddBranch(string name, Address address, GeoLocation? location = null) + { + if (!_type.SupportsBranches) + throw new DomainException("Online-only shops cannot have physical branches"); + + var branch = new ShopBranch(Id, name, address, location); + _branches.Add(branch); + _updatedAt = DateTime.UtcNow; + + AddDomainEvent(new ShopBranchAddedDomainEvent(this, branch)); + return branch; + } + + /// + /// EN: Remove a branch. + /// VI: Xóa chi nhánh. + /// + public void RemoveBranch(Guid branchId) + { + var branch = _branches.FirstOrDefault(b => b.Id == branchId); + if (branch == null) + throw new DomainException("Branch not found"); + + _branches.Remove(branch); + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Soft delete shop. + /// VI: Xóa mềm shop. + /// + public void Delete() + { + if (_isDeleted) + throw new DomainException("Shop is already deleted"); + + _isDeleted = true; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Validate slug format. + /// VI: Kiểm tra định dạng slug. + /// + private static bool IsValidSlug(string slug) + { + return SlugRegex().IsMatch(slug); + } + + [GeneratedRegex(@"^[a-z0-9]+(?:-[a-z0-9]+)*$")] + private static partial Regex SlugRegex(); +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/ShopBranch.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/ShopBranch.cs new file mode 100644 index 00000000..34b0abab --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/ShopBranch.cs @@ -0,0 +1,170 @@ +// EN: Shop branch entity. +// VI: Entity chi nhánh shop. + +using MerchantService.Domain.Exceptions; +using MerchantService.Domain.SeedWork; + +namespace MerchantService.Domain.AggregatesModel.ShopAggregate; + +/// +/// EN: Represents a physical branch/location of a shop. +/// VI: Đại diện cho chi nhánh/địa điểm vật lý của shop. +/// +public class ShopBranch : Entity +{ + private Guid _shopId; + private string _name = null!; + private string? _code; + private Address _address = null!; + private GeoLocation? _location; + private string? _phone; + private OperatingHours? _operatingHours; + private bool _isActive; + private DateTime _createdAt; + private DateTime? _updatedAt; + + /// + /// EN: Parent shop ID. + /// VI: ID shop cha. + /// + public Guid ShopId => _shopId; + + /// + /// EN: Branch name (e.g., "Chi Nhánh Quận 1"). + /// VI: Tên chi nhánh (ví dụ: "Chi Nhánh Quận 1"). + /// + public string Name => _name; + + /// + /// EN: Branch code for internal reference (e.g., "HN01"). + /// VI: Mã chi nhánh để tham chiếu nội bộ (ví dụ: "HN01"). + /// + public string? Code => _code; + + /// + /// EN: Physical address of the branch. + /// VI: Địa chỉ vật lý của chi nhánh. + /// + public Address Address => _address; + + /// + /// EN: Geographic coordinates for map display. + /// VI: Tọa độ địa lý để hiển thị bản đồ. + /// + public GeoLocation? Location => _location; + + /// + /// EN: Branch phone number. + /// VI: Số điện thoại chi nhánh. + /// + public string? Phone => _phone; + + /// + /// EN: Operating hours for this branch. + /// VI: Giờ hoạt động của chi nhánh này. + /// + public OperatingHours? OperatingHours => _operatingHours; + + /// + /// EN: Whether branch is active. + /// VI: Chi nhánh có đang hoạt động không. + /// + public bool IsActive => _isActive; + + /// + /// 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 ShopBranch() + { + } + + /// + /// EN: Create a new shop branch. + /// VI: Tạo chi nhánh shop mới. + /// + public ShopBranch(Guid shopId, string name, Address address, GeoLocation? location = null) + { + if (shopId == Guid.Empty) + throw new DomainException("Shop ID cannot be empty"); + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException("Branch name cannot be empty"); + ArgumentNullException.ThrowIfNull(address, nameof(address)); + + Id = Guid.NewGuid(); + _shopId = shopId; + _name = name.Trim(); + _address = address; + _location = location; + _isActive = true; + _createdAt = DateTime.UtcNow; + } + + /// + /// EN: Update branch information. + /// VI: Cập nhật thông tin chi nhánh. + /// + public void Update(string name, string? code, Address address, GeoLocation? location) + { + if (string.IsNullOrWhiteSpace(name)) + throw new DomainException("Branch name cannot be empty"); + + _name = name.Trim(); + _code = code?.Trim(); + _address = address; + _location = location; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Set branch phone number. + /// VI: Đặt số điện thoại chi nhánh. + /// + public void SetPhone(string? phone) + { + _phone = phone?.Trim(); + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Set operating hours for this branch. + /// VI: Đặt giờ hoạt động cho chi nhánh này. + /// + public void SetOperatingHours(OperatingHours? hours) + { + _operatingHours = hours; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Activate the branch. + /// VI: Kích hoạt chi nhánh. + /// + public void Activate() + { + _isActive = true; + _updatedAt = DateTime.UtcNow; + } + + /// + /// EN: Deactivate the branch. + /// VI: Vô hiệu hóa chi nhánh. + /// + public void Deactivate() + { + _isActive = false; + _updatedAt = DateTime.UtcNow; + } +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/ShopStatus.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/ShopStatus.cs new file mode 100644 index 00000000..5d28ffd6 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/ShopStatus.cs @@ -0,0 +1,53 @@ +// EN: Shop status enumeration. +// VI: Enumeration trạng thái Shop. + +using MerchantService.Domain.SeedWork; + +namespace MerchantService.Domain.AggregatesModel.ShopAggregate; + +/// +/// EN: Represents the status of a shop. +/// VI: Đại diện cho trạng thái của shop. +/// +public class ShopStatus : Enumeration +{ + /// + /// EN: Shop is in draft mode (not published). + /// VI: Shop ở chế độ nháp (chưa công khai). + /// + public static readonly ShopStatus Draft = new(1, nameof(Draft)); + + /// + /// EN: Shop is active and visible to customers. + /// VI: Shop đang hoạt động và hiển thị với khách hàng. + /// + public static readonly ShopStatus Active = new(2, nameof(Active)); + + /// + /// EN: Shop is temporarily inactive. + /// VI: Shop tạm thời không hoạt động. + /// + public static readonly ShopStatus Inactive = new(3, nameof(Inactive)); + + /// + /// EN: Shop is permanently closed. + /// VI: Shop đóng cửa vĩnh viễn. + /// + public static readonly ShopStatus Closed = new(4, nameof(Closed)); + + public ShopStatus(int id, string name) : base(id, name) + { + } + + /// + /// EN: Check if shop is visible to customers. + /// VI: Kiểm tra shop có hiển thị với khách hàng không. + /// + public bool IsVisible => this == Active; + + /// + /// EN: Check if shop can accept orders. + /// VI: Kiểm tra shop có thể nhận đơn hàng không. + /// + public bool CanAcceptOrders => this == Active; +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/ShopType.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/ShopType.cs new file mode 100644 index 00000000..242dcc16 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/ShopType.cs @@ -0,0 +1,41 @@ +// EN: Shop type enumeration. +// VI: Enumeration loại Shop. + +using MerchantService.Domain.SeedWork; + +namespace MerchantService.Domain.AggregatesModel.ShopAggregate; + +/// +/// EN: Represents the type of shop (Online, Physical, or Hybrid). +/// VI: Đại diện cho loại shop (Online, Cửa hàng vật lý, hoặc Hybrid). +/// +public class ShopType : Enumeration +{ + /// + /// EN: Online-only shop (e-commerce). + /// VI: Shop chỉ online (thương mại điện tử). + /// + public static readonly ShopType OnlineOnly = new(1, nameof(OnlineOnly)); + + /// + /// EN: Physical store only. + /// VI: Chỉ cửa hàng vật lý. + /// + public static readonly ShopType PhysicalOnly = new(2, nameof(PhysicalOnly)); + + /// + /// EN: Both online and physical presence. + /// VI: Cả online và cửa hàng vật lý. + /// + public static readonly ShopType Hybrid = new(3, nameof(Hybrid)); + + public ShopType(int id, string name) : base(id, name) + { + } + + /// + /// EN: Check if shop type supports physical branches. + /// VI: Kiểm tra loại shop có hỗ trợ chi nhánh vật lý không. + /// + public bool SupportsBranches => this != OnlineOnly; +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/ShopValueObjects.cs b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/ShopValueObjects.cs new file mode 100644 index 00000000..378c0307 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/AggregatesModel/ShopAggregate/ShopValueObjects.cs @@ -0,0 +1,194 @@ +// EN: Common value objects for Shop aggregate. +// VI: Các value objects chung cho Shop aggregate. + +namespace MerchantService.Domain.AggregatesModel.ShopAggregate; + +/// +/// EN: Contact information value object. +/// VI: Value object thông tin liên hệ. +/// +public record ContactInfo +{ + /// + /// EN: Phone number. + /// VI: Số điện thoại. + /// + public string Phone { get; init; } = string.Empty; + + /// + /// EN: Email address. + /// VI: Địa chỉ email. + /// + public string? Email { get; init; } + + /// + /// EN: Website URL. + /// VI: URL website. + /// + public string? Website { get; init; } + + /// + /// EN: Empty contact info. + /// VI: Thông tin liên hệ rỗng. + /// + public static ContactInfo Empty => new(); +} + +/// +/// EN: Physical address value object. +/// VI: Value object địa chỉ vật lý. +/// +public record Address +{ + /// + /// EN: Street address. + /// VI: Địa chỉ đường phố. + /// + public string Street { get; init; } = string.Empty; + + /// + /// EN: Ward/Commune. + /// VI: Phường/Xã. + /// + public string? Ward { get; init; } + + /// + /// EN: District. + /// VI: Quận/Huyện. + /// + public string District { get; init; } = string.Empty; + + /// + /// EN: City. + /// VI: Thành phố. + /// + public string City { get; init; } = string.Empty; + + /// + /// EN: Province/State. + /// VI: Tỉnh/Thành. + /// + public string? Province { get; init; } + + /// + /// EN: Postal/ZIP code. + /// VI: Mã bưu điện. + /// + public string? PostalCode { get; init; } + + /// + /// EN: Country code (ISO 3166-1 alpha-2). + /// VI: Mã quốc gia (ISO 3166-1 alpha-2). + /// + public string CountryCode { get; init; } = "VN"; + + /// + /// EN: Get full address string. + /// VI: Lấy chuỗi địa chỉ đầy đủ. + /// + public string FullAddress => $"{Street}, {Ward}, {District}, {City}".Trim(' ', ','); +} + +/// +/// EN: Geographic location value object. +/// VI: Value object vị trí địa lý. +/// +public record GeoLocation +{ + /// + /// EN: Latitude coordinate. + /// VI: Tọa độ vĩ độ. + /// + public double Latitude { get; init; } + + /// + /// EN: Longitude coordinate. + /// VI: Tọa độ kinh độ. + /// + public double Longitude { get; init; } + + /// + /// EN: Default location (Ho Chi Minh City center). + /// VI: Vị trí mặc định (trung tâm TP.HCM). + /// + public static GeoLocation Default => new() { Latitude = 10.7769, Longitude = 106.7009 }; + + /// + /// EN: Calculate distance to another location in kilometers. + /// VI: Tính khoảng cách đến vị trí khác tính bằng km. + /// + public double DistanceTo(GeoLocation other) + { + const double R = 6371; // Earth's radius in kilometers + var lat1 = Latitude * Math.PI / 180; + var lat2 = other.Latitude * Math.PI / 180; + var deltaLat = (other.Latitude - Latitude) * Math.PI / 180; + var deltaLon = (other.Longitude - Longitude) * Math.PI / 180; + + var a = Math.Sin(deltaLat / 2) * Math.Sin(deltaLat / 2) + + Math.Cos(lat1) * Math.Cos(lat2) * + Math.Sin(deltaLon / 2) * Math.Sin(deltaLon / 2); + var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a)); + + return R * c; + } +} + +/// +/// EN: Operating hours value object. +/// VI: Value object giờ hoạt động. +/// +public record OperatingHours +{ + /// + /// EN: Opening time. + /// VI: Giờ mở cửa. + /// + public TimeOnly OpenTime { get; init; } = new(8, 0); + + /// + /// EN: Closing time. + /// VI: Giờ đóng cửa. + /// + public TimeOnly CloseTime { get; init; } = new(22, 0); + + /// + /// EN: Days the shop is open. + /// VI: Các ngày shop mở cửa. + /// + public List OpenDays { get; init; } = new() + { + DayOfWeek.Monday, + DayOfWeek.Tuesday, + DayOfWeek.Wednesday, + DayOfWeek.Thursday, + DayOfWeek.Friday, + DayOfWeek.Saturday, + DayOfWeek.Sunday + }; + + /// + /// EN: Check if currently open. + /// VI: Kiểm tra có đang mở cửa không. + /// + public bool IsCurrentlyOpen() + { + var now = DateTime.UtcNow; + var currentTime = TimeOnly.FromDateTime(now); + var currentDay = now.DayOfWeek; + + return OpenDays.Contains(currentDay) && + currentTime >= OpenTime && + currentTime <= CloseTime; + } + + /// + /// EN: Default 24/7 operating hours. + /// VI: Giờ hoạt động mặc định 24/7. + /// + public static OperatingHours TwentyFourSeven => new() + { + OpenTime = new TimeOnly(0, 0), + CloseTime = new TimeOnly(23, 59) + }; +} diff --git a/services/merchant-service-net/src/MerchantService.Domain/Events/MerchantDomainEvents.cs b/services/merchant-service-net/src/MerchantService.Domain/Events/MerchantDomainEvents.cs new file mode 100644 index 00000000..6393e24c --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/Events/MerchantDomainEvents.cs @@ -0,0 +1,37 @@ +// EN: Domain event when a new merchant is registered. +// VI: Domain event khi merchant mới được đăng ký. + +using MediatR; +using MerchantService.Domain.AggregatesModel.MerchantAggregate; + +namespace MerchantService.Domain.Events; + +/// +/// EN: Raised when a new merchant registers. +/// VI: Được phát ra khi merchant mới đăng ký. +/// +public record MerchantRegisteredDomainEvent(Merchant Merchant) : INotification; + +/// +/// EN: Raised when a merchant submits verification documents. +/// VI: Được phát ra khi merchant nộp tài liệu xác minh. +/// +public record MerchantVerificationSubmittedDomainEvent(Merchant Merchant) : INotification; + +/// +/// EN: Raised when a merchant is approved by admin. +/// VI: Được phát ra khi merchant được admin phê duyệt. +/// +public record MerchantApprovedDomainEvent(Merchant Merchant, Guid ApprovedBy) : INotification; + +/// +/// EN: Raised when a merchant is suspended. +/// VI: Được phát ra khi merchant bị tạm ngưng. +/// +public record MerchantSuspendedDomainEvent(Merchant Merchant, string Reason) : INotification; + +/// +/// EN: Raised when a merchant is permanently banned. +/// VI: Được phát ra khi merchant bị cấm vĩnh viễn. +/// +public record MerchantBannedDomainEvent(Merchant Merchant, string Reason) : INotification; diff --git a/services/merchant-service-net/src/MerchantService.Domain/Events/ShopDomainEvents.cs b/services/merchant-service-net/src/MerchantService.Domain/Events/ShopDomainEvents.cs new file mode 100644 index 00000000..de147e3a --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/Events/ShopDomainEvents.cs @@ -0,0 +1,31 @@ +// EN: Domain events for Shop aggregate. +// VI: Domain events cho Shop aggregate. + +using MediatR; +using MerchantService.Domain.AggregatesModel.ShopAggregate; + +namespace MerchantService.Domain.Events; + +/// +/// EN: Raised when a new shop is created. +/// VI: Được phát ra khi shop mới được tạo. +/// +public record ShopCreatedDomainEvent(Shop Shop) : INotification; + +/// +/// EN: Raised when a shop is published (made visible to customers). +/// VI: Được phát ra khi shop được công khai (hiển thị với khách hàng). +/// +public record ShopPublishedDomainEvent(Shop Shop) : INotification; + +/// +/// EN: Raised when a shop is permanently closed. +/// VI: Được phát ra khi shop đóng cửa vĩnh viễn. +/// +public record ShopClosedDomainEvent(Shop Shop) : INotification; + +/// +/// EN: Raised when a new branch is added to a shop. +/// VI: Được phát ra khi chi nhánh mới được thêm vào shop. +/// +public record ShopBranchAddedDomainEvent(Shop Shop, ShopBranch Branch) : INotification; diff --git a/services/merchant-service-net/src/MerchantService.Domain/Events/StaffDomainEvents.cs b/services/merchant-service-net/src/MerchantService.Domain/Events/StaffDomainEvents.cs new file mode 100644 index 00000000..a6b01038 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/Events/StaffDomainEvents.cs @@ -0,0 +1,43 @@ +// EN: Domain events for MerchantStaff aggregate. +// VI: Domain events cho MerchantStaff aggregate. + +using MediatR; +using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate; + +namespace MerchantService.Domain.Events; + +/// +/// EN: Raised when a staff member is invited. +/// VI: Được phát ra khi nhân viên được mời. +/// +public record StaffInvitedDomainEvent(MerchantStaff Staff) : INotification; + +/// +/// EN: Raised when a staff member accepts invitation and joins. +/// VI: Được phát ra khi nhân viên chấp nhận lời mời và gia nhập. +/// +public record StaffJoinedDomainEvent(MerchantStaff Staff) : INotification; + +/// +/// EN: Raised when a staff member sets their PIN code. +/// VI: Được phát ra khi nhân viên đặt mã PIN. +/// +public record StaffPinCodeSetDomainEvent(MerchantStaff Staff) : INotification; + +/// +/// EN: Raised when a staff member registers a device. +/// VI: Được phát ra khi nhân viên đăng ký thiết bị. +/// +public record StaffDeviceRegisteredDomainEvent(MerchantStaff Staff, DeviceToken Device) : INotification; + +/// +/// EN: Raised when a staff member is assigned to a shop. +/// VI: Được phát ra khi nhân viên được gán vào shop. +/// +public record StaffAssignedToShopDomainEvent(MerchantStaff Staff, ShopMember Assignment) : INotification; + +/// +/// EN: Raised when a staff member's employment is terminated. +/// VI: Được phát ra khi nhân viên bị chấm dứt hợp đồng. +/// +public record StaffTerminatedDomainEvent(MerchantStaff Staff) : INotification; diff --git a/services/merchant-service-net/src/MerchantService.Domain/Exceptions/DomainException.cs b/services/merchant-service-net/src/MerchantService.Domain/Exceptions/DomainException.cs new file mode 100644 index 00000000..09557bba --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/Exceptions/DomainException.cs @@ -0,0 +1,21 @@ +namespace MerchantService.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/merchant-service-net/src/MerchantService.Domain/Exceptions/SampleDomainException.cs b/services/merchant-service-net/src/MerchantService.Domain/Exceptions/SampleDomainException.cs new file mode 100644 index 00000000..39b4d814 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/Exceptions/SampleDomainException.cs @@ -0,0 +1,21 @@ +namespace MerchantService.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/merchant-service-net/src/MerchantService.Domain/MerchantService.Domain.csproj b/services/merchant-service-net/src/MerchantService.Domain/MerchantService.Domain.csproj new file mode 100644 index 00000000..ae515f88 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/MerchantService.Domain.csproj @@ -0,0 +1,14 @@ + + + + MerchantService.Domain + MerchantService.Domain + Domain layer containing core business logic and entities + + + + + + + + diff --git a/services/merchant-service-net/src/MerchantService.Domain/SeedWork/Entity.cs b/services/merchant-service-net/src/MerchantService.Domain/SeedWork/Entity.cs new file mode 100644 index 00000000..7c25a05c --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/SeedWork/Entity.cs @@ -0,0 +1,102 @@ +using MediatR; + +namespace MerchantService.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/merchant-service-net/src/MerchantService.Domain/SeedWork/Enumeration.cs b/services/merchant-service-net/src/MerchantService.Domain/SeedWork/Enumeration.cs new file mode 100644 index 00000000..e9d64a77 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/SeedWork/Enumeration.cs @@ -0,0 +1,95 @@ +using System.Reflection; + +namespace MerchantService.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/merchant-service-net/src/MerchantService.Domain/SeedWork/IAggregateRoot.cs b/services/merchant-service-net/src/MerchantService.Domain/SeedWork/IAggregateRoot.cs new file mode 100644 index 00000000..d8721b3d --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/SeedWork/IAggregateRoot.cs @@ -0,0 +1,15 @@ +namespace MerchantService.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/merchant-service-net/src/MerchantService.Domain/SeedWork/IRepository.cs b/services/merchant-service-net/src/MerchantService.Domain/SeedWork/IRepository.cs new file mode 100644 index 00000000..3b169373 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/SeedWork/IRepository.cs @@ -0,0 +1,15 @@ +namespace MerchantService.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/merchant-service-net/src/MerchantService.Domain/SeedWork/IUnitOfWork.cs b/services/merchant-service-net/src/MerchantService.Domain/SeedWork/IUnitOfWork.cs new file mode 100644 index 00000000..8713632c --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/SeedWork/IUnitOfWork.cs @@ -0,0 +1,30 @@ +namespace MerchantService.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/merchant-service-net/src/MerchantService.Domain/SeedWork/ValueObject.cs b/services/merchant-service-net/src/MerchantService.Domain/SeedWork/ValueObject.cs new file mode 100644 index 00000000..894b806e --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Domain/SeedWork/ValueObject.cs @@ -0,0 +1,53 @@ +namespace MerchantService.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/merchant-service-net/src/MerchantService.Infrastructure/DependencyInjection.cs b/services/merchant-service-net/src/MerchantService.Infrastructure/DependencyInjection.cs new file mode 100644 index 00000000..b038fd8b --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Infrastructure/DependencyInjection.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using MerchantService.Domain.AggregatesModel.MerchantAggregate; +using MerchantService.Domain.AggregatesModel.ShopAggregate; +using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate; +using MerchantService.Infrastructure.Idempotency; +using MerchantService.Infrastructure.Repositories; + +namespace MerchantService.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(MerchantServiceContext).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(); + services.AddScoped(); + services.AddScoped(); + + // EN: Register idempotency services / VI: Đăng ký idempotency services + services.AddScoped(); + + return services; + } +} diff --git a/services/merchant-service-net/src/MerchantService.Infrastructure/Idempotency/ClientRequest.cs b/services/merchant-service-net/src/MerchantService.Infrastructure/Idempotency/ClientRequest.cs new file mode 100644 index 00000000..c40ac39e --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Infrastructure/Idempotency/ClientRequest.cs @@ -0,0 +1,26 @@ +namespace MerchantService.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/merchant-service-net/src/MerchantService.Infrastructure/Idempotency/IRequestManager.cs b/services/merchant-service-net/src/MerchantService.Infrastructure/Idempotency/IRequestManager.cs new file mode 100644 index 00000000..2a29250a --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Infrastructure/Idempotency/IRequestManager.cs @@ -0,0 +1,24 @@ +namespace MerchantService.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/merchant-service-net/src/MerchantService.Infrastructure/Idempotency/RequestManager.cs b/services/merchant-service-net/src/MerchantService.Infrastructure/Idempotency/RequestManager.cs new file mode 100644 index 00000000..70b2eaef --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Infrastructure/Idempotency/RequestManager.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; + +namespace MerchantService.Infrastructure.Idempotency; + +/// +/// EN: Implementation of request manager for idempotency. +/// VI: Triển khai request manager cho idempotency. +/// +public class RequestManager : IRequestManager +{ + private readonly MerchantServiceContext _context; + + public RequestManager(MerchantServiceContext 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/merchant-service-net/src/MerchantService.Infrastructure/MerchantService.Infrastructure.csproj b/services/merchant-service-net/src/MerchantService.Infrastructure/MerchantService.Infrastructure.csproj new file mode 100644 index 00000000..e61f2cd1 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Infrastructure/MerchantService.Infrastructure.csproj @@ -0,0 +1,36 @@ + + + + MerchantService.Infrastructure + MerchantService.Infrastructure + Infrastructure layer for data access and external services + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + diff --git a/services/merchant-service-net/src/MerchantService.Infrastructure/MerchantServiceContext.cs b/services/merchant-service-net/src/MerchantService.Infrastructure/MerchantServiceContext.cs new file mode 100644 index 00000000..9c43139c --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Infrastructure/MerchantServiceContext.cs @@ -0,0 +1,194 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using MerchantService.Domain.AggregatesModel.MerchantAggregate; +using MerchantService.Domain.AggregatesModel.ShopAggregate; +using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate; +using MerchantService.Domain.SeedWork; + +namespace MerchantService.Infrastructure; + +/// +/// EN: EF Core DbContext for MerchantService. +/// VI: EF Core DbContext cho MerchantService. +/// +public class MerchantServiceContext : DbContext, IUnitOfWork +{ + private readonly IMediator _mediator; + private IDbContextTransaction? _currentTransaction; + + #region DbSets + + /// + /// EN: Merchants table. + /// VI: Bảng Merchants. + /// + public DbSet Merchants => Set(); + + /// + /// EN: Shops table. + /// VI: Bảng Shops. + /// + public DbSet Shops => Set(); + + /// + /// EN: Shop branches table. + /// VI: Bảng chi nhánh Shop. + /// + public DbSet ShopBranches => Set(); + + /// + /// EN: Merchant staff table. + /// VI: Bảng nhân viên Merchant. + /// + public DbSet MerchantStaff => Set(); + + /// + /// EN: Shop members table (staff-shop assignments). + /// VI: Bảng shop member (gán nhân viên-shop). + /// + public DbSet ShopMembers => Set(); + + /// + /// EN: Device tokens table. + /// VI: Bảng device tokens. + /// + public DbSet DeviceTokens => Set(); + + #endregion + + /// + /// 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 MerchantServiceContext(DbContextOptions options) : base(options) + { + _mediator = null!; + } + + public MerchantServiceContext(DbContextOptions options, IMediator mediator) : base(options) + { + _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); + + System.Diagnostics.Debug.WriteLine("MerchantServiceContext::ctor - " + GetHashCode()); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // EN: Apply entity configurations from this assembly + // VI: Áp dụng các cấu hình entity từ assembly này + modelBuilder.ApplyConfigurationsFromAssembly(typeof(MerchantServiceContext).Assembly); + } + + /// + /// 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/merchant-service-net/src/MerchantService.Infrastructure/Repositories/MerchantRepository.cs b/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/MerchantRepository.cs new file mode 100644 index 00000000..e2619e00 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/MerchantRepository.cs @@ -0,0 +1,77 @@ +// EN: Merchant repository implementation. +// VI: Implementation repository cho Merchant. + +using Microsoft.EntityFrameworkCore; +using MerchantService.Domain.AggregatesModel.MerchantAggregate; +using MerchantService.Domain.SeedWork; + +namespace MerchantService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for Merchant aggregate. +/// VI: Implementation repository cho Merchant aggregate. +/// +public class MerchantRepository : IMerchantRepository +{ + private readonly MerchantServiceContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public MerchantRepository(MerchantServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Merchants + .FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted, cancellationToken); + } + + /// + public async Task GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default) + { + return await _context.Merchants + .FirstOrDefaultAsync(m => m.UserId == userId && !m.IsDeleted, cancellationToken); + } + + /// + public async Task ExistsByUserIdAsync(Guid userId, CancellationToken cancellationToken = default) + { + return await _context.Merchants + .AnyAsync(m => m.UserId == userId && !m.IsDeleted, cancellationToken); + } + + /// + public async Task> GetAllAsync(int pageNumber, int pageSize, CancellationToken cancellationToken = default) + { + return await _context.Merchants + .Where(m => !m.IsDeleted) + .OrderByDescending(m => m.CreatedAt) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + } + + /// + public async Task> GetByStatusAsync(MerchantStatus status, CancellationToken cancellationToken = default) + { + return await _context.Merchants + .Where(m => m.StatusId == status.Id && !m.IsDeleted) + .OrderByDescending(m => m.CreatedAt) + .ToListAsync(cancellationToken); + } + + /// + public Merchant Add(Merchant merchant) + { + return _context.Merchants.Add(merchant).Entity; + } + + /// + public void Update(Merchant merchant) + { + _context.Entry(merchant).State = EntityState.Modified; + } +} diff --git a/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/MerchantStaffRepository.cs b/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/MerchantStaffRepository.cs new file mode 100644 index 00000000..b0a6f0a8 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/MerchantStaffRepository.cs @@ -0,0 +1,86 @@ +// EN: MerchantStaff repository implementation. +// VI: Implementation repository cho MerchantStaff. + +using Microsoft.EntityFrameworkCore; +using MerchantService.Domain.AggregatesModel.MerchantStaffAggregate; +using MerchantService.Domain.SeedWork; + +namespace MerchantService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for MerchantStaff aggregate. +/// VI: Implementation repository cho MerchantStaff aggregate. +/// +public class MerchantStaffRepository : IMerchantStaffRepository +{ + private readonly MerchantServiceContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public MerchantStaffRepository(MerchantServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.MerchantStaff + .Include(s => s.ShopAssignments) + .Include(s => s.DeviceTokens) + .FirstOrDefaultAsync(s => s.Id == id, cancellationToken); + } + + /// + public async Task GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default) + { + return await _context.MerchantStaff + .Include(s => s.ShopAssignments) + .FirstOrDefaultAsync(s => s.UserId == userId && s.StatusId != StaffStatus.Terminated.Id, cancellationToken); + } + + /// + public async Task GetByUserIdAndMerchantIdAsync(Guid userId, Guid merchantId, CancellationToken cancellationToken = default) + { + return await _context.MerchantStaff + .Include(s => s.ShopAssignments) + .FirstOrDefaultAsync(s => s.UserId == userId && s.MerchantId == merchantId && s.StatusId != StaffStatus.Terminated.Id, cancellationToken); + } + + /// + public async Task> GetByMerchantIdAsync(Guid merchantId, CancellationToken cancellationToken = default) + { + return await _context.MerchantStaff + .Where(s => s.MerchantId == merchantId && s.StatusId != StaffStatus.Terminated.Id) + .OrderByDescending(s => s.JoinedAt) + .ToListAsync(cancellationToken); + } + + /// + public async Task> GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default) + { + return await _context.MerchantStaff + .Where(s => s.ShopAssignments.Any(a => a.ShopId == shopId) && s.StatusId != StaffStatus.Terminated.Id) + .Include(s => s.ShopAssignments.Where(a => a.ShopId == shopId)) + .ToListAsync(cancellationToken); + } + + /// + public async Task ExistsByUserIdAndMerchantIdAsync(Guid userId, Guid merchantId, CancellationToken cancellationToken = default) + { + return await _context.MerchantStaff + .AnyAsync(s => s.UserId == userId && s.MerchantId == merchantId && s.StatusId != StaffStatus.Terminated.Id, cancellationToken); + } + + /// + public MerchantStaff Add(MerchantStaff staff) + { + return _context.MerchantStaff.Add(staff).Entity; + } + + /// + public void Update(MerchantStaff staff) + { + _context.Entry(staff).State = EntityState.Modified; + } +} diff --git a/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/ShopRepository.cs b/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/ShopRepository.cs new file mode 100644 index 00000000..73898a64 --- /dev/null +++ b/services/merchant-service-net/src/MerchantService.Infrastructure/Repositories/ShopRepository.cs @@ -0,0 +1,85 @@ +// EN: Shop repository implementation. +// VI: Implementation repository cho Shop. + +using Microsoft.EntityFrameworkCore; +using MerchantService.Domain.AggregatesModel.ShopAggregate; +using MerchantService.Domain.SeedWork; + +namespace MerchantService.Infrastructure.Repositories; + +/// +/// EN: Repository implementation for Shop aggregate. +/// VI: Implementation repository cho Shop aggregate. +/// +public class ShopRepository : IShopRepository +{ + private readonly MerchantServiceContext _context; + + public IUnitOfWork UnitOfWork => _context; + + public ShopRepository(MerchantServiceContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Shops + .FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted, cancellationToken); + } + + /// + public async Task GetByIdWithBranchesAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Shops + .Include(s => s.Branches) + .FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted, cancellationToken); + } + + /// + public async Task GetBySlugAsync(string slug, CancellationToken cancellationToken = default) + { + return await _context.Shops + .FirstOrDefaultAsync(s => s.Slug == slug.ToLowerInvariant() && !s.IsDeleted, cancellationToken); + } + + /// + public async Task SlugExistsAsync(string slug, CancellationToken cancellationToken = default) + { + return await _context.Shops + .AnyAsync(s => s.Slug == slug.ToLowerInvariant(), cancellationToken); + } + + /// + public async Task> GetByMerchantIdAsync(Guid merchantId, CancellationToken cancellationToken = default) + { + return await _context.Shops + .Where(s => s.MerchantId == merchantId && !s.IsDeleted) + .OrderByDescending(s => s.CreatedAt) + .ToListAsync(cancellationToken); + } + + /// + public async Task> GetByCategoryAsync(BusinessCategory category, int pageNumber, int pageSize, CancellationToken cancellationToken = default) + { + return await _context.Shops + .Where(s => s.CategoryId == category.Id && !s.IsDeleted && s.StatusId == ShopStatus.Active.Id) + .OrderByDescending(s => s.CreatedAt) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + } + + /// + public Shop Add(Shop shop) + { + return _context.Shops.Add(shop).Entity; + } + + /// + public void Update(Shop shop) + { + _context.Entry(shop).State = EntityState.Modified; + } +} diff --git a/services/merchant-service-net/tests/MerchantService.FunctionalTests/CustomWebApplicationFactory.cs b/services/merchant-service-net/tests/MerchantService.FunctionalTests/CustomWebApplicationFactory.cs new file mode 100644 index 00000000..5e152577 --- /dev/null +++ b/services/merchant-service-net/tests/MerchantService.FunctionalTests/CustomWebApplicationFactory.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using MerchantService.Infrastructure; + +namespace MerchantService.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(MerchantServiceContext)); + + 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/merchant-service-net/tests/MerchantService.FunctionalTests/MerchantService.FunctionalTests.csproj b/services/merchant-service-net/tests/MerchantService.FunctionalTests/MerchantService.FunctionalTests.csproj new file mode 100644 index 00000000..29c8228f --- /dev/null +++ b/services/merchant-service-net/tests/MerchantService.FunctionalTests/MerchantService.FunctionalTests.csproj @@ -0,0 +1,38 @@ + + + + MerchantService.FunctionalTests + MerchantService.FunctionalTests + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/services/merchant-service-net/tests/MerchantService.UnitTests/MerchantService.UnitTests.csproj b/services/merchant-service-net/tests/MerchantService.UnitTests/MerchantService.UnitTests.csproj new file mode 100644 index 00000000..f89a031e --- /dev/null +++ b/services/merchant-service-net/tests/MerchantService.UnitTests/MerchantService.UnitTests.csproj @@ -0,0 +1,35 @@ + + + + MerchantService.UnitTests + MerchantService.UnitTests + false + true + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + +