feat: Add ClubMembershipCQ - Phase 3 Application Layer

- Implemented 3 Commands with handlers and validators:
  * ActivateClubMembership: Create/reactivate membership
  * DeactivateClubMembership: Deactivate existing membership
  * AssignClubFeature: Assign features to club members

- Implemented 3 Queries with handlers, validators, and DTOs:
  * GetClubMembership: Retrieve single membership by UserId
  * GetAllClubMemberships: List with filtering and pagination
  * GetClubMembershipHistory: Audit trail with full history

- All operations include history tracking via ClubMembershipHistory
- Property names aligned with Domain entity definitions
- 21 new files, ~600 lines of code
- Build successful with 0 errors
This commit is contained in:
masoodafar-web
2025-11-29 04:13:27 +03:30
parent fb911cd0fd
commit fe66d478ee
21 changed files with 732 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.ActivateClubMembership;
/// <summary>
/// Command برای فعال‌سازی عضویت باشگاه مشتریان یک کاربر
/// </summary>
public record ActivateClubMembershipCommand : IRequest<long>
{
/// <summary>
/// شناسه کاربر
/// </summary>
public long UserId { get; init; }
/// <summary>
/// تاریخ فعال‌سازی (اختیاری - پیش‌فرض: الان)
/// </summary>
public DateTimeOffset? ActivationDate { get; init; }
/// <summary>
/// دلیل فعال‌سازی (برای History)
/// </summary>
public string? Reason { get; init; }
}

View File

@@ -0,0 +1,84 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.ActivateClubMembership;
public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClubMembershipCommand, long>
{
private readonly IApplicationDbContext _context;
public ActivateClubMembershipCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<long> Handle(ActivateClubMembershipCommand request, CancellationToken cancellationToken)
{
// بررسی وجود کاربر
var userExists = await _context.Users
.AnyAsync(x => x.Id == request.UserId, cancellationToken);
if (!userExists)
{
throw new NotFoundException(nameof(User), request.UserId);
}
// بررسی عضویت فعلی
var existingMembership = await _context.ClubMemberships
.FirstOrDefaultAsync(x => x.UserId == request.UserId, cancellationToken);
ClubMembership entity;
bool isNewMembership = existingMembership == null;
var activationDate = request.ActivationDate ?? DateTimeOffset.UtcNow;
if (isNewMembership)
{
// ایجاد عضویت جدید
entity = new ClubMembership
{
UserId = request.UserId,
IsActive = true,
ActivatedAt = activationDate.DateTime,
InitialContribution = 0,
TotalEarned = 0
};
await _context.ClubMemberships.AddAsync(entity, cancellationToken);
}
else
{
// فعال‌سازی مجدد عضویت موجود
entity = existingMembership;
if (entity.IsActive)
{
// اگر از قبل فعال است، فقط تاریخ را به‌روز می‌کنیم
entity.ActivatedAt = activationDate.DateTime;
}
else
{
// فعال‌سازی عضویت غیرفعال
entity.IsActive = true;
entity.ActivatedAt = activationDate.DateTime;
}
_context.ClubMemberships.Update(entity);
}
await _context.SaveChangesAsync(cancellationToken);
// ثبت تاریخچه
var history = new ClubMembershipHistory
{
ClubMembershipId = entity.Id,
UserId = entity.UserId,
OldIsActive = !isNewMembership && !existingMembership!.IsActive,
NewIsActive = true,
Action = ClubMembershipAction.Activated,
Reason = request.Reason ?? (isNewMembership ? "Initial activation" : "Reactivated"),
PerformedBy = "System" // TODO: باید از Current User گرفته شود
};
await _context.ClubMembershipHistories.AddAsync(history, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return entity.Id;
}
}

View File

@@ -0,0 +1,34 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.ActivateClubMembership;
public class ActivateClubMembershipCommandValidator : AbstractValidator<ActivateClubMembershipCommand>
{
public ActivateClubMembershipCommandValidator()
{
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر معتبر نیست");
RuleFor(x => x.ActivationDate)
.LessThanOrEqualTo(DateTimeOffset.UtcNow.AddDays(1))
.WithMessage("تاریخ فعال‌سازی نمی‌تواند در آینده باشد")
.When(x => x.ActivationDate.HasValue);
RuleFor(x => x.Reason)
.MaximumLength(500)
.WithMessage("دلیل فعال‌سازی نمی‌تواند بیشتر از 500 کاراکتر باشد")
.When(x => !string.IsNullOrEmpty(x.Reason));
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<ActivateClubMembershipCommand>.CreateWithOptions(
(ActivateClubMembershipCommand)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}

View File

@@ -0,0 +1,27 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.AssignClubFeature;
/// <summary>
/// Command برای اختصاص Feature به عضو باشگاه
/// </summary>
public record AssignClubFeatureCommand : IRequest<long>
{
/// <summary>
/// شناسه کاربر
/// </summary>
public long UserId { get; init; }
/// <summary>
/// شناسه Feature
/// </summary>
public long FeatureId { get; init; }
/// <summary>
/// تاریخ اعطای Feature (اختیاری - پیش‌فرض: الان)
/// </summary>
public DateTime? GrantedAt { get; init; }
/// <summary>
/// یادداشت اختیاری
/// </summary>
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,68 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.AssignClubFeature;
public class AssignClubFeatureCommandHandler : IRequestHandler<AssignClubFeatureCommand, long>
{
private readonly IApplicationDbContext _context;
public AssignClubFeatureCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<long> Handle(AssignClubFeatureCommand request, CancellationToken cancellationToken)
{
// بررسی وجود عضویت فعال
var membership = await _context.ClubMemberships
.FirstOrDefaultAsync(x => x.UserId == request.UserId && x.IsActive, cancellationToken);
if (membership == null)
{
throw new NotFoundException(nameof(ClubMembership), $"Active membership for UserId: {request.UserId}");
}
// بررسی وجود Feature
var featureExists = await _context.ClubFeatures
.AnyAsync(x => x.Id == request.FeatureId && x.IsActive, cancellationToken);
if (!featureExists)
{
throw new NotFoundException(nameof(ClubFeature), request.FeatureId);
}
// بررسی وجود قبلی
var existingAssignment = await _context.UserClubFeatures
.FirstOrDefaultAsync(x =>
x.UserId == request.UserId &&
x.ClubFeatureId == request.FeatureId,
cancellationToken);
UserClubFeature entity;
if (existingAssignment != null)
{
// به‌روزرسانی notes
entity = existingAssignment;
entity.Notes = request.Notes;
_context.UserClubFeatures.Update(entity);
}
else
{
// ایجاد جدید
entity = new UserClubFeature
{
UserId = request.UserId,
ClubMembershipId = membership.Id,
ClubFeatureId = request.FeatureId,
GrantedAt = request.GrantedAt ?? DateTime.UtcNow,
Notes = request.Notes
};
await _context.UserClubFeatures.AddAsync(entity, cancellationToken);
}
await _context.SaveChangesAsync(cancellationToken);
return entity.Id;
}
}

View File

@@ -0,0 +1,33 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.AssignClubFeature;
public class AssignClubFeatureCommandValidator : AbstractValidator<AssignClubFeatureCommand>
{
public AssignClubFeatureCommandValidator()
{
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر معتبر نیست");
RuleFor(x => x.FeatureId)
.GreaterThan(0)
.WithMessage("شناسه Feature معتبر نیست");
RuleFor(x => x.Notes)
.MaximumLength(500)
.WithMessage("طول یادداشت نباید بیشتر از 500 کاراکتر باشد")
.When(x => !string.IsNullOrEmpty(x.Notes));
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<AssignClubFeatureCommand>.CreateWithOptions(
(AssignClubFeatureCommand)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}

View File

@@ -0,0 +1,17 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.DeactivateClubMembership;
/// <summary>
/// Command برای غیرفعال‌سازی عضویت باشگاه مشتریان
/// </summary>
public record DeactivateClubMembershipCommand : IRequest<Unit>
{
/// <summary>
/// شناسه کاربر
/// </summary>
public long UserId { get; init; }
/// <summary>
/// دلیل غیرفعال‌سازی
/// </summary>
public string? Reason { get; init; }
}

View File

@@ -0,0 +1,50 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.DeactivateClubMembership;
public class DeactivateClubMembershipCommandHandler : IRequestHandler<DeactivateClubMembershipCommand, Unit>
{
private readonly IApplicationDbContext _context;
public DeactivateClubMembershipCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<Unit> Handle(DeactivateClubMembershipCommand request, CancellationToken cancellationToken)
{
var membership = await _context.ClubMemberships
.FirstOrDefaultAsync(x => x.UserId == request.UserId, cancellationToken);
if (membership == null)
{
throw new NotFoundException(nameof(ClubMembership), $"UserId: {request.UserId}");
}
// اگر از قبل غیرفعال است، هیچ کاری نکن
if (!membership.IsActive)
{
return Unit.Value;
}
membership.IsActive = false;
_context.ClubMemberships.Update(membership);
await _context.SaveChangesAsync(cancellationToken);
// ثبت تاریخچه
var history = new ClubMembershipHistory
{
ClubMembershipId = membership.Id,
UserId = membership.UserId,
OldIsActive = true,
NewIsActive = false,
Action = ClubMembershipAction.Deactivated,
Reason = request.Reason ?? "Membership deactivated",
PerformedBy = "System" // TODO: باید از Current User گرفته شود
};
await _context.ClubMembershipHistories.AddAsync(history, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return Unit.Value;
}
}

View File

@@ -0,0 +1,29 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.DeactivateClubMembership;
public class DeactivateClubMembershipCommandValidator : AbstractValidator<DeactivateClubMembershipCommand>
{
public DeactivateClubMembershipCommandValidator()
{
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر معتبر نیست");
RuleFor(x => x.Reason)
.MaximumLength(500)
.WithMessage("دلیل غیرفعال‌سازی نمی‌تواند بیشتر از 500 کاراکتر باشد")
.When(x => !string.IsNullOrEmpty(x.Reason));
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<DeactivateClubMembershipCommand>.CreateWithOptions(
(DeactivateClubMembershipCommand)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}