feat: add geography entities with countries, states and cities

This commit is contained in:
masoodafar-web
2025-12-18 00:43:55 +03:30
parent 26e1243cfe
commit ead0cf4235
40 changed files with 5386 additions and 7 deletions
@@ -12,6 +12,7 @@
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" />
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.6.10" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,42 @@
namespace CMSMicroservice.Application.CityCQ.Queries.GetAllCitiesByFilter;
public record GetAllCitiesByFilterQuery : IRequest<GetAllCitiesByFilterResponseDto>
{
/// <summary>
/// موقعیت صفحه بندی
/// </summary>
public PaginationState? PaginationState { get; init; }
/// <summary>
/// مرتب سازی بر اساس
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// فیلتر
/// </summary>
public GetAllCitiesByFilterFilter? Filter { get; init; }
}
public class GetAllCitiesByFilterFilter
{
/// <summary>
/// شناسه
/// </summary>
public long? Id { get; set; }
/// <summary>
/// نام شهر (Contains)
/// </summary>
public string? Name { get; set; }
/// <summary>
/// نام بومی شهر (Contains)
/// </summary>
public string? Native { get; set; }
/// <summary>
/// شناسه استان
/// </summary>
public long? StateId { get; set; }
}
@@ -0,0 +1,43 @@
using CMSMicroservice.Application.Common.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.CityCQ.Queries.GetAllCitiesByFilter;
public class GetAllCitiesByFilterQueryHandler : IRequestHandler<GetAllCitiesByFilterQuery, GetAllCitiesByFilterResponseDto>
{
private readonly IApplicationDbContext _context;
public GetAllCitiesByFilterQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetAllCitiesByFilterResponseDto> Handle(
GetAllCitiesByFilterQuery request,
CancellationToken cancellationToken)
{
var query = _context.Cities
.Include(c => c.State)
.ApplyOrder(sortBy: request.SortBy)
.AsNoTracking()
.AsQueryable();
if (request.Filter is not null)
{
query = query
.Where(x => request.Filter.Id == null || x.Id == request.Filter.Id)
.Where(x => request.Filter.Name == null || x.Name.Contains(request.Filter.Name))
.Where(x => request.Filter.Native == null || x.Native.Contains(request.Filter.Native))
.Where(x => request.Filter.StateId == null || x.StateId == request.Filter.StateId);
}
return new GetAllCitiesByFilterResponseDto
{
MetaData = await query.GetMetaData(request.PaginationState, cancellationToken),
Models = await query
.PaginatedListAsync(paginationState: request.PaginationState)
.ProjectToType<GetAllCitiesByFilterResponseModel>()
.ToListAsync(cancellationToken)
};
}
}
@@ -0,0 +1,20 @@
using FluentValidation;
namespace CMSMicroservice.Application.CityCQ.Queries.GetAllCitiesByFilter;
public class GetAllCitiesByFilterQueryValidator : AbstractValidator<GetAllCitiesByFilterQuery>
{
public GetAllCitiesByFilterQueryValidator()
{
// Validation rules اگر نیاز باشد
}
public async Task<bool> Validate(object model, string propertyName)
{
var result = await ValidateAsync(
ValidationContext<GetAllCitiesByFilterQuery>.CreateWithOptions(
(GetAllCitiesByFilterQuery)model,
x => x.IncludeProperties(propertyName)));
return result.IsValid;
}
}
@@ -0,0 +1,62 @@
namespace CMSMicroservice.Application.CityCQ.Queries.GetAllCitiesByFilter;
public class GetAllCitiesByFilterResponseDto
{
/// <summary>
/// متادیتا
/// </summary>
public MetaData MetaData { get; set; } = null!;
/// <summary>
/// مدل خروجی
/// </summary>
public List<GetAllCitiesByFilterResponseModel>? Models { get; set; }
}
public class GetAllCitiesByFilterResponseModel
{
/// <summary>
/// شناسه
/// </summary>
public long Id { get; set; }
/// <summary>
/// شناسه خارجی
/// </summary>
public long ExternalId { get; set; }
/// <summary>
/// نام شهر
/// </summary>
public string Name { get; set; } = null!;
/// <summary>
/// نام بومی شهر (فارسی)
/// </summary>
public string Native { get; set; } = null!;
/// <summary>
/// عرض جغرافیایی
/// </summary>
public string Latitude { get; set; } = null!;
/// <summary>
/// طول جغرافیایی
/// </summary>
public string Longitude { get; set; } = null!;
/// <summary>
/// شناسه استان
/// </summary>
public long StateId { get; set; }
/// <summary>
/// نام استان
/// </summary>
public string StateName { get; set; } = null!;
/// <summary>
/// نام بومی استان
/// </summary>
public string StateNative { get; set; } = null!;
}
@@ -0,0 +1,48 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.AcceptClubMembershipContract;
/// <summary>
/// Command برای پذیرش قرارداد باشگاه مشتریان
/// </summary>
public record AcceptClubMembershipContractCommand : IRequest<AcceptClubMembershipContractResponseDto>
{
/// <summary>
/// شناسه کاربر
/// </summary>
public long UserId { get; init; }
/// <summary>
/// کد OTP برای تایید
/// </summary>
public string OtpCode { get; init; }
/// <summary>
/// شناسه یکتای امضا (GUID)
/// </summary>
public string SignGuid { get; init; }
/// <summary>
/// محتوای HTML قرارداد
/// </summary>
public string ContractHtml { get; init; }
}
/// <summary>
/// DTO پاسخ پذیرش قرارداد باشگاه مشتریان
/// </summary>
public class AcceptClubMembershipContractResponseDto
{
/// <summary>
/// آیا عملیات موفق بود؟
/// </summary>
public bool Success { get; set; }
/// <summary>
/// پیام نتیجه
/// </summary>
public string Message { get; set; }
/// <summary>
/// شناسه قرارداد ثبت شده
/// </summary>
public long ContractId { get; set; }
}
@@ -0,0 +1,196 @@
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Enums;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.AcceptClubMembershipContract;
/// <summary>
/// Handler برای پذیرش قرارداد باشگاه مشتریان
/// این handler:
/// 1. کد OTP را تایید می‌کند
/// 2. قرارداد را در جدول UserContract ثبت می‌کند
/// 3. باشگاه مشتری را فعال می‌کند (IsActive = true)
/// </summary>
public class AcceptClubMembershipContractCommandHandler
: IRequestHandler<AcceptClubMembershipContractCommand, AcceptClubMembershipContractResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IConfiguration _cfg;
private readonly IHashService _hashService;
private readonly ILogger<AcceptClubMembershipContractCommandHandler> _logger;
private const int MaxAttempts = 5;
private const string OtpPurpose = "signClubContract";
public AcceptClubMembershipContractCommandHandler(
IApplicationDbContext context,
IConfiguration cfg,
IHashService hashService,
ILogger<AcceptClubMembershipContractCommandHandler> logger)
{
_context = context;
_cfg = cfg;
_hashService = hashService;
_logger = logger;
}
public async Task<AcceptClubMembershipContractResponseDto> Handle(
AcceptClubMembershipContractCommand request,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"Processing club membership contract for UserId: {UserId}",
request.UserId
);
// 1. دریافت کاربر
var user = await _context.Users
.Include(u => u.ClubMembership)
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user == null)
{
_logger.LogWarning("User not found: {UserId}", request.UserId);
throw new NotFoundException(nameof(User), request.UserId);
}
// 2. بررسی خرید پکیج
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
{
return new AcceptClubMembershipContractResponseDto
{
Success = false,
Message = "برای امضای قرارداد باشگاه مشتریان ابتدا باید پکیج پایه را خریداری کنید"
};
}
// 3. بررسی عدم فعال بودن قبلی باشگاه
if (user.ClubMembership?.IsActive == true)
{
return new AcceptClubMembershipContractResponseDto
{
Success = false,
Message = "شما قبلاً عضو باشگاه مشتریان شده‌اید"
};
}
// 4. تایید OTP
var otpResult = await VerifyOtpAsync(user.Mobile, request.OtpCode, cancellationToken);
if (!otpResult.Success)
{
return new AcceptClubMembershipContractResponseDto
{
Success = false,
Message = otpResult.Message
};
}
// 5. ثبت قرارداد در جدول UserContract
var contract = await _context.Contracts
.FirstOrDefaultAsync(c => c.Type == ContractType.ClubMembership, cancellationToken);
if (contract == null)
{
// اگر قرارداد وجود ندارد، یک قرارداد پیش‌فرض ایجاد کنید
contract = new Contract
{
Title = "قرارداد باشگاه مشتریان",
Description = "قوانین و مقررات باشگاه مشتریان کارابازار",
HtmlContent = request.ContractHtml,
Type = ContractType.ClubMembership
};
await _context.Contracts.AddAsync(contract, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
}
var userContract = new UserContract
{
UserId = user.Id,
ContractId = contract.Id,
SignGuid = request.SignGuid,
SignedPdfFile = request.ContractHtml
};
await _context.UserContracts.AddAsync(userContract, cancellationToken);
// 6. فعال‌سازی باشگاه مشتریان
if (user.ClubMembership == null)
{
user.ClubMembership = new Domain.Entities.Club.ClubMembership
{
UserId = user.Id,
IsActive = true,
ActivatedAt = DateTime.Now,
InitialContribution = 56_000_000,
GiftValue = 25_200_000,
PurchaseMethod = user.PackagePurchaseMethod
};
await _context.ClubMemberships.AddAsync(user.ClubMembership, cancellationToken);
}
else
{
user.ClubMembership.IsActive = true;
user.ClubMembership.ActivatedAt = DateTime.Now;
_context.ClubMemberships.Update(user.ClubMembership);
}
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Club membership contract accepted and activated for UserId: {UserId}, ContractId: {ContractId}",
request.UserId,
userContract.Id
);
return new AcceptClubMembershipContractResponseDto
{
Success = true,
Message = "قرارداد باشگاه مشتریان با موفقیت ثبت شد و عضویت شما فعال گردید",
ContractId = userContract.Id
};
}
private async Task<(bool Success, string Message)> VerifyOtpAsync(
string mobile,
string code,
CancellationToken cancellationToken)
{
var normalizedMobile = mobile.NormalizeIranMobile();
var now = DateTime.Now;
var otp = await _context.OtpTokens
.Where(o => o.Mobile == normalizedMobile
&& o.Purpose == OtpPurpose
&& !o.IsUsed
&& o.ExpiresAt > now)
.OrderByDescending(o => o.Created)
.FirstOrDefaultAsync(cancellationToken);
if (otp == null)
{
return (false, "کد تایید پیدا نشد یا منقضی شده است");
}
if (otp.Attempts >= MaxAttempts)
{
return (false, "تعداد تلاش‌ها بیش از حد مجاز است. لطفاً کد جدید دریافت کنید");
}
otp.Attempts++;
var secret = _cfg["Otp:Secret"] ?? throw new InvalidOperationException("Otp:Secret not configured");
if (!_hashService.VerifyHmacSha256Hex(code, otp.CodeHash, secret))
{
await _context.SaveChangesAsync(cancellationToken);
return (false, "کد تایید نادرست است");
}
// کد صحیح است - علامت‌گذاری به عنوان استفاده شده
otp.IsUsed = true;
await _context.SaveChangesAsync(cancellationToken);
return (true, "کد تایید صحیح است");
}
}
@@ -0,0 +1,25 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Commands.AcceptClubMembershipContract;
public class AcceptClubMembershipContractCommandValidator : AbstractValidator<AcceptClubMembershipContractCommand>
{
public AcceptClubMembershipContractCommandValidator()
{
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر الزامی است");
RuleFor(x => x.OtpCode)
.NotEmpty()
.WithMessage("کد تایید الزامی است")
.Length(6)
.WithMessage("کد تایید باید ۶ رقم باشد");
RuleFor(x => x.SignGuid)
.NotEmpty()
.WithMessage("شناسه امضا الزامی است");
RuleFor(x => x.ContractHtml)
.NotEmpty()
.WithMessage("محتوای قرارداد الزامی است");
}
}
@@ -1,6 +1,7 @@
using CMSMicroservice.Domain.Entities.Payment;
using CMSMicroservice.Domain.Entities.Order;
using CMSMicroservice.Domain.Entities.DiscountShop;
using CMSMicroservice.Domain.Entities.Geography;
namespace CMSMicroservice.Application.Common.Interfaces;
@@ -53,5 +54,10 @@ public interface IApplicationDbContext
DbSet<DiscountOrder> DiscountOrders { get; }
DbSet<DiscountOrderDetail> DiscountOrderDetails { get; }
// ============= Geography =============
DbSet<Country> Countries { get; }
DbSet<State> States { get; }
DbSet<City> Cities { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,26 @@
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// Interface for sending token-related notifications via SignalR
/// </summary>
public interface ITokenNotificationService
{
/// <summary>
/// Notify that a user's token has been revoked and they should refresh their token
/// </summary>
/// <param name="userId">The user ID whose token was revoked</param>
/// <param name="reason">The reason for revocation</param>
Task NotifyTokenRevokedAsync(long userId, string reason);
/// <summary>
/// Notify that a user should refresh their token due to profile changes
/// </summary>
/// <param name="userId">The user ID whose profile changed</param>
Task NotifyForceRefreshAsync(long userId);
/// <summary>
/// Broadcast a message to all connected clients
/// </summary>
/// <param name="message">The message to broadcast</param>
Task BroadcastMessageAsync(string message);
}
@@ -0,0 +1,22 @@
using CMSMicroservice.Application.CityCQ.Queries.GetAllCitiesByFilter;
using CMSMicroservice.Domain.Entities.Geography;
using Mapster;
namespace CMSMicroservice.Application.Profiles;
public class CityProfile : IRegister
{
public void Register(TypeAdapterConfig config)
{
config.NewConfig<City, GetAllCitiesByFilterResponseModel>()
.Map(dest => dest.Id, src => src.Id)
.Map(dest => dest.ExternalId, src => src.ExternalId)
.Map(dest => dest.Name, src => src.Name)
.Map(dest => dest.Native, src => src.Native)
.Map(dest => dest.Latitude, src => src.Latitude)
.Map(dest => dest.Longitude, src => src.Longitude)
.Map(dest => dest.StateId, src => src.StateId)
.Map(dest => dest.StateName, src => src.State.Name)
.Map(dest => dest.StateNative, src => src.State.Native);
}
}
@@ -0,0 +1,14 @@
using MediatR;
namespace CMSMicroservice.Application.UserCQ.Commands.RefreshToken;
/// <summary>
/// درخواست رفرش توکن JWT
/// </summary>
public sealed record RefreshTokenCommand : IRequest<RefreshTokenResponseDto>
{
/// <summary>
/// توکن فعلی کاربر
/// </summary>
public string CurrentToken { get; init; } = null!;
}
@@ -0,0 +1,82 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.UserCQ.Commands.RefreshToken;
/// <summary>
/// هندلر رفرش توکن - توکن جدید با آخرین اطلاعات کاربر تولید می‌کند
/// </summary>
public class RefreshTokenCommandHandler : IRequestHandler<RefreshTokenCommand, RefreshTokenResponseDto>
{
private readonly IApplicationDbContext _context;
private readonly IGenerateJwtToken _generateJwt;
public RefreshTokenCommandHandler(IApplicationDbContext context, IGenerateJwtToken generateJwt)
{
_context = context;
_generateJwt = generateJwt;
}
public async Task<RefreshTokenResponseDto> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
{
try
{
// Extract user id from current token
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(request.CurrentToken);
var userIdClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier);
if (userIdClaim == null || !long.TryParse(userIdClaim.Value, out var userId))
{
return new RefreshTokenResponseDto
{
Success = false,
Message = "توکن نامعتبر است",
Token = string.Empty
};
}
// Get user with all required relations
var user = await _context.Users
.Include(u => u.UserContracts)
.ThenInclude(u => u.Contract)
.Include(u => u.UserRoles)
.ThenInclude(ur => ur.Role)
.Include(u => u.ClubMembership)
.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
if (user == null)
{
return new RefreshTokenResponseDto
{
Success = false,
Message = "کاربر یافت نشد",
Token = string.Empty
};
}
// Generate new token
var newToken = await _generateJwt.GenerateJwtToken(user);
return new RefreshTokenResponseDto
{
Success = true,
Message = "توکن با موفقیت رفرش شد",
Token = newToken
};
}
catch (Exception ex)
{
return new RefreshTokenResponseDto
{
Success = false,
Message = $"خطا در رفرش توکن: {ex.Message}",
Token = string.Empty
};
}
}
}
@@ -0,0 +1,22 @@
namespace CMSMicroservice.Application.UserCQ.Commands.RefreshToken;
/// <summary>
/// پاسخ رفرش توکن
/// </summary>
public class RefreshTokenResponseDto
{
/// <summary>
/// توکن جدید
/// </summary>
public string Token { get; set; } = null!;
/// <summary>
/// آیا عملیات موفق بود؟
/// </summary>
public bool Success { get; set; }
/// <summary>
/// پیام
/// </summary>
public string Message { get; set; } = null!;
}
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Events;
using Microsoft.Extensions.Logging;
@@ -6,16 +7,23 @@ namespace CMSMicroservice.Application.UserCQ.EventHandlers;
public class UpdateUserEventHandler : INotificationHandler<UpdateUserEvent>
{
private readonly ILogger<UpdateUserEventHandler> _logger;
private readonly ITokenNotificationService _tokenNotificationService;
public UpdateUserEventHandler(ILogger<UpdateUserEventHandler> logger)
public UpdateUserEventHandler(
ILogger<UpdateUserEventHandler> logger,
ITokenNotificationService tokenNotificationService)
{
_logger = logger;
_tokenNotificationService = tokenNotificationService;
}
public Task Handle(UpdateUserEvent notification, CancellationToken cancellationToken)
public async Task Handle(UpdateUserEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Domain Event: {DomainEvent}", notification.GetType().Name);
_logger.LogInformation("Domain Event: {DomainEvent} for User {UserId}",
notification.GetType().Name,
notification.Item.Id);
return Task.CompletedTask;
// Notify connected clients that user profile has changed and token should be refreshed
await _tokenNotificationService.NotifyForceRefreshAsync(notification.Item.Id);
}
}