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);
}
}
@@ -0,0 +1,49 @@
using CMSMicroservice.Domain.Common;
namespace CMSMicroservice.Domain.Entities.Geography;
/// <summary>
/// شهر - City
/// </summary>
public class City : BaseAuditableEntity
{
/// <summary>
/// شناسه یکتا
/// </summary>
public long Id { get; set; }
/// <summary>
/// شناسه خارجی (External ID)
/// </summary>
public long ExternalId { get; set; }
/// <summary>
/// نام شهر
/// </summary>
public required string Name { get; set; }
/// <summary>
/// عرض جغرافیایی
/// </summary>
public required string Latitude { get; set; }
/// <summary>
/// طول جغرافیایی
/// </summary>
public required string Longitude { get; set; }
/// <summary>
/// نام بومی شهر (فارسی یا زبان محلی)
/// </summary>
public required string Native { get; set; }
/// <summary>
/// شناسه استان متعلق
/// </summary>
public long StateId { get; set; }
/// <summary>
/// استان متعلق
/// </summary>
public virtual State State { get; set; } = null!;
}
@@ -0,0 +1,109 @@
using CMSMicroservice.Domain.Common;
namespace CMSMicroservice.Domain.Entities.Geography;
/// <summary>
/// کشور - Country
/// </summary>
public class Country : BaseAuditableEntity
{
/// <summary>
/// شناسه یکتا
/// </summary>
public long Id { get; set; }
/// <summary>
/// شناسه خارجی (External ID)
/// </summary>
public long ExternalId { get; set; }
/// <summary>
/// نام کشور (انگلیسی)
/// </summary>
public required string Name { get; set; }
/// <summary>
/// کد ISO3 (3 حرفی)
/// </summary>
public required string Iso3 { get; set; }
/// <summary>
/// کد ISO2 (2 حرفی)
/// </summary>
public required string Iso2 { get; set; }
/// <summary>
/// کد عددی کشور
/// </summary>
public required string NumericCode { get; set; }
/// <summary>
/// کد تلفن کشور (مثلاً 98 برای ایران)
/// </summary>
public required string PhoneCode { get; set; }
/// <summary>
/// پایتخت
/// </summary>
public required string Capital { get; set; }
/// <summary>
/// واحد پول (کد ارز)
/// </summary>
public required string Currency { get; set; }
/// <summary>
/// نام واحد پول
/// </summary>
public required string CurrencyName { get; set; }
/// <summary>
/// نماد واحد پول
/// </summary>
public required string CurrencySymbol { get; set; }
/// <summary>
/// Top-Level Domain (مثلاً .ir)
/// </summary>
public required string Tld { get; set; }
/// <summary>
/// نام بومی کشور (فارسی یا زبان محلی)
/// </summary>
public required string Native { get; set; }
/// <summary>
/// منطقه جغرافیایی (مثلاً Asia)
/// </summary>
public required string Region { get; set; }
/// <summary>
/// زیرمنطقه (مثلاً Southern Asia)
/// </summary>
public required string Subregion { get; set; }
/// <summary>
/// عرض جغرافیایی
/// </summary>
public required string Latitude { get; set; }
/// <summary>
/// طول جغرافیایی
/// </summary>
public required string Longitude { get; set; }
/// <summary>
/// ایموجی پرچم کشور
/// </summary>
public required string Emoji { get; set; }
/// <summary>
/// کد یونیکد ایموجی
/// </summary>
public required string EmojiU { get; set; }
/// <summary>
/// استان‌های این کشور
/// </summary>
public virtual ICollection<State> States { get; set; } = new List<State>();
}
@@ -0,0 +1,64 @@
using CMSMicroservice.Domain.Common;
namespace CMSMicroservice.Domain.Entities.Geography;
/// <summary>
/// استان - State/Province
/// </summary>
public class State : BaseAuditableEntity
{
/// <summary>
/// شناسه یکتا
/// </summary>
public long Id { get; set; }
/// <summary>
/// شناسه خارجی (External ID)
/// </summary>
public long ExternalId { get; set; }
/// <summary>
/// نام استان
/// </summary>
public required string Name { get; set; }
/// <summary>
/// کد استان
/// </summary>
public required string StateCode { get; set; }
/// <summary>
/// عرض جغرافیایی
/// </summary>
public required string Latitude { get; set; }
/// <summary>
/// طول جغرافیایی
/// </summary>
public required string Longitude { get; set; }
/// <summary>
/// نوع استان (province, state, etc.)
/// </summary>
public required string Type { get; set; }
/// <summary>
/// نام بومی استان (فارسی یا زبان محلی)
/// </summary>
public required string Native { get; set; }
/// <summary>
/// شناسه کشور متعلق
/// </summary>
public long CountryId { get; set; }
/// <summary>
/// کشور متعلق
/// </summary>
public virtual Country Country { get; set; } = null!;
/// <summary>
/// شهرهای این استان
/// </summary>
public virtual ICollection<City> Cities { get; set; } = new List<City>();
}
@@ -2,6 +2,6 @@ namespace CMSMicroservice.Domain.Enums;
//قراردادها
public enum ContractType
{
Main = 0,
CMS = 1,
Main = 0, // قرارداد ثبت‌نام اولیه
ClubMembership = 1, // قرارداد باشگاه مشتریان
}
@@ -3,7 +3,7 @@ using CMSMicroservice.Application.Common.Interfaces;
using Microsoft.EntityFrameworkCore.Diagnostics;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Entities.Payment;
using CMSMicroservice.Domain.Entities.Geography;
using CMSMicroservice.Domain.Entities.Order;
using CMSMicroservice.Domain.Entities.DiscountShop;
using CMSMicroservice.Infrastructure.Persistence.Interceptors;
@@ -111,4 +111,9 @@ public class ApplicationDbContext : DbContext, IApplicationDbContext
public DbSet<DiscountShoppingCart> DiscountShoppingCarts => Set<DiscountShoppingCart>();
public DbSet<DiscountOrder> DiscountOrders => Set<DiscountOrder>();
public DbSet<DiscountOrderDetail> DiscountOrderDetails => Set<DiscountOrderDetail>();
// ============= Geography DbSets =============
public DbSet<Country> Countries => Set<Country>();
public DbSet<State> States => Set<State>();
public DbSet<City> Cities => Set<City>();
}
@@ -0,0 +1,55 @@
using CMSMicroservice.Domain.Entities.Geography;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CMSMicroservice.Infrastructure.Persistence.Configurations.Geography;
public class CityConfiguration : IEntityTypeConfiguration<City>
{
public void Configure(EntityTypeBuilder<City> builder)
{
builder.ToTable("Cities", "GMS");
builder.HasKey(c => c.Id);
builder.Property(c => c.Id)
.ValueGeneratedOnAdd();
builder.Property(c => c.ExternalId)
.IsRequired();
builder.Property(c => c.Name)
.IsRequired()
.HasMaxLength(500);
builder.Property(c => c.Latitude)
.IsRequired()
.HasMaxLength(50);
builder.Property(c => c.Longitude)
.IsRequired()
.HasMaxLength(50);
builder.Property(c => c.Native)
.IsRequired()
.HasMaxLength(500)
.HasDefaultValue("");
builder.Property(c => c.IsDeleted)
.IsRequired()
.HasDefaultValue(false);
builder.Property(c => c.StateId)
.IsRequired();
// Index
builder.HasIndex(c => c.StateId)
.HasDatabaseName("IX_Cities_StateId");
// Relationships
builder.HasOne(c => c.State)
.WithMany(s => s.Cities)
.HasForeignKey(c => c.StateId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,99 @@
using CMSMicroservice.Domain.Entities.Geography;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CMSMicroservice.Infrastructure.Persistence.Configurations.Geography;
public class CountryConfiguration : IEntityTypeConfiguration<Country>
{
public void Configure(EntityTypeBuilder<Country> builder)
{
builder.ToTable("Countries", "GMS");
builder.HasKey(c => c.Id);
builder.Property(c => c.Id)
.ValueGeneratedOnAdd();
builder.Property(c => c.ExternalId)
.IsRequired();
builder.Property(c => c.Name)
.IsRequired()
.HasMaxLength(500);
builder.Property(c => c.Iso3)
.IsRequired()
.HasMaxLength(3);
builder.Property(c => c.Iso2)
.IsRequired()
.HasMaxLength(2);
builder.Property(c => c.NumericCode)
.IsRequired()
.HasMaxLength(10);
builder.Property(c => c.PhoneCode)
.IsRequired()
.HasMaxLength(10);
builder.Property(c => c.Capital)
.IsRequired()
.HasMaxLength(500);
builder.Property(c => c.Currency)
.IsRequired()
.HasMaxLength(10);
builder.Property(c => c.CurrencyName)
.IsRequired()
.HasMaxLength(100);
builder.Property(c => c.CurrencySymbol)
.IsRequired()
.HasMaxLength(10);
builder.Property(c => c.Tld)
.IsRequired()
.HasMaxLength(10);
builder.Property(c => c.Native)
.IsRequired()
.HasMaxLength(500);
builder.Property(c => c.Region)
.IsRequired()
.HasMaxLength(100);
builder.Property(c => c.Subregion)
.IsRequired()
.HasMaxLength(100);
builder.Property(c => c.Latitude)
.IsRequired()
.HasMaxLength(50);
builder.Property(c => c.Longitude)
.IsRequired()
.HasMaxLength(50);
builder.Property(c => c.Emoji)
.IsRequired()
.HasMaxLength(10);
builder.Property(c => c.EmojiU)
.IsRequired()
.HasMaxLength(50);
builder.Property(c => c.IsDeleted)
.IsRequired()
.HasDefaultValue(false);
// Relationships
builder.HasMany(c => c.States)
.WithOne(s => s.Country)
.HasForeignKey(s => s.CountryId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,68 @@
using CMSMicroservice.Domain.Entities.Geography;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CMSMicroservice.Infrastructure.Persistence.Configurations.Geography;
public class StateConfiguration : IEntityTypeConfiguration<State>
{
public void Configure(EntityTypeBuilder<State> builder)
{
builder.ToTable("States", "GMS");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id)
.ValueGeneratedOnAdd();
builder.Property(s => s.ExternalId)
.IsRequired();
builder.Property(s => s.Name)
.IsRequired()
.HasMaxLength(500);
builder.Property(s => s.StateCode)
.IsRequired()
.HasMaxLength(10);
builder.Property(s => s.Latitude)
.IsRequired()
.HasMaxLength(50);
builder.Property(s => s.Longitude)
.IsRequired()
.HasMaxLength(50);
builder.Property(s => s.Type)
.IsRequired()
.HasMaxLength(100);
builder.Property(s => s.Native)
.IsRequired()
.HasMaxLength(500)
.HasDefaultValue("");
builder.Property(s => s.IsDeleted)
.IsRequired()
.HasDefaultValue(false);
builder.Property(s => s.CountryId)
.IsRequired();
// Index
builder.HasIndex(s => s.CountryId)
.HasDatabaseName("IX_States_CountryId");
// Relationships
builder.HasOne(s => s.Country)
.WithMany(c => c.States)
.HasForeignKey(s => s.CountryId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(s => s.Cities)
.WithOne(c => c.State)
.HasForeignKey(c => c.StateId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,146 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CMSMicroservice.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddGeographyEntities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "GMS");
migrationBuilder.CreateTable(
name: "Countries",
schema: "GMS",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ExternalId = table.Column<long>(type: "bigint", nullable: false),
Name = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
Iso3 = table.Column<string>(type: "nvarchar(3)", maxLength: 3, nullable: false),
Iso2 = table.Column<string>(type: "nvarchar(2)", maxLength: 2, nullable: false),
NumericCode = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: false),
PhoneCode = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: false),
Capital = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
Currency = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: false),
CurrencyName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
CurrencySymbol = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: false),
Tld = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: false),
Native = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
Region = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Subregion = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Latitude = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
Longitude = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
Emoji = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: false),
EmojiU = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
Created = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
LastModified = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifiedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Countries", x => x.Id);
});
migrationBuilder.CreateTable(
name: "States",
schema: "GMS",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ExternalId = table.Column<long>(type: "bigint", nullable: false),
Name = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
StateCode = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: false),
Latitude = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
Longitude = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
Type = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Native = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""),
CountryId = table.Column<long>(type: "bigint", nullable: false),
Created = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
LastModified = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifiedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_States", x => x.Id);
table.ForeignKey(
name: "FK_States_Countries_CountryId",
column: x => x.CountryId,
principalSchema: "GMS",
principalTable: "Countries",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Cities",
schema: "GMS",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ExternalId = table.Column<long>(type: "bigint", nullable: false),
Name = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
Latitude = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
Longitude = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
Native = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""),
StateId = table.Column<long>(type: "bigint", nullable: false),
Created = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
LastModified = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifiedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Cities", x => x.Id);
table.ForeignKey(
name: "FK_Cities_States_StateId",
column: x => x.StateId,
principalSchema: "GMS",
principalTable: "States",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Cities_StateId",
schema: "GMS",
table: "Cities",
column: "StateId");
migrationBuilder.CreateIndex(
name: "IX_States_CountryId",
schema: "GMS",
table: "States",
column: "CountryId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Cities",
schema: "GMS");
migrationBuilder.DropTable(
name: "States",
schema: "GMS");
migrationBuilder.DropTable(
name: "Countries",
schema: "GMS");
}
}
}
@@ -1014,6 +1014,256 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.ToTable("FactorDetails", "CMS");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Geography.City", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime>("Created")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<long>("ExternalId")
.HasColumnType("bigint");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<DateTime?>("LastModified")
.HasColumnType("datetime2");
b.Property<string>("LastModifiedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("Latitude")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Longitude")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("Native")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<long>("StateId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("StateId")
.HasDatabaseName("IX_Cities_StateId");
b.ToTable("Cities", "GMS");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Geography.Country", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Capital")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime>("Created")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("CurrencyName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("CurrencySymbol")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("Emoji")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("EmojiU")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<long>("ExternalId")
.HasColumnType("bigint");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<string>("Iso2")
.IsRequired()
.HasMaxLength(2)
.HasColumnType("nvarchar(2)");
b.Property<string>("Iso3")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("nvarchar(3)");
b.Property<DateTime?>("LastModified")
.HasColumnType("datetime2");
b.Property<string>("LastModifiedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("Latitude")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Longitude")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("Native")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("NumericCode")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("PhoneCode")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("Region")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Subregion")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Tld")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.HasKey("Id");
b.ToTable("Countries", "GMS");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Geography.State", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long>("CountryId")
.HasColumnType("bigint");
b.Property<DateTime>("Created")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<long>("ExternalId")
.HasColumnType("bigint");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<DateTime?>("LastModified")
.HasColumnType("datetime2");
b.Property<string>("LastModifiedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("Latitude")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Longitude")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("Native")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<string>("StateCode")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.HasIndex("CountryId")
.HasDatabaseName("IX_States_CountryId");
b.ToTable("States", "GMS");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.History.ClubMembershipHistory", b =>
{
b.Property<long>("Id")
@@ -2789,6 +3039,28 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.Navigation("Product");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Geography.City", b =>
{
b.HasOne("CMSMicroservice.Domain.Entities.Geography.State", "State")
.WithMany("Cities")
.HasForeignKey("StateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("State");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Geography.State", b =>
{
b.HasOne("CMSMicroservice.Domain.Entities.Geography.Country", "Country")
.WithMany("States")
.HasForeignKey("CountryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Country");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.History.ClubMembershipHistory", b =>
{
b.HasOne("CMSMicroservice.Domain.Entities.Club.ClubMembership", "ClubMembership")
@@ -3149,6 +3421,16 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.Navigation("ShoppingCarts");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Geography.Country", b =>
{
b.Navigation("States");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Geography.State", b =>
{
b.Navigation("Cities");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.Package", b =>
{
b.Navigation("UserOrders");
@@ -55,6 +55,8 @@
<Protobuf Include="Protos\discountcategory.proto" ProtoRoot="Protos\" GrpcServices="Both" />
<Protobuf Include="Protos\discountshoppingcart.proto" ProtoRoot="Protos\" GrpcServices="Both" />
<Protobuf Include="Protos\discountorder.proto" ProtoRoot="Protos\" GrpcServices="Both" />
<!-- Geography System (GMS) -->
<Protobuf Include="Protos\city.proto" ProtoRoot="Protos\" GrpcServices="Both" />
</ItemGroup>
<Target Name="PushToFoursatNuget" AfterTargets="Pack">
@@ -0,0 +1,54 @@
syntax = "proto3";
package city;
import "public_messages.proto";
import "google/protobuf/empty.proto";
import "google/protobuf/wrappers.proto";
import "google/protobuf/timestamp.proto";
import "google/api/annotations.proto";
option csharp_namespace = "CMSMicroservice.Protobuf.Protos.City";
service CityContract
{
rpc GetAllCitiesByFilter(GetAllCitiesByFilterRequest) returns (GetAllCitiesByFilterResponse){
option (google.api.http) = {
get: "/GetAllCitiesByFilter"
};
};
}
message GetAllCitiesByFilterRequest
{
messages.PaginationState pagination_state = 1;
google.protobuf.StringValue sort_by = 2;
GetAllCitiesByFilterFilter filter = 3;
}
message GetAllCitiesByFilterFilter
{
google.protobuf.Int64Value id = 1;
google.protobuf.StringValue name = 2;
google.protobuf.StringValue native = 3;
google.protobuf.Int64Value state_id = 4;
}
message GetAllCitiesByFilterResponse
{
messages.MetaData meta_data = 1;
repeated GetAllCitiesByFilterResponseModel models = 2;
}
message GetAllCitiesByFilterResponseModel
{
int64 id = 1;
int64 external_id = 2;
string name = 3;
string native = 4;
string latitude = 5;
string longitude = 6;
int64 state_id = 7;
string state_name = 8;
string state_native = 9;
}
@@ -63,6 +63,14 @@ service ClubMembershipContract
body: "*"
};
};
// امضای قرارداد باشگاه مشتریان
rpc AcceptClubMembershipContract(AcceptClubMembershipContractRequest) returns (AcceptClubMembershipContractResponse){
option (google.api.http) = {
post: "/ClubMembership/AcceptContract"
body: "*"
};
};
}
// Activate Command
@@ -258,3 +266,19 @@ message ToggleUserClubFeatureResponse
google.protobuf.Int64Value user_club_feature_id = 3;
google.protobuf.BoolValue is_active = 4;
}
// AcceptClubMembershipContract Command - امضای قرارداد باشگاه مشتریان
message AcceptClubMembershipContractRequest
{
int64 user_id = 1;
string otp_code = 2;
string sign_guid = 3;
string contract_html = 4;
}
message AcceptClubMembershipContractResponse
{
bool success = 1;
string message = 2;
int64 contract_id = 3;
}
@@ -61,6 +61,12 @@ service UserContract
body: "*"
};
};
rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse){
option (google.api.http) = {
post: "/RefreshToken"
body: "*"
};
};
}
message CreateNewUserRequest
{
@@ -191,3 +197,13 @@ message SetPasswordForUserRequest
string new_password = 3;
string confirm_password = 4;
}
message RefreshTokenRequest
{
string current_token = 1;
}
message RefreshTokenResponse
{
string token = 1;
bool success = 2;
string message = 3;
}
@@ -28,6 +28,7 @@
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.MSSqlServer" Version="9.0.2" />
<PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,40 @@
using CMSMicroservice.Application.CityCQ.Queries.GetAllCitiesByFilter;
using Mapster;
using ProtoCity = CMSMicroservice.Protobuf.Protos.City;
using AppCity = CMSMicroservice.Application.CityCQ.Queries.GetAllCitiesByFilter;
namespace CMSMicroservice.WebApi.Common.Mappings;
public class CityProfile : IRegister
{
void IRegister.Register(TypeAdapterConfig config)
{
// Request: Proto → Application
config.NewConfig<ProtoCity.GetAllCitiesByFilterRequest, AppCity.GetAllCitiesByFilterQuery>()
.Map(dest => dest.PaginationState, src => src.PaginationState)
.Map(dest => dest.SortBy, src => src.SortBy)
.Map(dest => dest.Filter, src => src.Filter);
config.NewConfig<ProtoCity.GetAllCitiesByFilterFilter, AppCity.GetAllCitiesByFilterFilter>()
.Map(dest => dest.Id, src => src.Id)
.Map(dest => dest.Name, src => src.Name)
.Map(dest => dest.Native, src => src.Native)
.Map(dest => dest.StateId, src => src.StateId);
// Response: Application → Proto
config.NewConfig<AppCity.GetAllCitiesByFilterResponseDto, ProtoCity.GetAllCitiesByFilterResponse>()
.Map(dest => dest.MetaData, src => src.MetaData)
.Map(dest => dest.Models, src => src.Models);
config.NewConfig<AppCity.GetAllCitiesByFilterResponseModel, ProtoCity.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.StateName)
.Map(dest => dest.StateNative, src => src.StateNative);
}
}
@@ -1,5 +1,6 @@
using CMSMicroservice.Application.ClubFeatureCQ.Commands.ToggleUserClubFeature;
using CMSMicroservice.Application.ClubFeatureCQ.Queries.GetUserClubFeatures;
using CMSMicroservice.Application.ClubMembershipCQ.Commands.AcceptClubMembershipContract;
using CMSMicroservice.Protobuf.Protos.ClubMembership;
using Google.Protobuf.WellKnownTypes;
using System;
@@ -43,5 +44,18 @@ public class ClubFeatureProfile : IRegister
.Map(dest => dest.Message, src => src.Message)
.Map(dest => dest.UserClubFeatureId, src => src.UserClubFeatureId.HasValue ? (long?)src.UserClubFeatureId.Value : null)
.Map(dest => dest.IsActive, src => src.IsActive.HasValue ? (bool?)src.IsActive.Value : null);
// AcceptClubMembershipContractRequest → AcceptClubMembershipContractCommand
config.NewConfig<AcceptClubMembershipContractRequest, AcceptClubMembershipContractCommand>()
.Map(dest => dest.UserId, src => src.UserId)
.Map(dest => dest.OtpCode, src => src.OtpCode)
.Map(dest => dest.SignGuid, src => src.SignGuid)
.Map(dest => dest.ContractHtml, src => src.ContractHtml);
// AcceptClubMembershipContractResponseDto → AcceptClubMembershipContractResponse
config.NewConfig<AcceptClubMembershipContractResponseDto, AcceptClubMembershipContractResponse>()
.Map(dest => dest.Success, src => src.Success)
.Map(dest => dest.Message, src => src.Message)
.Map(dest => dest.ContractId, src => src.ContractId);
}
}
@@ -0,0 +1,115 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.WebApi.Hubs;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.WebApi.Common.Services;
/// <summary>
/// Service for sending token-related notifications via SignalR
/// </summary>
public class TokenNotificationService : ITokenNotificationService
{
private readonly IHubContext<TokenNotificationHub> _hubContext;
private readonly ILogger<TokenNotificationService> _logger;
public TokenNotificationService(
IHubContext<TokenNotificationHub> hubContext,
ILogger<TokenNotificationService> logger)
{
_hubContext = hubContext;
_logger = logger;
}
/// <inheritdoc />
public async Task NotifyTokenRevokedAsync(long userId, string reason)
{
try
{
_logger.LogInformation("Notifying token revoked for user {UserId}. Reason: {Reason}", userId, reason);
await _hubContext.Clients.Group($"user_{userId}")
.SendAsync("TokenRevoked", new TokenRevokedNotification
{
UserId = userId,
Reason = reason,
Timestamp = DateTime.UtcNow
});
_logger.LogInformation("Token revoked notification sent for user {UserId}", userId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send token revoked notification for user {UserId}", userId);
}
}
/// <inheritdoc />
public async Task NotifyForceRefreshAsync(long userId)
{
try
{
_logger.LogInformation("Notifying force refresh for user {UserId}", userId);
await _hubContext.Clients.Group($"user_{userId}")
.SendAsync("ForceRefreshToken", new ForceRefreshNotification
{
UserId = userId,
Timestamp = DateTime.UtcNow
});
_logger.LogInformation("Force refresh notification sent for user {UserId}", userId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send force refresh notification for user {UserId}", userId);
}
}
/// <inheritdoc />
public async Task BroadcastMessageAsync(string message)
{
try
{
_logger.LogInformation("Broadcasting message to all clients: {Message}", message);
await _hubContext.Clients.All.SendAsync("BroadcastMessage", new BroadcastNotification
{
Message = message,
Timestamp = DateTime.UtcNow
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to broadcast message");
}
}
}
/// <summary>
/// Notification payload for token revoked event
/// </summary>
public class TokenRevokedNotification
{
public long UserId { get; set; }
public string Reason { get; set; } = string.Empty;
public DateTime Timestamp { get; set; }
}
/// <summary>
/// Notification payload for force refresh event
/// </summary>
public class ForceRefreshNotification
{
public long UserId { get; set; }
public DateTime Timestamp { get; set; }
}
/// <summary>
/// Notification payload for broadcast message
/// </summary>
public class BroadcastNotification
{
public string Message { get; set; } = string.Empty;
public DateTime Timestamp { get; set; }
}
@@ -1,6 +1,7 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Infrastructure.Persistence;
using CMSMicroservice.WebApi.Common.Services;
using CMSMicroservice.WebApi.Hubs;
using MapsterMapper;
using System.Reflection;
using CMSMicroservice.WebApi.Services;
@@ -21,6 +22,10 @@ public static class ConfigureServices
services.AddScoped<ICurrentUserService, CurrentUserService>();
services.AddScoped<IDispatchRequestToCQRS, DispatchRequestToCQRS>();
// Add SignalR services
services.AddSignalR();
services.AddScoped<ITokenNotificationService, TokenNotificationService>();
services.AddHttpContextAccessor();
@@ -0,0 +1,50 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.WebApi.Hubs;
/// <summary>
/// SignalR Hub for broadcasting token-related notifications to connected BFF clients.
/// This hub is used internally between CMS and BFF services.
/// </summary>
public class TokenNotificationHub : Hub
{
private readonly ILogger<TokenNotificationHub> _logger;
public TokenNotificationHub(ILogger<TokenNotificationHub> logger)
{
_logger = logger;
}
public override async Task OnConnectedAsync()
{
_logger.LogInformation("Client connected to TokenNotificationHub: {ConnectionId}", Context.ConnectionId);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
_logger.LogInformation("Client disconnected from TokenNotificationHub: {ConnectionId}, Exception: {Exception}",
Context.ConnectionId, exception?.Message);
await base.OnDisconnectedAsync(exception);
}
/// <summary>
/// Subscribe to notifications for a specific user
/// </summary>
public async Task SubscribeToUser(long userId)
{
await Groups.AddToGroupAsync(Context.ConnectionId, $"user_{userId}");
_logger.LogInformation("Client {ConnectionId} subscribed to user_{UserId}", Context.ConnectionId, userId);
}
/// <summary>
/// Unsubscribe from notifications for a specific user
/// </summary>
public async Task UnsubscribeFromUser(long userId)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"user_{userId}");
_logger.LogInformation("Client {ConnectionId} unsubscribed from user_{UserId}", Context.ConnectionId, userId);
}
}
+5
View File
@@ -1,5 +1,6 @@
using CMSMicroservice.Infrastructure.Persistence;
using CMSMicroservice.Infrastructure.Data.Seeding;
using CMSMicroservice.WebApi.Hubs;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -169,6 +170,10 @@ app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthC
});
app.MapControllers();
app.UseGrpcWeb(new GrpcWebOptions { DefaultEnabled = true }); // Configure the HTTP request pipeline.
// Map SignalR Hub for token notifications
app.MapHub<TokenNotificationHub>("/hubs/token-notification");
app.ConfigureGrpcEndpoints(Assembly.GetExecutingAssembly(), endpoints =>
{
// endpoints.MapGrpcService<ExampleService>();
@@ -0,0 +1,25 @@
using CMSMicroservice.Protobuf.Protos.City;
using CMSMicroservice.WebApi.Common.Services;
using CMSMicroservice.Application.CityCQ.Queries.GetAllCitiesByFilter;
namespace CMSMicroservice.WebApi.Services;
public class CityService : CityContract.CityContractBase
{
private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS;
public CityService(IDispatchRequestToCQRS dispatchRequestToCQRS)
{
_dispatchRequestToCQRS = dispatchRequestToCQRS;
}
public override async Task<GetAllCitiesByFilterResponse> GetAllCitiesByFilter(
GetAllCitiesByFilterRequest request,
ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<
GetAllCitiesByFilterRequest,
GetAllCitiesByFilterQuery,
GetAllCitiesByFilterResponse>(request, context);
}
}
@@ -3,6 +3,7 @@ using CMSMicroservice.WebApi.Common.Services;
using CMSMicroservice.Application.ClubMembershipCQ.Commands.ActivateClubMembership;
using CMSMicroservice.Application.ClubMembershipCQ.Commands.DeactivateClubMembership;
using CMSMicroservice.Application.ClubMembershipCQ.Commands.AssignClubFeature;
using CMSMicroservice.Application.ClubMembershipCQ.Commands.AcceptClubMembershipContract;
using CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubMembership;
using CMSMicroservice.Application.ClubMembershipCQ.Queries.GetAllClubMemberships;
using CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubMembershipHistory;
@@ -65,4 +66,9 @@ public class ClubMembershipService : ClubMembershipContract.ClubMembershipContra
{
return await _dispatchRequestToCQRS.Handle<ToggleUserClubFeatureRequest, ToggleUserClubFeatureCommand, Protobuf.Protos.ClubMembership.ToggleUserClubFeatureResponse>(request, context);
}
public override async Task<AcceptClubMembershipContractResponse> AcceptClubMembershipContract(AcceptClubMembershipContractRequest request, ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<AcceptClubMembershipContractRequest, AcceptClubMembershipContractCommand, AcceptClubMembershipContractResponse>(request, context);
}
}
@@ -8,6 +8,7 @@ using CMSMicroservice.Application.UserCQ.Queries.GetAllUserByFilter;
using CMSMicroservice.Application.UserCQ.Queries.GetJwtToken;
using CMSMicroservice.Application.UserCQ.Queries.AdminGetJwtToken;
using CMSMicroservice.Application.UserCQ.Commands.SetPasswordForUser;
using CMSMicroservice.Application.UserCQ.Commands.RefreshToken;
namespace CMSMicroservice.WebApi.Services;
public class UserService : UserContract.UserContractBase
{
@@ -49,4 +50,8 @@ public class UserService : UserContract.UserContractBase
{
return await _dispatchRequestToCQRS.Handle<SetPasswordForUserRequest, SetPasswordForUserCommand>(request, context);
}
public override async Task<RefreshTokenResponse> RefreshToken(RefreshTokenRequest request, ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<RefreshTokenRequest, RefreshTokenCommand, RefreshTokenResponse>(request, context);
}
}