feat: Thêm dịch vụ MerchantService mới và cập nhật các tệp điều khiển thành viên trong MembershipService.

This commit is contained in:
Ho Ngoc Hai
2026-01-15 18:14:13 +07:00
parent c3b4224ac5
commit 580e074145
102 changed files with 6892 additions and 217 deletions

View File

@@ -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"

View File

@@ -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"
- url: "http://storage-service:8080"
# EN: Membership Service
# VI: Membership Service
membership-service:
loadBalancer:
servers:
- url: "http://membership-service-net:8080"

View File

@@ -247,6 +247,11 @@ var app = builder.Build();
var logger = app.Services.GetRequiredService<ILogger<Program>>();
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();

View File

@@ -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;
/// <summary>
/// EN: Database seeder for system roles and initial data.
/// VI: Database seeder cho system roles và dữ liệu khởi tạo.
/// </summary>
public static class DataSeeder
{
/// <summary>
/// EN: Seed system roles into the database.
/// VI: Seed system roles vào database.
/// </summary>
public static async Task SeedRolesAsync(IServiceProvider serviceProvider)
{
using var scope = serviceProvider.CreateScope();
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<ApplicationRole>>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<RoleManager<ApplicationRole>>>();
// 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");
}
}

View File

@@ -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"]

View File

@@ -0,0 +1,95 @@
using MediatR;
namespace MembershipService.API.Application.Commands;
/// <summary>
/// EN: Command to add experience points to a member.
/// VI: Command để thêm điểm kinh nghiệm cho member.
/// </summary>
public class AddExperienceCommand : IRequest<AddExperienceResult>
{
/// <summary>
/// EN: Member ID.
/// VI: ID của member.
/// </summary>
public Guid MemberId { get; set; }
/// <summary>
/// EN: Amount of EXP points to add.
/// VI: Số điểm EXP cộng thêm.
/// </summary>
public int Points { get; set; }
/// <summary>
/// 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).
/// </summary>
public int SourceId { get; set; }
/// <summary>
/// EN: Reference ID (Order ID, Referral Code, etc.).
/// VI: ID tham chiếu (Order ID, Referral Code, etc.).
/// </summary>
public string? ReferenceId { get; set; }
/// <summary>
/// EN: Additional metadata as JSON string.
/// VI: Metadata bổ sung dạng chuỗi JSON.
/// </summary>
public string? Metadata { get; set; }
}
/// <summary>
/// EN: Result of add experience command.
/// VI: Kết quả của add experience command.
/// </summary>
public class AddExperienceResult
{
/// <summary>
/// EN: Member ID.
/// VI: ID của member.
/// </summary>
public Guid MemberId { get; set; }
/// <summary>
/// EN: Points added.
/// VI: Điểm đã thêm.
/// </summary>
public int PointsAdded { get; set; }
/// <summary>
/// EN: New current EXP.
/// VI: EXP hiện tại mới.
/// </summary>
public int CurrentExp { get; set; }
/// <summary>
/// EN: Total EXP earned.
/// VI: Tổng EXP đã kiếm được.
/// </summary>
public int TotalExpEarned { get; set; }
/// <summary>
/// EN: Previous level before adding EXP.
/// VI: Level trước khi thêm EXP.
/// </summary>
public int PreviousLevel { get; set; }
/// <summary>
/// EN: Current level after adding EXP.
/// VI: Level hiện tại sau khi thêm EXP.
/// </summary>
public int CurrentLevel { get; set; }
/// <summary>
/// EN: Whether the member leveled up.
/// VI: Member có lên level không.
/// </summary>
public bool LeveledUp { get; set; }
/// <summary>
/// EN: Experience transaction ID.
/// VI: ID của experience transaction.
/// </summary>
public Guid TransactionId { get; set; }
}

View File

@@ -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;
/// <summary>
/// EN: Handler for adding experience points to a member.
/// VI: Handler để thêm điểm kinh nghiệm cho member.
/// </summary>
public class AddExperienceCommandHandler : IRequestHandler<AddExperienceCommand, AddExperienceResult>
{
private readonly IMemberRepository _memberRepository;
private readonly ILevelDefinitionRepository _levelDefinitionRepository;
private readonly IExperienceTransactionRepository _experienceTransactionRepository;
private readonly ILogger<AddExperienceCommandHandler> _logger;
public AddExperienceCommandHandler(
IMemberRepository memberRepository,
ILevelDefinitionRepository levelDefinitionRepository,
IExperienceTransactionRepository experienceTransactionRepository,
ILogger<AddExperienceCommandHandler> 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<AddExperienceResult> 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<ExperienceSource>(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
};
}
}

View File

@@ -1,34 +0,0 @@
using MediatR;
namespace MembershipService.API.Application.Commands;
/// <summary>
/// EN: Command to change membership level.
/// VI: Command để thay đổi cấp thành viên.
/// </summary>
public class ChangeMembershipLevelCommand : IRequest<ChangeMembershipLevelResult>
{
/// <summary>
/// EN: Member ID.
/// VI: ID member.
/// </summary>
public Guid MemberId { get; set; }
/// <summary>
/// EN: New membership level ID.
/// VI: ID cấp thành viên mới.
/// </summary>
public int NewLevelId { get; set; }
}
/// <summary>
/// EN: Result of change membership level command.
/// VI: Kết quả của change membership level command.
/// </summary>
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; }
}

View File

@@ -1,54 +0,0 @@
using MediatR;
using MembershipService.Domain.AggregatesModel.MemberAggregate;
namespace MembershipService.API.Application.Commands;
/// <summary>
/// EN: Handler for changing membership level.
/// VI: Handler để thay đổi cấp thành viên.
/// </summary>
public class ChangeMembershipLevelCommandHandler : IRequestHandler<ChangeMembershipLevelCommand, ChangeMembershipLevelResult>
{
private readonly IMemberRepository _memberRepository;
private readonly ILogger<ChangeMembershipLevelCommandHandler> _logger;
public ChangeMembershipLevelCommandHandler(
IMemberRepository memberRepository,
ILogger<ChangeMembershipLevelCommandHandler> logger)
{
_memberRepository = memberRepository ?? throw new ArgumentNullException(nameof(memberRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<ChangeMembershipLevelResult> 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<MembershipLevel>(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
};
}
}

View File

@@ -35,6 +35,12 @@ public class CreateMemberResult
{
public Guid MemberId { get; set; }
public Guid UserId { get; set; }
public string MembershipLevel { get; set; } = null!;
/// <summary>
/// EN: Initial level (always 1 for new members).
/// VI: Level ban đầu (luôn là 1 cho member mới).
/// </summary>
public int CurrentLevel { get; set; }
public DateTime CreatedAt { get; set; }
}

View File

@@ -30,20 +30,21 @@ public class CreateMemberCommandHandler : IRequestHandler<CreateMemberCommand, C
throw new InvalidOperationException($"Member already exists for user {request.UserId}");
}
// EN: Create new member with gender
// VI: Tạo member mới với gender
// EN: Create new member with gender (starts at level 1, exp 0)
// VI: Tạo member mới với gender (bắt đầu ở level 1, exp 0)
var member = new Member(request.UserId, request.CountryCode, request.Gender);
_memberRepository.Add(member);
await _memberRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
_logger.LogInformation("Created member {MemberId} for user {UserId}", member.Id, request.UserId);
_logger.LogInformation("Created member {MemberId} for user {UserId} at level {Level}",
member.Id, request.UserId, member.CurrentLevel);
return new CreateMemberResult
{
MemberId = member.Id,
UserId = member.UserId,
MembershipLevel = member.MembershipLevel.Name,
CurrentLevel = member.CurrentLevel,
CreatedAt = member.CreatedAt
};
}

View File

@@ -0,0 +1,82 @@
using MediatR;
namespace MembershipService.API.Application.Queries;
/// <summary>
/// EN: Query to get experience history for a member.
/// VI: Query để lấy lịch sử EXP của member.
/// </summary>
public class GetExperienceHistoryQuery : IRequest<ExperienceHistoryResult>
{
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;
}
}
/// <summary>
/// EN: Experience history result.
/// VI: Kết quả lịch sử EXP.
/// </summary>
public class ExperienceHistoryResult
{
public IEnumerable<ExperienceTransactionDto> Transactions { get; set; } = Enumerable.Empty<ExperienceTransactionDto>();
public int TotalCount { get; set; }
public int PageIndex { get; set; }
public int PageSize { get; set; }
}
/// <summary>
/// EN: Experience transaction DTO.
/// VI: DTO giao dịch EXP.
/// </summary>
public class ExperienceTransactionDto
{
/// <summary>
/// EN: Transaction ID.
/// VI: ID giao dịch.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// EN: Points earned.
/// VI: Điểm kiếm được.
/// </summary>
public int Points { get; set; }
/// <summary>
/// EN: Source name.
/// VI: Tên nguồn.
/// </summary>
public string Source { get; set; } = null!;
/// <summary>
/// EN: Source ID.
/// VI: ID nguồn.
/// </summary>
public int SourceId { get; set; }
/// <summary>
/// EN: Reference ID.
/// VI: ID tham chiếu.
/// </summary>
public string? ReferenceId { get; set; }
/// <summary>
/// EN: Level at time of transaction.
/// VI: Level tại thời điểm giao dịch.
/// </summary>
public int LevelAtTime { get; set; }
/// <summary>
/// EN: Creation timestamp.
/// VI: Thời gian tạo.
/// </summary>
public DateTime CreatedAt { get; set; }
}

View File

@@ -0,0 +1,50 @@
using MediatR;
using MembershipService.Domain.AggregatesModel.ExperienceAggregate;
namespace MembershipService.API.Application.Queries;
/// <summary>
/// EN: Handler for getting experience history.
/// VI: Handler để lấy lịch sử EXP.
/// </summary>
public class GetExperienceHistoryQueryHandler : IRequestHandler<GetExperienceHistoryQuery, ExperienceHistoryResult>
{
private readonly IExperienceTransactionRepository _experienceTransactionRepository;
public GetExperienceHistoryQueryHandler(IExperienceTransactionRepository experienceTransactionRepository)
{
_experienceTransactionRepository = experienceTransactionRepository
?? throw new ArgumentNullException(nameof(experienceTransactionRepository));
}
public async Task<ExperienceHistoryResult> 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
};
}
}

View File

@@ -0,0 +1,46 @@
using MediatR;
namespace MembershipService.API.Application.Queries;
/// <summary>
/// EN: Query to get all level definitions.
/// VI: Query để lấy tất cả level definitions.
/// </summary>
public class GetLevelDefinitionsQuery : IRequest<IEnumerable<LevelDefinitionDto>>
{
/// <summary>
/// EN: Whether to include inactive levels.
/// VI: Có bao gồm levels không active không.
/// </summary>
public bool IncludeInactive { get; set; } = false;
}
/// <summary>
/// EN: Level definition DTO.
/// VI: DTO level definition.
/// </summary>
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<LevelBenefitDto> Benefits { get; set; } = Enumerable.Empty<LevelBenefitDto>();
}
/// <summary>
/// EN: Level benefit DTO.
/// VI: DTO level benefit.
/// </summary>
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; }
}

View File

@@ -0,0 +1,46 @@
using MediatR;
using MembershipService.Domain.AggregatesModel.LevelAggregate;
namespace MembershipService.API.Application.Queries;
/// <summary>
/// EN: Handler for getting level definitions.
/// VI: Handler để lấy level definitions.
/// </summary>
public class GetLevelDefinitionsQueryHandler : IRequestHandler<GetLevelDefinitionsQuery, IEnumerable<LevelDefinitionDto>>
{
private readonly ILevelDefinitionRepository _levelDefinitionRepository;
public GetLevelDefinitionsQueryHandler(ILevelDefinitionRepository levelDefinitionRepository)
{
_levelDefinitionRepository = levelDefinitionRepository
?? throw new ArgumentNullException(nameof(levelDefinitionRepository));
}
public async Task<IEnumerable<LevelDefinitionDto>> 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
})
});
}
}

View File

@@ -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!;
/// <summary>
/// EN: Current member level (1, 2, 3...).
/// VI: Level hiện tại của member (1, 2, 3...).
/// </summary>
public int CurrentLevel { get; set; }
/// <summary>
/// EN: Current experience points.
/// VI: Điểm kinh nghiệm hiện tại.
/// </summary>
public int CurrentExp { get; set; }
/// <summary>
/// EN: Total experience points ever earned.
/// VI: Tổng điểm kinh nghiệm đã kiếm được.
/// </summary>
public int TotalExpEarned { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
/// <summary>
/// EN: Membership level DTO.
/// VI: DTO cấp thành viên.
/// </summary>
public class MembershipLevelDto
{
public int Id { get; set; }
public string Name { get; set; } = null!;
}

View File

@@ -36,11 +36,9 @@ public class GetMemberByIdQueryHandler : IRequestHandler<GetMemberByIdQuery, Mem
Gender = member.Gender,
CountryCode = member.CountryCode,
Preferences = member.Preferences,
MembershipLevel = new MembershipLevelDto
{
Id = member.MembershipLevel.Id,
Name = member.MembershipLevel.Name
},
CurrentLevel = member.CurrentLevel,
CurrentExp = member.CurrentExp,
TotalExpEarned = member.TotalExpEarned,
CreatedAt = member.CreatedAt,
UpdatedAt = member.UpdatedAt
};

View File

@@ -0,0 +1,84 @@
using MediatR;
namespace MembershipService.API.Application.Queries;
/// <summary>
/// EN: Query to get member level progress.
/// VI: Query để lấy tiến độ level của member.
/// </summary>
public class GetMemberProgressQuery : IRequest<MemberProgressDto?>
{
public Guid MemberId { get; set; }
public GetMemberProgressQuery(Guid memberId)
{
MemberId = memberId;
}
}
/// <summary>
/// EN: Member progress DTO.
/// VI: DTO tiến độ member.
/// </summary>
public class MemberProgressDto
{
/// <summary>
/// EN: Member ID.
/// VI: ID của member.
/// </summary>
public Guid MemberId { get; set; }
/// <summary>
/// EN: Current level number.
/// VI: Số level hiện tại.
/// </summary>
public int CurrentLevel { get; set; }
/// <summary>
/// EN: Current level name.
/// VI: Tên level hiện tại.
/// </summary>
public string CurrentLevelName { get; set; } = null!;
/// <summary>
/// EN: Current experience points.
/// VI: Điểm kinh nghiệm hiện tại.
/// </summary>
public int CurrentExp { get; set; }
/// <summary>
/// EN: Total experience points ever earned.
/// VI: Tổng điểm kinh nghiệm đã kiếm được.
/// </summary>
public int TotalExpEarned { get; set; }
/// <summary>
/// EN: EXP required for next level.
/// VI: EXP cần để lên level tiếp.
/// </summary>
public int ExpToNextLevel { get; set; }
/// <summary>
/// EN: Progress percentage to next level (0-100).
/// VI: Phần trăm tiến độ đến level tiếp (0-100).
/// </summary>
public int ProgressPercent { get; set; }
/// <summary>
/// EN: Next level number (null if max level).
/// VI: Số level tiếp theo (null nếu đã max).
/// </summary>
public int? NextLevel { get; set; }
/// <summary>
/// EN: Next level name (null if max level).
/// VI: Tên level tiếp theo (null nếu đã max).
/// </summary>
public string? NextLevelName { get; set; }
/// <summary>
/// EN: Badge color for current level.
/// VI: Màu badge của level hiện tại.
/// </summary>
public string? BadgeColor { get; set; }
}

View File

@@ -0,0 +1,80 @@
using MediatR;
using MembershipService.Domain.AggregatesModel.LevelAggregate;
using MembershipService.Domain.AggregatesModel.MemberAggregate;
namespace MembershipService.API.Application.Queries;
/// <summary>
/// EN: Handler for getting member level progress.
/// VI: Handler để lấy tiến độ level của member.
/// </summary>
public class GetMemberProgressQueryHandler : IRequestHandler<GetMemberProgressQuery, MemberProgressDto?>
{
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<MemberProgressDto?> 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
};
}
}

View File

@@ -31,11 +31,9 @@ public class GetMembersQueryHandler : IRequestHandler<GetMembersQuery, GetMember
Gender = m.Gender,
CountryCode = m.CountryCode,
Preferences = m.Preferences,
MembershipLevel = new MembershipLevelDto
{
Id = m.MembershipLevel.Id,
Name = m.MembershipLevel.Name
},
CurrentLevel = m.CurrentLevel,
CurrentExp = m.CurrentExp,
TotalExpEarned = m.TotalExpEarned,
CreatedAt = m.CreatedAt,
UpdatedAt = m.UpdatedAt
});

View File

@@ -0,0 +1,50 @@
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MembershipService.API.Application.Queries;
using Swashbuckle.AspNetCore.Annotations;
namespace MembershipService.API.Controllers;
/// <summary>
/// EN: Controller for managing level definitions.
/// VI: Controller để quản lý level definitions.
/// </summary>
[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<LevelsController> _logger;
public LevelsController(IMediator mediator, ILogger<LevelsController> logger)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// EN: Get all level definitions.
/// VI: Lấy tất cả level definitions.
/// </summary>
[HttpGet]
[AllowAnonymous]
[SwaggerOperation(Summary = "Get level definitions", Description = "Retrieves all level definitions")]
[SwaggerResponse(200, "Level definitions retrieved", typeof(IEnumerable<LevelDefinitionDto>))]
public async Task<ActionResult<IEnumerable<LevelDefinitionDto>>> 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)
}

View File

@@ -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
/// <summary>
/// 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.
/// </summary>
[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<ActionResult<ChangeMembershipLevelResult>> ChangeLevel(
public async Task<ActionResult<AddExperienceResult>> AddExperience(
Guid id,
[FromBody] ChangeMembershipLevelCommand command)
[FromBody] AddExperienceCommand command)
{
command.MemberId = id;
@@ -175,6 +180,42 @@ public class MembersController : ControllerBase
}
}
/// <summary>
/// EN: Get member's level progress.
/// VI: Lấy tiến độ level của member.
/// </summary>
[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<ActionResult<MemberProgressDto>> 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);
}
/// <summary>
/// EN: Get member's experience history.
/// VI: Lấy lịch sử EXP của member.
/// </summary>
[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<ActionResult<ExperienceHistoryResult>> 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;

View File

@@ -14,6 +14,10 @@
<!-- EN: FluentValidation for request validation / VI: FluentValidation cho validation request -->
<PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<!-- EN: Swagger/OpenAPI / VI: Swagger/OpenAPI -->
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />

View File

@@ -0,0 +1,64 @@
using MembershipService.Domain.SeedWork;
namespace MembershipService.Domain.AggregatesModel.ExperienceAggregate;
/// <summary>
/// EN: Experience source enumeration - defines where EXP comes from.
/// VI: Experience source enumeration - định nghĩa nguồn gốc EXP.
/// </summary>
/// <remarks>
/// 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ú.
/// </remarks>
public class ExperienceSource : Enumeration
{
/// <summary>
/// EN: EXP from purchase/order completion.
/// VI: EXP từ hoàn thành đơn hàng.
/// </summary>
public static readonly ExperienceSource Purchase = new(1, nameof(Purchase));
/// <summary>
/// EN: EXP from friend referral.
/// VI: EXP từ giới thiệu bạn bè.
/// </summary>
public static readonly ExperienceSource Referral = new(2, nameof(Referral));
/// <summary>
/// EN: EXP from app activity (browsing, engagement).
/// VI: EXP từ hoạt động trên app (duyệt, tương tác).
/// </summary>
public static readonly ExperienceSource Activity = new(3, nameof(Activity));
/// <summary>
/// EN: EXP from promotional campaigns.
/// VI: EXP từ chiến dịch khuyến mãi.
/// </summary>
public static readonly ExperienceSource Promotion = new(4, nameof(Promotion));
/// <summary>
/// EN: EXP from product reviews.
/// VI: EXP từ đánh giá sản phẩm.
/// </summary>
public static readonly ExperienceSource Review = new(5, nameof(Review));
/// <summary>
/// EN: EXP from daily check-in.
/// VI: EXP từ check-in hàng ngày.
/// </summary>
public static readonly ExperienceSource CheckIn = new(6, nameof(CheckIn));
/// <summary>
/// EN: EXP manually granted by admin.
/// VI: EXP được admin cấp thủ công.
/// </summary>
public static readonly ExperienceSource Admin = new(7, nameof(Admin));
/// <summary>
/// EN: Private constructor for Enumeration pattern.
/// VI: Constructor private cho Enumeration pattern.
/// </summary>
public ExperienceSource(int id, string name) : base(id, name)
{
}
}

View File

@@ -0,0 +1,128 @@
using MembershipService.Domain.SeedWork;
namespace MembershipService.Domain.AggregatesModel.ExperienceAggregate;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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;
/// <summary>
/// EN: Member ID who received the EXP.
/// VI: ID của member nhận EXP.
/// </summary>
public Guid MemberId => _memberId;
/// <summary>
/// EN: Amount of EXP points earned.
/// VI: Số điểm EXP kiếm được.
/// </summary>
public int Points => _points;
/// <summary>
/// EN: Experience source ID (for EF Core mapping).
/// VI: ID nguồn EXP (cho EF Core mapping).
/// </summary>
public int SourceId => _sourceId;
/// <summary>
/// EN: Experience source.
/// VI: Nguồn EXP.
/// </summary>
public ExperienceSource Source => _source;
/// <summary>
/// EN: Reference ID (Order ID, Referral Code, etc.).
/// VI: ID tham chiếu (Order ID, Referral Code, etc.).
/// </summary>
public string? ReferenceId => _referenceId;
/// <summary>
/// EN: Additional metadata as JSON string.
/// VI: Metadata bổ sung dạng chuỗi JSON.
/// </summary>
public string? Metadata => _metadata;
/// <summary>
/// EN: Member's level when this EXP was earned.
/// VI: Level của member khi nhận EXP này.
/// </summary>
public int LevelAtTime => _levelAtTime;
/// <summary>
/// EN: Creation timestamp.
/// VI: Thời gian tạo.
/// </summary>
public DateTime CreatedAt => _createdAt;
/// <summary>
/// EN: Private constructor for EF Core.
/// VI: Constructor private cho EF Core.
/// </summary>
protected ExperienceTransaction()
{
}
/// <summary>
/// EN: Create new experience transaction.
/// VI: Tạo experience transaction mới.
/// </summary>
/// <param name="memberId">Member ID / ID của member</param>
/// <param name="points">EXP points / Số điểm EXP</param>
/// <param name="source">Experience source / Nguồn EXP</param>
/// <param name="levelAtTime">Member's current level / Level hiện tại của member</param>
/// <param name="referenceId">Reference ID (optional) / ID tham chiếu (tùy chọn)</param>
/// <param name="metadata">Metadata JSON (optional) / Metadata JSON (tùy chọn)</param>
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;
}
/// <summary>
/// EN: Set experience source (for EF Core loading).
/// VI: Set experience source (cho EF Core loading).
/// </summary>
public void SetSource(ExperienceSource source)
{
_source = source;
}
}

View File

@@ -0,0 +1,69 @@
using MembershipService.Domain.SeedWork;
namespace MembershipService.Domain.AggregatesModel.ExperienceAggregate;
/// <summary>
/// EN: Repository interface for ExperienceTransaction.
/// VI: Interface repository cho ExperienceTransaction.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public interface IExperienceTransactionRepository
{
/// <summary>
/// EN: The unit of work for this repository.
/// VI: Unit of work cho repository này.
/// </summary>
IUnitOfWork UnitOfWork { get; }
/// <summary>
/// EN: Get experience transaction by ID.
/// VI: Lấy experience transaction theo ID.
/// </summary>
Task<ExperienceTransaction?> GetAsync(Guid id);
/// <summary>
/// EN: Get paginated experience transactions for a member.
/// VI: Lấy experience transactions phân trang cho một member.
/// </summary>
Task<IReadOnlyList<ExperienceTransaction>> GetByMemberIdAsync(
Guid memberId,
int skip = 0,
int take = 20);
/// <summary>
/// EN: Get experience transactions by source for a member.
/// VI: Lấy experience transactions theo nguồn cho một member.
/// </summary>
Task<IReadOnlyList<ExperienceTransaction>> GetBySourceAsync(
Guid memberId,
ExperienceSource source);
/// <summary>
/// EN: Get total EXP points for a member.
/// VI: Lấy tổng điểm EXP của một member.
/// </summary>
Task<int> GetTotalPointsByMemberIdAsync(Guid memberId);
/// <summary>
/// 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.
/// </summary>
Task<int> GetTotalPointsBySourceAsync(Guid memberId, ExperienceSource source);
/// <summary>
/// EN: Get transaction count for a member.
/// VI: Lấy số lượng transactions của một member.
/// </summary>
Task<int> GetCountByMemberIdAsync(Guid memberId);
/// <summary>
/// EN: Add new experience transaction.
/// VI: Thêm experience transaction mới.
/// </summary>
ExperienceTransaction Add(ExperienceTransaction transaction);
}

View File

@@ -0,0 +1,58 @@
using MembershipService.Domain.SeedWork;
namespace MembershipService.Domain.AggregatesModel.LevelAggregate;
/// <summary>
/// EN: Repository interface for LevelDefinition aggregate.
/// VI: Interface repository cho LevelDefinition aggregate.
/// </summary>
public interface ILevelDefinitionRepository : IRepository<LevelDefinition>
{
/// <summary>
/// EN: Get level definition by ID.
/// VI: Lấy level definition theo ID.
/// </summary>
Task<LevelDefinition?> GetAsync(Guid id);
/// <summary>
/// EN: Get level definition by level number.
/// VI: Lấy level definition theo số thứ tự level.
/// </summary>
Task<LevelDefinition?> GetByLevelNumberAsync(int levelNumber);
/// <summary>
/// EN: Get level definition with benefits.
/// VI: Lấy level definition kèm benefits.
/// </summary>
Task<LevelDefinition?> GetWithBenefitsAsync(Guid id);
/// <summary>
/// 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ự.
/// </summary>
Task<IReadOnlyList<LevelDefinition>> GetAllActiveAsync();
/// <summary>
/// EN: Get all level definitions (including inactive).
/// VI: Lấy tất cả level definitions (bao gồm cả inactive).
/// </summary>
Task<IReadOnlyList<LevelDefinition>> GetAllAsync();
/// <summary>
/// EN: Check if level number already exists.
/// VI: Kiểm tra xem số thứ tự level đã tồn tại chưa.
/// </summary>
Task<bool> ExistsByLevelNumberAsync(int levelNumber);
/// <summary>
/// EN: Add new level definition.
/// VI: Thêm level definition mới.
/// </summary>
LevelDefinition Add(LevelDefinition levelDefinition);
/// <summary>
/// EN: Update level definition.
/// VI: Cập nhật level definition.
/// </summary>
void Update(LevelDefinition levelDefinition);
}

View File

@@ -0,0 +1,146 @@
using MembershipService.Domain.SeedWork;
namespace MembershipService.Domain.AggregatesModel.LevelAggregate;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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)
/// </remarks>
public class LevelBenefit : Entity
{
private Guid _levelDefinitionId;
private string _benefitType;
private string _benefitValue; // JSON
private string? _description;
private bool _isActive;
private DateTime _createdAt;
/// <summary>
/// EN: Foreign key to LevelDefinition.
/// VI: Foreign key đến LevelDefinition.
/// </summary>
public Guid LevelDefinitionId => _levelDefinitionId;
/// <summary>
/// EN: Benefit type (discount_percent, free_shipping, etc.).
/// VI: Loại benefit (discount_percent, free_shipping, etc.).
/// </summary>
public string BenefitType => _benefitType;
/// <summary>
/// EN: Benefit value as JSON string.
/// VI: Giá trị benefit dạng chuỗi JSON.
/// </summary>
public string BenefitValue => _benefitValue;
/// <summary>
/// EN: Benefit description.
/// VI: Mô tả benefit.
/// </summary>
public string? Description => _description;
/// <summary>
/// EN: Whether this benefit is active.
/// VI: Benefit có đang active không.
/// </summary>
public bool IsActive => _isActive;
/// <summary>
/// EN: Creation timestamp.
/// VI: Thời gian tạo.
/// </summary>
public DateTime CreatedAt => _createdAt;
/// <summary>
/// EN: Private constructor for EF Core.
/// VI: Constructor private cho EF Core.
/// </summary>
protected LevelBenefit()
{
_benefitType = string.Empty;
_benefitValue = "{}";
}
/// <summary>
/// EN: Create new level benefit.
/// VI: Tạo level benefit mới.
/// </summary>
/// <param name="levelDefinitionId">Level definition ID / ID của level definition</param>
/// <param name="benefitType">Benefit type / Loại benefit</param>
/// <param name="benefitValue">Benefit value as JSON / Giá trị benefit dạng JSON</param>
/// <param name="description">Description / Mô tả</param>
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;
}
/// <summary>
/// EN: Update benefit value.
/// VI: Cập nhật giá trị benefit.
/// </summary>
public void UpdateBenefitValue(string benefitValue)
{
if (string.IsNullOrWhiteSpace(benefitValue))
throw new ArgumentException("Benefit value cannot be empty", nameof(benefitValue));
_benefitValue = benefitValue;
}
/// <summary>
/// EN: Update description.
/// VI: Cập nhật mô tả.
/// </summary>
public void UpdateDescription(string? description)
{
_description = description;
}
/// <summary>
/// EN: Activate this benefit.
/// VI: Kích hoạt benefit này.
/// </summary>
public void Activate()
{
_isActive = true;
}
/// <summary>
/// EN: Deactivate this benefit.
/// VI: Vô hiệu hóa benefit này.
/// </summary>
public void Deactivate()
{
_isActive = false;
}
}

View File

@@ -0,0 +1,233 @@
using MembershipService.Domain.SeedWork;
namespace MembershipService.Domain.AggregatesModel.LevelAggregate;
/// <summary>
/// EN: Level definition aggregate root - configurable level rules.
/// VI: Level definition aggregate root - cấu hình level có thể tùy chỉnh.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public class LevelDefinition : Entity, IAggregateRoot
{
private readonly List<LevelBenefit> _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;
/// <summary>
/// EN: Level number (1, 2, 3...).
/// VI: Số thứ tự level (1, 2, 3...).
/// </summary>
public int LevelNumber => _levelNumber;
/// <summary>
/// EN: Level name (Bronze, Silver, Gold...).
/// VI: Tên level (Bronze, Silver, Gold...).
/// </summary>
public string Name => _name;
/// <summary>
/// EN: Required EXP to reach this level.
/// VI: EXP cần thiết để đạt level này.
/// </summary>
public int RequiredExp => _requiredExp;
/// <summary>
/// EN: Level description.
/// VI: Mô tả level.
/// </summary>
public string? Description => _description;
/// <summary>
/// EN: Icon URL for the level badge.
/// VI: URL icon cho badge level.
/// </summary>
public string? IconUrl => _iconUrl;
/// <summary>
/// EN: Badge color in hex format (#CD7F32, #FFD700...).
/// VI: Màu badge dạng hex (#CD7F32, #FFD700...).
/// </summary>
public string? BadgeColor => _badgeColor;
/// <summary>
/// EN: Whether this level is active.
/// VI: Level có đang active không.
/// </summary>
public bool IsActive => _isActive;
/// <summary>
/// EN: Creation timestamp.
/// VI: Thời gian tạo.
/// </summary>
public DateTime CreatedAt => _createdAt;
/// <summary>
/// EN: Last update timestamp.
/// VI: Thời gian cập nhật cuối.
/// </summary>
public DateTime UpdatedAt => _updatedAt;
/// <summary>
/// EN: Benefits associated with this level.
/// VI: Các benefits đi kèm với level này.
/// </summary>
public IReadOnlyCollection<LevelBenefit> Benefits => _benefits.AsReadOnly();
/// <summary>
/// EN: Private constructor for EF Core.
/// VI: Constructor private cho EF Core.
/// </summary>
protected LevelDefinition()
{
_name = string.Empty;
}
/// <summary>
/// EN: Create new level definition.
/// VI: Tạo level definition mới.
/// </summary>
/// <param name="levelNumber">Level number (1, 2, 3...) / Số thứ tự level</param>
/// <param name="name">Level name / Tên level</param>
/// <param name="requiredExp">Required EXP / EXP yêu cầu</param>
/// <param name="description">Description / Mô tả</param>
/// <param name="iconUrl">Icon URL / URL icon</param>
/// <param name="badgeColor">Badge color (hex) / Màu badge</param>
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;
}
/// <summary>
/// EN: Update level name.
/// VI: Cập nhật tên level.
/// </summary>
public void UpdateName(string name)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Level name cannot be empty", nameof(name));
_name = name;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Update required EXP threshold.
/// VI: Cập nhật ngưỡng EXP yêu cầu.
/// </summary>
public void UpdateRequiredExp(int requiredExp)
{
if (requiredExp < 0)
throw new ArgumentException("Required EXP cannot be negative", nameof(requiredExp));
_requiredExp = requiredExp;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Update level description.
/// VI: Cập nhật mô tả level.
/// </summary>
public void UpdateDescription(string? description)
{
_description = description;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Update icon URL.
/// VI: Cập nhật URL icon.
/// </summary>
public void UpdateIconUrl(string? iconUrl)
{
_iconUrl = iconUrl;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Update badge color.
/// VI: Cập nhật màu badge.
/// </summary>
public void UpdateBadgeColor(string? badgeColor)
{
_badgeColor = badgeColor;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Add a benefit to this level.
/// VI: Thêm một benefit cho level này.
/// </summary>
public void AddBenefit(LevelBenefit benefit)
{
if (benefit == null)
throw new ArgumentNullException(nameof(benefit));
_benefits.Add(benefit);
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Remove a benefit from this level.
/// VI: Xóa một benefit khỏi level này.
/// </summary>
public void RemoveBenefit(LevelBenefit benefit)
{
_benefits.Remove(benefit);
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Activate this level.
/// VI: Kích hoạt level này.
/// </summary>
public void Activate()
{
_isActive = true;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Deactivate this level (soft delete).
/// VI: Vô hiệu hóa level này (xóa mềm).
/// </summary>
public void Deactivate()
{
_isActive = false;
_updatedAt = DateTime.UtcNow;
}
}

View File

@@ -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;
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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;
/// <summary>
/// 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.
/// </summary>
public int MembershipLevelId => _membershipLevelId;
public int CurrentLevel => _currentLevel;
/// <summary>
/// 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.
/// </summary>
public MembershipLevel MembershipLevel => _membershipLevel;
public int CurrentExp => _currentExp;
/// <summary>
/// EN: Total experience points ever earned.
/// VI: Tổng điểm kinh nghiệm đã kiếm được.
/// </summary>
public int TotalExpEarned => _totalExpEarned;
/// <summary>
/// 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));
}
/// <summary>
/// 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.
/// </summary>
/// <param name="points">Amount of EXP to add / Số EXP cộng thêm</param>
/// <param name="source">Source of experience / Nguồn EXP</param>
/// <param name="levelRules">Active level definitions / Các level definitions đang active</param>
/// <param name="referenceId">Reference ID (optional) / ID tham chiếu (tùy chọn)</param>
/// <param name="metadata">Metadata JSON (optional) / Metadata JSON (tùy chọn)</param>
/// <returns>ExperienceTransaction for tracking / ExperienceTransaction để tracking</returns>
public ExperienceTransaction AddExperience(
int points,
ExperienceSource source,
IReadOnlyList<LevelDefinition> 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;
}
/// <summary>
/// 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.
/// </summary>
private static int CalculateLevel(int exp, IReadOnlyList<LevelDefinition> rules)
{
return rules
.Where(r => r.IsActive && r.RequiredExp <= exp)
.OrderByDescending(r => r.LevelNumber)
.FirstOrDefault()?.LevelNumber ?? 1;
}
/// <summary>
/// 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));
}
/// <summary>
/// EN: Change membership level.
/// VI: Thay đổi cấp thành viên.
/// </summary>
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));
}
/// <summary>
/// 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;
}
/// <summary>
/// EN: Set membership level (for EF Core loading).
/// VI: Set membership level (cho EF Core loading).
/// </summary>
internal void SetMembershipLevel(MembershipLevel level)
{
_membershipLevel = level;
}
}

View File

@@ -0,0 +1,62 @@
using MediatR;
using MembershipService.Domain.AggregatesModel.ExperienceAggregate;
using MembershipService.Domain.AggregatesModel.MemberAggregate;
namespace MembershipService.Domain.Events;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public class MemberExperienceAddedDomainEvent : INotification
{
/// <summary>
/// EN: Event ID.
/// VI: ID của event.
/// </summary>
public Guid Id { get; } = Guid.NewGuid();
/// <summary>
/// EN: When the event occurred.
/// VI: Thời điểm event xảy ra.
/// </summary>
public DateTime OccurredOn { get; } = DateTime.UtcNow;
/// <summary>
/// EN: The member who received EXP.
/// VI: Member đã nhận EXP.
/// </summary>
public Member Member { get; }
/// <summary>
/// EN: Amount of EXP points received.
/// VI: Số điểm EXP nhận được.
/// </summary>
public int Points { get; }
/// <summary>
/// EN: Source of the experience points.
/// VI: Nguồn gốc của điểm kinh nghiệm.
/// </summary>
public ExperienceSource Source { get; }
/// <summary>
/// EN: Member ID.
/// VI: ID của member.
/// </summary>
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));
}
}

View File

@@ -0,0 +1,58 @@
using MediatR;
using MembershipService.Domain.AggregatesModel.MemberAggregate;
namespace MembershipService.Domain.Events;
/// <summary>
/// EN: Domain event raised when a member levels up.
/// VI: Domain event được phát ra khi member lên level.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public class MemberLevelUpDomainEvent : INotification
{
/// <summary>
/// EN: Event ID.
/// VI: ID của event.
/// </summary>
public Guid Id { get; } = Guid.NewGuid();
/// <summary>
/// EN: When the event occurred.
/// VI: Thời điểm event xảy ra.
/// </summary>
public DateTime OccurredOn { get; } = DateTime.UtcNow;
/// <summary>
/// EN: The member who leveled up.
/// VI: Member đã lên level.
/// </summary>
public Member Member { get; }
/// <summary>
/// EN: Previous level before leveling up.
/// VI: Level trước khi lên.
/// </summary>
public int OldLevel { get; }
/// <summary>
/// EN: New level after leveling up.
/// VI: Level mới sau khi lên.
/// </summary>
public int NewLevel { get; }
/// <summary>
/// EN: Member ID.
/// VI: ID của member.
/// </summary>
public Guid MemberId => Member.Id;
public MemberLevelUpDomainEvent(Member member, int oldLevel, int newLevel)
{
Member = member ?? throw new ArgumentNullException(nameof(member));
OldLevel = oldLevel;
NewLevel = newLevel;
}
}

View File

@@ -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<IMemberRepository, MemberRepository>();
services.AddScoped<ILevelDefinitionRepository, LevelDefinitionRepository>();
services.AddScoped<IExperienceTransactionRepository, ExperienceTransactionRepository>();
// EN: Register idempotency services / VI: Đăng ký idempotency services
services.AddScoped<IRequestManager, RequestManager>();

View File

@@ -0,0 +1,84 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MembershipService.Domain.AggregatesModel.ExperienceAggregate;
namespace MembershipService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: Entity configuration for ExperienceTransaction entity.
/// VI: Cấu hình entity cho ExperienceTransaction entity.
/// </summary>
public class ExperienceTransactionEntityTypeConfiguration : IEntityTypeConfiguration<ExperienceTransaction>
{
public void Configure(EntityTypeBuilder<ExperienceTransaction> 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);
}
}

View File

@@ -0,0 +1,70 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MembershipService.Domain.AggregatesModel.LevelAggregate;
namespace MembershipService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: Entity configuration for LevelBenefit entity.
/// VI: Cấu hình entity cho LevelBenefit entity.
/// </summary>
public class LevelBenefitEntityTypeConfiguration : IEntityTypeConfiguration<LevelBenefit>
{
public void Configure(EntityTypeBuilder<LevelBenefit> 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);
}
}

View File

@@ -0,0 +1,97 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MembershipService.Domain.AggregatesModel.LevelAggregate;
namespace MembershipService.Infrastructure.EntityConfigurations;
/// <summary>
/// EN: Entity configuration for LevelDefinition aggregate.
/// VI: Cấu hình entity cho LevelDefinition aggregate.
/// </summary>
public class LevelDefinitionEntityTypeConfiguration : IEntityTypeConfiguration<LevelDefinition>
{
public void Configure(EntityTypeBuilder<LevelDefinition> 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);
}
}

View File

@@ -35,16 +35,26 @@ public class MemberEntityTypeConfiguration : IEntityTypeConfiguration<Member>
.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<Member>
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");

View File

@@ -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<Member> Members { get; set; } = null!;
/// <summary>
/// EN: Membership levels table.
/// VI: Bảng cấp thành viên.
/// EN: Level definitions table.
/// VI: Bảng level definitions.
/// </summary>
public DbSet<MembershipLevel> MembershipLevels { get; set; } = null!;
public DbSet<LevelDefinition> LevelDefinitions { get; set; } = null!;
/// <summary>
/// EN: Level benefits table.
/// VI: Bảng level benefits.
/// </summary>
public DbSet<LevelBenefit> LevelBenefits { get; set; } = null!;
/// <summary>
/// EN: Experience transactions table.
/// VI: Bảng experience transactions.
/// </summary>
public DbSet<ExperienceTransaction> ExperienceTransactions { get; set; } = null!;
/// <summary>
/// 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<MembershipLevel>().ToTable("membership_levels");
modelBuilder.Entity<MembershipLevel>().HasData(
MembershipLevel.Free,
MembershipLevel.Basic,
MembershipLevel.Premium
);
}
/// <summary>

View File

@@ -0,0 +1,133 @@
using Microsoft.EntityFrameworkCore;
using MembershipService.Domain.AggregatesModel.ExperienceAggregate;
using MembershipService.Domain.SeedWork;
namespace MembershipService.Infrastructure.Repositories;
/// <summary>
/// EN: Repository implementation for ExperienceTransaction.
/// VI: Repository implementation cho ExperienceTransaction.
/// </summary>
public class ExperienceTransactionRepository : IExperienceTransactionRepository
{
private readonly MembershipServiceContext _context;
public ExperienceTransactionRepository(MembershipServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public IUnitOfWork UnitOfWork => _context;
/// <summary>
/// EN: Get experience transaction by ID.
/// VI: Lấy experience transaction theo ID.
/// </summary>
public async Task<ExperienceTransaction?> 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<ExperienceSource>(transaction.SourceId);
transaction.SetSource(source);
}
return transaction;
}
/// <summary>
/// EN: Get paginated experience transactions for a member.
/// VI: Lấy experience transactions phân trang cho một member.
/// </summary>
public async Task<IReadOnlyList<ExperienceTransaction>> GetByMemberIdAsync(
Guid memberId,
int skip = 0,
int take = 20)
{
var transactions = await _context.ExperienceTransactions
.Where(t => EF.Property<Guid>(t, "_memberId") == memberId)
.OrderByDescending(t => EF.Property<DateTime>(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<ExperienceSource>(transaction.SourceId);
transaction.SetSource(source);
}
return transactions;
}
/// <summary>
/// EN: Get experience transactions by source for a member.
/// VI: Lấy experience transactions theo nguồn cho một member.
/// </summary>
public async Task<IReadOnlyList<ExperienceTransaction>> GetBySourceAsync(
Guid memberId,
ExperienceSource source)
{
var transactions = await _context.ExperienceTransactions
.Where(t => EF.Property<Guid>(t, "_memberId") == memberId &&
EF.Property<int>(t, "_sourceId") == source.Id)
.OrderByDescending(t => EF.Property<DateTime>(t, "_createdAt"))
.ToListAsync();
foreach (var transaction in transactions)
{
transaction.SetSource(source);
}
return transactions;
}
/// <summary>
/// EN: Get total EXP points for a member.
/// VI: Lấy tổng điểm EXP của một member.
/// </summary>
public async Task<int> GetTotalPointsByMemberIdAsync(Guid memberId)
{
return await _context.ExperienceTransactions
.Where(t => EF.Property<Guid>(t, "_memberId") == memberId)
.SumAsync(t => EF.Property<int>(t, "_points"));
}
/// <summary>
/// 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.
/// </summary>
public async Task<int> GetTotalPointsBySourceAsync(Guid memberId, ExperienceSource source)
{
return await _context.ExperienceTransactions
.Where(t => EF.Property<Guid>(t, "_memberId") == memberId &&
EF.Property<int>(t, "_sourceId") == source.Id)
.SumAsync(t => EF.Property<int>(t, "_points"));
}
/// <summary>
/// EN: Get transaction count for a member.
/// VI: Lấy số lượng transactions của một member.
/// </summary>
public async Task<int> GetCountByMemberIdAsync(Guid memberId)
{
return await _context.ExperienceTransactions
.Where(t => EF.Property<Guid>(t, "_memberId") == memberId)
.CountAsync();
}
/// <summary>
/// EN: Add new experience transaction.
/// VI: Thêm experience transaction mới.
/// </summary>
public ExperienceTransaction Add(ExperienceTransaction transaction)
{
return _context.ExperienceTransactions.Add(transaction).Entity;
}
}

View File

@@ -0,0 +1,103 @@
using Microsoft.EntityFrameworkCore;
using MembershipService.Domain.AggregatesModel.LevelAggregate;
using MembershipService.Domain.SeedWork;
namespace MembershipService.Infrastructure.Repositories;
/// <summary>
/// EN: Repository implementation for LevelDefinition aggregate.
/// VI: Repository implementation cho LevelDefinition aggregate.
/// </summary>
public class LevelDefinitionRepository : ILevelDefinitionRepository
{
private readonly MembershipServiceContext _context;
public LevelDefinitionRepository(MembershipServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public IUnitOfWork UnitOfWork => _context;
/// <summary>
/// EN: Get level definition by ID.
/// VI: Lấy level definition theo ID.
/// </summary>
public async Task<LevelDefinition?> GetAsync(Guid id)
{
return await _context.LevelDefinitions
.FirstOrDefaultAsync(l => l.Id == id);
}
/// <summary>
/// EN: Get level definition by level number.
/// VI: Lấy level definition theo số thứ tự level.
/// </summary>
public async Task<LevelDefinition?> GetByLevelNumberAsync(int levelNumber)
{
return await _context.LevelDefinitions
.FirstOrDefaultAsync(l => EF.Property<int>(l, "_levelNumber") == levelNumber);
}
/// <summary>
/// EN: Get level definition with benefits.
/// VI: Lấy level definition kèm benefits.
/// </summary>
public async Task<LevelDefinition?> GetWithBenefitsAsync(Guid id)
{
return await _context.LevelDefinitions
.Include(l => l.Benefits)
.FirstOrDefaultAsync(l => l.Id == id);
}
/// <summary>
/// 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ự.
/// </summary>
public async Task<IReadOnlyList<LevelDefinition>> GetAllActiveAsync()
{
return await _context.LevelDefinitions
.Where(l => EF.Property<bool>(l, "_isActive"))
.OrderBy(l => EF.Property<int>(l, "_levelNumber"))
.ToListAsync();
}
/// <summary>
/// EN: Get all level definitions (including inactive).
/// VI: Lấy tất cả level definitions (bao gồm cả inactive).
/// </summary>
public async Task<IReadOnlyList<LevelDefinition>> GetAllAsync()
{
return await _context.LevelDefinitions
.OrderBy(l => EF.Property<int>(l, "_levelNumber"))
.ToListAsync();
}
/// <summary>
/// EN: Check if level number already exists.
/// VI: Kiểm tra xem số thứ tự level đã tồn tại chưa.
/// </summary>
public async Task<bool> ExistsByLevelNumberAsync(int levelNumber)
{
return await _context.LevelDefinitions
.AnyAsync(l => EF.Property<int>(l, "_levelNumber") == levelNumber);
}
/// <summary>
/// EN: Add new level definition.
/// VI: Thêm level definition mới.
/// </summary>
public LevelDefinition Add(LevelDefinition levelDefinition)
{
return _context.LevelDefinitions.Add(levelDefinition).Entity;
}
/// <summary>
/// EN: Update level definition.
/// VI: Cập nhật level definition.
/// </summary>
public void Update(LevelDefinition levelDefinition)
{
_context.Entry(levelDefinition).State = EntityState.Modified;
}
}

View File

@@ -26,7 +26,6 @@ public class MemberRepository : IMemberRepository
public async Task<Member?> 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<string?>(m, "_phoneNumber") != null &&
EF.Property<string>(m, "_phoneNumber").Contains(searchTerm));
m.CountryCode.Contains(searchTerm) ||
(m.Gender != null && m.Gender.Contains(searchTerm)));
}
var totalCount = await query.CountAsync(cancellationToken);

View File

@@ -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<ArgumentException>().WithMessage("*positive*");
}
[Fact]
@@ -143,4 +185,16 @@ public class MemberAggregateTests
// Assert
member.IsDeleted.Should().BeTrue();
}
private static IReadOnlyList<LevelDefinition> CreateDefaultLevelRules()
{
return new List<LevelDefinition>
{
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")
};
}
}

View File

@@ -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

View File

@@ -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
~$*

View File

@@ -0,0 +1,22 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>14.0</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591;CA2017</NoWarn>
</PropertyGroup>
<PropertyGroup>
<Authors>GoodGo Team</Authors>
<Company>GoodGo</Company>
<Copyright>© 2026 GoodGo. All rights reserved.</Copyright>
<RepositoryType>git</RepositoryType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
</ItemGroup>
</Project>

View File

@@ -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"]

View File

@@ -0,0 +1,11 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/MerchantService.API/MerchantService.API.csproj" />
<Project Path="src/MerchantService.Domain/MerchantService.Domain.csproj" />
<Project Path="src/MerchantService.Infrastructure/MerchantService.Infrastructure.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/MerchantService.FunctionalTests/MerchantService.FunctionalTests.csproj" />
<Project Path="tests/MerchantService.UnitTests/MerchantService.UnitTests.csproj" />
</Folder>
</Solution>

View File

@@ -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

View File

@@ -0,0 +1,7 @@
{
"sdk": {
"version": "10.0.101",
"rollForward": "latestMinor",
"allowPrerelease": false
}
}

View File

@@ -0,0 +1,58 @@
using System.Diagnostics;
using MediatR;
namespace MerchantService.API.Application.Behaviors;
/// <summary>
/// EN: MediatR behavior for logging request handling.
/// VI: MediatR behavior để logging việc xử lý request.
/// </summary>
/// <typeparam name="TRequest">EN: Request type / VI: Loại request</typeparam>
/// <typeparam name="TResponse">EN: Response type / VI: Loại response</typeparam>
public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> 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;
}
}
}

View File

@@ -0,0 +1,84 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using MerchantService.Infrastructure;
namespace MerchantService.API.Application.Behaviors;
/// <summary>
/// EN: MediatR behavior for handling database transactions.
/// VI: MediatR behavior để xử lý database transactions.
/// </summary>
/// <typeparam name="TRequest">EN: Request type / VI: Loại request</typeparam>
/// <typeparam name="TResponse">EN: Response type / VI: Loại response</typeparam>
public class TransactionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly MerchantServiceContext _dbContext;
private readonly ILogger<TransactionBehavior<TRequest, TResponse>> _logger;
public TransactionBehavior(
MerchantServiceContext dbContext,
ILogger<TransactionBehavior<TRequest, TResponse>> logger)
{
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> 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;
}
});
}
}

View File

@@ -0,0 +1,63 @@
using FluentValidation;
using MediatR;
namespace MerchantService.API.Application.Behaviors;
/// <summary>
/// EN: MediatR behavior for FluentValidation integration.
/// VI: MediatR behavior để tích hợp FluentValidation.
/// </summary>
/// <typeparam name="TRequest">EN: Request type / VI: Loại request</typeparam>
/// <typeparam name="TResponse">EN: Response type / VI: Loại response</typeparam>
public class ValidatorBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
private readonly ILogger<ValidatorBehavior<TRequest, TResponse>> _logger;
public ValidatorBehavior(
IEnumerable<IValidator<TRequest>> validators,
ILogger<ValidatorBehavior<TRequest, TResponse>> logger)
{
_validators = validators ?? throw new ArgumentNullException(nameof(validators));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> 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<TRequest>(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();
}
}

View File

@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<AssemblyName>MerchantService.API</AssemblyName>
<RootNamespace>MerchantService.API</RootNamespace>
<Description>Web API layer with CQRS pattern</Description>
<UserSecretsId>myservice-api</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<!-- EN: MediatR for CQRS / VI: MediatR cho CQRS -->
<PackageReference Include="MediatR" Version="12.4.1" />
<!-- EN: FluentValidation for request validation / VI: FluentValidation cho validation request -->
<PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
<!-- EN: Swagger/OpenAPI / VI: Swagger/OpenAPI -->
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
<!-- EN: API Versioning / VI: API Versioning -->
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<!-- EN: Health checks / VI: Health checks -->
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="8.0.2" />
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" />
<!-- EN: Problem Details (RFC 7807) / VI: Problem Details (RFC 7807) -->
<PackageReference Include="Hellang.Middleware.ProblemDetails" Version="6.5.1" />
<!-- EN: Serilog for structured logging / VI: Serilog cho structured logging -->
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MerchantService.Domain\MerchantService.Domain.csproj" />
<ProjectReference Include="..\MerchantService.Infrastructure\MerchantService.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -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<Program>();
cfg.AddOpenBehavior(typeof(LoggingBehavior<,>));
cfg.AddOpenBehavior(typeof(ValidatorBehavior<,>));
cfg.AddOpenBehavior(typeof(TransactionBehavior<,>));
});
// EN: Add FluentValidation / VI: Thêm FluentValidation
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
// 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 { }

View File

@@ -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"
}
}
}
}

View File

@@ -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"
}
}
}
}

View File

@@ -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": "*"
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public record BankAccount
{
/// <summary>
/// EN: Bank code (e.g., "VCB", "TCB").
/// VI: Mã ngân hàng (ví dụ: "VCB", "TCB").
/// </summary>
public string BankCode { get; init; } = string.Empty;
/// <summary>
/// EN: Full bank name.
/// VI: Tên đầy đủ ngân hàng.
/// </summary>
public string BankName { get; init; } = string.Empty;
/// <summary>
/// EN: Account number.
/// VI: Số tài khoản.
/// </summary>
public string AccountNumber { get; init; } = string.Empty;
/// <summary>
/// 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).
/// </summary>
public string AccountHolderName { get; init; } = string.Empty;
/// <summary>
/// EN: Creates empty bank account.
/// VI: Tạo tài khoản ngân hàng rỗng.
/// </summary>
public static BankAccount Empty => new();
/// <summary>
/// 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.
/// </summary>
public bool IsValid =>
!string.IsNullOrWhiteSpace(BankCode) &&
!string.IsNullOrWhiteSpace(AccountNumber) &&
!string.IsNullOrWhiteSpace(AccountHolderName);
}

View File

@@ -0,0 +1,49 @@
// EN: Business information value object.
// VI: Value object thông tin doanh nghiệp.
namespace MerchantService.Domain.AggregatesModel.MerchantAggregate;
/// <summary>
/// EN: Value object representing business information for verification.
/// VI: Value object đại diện cho thông tin doanh nghiệp để xác minh.
/// </summary>
public record BusinessInfo
{
/// <summary>
/// EN: Tax identification number.
/// VI: Mã số thuế.
/// </summary>
public string? TaxId { get; init; }
/// <summary>
/// EN: Business license number.
/// VI: Số giấy phép kinh doanh.
/// </summary>
public string? BusinessLicenseNumber { get; init; }
/// <summary>
/// EN: Company registration number.
/// VI: Số đăng ký công ty.
/// </summary>
public string? CompanyRegistrationNumber { get; init; }
/// <summary>
/// EN: Date when the business was established.
/// VI: Ngày thành lập doanh nghiệp.
/// </summary>
public DateTime? EstablishedDate { get; init; }
/// <summary>
/// EN: Creates empty business info.
/// VI: Tạo thông tin doanh nghiệp rỗng.
/// </summary>
public static BusinessInfo Empty => new();
/// <summary>
/// EN: Check if business info is complete for verification.
/// VI: Kiểm tra thông tin doanh nghiệp đã đủ để xác minh chưa.
/// </summary>
public bool IsCompleteForVerification =>
!string.IsNullOrWhiteSpace(TaxId) &&
!string.IsNullOrWhiteSpace(BusinessLicenseNumber);
}

View File

@@ -0,0 +1,55 @@
// EN: Merchant repository interface.
// VI: Interface repository cho Merchant.
using MerchantService.Domain.SeedWork;
namespace MerchantService.Domain.AggregatesModel.MerchantAggregate;
/// <summary>
/// EN: Repository interface for Merchant aggregate.
/// VI: Interface repository cho Merchant aggregate.
/// </summary>
public interface IMerchantRepository : IRepository<Merchant>
{
/// <summary>
/// EN: Get merchant by ID.
/// VI: Lấy merchant theo ID.
/// </summary>
Task<Merchant?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get merchant by user ID (IAM User).
/// VI: Lấy merchant theo user ID (IAM User).
/// </summary>
Task<Merchant?> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Check if user already has a merchant account.
/// VI: Kiểm tra user đã có tài khoản merchant chưa.
/// </summary>
Task<bool> ExistsByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get all merchants with pagination.
/// VI: Lấy tất cả merchants với phân trang.
/// </summary>
Task<IReadOnlyList<Merchant>> GetAllAsync(int pageNumber, int pageSize, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get merchants by status.
/// VI: Lấy merchants theo trạng thái.
/// </summary>
Task<IReadOnlyList<Merchant>> GetByStatusAsync(MerchantStatus status, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Add a new merchant.
/// VI: Thêm merchant mới.
/// </summary>
Merchant Add(Merchant merchant);
/// <summary>
/// EN: Update a merchant.
/// VI: Cập nhật merchant.
/// </summary>
void Update(Merchant merchant);
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// EN: Reference to IAM User (Owner).
/// VI: Tham chiếu đến IAM User (Chủ sở hữu).
/// </summary>
public Guid UserId => _userId;
/// <summary>
/// EN: Business/Company name.
/// VI: Tên doanh nghiệp/công ty.
/// </summary>
public string BusinessName => _businessName;
/// <summary>
/// EN: Merchant type (Individual or Company).
/// VI: Loại merchant (Cá nhân hoặc Doanh nghiệp).
/// </summary>
public MerchantType Type => _type;
/// <summary>
/// EN: Merchant type ID for EF Core mapping.
/// VI: Type ID cho EF Core mapping.
/// </summary>
public int TypeId { get; private set; }
/// <summary>
/// EN: Current merchant status.
/// VI: Trạng thái merchant hiện tại.
/// </summary>
public MerchantStatus Status => _status;
/// <summary>
/// EN: Status ID for EF Core mapping.
/// VI: Status ID cho EF Core mapping.
/// </summary>
public int StatusId { get; private set; }
/// <summary>
/// EN: Verification status.
/// VI: Trạng thái xác minh.
/// </summary>
public VerificationStatus VerificationStatus => _verificationStatus;
/// <summary>
/// EN: Verification status ID for EF Core mapping.
/// VI: Verification status ID cho EF Core mapping.
/// </summary>
public int VerificationStatusId { get; private set; }
/// <summary>
/// EN: Business information for verification.
/// VI: Thông tin doanh nghiệp để xác minh.
/// </summary>
public BusinessInfo BusinessInfo => _businessInfo;
/// <summary>
/// EN: Settlement configuration.
/// VI: Cấu hình thanh toán.
/// </summary>
public SettlementConfig SettlementConfig => _settlementConfig;
/// <summary>
/// EN: When merchant was verified.
/// VI: Thời điểm merchant được xác minh.
/// </summary>
public DateTime? VerifiedAt { get; private set; }
/// <summary>
/// EN: Who verified the merchant.
/// VI: Ai đã xác minh merchant.
/// </summary>
public Guid? VerifiedBy { get; private set; }
/// <summary>
/// EN: Creation timestamp.
/// VI: Thời gian tạo.
/// </summary>
public DateTime CreatedAt => _createdAt;
/// <summary>
/// EN: Last update timestamp.
/// VI: Thời gian cập nhật cuối.
/// </summary>
public DateTime? UpdatedAt => _updatedAt;
/// <summary>
/// EN: Soft delete flag.
/// VI: Cờ xóa mềm.
/// </summary>
public bool IsDeleted => _isDeleted;
/// <summary>
/// EN: Private constructor for EF Core.
/// VI: Constructor private cho EF Core.
/// </summary>
protected Merchant()
{
_businessInfo = BusinessInfo.Empty;
_settlementConfig = SettlementConfig.Default;
}
/// <summary>
/// EN: Factory method to register a new merchant.
/// VI: Factory method để đăng ký merchant mới.
/// </summary>
/// <param name="userId">IAM User ID / ID người dùng IAM</param>
/// <param name="businessName">Business name / Tên doanh nghiệp</param>
/// <param name="type">Merchant type / Loại merchant</param>
/// <returns>New Merchant instance / Instance Merchant mới</returns>
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;
}
/// <summary>
/// EN: Update business name.
/// VI: Cập nhật tên doanh nghiệp.
/// </summary>
public void UpdateBusinessName(string businessName)
{
if (string.IsNullOrWhiteSpace(businessName))
throw new DomainException("Business name cannot be empty");
_businessName = businessName.Trim();
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Update business information.
/// VI: Cập nhật thông tin doanh nghiệp.
/// </summary>
public void UpdateBusinessInfo(BusinessInfo businessInfo)
{
_businessInfo = businessInfo ?? BusinessInfo.Empty;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Update settlement configuration.
/// VI: Cập nhật cấu hình thanh toán.
/// </summary>
public void UpdateSettlementConfig(SettlementConfig config)
{
_settlementConfig = config ?? SettlementConfig.Default;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Submit verification documents.
/// VI: Nộp tài liệu xác minh.
/// </summary>
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));
}
/// <summary>
/// EN: Approve merchant and activate account.
/// VI: Phê duyệt merchant và kích hoạt tài khoản.
/// </summary>
/// <param name="approvedBy">Admin user ID who approved / ID admin phê duyệt</param>
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));
}
/// <summary>
/// EN: Suspend merchant.
/// VI: Tạm ngưng merchant.
/// </summary>
/// <param name="reason">Reason for suspension / Lý do tạm ngưng</param>
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));
}
/// <summary>
/// EN: Reactivate suspended merchant.
/// VI: Kích hoạt lại merchant bị tạm ngưng.
/// </summary>
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;
}
/// <summary>
/// EN: Ban merchant permanently.
/// VI: Cấm merchant vĩnh viễn.
/// </summary>
/// <param name="reason">Reason for ban / Lý do cấm</param>
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));
}
/// <summary>
/// EN: Soft delete merchant.
/// VI: Xóa mềm merchant.
/// </summary>
public void Delete()
{
if (_isDeleted)
throw new DomainException("Merchant is already deleted");
_isDeleted = true;
_updatedAt = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,59 @@
// EN: Merchant status enumeration.
// VI: Enumeration trạng thái Merchant.
using MerchantService.Domain.SeedWork;
namespace MerchantService.Domain.AggregatesModel.MerchantAggregate;
/// <summary>
/// EN: Represents the status of a merchant.
/// VI: Đại diện cho trạng thái của merchant.
/// </summary>
public class MerchantStatus : Enumeration
{
/// <summary>
/// EN: Merchant is pending admin approval.
/// VI: Merchant đang chờ admin phê duyệt.
/// </summary>
public static readonly MerchantStatus PendingApproval = new(1, nameof(PendingApproval));
/// <summary>
/// EN: Merchant is active and can operate.
/// VI: Merchant đang hoạt động và có thể kinh doanh.
/// </summary>
public static readonly MerchantStatus Active = new(2, nameof(Active));
/// <summary>
/// EN: Merchant is temporarily suspended.
/// VI: Merchant tạm thời bị đình chỉ.
/// </summary>
public static readonly MerchantStatus Suspended = new(3, nameof(Suspended));
/// <summary>
/// EN: Merchant is permanently banned.
/// VI: Merchant bị cấm vĩnh viễn.
/// </summary>
public static readonly MerchantStatus Banned = new(4, nameof(Banned));
public MerchantStatus(int id, string name) : base(id, name)
{
}
/// <summary>
/// EN: Check if merchant can create shops.
/// VI: Kiểm tra merchant có thể tạo shop không.
/// </summary>
public bool CanCreateShop => this == Active;
/// <summary>
/// EN: Check if merchant can be suspended.
/// VI: Kiểm tra merchant có thể bị đình chỉ không.
/// </summary>
public bool CanBeSuspended => this == Active;
/// <summary>
/// EN: Check if merchant can be activated.
/// VI: Kiểm tra merchant có thể được kích hoạt không.
/// </summary>
public bool CanBeActivated => this == PendingApproval || this == Suspended;
}

View File

@@ -0,0 +1,29 @@
// EN: Merchant type enumeration.
// VI: Enumeration loại Merchant.
using MerchantService.Domain.SeedWork;
namespace MerchantService.Domain.AggregatesModel.MerchantAggregate;
/// <summary>
/// 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).
/// </summary>
public class MerchantType : Enumeration
{
/// <summary>
/// EN: Individual merchant (sole proprietor).
/// VI: Merchant cá nhân (hộ kinh doanh).
/// </summary>
public static readonly MerchantType Individual = new(1, nameof(Individual));
/// <summary>
/// EN: Company/Business merchant.
/// VI: Merchant doanh nghiệp.
/// </summary>
public static readonly MerchantType Company = new(2, nameof(Company));
public MerchantType(int id, string name) : base(id, name)
{
}
}

View File

@@ -0,0 +1,53 @@
// EN: Settlement configuration value object.
// VI: Value object cấu hình thanh toán.
namespace MerchantService.Domain.AggregatesModel.MerchantAggregate;
/// <summary>
/// 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.
/// </summary>
public record SettlementConfig
{
/// <summary>
/// EN: Bank account for receiving settlements.
/// VI: Tài khoản ngân hàng để nhận thanh toán.
/// </summary>
public BankAccount BankAccount { get; init; } = BankAccount.Empty;
/// <summary>
/// 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%).
/// </summary>
public decimal CommissionRate { get; init; } = 0m;
/// <summary>
/// EN: Settlement cycle (Daily, Weekly, Monthly).
/// VI: Chu kỳ thanh toán (Hàng ngày, Hàng tuần, Hàng tháng).
/// </summary>
public int SettlementCycleId { get; init; } = SettlementCycle.Monthly.Id;
/// <summary>
/// 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.
/// </summary>
public bool AutoSettlement { get; init; } = true;
/// <summary>
/// EN: Creates default settlement config.
/// VI: Tạo cấu hình thanh toán mặc định.
/// </summary>
public static SettlementConfig Default => new()
{
BankAccount = BankAccount.Empty,
CommissionRate = 0m,
SettlementCycleId = SettlementCycle.Monthly.Id,
AutoSettlement = true
};
/// <summary>
/// EN: Check if settlement config is complete.
/// VI: Kiểm tra cấu hình thanh toán đã đầy đủ chưa.
/// </summary>
public bool IsComplete => BankAccount.IsValid;
}

View File

@@ -0,0 +1,35 @@
// EN: Settlement cycle enumeration.
// VI: Enumeration chu kỳ thanh toán.
using MerchantService.Domain.SeedWork;
namespace MerchantService.Domain.AggregatesModel.MerchantAggregate;
/// <summary>
/// EN: Represents the settlement cycle for merchant payouts.
/// VI: Đại diện cho chu kỳ thanh toán cho merchant.
/// </summary>
public class SettlementCycle : Enumeration
{
/// <summary>
/// EN: Daily settlement.
/// VI: Thanh toán hàng ngày.
/// </summary>
public static readonly SettlementCycle Daily = new(1, nameof(Daily));
/// <summary>
/// EN: Weekly settlement.
/// VI: Thanh toán hàng tuần.
/// </summary>
public static readonly SettlementCycle Weekly = new(2, nameof(Weekly));
/// <summary>
/// EN: Monthly settlement.
/// VI: Thanh toán hàng tháng.
/// </summary>
public static readonly SettlementCycle Monthly = new(3, nameof(Monthly));
public SettlementCycle(int id, string name) : base(id, name)
{
}
}

View File

@@ -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;
/// <summary>
/// EN: Represents the verification status of a merchant.
/// VI: Đại diện cho trạng thái xác minh của merchant.
/// </summary>
public class VerificationStatus : Enumeration
{
/// <summary>
/// EN: Merchant has not submitted verification documents.
/// VI: Merchant chưa nộp tài liệu xác minh.
/// </summary>
public static readonly VerificationStatus Unverified = new(1, nameof(Unverified));
/// <summary>
/// EN: Verification documents are pending review.
/// VI: Tài liệu xác minh đang chờ xem xét.
/// </summary>
public static readonly VerificationStatus Pending = new(2, nameof(Pending));
/// <summary>
/// EN: Merchant is verified.
/// VI: Merchant đã được xác minh.
/// </summary>
public static readonly VerificationStatus Verified = new(3, nameof(Verified));
/// <summary>
/// EN: Verification was rejected.
/// VI: Xác minh bị từ chối.
/// </summary>
public static readonly VerificationStatus Rejected = new(4, nameof(Rejected));
public VerificationStatus(int id, string name) : base(id, name)
{
}
}

View File

@@ -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;
/// <summary>
/// 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).
/// </summary>
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;
/// <summary>
/// EN: Staff ID this device belongs to.
/// VI: ID nhân viên sở hữu thiết bị này.
/// </summary>
public Guid StaffId => _staffId;
/// <summary>
/// EN: Unique device identifier.
/// VI: Định danh thiết bị duy nhất.
/// </summary>
public string DeviceId => _deviceId;
/// <summary>
/// EN: Human-readable device name.
/// VI: Tên thiết bị dễ đọc.
/// </summary>
public string? DeviceName => _deviceName;
/// <summary>
/// EN: Firebase Cloud Messaging token for push notifications.
/// VI: Token Firebase Cloud Messaging cho push notifications.
/// </summary>
public string? FcmToken => _fcmToken;
/// <summary>
/// EN: Platform (ios, android, pos).
/// VI: Nền tảng (ios, android, pos).
/// </summary>
public string Platform => _platform;
/// <summary>
/// EN: Last time this device was used.
/// VI: Lần cuối thiết bị này được sử dụng.
/// </summary>
public DateTime? LastUsedAt => _lastUsedAt;
/// <summary>
/// EN: When the device was registered.
/// VI: Thời điểm thiết bị được đăng ký.
/// </summary>
public DateTime CreatedAt => _createdAt;
/// <summary>
/// EN: Private constructor for EF Core.
/// VI: Constructor private cho EF Core.
/// </summary>
protected DeviceToken()
{
}
/// <summary>
/// EN: Create a new device token.
/// VI: Tạo device token mới.
/// </summary>
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;
}
/// <summary>
/// EN: Update FCM token.
/// VI: Cập nhật FCM token.
/// </summary>
public void UpdateFcmToken(string? fcmToken)
{
_fcmToken = fcmToken;
}
/// <summary>
/// EN: Update device name.
/// VI: Cập nhật tên thiết bị.
/// </summary>
public void UpdateDeviceName(string? deviceName)
{
_deviceName = deviceName;
}
/// <summary>
/// EN: Mark device as used now.
/// VI: Đánh dấu thiết bị vừa được sử dụng.
/// </summary>
public void MarkUsed()
{
_lastUsedAt = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,61 @@
// EN: MerchantStaff repository interface.
// VI: Interface repository cho MerchantStaff.
using MerchantService.Domain.SeedWork;
namespace MerchantService.Domain.AggregatesModel.MerchantStaffAggregate;
/// <summary>
/// EN: Repository interface for MerchantStaff aggregate.
/// VI: Interface repository cho MerchantStaff aggregate.
/// </summary>
public interface IMerchantStaffRepository : IRepository<MerchantStaff>
{
/// <summary>
/// EN: Get staff by ID.
/// VI: Lấy nhân viên theo ID.
/// </summary>
Task<MerchantStaff?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get staff by user ID (IAM User).
/// VI: Lấy nhân viên theo user ID (IAM User).
/// </summary>
Task<MerchantStaff?> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get staff by user ID and merchant ID.
/// VI: Lấy nhân viên theo user ID và merchant ID.
/// </summary>
Task<MerchantStaff?> GetByUserIdAndMerchantIdAsync(Guid userId, Guid merchantId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get all staff by merchant ID.
/// VI: Lấy tất cả nhân viên theo merchant ID.
/// </summary>
Task<IReadOnlyList<MerchantStaff>> GetByMerchantIdAsync(Guid merchantId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get staff by shop ID (through ShopMember).
/// VI: Lấy nhân viên theo shop ID (thông qua ShopMember).
/// </summary>
Task<IReadOnlyList<MerchantStaff>> GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Check if user is already staff of merchant.
/// VI: Kiểm tra user đã là nhân viên của merchant chưa.
/// </summary>
Task<bool> ExistsByUserIdAndMerchantIdAsync(Guid userId, Guid merchantId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Add a new staff member.
/// VI: Thêm nhân viên mới.
/// </summary>
MerchantStaff Add(MerchantStaff staff);
/// <summary>
/// EN: Update a staff member.
/// VI: Cập nhật nhân viên.
/// </summary>
void Update(MerchantStaff staff);
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
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<DeviceToken> _deviceTokens = new();
private readonly List<ShopMember> _shopAssignments = new();
/// <summary>
/// EN: Reference to IAM User.
/// VI: Tham chiếu đến IAM User.
/// </summary>
public Guid UserId => _userId;
/// <summary>
/// EN: Merchant ID (employer).
/// VI: ID merchant (chủ lao động).
/// </summary>
public Guid MerchantId => _merchantId;
/// <summary>
/// EN: Employee code for internal reference.
/// VI: Mã nhân viên để tham chiếu nội bộ.
/// </summary>
public string? EmployeeCode => _employeeCode;
/// <summary>
/// EN: Staff role.
/// VI: Vai trò nhân viên.
/// </summary>
public StaffRole Role => _role;
/// <summary>
/// EN: Role ID for EF Core mapping.
/// VI: Role ID cho EF Core mapping.
/// </summary>
public int RoleId { get; private set; }
/// <summary>
/// EN: Staff status.
/// VI: Trạng thái nhân viên.
/// </summary>
public StaffStatus Status => _status;
/// <summary>
/// EN: Status ID for EF Core mapping.
/// VI: Status ID cho EF Core mapping.
/// </summary>
public int StatusId { get; private set; }
/// <summary>
/// EN: Staff permissions bitmask.
/// VI: Bitmask quyền hạn nhân viên.
/// </summary>
public StaffPermissions Permissions => _permissions;
/// <summary>
/// EN: Staff phone number.
/// VI: Số điện thoại nhân viên.
/// </summary>
public string? Phone => _phone;
/// <summary>
/// EN: Staff email.
/// VI: Email nhân viên.
/// </summary>
public string? Email => _email;
/// <summary>
/// EN: Hashed PIN code for POS authentication.
/// VI: Mã PIN đã hash cho xác thực POS.
/// </summary>
public string? PinCodeHash => _pinCodeHash;
/// <summary>
/// EN: Registered devices for this staff.
/// VI: Các thiết bị đã đăng ký của nhân viên này.
/// </summary>
public IReadOnlyCollection<DeviceToken> DeviceTokens => _deviceTokens.AsReadOnly();
/// <summary>
/// EN: Shop assignments for this staff.
/// VI: Các shop được gán cho nhân viên này.
/// </summary>
public IReadOnlyCollection<ShopMember> ShopAssignments => _shopAssignments.AsReadOnly();
/// <summary>
/// EN: When staff joined.
/// VI: Thời điểm nhân viên gia nhập.
/// </summary>
public DateTime JoinedAt => _joinedAt;
/// <summary>
/// EN: When employment was terminated.
/// VI: Thời điểm chấm dứt hợp đồng.
/// </summary>
public DateTime? TerminatedAt => _terminatedAt;
/// <summary>
/// EN: Creation timestamp.
/// VI: Thời gian tạo.
/// </summary>
public DateTime CreatedAt => _createdAt;
/// <summary>
/// EN: Last update timestamp.
/// VI: Thời gian cập nhật cuối.
/// </summary>
public DateTime? UpdatedAt => _updatedAt;
/// <summary>
/// EN: Private constructor for EF Core.
/// VI: Constructor private cho EF Core.
/// </summary>
protected MerchantStaff()
{
}
/// <summary>
/// EN: Invite a new staff member.
/// VI: Mời nhân viên mới.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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));
}
/// <summary>
/// EN: Update staff information.
/// VI: Cập nhật thông tin nhân viên.
/// </summary>
public void Update(string? employeeCode, string? phone)
{
_employeeCode = employeeCode?.Trim();
_phone = phone?.Trim();
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Update staff role.
/// VI: Cập nhật vai trò nhân viên.
/// </summary>
public void UpdateRole(StaffRole role)
{
ArgumentNullException.ThrowIfNull(role, nameof(role));
_role = role;
RoleId = role.Id;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Update staff permissions.
/// VI: Cập nhật quyền hạn nhân viên.
/// </summary>
public void UpdatePermissions(StaffPermissions permissions)
{
_permissions = permissions;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Set PIN code for POS authentication.
/// VI: Đặt mã PIN cho xác thực POS.
/// </summary>
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));
}
/// <summary>
/// EN: Verify PIN code for POS login.
/// VI: Xác minh mã PIN cho đăng nhập POS.
/// </summary>
public bool VerifyPinCode(string pin)
{
if (string.IsNullOrEmpty(_pinCodeHash))
return false;
var hash = HashPin(pin);
return hash == _pinCodeHash;
}
/// <summary>
/// EN: Register a device for POS/push notifications.
/// VI: Đăng ký thiết bị cho POS/push notifications.
/// </summary>
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;
}
/// <summary>
/// EN: Remove a registered device.
/// VI: Xóa thiết bị đã đăng ký.
/// </summary>
public void RemoveDevice(string deviceId)
{
var token = _deviceTokens.FirstOrDefault(t => t.DeviceId == deviceId);
if (token != null)
{
_deviceTokens.Remove(token);
_updatedAt = DateTime.UtcNow;
}
}
/// <summary>
/// EN: Assign staff to a shop.
/// VI: Gán nhân viên vào shop.
/// </summary>
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;
}
/// <summary>
/// EN: Remove shop assignment.
/// VI: Xóa gán shop.
/// </summary>
public void RemoveFromShop(Guid shopId)
{
var assignment = _shopAssignments.FirstOrDefault(a => a.ShopId == shopId);
if (assignment != null)
{
_shopAssignments.Remove(assignment);
_updatedAt = DateTime.UtcNow;
}
}
/// <summary>
/// EN: Deactivate staff.
/// VI: Vô hiệu hóa nhân viên.
/// </summary>
public void Deactivate()
{
if (_status == StaffStatus.Terminated)
throw new DomainException("Cannot deactivate terminated staff");
_status = StaffStatus.Inactive;
StatusId = StaffStatus.Inactive.Id;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Reactivate staff.
/// VI: Kích hoạt lại nhân viên.
/// </summary>
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;
}
/// <summary>
/// EN: Terminate employment.
/// VI: Chấm dứt hợp đồng.
/// </summary>
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));
}
/// <summary>
/// EN: Check if staff has a specific permission.
/// VI: Kiểm tra nhân viên có quyền cụ thể không.
/// </summary>
public bool HasPermission(StaffPermissions permission)
{
if (_permissions == StaffPermissions.All)
return true;
return (_permissions & permission) == permission;
}
/// <summary>
/// EN: Hash PIN code.
/// VI: Hash mã PIN.
/// </summary>
private static string HashPin(string pin)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(pin));
return Convert.ToBase64String(bytes);
}
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// EN: Staff ID.
/// VI: ID nhân viên.
/// </summary>
public Guid StaffId => _staffId;
/// <summary>
/// EN: Shop ID.
/// VI: ID shop.
/// </summary>
public Guid ShopId => _shopId;
/// <summary>
/// EN: Specific branch ID (null = all branches).
/// VI: ID chi nhánh cụ thể (null = tất cả chi nhánh).
/// </summary>
public Guid? BranchId => _branchId;
/// <summary>
/// EN: Role at this shop.
/// VI: Vai trò tại shop này.
/// </summary>
public ShopRole Role => _role;
/// <summary>
/// EN: Role ID for EF Core mapping.
/// VI: Role ID cho EF Core mapping.
/// </summary>
public int RoleId { get; private set; }
/// <summary>
/// EN: Custom permissions override for this shop.
/// VI: Quyền tùy chỉnh ghi đè cho shop này.
/// </summary>
public StaffPermissions? CustomPermissions => _customPermissions;
/// <summary>
/// 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.
/// </summary>
public bool IsPrimary => _isPrimary;
/// <summary>
/// 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.
/// </summary>
public DateTime AssignedAt => _assignedAt;
/// <summary>
/// EN: Private constructor for EF Core.
/// VI: Constructor private cho EF Core.
/// </summary>
protected ShopMember()
{
}
/// <summary>
/// EN: Create a new shop member assignment.
/// VI: Tạo việc gán shop member mới.
/// </summary>
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;
}
/// <summary>
/// EN: Update role at this shop.
/// VI: Cập nhật vai trò tại shop này.
/// </summary>
public void UpdateRole(ShopRole role)
{
ArgumentNullException.ThrowIfNull(role, nameof(role));
_role = role;
RoleId = role.Id;
}
/// <summary>
/// EN: Set custom permissions for this shop.
/// VI: Đặt quyền tùy chỉnh cho shop này.
/// </summary>
public void SetCustomPermissions(StaffPermissions? permissions)
{
_customPermissions = permissions;
}
/// <summary>
/// EN: Set as primary shop.
/// VI: Đặt làm shop chính.
/// </summary>
public void SetAsPrimary()
{
_isPrimary = true;
}
/// <summary>
/// EN: Unset as primary shop.
/// VI: Bỏ đặt làm shop chính.
/// </summary>
public void UnsetAsPrimary()
{
_isPrimary = false;
}
/// <summary>
/// EN: Assign to specific branch.
/// VI: Gán vào chi nhánh cụ thể.
/// </summary>
public void AssignToBranch(Guid branchId)
{
_branchId = branchId;
}
/// <summary>
/// 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).
/// </summary>
public void AssignToAllBranches()
{
_branchId = null;
}
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public class StaffRole : Enumeration
{
/// <summary>
/// EN: Cashier - handles payments.
/// VI: Thu ngân - xử lý thanh toán.
/// </summary>
public static readonly StaffRole Cashier = new(1, nameof(Cashier));
/// <summary>
/// EN: Waiter/Server - serves customers.
/// VI: Phục vụ - phục vụ khách hàng.
/// </summary>
public static readonly StaffRole Waiter = new(2, nameof(Waiter));
/// <summary>
/// EN: Manager - manages staff and operations.
/// VI: Quản lý - quản lý nhân viên và hoạt động.
/// </summary>
public static readonly StaffRole Manager = new(3, nameof(Manager));
/// <summary>
/// EN: Admin - full access to merchant features.
/// VI: Admin - toàn quyền truy cập tính năng merchant.
/// </summary>
public static readonly StaffRole Admin = new(4, nameof(Admin));
public StaffRole(int id, string name) : base(id, name)
{
}
}
/// <summary>
/// EN: Represents the status of a staff member.
/// VI: Đại diện cho trạng thái của nhân viên.
/// </summary>
public class StaffStatus : Enumeration
{
/// <summary>
/// EN: Invited but not yet accepted.
/// VI: Đã mời nhưng chưa chấp nhận.
/// </summary>
public static readonly StaffStatus Invited = new(1, nameof(Invited));
/// <summary>
/// EN: Active staff member.
/// VI: Nhân viên đang hoạt động.
/// </summary>
public static readonly StaffStatus Active = new(2, nameof(Active));
/// <summary>
/// EN: Temporarily inactive.
/// VI: Tạm thời không hoạt động.
/// </summary>
public static readonly StaffStatus Inactive = new(3, nameof(Inactive));
/// <summary>
/// EN: Employment terminated.
/// VI: Đã chấm dứt hợp đồng.
/// </summary>
public static readonly StaffStatus Terminated = new(4, nameof(Terminated));
public StaffStatus(int id, string name) : base(id, name)
{
}
}
/// <summary>
/// EN: Staff permissions bitmask.
/// VI: Bitmask quyền hạn nhân viên.
/// </summary>
[Flags]
public enum StaffPermissions
{
/// <summary>
/// EN: No permissions.
/// VI: Không có quyền.
/// </summary>
None = 0,
/// <summary>
/// EN: View sales data.
/// VI: Xem dữ liệu bán hàng.
/// </summary>
ViewSales = 1,
/// <summary>
/// EN: Process payment transactions.
/// VI: Xử lý giao dịch thanh toán.
/// </summary>
ProcessPayment = 2,
/// <summary>
/// EN: Process refunds.
/// VI: Xử lý hoàn tiền.
/// </summary>
RefundOrder = 4,
/// <summary>
/// EN: Manage inventory.
/// VI: Quản lý kho.
/// </summary>
ManageInventory = 8,
/// <summary>
/// EN: View reports and analytics.
/// VI: Xem báo cáo và phân tích.
/// </summary>
ViewReports = 16,
/// <summary>
/// EN: Manage other staff members.
/// VI: Quản lý nhân viên khác.
/// </summary>
ManageStaff = 32,
/// <summary>
/// EN: Manage shop settings.
/// VI: Quản lý cài đặt shop.
/// </summary>
ManageSettings = 64,
/// <summary>
/// EN: All permissions.
/// VI: Tất cả quyền.
/// </summary>
All = int.MaxValue
}
/// <summary>
/// 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.
/// </summary>
public class ShopRole : Enumeration
{
/// <summary>
/// EN: Cashier role at shop level.
/// VI: Vai trò thu ngân ở cấp shop.
/// </summary>
public static readonly ShopRole Cashier = new(1, nameof(Cashier));
/// <summary>
/// EN: Waiter role at shop level.
/// VI: Vai trò phục vụ ở cấp shop.
/// </summary>
public static readonly ShopRole Waiter = new(2, nameof(Waiter));
/// <summary>
/// EN: Manager role at shop level.
/// VI: Vai trò quản lý ở cấp shop.
/// </summary>
public static readonly ShopRole Manager = new(3, nameof(Manager));
/// <summary>
/// EN: Owner role at shop level (merchant themselves).
/// VI: Vai trò chủ sở hữu ở cấp shop (chính merchant).
/// </summary>
public static readonly ShopRole Owner = new(4, nameof(Owner));
public ShopRole(int id, string name) : base(id, name)
{
}
}

View File

@@ -0,0 +1,83 @@
// EN: Business category enumeration.
// VI: Enumeration ngành nghề kinh doanh.
using MerchantService.Domain.SeedWork;
namespace MerchantService.Domain.AggregatesModel.ShopAggregate;
/// <summary>
/// EN: Represents business categories for shops.
/// VI: Đại diện cho ngành nghề kinh doanh của shop.
/// </summary>
public class BusinessCategory : Enumeration
{
/// <summary>
/// EN: Food and Beverage.
/// VI: Ẩm thực và Đồ uống.
/// </summary>
public static readonly BusinessCategory FoodBeverage = new(1, nameof(FoodBeverage));
/// <summary>
/// EN: Fashion and Apparel.
/// VI: Thời trang và May mặc.
/// </summary>
public static readonly BusinessCategory Fashion = new(2, nameof(Fashion));
/// <summary>
/// EN: Electronics and Technology.
/// VI: Điện tử và Công nghệ.
/// </summary>
public static readonly BusinessCategory Electronics = new(3, nameof(Electronics));
/// <summary>
/// EN: Healthcare and Pharmacy.
/// VI: Chăm sóc sức khỏe và Dược phẩm.
/// </summary>
public static readonly BusinessCategory Healthcare = new(4, nameof(Healthcare));
/// <summary>
/// EN: Beauty and Personal Care.
/// VI: Làm đẹp và Chăm sóc cá nhân.
/// </summary>
public static readonly BusinessCategory Beauty = new(5, nameof(Beauty));
/// <summary>
/// EN: Education and Training.
/// VI: Giáo dục và Đào tạo.
/// </summary>
public static readonly BusinessCategory Education = new(6, nameof(Education));
/// <summary>
/// EN: Entertainment and Leisure.
/// VI: Giải trí và Nghỉ dưỡng.
/// </summary>
public static readonly BusinessCategory Entertainment = new(7, nameof(Entertainment));
/// <summary>
/// EN: Professional Services.
/// VI: Dịch vụ chuyên nghiệp.
/// </summary>
public static readonly BusinessCategory Services = new(8, nameof(Services));
/// <summary>
/// EN: Grocery and Supermarket.
/// VI: Tạp hóa và Siêu thị.
/// </summary>
public static readonly BusinessCategory Grocery = new(9, nameof(Grocery));
/// <summary>
/// EN: Home and Furniture.
/// VI: Nhà cửa và Nội thất.
/// </summary>
public static readonly BusinessCategory HomeFurniture = new(10, nameof(HomeFurniture));
/// <summary>
/// EN: Other categories.
/// VI: Các ngành nghề khác.
/// </summary>
public static readonly BusinessCategory Other = new(99, nameof(Other));
public BusinessCategory(int id, string name) : base(id, name)
{
}
}

View File

@@ -0,0 +1,61 @@
// EN: Shop repository interface.
// VI: Interface repository cho Shop.
using MerchantService.Domain.SeedWork;
namespace MerchantService.Domain.AggregatesModel.ShopAggregate;
/// <summary>
/// EN: Repository interface for Shop aggregate.
/// VI: Interface repository cho Shop aggregate.
/// </summary>
public interface IShopRepository : IRepository<Shop>
{
/// <summary>
/// EN: Get shop by ID.
/// VI: Lấy shop theo ID.
/// </summary>
Task<Shop?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get shop by ID with branches loaded.
/// VI: Lấy shop theo ID kèm theo các chi nhánh.
/// </summary>
Task<Shop?> GetByIdWithBranchesAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get shop by slug (URL-friendly identifier).
/// VI: Lấy shop theo slug (định danh thân thiện URL).
/// </summary>
Task<Shop?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Check if slug is already taken.
/// VI: Kiểm tra slug đã được sử dụng chưa.
/// </summary>
Task<bool> SlugExistsAsync(string slug, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get all shops by merchant ID.
/// VI: Lấy tất cả shops theo merchant ID.
/// </summary>
Task<IReadOnlyList<Shop>> GetByMerchantIdAsync(Guid merchantId, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get shops by category.
/// VI: Lấy shops theo ngành nghề.
/// </summary>
Task<IReadOnlyList<Shop>> GetByCategoryAsync(BusinessCategory category, int pageNumber, int pageSize, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Add a new shop.
/// VI: Thêm shop mới.
/// </summary>
Shop Add(Shop shop);
/// <summary>
/// EN: Update a shop.
/// VI: Cập nhật shop.
/// </summary>
void Update(Shop shop);
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
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<ShopBranch> _branches = new();
/// <summary>
/// EN: Parent merchant ID.
/// VI: ID merchant cha.
/// </summary>
public Guid MerchantId => _merchantId;
/// <summary>
/// EN: Shop name.
/// VI: Tên shop.
/// </summary>
public string Name => _name;
/// <summary>
/// EN: URL-friendly unique identifier.
/// VI: Định danh duy nhất thân thiện URL.
/// </summary>
public string Slug => _slug;
/// <summary>
/// EN: Shop type (Online, Physical, Hybrid).
/// VI: Loại shop (Online, Cửa hàng vật lý, Hybrid).
/// </summary>
public ShopType Type => _type;
/// <summary>
/// EN: Type ID for EF Core mapping.
/// VI: Type ID cho EF Core mapping.
/// </summary>
public int TypeId { get; private set; }
/// <summary>
/// EN: Business category.
/// VI: Ngành nghề kinh doanh.
/// </summary>
public BusinessCategory Category => _category;
/// <summary>
/// EN: Category ID for EF Core mapping.
/// VI: Category ID cho EF Core mapping.
/// </summary>
public int CategoryId { get; private set; }
/// <summary>
/// EN: Current shop status.
/// VI: Trạng thái shop hiện tại.
/// </summary>
public ShopStatus Status => _status;
/// <summary>
/// EN: Status ID for EF Core mapping.
/// VI: Status ID cho EF Core mapping.
/// </summary>
public int StatusId { get; private set; }
/// <summary>
/// EN: Contact information.
/// VI: Thông tin liên hệ.
/// </summary>
public ContactInfo ContactInfo => _contactInfo;
/// <summary>
/// EN: Operating hours.
/// VI: Giờ hoạt động.
/// </summary>
public OperatingHours? OperatingHours => _operatingHours;
/// <summary>
/// EN: Shop description.
/// VI: Mô tả shop.
/// </summary>
public string? Description => _description;
/// <summary>
/// EN: Shop logo URL.
/// VI: URL logo shop.
/// </summary>
public string? LogoUrl => _logoUrl;
/// <summary>
/// EN: Cover image URL.
/// VI: URL ảnh bìa.
/// </summary>
public string? CoverImageUrl => _coverImageUrl;
/// <summary>
/// EN: Physical branches of the shop.
/// VI: Các chi nhánh vật lý của shop.
/// </summary>
public IReadOnlyCollection<ShopBranch> Branches => _branches.AsReadOnly();
/// <summary>
/// EN: Creation timestamp.
/// VI: Thời gian tạo.
/// </summary>
public DateTime CreatedAt => _createdAt;
/// <summary>
/// EN: Last update timestamp.
/// VI: Thời gian cập nhật cuối.
/// </summary>
public DateTime? UpdatedAt => _updatedAt;
/// <summary>
/// EN: Soft delete flag.
/// VI: Cờ xóa mềm.
/// </summary>
public bool IsDeleted => _isDeleted;
/// <summary>
/// EN: Private constructor for EF Core.
/// VI: Constructor private cho EF Core.
/// </summary>
protected Shop()
{
_contactInfo = ContactInfo.Empty;
}
/// <summary>
/// EN: Create a new shop.
/// VI: Tạo shop mới.
/// </summary>
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));
}
/// <summary>
/// EN: Update basic shop information.
/// VI: Cập nhật thông tin cơ bản của shop.
/// </summary>
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;
}
/// <summary>
/// EN: Update shop slug.
/// VI: Cập nhật slug của shop.
/// </summary>
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;
}
/// <summary>
/// EN: Update contact information.
/// VI: Cập nhật thông tin liên hệ.
/// </summary>
public void UpdateContactInfo(ContactInfo contactInfo)
{
_contactInfo = contactInfo ?? ContactInfo.Empty;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Update operating hours.
/// VI: Cập nhật giờ hoạt động.
/// </summary>
public void UpdateOperatingHours(OperatingHours? hours)
{
_operatingHours = hours;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Update shop images.
/// VI: Cập nhật hình ảnh shop.
/// </summary>
public void UpdateImages(string? logoUrl, string? coverImageUrl)
{
_logoUrl = logoUrl;
_coverImageUrl = coverImageUrl;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Publish shop (make it visible to customers).
/// VI: Công khai shop (hiển thị với khách hàng).
/// </summary>
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));
}
/// <summary>
/// EN: Set shop to inactive.
/// VI: Đặt shop thành không hoạt động.
/// </summary>
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;
}
/// <summary>
/// EN: Close shop permanently.
/// VI: Đóng cửa shop vĩnh viễn.
/// </summary>
public void Close()
{
_status = ShopStatus.Closed;
StatusId = ShopStatus.Closed.Id;
_updatedAt = DateTime.UtcNow;
AddDomainEvent(new ShopClosedDomainEvent(this));
}
/// <summary>
/// EN: Add a physical branch to the shop.
/// VI: Thêm chi nhánh vật lý cho shop.
/// </summary>
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;
}
/// <summary>
/// EN: Remove a branch.
/// VI: Xóa chi nhánh.
/// </summary>
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;
}
/// <summary>
/// EN: Soft delete shop.
/// VI: Xóa mềm shop.
/// </summary>
public void Delete()
{
if (_isDeleted)
throw new DomainException("Shop is already deleted");
_isDeleted = true;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Validate slug format.
/// VI: Kiểm tra định dạng slug.
/// </summary>
private static bool IsValidSlug(string slug)
{
return SlugRegex().IsMatch(slug);
}
[GeneratedRegex(@"^[a-z0-9]+(?:-[a-z0-9]+)*$")]
private static partial Regex SlugRegex();
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// EN: Parent shop ID.
/// VI: ID shop cha.
/// </summary>
public Guid ShopId => _shopId;
/// <summary>
/// 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").
/// </summary>
public string Name => _name;
/// <summary>
/// EN: Branch code for internal reference (e.g., "HN01").
/// VI: Mã chi nhánh để tham chiếu nội bộ (ví dụ: "HN01").
/// </summary>
public string? Code => _code;
/// <summary>
/// EN: Physical address of the branch.
/// VI: Địa chỉ vật lý của chi nhánh.
/// </summary>
public Address Address => _address;
/// <summary>
/// EN: Geographic coordinates for map display.
/// VI: Tọa độ địa lý để hiển thị bản đồ.
/// </summary>
public GeoLocation? Location => _location;
/// <summary>
/// EN: Branch phone number.
/// VI: Số điện thoại chi nhánh.
/// </summary>
public string? Phone => _phone;
/// <summary>
/// EN: Operating hours for this branch.
/// VI: Giờ hoạt động của chi nhánh này.
/// </summary>
public OperatingHours? OperatingHours => _operatingHours;
/// <summary>
/// EN: Whether branch is active.
/// VI: Chi nhánh có đang hoạt động không.
/// </summary>
public bool IsActive => _isActive;
/// <summary>
/// EN: Creation timestamp.
/// VI: Thời gian tạo.
/// </summary>
public DateTime CreatedAt => _createdAt;
/// <summary>
/// EN: Last update timestamp.
/// VI: Thời gian cập nhật cuối.
/// </summary>
public DateTime? UpdatedAt => _updatedAt;
/// <summary>
/// EN: Private constructor for EF Core.
/// VI: Constructor private cho EF Core.
/// </summary>
protected ShopBranch()
{
}
/// <summary>
/// EN: Create a new shop branch.
/// VI: Tạo chi nhánh shop mới.
/// </summary>
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;
}
/// <summary>
/// EN: Update branch information.
/// VI: Cập nhật thông tin chi nhánh.
/// </summary>
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;
}
/// <summary>
/// EN: Set branch phone number.
/// VI: Đặt số điện thoại chi nhánh.
/// </summary>
public void SetPhone(string? phone)
{
_phone = phone?.Trim();
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Set operating hours for this branch.
/// VI: Đặt giờ hoạt động cho chi nhánh này.
/// </summary>
public void SetOperatingHours(OperatingHours? hours)
{
_operatingHours = hours;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Activate the branch.
/// VI: Kích hoạt chi nhánh.
/// </summary>
public void Activate()
{
_isActive = true;
_updatedAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Deactivate the branch.
/// VI: Vô hiệu hóa chi nhánh.
/// </summary>
public void Deactivate()
{
_isActive = false;
_updatedAt = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,53 @@
// EN: Shop status enumeration.
// VI: Enumeration trạng thái Shop.
using MerchantService.Domain.SeedWork;
namespace MerchantService.Domain.AggregatesModel.ShopAggregate;
/// <summary>
/// EN: Represents the status of a shop.
/// VI: Đại diện cho trạng thái của shop.
/// </summary>
public class ShopStatus : Enumeration
{
/// <summary>
/// EN: Shop is in draft mode (not published).
/// VI: Shop ở chế độ nháp (chưa công khai).
/// </summary>
public static readonly ShopStatus Draft = new(1, nameof(Draft));
/// <summary>
/// EN: Shop is active and visible to customers.
/// VI: Shop đang hoạt động và hiển thị với khách hàng.
/// </summary>
public static readonly ShopStatus Active = new(2, nameof(Active));
/// <summary>
/// EN: Shop is temporarily inactive.
/// VI: Shop tạm thời không hoạt động.
/// </summary>
public static readonly ShopStatus Inactive = new(3, nameof(Inactive));
/// <summary>
/// EN: Shop is permanently closed.
/// VI: Shop đóng cửa vĩnh viễn.
/// </summary>
public static readonly ShopStatus Closed = new(4, nameof(Closed));
public ShopStatus(int id, string name) : base(id, name)
{
}
/// <summary>
/// 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.
/// </summary>
public bool IsVisible => this == Active;
/// <summary>
/// EN: Check if shop can accept orders.
/// VI: Kiểm tra shop có thể nhận đơn hàng không.
/// </summary>
public bool CanAcceptOrders => this == Active;
}

View File

@@ -0,0 +1,41 @@
// EN: Shop type enumeration.
// VI: Enumeration loại Shop.
using MerchantService.Domain.SeedWork;
namespace MerchantService.Domain.AggregatesModel.ShopAggregate;
/// <summary>
/// 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).
/// </summary>
public class ShopType : Enumeration
{
/// <summary>
/// EN: Online-only shop (e-commerce).
/// VI: Shop chỉ online (thương mại điện tử).
/// </summary>
public static readonly ShopType OnlineOnly = new(1, nameof(OnlineOnly));
/// <summary>
/// EN: Physical store only.
/// VI: Chỉ cửa hàng vật lý.
/// </summary>
public static readonly ShopType PhysicalOnly = new(2, nameof(PhysicalOnly));
/// <summary>
/// EN: Both online and physical presence.
/// VI: Cả online và cửa hàng vật lý.
/// </summary>
public static readonly ShopType Hybrid = new(3, nameof(Hybrid));
public ShopType(int id, string name) : base(id, name)
{
}
/// <summary>
/// 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.
/// </summary>
public bool SupportsBranches => this != OnlineOnly;
}

View File

@@ -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;
/// <summary>
/// EN: Contact information value object.
/// VI: Value object thông tin liên hệ.
/// </summary>
public record ContactInfo
{
/// <summary>
/// EN: Phone number.
/// VI: Số điện thoại.
/// </summary>
public string Phone { get; init; } = string.Empty;
/// <summary>
/// EN: Email address.
/// VI: Địa chỉ email.
/// </summary>
public string? Email { get; init; }
/// <summary>
/// EN: Website URL.
/// VI: URL website.
/// </summary>
public string? Website { get; init; }
/// <summary>
/// EN: Empty contact info.
/// VI: Thông tin liên hệ rỗng.
/// </summary>
public static ContactInfo Empty => new();
}
/// <summary>
/// EN: Physical address value object.
/// VI: Value object địa chỉ vật lý.
/// </summary>
public record Address
{
/// <summary>
/// EN: Street address.
/// VI: Địa chỉ đường phố.
/// </summary>
public string Street { get; init; } = string.Empty;
/// <summary>
/// EN: Ward/Commune.
/// VI: Phường/Xã.
/// </summary>
public string? Ward { get; init; }
/// <summary>
/// EN: District.
/// VI: Quận/Huyện.
/// </summary>
public string District { get; init; } = string.Empty;
/// <summary>
/// EN: City.
/// VI: Thành phố.
/// </summary>
public string City { get; init; } = string.Empty;
/// <summary>
/// EN: Province/State.
/// VI: Tỉnh/Thành.
/// </summary>
public string? Province { get; init; }
/// <summary>
/// EN: Postal/ZIP code.
/// VI: Mã bưu điện.
/// </summary>
public string? PostalCode { get; init; }
/// <summary>
/// EN: Country code (ISO 3166-1 alpha-2).
/// VI: Mã quốc gia (ISO 3166-1 alpha-2).
/// </summary>
public string CountryCode { get; init; } = "VN";
/// <summary>
/// EN: Get full address string.
/// VI: Lấy chuỗi địa chỉ đầy đủ.
/// </summary>
public string FullAddress => $"{Street}, {Ward}, {District}, {City}".Trim(' ', ',');
}
/// <summary>
/// EN: Geographic location value object.
/// VI: Value object vị trí địa lý.
/// </summary>
public record GeoLocation
{
/// <summary>
/// EN: Latitude coordinate.
/// VI: Tọa độ vĩ độ.
/// </summary>
public double Latitude { get; init; }
/// <summary>
/// EN: Longitude coordinate.
/// VI: Tọa độ kinh độ.
/// </summary>
public double Longitude { get; init; }
/// <summary>
/// EN: Default location (Ho Chi Minh City center).
/// VI: Vị trí mặc định (trung tâm TP.HCM).
/// </summary>
public static GeoLocation Default => new() { Latitude = 10.7769, Longitude = 106.7009 };
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>
/// EN: Operating hours value object.
/// VI: Value object giờ hoạt động.
/// </summary>
public record OperatingHours
{
/// <summary>
/// EN: Opening time.
/// VI: Giờ mở cửa.
/// </summary>
public TimeOnly OpenTime { get; init; } = new(8, 0);
/// <summary>
/// EN: Closing time.
/// VI: Giờ đóng cửa.
/// </summary>
public TimeOnly CloseTime { get; init; } = new(22, 0);
/// <summary>
/// EN: Days the shop is open.
/// VI: Các ngày shop mở cửa.
/// </summary>
public List<DayOfWeek> OpenDays { get; init; } = new()
{
DayOfWeek.Monday,
DayOfWeek.Tuesday,
DayOfWeek.Wednesday,
DayOfWeek.Thursday,
DayOfWeek.Friday,
DayOfWeek.Saturday,
DayOfWeek.Sunday
};
/// <summary>
/// EN: Check if currently open.
/// VI: Kiểm tra có đang mở cửa không.
/// </summary>
public bool IsCurrentlyOpen()
{
var now = DateTime.UtcNow;
var currentTime = TimeOnly.FromDateTime(now);
var currentDay = now.DayOfWeek;
return OpenDays.Contains(currentDay) &&
currentTime >= OpenTime &&
currentTime <= CloseTime;
}
/// <summary>
/// EN: Default 24/7 operating hours.
/// VI: Giờ hoạt động mặc định 24/7.
/// </summary>
public static OperatingHours TwentyFourSeven => new()
{
OpenTime = new TimeOnly(0, 0),
CloseTime = new TimeOnly(23, 59)
};
}

View File

@@ -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;
/// <summary>
/// EN: Raised when a new merchant registers.
/// VI: Được phát ra khi merchant mới đăng ký.
/// </summary>
public record MerchantRegisteredDomainEvent(Merchant Merchant) : INotification;
/// <summary>
/// EN: Raised when a merchant submits verification documents.
/// VI: Được phát ra khi merchant nộp tài liệu xác minh.
/// </summary>
public record MerchantVerificationSubmittedDomainEvent(Merchant Merchant) : INotification;
/// <summary>
/// EN: Raised when a merchant is approved by admin.
/// VI: Được phát ra khi merchant được admin phê duyệt.
/// </summary>
public record MerchantApprovedDomainEvent(Merchant Merchant, Guid ApprovedBy) : INotification;
/// <summary>
/// EN: Raised when a merchant is suspended.
/// VI: Được phát ra khi merchant bị tạm ngưng.
/// </summary>
public record MerchantSuspendedDomainEvent(Merchant Merchant, string Reason) : INotification;
/// <summary>
/// EN: Raised when a merchant is permanently banned.
/// VI: Được phát ra khi merchant bị cấm vĩnh viễn.
/// </summary>
public record MerchantBannedDomainEvent(Merchant Merchant, string Reason) : INotification;

View File

@@ -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;
/// <summary>
/// EN: Raised when a new shop is created.
/// VI: Được phát ra khi shop mới được tạo.
/// </summary>
public record ShopCreatedDomainEvent(Shop Shop) : INotification;
/// <summary>
/// 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).
/// </summary>
public record ShopPublishedDomainEvent(Shop Shop) : INotification;
/// <summary>
/// EN: Raised when a shop is permanently closed.
/// VI: Được phát ra khi shop đóng cửa vĩnh viễn.
/// </summary>
public record ShopClosedDomainEvent(Shop Shop) : INotification;
/// <summary>
/// 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.
/// </summary>
public record ShopBranchAddedDomainEvent(Shop Shop, ShopBranch Branch) : INotification;

View File

@@ -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;
/// <summary>
/// EN: Raised when a staff member is invited.
/// VI: Được phát ra khi nhân viên được mời.
/// </summary>
public record StaffInvitedDomainEvent(MerchantStaff Staff) : INotification;
/// <summary>
/// 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.
/// </summary>
public record StaffJoinedDomainEvent(MerchantStaff Staff) : INotification;
/// <summary>
/// EN: Raised when a staff member sets their PIN code.
/// VI: Được phát ra khi nhân viên đặt mã PIN.
/// </summary>
public record StaffPinCodeSetDomainEvent(MerchantStaff Staff) : INotification;
/// <summary>
/// EN: Raised when a staff member registers a device.
/// VI: Được phát ra khi nhân viên đăng ký thiết bị.
/// </summary>
public record StaffDeviceRegisteredDomainEvent(MerchantStaff Staff, DeviceToken Device) : INotification;
/// <summary>
/// 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.
/// </summary>
public record StaffAssignedToShopDomainEvent(MerchantStaff Staff, ShopMember Assignment) : INotification;
/// <summary>
/// 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.
/// </summary>
public record StaffTerminatedDomainEvent(MerchantStaff Staff) : INotification;

View File

@@ -0,0 +1,21 @@
namespace MerchantService.Domain.Exceptions;
/// <summary>
/// EN: Base exception for domain errors.
/// VI: Exception cơ sở cho các lỗi domain.
/// </summary>
public class DomainException : Exception
{
public DomainException()
{
}
public DomainException(string message) : base(message)
{
}
public DomainException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -0,0 +1,21 @@
namespace MerchantService.Domain.Exceptions;
/// <summary>
/// EN: Exception for Sample aggregate domain errors.
/// VI: Exception cho các lỗi domain của Sample aggregate.
/// </summary>
public class SampleDomainException : DomainException
{
public SampleDomainException()
{
}
public SampleDomainException(string message) : base(message)
{
}
public SampleDomainException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>MerchantService.Domain</AssemblyName>
<RootNamespace>MerchantService.Domain</RootNamespace>
<Description>Domain layer containing core business logic and entities</Description>
</PropertyGroup>
<ItemGroup>
<!-- EN: MediatR for domain events / VI: MediatR cho domain events -->
<PackageReference Include="MediatR.Contracts" Version="2.0.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,102 @@
using MediatR;
namespace MerchantService.Domain.SeedWork;
/// <summary>
/// EN: Base class for all domain entities.
/// VI: Lớp cơ sở cho tất cả các entity trong domain.
/// </summary>
public abstract class Entity
{
private int? _requestedHashCode;
private Guid _id;
private List<INotification> _domainEvents = new();
/// <summary>
/// EN: Unique identifier for the entity.
/// VI: Định danh duy nhất cho entity.
/// </summary>
public virtual Guid Id
{
get => _id;
protected set => _id = value;
}
/// <summary>
/// EN: Domain events raised by this entity.
/// VI: Các domain event được phát ra bởi entity này.
/// </summary>
public IReadOnlyCollection<INotification> DomainEvents => _domainEvents.AsReadOnly();
/// <summary>
/// EN: Add a domain event to be dispatched.
/// VI: Thêm một domain event để dispatch.
/// </summary>
public void AddDomainEvent(INotification eventItem)
{
_domainEvents.Add(eventItem);
}
/// <summary>
/// EN: Remove a domain event.
/// VI: Xóa một domain event.
/// </summary>
public void RemoveDomainEvent(INotification eventItem)
{
_domainEvents.Remove(eventItem);
}
/// <summary>
/// EN: Clear all domain events.
/// VI: Xóa tất cả domain events.
/// </summary>
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
/// <summary>
/// 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.
/// </summary>
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);
}
}

View File

@@ -0,0 +1,95 @@
using System.Reflection;
namespace MerchantService.Domain.SeedWork;
/// <summary>
/// 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).
/// </summary>
/// <remarks>
/// 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ú.
/// </remarks>
public abstract class Enumeration : IComparable
{
/// <summary>
/// EN: The name of the enumeration value.
/// VI: Tên của giá trị enumeration.
/// </summary>
public string Name { get; private set; }
/// <summary>
/// EN: The unique identifier of the enumeration value.
/// VI: Định danh duy nhất của giá trị enumeration.
/// </summary>
public int Id { get; private set; }
protected Enumeration(int id, string name) => (Id, Name) = (id, name);
public override string ToString() => Name;
/// <summary>
/// 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.
/// </summary>
public static IEnumerable<T> GetAll<T>() where T : Enumeration =>
typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
.Select(f => f.GetValue(null))
.Cast<T>();
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();
/// <summary>
/// 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.
/// </summary>
public static int AbsoluteDifference(Enumeration firstValue, Enumeration secondValue)
{
return Math.Abs(firstValue.Id - secondValue.Id);
}
/// <summary>
/// EN: Parse an integer ID to the corresponding enumeration value.
/// VI: Parse một ID integer thành giá trị enumeration tương ứng.
/// </summary>
public static T FromValue<T>(int value) where T : Enumeration
{
var matchingItem = Parse<T, int>(value, "value", item => item.Id == value);
return matchingItem;
}
/// <summary>
/// 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.
/// </summary>
public static T FromDisplayName<T>(string displayName) where T : Enumeration
{
var matchingItem = Parse<T, string>(displayName, "display name", item => item.Name == displayName);
return matchingItem;
}
private static T Parse<T, TValue>(TValue value, string description, Func<T, bool> predicate) where T : Enumeration
{
var matchingItem = GetAll<T>().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);
}

View File

@@ -0,0 +1,15 @@
namespace MerchantService.Domain.SeedWork;
/// <summary>
/// EN: Marker interface for aggregate roots.
/// VI: Interface đánh dấu cho aggregate roots.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public interface IAggregateRoot
{
}

View File

@@ -0,0 +1,15 @@
namespace MerchantService.Domain.SeedWork;
/// <summary>
/// EN: Generic repository interface for aggregate roots.
/// VI: Interface repository generic cho aggregate roots.
/// </summary>
/// <typeparam name="T">EN: The aggregate root type / VI: Kiểu aggregate root</typeparam>
public interface IRepository<T> where T : IAggregateRoot
{
/// <summary>
/// EN: The unit of work for this repository.
/// VI: Unit of work cho repository này.
/// </summary>
IUnitOfWork UnitOfWork { get; }
}

View File

@@ -0,0 +1,30 @@
namespace MerchantService.Domain.SeedWork;
/// <summary>
/// EN: Unit of Work pattern interface.
/// VI: Interface cho Unit of Work pattern.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public interface IUnitOfWork : IDisposable
{
/// <summary>
/// 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.
/// </summary>
/// <param name="cancellationToken">EN: Cancellation token / VI: Token hủy</param>
/// <returns>EN: Number of entities written / VI: Số entity đã ghi</returns>
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
/// <summary>
/// EN: Save all changes and dispatch domain events.
/// VI: Lưu tất cả thay đổi và dispatch domain events.
/// </summary>
/// <param name="cancellationToken">EN: Cancellation token / VI: Token hủy</param>
/// <returns>EN: True if successful / VI: True nếu thành công</returns>
Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,53 @@
namespace MerchantService.Domain.SeedWork;
/// <summary>
/// EN: Base class for Value Objects following DDD patterns.
/// VI: Lớp cơ sở cho Value Objects theo mẫu DDD.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public abstract class ValueObject
{
/// <summary>
/// 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.
/// </summary>
protected abstract IEnumerable<object?> 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);
}
/// <summary>
/// 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.
/// </summary>
protected ValueObject GetCopy()
{
return (ValueObject)MemberwiseClone();
}
}

View File

@@ -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;
/// <summary>
/// EN: Dependency injection extensions for Infrastructure layer.
/// VI: Extensions dependency injection cho lớp Infrastructure.
/// </summary>
public static class DependencyInjection
{
/// <summary>
/// EN: Add infrastructure services to the DI container.
/// VI: Thêm các services infrastructure vào DI container.
/// </summary>
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
// EN: Add DbContext with PostgreSQL / VI: Thêm DbContext với PostgreSQL
services.AddDbContext<MerchantServiceContext>(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<IMerchantRepository, MerchantRepository>();
services.AddScoped<IShopRepository, ShopRepository>();
services.AddScoped<IMerchantStaffRepository, MerchantStaffRepository>();
// EN: Register idempotency services / VI: Đăng ký idempotency services
services.AddScoped<IRequestManager, RequestManager>();
return services;
}
}

View File

@@ -0,0 +1,26 @@
namespace MerchantService.Infrastructure.Idempotency;
/// <summary>
/// EN: Entity for tracking client requests to ensure idempotency.
/// VI: Entity để theo dõi các requests từ client đảm bảo idempotency.
/// </summary>
public class ClientRequest
{
/// <summary>
/// EN: Unique request identifier.
/// VI: Định danh request duy nhất.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// EN: Name of the command/request type.
/// VI: Tên của loại command/request.
/// </summary>
public string Name { get; set; } = null!;
/// <summary>
/// EN: Timestamp when the request was received.
/// VI: Thời điểm request được nhận.
/// </summary>
public DateTime Time { get; set; }
}

View File

@@ -0,0 +1,24 @@
namespace MerchantService.Infrastructure.Idempotency;
/// <summary>
/// EN: Interface for managing client request idempotency.
/// VI: Interface để quản lý idempotency của client requests.
/// </summary>
public interface IRequestManager
{
/// <summary>
/// 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.
/// </summary>
/// <param name="id">EN: Request ID / VI: ID của request</param>
/// <returns>EN: True if exists / VI: True nếu tồn tại</returns>
Task<bool> ExistAsync(Guid id);
/// <summary>
/// EN: Create a new request record for tracking.
/// VI: Tạo bản ghi request mới để theo dõi.
/// </summary>
/// <typeparam name="T">EN: Command type / VI: Loại command</typeparam>
/// <param name="id">EN: Request ID / VI: ID của request</param>
Task CreateRequestForCommandAsync<T>(Guid id);
}

View File

@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore;
namespace MerchantService.Infrastructure.Idempotency;
/// <summary>
/// EN: Implementation of request manager for idempotency.
/// VI: Triển khai request manager cho idempotency.
/// </summary>
public class RequestManager : IRequestManager
{
private readonly MerchantServiceContext _context;
public RequestManager(MerchantServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
/// <inheritdoc/>
public async Task<bool> ExistAsync(Guid id)
{
var request = await _context
.FindAsync<ClientRequest>(id);
return request != null;
}
/// <inheritdoc/>
public async Task CreateRequestForCommandAsync<T>(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();
}
}

View File

@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>MerchantService.Infrastructure</AssemblyName>
<RootNamespace>MerchantService.Infrastructure</RootNamespace>
<Description>Infrastructure layer for data access and external services</Description>
</PropertyGroup>
<ItemGroup>
<!-- EN: Entity Framework Core with PostgreSQL / VI: Entity Framework Core với PostgreSQL -->
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<!-- EN: MediatR for dispatching domain events / VI: MediatR để dispatch domain events -->
<PackageReference Include="MediatR" Version="12.4.1" />
<!-- EN: Dapper for read-optimized queries / VI: Dapper cho queries tối ưu đọc -->
<PackageReference Include="Dapper" Version="2.1.35" />
<!-- EN: Resilience with Polly / VI: Resilience với Polly -->
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.0" />
<PackageReference Include="Polly" Version="8.5.0" />
<!-- EN: Redis cache / VI: Redis cache -->
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MerchantService.Domain\MerchantService.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -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;
/// <summary>
/// EN: EF Core DbContext for MerchantService.
/// VI: EF Core DbContext cho MerchantService.
/// </summary>
public class MerchantServiceContext : DbContext, IUnitOfWork
{
private readonly IMediator _mediator;
private IDbContextTransaction? _currentTransaction;
#region DbSets
/// <summary>
/// EN: Merchants table.
/// VI: Bảng Merchants.
/// </summary>
public DbSet<Merchant> Merchants => Set<Merchant>();
/// <summary>
/// EN: Shops table.
/// VI: Bảng Shops.
/// </summary>
public DbSet<Shop> Shops => Set<Shop>();
/// <summary>
/// EN: Shop branches table.
/// VI: Bảng chi nhánh Shop.
/// </summary>
public DbSet<ShopBranch> ShopBranches => Set<ShopBranch>();
/// <summary>
/// EN: Merchant staff table.
/// VI: Bảng nhân viên Merchant.
/// </summary>
public DbSet<MerchantStaff> MerchantStaff => Set<MerchantStaff>();
/// <summary>
/// EN: Shop members table (staff-shop assignments).
/// VI: Bảng shop member (gán nhân viên-shop).
/// </summary>
public DbSet<ShopMember> ShopMembers => Set<ShopMember>();
/// <summary>
/// EN: Device tokens table.
/// VI: Bảng device tokens.
/// </summary>
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
#endregion
/// <summary>
/// EN: Read-only access to current transaction.
/// VI: Truy cập chỉ đọc đến transaction hiện tại.
/// </summary>
public IDbContextTransaction? CurrentTransaction => _currentTransaction;
/// <summary>
/// EN: Check if there is an active transaction.
/// VI: Kiểm tra xem có transaction đang hoạt động không.
/// </summary>
public bool HasActiveTransaction => _currentTransaction != null;
public MerchantServiceContext(DbContextOptions<MerchantServiceContext> options) : base(options)
{
_mediator = null!;
}
public MerchantServiceContext(DbContextOptions<MerchantServiceContext> 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);
}
/// <summary>
/// EN: Save entities and dispatch domain events.
/// VI: Lưu entities và dispatch domain events.
/// </summary>
public async Task<bool> 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;
}
/// <summary>
/// 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.
/// </summary>
public async Task<IDbContextTransaction?> BeginTransactionAsync()
{
if (_currentTransaction != null) return null;
_currentTransaction = await Database.BeginTransactionAsync(System.Data.IsolationLevel.ReadCommitted);
return _currentTransaction;
}
/// <summary>
/// EN: Commit the current transaction.
/// VI: Commit transaction hiện tại.
/// </summary>
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;
}
}
}
/// <summary>
/// EN: Rollback the current transaction.
/// VI: Rollback transaction hiện tại.
/// </summary>
public void RollbackTransaction()
{
try
{
_currentTransaction?.Rollback();
}
finally
{
if (_currentTransaction != null)
{
_currentTransaction.Dispose();
_currentTransaction = null;
}
}
}
/// <summary>
/// EN: Dispatch all domain events from tracked entities.
/// VI: Dispatch tất cả domain events từ các entities đang được track.
/// </summary>
private async Task DispatchDomainEventsAsync()
{
var domainEntities = ChangeTracker
.Entries<Entity>()
.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);
}
}
}

View File

@@ -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;
/// <summary>
/// EN: Repository implementation for Merchant aggregate.
/// VI: Implementation repository cho Merchant aggregate.
/// </summary>
public class MerchantRepository : IMerchantRepository
{
private readonly MerchantServiceContext _context;
public IUnitOfWork UnitOfWork => _context;
public MerchantRepository(MerchantServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
/// <inheritdoc />
public async Task<Merchant?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.Merchants
.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted, cancellationToken);
}
/// <inheritdoc />
public async Task<Merchant?> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken = default)
{
return await _context.Merchants
.FirstOrDefaultAsync(m => m.UserId == userId && !m.IsDeleted, cancellationToken);
}
/// <inheritdoc />
public async Task<bool> ExistsByUserIdAsync(Guid userId, CancellationToken cancellationToken = default)
{
return await _context.Merchants
.AnyAsync(m => m.UserId == userId && !m.IsDeleted, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Merchant>> 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);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Merchant>> GetByStatusAsync(MerchantStatus status, CancellationToken cancellationToken = default)
{
return await _context.Merchants
.Where(m => m.StatusId == status.Id && !m.IsDeleted)
.OrderByDescending(m => m.CreatedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public Merchant Add(Merchant merchant)
{
return _context.Merchants.Add(merchant).Entity;
}
/// <inheritdoc />
public void Update(Merchant merchant)
{
_context.Entry(merchant).State = EntityState.Modified;
}
}

View File

@@ -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;
/// <summary>
/// EN: Repository implementation for MerchantStaff aggregate.
/// VI: Implementation repository cho MerchantStaff aggregate.
/// </summary>
public class MerchantStaffRepository : IMerchantStaffRepository
{
private readonly MerchantServiceContext _context;
public IUnitOfWork UnitOfWork => _context;
public MerchantStaffRepository(MerchantServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
/// <inheritdoc />
public async Task<MerchantStaff?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.MerchantStaff
.Include(s => s.ShopAssignments)
.Include(s => s.DeviceTokens)
.FirstOrDefaultAsync(s => s.Id == id, cancellationToken);
}
/// <inheritdoc />
public async Task<MerchantStaff?> 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);
}
/// <inheritdoc />
public async Task<MerchantStaff?> 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);
}
/// <inheritdoc />
public async Task<IReadOnlyList<MerchantStaff>> 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);
}
/// <inheritdoc />
public async Task<IReadOnlyList<MerchantStaff>> 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);
}
/// <inheritdoc />
public async Task<bool> 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);
}
/// <inheritdoc />
public MerchantStaff Add(MerchantStaff staff)
{
return _context.MerchantStaff.Add(staff).Entity;
}
/// <inheritdoc />
public void Update(MerchantStaff staff)
{
_context.Entry(staff).State = EntityState.Modified;
}
}

View File

@@ -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;
/// <summary>
/// EN: Repository implementation for Shop aggregate.
/// VI: Implementation repository cho Shop aggregate.
/// </summary>
public class ShopRepository : IShopRepository
{
private readonly MerchantServiceContext _context;
public IUnitOfWork UnitOfWork => _context;
public ShopRepository(MerchantServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
/// <inheritdoc />
public async Task<Shop?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.Shops
.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted, cancellationToken);
}
/// <inheritdoc />
public async Task<Shop?> GetByIdWithBranchesAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.Shops
.Include(s => s.Branches)
.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted, cancellationToken);
}
/// <inheritdoc />
public async Task<Shop?> GetBySlugAsync(string slug, CancellationToken cancellationToken = default)
{
return await _context.Shops
.FirstOrDefaultAsync(s => s.Slug == slug.ToLowerInvariant() && !s.IsDeleted, cancellationToken);
}
/// <inheritdoc />
public async Task<bool> SlugExistsAsync(string slug, CancellationToken cancellationToken = default)
{
return await _context.Shops
.AnyAsync(s => s.Slug == slug.ToLowerInvariant(), cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Shop>> GetByMerchantIdAsync(Guid merchantId, CancellationToken cancellationToken = default)
{
return await _context.Shops
.Where(s => s.MerchantId == merchantId && !s.IsDeleted)
.OrderByDescending(s => s.CreatedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Shop>> 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);
}
/// <inheritdoc />
public Shop Add(Shop shop)
{
return _context.Shops.Add(shop).Entity;
}
/// <inheritdoc />
public void Update(Shop shop)
{
_context.Entry(shop).State = EntityState.Modified;
}
}

View File

@@ -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;
/// <summary>
/// EN: Custom WebApplicationFactory for functional tests.
/// VI: WebApplicationFactory tùy chỉnh cho functional tests.
/// </summary>
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
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<MerchantServiceContext>));
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<MerchantServiceContext>(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<MerchantServiceContext>();
db.Database.EnsureCreated();
});
}
}

Some files were not shown because too many files have changed in this diff Show More