feat: Implement promotion, campaign, voucher, and redemption domain features, replacing sample entities and related infrastructure.
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PromotionService.API.Application.DTOs;
|
||||
using PromotionService.API.Application.Services;
|
||||
using PromotionService.Domain.AggregatesModel.CampaignAggregate;
|
||||
using PromotionService.Domain.Exceptions;
|
||||
|
||||
namespace PromotionService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for CreateCampaignCommand.
|
||||
/// VI: Handler cho CreateCampaignCommand.
|
||||
/// </summary>
|
||||
public class CreateCampaignCommandHandler : IRequestHandler<CreateCampaignCommand, CampaignDto>
|
||||
{
|
||||
private readonly ICampaignRepository _campaignRepository;
|
||||
private readonly IWalletServiceClient _walletService;
|
||||
private readonly ILogger<CreateCampaignCommandHandler> _logger;
|
||||
|
||||
public CreateCampaignCommandHandler(
|
||||
ICampaignRepository campaignRepository,
|
||||
IWalletServiceClient walletService,
|
||||
ILogger<CreateCampaignCommandHandler> logger)
|
||||
{
|
||||
_campaignRepository = campaignRepository;
|
||||
_walletService = walletService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<CampaignDto> Handle(CreateCampaignCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Creating campaign {Name} for merchant {MerchantId}",
|
||||
request.Name, request.MerchantId);
|
||||
|
||||
// Parse enums
|
||||
var backingAssetType = request.BackingAssetType.ToLower() == "point"
|
||||
? AssetType.Point
|
||||
: AssetType.Currency;
|
||||
|
||||
var acquisitionType = request.AcquisitionType.ToLower() switch
|
||||
{
|
||||
"free" => AcquisitionType.Free,
|
||||
"exchangepoints" => AcquisitionType.ExchangePoints,
|
||||
"purchase" => AcquisitionType.Purchase,
|
||||
_ => throw new PromotionDomainException($"Invalid acquisition type: {request.AcquisitionType}")
|
||||
};
|
||||
|
||||
// Create campaign aggregate
|
||||
var campaign = new Campaign(
|
||||
request.MerchantId,
|
||||
request.Name,
|
||||
request.Description,
|
||||
backingAssetType,
|
||||
request.BackingAssetCode,
|
||||
request.FaceValue,
|
||||
acquisitionType,
|
||||
request.AcquisitionPrice,
|
||||
request.TotalVouchers,
|
||||
request.StartDate,
|
||||
request.EndDate,
|
||||
request.VoucherValidityDays,
|
||||
request.MaxPerUser);
|
||||
|
||||
// Create escrow hold in Wallet Service
|
||||
var escrowAmount = request.FaceValue * request.TotalVouchers;
|
||||
_logger.LogInformation("Creating escrow hold for {Amount} {Currency}",
|
||||
escrowAmount, request.BackingAssetCode);
|
||||
|
||||
var holdResult = await _walletService.CreateHoldAsync(
|
||||
request.MerchantWalletId,
|
||||
escrowAmount,
|
||||
request.BackingAssetCode,
|
||||
"CAMPAIGN",
|
||||
campaign.Id,
|
||||
$"Campaign escrow: {request.Name}",
|
||||
cancellationToken);
|
||||
|
||||
// Set escrow reference on campaign
|
||||
campaign.SetEscrowHold(holdResult.WalletId, holdResult.HoldId);
|
||||
|
||||
// Generate voucher codes
|
||||
campaign.GenerateVouchers(request.TotalVouchers);
|
||||
|
||||
// Persist
|
||||
_campaignRepository.Add(campaign);
|
||||
await _campaignRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Campaign {CampaignId} created with {VoucherCount} vouchers",
|
||||
campaign.Id, request.TotalVouchers);
|
||||
|
||||
return MapToDto(campaign);
|
||||
}
|
||||
|
||||
private static CampaignDto MapToDto(Campaign campaign) => new(
|
||||
campaign.Id,
|
||||
campaign.MerchantId,
|
||||
campaign.Name,
|
||||
campaign.Description,
|
||||
campaign.BackingAssetTypeId == AssetType.Point.Id ? "Point" : "Currency",
|
||||
campaign.BackingAssetCode,
|
||||
campaign.FaceValue,
|
||||
campaign.AcquisitionTypeId == AcquisitionType.Free.Id ? "Free"
|
||||
: campaign.AcquisitionTypeId == AcquisitionType.ExchangePoints.Id ? "ExchangePoints"
|
||||
: "Purchase",
|
||||
campaign.AcquisitionPrice,
|
||||
campaign.TotalVouchers,
|
||||
campaign.IssuedVouchers,
|
||||
campaign.AvailableVoucherCount,
|
||||
campaign.StartDate,
|
||||
campaign.EndDate,
|
||||
campaign.VoucherValidityDays,
|
||||
campaign.StatusId == CampaignStatus.Draft.Id ? "Draft"
|
||||
: campaign.StatusId == CampaignStatus.Active.Id ? "Active"
|
||||
: campaign.StatusId == CampaignStatus.Paused.Id ? "Paused"
|
||||
: campaign.StatusId == CampaignStatus.Completed.Id ? "Completed"
|
||||
: "Cancelled",
|
||||
campaign.CreatedAt,
|
||||
campaign.UpdatedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for ActivateCampaignCommand.
|
||||
/// VI: Handler cho ActivateCampaignCommand.
|
||||
/// </summary>
|
||||
public class ActivateCampaignCommandHandler : IRequestHandler<ActivateCampaignCommand, bool>
|
||||
{
|
||||
private readonly ICampaignRepository _campaignRepository;
|
||||
private readonly ILogger<ActivateCampaignCommandHandler> _logger;
|
||||
|
||||
public ActivateCampaignCommandHandler(
|
||||
ICampaignRepository campaignRepository,
|
||||
ILogger<ActivateCampaignCommandHandler> logger)
|
||||
{
|
||||
_campaignRepository = campaignRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(ActivateCampaignCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var campaign = await _campaignRepository.GetByIdAsync(request.CampaignId)
|
||||
?? throw new PromotionDomainException($"Campaign {request.CampaignId} not found");
|
||||
|
||||
campaign.Activate();
|
||||
_campaignRepository.Update(campaign);
|
||||
await _campaignRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Campaign {CampaignId} activated", request.CampaignId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for PauseCampaignCommand.
|
||||
/// VI: Handler cho PauseCampaignCommand.
|
||||
/// </summary>
|
||||
public class PauseCampaignCommandHandler : IRequestHandler<PauseCampaignCommand, bool>
|
||||
{
|
||||
private readonly ICampaignRepository _campaignRepository;
|
||||
private readonly ILogger<PauseCampaignCommandHandler> _logger;
|
||||
|
||||
public PauseCampaignCommandHandler(
|
||||
ICampaignRepository campaignRepository,
|
||||
ILogger<PauseCampaignCommandHandler> logger)
|
||||
{
|
||||
_campaignRepository = campaignRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(PauseCampaignCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var campaign = await _campaignRepository.GetByIdAsync(request.CampaignId)
|
||||
?? throw new PromotionDomainException($"Campaign {request.CampaignId} not found");
|
||||
|
||||
campaign.Pause();
|
||||
_campaignRepository.Update(campaign);
|
||||
await _campaignRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Campaign {CampaignId} paused", request.CampaignId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for CancelCampaignCommand.
|
||||
/// VI: Handler cho CancelCampaignCommand.
|
||||
/// </summary>
|
||||
public class CancelCampaignCommandHandler : IRequestHandler<CancelCampaignCommand, bool>
|
||||
{
|
||||
private readonly ICampaignRepository _campaignRepository;
|
||||
private readonly IWalletServiceClient _walletService;
|
||||
private readonly ILogger<CancelCampaignCommandHandler> _logger;
|
||||
|
||||
public CancelCampaignCommandHandler(
|
||||
ICampaignRepository campaignRepository,
|
||||
IWalletServiceClient walletService,
|
||||
ILogger<CancelCampaignCommandHandler> logger)
|
||||
{
|
||||
_campaignRepository = campaignRepository;
|
||||
_walletService = walletService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(CancelCampaignCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var campaign = await _campaignRepository.GetByIdAsync(request.CampaignId)
|
||||
?? throw new PromotionDomainException($"Campaign {request.CampaignId} not found");
|
||||
|
||||
// Release escrow if exists
|
||||
if (campaign.EscrowHoldId.HasValue && campaign.EscrowWalletId.HasValue)
|
||||
{
|
||||
_logger.LogInformation("Releasing escrow hold {HoldId} for campaign {CampaignId}",
|
||||
campaign.EscrowHoldId, request.CampaignId);
|
||||
|
||||
await _walletService.CancelHoldAsync(
|
||||
campaign.EscrowWalletId.Value,
|
||||
campaign.EscrowHoldId.Value,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
campaign.Cancel();
|
||||
_campaignRepository.Update(campaign);
|
||||
await _campaignRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Campaign {CampaignId} cancelled", request.CampaignId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using MediatR;
|
||||
using PromotionService.API.Application.DTOs;
|
||||
|
||||
namespace PromotionService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to create a new campaign.
|
||||
/// VI: Command để tạo chiến dịch mới.
|
||||
/// </summary>
|
||||
public record CreateCampaignCommand(
|
||||
Guid MerchantId,
|
||||
Guid MerchantWalletId,
|
||||
string Name,
|
||||
string? Description,
|
||||
string BackingAssetType,
|
||||
string BackingAssetCode,
|
||||
decimal FaceValue,
|
||||
string AcquisitionType,
|
||||
decimal AcquisitionPrice,
|
||||
int TotalVouchers,
|
||||
DateTime StartDate,
|
||||
DateTime EndDate,
|
||||
int VoucherValidityDays = 30,
|
||||
int MaxPerUser = 1) : IRequest<CampaignDto>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to activate a campaign.
|
||||
/// VI: Command để kích hoạt chiến dịch.
|
||||
/// </summary>
|
||||
public record ActivateCampaignCommand(Guid CampaignId) : IRequest<bool>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to pause a campaign.
|
||||
/// VI: Command để tạm dừng chiến dịch.
|
||||
/// </summary>
|
||||
public record PauseCampaignCommand(Guid CampaignId) : IRequest<bool>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to cancel a campaign.
|
||||
/// VI: Command để hủy chiến dịch.
|
||||
/// </summary>
|
||||
public record CancelCampaignCommand(Guid CampaignId) : IRequest<bool>;
|
||||
@@ -1,14 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace PromotionService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to change status of a Sample.
|
||||
/// VI: Command để thay đổi trạng thái của Sample.
|
||||
/// </summary>
|
||||
/// <param name="SampleId">EN: Sample ID / VI: ID sample</param>
|
||||
/// <param name="NewStatus">EN: New status (activate, complete, cancel) / VI: Trạng thái mới (activate, complete, cancel)</param>
|
||||
public record ChangeSampleStatusCommand(
|
||||
Guid SampleId,
|
||||
string NewStatus
|
||||
) : IRequest<bool>;
|
||||
@@ -1,70 +0,0 @@
|
||||
using MediatR;
|
||||
using PromotionService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace PromotionService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for ChangeSampleStatusCommand.
|
||||
/// VI: Handler cho ChangeSampleStatusCommand.
|
||||
/// </summary>
|
||||
public class ChangeSampleStatusCommandHandler : IRequestHandler<ChangeSampleStatusCommand, bool>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
private readonly ILogger<ChangeSampleStatusCommandHandler> _logger;
|
||||
|
||||
public ChangeSampleStatusCommandHandler(
|
||||
ISampleRepository sampleRepository,
|
||||
ILogger<ChangeSampleStatusCommandHandler> logger)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(
|
||||
ChangeSampleStatusCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Changing status of sample {SampleId} to {NewStatus} / Thay đổi trạng thái sample {SampleId} thành {NewStatus}",
|
||||
request.SampleId, request.NewStatus);
|
||||
|
||||
// EN: Get existing sample / VI: Lấy sample đã tồn tại
|
||||
var sample = await _sampleRepository.GetAsync(request.SampleId);
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sample {SampleId} not found / Sample {SampleId} không tìm thấy",
|
||||
request.SampleId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EN: Change status based on action / VI: Thay đổi trạng thái dựa trên action
|
||||
switch (request.NewStatus.ToLowerInvariant())
|
||||
{
|
||||
case "activate":
|
||||
sample.Activate();
|
||||
break;
|
||||
case "complete":
|
||||
sample.Complete();
|
||||
break;
|
||||
case "cancel":
|
||||
sample.Cancel();
|
||||
break;
|
||||
default:
|
||||
_logger.LogWarning(
|
||||
"Invalid status action: {NewStatus} / Action trạng thái không hợp lệ: {NewStatus}",
|
||||
request.NewStatus);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EN: Save changes / VI: Lưu thay đổi
|
||||
await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sample {SampleId} status changed to {NewStatus} / Trạng thái sample {SampleId} đã đổi thành {NewStatus}",
|
||||
request.SampleId, request.NewStatus);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace PromotionService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to create a new Sample.
|
||||
/// VI: Command để tạo một Sample mới.
|
||||
/// </summary>
|
||||
/// <param name="Name">EN: Sample name / VI: Tên sample</param>
|
||||
/// <param name="Description">EN: Optional description / VI: Mô tả tùy chọn</param>
|
||||
public record CreateSampleCommand(
|
||||
string Name,
|
||||
string? Description
|
||||
) : IRequest<CreateSampleCommandResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of CreateSampleCommand.
|
||||
/// VI: Kết quả của CreateSampleCommand.
|
||||
/// </summary>
|
||||
/// <param name="Id">EN: Created sample ID / VI: ID sample đã tạo</param>
|
||||
public record CreateSampleCommandResult(Guid Id);
|
||||
@@ -1,46 +0,0 @@
|
||||
using MediatR;
|
||||
using PromotionService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace PromotionService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for CreateSampleCommand.
|
||||
/// VI: Handler cho CreateSampleCommand.
|
||||
/// </summary>
|
||||
public class CreateSampleCommandHandler : IRequestHandler<CreateSampleCommand, CreateSampleCommandResult>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
private readonly ILogger<CreateSampleCommandHandler> _logger;
|
||||
|
||||
public CreateSampleCommandHandler(
|
||||
ISampleRepository sampleRepository,
|
||||
ILogger<CreateSampleCommandHandler> logger)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<CreateSampleCommandResult> Handle(
|
||||
CreateSampleCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Creating new sample with name: {Name} / Tạo sample mới với tên: {Name}",
|
||||
request.Name);
|
||||
|
||||
// EN: Create domain entity / VI: Tạo domain entity
|
||||
var sample = new Sample(request.Name, request.Description);
|
||||
|
||||
// EN: Add to repository / VI: Thêm vào repository
|
||||
_sampleRepository.Add(sample);
|
||||
|
||||
// EN: Save changes (dispatches domain events) / VI: Lưu thay đổi (dispatch domain events)
|
||||
await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sample created successfully with ID: {SampleId} / Sample đã tạo thành công với ID: {SampleId}",
|
||||
sample.Id);
|
||||
|
||||
return new CreateSampleCommandResult(sample.Id);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace PromotionService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to delete a Sample.
|
||||
/// VI: Command để xóa một Sample.
|
||||
/// </summary>
|
||||
/// <param name="SampleId">EN: Sample ID to delete / VI: ID sample cần xóa</param>
|
||||
public record DeleteSampleCommand(Guid SampleId) : IRequest<bool>;
|
||||
@@ -1,54 +0,0 @@
|
||||
using MediatR;
|
||||
using PromotionService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace PromotionService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for DeleteSampleCommand.
|
||||
/// VI: Handler cho DeleteSampleCommand.
|
||||
/// </summary>
|
||||
public class DeleteSampleCommandHandler : IRequestHandler<DeleteSampleCommand, bool>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
private readonly ILogger<DeleteSampleCommandHandler> _logger;
|
||||
|
||||
public DeleteSampleCommandHandler(
|
||||
ISampleRepository sampleRepository,
|
||||
ILogger<DeleteSampleCommandHandler> logger)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(
|
||||
DeleteSampleCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Deleting sample {SampleId} / Xóa sample {SampleId}",
|
||||
request.SampleId);
|
||||
|
||||
// EN: Get existing sample / VI: Lấy sample đã tồn tại
|
||||
var sample = await _sampleRepository.GetAsync(request.SampleId);
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sample {SampleId} not found / Sample {SampleId} không tìm thấy",
|
||||
request.SampleId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EN: Delete sample / VI: Xóa sample
|
||||
_sampleRepository.Delete(sample);
|
||||
|
||||
// EN: Save changes / VI: Lưu thay đổi
|
||||
await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sample {SampleId} deleted successfully / Sample {SampleId} đã xóa thành công",
|
||||
request.SampleId);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace PromotionService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to update an existing Sample.
|
||||
/// VI: Command để cập nhật một Sample đã tồn tại.
|
||||
/// </summary>
|
||||
/// <param name="SampleId">EN: Sample ID to update / VI: ID sample cần cập nhật</param>
|
||||
/// <param name="Name">EN: New name / VI: Tên mới</param>
|
||||
/// <param name="Description">EN: New description / VI: Mô tả mới</param>
|
||||
public record UpdateSampleCommand(
|
||||
Guid SampleId,
|
||||
string Name,
|
||||
string? Description
|
||||
) : IRequest<bool>;
|
||||
@@ -1,54 +0,0 @@
|
||||
using MediatR;
|
||||
using PromotionService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace PromotionService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for UpdateSampleCommand.
|
||||
/// VI: Handler cho UpdateSampleCommand.
|
||||
/// </summary>
|
||||
public class UpdateSampleCommandHandler : IRequestHandler<UpdateSampleCommand, bool>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
private readonly ILogger<UpdateSampleCommandHandler> _logger;
|
||||
|
||||
public UpdateSampleCommandHandler(
|
||||
ISampleRepository sampleRepository,
|
||||
ILogger<UpdateSampleCommandHandler> logger)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(
|
||||
UpdateSampleCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Updating sample {SampleId} / Cập nhật sample {SampleId}",
|
||||
request.SampleId);
|
||||
|
||||
// EN: Get existing sample / VI: Lấy sample đã tồn tại
|
||||
var sample = await _sampleRepository.GetAsync(request.SampleId);
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sample {SampleId} not found / Sample {SampleId} không tìm thấy",
|
||||
request.SampleId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EN: Update sample using domain method / VI: Cập nhật sample sử dụng domain method
|
||||
sample.Update(request.Name, request.Description);
|
||||
|
||||
// EN: Save changes / VI: Lưu thay đổi
|
||||
await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sample {SampleId} updated successfully / Sample {SampleId} đã cập nhật thành công",
|
||||
request.SampleId);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PromotionService.API.Application.DTOs;
|
||||
using PromotionService.API.Application.Services;
|
||||
using PromotionService.Domain.AggregatesModel.CampaignAggregate;
|
||||
using PromotionService.Domain.AggregatesModel.RedemptionAggregate;
|
||||
using PromotionService.Domain.Events;
|
||||
using PromotionService.Domain.Exceptions;
|
||||
|
||||
namespace PromotionService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for ClaimVoucherCommand (free vouchers).
|
||||
/// VI: Handler cho ClaimVoucherCommand (voucher miễn phí).
|
||||
/// </summary>
|
||||
public class ClaimVoucherCommandHandler : IRequestHandler<ClaimVoucherCommand, VoucherDto>
|
||||
{
|
||||
private readonly ICampaignRepository _campaignRepository;
|
||||
private readonly ILogger<ClaimVoucherCommandHandler> _logger;
|
||||
|
||||
public ClaimVoucherCommandHandler(
|
||||
ICampaignRepository campaignRepository,
|
||||
ILogger<ClaimVoucherCommandHandler> logger)
|
||||
{
|
||||
_campaignRepository = campaignRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<VoucherDto> Handle(ClaimVoucherCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var campaign = await _campaignRepository.GetByIdAsync(request.CampaignId)
|
||||
?? throw new PromotionDomainException($"Campaign {request.CampaignId} not found");
|
||||
|
||||
// Verify it's a free campaign
|
||||
if (campaign.AcquisitionTypeId != AcquisitionType.Free.Id)
|
||||
throw new PromotionDomainException("This campaign requires payment or point exchange");
|
||||
|
||||
var voucher = campaign.IssueVoucher(request.UserId);
|
||||
_campaignRepository.Update(campaign);
|
||||
await _campaignRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Voucher {VoucherCode} claimed by user {UserId}", voucher.Code, request.UserId);
|
||||
|
||||
return MapToDto(voucher);
|
||||
}
|
||||
|
||||
private static VoucherDto MapToDto(Voucher voucher) => new(
|
||||
voucher.Id,
|
||||
voucher.CampaignId,
|
||||
voucher.Code,
|
||||
voucher.OwnerId,
|
||||
voucher.FaceValue,
|
||||
voucher.RemainingValue,
|
||||
voucher.StatusId == VoucherStatus.Available.Id ? "Available"
|
||||
: voucher.StatusId == VoucherStatus.Claimed.Id ? "Claimed"
|
||||
: voucher.StatusId == VoucherStatus.PartiallyRedeemed.Id ? "PartiallyRedeemed"
|
||||
: voucher.StatusId == VoucherStatus.FullyRedeemed.Id ? "FullyRedeemed"
|
||||
: "Expired",
|
||||
voucher.ClaimedAt,
|
||||
voucher.ExpiresAt,
|
||||
voucher.RedeemedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for RedeemVoucherCommand.
|
||||
/// VI: Handler cho RedeemVoucherCommand.
|
||||
/// </summary>
|
||||
public class RedeemVoucherCommandHandler : IRequestHandler<RedeemVoucherCommand, RedemptionDto>
|
||||
{
|
||||
private readonly ICampaignRepository _campaignRepository;
|
||||
private readonly IRedemptionRepository _redemptionRepository;
|
||||
private readonly IWalletServiceClient _walletService;
|
||||
private readonly ILogger<RedeemVoucherCommandHandler> _logger;
|
||||
|
||||
public RedeemVoucherCommandHandler(
|
||||
ICampaignRepository campaignRepository,
|
||||
IRedemptionRepository redemptionRepository,
|
||||
IWalletServiceClient walletService,
|
||||
ILogger<RedeemVoucherCommandHandler> logger)
|
||||
{
|
||||
_campaignRepository = campaignRepository;
|
||||
_redemptionRepository = redemptionRepository;
|
||||
_walletService = walletService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<RedemptionDto> Handle(RedeemVoucherCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Find voucher by code
|
||||
var voucher = await _campaignRepository.GetVoucherByCodeAsync(request.VoucherCode)
|
||||
?? throw new PromotionDomainException($"Voucher {request.VoucherCode} not found");
|
||||
|
||||
// Verify ownership
|
||||
if (voucher.OwnerId != request.UserId)
|
||||
throw new PromotionDomainException("You do not own this voucher");
|
||||
|
||||
// Validate voucher can be redeemed
|
||||
if (!voucher.IsValidForRedemption())
|
||||
{
|
||||
if (voucher.IsExpired)
|
||||
throw new VoucherExpiredException(voucher.Id, voucher.Code);
|
||||
throw new PromotionDomainException("Voucher cannot be redeemed");
|
||||
}
|
||||
|
||||
// Get campaign for escrow info
|
||||
var campaign = await _campaignRepository.GetByIdAsync(voucher.CampaignId)
|
||||
?? throw new PromotionDomainException("Campaign not found");
|
||||
|
||||
// Calculate amounts
|
||||
var orderAmount = request.OrderAmount;
|
||||
var voucherValue = voucher.RemainingValue;
|
||||
var amountUsed = Math.Min(orderAmount, voucherValue);
|
||||
var amountRefunded = voucherValue - amountUsed; // Surplus goes back to merchant
|
||||
|
||||
_logger.LogInformation("Redeeming voucher {VoucherCode}: OrderAmount={OrderAmount}, VoucherValue={VoucherValue}, AmountUsed={AmountUsed}, Refund={Refund}",
|
||||
request.VoucherCode, orderAmount, voucherValue, amountUsed, amountRefunded);
|
||||
|
||||
// Execute escrow for amount used
|
||||
if (campaign.EscrowHoldId.HasValue && campaign.EscrowWalletId.HasValue)
|
||||
{
|
||||
var executionRef = $"REDEEM:{voucher.Code}:{request.OrderId}";
|
||||
|
||||
await _walletService.ExecuteHoldAsync(
|
||||
campaign.EscrowWalletId.Value,
|
||||
campaign.EscrowHoldId.Value,
|
||||
amountUsed,
|
||||
executionRef,
|
||||
cancellationToken);
|
||||
|
||||
// Release surplus back to merchant if any
|
||||
if (amountRefunded > 0)
|
||||
{
|
||||
await _walletService.ReleaseHoldAsync(
|
||||
campaign.EscrowWalletId.Value,
|
||||
campaign.EscrowHoldId.Value,
|
||||
amountRefunded,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
// Update voucher
|
||||
voucher.Redeem(voucherValue); // Redeem full value
|
||||
|
||||
// Create redemption record
|
||||
var redemption = new Redemption(
|
||||
voucher.Id,
|
||||
voucher.CampaignId,
|
||||
request.UserId,
|
||||
request.OrderId,
|
||||
amountUsed,
|
||||
amountRefunded,
|
||||
$"REDEEM:{voucher.Code}");
|
||||
|
||||
_redemptionRepository.Add(redemption);
|
||||
_campaignRepository.Update(campaign);
|
||||
await _campaignRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Voucher {VoucherCode} redeemed, AmountUsed={AmountUsed}",
|
||||
voucher.Code, amountUsed);
|
||||
|
||||
return new RedemptionDto(
|
||||
redemption.Id,
|
||||
redemption.VoucherId,
|
||||
redemption.CampaignId,
|
||||
redemption.UserId,
|
||||
redemption.OrderId,
|
||||
redemption.AmountUsed,
|
||||
redemption.AmountRefunded,
|
||||
redemption.RedeemedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using MediatR;
|
||||
using PromotionService.API.Application.DTOs;
|
||||
|
||||
namespace PromotionService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to claim a free voucher.
|
||||
/// VI: Command để nhận voucher miễn phí.
|
||||
/// </summary>
|
||||
public record ClaimVoucherCommand(
|
||||
Guid CampaignId,
|
||||
Guid UserId) : IRequest<VoucherDto>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to exchange points for a voucher.
|
||||
/// VI: Command để đổi điểm lấy voucher.
|
||||
/// </summary>
|
||||
public record ExchangeVoucherCommand(
|
||||
Guid CampaignId,
|
||||
Guid UserId,
|
||||
Guid UserWalletId) : IRequest<VoucherDto>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to purchase a voucher.
|
||||
/// VI: Command để mua voucher.
|
||||
/// </summary>
|
||||
public record PurchaseVoucherCommand(
|
||||
Guid CampaignId,
|
||||
Guid UserId,
|
||||
Guid UserWalletId) : IRequest<VoucherDto>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to redeem a voucher for an order.
|
||||
/// VI: Command để sử dụng voucher cho đơn hàng.
|
||||
/// </summary>
|
||||
public record RedeemVoucherCommand(
|
||||
string VoucherCode,
|
||||
Guid UserId,
|
||||
Guid? OrderId,
|
||||
decimal OrderAmount) : IRequest<RedemptionDto>;
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace PromotionService.API.Application.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Campaign DTO for API responses.
|
||||
/// VI: DTO Campaign cho các phản hồi API.
|
||||
/// </summary>
|
||||
public record CampaignDto(
|
||||
Guid Id,
|
||||
Guid MerchantId,
|
||||
string Name,
|
||||
string? Description,
|
||||
string BackingAssetType,
|
||||
string BackingAssetCode,
|
||||
decimal FaceValue,
|
||||
string AcquisitionType,
|
||||
decimal AcquisitionPrice,
|
||||
int TotalVouchers,
|
||||
int IssuedVouchers,
|
||||
int AvailableVouchers,
|
||||
DateTime StartDate,
|
||||
DateTime EndDate,
|
||||
int VoucherValidityDays,
|
||||
string Status,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Campaign summary DTO for list responses.
|
||||
/// VI: DTO tóm tắt Campaign cho các phản hồi danh sách.
|
||||
/// </summary>
|
||||
public record CampaignSummaryDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Status,
|
||||
int TotalVouchers,
|
||||
int IssuedVouchers,
|
||||
DateTime StartDate,
|
||||
DateTime EndDate);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Campaign statistics DTO.
|
||||
/// VI: DTO thống kê Campaign.
|
||||
/// </summary>
|
||||
public record CampaignStatisticsDto(
|
||||
Guid CampaignId,
|
||||
string CampaignName,
|
||||
int TotalVouchers,
|
||||
int AvailableVouchers,
|
||||
int ClaimedVouchers,
|
||||
int RedeemedVouchers,
|
||||
decimal TotalFaceValue,
|
||||
decimal TotalRedeemedValue,
|
||||
decimal UtilizationRate);
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace PromotionService.API.Application.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Voucher DTO for API responses.
|
||||
/// VI: DTO Voucher cho các phản hồi API.
|
||||
/// </summary>
|
||||
public record VoucherDto(
|
||||
Guid Id,
|
||||
Guid CampaignId,
|
||||
string Code,
|
||||
Guid? OwnerId,
|
||||
decimal FaceValue,
|
||||
decimal RemainingValue,
|
||||
string Status,
|
||||
DateTime? ClaimedAt,
|
||||
DateTime? ExpiresAt,
|
||||
DateTime? RedeemedAt);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Voucher summary DTO for list responses.
|
||||
/// VI: DTO tóm tắt Voucher cho các phản hồi danh sách.
|
||||
/// </summary>
|
||||
public record VoucherSummaryDto(
|
||||
Guid Id,
|
||||
string Code,
|
||||
decimal RemainingValue,
|
||||
string Status,
|
||||
DateTime? ExpiresAt);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Voucher validation result.
|
||||
/// VI: Kết quả xác thực voucher.
|
||||
/// </summary>
|
||||
public record VoucherValidationDto(
|
||||
bool IsValid,
|
||||
string? ErrorMessage,
|
||||
Guid? VoucherId,
|
||||
string? VoucherCode,
|
||||
decimal? RemainingValue,
|
||||
DateTime? ExpiresAt,
|
||||
string? CampaignName);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Redemption DTO.
|
||||
/// VI: DTO Redemption.
|
||||
/// </summary>
|
||||
public record RedemptionDto(
|
||||
Guid Id,
|
||||
Guid VoucherId,
|
||||
Guid CampaignId,
|
||||
Guid UserId,
|
||||
Guid? OrderId,
|
||||
decimal AmountUsed,
|
||||
decimal AmountRefunded,
|
||||
DateTime RedeemedAt);
|
||||
@@ -1,23 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace PromotionService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get a Sample by ID.
|
||||
/// VI: Query để lấy một Sample theo ID.
|
||||
/// </summary>
|
||||
/// <param name="SampleId">EN: Sample ID / VI: ID sample</param>
|
||||
public record GetSampleQuery(Guid SampleId) : IRequest<SampleViewModel?>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sample view model for API responses.
|
||||
/// VI: Sample view model cho API responses.
|
||||
/// </summary>
|
||||
public record SampleViewModel(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
string Status,
|
||||
DateTime CreatedAt,
|
||||
DateTime? UpdatedAt
|
||||
);
|
||||
@@ -1,39 +0,0 @@
|
||||
using MediatR;
|
||||
using PromotionService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace PromotionService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetSampleQuery.
|
||||
/// VI: Handler cho GetSampleQuery.
|
||||
/// </summary>
|
||||
public class GetSampleQueryHandler : IRequestHandler<GetSampleQuery, SampleViewModel?>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
|
||||
public GetSampleQueryHandler(ISampleRepository sampleRepository)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
}
|
||||
|
||||
public async Task<SampleViewModel?> Handle(
|
||||
GetSampleQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sample = await _sampleRepository.GetAsync(request.SampleId);
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SampleViewModel(
|
||||
sample.Id,
|
||||
sample.Name,
|
||||
sample.Description,
|
||||
sample.Status.Name,
|
||||
sample.CreatedAt,
|
||||
sample.UpdatedAt
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace PromotionService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get all Samples.
|
||||
/// VI: Query để lấy tất cả Samples.
|
||||
/// </summary>
|
||||
public record GetSamplesQuery : IRequest<IEnumerable<SampleViewModel>>;
|
||||
@@ -1,34 +0,0 @@
|
||||
using MediatR;
|
||||
using PromotionService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace PromotionService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetSamplesQuery.
|
||||
/// VI: Handler cho GetSamplesQuery.
|
||||
/// </summary>
|
||||
public class GetSamplesQueryHandler : IRequestHandler<GetSamplesQuery, IEnumerable<SampleViewModel>>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
|
||||
public GetSamplesQueryHandler(ISampleRepository sampleRepository)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SampleViewModel>> Handle(
|
||||
GetSamplesQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var samples = await _sampleRepository.GetAllAsync();
|
||||
|
||||
return samples.Select(sample => new SampleViewModel(
|
||||
sample.Id,
|
||||
sample.Name,
|
||||
sample.Description,
|
||||
sample.Status.Name,
|
||||
sample.CreatedAt,
|
||||
sample.UpdatedAt
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using MediatR;
|
||||
using PromotionService.API.Application.DTOs;
|
||||
|
||||
namespace PromotionService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get a campaign by ID.
|
||||
/// VI: Query để lấy chiến dịch theo ID.
|
||||
/// </summary>
|
||||
public record GetCampaignQuery(Guid CampaignId) : IRequest<CampaignDto?>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get campaigns by merchant.
|
||||
/// VI: Query để lấy chiến dịch theo merchant.
|
||||
/// </summary>
|
||||
public record GetCampaignsQuery(Guid? MerchantId = null, bool ActiveOnly = false) : IRequest<IEnumerable<CampaignSummaryDto>>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get campaign statistics.
|
||||
/// VI: Query để lấy thống kê chiến dịch.
|
||||
/// </summary>
|
||||
public record GetCampaignStatisticsQuery(Guid CampaignId) : IRequest<CampaignStatisticsDto?>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to validate a voucher code.
|
||||
/// VI: Query để xác thực mã voucher.
|
||||
/// </summary>
|
||||
public record ValidateVoucherQuery(string VoucherCode, Guid UserId) : IRequest<VoucherValidationDto>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get user's vouchers.
|
||||
/// VI: Query để lấy voucher của người dùng.
|
||||
/// </summary>
|
||||
public record GetUserVouchersQuery(Guid UserId) : IRequest<IEnumerable<VoucherSummaryDto>>;
|
||||
@@ -0,0 +1,138 @@
|
||||
using MediatR;
|
||||
using PromotionService.API.Application.DTOs;
|
||||
using PromotionService.Domain.AggregatesModel.CampaignAggregate;
|
||||
|
||||
namespace PromotionService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetCampaignQuery.
|
||||
/// VI: Handler cho GetCampaignQuery.
|
||||
/// </summary>
|
||||
public class GetCampaignQueryHandler : IRequestHandler<GetCampaignQuery, CampaignDto?>
|
||||
{
|
||||
private readonly ICampaignRepository _campaignRepository;
|
||||
|
||||
public GetCampaignQueryHandler(ICampaignRepository campaignRepository)
|
||||
{
|
||||
_campaignRepository = campaignRepository;
|
||||
}
|
||||
|
||||
public async Task<CampaignDto?> Handle(GetCampaignQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var campaign = await _campaignRepository.GetByIdAsync(request.CampaignId);
|
||||
return campaign == null ? null : MapToDto(campaign);
|
||||
}
|
||||
|
||||
private static CampaignDto MapToDto(Campaign c) => new(
|
||||
c.Id, c.MerchantId, c.Name, c.Description,
|
||||
c.BackingAssetTypeId == AssetType.Point.Id ? "Point" : "Currency",
|
||||
c.BackingAssetCode, c.FaceValue,
|
||||
c.AcquisitionTypeId == AcquisitionType.Free.Id ? "Free"
|
||||
: c.AcquisitionTypeId == AcquisitionType.ExchangePoints.Id ? "ExchangePoints" : "Purchase",
|
||||
c.AcquisitionPrice, c.TotalVouchers, c.IssuedVouchers, c.AvailableVoucherCount,
|
||||
c.StartDate, c.EndDate, c.VoucherValidityDays,
|
||||
MapStatus(c.StatusId), c.CreatedAt, c.UpdatedAt);
|
||||
|
||||
private static string MapStatus(int statusId) => statusId switch
|
||||
{
|
||||
1 => "Draft", 2 => "Active", 3 => "Paused", 4 => "Completed", _ => "Cancelled"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetCampaignsQuery.
|
||||
/// VI: Handler cho GetCampaignsQuery.
|
||||
/// </summary>
|
||||
public class GetCampaignsQueryHandler : IRequestHandler<GetCampaignsQuery, IEnumerable<CampaignSummaryDto>>
|
||||
{
|
||||
private readonly ICampaignRepository _campaignRepository;
|
||||
|
||||
public GetCampaignsQueryHandler(ICampaignRepository campaignRepository)
|
||||
{
|
||||
_campaignRepository = campaignRepository;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CampaignSummaryDto>> Handle(GetCampaignsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
IEnumerable<Campaign> campaigns;
|
||||
|
||||
if (request.ActiveOnly)
|
||||
{
|
||||
campaigns = await _campaignRepository.GetActiveAsync();
|
||||
}
|
||||
else if (request.MerchantId.HasValue)
|
||||
{
|
||||
campaigns = await _campaignRepository.GetByMerchantIdAsync(request.MerchantId.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
campaigns = await _campaignRepository.GetActiveAsync();
|
||||
}
|
||||
|
||||
return campaigns.Select(c => new CampaignSummaryDto(
|
||||
c.Id, c.Name,
|
||||
c.StatusId == 1 ? "Draft" : c.StatusId == 2 ? "Active" : c.StatusId == 3 ? "Paused" : c.StatusId == 4 ? "Completed" : "Cancelled",
|
||||
c.TotalVouchers, c.IssuedVouchers, c.StartDate, c.EndDate));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for ValidateVoucherQuery.
|
||||
/// VI: Handler cho ValidateVoucherQuery.
|
||||
/// </summary>
|
||||
public class ValidateVoucherQueryHandler : IRequestHandler<ValidateVoucherQuery, VoucherValidationDto>
|
||||
{
|
||||
private readonly ICampaignRepository _campaignRepository;
|
||||
|
||||
public ValidateVoucherQueryHandler(ICampaignRepository campaignRepository)
|
||||
{
|
||||
_campaignRepository = campaignRepository;
|
||||
}
|
||||
|
||||
public async Task<VoucherValidationDto> Handle(ValidateVoucherQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var voucher = await _campaignRepository.GetVoucherByCodeAsync(request.VoucherCode);
|
||||
|
||||
if (voucher == null)
|
||||
return new VoucherValidationDto(false, "Voucher not found", null, null, null, null, null);
|
||||
|
||||
if (voucher.OwnerId != request.UserId)
|
||||
return new VoucherValidationDto(false, "You do not own this voucher", voucher.Id, voucher.Code, null, null, null);
|
||||
|
||||
if (!voucher.IsValidForRedemption())
|
||||
{
|
||||
var errorMsg = voucher.IsExpired ? "Voucher has expired" : "Voucher cannot be redeemed";
|
||||
return new VoucherValidationDto(false, errorMsg, voucher.Id, voucher.Code, voucher.RemainingValue, voucher.ExpiresAt, null);
|
||||
}
|
||||
|
||||
var campaign = await _campaignRepository.GetByIdAsync(voucher.CampaignId);
|
||||
|
||||
return new VoucherValidationDto(
|
||||
true, null, voucher.Id, voucher.Code,
|
||||
voucher.RemainingValue, voucher.ExpiresAt, campaign?.Name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetUserVouchersQuery.
|
||||
/// VI: Handler cho GetUserVouchersQuery.
|
||||
/// </summary>
|
||||
public class GetUserVouchersQueryHandler : IRequestHandler<GetUserVouchersQuery, IEnumerable<VoucherSummaryDto>>
|
||||
{
|
||||
private readonly ICampaignRepository _campaignRepository;
|
||||
|
||||
public GetUserVouchersQueryHandler(ICampaignRepository campaignRepository)
|
||||
{
|
||||
_campaignRepository = campaignRepository;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<VoucherSummaryDto>> Handle(GetUserVouchersQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var vouchers = await _campaignRepository.GetUserVouchersAsync(request.UserId);
|
||||
|
||||
return vouchers.Select(v => new VoucherSummaryDto(
|
||||
v.Id, v.Code, v.RemainingValue,
|
||||
v.StatusId == 1 ? "Available" : v.StatusId == 2 ? "Claimed" : v.StatusId == 3 ? "PartiallyRedeemed" : v.StatusId == 4 ? "FullyRedeemed" : "Expired",
|
||||
v.ExpiresAt));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
namespace PromotionService.API.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Interface for Wallet Service client (escrow operations).
|
||||
/// VI: Interface cho Wallet Service client (thao tác ký quỹ).
|
||||
/// </summary>
|
||||
public interface IWalletServiceClient
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Create an escrow hold in the wallet.
|
||||
/// VI: Tạo một lệnh giữ ký quỹ trong ví.
|
||||
/// </summary>
|
||||
Task<HoldResult> CreateHoldAsync(
|
||||
Guid walletId,
|
||||
decimal amount,
|
||||
string currencyType,
|
||||
string referenceType,
|
||||
Guid referenceId,
|
||||
string description,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Execute (commit) a portion of the held amount.
|
||||
/// VI: Thực thi (cam kết) một phần số tiền bị giữ.
|
||||
/// </summary>
|
||||
Task<bool> ExecuteHoldAsync(
|
||||
Guid walletId,
|
||||
Guid holdId,
|
||||
decimal amount,
|
||||
string? executionRef = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Release (return) a portion of the held amount.
|
||||
/// VI: Giải phóng (trả lại) một phần số tiền bị giữ.
|
||||
/// </summary>
|
||||
Task<bool> ReleaseHoldAsync(
|
||||
Guid walletId,
|
||||
Guid holdId,
|
||||
decimal? amount = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Cancel the entire hold.
|
||||
/// VI: Hủy toàn bộ lệnh giữ.
|
||||
/// </summary>
|
||||
Task<bool> CancelHoldAsync(
|
||||
Guid walletId,
|
||||
Guid holdId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get wallet by user ID.
|
||||
/// VI: Lấy ví theo ID người dùng.
|
||||
/// </summary>
|
||||
Task<WalletInfo?> GetWalletByUserIdAsync(
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of escrow hold creation.
|
||||
/// VI: Kết quả tạo lệnh giữ ký quỹ.
|
||||
/// </summary>
|
||||
public record HoldResult(
|
||||
Guid HoldId,
|
||||
Guid WalletId,
|
||||
decimal Amount,
|
||||
string CurrencyType,
|
||||
string ReferenceType,
|
||||
Guid ReferenceId,
|
||||
string Status);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Wallet information.
|
||||
/// VI: Thông tin ví.
|
||||
/// </summary>
|
||||
public record WalletInfo(
|
||||
Guid Id,
|
||||
Guid UserId,
|
||||
string Status);
|
||||
@@ -0,0 +1,148 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
|
||||
namespace PromotionService.API.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// EN: HTTP client implementation for Wallet Service.
|
||||
/// VI: Triển khai HTTP client cho Wallet Service.
|
||||
/// </summary>
|
||||
public class WalletServiceClient : IWalletServiceClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<WalletServiceClient> _logger;
|
||||
private readonly AsyncRetryPolicy<HttpResponseMessage> _retryPolicy;
|
||||
|
||||
public WalletServiceClient(HttpClient httpClient, ILogger<WalletServiceClient> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
|
||||
// Retry policy with exponential backoff
|
||||
_retryPolicy = Policy<HttpResponseMessage>
|
||||
.Handle<HttpRequestException>()
|
||||
.OrResult(r => !r.IsSuccessStatusCode && r.StatusCode != System.Net.HttpStatusCode.BadRequest)
|
||||
.WaitAndRetryAsync(3, retryAttempt =>
|
||||
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
|
||||
(result, timeSpan, retryCount, context) =>
|
||||
{
|
||||
_logger.LogWarning("Retry {RetryCount} for Wallet Service call after {Delay}s",
|
||||
retryCount, timeSpan.TotalSeconds);
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<HoldResult> CreateHoldAsync(
|
||||
Guid walletId,
|
||||
decimal amount,
|
||||
string currencyType,
|
||||
string referenceType,
|
||||
Guid referenceId,
|
||||
string description,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var request = new
|
||||
{
|
||||
Amount = amount,
|
||||
CurrencyType = currencyType,
|
||||
ReferenceType = referenceType,
|
||||
ReferenceId = referenceId,
|
||||
Description = description
|
||||
};
|
||||
|
||||
var response = await _retryPolicy.ExecuteAsync(async () =>
|
||||
await _httpClient.PostAsJsonAsync($"/api/v1/wallets/{walletId}/holds", request, cancellationToken));
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<HoldResult>(cancellationToken: cancellationToken)
|
||||
?? throw new InvalidOperationException("Failed to deserialize hold result");
|
||||
|
||||
_logger.LogInformation("Created hold {HoldId} for wallet {WalletId}", result.HoldId, walletId);
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<bool> ExecuteHoldAsync(
|
||||
Guid walletId,
|
||||
Guid holdId,
|
||||
decimal amount,
|
||||
string? executionRef = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var request = new { Amount = amount, ExecutionReference = executionRef };
|
||||
|
||||
var response = await _retryPolicy.ExecuteAsync(async () =>
|
||||
await _httpClient.PostAsJsonAsync($"/api/v1/wallets/{walletId}/holds/{holdId}/execute", request, cancellationToken));
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Failed to execute hold {HoldId}: {StatusCode}", holdId, response.StatusCode);
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Executed hold {HoldId} for {Amount}", holdId, amount);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> ReleaseHoldAsync(
|
||||
Guid walletId,
|
||||
Guid holdId,
|
||||
decimal? amount = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var request = new { Amount = amount };
|
||||
|
||||
var response = await _retryPolicy.ExecuteAsync(async () =>
|
||||
await _httpClient.PostAsJsonAsync($"/api/v1/wallets/{walletId}/holds/{holdId}/release", request, cancellationToken));
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Failed to release hold {HoldId}: {StatusCode}", holdId, response.StatusCode);
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Released hold {HoldId}", holdId);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> CancelHoldAsync(
|
||||
Guid walletId,
|
||||
Guid holdId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await _retryPolicy.ExecuteAsync(async () =>
|
||||
await _httpClient.PostAsync($"/api/v1/wallets/{walletId}/holds/{holdId}/cancel", null, cancellationToken));
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Failed to cancel hold {HoldId}: {StatusCode}", holdId, response.StatusCode);
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Cancelled hold {HoldId}", holdId);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<WalletInfo?> GetWalletByUserIdAsync(
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"/api/v1/wallets/user/{userId}", cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<WalletInfo>(cancellationToken: cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get wallet for user {UserId}", userId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
using FluentValidation;
|
||||
using PromotionService.API.Application.Commands;
|
||||
|
||||
namespace PromotionService.API.Application.Validations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for CreateSampleCommand.
|
||||
/// VI: Validator cho CreateSampleCommand.
|
||||
/// </summary>
|
||||
public class CreateSampleCommandValidator : AbstractValidator<CreateSampleCommand>
|
||||
{
|
||||
public CreateSampleCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.WithMessage("Name is required / Tên là bắt buộc")
|
||||
.MaximumLength(200)
|
||||
.WithMessage("Name must be less than 200 characters / Tên phải ít hơn 200 ký tự");
|
||||
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(1000)
|
||||
.WithMessage("Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự")
|
||||
.When(x => x.Description != null);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
using FluentValidation;
|
||||
using PromotionService.API.Application.Commands;
|
||||
|
||||
namespace PromotionService.API.Application.Validations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for UpdateSampleCommand.
|
||||
/// VI: Validator cho UpdateSampleCommand.
|
||||
/// </summary>
|
||||
public class UpdateSampleCommandValidator : AbstractValidator<UpdateSampleCommand>
|
||||
{
|
||||
public UpdateSampleCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.SampleId)
|
||||
.NotEmpty()
|
||||
.WithMessage("Sample ID is required / ID sample là bắt buộc");
|
||||
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.WithMessage("Name is required / Tên là bắt buộc")
|
||||
.MaximumLength(200)
|
||||
.WithMessage("Name must be less than 200 characters / Tên phải ít hơn 200 ký tự");
|
||||
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(1000)
|
||||
.WithMessage("Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự")
|
||||
.When(x => x.Description != null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PromotionService.API.Application.Commands;
|
||||
using PromotionService.API.Application.DTOs;
|
||||
using PromotionService.API.Application.Queries;
|
||||
|
||||
namespace PromotionService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Controller for Campaign management.
|
||||
/// VI: Controller để quản lý Campaign.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/[controller]")]
|
||||
[Produces("application/json")]
|
||||
public class CampaignsController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<CampaignsController> _logger;
|
||||
|
||||
public CampaignsController(IMediator mediator, ILogger<CampaignsController> logger)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new campaign.
|
||||
/// VI: Tạo chiến dịch mới.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Merchant,Admin")]
|
||||
[ProducesResponseType(typeof(CampaignDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<CampaignDto>> CreateCampaign([FromBody] CreateCampaignCommand command)
|
||||
{
|
||||
_logger.LogInformation("Creating campaign {Name}", command.Name);
|
||||
var result = await _mediator.Send(command);
|
||||
return CreatedAtAction(nameof(GetCampaign), new { id = result.Id }, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get campaign by ID.
|
||||
/// VI: Lấy chiến dịch theo ID.
|
||||
/// </summary>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(CampaignDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CampaignDto>> GetCampaign(Guid id)
|
||||
{
|
||||
var result = await _mediator.Send(new GetCampaignQuery(id));
|
||||
return result == null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get campaigns list.
|
||||
/// VI: Lấy danh sách chiến dịch.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IEnumerable<CampaignSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IEnumerable<CampaignSummaryDto>>> GetCampaigns(
|
||||
[FromQuery] Guid? merchantId = null,
|
||||
[FromQuery] bool activeOnly = false)
|
||||
{
|
||||
var result = await _mediator.Send(new GetCampaignsQuery(merchantId, activeOnly));
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Activate a campaign.
|
||||
/// VI: Kích hoạt chiến dịch.
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/activate")]
|
||||
[Authorize(Roles = "Merchant,Admin")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> ActivateCampaign(Guid id)
|
||||
{
|
||||
var result = await _mediator.Send(new ActivateCampaignCommand(id));
|
||||
return result ? Ok() : NotFound();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Pause a campaign.
|
||||
/// VI: Tạm dừng chiến dịch.
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/pause")]
|
||||
[Authorize(Roles = "Merchant,Admin")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> PauseCampaign(Guid id)
|
||||
{
|
||||
var result = await _mediator.Send(new PauseCampaignCommand(id));
|
||||
return result ? Ok() : NotFound();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Cancel a campaign.
|
||||
/// VI: Hủy chiến dịch.
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/cancel")]
|
||||
[Authorize(Roles = "Merchant,Admin")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> CancelCampaign(Guid id)
|
||||
{
|
||||
var result = await _mediator.Send(new CancelCampaignCommand(id));
|
||||
return result ? Ok() : NotFound();
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
using Asp.Versioning;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PromotionService.API.Application.Commands;
|
||||
using PromotionService.API.Application.Queries;
|
||||
|
||||
namespace PromotionService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Controller for Sample CRUD operations using CQRS pattern.
|
||||
/// VI: Controller cho các thao tác CRUD Sample sử dụng pattern CQRS.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
[Produces("application/json")]
|
||||
public class SamplesController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<SamplesController> _logger;
|
||||
|
||||
public SamplesController(IMediator mediator, ILogger<SamplesController> logger)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all samples.
|
||||
/// VI: Lấy tất cả samples.
|
||||
/// </summary>
|
||||
/// <returns>EN: List of samples / VI: Danh sách samples</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IEnumerable<SampleViewModel>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetSamples()
|
||||
{
|
||||
var samples = await _mediator.Send(new GetSamplesQuery());
|
||||
return Ok(new { success = true, data = samples });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get a sample by ID.
|
||||
/// VI: Lấy một sample theo ID.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Sample ID / VI: ID sample</param>
|
||||
/// <returns>EN: Sample details / VI: Chi tiết sample</returns>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(SampleViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetSample(Guid id)
|
||||
{
|
||||
var sample = await _mediator.Send(new GetSampleQuery(id));
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
success = false,
|
||||
error = new
|
||||
{
|
||||
code = "SAMPLE_NOT_FOUND",
|
||||
message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { success = true, data = sample });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new sample.
|
||||
/// VI: Tạo một sample mới.
|
||||
/// </summary>
|
||||
/// <param name="request">EN: Create request / VI: Request tạo</param>
|
||||
/// <returns>EN: Created sample ID / VI: ID sample đã tạo</returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(CreateSampleCommandResult), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> CreateSample([FromBody] CreateSampleRequest request)
|
||||
{
|
||||
var command = new CreateSampleCommand(request.Name, request.Description);
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetSample),
|
||||
new { id = result.Id },
|
||||
new { success = true, data = result });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update an existing sample.
|
||||
/// VI: Cập nhật một sample đã tồn tại.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Sample ID / VI: ID sample</param>
|
||||
/// <param name="request">EN: Update request / VI: Request cập nhật</param>
|
||||
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdateSample(Guid id, [FromBody] UpdateSampleRequest request)
|
||||
{
|
||||
var command = new UpdateSampleCommand(id, request.Name, request.Description);
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
success = false,
|
||||
error = new
|
||||
{
|
||||
code = "SAMPLE_NOT_FOUND",
|
||||
message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { success = true, message = "Sample updated successfully / Sample đã cập nhật thành công" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Delete a sample.
|
||||
/// VI: Xóa một sample.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Sample ID / VI: ID sample</param>
|
||||
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteSample(Guid id)
|
||||
{
|
||||
var command = new DeleteSampleCommand(id);
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
success = false,
|
||||
error = new
|
||||
{
|
||||
code = "SAMPLE_NOT_FOUND",
|
||||
message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Change sample status.
|
||||
/// VI: Thay đổi trạng thái sample.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Sample ID / VI: ID sample</param>
|
||||
/// <param name="request">EN: Status change request / VI: Request thay đổi trạng thái</param>
|
||||
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
|
||||
[HttpPatch("{id:guid}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> ChangeSampleStatus(Guid id, [FromBody] ChangeStatusRequest request)
|
||||
{
|
||||
var command = new ChangeSampleStatusCommand(id, request.Status);
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
success = false,
|
||||
error = new
|
||||
{
|
||||
code = "STATUS_CHANGE_FAILED",
|
||||
message = "Failed to change sample status / Thay đổi trạng thái sample thất bại"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { success = true, message = "Sample status changed successfully / Trạng thái sample đã thay đổi thành công" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request model for creating a sample.
|
||||
/// VI: Model request để tạo sample.
|
||||
/// </summary>
|
||||
public record CreateSampleRequest(string Name, string? Description);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request model for updating a sample.
|
||||
/// VI: Model request để cập nhật sample.
|
||||
/// </summary>
|
||||
public record UpdateSampleRequest(string Name, string? Description);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request model for changing sample status.
|
||||
/// VI: Model request để thay đổi trạng thái sample.
|
||||
/// </summary>
|
||||
public record ChangeStatusRequest(string Status);
|
||||
@@ -0,0 +1,81 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PromotionService.API.Application.Commands;
|
||||
using PromotionService.API.Application.DTOs;
|
||||
using PromotionService.API.Application.Queries;
|
||||
|
||||
namespace PromotionService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Controller for Voucher operations.
|
||||
/// VI: Controller cho các thao tác Voucher.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/[controller]")]
|
||||
[Produces("application/json")]
|
||||
public class VouchersController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<VouchersController> _logger;
|
||||
|
||||
public VouchersController(IMediator mediator, ILogger<VouchersController> logger)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Claim a free voucher.
|
||||
/// VI: Nhận voucher miễn phí.
|
||||
/// </summary>
|
||||
[HttpPost("claim")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(VoucherDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<VoucherDto>> ClaimVoucher([FromBody] ClaimVoucherCommand command)
|
||||
{
|
||||
var result = await _mediator.Send(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validate a voucher code.
|
||||
/// VI: Xác thực mã voucher.
|
||||
/// </summary>
|
||||
[HttpGet("validate/{code}")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(VoucherValidationDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<VoucherValidationDto>> ValidateVoucher(string code, [FromQuery] Guid userId)
|
||||
{
|
||||
var result = await _mediator.Send(new ValidateVoucherQuery(code, userId));
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Redeem a voucher.
|
||||
/// VI: Sử dụng voucher.
|
||||
/// </summary>
|
||||
[HttpPost("redeem")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(RedemptionDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<RedemptionDto>> RedeemVoucher([FromBody] RedeemVoucherCommand command)
|
||||
{
|
||||
var result = await _mediator.Send(command);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get user's vouchers.
|
||||
/// VI: Lấy voucher của người dùng.
|
||||
/// </summary>
|
||||
[HttpGet("user/{userId:guid}")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(IEnumerable<VoucherSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IEnumerable<VoucherSummaryDto>>> GetUserVouchers(Guid userId)
|
||||
{
|
||||
var result = await _mediator.Send(new GetUserVouchersQuery(userId));
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
namespace PromotionService.API.HealthChecks;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Health check for Wallet Service connectivity.
|
||||
/// VI: Kiểm tra sức khỏe kết nối Wallet Service.
|
||||
/// </summary>
|
||||
public class WalletServiceHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<WalletServiceHealthCheck> _logger;
|
||||
|
||||
public WalletServiceHealthCheck(IHttpClientFactory httpClientFactory, ILogger<WalletServiceHealthCheck> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient("WalletService");
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync("/health/live", cancellationToken);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return HealthCheckResult.Healthy("Wallet Service is reachable");
|
||||
}
|
||||
|
||||
_logger.LogWarning("Wallet Service health check failed with status {StatusCode}", response.StatusCode);
|
||||
return HealthCheckResult.Degraded($"Wallet Service returned {response.StatusCode}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Wallet Service health check failed");
|
||||
return HealthCheckResult.Unhealthy("Wallet Service is not reachable", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
using Asp.Versioning;
|
||||
using FluentValidation;
|
||||
using Hellang.Middleware.ProblemDetails;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using PromotionService.API.Application.Behaviors;
|
||||
using PromotionService.API.Application.Services;
|
||||
using PromotionService.API.HealthChecks;
|
||||
using PromotionService.Infrastructure;
|
||||
using Serilog;
|
||||
using System.Text;
|
||||
|
||||
// EN: Configure Serilog early / VI: Cấu hình Serilog sớm
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
@@ -26,6 +31,14 @@ try
|
||||
// EN: Add Infrastructure services / VI: Thêm Infrastructure services
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
|
||||
// EN: Add Wallet Service HTTP client / VI: Thêm HTTP client cho Wallet Service
|
||||
builder.Services.AddHttpClient<IWalletServiceClient, WalletServiceClient>(client =>
|
||||
{
|
||||
var walletServiceUrl = builder.Configuration["WalletService:BaseUrl"] ?? "http://wallet-service-net:8080";
|
||||
client.BaseAddress = new Uri(walletServiceUrl);
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
});
|
||||
|
||||
// EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors
|
||||
builder.Services.AddMediatR(cfg =>
|
||||
{
|
||||
@@ -77,13 +90,20 @@ try
|
||||
});
|
||||
|
||||
// EN: Add health checks / VI: Thêm health checks
|
||||
builder.Services.AddHttpClient("WalletService", client =>
|
||||
{
|
||||
var walletServiceUrl = builder.Configuration["WalletService:BaseUrl"] ?? "http://wallet-service-net:8080";
|
||||
client.BaseAddress = new Uri(walletServiceUrl);
|
||||
});
|
||||
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddNpgSql(
|
||||
builder.Configuration.GetConnectionString("DefaultConnection")
|
||||
?? builder.Configuration["DATABASE_URL"]
|
||||
?? "",
|
||||
name: "postgresql",
|
||||
tags: ["db", "postgresql"]);
|
||||
tags: ["db", "postgresql"])
|
||||
.AddCheck<WalletServiceHealthCheck>("wallet-service", tags: ["service", "wallet"]);
|
||||
|
||||
// EN: Add CORS / VI: Thêm CORS
|
||||
builder.Services.AddCors(options =>
|
||||
@@ -96,6 +116,23 @@ try
|
||||
});
|
||||
});
|
||||
|
||||
// EN: Add JWT Authentication / VI: Thêm JWT Authentication
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.Authority = builder.Configuration["Jwt:Authority"];
|
||||
options.Audience = builder.Configuration["Jwt:Audience"];
|
||||
options.RequireHttpsMetadata = builder.Configuration.GetValue<bool>("Jwt:RequireHttpsMetadata", false);
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true
|
||||
};
|
||||
});
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// EN: Configure middleware pipeline / VI: Cấu hình middleware pipeline
|
||||
@@ -114,6 +151,8 @@ try
|
||||
|
||||
app.UseCors();
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// EN: Map health check endpoints / VI: Map health check endpoints
|
||||
app.MapHealthChecks("/health");
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
<!-- 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.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
|
||||
<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" />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Information",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
|
||||
"Microsoft.EntityFrameworkCore": "Information"
|
||||
}
|
||||
},
|
||||
"Serilog": {
|
||||
@@ -11,9 +11,32 @@
|
||||
"Default": "Debug",
|
||||
"Override": {
|
||||
"Microsoft": "Information",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Information",
|
||||
"System": "Information"
|
||||
"Microsoft.EntityFrameworkCore": "Information",
|
||||
"System": "Warning"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=promotion_service_dev;Username=postgres;Password=postgres"
|
||||
},
|
||||
"WalletService": {
|
||||
"BaseUrl": "http://localhost:5003",
|
||||
"TimeoutSeconds": 30
|
||||
},
|
||||
"IamService": {
|
||||
"BaseUrl": "http://localhost:5001",
|
||||
"ServiceName": "promotion-service"
|
||||
},
|
||||
"RabbitMQ": {
|
||||
"Host": "localhost",
|
||||
"Port": 5672,
|
||||
"Username": "guest",
|
||||
"Password": "guest",
|
||||
"VirtualHost": "/"
|
||||
},
|
||||
"Jwt": {
|
||||
"Authority": "http://localhost:5001",
|
||||
"Audience": "goodgo-api",
|
||||
"RequireHttpsMetadata": false
|
||||
}
|
||||
}
|
||||
@@ -30,17 +30,34 @@
|
||||
]
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres"
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=promotion_service;Username=postgres;Password=postgres"
|
||||
},
|
||||
"WalletService": {
|
||||
"BaseUrl": "http://localhost:5003",
|
||||
"TimeoutSeconds": 30
|
||||
},
|
||||
"IamService": {
|
||||
"BaseUrl": "http://localhost:5001",
|
||||
"ServiceName": "promotion-service"
|
||||
},
|
||||
"RabbitMQ": {
|
||||
"Host": "localhost",
|
||||
"Port": 5672,
|
||||
"Username": "guest",
|
||||
"Password": "guest",
|
||||
"VirtualHost": "/"
|
||||
},
|
||||
"Jwt": {
|
||||
"Authority": "http://localhost:5001",
|
||||
"Audience": "goodgo-api",
|
||||
"RequireHttpsMetadata": false,
|
||||
"Secret": "your-super-secret-key-min-32-characters",
|
||||
"Issuer": "goodgo-platform",
|
||||
"AccessTokenExpiryMinutes": 15,
|
||||
"RefreshTokenExpiryDays": 7
|
||||
},
|
||||
"Redis": {
|
||||
"ConnectionString": "localhost:6379"
|
||||
},
|
||||
"Jwt": {
|
||||
"Secret": "your-super-secret-key-min-32-characters",
|
||||
"Issuer": "goodgo-platform",
|
||||
"Audience": "goodgo-services",
|
||||
"AccessTokenExpiryMinutes": 15,
|
||||
"RefreshTokenExpiryDays": 7
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using PromotionService.Domain.SeedWork;
|
||||
|
||||
namespace PromotionService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository interface for Sample aggregate.
|
||||
/// VI: Interface repository cho Sample aggregate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: Following repository pattern, this interface defines the contract
|
||||
/// for data access operations on Sample aggregate.
|
||||
/// VI: Theo pattern repository, interface này định nghĩa contract
|
||||
/// cho các thao tác truy cập dữ liệu trên Sample aggregate.
|
||||
/// </remarks>
|
||||
public interface ISampleRepository : IRepository<Sample>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Get a sample by its ID.
|
||||
/// VI: Lấy một sample theo ID.
|
||||
/// </summary>
|
||||
/// <param name="sampleId">EN: The sample ID / VI: ID của sample</param>
|
||||
/// <returns>EN: The sample or null if not found / VI: Sample hoặc null nếu không tìm thấy</returns>
|
||||
Task<Sample?> GetAsync(Guid sampleId);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all samples.
|
||||
/// VI: Lấy tất cả samples.
|
||||
/// </summary>
|
||||
/// <returns>EN: List of samples / VI: Danh sách samples</returns>
|
||||
Task<IEnumerable<Sample>> GetAllAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add a new sample.
|
||||
/// VI: Thêm một sample mới.
|
||||
/// </summary>
|
||||
/// <param name="sample">EN: The sample to add / VI: Sample cần thêm</param>
|
||||
/// <returns>EN: The added sample / VI: Sample đã thêm</returns>
|
||||
Sample Add(Sample sample);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update an existing sample.
|
||||
/// VI: Cập nhật một sample đã tồn tại.
|
||||
/// </summary>
|
||||
/// <param name="sample">EN: The sample to update / VI: Sample cần cập nhật</param>
|
||||
void Update(Sample sample);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Delete a sample.
|
||||
/// VI: Xóa một sample.
|
||||
/// </summary>
|
||||
/// <param name="sample">EN: The sample to delete / VI: Sample cần xóa</param>
|
||||
void Delete(Sample sample);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get samples by status.
|
||||
/// VI: Lấy samples theo trạng thái.
|
||||
/// </summary>
|
||||
/// <param name="statusId">EN: The status ID / VI: ID trạng thái</param>
|
||||
/// <returns>EN: List of samples with given status / VI: Danh sách samples với trạng thái cho trước</returns>
|
||||
Task<IEnumerable<Sample>> GetByStatusAsync(int statusId);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
using PromotionService.Domain.Events;
|
||||
using PromotionService.Domain.Exceptions;
|
||||
using PromotionService.Domain.SeedWork;
|
||||
|
||||
namespace PromotionService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sample aggregate root demonstrating DDD patterns.
|
||||
/// VI: Sample aggregate root minh họa các pattern DDD.
|
||||
/// </summary>
|
||||
public class Sample : Entity, IAggregateRoot
|
||||
{
|
||||
// EN: Private fields for encapsulation
|
||||
// VI: Fields private để đóng gói
|
||||
private string _name = null!;
|
||||
private string? _description;
|
||||
private SampleStatus _status = null!;
|
||||
private DateTime _createdAt;
|
||||
private DateTime? _updatedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sample name (required).
|
||||
/// VI: Tên sample (bắt buộc).
|
||||
/// </summary>
|
||||
public string Name => _name;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Optional description.
|
||||
/// VI: Mô tả tùy chọn.
|
||||
/// </summary>
|
||||
public string? Description => _description;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Current status.
|
||||
/// VI: Trạng thái hiện tại.
|
||||
/// </summary>
|
||||
public SampleStatus Status => _status;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Status ID for EF Core mapping.
|
||||
/// VI: ID trạng thái cho EF Core mapping.
|
||||
/// </summary>
|
||||
public int StatusId { 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: Private constructor for EF Core.
|
||||
/// VI: Constructor private cho EF Core.
|
||||
/// </summary>
|
||||
protected Sample()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new Sample with required information.
|
||||
/// VI: Tạo một Sample mới với thông tin bắt buộc.
|
||||
/// </summary>
|
||||
/// <param name="name">EN: Sample name / VI: Tên sample</param>
|
||||
/// <param name="description">EN: Optional description / VI: Mô tả tùy chọn</param>
|
||||
public Sample(string name, string? description = null) : this()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new SampleDomainException("Sample name cannot be empty");
|
||||
|
||||
Id = Guid.NewGuid();
|
||||
_name = name;
|
||||
_description = description;
|
||||
_status = SampleStatus.Draft;
|
||||
StatusId = SampleStatus.Draft.Id;
|
||||
_createdAt = DateTime.UtcNow;
|
||||
|
||||
// EN: Add domain event for creation
|
||||
// VI: Thêm domain event cho việc tạo
|
||||
AddDomainEvent(new SampleCreatedDomainEvent(this));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update sample information.
|
||||
/// VI: Cập nhật thông tin sample.
|
||||
/// </summary>
|
||||
public void Update(string name, string? description)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new SampleDomainException("Sample name cannot be empty");
|
||||
|
||||
if (_status == SampleStatus.Cancelled)
|
||||
throw new SampleDomainException("Cannot update a cancelled sample");
|
||||
|
||||
_name = name;
|
||||
_description = description;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Activate the sample.
|
||||
/// VI: Kích hoạt sample.
|
||||
/// </summary>
|
||||
public void Activate()
|
||||
{
|
||||
if (_status != SampleStatus.Draft)
|
||||
throw new SampleDomainException("Only draft samples can be activated");
|
||||
|
||||
var previousStatus = _status;
|
||||
_status = SampleStatus.Active;
|
||||
StatusId = SampleStatus.Active.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Complete the sample.
|
||||
/// VI: Hoàn thành sample.
|
||||
/// </summary>
|
||||
public void Complete()
|
||||
{
|
||||
if (_status != SampleStatus.Active)
|
||||
throw new SampleDomainException("Only active samples can be completed");
|
||||
|
||||
var previousStatus = _status;
|
||||
_status = SampleStatus.Completed;
|
||||
StatusId = SampleStatus.Completed.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Cancel the sample.
|
||||
/// VI: Hủy sample.
|
||||
/// </summary>
|
||||
public void Cancel()
|
||||
{
|
||||
if (_status == SampleStatus.Completed)
|
||||
throw new SampleDomainException("Cannot cancel a completed sample");
|
||||
|
||||
if (_status == SampleStatus.Cancelled)
|
||||
throw new SampleDomainException("Sample is already cancelled");
|
||||
|
||||
var previousStatus = _status;
|
||||
_status = SampleStatus.Cancelled;
|
||||
StatusId = SampleStatus.Cancelled.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status));
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
using PromotionService.Domain.SeedWork;
|
||||
|
||||
namespace PromotionService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sample status enumeration following type-safe enum pattern.
|
||||
/// VI: Enumeration trạng thái Sample theo pattern enum an toàn kiểu.
|
||||
/// </summary>
|
||||
public class SampleStatus : Enumeration
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Draft status - initial state
|
||||
/// VI: Trạng thái nháp - trạng thái ban đầu
|
||||
/// </summary>
|
||||
public static SampleStatus Draft = new(1, nameof(Draft));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Active status - ready for use
|
||||
/// VI: Trạng thái hoạt động - sẵn sàng sử dụng
|
||||
/// </summary>
|
||||
public static SampleStatus Active = new(2, nameof(Active));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Completed status - finished processing
|
||||
/// VI: Trạng thái hoàn thành - đã xử lý xong
|
||||
/// </summary>
|
||||
public static SampleStatus Completed = new(3, nameof(Completed));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Cancelled status - cancelled by user
|
||||
/// VI: Trạng thái đã hủy - bị hủy bởi người dùng
|
||||
/// </summary>
|
||||
public static SampleStatus Cancelled = new(4, nameof(Cancelled));
|
||||
|
||||
public SampleStatus(int id, string name) : base(id, name)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all available statuses.
|
||||
/// VI: Lấy tất cả các trạng thái có sẵn.
|
||||
/// </summary>
|
||||
public static IEnumerable<SampleStatus> List() => GetAll<SampleStatus>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Parse status from name.
|
||||
/// VI: Parse trạng thái từ tên.
|
||||
/// </summary>
|
||||
public static SampleStatus FromName(string name)
|
||||
{
|
||||
var status = List().SingleOrDefault(s =>
|
||||
string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase));
|
||||
|
||||
if (status is null)
|
||||
{
|
||||
throw new ArgumentException($"Possible values for SampleStatus: {string.Join(",", List().Select(s => s.Name))}");
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Parse status from ID.
|
||||
/// VI: Parse trạng thái từ ID.
|
||||
/// </summary>
|
||||
public static SampleStatus From(int id)
|
||||
{
|
||||
var status = List().SingleOrDefault(s => s.Id == id);
|
||||
|
||||
if (status is null)
|
||||
{
|
||||
throw new ArgumentException($"Possible values for SampleStatus: {string.Join(",", List().Select(s => s.Name))}");
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using MediatR;
|
||||
using PromotionService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace PromotionService.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when a new Sample is created.
|
||||
/// VI: Domain event được phát ra khi một Sample mới được tạo.
|
||||
/// </summary>
|
||||
public class SampleCreatedDomainEvent : INotification
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: The newly created sample.
|
||||
/// VI: Sample mới được tạo.
|
||||
/// </summary>
|
||||
public Sample Sample { get; }
|
||||
|
||||
public SampleCreatedDomainEvent(Sample sample)
|
||||
{
|
||||
Sample = sample;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using MediatR;
|
||||
using PromotionService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace PromotionService.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when Sample status changes.
|
||||
/// VI: Domain event được phát ra khi trạng thái Sample thay đổi.
|
||||
/// </summary>
|
||||
public class SampleStatusChangedDomainEvent : INotification
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: The sample ID.
|
||||
/// VI: ID của sample.
|
||||
/// </summary>
|
||||
public Guid SampleId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Previous status before the change.
|
||||
/// VI: Trạng thái trước khi thay đổi.
|
||||
/// </summary>
|
||||
public SampleStatus PreviousStatus { get; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: New status after the change.
|
||||
/// VI: Trạng thái mới sau khi thay đổi.
|
||||
/// </summary>
|
||||
public SampleStatus NewStatus { get; }
|
||||
|
||||
public SampleStatusChangedDomainEvent(
|
||||
Guid sampleId,
|
||||
SampleStatus previousStatus,
|
||||
SampleStatus newStatus)
|
||||
{
|
||||
SampleId = sampleId;
|
||||
PreviousStatus = previousStatus;
|
||||
NewStatus = newStatus;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PromotionService.Domain.AggregatesModel.SampleAggregate;
|
||||
using PromotionService.Domain.AggregatesModel.CampaignAggregate;
|
||||
using PromotionService.Domain.AggregatesModel.RedemptionAggregate;
|
||||
using PromotionService.Infrastructure.Idempotency;
|
||||
using PromotionService.Infrastructure.Repositories;
|
||||
|
||||
@@ -47,7 +48,8 @@ public static class DependencyInjection
|
||||
});
|
||||
|
||||
// EN: Register repositories / VI: Đăng ký repositories
|
||||
services.AddScoped<ISampleRepository, SampleRepository>();
|
||||
services.AddScoped<ICampaignRepository, CampaignRepository>();
|
||||
services.AddScoped<IRedemptionRepository, RedemptionRepository>();
|
||||
|
||||
// EN: Register idempotency services / VI: Đăng ký idempotency services
|
||||
services.AddScoped<IRequestManager, RequestManager>();
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using PromotionService.Domain.AggregatesModel.CampaignAggregate;
|
||||
|
||||
namespace PromotionService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for Campaign entity.
|
||||
/// VI: Cấu hình EF Core cho entity Campaign.
|
||||
/// </summary>
|
||||
public class CampaignEntityTypeConfiguration : IEntityTypeConfiguration<Campaign>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Campaign> builder)
|
||||
{
|
||||
builder.ToTable("campaigns");
|
||||
|
||||
builder.HasKey(c => c.Id);
|
||||
|
||||
builder.Property(c => c.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder.Property(c => c.MerchantId)
|
||||
.HasColumnName("merchant_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.Name)
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(255)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.Description)
|
||||
.HasColumnName("description")
|
||||
.HasMaxLength(1000);
|
||||
|
||||
// Backing Asset
|
||||
builder.Property(c => c.BackingAssetTypeId)
|
||||
.HasColumnName("backing_asset_type_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Ignore(c => c.BackingAssetType);
|
||||
|
||||
builder.Property(c => c.BackingAssetCode)
|
||||
.HasColumnName("backing_asset_code")
|
||||
.HasMaxLength(10)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.FaceValue)
|
||||
.HasColumnName("face_value")
|
||||
.HasPrecision(18, 2)
|
||||
.IsRequired();
|
||||
|
||||
// Acquisition
|
||||
builder.Property(c => c.AcquisitionTypeId)
|
||||
.HasColumnName("acquisition_type_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Ignore(c => c.AcquisitionType);
|
||||
|
||||
builder.Property(c => c.AcquisitionPrice)
|
||||
.HasColumnName("acquisition_price")
|
||||
.HasPrecision(18, 2);
|
||||
|
||||
// Escrow
|
||||
builder.Property(c => c.EscrowHoldId)
|
||||
.HasColumnName("escrow_hold_id");
|
||||
|
||||
builder.Property(c => c.EscrowWalletId)
|
||||
.HasColumnName("escrow_wallet_id");
|
||||
|
||||
builder.Property(c => c.EscrowAmount)
|
||||
.HasColumnName("escrow_amount")
|
||||
.HasPrecision(18, 2);
|
||||
|
||||
// Limits
|
||||
builder.Property(c => c.TotalVouchers)
|
||||
.HasColumnName("total_vouchers")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.IssuedVouchers)
|
||||
.HasColumnName("issued_vouchers")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.MaxPerUser)
|
||||
.HasColumnName("max_per_user");
|
||||
|
||||
builder.Property(c => c.StartDate)
|
||||
.HasColumnName("start_date")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.EndDate)
|
||||
.HasColumnName("end_date")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.VoucherValidityDays)
|
||||
.HasColumnName("voucher_validity_days");
|
||||
|
||||
// Status
|
||||
builder.Property(c => c.StatusId)
|
||||
.HasColumnName("status_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Ignore(c => c.Status);
|
||||
|
||||
builder.Property(c => c.CreatedAt)
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.UpdatedAt)
|
||||
.HasColumnName("updated_at")
|
||||
.IsRequired();
|
||||
|
||||
// Navigation to Vouchers
|
||||
builder.HasMany(c => c.Vouchers)
|
||||
.WithOne()
|
||||
.HasForeignKey(v => v.CampaignId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Indexes
|
||||
builder.HasIndex(c => c.MerchantId)
|
||||
.HasDatabaseName("ix_campaigns_merchant_id");
|
||||
|
||||
builder.HasIndex(c => c.StatusId)
|
||||
.HasDatabaseName("ix_campaigns_status_id");
|
||||
|
||||
builder.HasIndex(c => new { c.StartDate, c.EndDate })
|
||||
.HasDatabaseName("ix_campaigns_date_range");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using PromotionService.Domain.AggregatesModel.RedemptionAggregate;
|
||||
|
||||
namespace PromotionService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for Redemption entity.
|
||||
/// VI: Cấu hình EF Core cho entity Redemption.
|
||||
/// </summary>
|
||||
public class RedemptionEntityTypeConfiguration : IEntityTypeConfiguration<Redemption>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Redemption> builder)
|
||||
{
|
||||
builder.ToTable("redemptions");
|
||||
|
||||
builder.HasKey(r => r.Id);
|
||||
|
||||
builder.Property(r => r.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder.Property(r => r.VoucherId)
|
||||
.HasColumnName("voucher_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(r => r.CampaignId)
|
||||
.HasColumnName("campaign_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(r => r.UserId)
|
||||
.HasColumnName("user_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(r => r.OrderId)
|
||||
.HasColumnName("order_id");
|
||||
|
||||
builder.Property(r => r.AmountUsed)
|
||||
.HasColumnName("amount_used")
|
||||
.HasPrecision(18, 2)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(r => r.AmountRefunded)
|
||||
.HasColumnName("amount_refunded")
|
||||
.HasPrecision(18, 2);
|
||||
|
||||
builder.Property(r => r.ExecutionReference)
|
||||
.HasColumnName("execution_reference")
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(r => r.RedeemedAt)
|
||||
.HasColumnName("redeemed_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(r => r.CreatedAt)
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
// Indexes
|
||||
builder.HasIndex(r => r.VoucherId)
|
||||
.HasDatabaseName("ix_redemptions_voucher_id");
|
||||
|
||||
builder.HasIndex(r => r.UserId)
|
||||
.HasDatabaseName("ix_redemptions_user_id");
|
||||
|
||||
builder.HasIndex(r => r.CampaignId)
|
||||
.HasDatabaseName("ix_redemptions_campaign_id");
|
||||
|
||||
builder.HasIndex(r => r.OrderId)
|
||||
.HasDatabaseName("ix_redemptions_order_id");
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using PromotionService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace PromotionService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for Sample entity.
|
||||
/// VI: Cấu hình EF Core cho entity Sample.
|
||||
/// </summary>
|
||||
public class SampleEntityTypeConfiguration : IEntityTypeConfiguration<Sample>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Sample> builder)
|
||||
{
|
||||
// EN: Table name / VI: Tên bảng
|
||||
builder.ToTable("samples");
|
||||
|
||||
// EN: Primary key / VI: Khóa chính
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
// EN: Ignore domain events (not persisted)
|
||||
// VI: Bỏ qua domain events (không lưu)
|
||||
builder.Ignore(s => s.DomainEvents);
|
||||
|
||||
// EN: Properties / VI: Các thuộc tính
|
||||
builder.Property(s => s.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_name")
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(200)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string?>("_description")
|
||||
.HasColumnName("description")
|
||||
.HasMaxLength(1000);
|
||||
|
||||
builder.Property<DateTime>("_createdAt")
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime?>("_updatedAt")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
// EN: Status relationship / VI: Quan hệ với Status
|
||||
builder.Property(s => s.StatusId)
|
||||
.HasColumnName("status_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.HasOne(s => s.Status)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.StatusId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// EN: Indexes / VI: Các index
|
||||
builder.HasIndex("_name");
|
||||
builder.HasIndex(s => s.StatusId);
|
||||
builder.HasIndex("_createdAt");
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using PromotionService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace PromotionService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for SampleStatus enumeration.
|
||||
/// VI: Cấu hình EF Core cho enumeration SampleStatus.
|
||||
/// </summary>
|
||||
public class SampleStatusEntityTypeConfiguration : IEntityTypeConfiguration<SampleStatus>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SampleStatus> builder)
|
||||
{
|
||||
// EN: Table name / VI: Tên bảng
|
||||
builder.ToTable("sample_statuses");
|
||||
|
||||
// EN: Primary key / VI: Khóa chính
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
builder.Property(s => s.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever()
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.Name)
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
// EN: Seed initial data / VI: Seed dữ liệu ban đầu
|
||||
builder.HasData(
|
||||
SampleStatus.Draft,
|
||||
SampleStatus.Active,
|
||||
SampleStatus.Completed,
|
||||
SampleStatus.Cancelled
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using PromotionService.Domain.AggregatesModel.CampaignAggregate;
|
||||
|
||||
namespace PromotionService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for Voucher entity.
|
||||
/// VI: Cấu hình EF Core cho entity Voucher.
|
||||
/// </summary>
|
||||
public class VoucherEntityTypeConfiguration : IEntityTypeConfiguration<Voucher>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Voucher> builder)
|
||||
{
|
||||
builder.ToTable("vouchers");
|
||||
|
||||
builder.HasKey(v => v.Id);
|
||||
|
||||
builder.Property(v => v.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder.Property(v => v.CampaignId)
|
||||
.HasColumnName("campaign_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(v => v.Code)
|
||||
.HasColumnName("code")
|
||||
.HasMaxLength(20)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(v => v.OwnerId)
|
||||
.HasColumnName("owner_id");
|
||||
|
||||
builder.Property(v => v.FaceValue)
|
||||
.HasColumnName("face_value")
|
||||
.HasPrecision(18, 2)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(v => v.RemainingValue)
|
||||
.HasColumnName("remaining_value")
|
||||
.HasPrecision(18, 2)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(v => v.StatusId)
|
||||
.HasColumnName("status_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Ignore(v => v.Status);
|
||||
|
||||
builder.Property(v => v.ClaimedAt)
|
||||
.HasColumnName("claimed_at");
|
||||
|
||||
builder.Property(v => v.ExpiresAt)
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
builder.Property(v => v.RedeemedAt)
|
||||
.HasColumnName("redeemed_at");
|
||||
|
||||
builder.Property(v => v.CreatedAt)
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(v => v.UpdatedAt)
|
||||
.HasColumnName("updated_at")
|
||||
.IsRequired();
|
||||
|
||||
// Indexes
|
||||
builder.HasIndex(v => v.Code)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_vouchers_code");
|
||||
|
||||
builder.HasIndex(v => v.OwnerId)
|
||||
.HasDatabaseName("ix_vouchers_owner_id");
|
||||
|
||||
builder.HasIndex(v => v.CampaignId)
|
||||
.HasDatabaseName("ix_vouchers_campaign_id");
|
||||
|
||||
builder.HasIndex(v => v.StatusId)
|
||||
.HasDatabaseName("ix_vouchers_status_id");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using PromotionService.Infrastructure;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PromotionService.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(PromotionServiceContext))]
|
||||
[Migration("20260117144846_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.0")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("PromotionService.Domain.AggregatesModel.CampaignAggregate.Campaign", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<decimal>("AcquisitionPrice")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)")
|
||||
.HasColumnName("acquisition_price");
|
||||
|
||||
b.Property<int>("AcquisitionTypeId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("acquisition_type_id");
|
||||
|
||||
b.Property<string>("BackingAssetCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)")
|
||||
.HasColumnName("backing_asset_code");
|
||||
|
||||
b.Property<int>("BackingAssetTypeId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("backing_asset_type_id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<DateTime>("EndDate")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("end_date");
|
||||
|
||||
b.Property<decimal>("EscrowAmount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)")
|
||||
.HasColumnName("escrow_amount");
|
||||
|
||||
b.Property<Guid?>("EscrowHoldId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("escrow_hold_id");
|
||||
|
||||
b.Property<Guid?>("EscrowWalletId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("escrow_wallet_id");
|
||||
|
||||
b.Property<decimal>("FaceValue")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)")
|
||||
.HasColumnName("face_value");
|
||||
|
||||
b.Property<int>("IssuedVouchers")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("issued_vouchers");
|
||||
|
||||
b.Property<int>("MaxPerUser")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("max_per_user");
|
||||
|
||||
b.Property<Guid>("MerchantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("merchant_id");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("start_date");
|
||||
|
||||
b.Property<int>("StatusId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("status_id");
|
||||
|
||||
b.Property<int>("TotalVouchers")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("total_vouchers");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<int>("VoucherValidityDays")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("voucher_validity_days");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MerchantId")
|
||||
.HasDatabaseName("ix_campaigns_merchant_id");
|
||||
|
||||
b.HasIndex("StatusId")
|
||||
.HasDatabaseName("ix_campaigns_status_id");
|
||||
|
||||
b.HasIndex("StartDate", "EndDate")
|
||||
.HasDatabaseName("ix_campaigns_date_range");
|
||||
|
||||
b.ToTable("campaigns", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PromotionService.Domain.AggregatesModel.CampaignAggregate.Voucher", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("CampaignId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("campaign_id");
|
||||
|
||||
b.Property<DateTime?>("ClaimedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("claimed_at");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("code");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<decimal>("FaceValue")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)")
|
||||
.HasColumnName("face_value");
|
||||
|
||||
b.Property<Guid?>("OwnerId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("owner_id");
|
||||
|
||||
b.Property<DateTime?>("RedeemedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("redeemed_at");
|
||||
|
||||
b.Property<decimal>("RemainingValue")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)")
|
||||
.HasColumnName("remaining_value");
|
||||
|
||||
b.Property<int>("StatusId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("status_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CampaignId")
|
||||
.HasDatabaseName("ix_vouchers_campaign_id");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_vouchers_code");
|
||||
|
||||
b.HasIndex("OwnerId")
|
||||
.HasDatabaseName("ix_vouchers_owner_id");
|
||||
|
||||
b.HasIndex("StatusId")
|
||||
.HasDatabaseName("ix_vouchers_status_id");
|
||||
|
||||
b.ToTable("vouchers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PromotionService.Domain.AggregatesModel.RedemptionAggregate.Redemption", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<decimal>("AmountRefunded")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)")
|
||||
.HasColumnName("amount_refunded");
|
||||
|
||||
b.Property<decimal>("AmountUsed")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)")
|
||||
.HasColumnName("amount_used");
|
||||
|
||||
b.Property<Guid>("CampaignId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("campaign_id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("ExecutionReference")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("execution_reference");
|
||||
|
||||
b.Property<Guid?>("OrderId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("order_id");
|
||||
|
||||
b.Property<DateTime>("RedeemedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("redeemed_at");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<Guid>("VoucherId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("voucher_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CampaignId")
|
||||
.HasDatabaseName("ix_redemptions_campaign_id");
|
||||
|
||||
b.HasIndex("OrderId")
|
||||
.HasDatabaseName("ix_redemptions_order_id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_redemptions_user_id");
|
||||
|
||||
b.HasIndex("VoucherId")
|
||||
.HasDatabaseName("ix_redemptions_voucher_id");
|
||||
|
||||
b.ToTable("redemptions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PromotionService.Domain.AggregatesModel.CampaignAggregate.Voucher", b =>
|
||||
{
|
||||
b.HasOne("PromotionService.Domain.AggregatesModel.CampaignAggregate.Campaign", null)
|
||||
.WithMany("Vouchers")
|
||||
.HasForeignKey("CampaignId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PromotionService.Domain.AggregatesModel.CampaignAggregate.Campaign", b =>
|
||||
{
|
||||
b.Navigation("Vouchers");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PromotionService.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "campaigns",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
merchant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
name = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
|
||||
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
backing_asset_type_id = table.Column<int>(type: "integer", nullable: false),
|
||||
backing_asset_code = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
|
||||
face_value = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
|
||||
acquisition_type_id = table.Column<int>(type: "integer", nullable: false),
|
||||
acquisition_price = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
|
||||
escrow_hold_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
escrow_wallet_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
escrow_amount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
|
||||
total_vouchers = table.Column<int>(type: "integer", nullable: false),
|
||||
issued_vouchers = table.Column<int>(type: "integer", nullable: false),
|
||||
max_per_user = table.Column<int>(type: "integer", nullable: false),
|
||||
start_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
end_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
voucher_validity_days = table.Column<int>(type: "integer", nullable: false),
|
||||
status_id = table.Column<int>(type: "integer", nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_campaigns", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "redemptions",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
voucher_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
campaign_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
order_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
amount_used = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
|
||||
amount_refunded = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
|
||||
execution_reference = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
redeemed_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_redemptions", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "vouchers",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
campaign_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
code = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
owner_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
face_value = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
|
||||
remaining_value = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
|
||||
status_id = table.Column<int>(type: "integer", nullable: false),
|
||||
claimed_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
expires_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
redeemed_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_vouchers", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "FK_vouchers_campaigns_campaign_id",
|
||||
column: x => x.campaign_id,
|
||||
principalTable: "campaigns",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_campaigns_date_range",
|
||||
table: "campaigns",
|
||||
columns: new[] { "start_date", "end_date" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_campaigns_merchant_id",
|
||||
table: "campaigns",
|
||||
column: "merchant_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_campaigns_status_id",
|
||||
table: "campaigns",
|
||||
column: "status_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_redemptions_campaign_id",
|
||||
table: "redemptions",
|
||||
column: "campaign_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_redemptions_order_id",
|
||||
table: "redemptions",
|
||||
column: "order_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_redemptions_user_id",
|
||||
table: "redemptions",
|
||||
column: "user_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_redemptions_voucher_id",
|
||||
table: "redemptions",
|
||||
column: "voucher_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_vouchers_campaign_id",
|
||||
table: "vouchers",
|
||||
column: "campaign_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_vouchers_code",
|
||||
table: "vouchers",
|
||||
column: "code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_vouchers_owner_id",
|
||||
table: "vouchers",
|
||||
column: "owner_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_vouchers_status_id",
|
||||
table: "vouchers",
|
||||
column: "status_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "redemptions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "vouchers");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "campaigns");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using PromotionService.Infrastructure;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PromotionService.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(PromotionServiceContext))]
|
||||
partial class PromotionServiceContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.0")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("PromotionService.Domain.AggregatesModel.CampaignAggregate.Campaign", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<decimal>("AcquisitionPrice")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)")
|
||||
.HasColumnName("acquisition_price");
|
||||
|
||||
b.Property<int>("AcquisitionTypeId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("acquisition_type_id");
|
||||
|
||||
b.Property<string>("BackingAssetCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)")
|
||||
.HasColumnName("backing_asset_code");
|
||||
|
||||
b.Property<int>("BackingAssetTypeId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("backing_asset_type_id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<DateTime>("EndDate")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("end_date");
|
||||
|
||||
b.Property<decimal>("EscrowAmount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)")
|
||||
.HasColumnName("escrow_amount");
|
||||
|
||||
b.Property<Guid?>("EscrowHoldId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("escrow_hold_id");
|
||||
|
||||
b.Property<Guid?>("EscrowWalletId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("escrow_wallet_id");
|
||||
|
||||
b.Property<decimal>("FaceValue")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)")
|
||||
.HasColumnName("face_value");
|
||||
|
||||
b.Property<int>("IssuedVouchers")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("issued_vouchers");
|
||||
|
||||
b.Property<int>("MaxPerUser")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("max_per_user");
|
||||
|
||||
b.Property<Guid>("MerchantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("merchant_id");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("start_date");
|
||||
|
||||
b.Property<int>("StatusId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("status_id");
|
||||
|
||||
b.Property<int>("TotalVouchers")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("total_vouchers");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<int>("VoucherValidityDays")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("voucher_validity_days");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MerchantId")
|
||||
.HasDatabaseName("ix_campaigns_merchant_id");
|
||||
|
||||
b.HasIndex("StatusId")
|
||||
.HasDatabaseName("ix_campaigns_status_id");
|
||||
|
||||
b.HasIndex("StartDate", "EndDate")
|
||||
.HasDatabaseName("ix_campaigns_date_range");
|
||||
|
||||
b.ToTable("campaigns", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PromotionService.Domain.AggregatesModel.CampaignAggregate.Voucher", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("CampaignId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("campaign_id");
|
||||
|
||||
b.Property<DateTime?>("ClaimedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("claimed_at");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("code");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<DateTime?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<decimal>("FaceValue")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)")
|
||||
.HasColumnName("face_value");
|
||||
|
||||
b.Property<Guid?>("OwnerId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("owner_id");
|
||||
|
||||
b.Property<DateTime?>("RedeemedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("redeemed_at");
|
||||
|
||||
b.Property<decimal>("RemainingValue")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)")
|
||||
.HasColumnName("remaining_value");
|
||||
|
||||
b.Property<int>("StatusId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("status_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CampaignId")
|
||||
.HasDatabaseName("ix_vouchers_campaign_id");
|
||||
|
||||
b.HasIndex("Code")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_vouchers_code");
|
||||
|
||||
b.HasIndex("OwnerId")
|
||||
.HasDatabaseName("ix_vouchers_owner_id");
|
||||
|
||||
b.HasIndex("StatusId")
|
||||
.HasDatabaseName("ix_vouchers_status_id");
|
||||
|
||||
b.ToTable("vouchers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PromotionService.Domain.AggregatesModel.RedemptionAggregate.Redemption", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<decimal>("AmountRefunded")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)")
|
||||
.HasColumnName("amount_refunded");
|
||||
|
||||
b.Property<decimal>("AmountUsed")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)")
|
||||
.HasColumnName("amount_used");
|
||||
|
||||
b.Property<Guid>("CampaignId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("campaign_id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("ExecutionReference")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("execution_reference");
|
||||
|
||||
b.Property<Guid?>("OrderId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("order_id");
|
||||
|
||||
b.Property<DateTime>("RedeemedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("redeemed_at");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<Guid>("VoucherId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("voucher_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CampaignId")
|
||||
.HasDatabaseName("ix_redemptions_campaign_id");
|
||||
|
||||
b.HasIndex("OrderId")
|
||||
.HasDatabaseName("ix_redemptions_order_id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_redemptions_user_id");
|
||||
|
||||
b.HasIndex("VoucherId")
|
||||
.HasDatabaseName("ix_redemptions_voucher_id");
|
||||
|
||||
b.ToTable("redemptions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PromotionService.Domain.AggregatesModel.CampaignAggregate.Voucher", b =>
|
||||
{
|
||||
b.HasOne("PromotionService.Domain.AggregatesModel.CampaignAggregate.Campaign", null)
|
||||
.WithMany("Vouchers")
|
||||
.HasForeignKey("CampaignId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PromotionService.Domain.AggregatesModel.CampaignAggregate.Campaign", b =>
|
||||
{
|
||||
b.Navigation("Vouchers");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using PromotionService.Domain.AggregatesModel.SampleAggregate;
|
||||
using PromotionService.Domain.AggregatesModel.CampaignAggregate;
|
||||
using PromotionService.Domain.AggregatesModel.RedemptionAggregate;
|
||||
using PromotionService.Domain.SeedWork;
|
||||
using PromotionService.Infrastructure.EntityConfigurations;
|
||||
|
||||
@@ -17,10 +18,22 @@ public class PromotionServiceContext : DbContext, IUnitOfWork
|
||||
private IDbContextTransaction? _currentTransaction;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Samples table.
|
||||
/// VI: Bảng Samples.
|
||||
/// EN: Campaigns table.
|
||||
/// VI: Bảng Campaigns.
|
||||
/// </summary>
|
||||
public DbSet<Sample> Samples => Set<Sample>();
|
||||
public DbSet<Campaign> Campaigns => Set<Campaign>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Vouchers table.
|
||||
/// VI: Bảng Vouchers.
|
||||
/// </summary>
|
||||
public DbSet<Voucher> Vouchers => Set<Voucher>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Redemptions table.
|
||||
/// VI: Bảng Redemptions.
|
||||
/// </summary>
|
||||
public DbSet<Redemption> Redemptions => Set<Redemption>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Read-only access to current transaction.
|
||||
@@ -50,8 +63,9 @@ public class PromotionServiceContext : DbContext, IUnitOfWork
|
||||
{
|
||||
// EN: Apply entity configurations
|
||||
// VI: Áp dụng các cấu hình entity
|
||||
modelBuilder.ApplyConfiguration(new SampleEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new SampleStatusEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new CampaignEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new VoucherEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new RedemptionEntityTypeConfiguration());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PromotionService.Domain.AggregatesModel.CampaignAggregate;
|
||||
using PromotionService.Domain.SeedWork;
|
||||
|
||||
namespace PromotionService.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository implementation for Campaign aggregate.
|
||||
/// VI: Triển khai repository cho Campaign aggregate.
|
||||
/// </summary>
|
||||
public class CampaignRepository : ICampaignRepository
|
||||
{
|
||||
private readonly PromotionServiceContext _context;
|
||||
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public CampaignRepository(PromotionServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task<Campaign?> GetByIdAsync(Guid id)
|
||||
{
|
||||
return await _context.Campaigns
|
||||
.Include(c => c.Vouchers)
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Campaign>> GetByMerchantIdAsync(Guid merchantId)
|
||||
{
|
||||
return await _context.Campaigns
|
||||
.Where(c => c.MerchantId == merchantId)
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Campaign>> GetActiveAsync()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return await _context.Campaigns
|
||||
.Where(c => c.StatusId == CampaignStatus.Active.Id
|
||||
&& c.StartDate <= now
|
||||
&& c.EndDate >= now)
|
||||
.OrderBy(c => c.EndDate)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Voucher?> GetVoucherByCodeAsync(string code)
|
||||
{
|
||||
return await _context.Vouchers
|
||||
.FirstOrDefaultAsync(v => v.Code == code);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Voucher>> GetUserVouchersAsync(Guid userId)
|
||||
{
|
||||
return await _context.Vouchers
|
||||
.Where(v => v.OwnerId == userId)
|
||||
.OrderByDescending(v => v.ClaimedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public Campaign Add(Campaign campaign)
|
||||
{
|
||||
return _context.Campaigns.Add(campaign).Entity;
|
||||
}
|
||||
|
||||
public void Update(Campaign campaign)
|
||||
{
|
||||
_context.Entry(campaign).State = EntityState.Modified;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PromotionService.Domain.AggregatesModel.RedemptionAggregate;
|
||||
using PromotionService.Domain.SeedWork;
|
||||
|
||||
namespace PromotionService.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository implementation for Redemption aggregate.
|
||||
/// VI: Triển khai repository cho Redemption aggregate.
|
||||
/// </summary>
|
||||
public class RedemptionRepository : IRedemptionRepository
|
||||
{
|
||||
private readonly PromotionServiceContext _context;
|
||||
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public RedemptionRepository(PromotionServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Redemption>> GetByVoucherIdAsync(Guid voucherId)
|
||||
{
|
||||
return await _context.Redemptions
|
||||
.Where(r => r.VoucherId == voucherId)
|
||||
.OrderByDescending(r => r.RedeemedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Redemption>> GetByUserIdAsync(Guid userId)
|
||||
{
|
||||
return await _context.Redemptions
|
||||
.Where(r => r.UserId == userId)
|
||||
.OrderByDescending(r => r.RedeemedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Redemption>> GetByCampaignIdAsync(Guid campaignId)
|
||||
{
|
||||
return await _context.Redemptions
|
||||
.Where(r => r.CampaignId == campaignId)
|
||||
.OrderByDescending(r => r.RedeemedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public Redemption Add(Redemption redemption)
|
||||
{
|
||||
return _context.Redemptions.Add(redemption).Entity;
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PromotionService.Domain.AggregatesModel.SampleAggregate;
|
||||
using PromotionService.Domain.SeedWork;
|
||||
|
||||
namespace PromotionService.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository implementation for Sample aggregate.
|
||||
/// VI: Triển khai repository cho Sample aggregate.
|
||||
/// </summary>
|
||||
public class SampleRepository : ISampleRepository
|
||||
{
|
||||
private readonly PromotionServiceContext _context;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unit of work for transaction management.
|
||||
/// VI: Unit of work cho quản lý transaction.
|
||||
/// </summary>
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public SampleRepository(PromotionServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Sample?> GetAsync(Guid sampleId)
|
||||
{
|
||||
var sample = await _context.Samples
|
||||
.Include(s => s.Status)
|
||||
.FirstOrDefaultAsync(s => s.Id == sampleId);
|
||||
|
||||
return sample;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IEnumerable<Sample>> GetAllAsync()
|
||||
{
|
||||
return await _context.Samples
|
||||
.Include(s => s.Status)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Sample Add(Sample sample)
|
||||
{
|
||||
return _context.Samples.Add(sample).Entity;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Update(Sample sample)
|
||||
{
|
||||
_context.Entry(sample).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Delete(Sample sample)
|
||||
{
|
||||
_context.Samples.Remove(sample);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IEnumerable<Sample>> GetByStatusAsync(int statusId)
|
||||
{
|
||||
return await _context.Samples
|
||||
.Include(s => s.Status)
|
||||
.Where(s => s.StatusId == statusId)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using PromotionService.API.Application.Commands;
|
||||
using PromotionService.Domain.AggregatesModel.SampleAggregate;
|
||||
using PromotionService.Domain.SeedWork;
|
||||
using Xunit;
|
||||
|
||||
namespace PromotionService.UnitTests.Application;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unit tests for CreateSampleCommandHandler.
|
||||
/// VI: Unit tests cho CreateSampleCommandHandler.
|
||||
/// </summary>
|
||||
public class CreateSampleCommandHandlerTests
|
||||
{
|
||||
private readonly Mock<ISampleRepository> _mockRepository;
|
||||
private readonly Mock<ILogger<CreateSampleCommandHandler>> _mockLogger;
|
||||
private readonly CreateSampleCommandHandler _handler;
|
||||
|
||||
public CreateSampleCommandHandlerTests()
|
||||
{
|
||||
_mockRepository = new Mock<ISampleRepository>();
|
||||
_mockLogger = new Mock<ILogger<CreateSampleCommandHandler>>();
|
||||
|
||||
var mockUnitOfWork = new Mock<IUnitOfWork>();
|
||||
mockUnitOfWork.Setup(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
_mockRepository.SetupGet(r => r.UnitOfWork).Returns(mockUnitOfWork.Object);
|
||||
|
||||
_handler = new CreateSampleCommandHandler(_mockRepository.Object, _mockLogger.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidCommand_ShouldCreateSampleAndReturnId()
|
||||
{
|
||||
// Arrange
|
||||
var command = new CreateSampleCommand("Test Sample", "Test Description");
|
||||
|
||||
_mockRepository.Setup(r => r.Add(It.IsAny<Sample>()))
|
||||
.Returns((Sample s) => s);
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Id.Should().NotBeEmpty();
|
||||
_mockRepository.Verify(r => r.Add(It.IsAny<Sample>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidCommand_ShouldCallSaveEntities()
|
||||
{
|
||||
// Arrange
|
||||
var command = new CreateSampleCommand("Test Sample", null);
|
||||
|
||||
// Act
|
||||
await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_mockRepository.Verify(r => r.UnitOfWork.SaveEntitiesAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using PromotionService.Domain.AggregatesModel.SampleAggregate;
|
||||
using PromotionService.Domain.Exceptions;
|
||||
using Xunit;
|
||||
|
||||
namespace PromotionService.UnitTests.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unit tests for Sample aggregate.
|
||||
/// VI: Unit tests cho Sample aggregate.
|
||||
/// </summary>
|
||||
public class SampleAggregateTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateSample_WithValidName_ShouldCreateWithDraftStatus()
|
||||
{
|
||||
// Arrange
|
||||
var name = "Test Sample";
|
||||
var description = "Test Description";
|
||||
|
||||
// Act
|
||||
var sample = new Sample(name, description);
|
||||
|
||||
// Assert
|
||||
sample.Name.Should().Be(name);
|
||||
sample.Description.Should().Be(description);
|
||||
sample.Status.Should().Be(SampleStatus.Draft);
|
||||
sample.Id.Should().NotBeEmpty();
|
||||
sample.DomainEvents.Should().ContainSingle(); // SampleCreatedDomainEvent
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSample_WithEmptyName_ShouldThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var name = "";
|
||||
|
||||
// Act
|
||||
var act = () => new Sample(name);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<SampleDomainException>()
|
||||
.WithMessage("Sample name cannot be empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Activate_WhenDraft_ShouldChangeToActive()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.ClearDomainEvents();
|
||||
|
||||
// Act
|
||||
sample.Activate();
|
||||
|
||||
// Assert
|
||||
sample.Status.Should().Be(SampleStatus.Active);
|
||||
sample.DomainEvents.Should().ContainSingle(); // SampleStatusChangedDomainEvent
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Activate_WhenNotDraft_ShouldThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.Activate();
|
||||
|
||||
// Act
|
||||
var act = () => sample.Activate();
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<SampleDomainException>()
|
||||
.WithMessage("Only draft samples can be activated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Complete_WhenActive_ShouldChangeToCompleted()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.Activate();
|
||||
sample.ClearDomainEvents();
|
||||
|
||||
// Act
|
||||
sample.Complete();
|
||||
|
||||
// Assert
|
||||
sample.Status.Should().Be(SampleStatus.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_WhenDraftOrActive_ShouldChangeToCancelled()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
|
||||
// Act
|
||||
sample.Cancel();
|
||||
|
||||
// Assert
|
||||
sample.Status.Should().Be(SampleStatus.Cancelled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_WhenCompleted_ShouldThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.Activate();
|
||||
sample.Complete();
|
||||
|
||||
// Act
|
||||
var act = () => sample.Cancel();
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<SampleDomainException>()
|
||||
.WithMessage("Cannot cancel a completed sample");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_WhenNotCancelled_ShouldUpdateNameAndDescription()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Original Name", "Original Description");
|
||||
var newName = "Updated Name";
|
||||
var newDescription = "Updated Description";
|
||||
|
||||
// Act
|
||||
sample.Update(newName, newDescription);
|
||||
|
||||
// Assert
|
||||
sample.Name.Should().Be(newName);
|
||||
sample.Description.Should().Be(newDescription);
|
||||
sample.UpdatedAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_WhenCancelled_ShouldThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.Cancel();
|
||||
|
||||
// Act
|
||||
var act = () => sample.Update("New Name", null);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<SampleDomainException>()
|
||||
.WithMessage("Cannot update a cancelled sample");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user