feat: Add NetworkMembershipCQ - Phase 4 Application Layer

- Implemented 3 Commands with handlers and validators:
  * JoinNetworkCommand: Add user to binary network tree
    - Validates parent exists and is in network
    - Validates leg position is empty
    - Records history with NetworkMembershipAction.Join
  * MoveInNetworkCommand: Move user to different position
    - Validates new parent and leg availability
    - Prevents circular dependencies (IsDescendant check)
    - Records old/new parent and leg in history
  * RemoveFromNetworkCommand: Remove user from network
    - Validates no children exist (must move/remove first)
    - Soft delete (sets NetworkParentId to null)
    - Idempotent design

- Implemented 3 Queries with handlers, validators, and DTOs:
  * GetNetworkTreeQuery: Binary tree visualization
    - Recursive tree building with MaxDepth limit (1-10)
    - Returns nested structure with Left/Right children
  * GetUserNetworkPositionQuery: User position details
    - Parent info, leg position, children counts
    - Left/Right child counts for balance view
  * GetNetworkMembershipHistoryQuery: Complete audit trail
    - Filter by UserId, pagination support
    - Shows Join/Move/Remove actions with full details

- All operations include complete history tracking
- Binary tree validation (parent-child relationships)
- Circular dependency prevention in MoveInNetwork
- 21 new files, ~850 lines of code
- Build successful with 0 errors
This commit is contained in:
masoodafar-web
2025-11-29 04:19:40 +03:30
parent b21dda515e
commit db96a02f89
21 changed files with 813 additions and 0 deletions
@@ -0,0 +1,22 @@
namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkMembershipHistory;
/// <summary>
/// Query برای دریافت تاریخچه تغییرات شبکه یک کاربر
/// </summary>
public record GetNetworkMembershipHistoryQuery : IRequest<GetNetworkMembershipHistoryResponseDto>
{
/// <summary>
/// شناسه کاربر (اختیاری)
/// </summary>
public long? UserId { get; init; }
/// <summary>
/// مرتب‌سازی (پیش‌فرض: -Created)
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// Pagination
/// </summary>
public PaginationState? PaginationState { get; init; }
}
@@ -0,0 +1,50 @@
namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkMembershipHistory;
public class GetNetworkMembershipHistoryQueryHandler : IRequestHandler<GetNetworkMembershipHistoryQuery, GetNetworkMembershipHistoryResponseDto>
{
private readonly IApplicationDbContext _context;
public GetNetworkMembershipHistoryQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetNetworkMembershipHistoryResponseDto> Handle(GetNetworkMembershipHistoryQuery request, CancellationToken cancellationToken)
{
var query = _context.NetworkMembershipHistories
.AsNoTracking()
.AsQueryable();
if (request.UserId.HasValue)
{
query = query.Where(x => x.UserId == request.UserId.Value);
}
query = query.ApplyOrder(sortBy: request.SortBy ?? "-Created");
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
var models = await query
.PaginatedListAsync(paginationState: request.PaginationState)
.Select(x => new GetNetworkMembershipHistoryResponseModel
{
Id = x.Id,
UserId = x.UserId,
OldParentId = x.OldParentId,
NewParentId = x.NewParentId,
OldLegPosition = x.OldLegPosition,
NewLegPosition = x.NewLegPosition,
Action = x.Action,
Reason = x.Reason,
PerformedBy = x.PerformedBy,
Created = x.Created
})
.ToListAsync(cancellationToken);
return new GetNetworkMembershipHistoryResponseDto
{
MetaData = meta,
Models = models
};
}
}
@@ -0,0 +1,25 @@
namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkMembershipHistory;
public class GetNetworkMembershipHistoryQueryValidator : AbstractValidator<GetNetworkMembershipHistoryQuery>
{
public GetNetworkMembershipHistoryQueryValidator()
{
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر معتبر نیست")
.When(x => x.UserId.HasValue);
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<GetNetworkMembershipHistoryQuery>.CreateWithOptions(
(GetNetworkMembershipHistoryQuery)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}
@@ -0,0 +1,21 @@
namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkMembershipHistory;
public class GetNetworkMembershipHistoryResponseDto
{
public MetaData MetaData { get; set; }
public List<GetNetworkMembershipHistoryResponseModel> Models { get; set; }
}
public class GetNetworkMembershipHistoryResponseModel
{
public long Id { get; set; }
public long UserId { get; set; }
public long? OldParentId { get; set; }
public long? NewParentId { get; set; }
public NetworkLeg? OldLegPosition { get; set; }
public NetworkLeg? NewLegPosition { get; set; }
public NetworkMembershipAction Action { get; set; }
public string? Reason { get; set; }
public string? PerformedBy { get; set; }
public DateTimeOffset Created { get; set; }
}
@@ -0,0 +1,17 @@
namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkTree;
/// <summary>
/// Query برای دریافت درخت شبکه از یک کاربر (Binary Tree)
/// </summary>
public record GetNetworkTreeQuery : IRequest<NetworkTreeDto?>
{
/// <summary>
/// شناسه کاربر که می‌خواهیم درخت زیرمجموعه او را ببینیم
/// </summary>
public long UserId { get; init; }
/// <summary>
/// تعداد سطوح (Depth) که می‌خواهیم نمایش دهیم (پیش‌فرض: 3)
/// </summary>
public int MaxDepth { get; init; } = 3;
}
@@ -0,0 +1,78 @@
namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkTree;
public class GetNetworkTreeQueryHandler : IRequestHandler<GetNetworkTreeQuery, NetworkTreeDto?>
{
private readonly IApplicationDbContext _context;
public GetNetworkTreeQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<NetworkTreeDto?> Handle(GetNetworkTreeQuery request, CancellationToken cancellationToken)
{
var rootUser = await _context.Users
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == request.UserId, cancellationToken);
if (rootUser == null)
{
return null;
}
var tree = await BuildTree(rootUser.Id, request.MaxDepth, 0, cancellationToken);
return tree;
}
private async Task<NetworkTreeDto> BuildTree(long userId, int maxDepth, int currentDepth, CancellationToken cancellationToken)
{
var user = await _context.Users
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
if (user == null)
{
throw new NotFoundException(nameof(User), userId);
}
var node = new NetworkTreeDto
{
UserId = user.Id,
Mobile = user.Mobile,
FirstName = user.FirstName,
LastName = user.LastName,
LegPosition = user.LegPosition,
CurrentDepth = currentDepth
};
// اگر به حداکثر عمق رسیدیم، دیگر فرزندان را نمی‌خوانیم
if (currentDepth >= maxDepth)
{
return node;
}
// پیدا کردن فرزند چپ
var leftChild = await _context.Users
.AsNoTracking()
.FirstOrDefaultAsync(x => x.NetworkParentId == userId && x.LegPosition == NetworkLeg.Left,
cancellationToken);
if (leftChild != null)
{
node.LeftChild = await BuildTree(leftChild.Id, maxDepth, currentDepth + 1, cancellationToken);
}
// پیدا کردن فرزند راست
var rightChild = await _context.Users
.AsNoTracking()
.FirstOrDefaultAsync(x => x.NetworkParentId == userId && x.LegPosition == NetworkLeg.Right,
cancellationToken);
if (rightChild != null)
{
node.RightChild = await BuildTree(rightChild.Id, maxDepth, currentDepth + 1, cancellationToken);
}
return node;
}
}
@@ -0,0 +1,28 @@
namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkTree;
public class GetNetworkTreeQueryValidator : AbstractValidator<GetNetworkTreeQuery>
{
public GetNetworkTreeQueryValidator()
{
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر معتبر نیست");
RuleFor(x => x.MaxDepth)
.InclusiveBetween(1, 10)
.WithMessage("عمق درخت باید بین 1 تا 10 باشد");
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<GetNetworkTreeQuery>.CreateWithOptions(
(GetNetworkTreeQuery)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}
@@ -0,0 +1,16 @@
namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkTree;
/// <summary>
/// DTO برای نمایش درخت دوتایی شبکه
/// </summary>
public class NetworkTreeDto
{
public long UserId { get; set; }
public string? Mobile { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public NetworkLeg? LegPosition { get; set; }
public int CurrentDepth { get; set; }
public NetworkTreeDto? LeftChild { get; set; }
public NetworkTreeDto? RightChild { get; set; }
}
@@ -0,0 +1,12 @@
namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetUserNetworkPosition;
/// <summary>
/// Query برای دریافت موقعیت کاربر در شبکه
/// </summary>
public record GetUserNetworkPositionQuery : IRequest<UserNetworkPositionDto?>
{
/// <summary>
/// شناسه کاربر
/// </summary>
public long UserId { get; init; }
}
@@ -0,0 +1,70 @@
namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetUserNetworkPosition;
public class GetUserNetworkPositionQueryHandler : IRequestHandler<GetUserNetworkPositionQuery, UserNetworkPositionDto?>
{
private readonly IApplicationDbContext _context;
public GetUserNetworkPositionQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<UserNetworkPositionDto?> Handle(GetUserNetworkPositionQuery request, CancellationToken cancellationToken)
{
var user = await _context.Users
.AsNoTracking()
.Where(x => x.Id == request.UserId)
.Select(x => new
{
x.Id,
x.Mobile,
x.FirstName,
x.LastName,
x.NetworkParentId,
x.LegPosition
})
.FirstOrDefaultAsync(cancellationToken);
if (user == null)
{
return null;
}
// شمارش فرزندان
var childrenCount = await _context.Users
.CountAsync(x => x.NetworkParentId == request.UserId, cancellationToken);
var leftChildCount = await _context.Users
.CountAsync(x => x.NetworkParentId == request.UserId && x.LegPosition == NetworkLeg.Left,
cancellationToken);
var rightChildCount = await _context.Users
.CountAsync(x => x.NetworkParentId == request.UserId && x.LegPosition == NetworkLeg.Right,
cancellationToken);
// اطلاعات والد
string? parentMobile = null;
if (user.NetworkParentId.HasValue)
{
parentMobile = await _context.Users
.Where(x => x.Id == user.NetworkParentId)
.Select(x => x.Mobile)
.FirstOrDefaultAsync(cancellationToken);
}
return new UserNetworkPositionDto
{
UserId = user.Id,
Mobile = user.Mobile,
FirstName = user.FirstName,
LastName = user.LastName,
NetworkParentId = user.NetworkParentId,
ParentMobile = parentMobile,
LegPosition = user.LegPosition,
TotalChildren = childrenCount,
LeftChildCount = leftChildCount,
RightChildCount = rightChildCount,
IsInNetwork = user.NetworkParentId.HasValue
};
}
}
@@ -0,0 +1,24 @@
namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetUserNetworkPosition;
public class GetUserNetworkPositionQueryValidator : AbstractValidator<GetUserNetworkPositionQuery>
{
public GetUserNetworkPositionQueryValidator()
{
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر معتبر نیست");
}
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<GetUserNetworkPositionQuery>.CreateWithOptions(
(GetUserNetworkPositionQuery)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
}
@@ -0,0 +1,19 @@
namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetUserNetworkPosition;
/// <summary>
/// DTO برای نمایش موقعیت کاربر در شبکه
/// </summary>
public class UserNetworkPositionDto
{
public long UserId { get; set; }
public string? Mobile { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public long? NetworkParentId { get; set; }
public string? ParentMobile { get; set; }
public NetworkLeg? LegPosition { get; set; }
public int TotalChildren { get; set; }
public int LeftChildCount { get; set; }
public int RightChildCount { get; set; }
public bool IsInNetwork { get; set; }
}