From 4330ec372670895937e2a3ab6675678ea2ccf931 Mon Sep 17 00:00:00 2001 From: masoodafar-web Date: Thu, 18 Dec 2025 00:44:44 +0330 Subject: [PATCH] feat: add club membership contract signing and city services --- .../GetAllCitiesByFilterQuery.cs | 53 +++ .../GetAllCitiesByFilterQueryHandler.cs | 25 ++ .../GetAllCitiesByFilterResponseDto.cs | 72 ++++ .../AcceptClubMembershipContractCommand.cs | 33 ++ ...eptClubMembershipContractCommandHandler.cs | 80 ++++ ...tClubMembershipContractCommandValidator.cs | 21 + .../RequestClubContractOtpCommand.cs | 23 ++ .../RequestClubContractOtpCommandHandler.cs | 80 ++++ .../RequestClubContractOtpCommandValidator.cs | 11 + .../Interfaces/IApplicationContractContext.cs | 4 + .../Common/Mappings/CityProfile.cs | 54 +++ .../GetMyNetworkStatisticsQueryHandler.cs | 64 +-- .../GetMyNetworkTreeQueryHandler.cs | 4 + .../GetMyNetworkTreeResponseDto.cs | 20 + .../RefreshToken/RefreshTokenCommand.cs | 14 + .../RefreshTokenCommandHandler.cs | 47 +++ .../RefreshToken/RefreshTokenResponseDto.cs | 22 + .../Services/ApplicationContractContext.cs | 4 + .../CmsSignalRClientService.cs | 184 +++++++++ .../Common/Mappings/CityProfile.cs | 51 +++ .../Common/Mappings/ClubMembershipProfile.cs | 24 ++ .../Mappings/NetworkMembershipProfile.cs | 6 +- .../ConfigureServices.cs | 8 + .../FrontOffice.BFF.WebApi.csproj | 3 + .../Hubs/TokenRelayHub.cs | 51 +++ src/FrontOffice.BFF.WebApi/Program.cs | 5 + .../Services/CityService.cs | 25 ++ .../Services/ClubMembershipGrpcService.cs | 18 + .../Services/UserService.cs | 7 + src/FrontOffice.BFF.WebApi/appsettings.json | 7 +- src/FrontOffice.BFF.sln | 15 + .../FrontOffice.BFF.City.Protobuf.csproj | 35 ++ .../Protos/city.proto | 62 +++ .../Protos/google/api/annotations.proto | 31 ++ .../Protos/google/api/http.proto | 377 ++++++++++++++++++ .../Protos/clubmembership.proto | 48 +++ .../Protos/networkmembership.proto | 4 + .../Protos/user.proto | 16 + 38 files changed, 1583 insertions(+), 25 deletions(-) create mode 100644 src/FrontOffice.BFF.Application/CityCQ/Queries/GetAllCitiesByFilter/GetAllCitiesByFilterQuery.cs create mode 100644 src/FrontOffice.BFF.Application/CityCQ/Queries/GetAllCitiesByFilter/GetAllCitiesByFilterQueryHandler.cs create mode 100644 src/FrontOffice.BFF.Application/CityCQ/Queries/GetAllCitiesByFilter/GetAllCitiesByFilterResponseDto.cs create mode 100644 src/FrontOffice.BFF.Application/ClubMembershipCQ/Commands/AcceptClubMembershipContract/AcceptClubMembershipContractCommand.cs create mode 100644 src/FrontOffice.BFF.Application/ClubMembershipCQ/Commands/AcceptClubMembershipContract/AcceptClubMembershipContractCommandHandler.cs create mode 100644 src/FrontOffice.BFF.Application/ClubMembershipCQ/Commands/AcceptClubMembershipContract/AcceptClubMembershipContractCommandValidator.cs create mode 100644 src/FrontOffice.BFF.Application/ClubMembershipCQ/Commands/RequestClubContractOtp/RequestClubContractOtpCommand.cs create mode 100644 src/FrontOffice.BFF.Application/ClubMembershipCQ/Commands/RequestClubContractOtp/RequestClubContractOtpCommandHandler.cs create mode 100644 src/FrontOffice.BFF.Application/ClubMembershipCQ/Commands/RequestClubContractOtp/RequestClubContractOtpCommandValidator.cs create mode 100644 src/FrontOffice.BFF.Application/Common/Mappings/CityProfile.cs create mode 100644 src/FrontOffice.BFF.Application/UserCQ/Commands/RefreshToken/RefreshTokenCommand.cs create mode 100644 src/FrontOffice.BFF.Application/UserCQ/Commands/RefreshToken/RefreshTokenCommandHandler.cs create mode 100644 src/FrontOffice.BFF.Application/UserCQ/Commands/RefreshToken/RefreshTokenResponseDto.cs create mode 100644 src/FrontOffice.BFF.WebApi/BackgroundServices/CmsSignalRClientService.cs create mode 100644 src/FrontOffice.BFF.WebApi/Common/Mappings/CityProfile.cs create mode 100644 src/FrontOffice.BFF.WebApi/Hubs/TokenRelayHub.cs create mode 100644 src/FrontOffice.BFF.WebApi/Services/CityService.cs create mode 100644 src/Protobufs/FrontOffice.BFF.City.Protobuf/FrontOffice.BFF.City.Protobuf.csproj create mode 100644 src/Protobufs/FrontOffice.BFF.City.Protobuf/Protos/city.proto create mode 100644 src/Protobufs/FrontOffice.BFF.City.Protobuf/Protos/google/api/annotations.proto create mode 100644 src/Protobufs/FrontOffice.BFF.City.Protobuf/Protos/google/api/http.proto diff --git a/src/FrontOffice.BFF.Application/CityCQ/Queries/GetAllCitiesByFilter/GetAllCitiesByFilterQuery.cs b/src/FrontOffice.BFF.Application/CityCQ/Queries/GetAllCitiesByFilter/GetAllCitiesByFilterQuery.cs new file mode 100644 index 0000000..a5cda9f --- /dev/null +++ b/src/FrontOffice.BFF.Application/CityCQ/Queries/GetAllCitiesByFilter/GetAllCitiesByFilterQuery.cs @@ -0,0 +1,53 @@ +using MediatR; + +namespace FrontOffice.BFF.Application.CityCQ.Queries.GetAllCitiesByFilter; + +/// +/// Query برای دریافت لیست شهرها با فیلتر و صفحه‌بندی +/// +public sealed record GetAllCitiesByFilterQuery : IRequest +{ + /// + /// موقعیت صفحه بندی + /// + public PaginationStateDto? PaginationState { get; init; } + + /// + /// مرتب سازی بر اساس + /// + public string? SortBy { get; init; } + + /// + /// فیلتر + /// + public GetAllCitiesByFilterFilterDto? Filter { get; init; } +} + +public class PaginationStateDto +{ + public int PageNumber { get; set; } + public int PageSize { get; set; } +} + +public class GetAllCitiesByFilterFilterDto +{ + /// + /// شناسه + /// + public long? Id { get; set; } + + /// + /// نام شهر (Contains) + /// + public string? Name { get; set; } + + /// + /// نام بومی شهر (Contains) + /// + public string? Native { get; set; } + + /// + /// شناسه استان + /// + public long? StateId { get; set; } +} diff --git a/src/FrontOffice.BFF.Application/CityCQ/Queries/GetAllCitiesByFilter/GetAllCitiesByFilterQueryHandler.cs b/src/FrontOffice.BFF.Application/CityCQ/Queries/GetAllCitiesByFilter/GetAllCitiesByFilterQueryHandler.cs new file mode 100644 index 0000000..96d54d8 --- /dev/null +++ b/src/FrontOffice.BFF.Application/CityCQ/Queries/GetAllCitiesByFilter/GetAllCitiesByFilterQueryHandler.cs @@ -0,0 +1,25 @@ +using CMSMicroservice.Protobuf.Protos.City; +using FrontOffice.BFF.Application.Common.Interfaces; +using Mapster; +using MediatR; + +namespace FrontOffice.BFF.Application.CityCQ.Queries.GetAllCitiesByFilter; + +public class GetAllCitiesByFilterQueryHandler : IRequestHandler +{ + private readonly IApplicationContractContext _context; + + public GetAllCitiesByFilterQueryHandler(IApplicationContractContext context) + { + _context = context; + } + + public async Task Handle( + GetAllCitiesByFilterQuery request, + CancellationToken cancellationToken) + { + var grpcRequest = request.Adapt(); + var response = await _context.Cities.GetAllCitiesByFilterAsync(grpcRequest, cancellationToken: cancellationToken); + return response.Adapt(); + } +} diff --git a/src/FrontOffice.BFF.Application/CityCQ/Queries/GetAllCitiesByFilter/GetAllCitiesByFilterResponseDto.cs b/src/FrontOffice.BFF.Application/CityCQ/Queries/GetAllCitiesByFilter/GetAllCitiesByFilterResponseDto.cs new file mode 100644 index 0000000..ea155ae --- /dev/null +++ b/src/FrontOffice.BFF.Application/CityCQ/Queries/GetAllCitiesByFilter/GetAllCitiesByFilterResponseDto.cs @@ -0,0 +1,72 @@ +namespace FrontOffice.BFF.Application.CityCQ.Queries.GetAllCitiesByFilter; + +public class GetAllCitiesByFilterResponseDto +{ + /// + /// متادیتا صفحه‌بندی + /// + public MetaDataDto MetaData { get; set; } = null!; + + /// + /// لیست شهرها + /// + public List Models { get; set; } = new(); +} + +public class MetaDataDto +{ + public long CurrentPage { get; set; } + public long TotalPage { get; set; } + public long PageSize { get; set; } + public long TotalCount { get; set; } + public bool HasPrevious { get; set; } + public bool HasNext { get; set; } +} + +public class CityDto +{ + /// + /// شناسه + /// + public long Id { get; set; } + + /// + /// شناسه خارجی + /// + public long ExternalId { get; set; } + + /// + /// نام شهر (انگلیسی) + /// + public string Name { get; set; } = null!; + + /// + /// نام بومی شهر (فارسی) + /// + public string Native { get; set; } = null!; + + /// + /// عرض جغرافیایی + /// + public string Latitude { get; set; } = null!; + + /// + /// طول جغرافیایی + /// + public string Longitude { get; set; } = null!; + + /// + /// شناسه استان + /// + public long StateId { get; set; } + + /// + /// نام استان + /// + public string StateName { get; set; } = null!; + + /// + /// نام بومی استان (فارسی) + /// + public string StateNative { get; set; } = null!; +} diff --git a/src/FrontOffice.BFF.Application/ClubMembershipCQ/Commands/AcceptClubMembershipContract/AcceptClubMembershipContractCommand.cs b/src/FrontOffice.BFF.Application/ClubMembershipCQ/Commands/AcceptClubMembershipContract/AcceptClubMembershipContractCommand.cs new file mode 100644 index 0000000..0ab9d7f --- /dev/null +++ b/src/FrontOffice.BFF.Application/ClubMembershipCQ/Commands/AcceptClubMembershipContract/AcceptClubMembershipContractCommand.cs @@ -0,0 +1,33 @@ +namespace FrontOffice.BFF.Application.ClubMembershipCQ.Commands.AcceptClubMembershipContract; + +/// +/// Command برای امضای قرارداد باشگاه مشتریان +/// +public record AcceptClubMembershipContractCommand : IRequest +{ + /// + /// کد OTP دریافتی + /// + public string OtpCode { get; init; } + + /// + /// شناسه یکتای امضا (GUID) + /// + public string SignGuid { get; init; } + + /// + /// محتوای HTML قرارداد + /// + public string ContractHtml { get; init; } +} + +/// +/// DTO پاسخ امضای قرارداد +/// +public class AcceptClubMembershipContractResponseDto +{ + public bool Success { get; set; } + public string Message { get; set; } + public long ContractId { get; set; } + public string Token { get; set; } +} diff --git a/src/FrontOffice.BFF.Application/ClubMembershipCQ/Commands/AcceptClubMembershipContract/AcceptClubMembershipContractCommandHandler.cs b/src/FrontOffice.BFF.Application/ClubMembershipCQ/Commands/AcceptClubMembershipContract/AcceptClubMembershipContractCommandHandler.cs new file mode 100644 index 0000000..592989c --- /dev/null +++ b/src/FrontOffice.BFF.Application/ClubMembershipCQ/Commands/AcceptClubMembershipContract/AcceptClubMembershipContractCommandHandler.cs @@ -0,0 +1,80 @@ +using CMSMicroservice.Protobuf.Protos.ClubMembership; + +namespace FrontOffice.BFF.Application.ClubMembershipCQ.Commands.AcceptClubMembershipContract; + +/// +/// Handler برای امضای قرارداد باشگاه مشتریان +/// 1. فراخوانی CMS برای ثبت قرارداد و فعالسازی باشگاه +/// 2. دریافت توکن جدید با claims به‌روز شده (IsClubMemberActive = true) +/// +public class AcceptClubMembershipContractCommandHandler + : IRequestHandler +{ + private readonly IApplicationContractContext _context; + private readonly ICurrentUserService _currentUserService; + + public AcceptClubMembershipContractCommandHandler( + IApplicationContractContext context, + ICurrentUserService currentUserService) + { + _context = context; + _currentUserService = currentUserService; + } + + public async Task Handle( + AcceptClubMembershipContractCommand request, + CancellationToken cancellationToken) + { + var userId = _currentUserService.UserId + ?? throw new ForbiddenAccessException(); + + // 1. فراخوانی CMS برای ثبت قرارداد و فعالسازی باشگاه + var cmsResponse = await _context.ClubMemberships.AcceptClubMembershipContractAsync( + new AcceptClubMembershipContractRequest + { + UserId = userId, + OtpCode = request.OtpCode, + SignGuid = request.SignGuid, + ContractHtml = request.ContractHtml + }, + cancellationToken: cancellationToken); + + if (!cmsResponse.Success) + { + return new AcceptClubMembershipContractResponseDto + { + Success = false, + Message = cmsResponse.Message, + ContractId = 0, + Token = null + }; + } + + // 2. دریافت توکن جدید با claims به‌روز شده + string newToken = null; + try + { + var tokenResponse = await _context.User.GetJwtTokenAsync( + new CMSMicroservice.Protobuf.Protos.User.GetJwtTokenRequest + { + Id = userId + }, + cancellationToken: cancellationToken); + + newToken = tokenResponse?.Token; + } + catch + { + // اگر دریافت توکن با خطا مواجه شد، عملیات اصلی موفق بوده + // کاربر می‌تواند با login مجدد توکن جدید بگیرد + } + + return new AcceptClubMembershipContractResponseDto + { + Success = true, + Message = cmsResponse.Message, + ContractId = cmsResponse.ContractId, + Token = newToken + }; + } +} diff --git a/src/FrontOffice.BFF.Application/ClubMembershipCQ/Commands/AcceptClubMembershipContract/AcceptClubMembershipContractCommandValidator.cs b/src/FrontOffice.BFF.Application/ClubMembershipCQ/Commands/AcceptClubMembershipContract/AcceptClubMembershipContractCommandValidator.cs new file mode 100644 index 0000000..0d07c0f --- /dev/null +++ b/src/FrontOffice.BFF.Application/ClubMembershipCQ/Commands/AcceptClubMembershipContract/AcceptClubMembershipContractCommandValidator.cs @@ -0,0 +1,21 @@ +namespace FrontOffice.BFF.Application.ClubMembershipCQ.Commands.AcceptClubMembershipContract; + +public class AcceptClubMembershipContractCommandValidator : AbstractValidator +{ + public AcceptClubMembershipContractCommandValidator() + { + RuleFor(x => x.OtpCode) + .NotEmpty() + .WithMessage("کد تایید الزامی است") + .Length(6) + .WithMessage("کد تایید باید ۶ رقم باشد"); + + RuleFor(x => x.SignGuid) + .NotEmpty() + .WithMessage("شناسه امضا الزامی است"); + + RuleFor(x => x.ContractHtml) + .NotEmpty() + .WithMessage("محتوای قرارداد الزامی است"); + } +} diff --git a/src/FrontOffice.BFF.Application/ClubMembershipCQ/Commands/RequestClubContractOtp/RequestClubContractOtpCommand.cs b/src/FrontOffice.BFF.Application/ClubMembershipCQ/Commands/RequestClubContractOtp/RequestClubContractOtpCommand.cs new file mode 100644 index 0000000..55bc744 --- /dev/null +++ b/src/FrontOffice.BFF.Application/ClubMembershipCQ/Commands/RequestClubContractOtp/RequestClubContractOtpCommand.cs @@ -0,0 +1,23 @@ +namespace FrontOffice.BFF.Application.ClubMembershipCQ.Commands.RequestClubContractOtp; + +/// +/// Command برای درخواست OTP امضای قرارداد باشگاه مشتریان +/// +public record RequestClubContractOtpCommand : IRequest +{ + /// + /// شناسه یکتای امضا (GUID) + /// + public string SignGuid { get; init; } +} + +/// +/// DTO پاسخ درخواست OTP +/// +public class RequestClubContractOtpResponseDto +{ + public bool Success { get; set; } + public string Message { get; set; } + public int RemainingAttempts { get; set; } + public int RemainingSeconds { get; set; } +} diff --git a/src/FrontOffice.BFF.Application/ClubMembershipCQ/Commands/RequestClubContractOtp/RequestClubContractOtpCommandHandler.cs b/src/FrontOffice.BFF.Application/ClubMembershipCQ/Commands/RequestClubContractOtp/RequestClubContractOtpCommandHandler.cs new file mode 100644 index 0000000..7fd1689 --- /dev/null +++ b/src/FrontOffice.BFF.Application/ClubMembershipCQ/Commands/RequestClubContractOtp/RequestClubContractOtpCommandHandler.cs @@ -0,0 +1,80 @@ +using System.Text; +using CMSMicroservice.Protobuf.Protos.OtpToken; + +namespace FrontOffice.BFF.Application.ClubMembershipCQ.Commands.RequestClubContractOtp; + +/// +/// Handler برای درخواست OTP امضای قرارداد باشگاه مشتریان +/// از سرویس OTP موجود در CMS استفاده می‌کند و پیامک ارسال می‌کند +/// +public class RequestClubContractOtpCommandHandler + : IRequestHandler +{ + private readonly IApplicationContractContext _context; + private readonly IKavenegarService _kavenegarService; + private readonly ICurrentUserService _currentUserService; + + private const string OtpPurpose = "signClubContract"; + + public RequestClubContractOtpCommandHandler( + IApplicationContractContext context, + IKavenegarService kavenegarService, + ICurrentUserService currentUserService) + { + _context = context; + _kavenegarService = kavenegarService; + _currentUserService = currentUserService; + } + + public async Task Handle( + RequestClubContractOtpCommand request, + CancellationToken cancellationToken) + { + // دریافت شماره موبایل از توکن کاربر + var mobileNumber = _currentUserService.MobileNumber; + + if (string.IsNullOrEmpty(mobileNumber)) + { + return new RequestClubContractOtpResponseDto + { + Success = false, + Message = "شماره موبایل کاربر یافت نشد" + }; + } + + // فراخوانی سرویس OTP در CMS + var otpResponse = await _context.OtpToken.CreateNewOtpTokenAsync( + new CreateNewOtpTokenRequest + { + Mobile = mobileNumber, + Purpose = OtpPurpose + }, + cancellationToken: cancellationToken); + + // ارسال پیامک با کد OTP + if (otpResponse.Success && !string.IsNullOrWhiteSpace(otpResponse.Code)) + { + var fullName = $"{_currentUserService.FirstName} {_currentUserService.LastName}".Trim(); + + await _kavenegarService.Send( + mobile: mobileNumber, + new StringBuilder("سلام ") + .Append(string.IsNullOrEmpty(fullName) ? "کاربر" : fullName) + .AppendLine(" عزیز") + .Append("کد یک بار مصرف برای تایید قرارداد باشگاه مشتریان: ") + .AppendLine(otpResponse.Code) + .AppendLine("شناسه امضاء: ") + .AppendLine(request.SignGuid) + .AppendLine("کارابازار") + .ToString()); + } + + return new RequestClubContractOtpResponseDto + { + Success = otpResponse.Success, + Message = otpResponse.Message, + RemainingAttempts = otpResponse.RemainingAttempts, + RemainingSeconds = otpResponse.RemainingSeconds + }; + } +} diff --git a/src/FrontOffice.BFF.Application/ClubMembershipCQ/Commands/RequestClubContractOtp/RequestClubContractOtpCommandValidator.cs b/src/FrontOffice.BFF.Application/ClubMembershipCQ/Commands/RequestClubContractOtp/RequestClubContractOtpCommandValidator.cs new file mode 100644 index 0000000..7e52837 --- /dev/null +++ b/src/FrontOffice.BFF.Application/ClubMembershipCQ/Commands/RequestClubContractOtp/RequestClubContractOtpCommandValidator.cs @@ -0,0 +1,11 @@ +namespace FrontOffice.BFF.Application.ClubMembershipCQ.Commands.RequestClubContractOtp; + +public class RequestClubContractOtpCommandValidator : AbstractValidator +{ + public RequestClubContractOtpCommandValidator() + { + RuleFor(x => x.SignGuid) + .NotEmpty() + .WithMessage("شناسه امضا الزامی است"); + } +} diff --git a/src/FrontOffice.BFF.Application/Common/Interfaces/IApplicationContractContext.cs b/src/FrontOffice.BFF.Application/Common/Interfaces/IApplicationContractContext.cs index 45fea88..57e6112 100644 --- a/src/FrontOffice.BFF.Application/Common/Interfaces/IApplicationContractContext.cs +++ b/src/FrontOffice.BFF.Application/Common/Interfaces/IApplicationContractContext.cs @@ -20,6 +20,7 @@ using CMSMicroservice.Protobuf.Protos.DiscountProduct; using CMSMicroservice.Protobuf.Protos.DiscountCategory; using CMSMicroservice.Protobuf.Protos.DiscountShoppingCart; using CMSMicroservice.Protobuf.Protos.DiscountOrder; +using CMSMicroservice.Protobuf.Protos.City; using PYMSMicroservice.Protobuf.Protos.Transaction; namespace FrontOffice.BFF.Application.Common.Interfaces; @@ -59,6 +60,9 @@ public interface IApplicationContractContext DiscountCategoryContract.DiscountCategoryContractClient DiscountCategories { get; } DiscountShoppingCartContract.DiscountShoppingCartContractClient DiscountCart { get; } DiscountOrderContract.DiscountOrderContractClient DiscountOrders { get; } + + // Geography System (GMS) + CityContract.CityContractClient Cities { get; } #endregion #region PYMS diff --git a/src/FrontOffice.BFF.Application/Common/Mappings/CityProfile.cs b/src/FrontOffice.BFF.Application/Common/Mappings/CityProfile.cs new file mode 100644 index 0000000..1ca9ba9 --- /dev/null +++ b/src/FrontOffice.BFF.Application/Common/Mappings/CityProfile.cs @@ -0,0 +1,54 @@ +using CMSMicroservice.Protobuf.Protos.City; +using FrontOffice.BFF.Application.CityCQ.Queries.GetAllCitiesByFilter; +using Mapster; +using CmsProtos = CMSMicroservice.Protobuf.Protos; + +namespace FrontOffice.BFF.Application.Common.Mappings; + +public class CityProfile : IRegister +{ + void IRegister.Register(TypeAdapterConfig config) + { + // Request: BFF → CMS + config.NewConfig() + .Map(dest => dest.PaginationState, src => src.PaginationState) + .Map(dest => dest.SortBy, src => src.SortBy) + .Map(dest => dest.Filter, src => src.Filter); + + // PaginationState is in CMSMicroservice.Protobuf.Protos namespace (from public_messages.proto) + config.NewConfig() + .Map(dest => dest.PageNumber, src => src.PageNumber) + .Map(dest => dest.PageSize, src => src.PageSize); + + config.NewConfig() + .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: CMS → BFF + config.NewConfig() + .Map(dest => dest.MetaData, src => src.MetaData) + .Map(dest => dest.Models, src => src.Models); + + // MetaData is in CMSMicroservice.Protobuf.Protos namespace (from public_messages.proto) + config.NewConfig() + .Map(dest => dest.CurrentPage, src => src.CurrentPage) + .Map(dest => dest.TotalPage, src => src.TotalPage) + .Map(dest => dest.PageSize, src => src.PageSize) + .Map(dest => dest.TotalCount, src => src.TotalCount) + .Map(dest => dest.HasPrevious, src => src.HasPrevious) + .Map(dest => dest.HasNext, src => src.HasNext); + + config.NewConfig() + .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); + } +} diff --git a/src/FrontOffice.BFF.Application/NetworkMembershipCQ/Queries/GetMyNetworkStatistics/GetMyNetworkStatisticsQueryHandler.cs b/src/FrontOffice.BFF.Application/NetworkMembershipCQ/Queries/GetMyNetworkStatistics/GetMyNetworkStatisticsQueryHandler.cs index 8e15fd9..0f6aaa6 100644 --- a/src/FrontOffice.BFF.Application/NetworkMembershipCQ/Queries/GetMyNetworkStatistics/GetMyNetworkStatisticsQueryHandler.cs +++ b/src/FrontOffice.BFF.Application/NetworkMembershipCQ/Queries/GetMyNetworkStatistics/GetMyNetworkStatisticsQueryHandler.cs @@ -19,32 +19,50 @@ public class GetMyNetworkStatisticsQueryHandler : IRequestHandler 0 ? (double)userNetwork.TotalLeftLegMembers / totalSubtreeMembers * 100 : 0; + var rightPercentage = totalSubtreeMembers > 0 ? (double)userNetwork.TotalRightLegMembers / totalSubtreeMembers * 100 : 0; - // Find last member from TopUsers if available - var lastMember = response.TopUsers.LastOrDefault(); + var weakerLeg = userNetwork.TotalLeftLegMembers < userNetwork.TotalRightLegMembers ? "Left" : "Right"; + + // Get tree to find last member and calculate depth + var treeRequest = new GetNetworkTreeRequest + { + UserId = userId, + MaxDepth = 20 // Get deep enough to find last member + }; + var treeResponse = await _context.NetworkMemberships.GetNetworkTreeAsync(treeRequest, cancellationToken: cancellationToken); + + // Find the last joined member in user's subtree + var lastMember = treeResponse.Nodes + .Where(n => n.UserId != userId && n.JoinedAt != null) + .OrderByDescending(n => n.JoinedAt) + .FirstOrDefault(); + + // Calculate max depth from tree nodes + var maxDepth = treeResponse.Nodes.Count > 0 + ? treeResponse.Nodes.Max(n => n.NetworkLevel) - userNetwork.NetworkLevel + : 0; + + // Calculate active members in user's subtree + var activeMembers = treeResponse.Nodes.Count(n => n.IsActive); return new GetMyNetworkStatisticsResponseDto { - // Overall network stats - TotalMembers = response.TotalMembers, - ActiveMembers = response.ActiveMembers, - LeftLegCount = response.LeftLegCount, - RightLegCount = response.RightLegCount, - LeftPercentage = response.LeftPercentage, - RightPercentage = response.RightPercentage, - AverageDepth = response.AverageDepth, - MaxDepth = response.MaxDepth, + // User's subtree stats from GetUserNetwork + TotalMembers = userNetwork.TotalNetworkSize, + ActiveMembers = activeMembers, + LeftLegCount = userNetwork.TotalLeftLegMembers, + RightLegCount = userNetwork.TotalRightLegMembers, + LeftPercentage = Math.Round(leftPercentage, 2), + RightPercentage = Math.Round(rightPercentage, 2), + AverageDepth = 0, // Not available from current API + MaxDepth = Math.Max(maxDepth, userNetwork.MaxNetworkDepth), WeakerLeg = weakerLeg, // User's personal info @@ -52,13 +70,13 @@ public class GetMyNetworkStatisticsQueryHandler : IRequestHandler lastMember.RightCount ? "Left" : "Right", - TotalChildren = lastMember.TotalChildren + FullName = lastMember.UserName ?? string.Empty, + Position = lastMember.NetworkLeg == 0 ? "Left" : "Right", + TotalChildren = 0 // Would need another call to get this } : null }; } diff --git a/src/FrontOffice.BFF.Application/NetworkMembershipCQ/Queries/GetMyNetworkTree/GetMyNetworkTreeQueryHandler.cs b/src/FrontOffice.BFF.Application/NetworkMembershipCQ/Queries/GetMyNetworkTree/GetMyNetworkTreeQueryHandler.cs index e81e9f2..7a5a456 100644 --- a/src/FrontOffice.BFF.Application/NetworkMembershipCQ/Queries/GetMyNetworkTree/GetMyNetworkTreeQueryHandler.cs +++ b/src/FrontOffice.BFF.Application/NetworkMembershipCQ/Queries/GetMyNetworkTree/GetMyNetworkTreeQueryHandler.cs @@ -81,6 +81,10 @@ public class GetMyNetworkTreeQueryHandler : IRequestHandler public bool HasChildren => LeftChild != null || RightChild != null; + + /// + /// آیا فعال است؟ + /// + public bool IsActive { get; set; } + + /// + /// تاریخ عضویت در شبکه + /// + public DateTime? JoinedAt { get; set; } + + /// + /// آیا در باشگاه فعال است؟ + /// + public bool IsClubActive { get; set; } + + /// + /// شماره هفته فعال‌سازی + /// + public string? ActivationWeekNumber { get; set; } } diff --git a/src/FrontOffice.BFF.Application/UserCQ/Commands/RefreshToken/RefreshTokenCommand.cs b/src/FrontOffice.BFF.Application/UserCQ/Commands/RefreshToken/RefreshTokenCommand.cs new file mode 100644 index 0000000..bf89ef4 --- /dev/null +++ b/src/FrontOffice.BFF.Application/UserCQ/Commands/RefreshToken/RefreshTokenCommand.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace FrontOffice.BFF.Application.UserCQ.Commands.RefreshToken; + +/// +/// درخواست رفرش توکن از BFF +/// +public sealed record RefreshTokenCommand : IRequest +{ + /// + /// توکن فعلی کاربر + /// + public string CurrentToken { get; init; } = null!; +} diff --git a/src/FrontOffice.BFF.Application/UserCQ/Commands/RefreshToken/RefreshTokenCommandHandler.cs b/src/FrontOffice.BFF.Application/UserCQ/Commands/RefreshToken/RefreshTokenCommandHandler.cs new file mode 100644 index 0000000..0ee6562 --- /dev/null +++ b/src/FrontOffice.BFF.Application/UserCQ/Commands/RefreshToken/RefreshTokenCommandHandler.cs @@ -0,0 +1,47 @@ +using FrontOffice.BFF.Application.Common.Interfaces; +using Mapster; +using MediatR; + +namespace FrontOffice.BFF.Application.UserCQ.Commands.RefreshToken; + +/// +/// هندلر رفرش توکن - فراخوانی CMS برای دریافت توکن جدید +/// +public class RefreshTokenCommandHandler : IRequestHandler +{ + private readonly IApplicationContractContext _context; + + public RefreshTokenCommandHandler(IApplicationContractContext context) + { + _context = context; + } + + public async Task Handle(RefreshTokenCommand request, CancellationToken cancellationToken) + { + try + { + var cmsRequest = new CMSMicroservice.Protobuf.Protos.User.RefreshTokenRequest + { + CurrentToken = request.CurrentToken + }; + + var response = await _context.User.RefreshTokenAsync(cmsRequest, cancellationToken: cancellationToken); + + return new RefreshTokenResponseDto + { + Token = response.Token, + Success = response.Success, + Message = response.Message + }; + } + catch (Exception ex) + { + return new RefreshTokenResponseDto + { + Success = false, + Message = $"خطا در رفرش توکن: {ex.Message}", + Token = string.Empty + }; + } + } +} diff --git a/src/FrontOffice.BFF.Application/UserCQ/Commands/RefreshToken/RefreshTokenResponseDto.cs b/src/FrontOffice.BFF.Application/UserCQ/Commands/RefreshToken/RefreshTokenResponseDto.cs new file mode 100644 index 0000000..99ea770 --- /dev/null +++ b/src/FrontOffice.BFF.Application/UserCQ/Commands/RefreshToken/RefreshTokenResponseDto.cs @@ -0,0 +1,22 @@ +namespace FrontOffice.BFF.Application.UserCQ.Commands.RefreshToken; + +/// +/// پاسخ رفرش توکن +/// +public class RefreshTokenResponseDto +{ + /// + /// توکن جدید + /// + public string Token { get; set; } = null!; + + /// + /// آیا عملیات موفق بود؟ + /// + public bool Success { get; set; } + + /// + /// پیام + /// + public string Message { get; set; } = null!; +} diff --git a/src/FrontOffice.BFF.Infrastructure/Services/ApplicationContractContext.cs b/src/FrontOffice.BFF.Infrastructure/Services/ApplicationContractContext.cs index 3bd2566..0ec3139 100644 --- a/src/FrontOffice.BFF.Infrastructure/Services/ApplicationContractContext.cs +++ b/src/FrontOffice.BFF.Infrastructure/Services/ApplicationContractContext.cs @@ -20,6 +20,7 @@ using CMSMicroservice.Protobuf.Protos.DiscountProduct; using CMSMicroservice.Protobuf.Protos.DiscountCategory; using CMSMicroservice.Protobuf.Protos.DiscountShoppingCart; using CMSMicroservice.Protobuf.Protos.DiscountOrder; +using CMSMicroservice.Protobuf.Protos.City; using FrontOffice.BFF.Application.Common.Interfaces; using Microsoft.Extensions.DependencyInjection; using PYMSMicroservice.Protobuf.Protos.Transaction; @@ -86,6 +87,9 @@ public class ApplicationContractContext : IApplicationContractContext public DiscountCategoryContract.DiscountCategoryContractClient DiscountCategories => GetService(); public DiscountShoppingCartContract.DiscountShoppingCartContractClient DiscountCart => GetService(); public DiscountOrderContract.DiscountOrderContractClient DiscountOrders => GetService(); + + // Geography System (GMS) + public CityContract.CityContractClient Cities => GetService(); #endregion #region PYMS diff --git a/src/FrontOffice.BFF.WebApi/BackgroundServices/CmsSignalRClientService.cs b/src/FrontOffice.BFF.WebApi/BackgroundServices/CmsSignalRClientService.cs new file mode 100644 index 0000000..51e99b7 --- /dev/null +++ b/src/FrontOffice.BFF.WebApi/BackgroundServices/CmsSignalRClientService.cs @@ -0,0 +1,184 @@ +using System.Threading; +using FrontOffice.BFF.WebApi.Hubs; +using Microsoft.AspNetCore.SignalR; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace FrontOffice.BFF.WebApi.BackgroundServices; + +/// +/// Background service that connects to CMS SignalR Hub and relays token notifications to Frontend clients. +/// This service maintains a persistent connection to CMS and forwards messages to the appropriate users. +/// +public class CmsSignalRClientService : BackgroundService +{ + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly IHubContext _hubContext; + private HubConnection? _cmsHubConnection; + + public CmsSignalRClientService( + ILogger logger, + IConfiguration configuration, + IHubContext hubContext) + { + _logger = logger; + _configuration = configuration; + _hubContext = hubContext; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ConnectToCmsHubAsync(stoppingToken); + + // Keep the connection alive + while (_cmsHubConnection?.State == HubConnectionState.Connected && !stoppingToken.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); + } + } + catch (OperationCanceledException) + { + _logger.LogInformation("CMS SignalR client service is stopping"); + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in CMS SignalR connection. Retrying in 5 seconds..."); + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + } + } + } + + private async Task ConnectToCmsHubAsync(CancellationToken stoppingToken) + { + var cmsBaseUrl = _configuration["GrpcChannelOptions:CMSMSAddress"]?.TrimEnd('/') ?? "http://localhost:5000"; + var hubPath = _configuration["CmsSignalR:HubPath"] ?? "/hubs/token-notification"; + var cmsSignalRUrl = $"{cmsBaseUrl}{hubPath}"; + + _logger.LogInformation("Connecting to CMS SignalR Hub at {Url}", cmsSignalRUrl); + + _cmsHubConnection = new HubConnectionBuilder() + .WithUrl(cmsSignalRUrl) + .WithAutomaticReconnect(new[] { TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10) }) + .Build(); + + // Register event handlers for CMS notifications + RegisterEventHandlers(); + + // Connect to CMS Hub + await _cmsHubConnection.StartAsync(stoppingToken); + + _logger.LogInformation("Successfully connected to CMS SignalR Hub"); + + // Handle reconnection events + _cmsHubConnection.Reconnecting += error => + { + _logger.LogWarning(error, "CMS SignalR connection lost. Attempting to reconnect..."); + return Task.CompletedTask; + }; + + _cmsHubConnection.Reconnected += connectionId => + { + _logger.LogInformation("CMS SignalR reconnected. ConnectionId: {ConnectionId}", connectionId); + return Task.CompletedTask; + }; + + _cmsHubConnection.Closed += async error => + { + _logger.LogWarning(error, "CMS SignalR connection closed"); + await Task.CompletedTask; + }; + } + + private void RegisterEventHandlers() + { + if (_cmsHubConnection == null) return; + + // Handle TokenRevoked event from CMS + _cmsHubConnection.On("TokenRevoked", async notification => + { + _logger.LogInformation("Received TokenRevoked for user {UserId}. Reason: {Reason}", + notification.UserId, notification.Reason); + + // Relay to Frontend clients subscribed to this user + await _hubContext.Clients.Group($"user_{notification.UserId}") + .SendAsync("TokenRevoked", new + { + notification.UserId, + notification.Reason, + notification.Timestamp + }); + }); + + // Handle ForceRefreshToken event from CMS + _cmsHubConnection.On("ForceRefreshToken", async notification => + { + _logger.LogInformation("Received ForceRefreshToken for user {UserId}", notification.UserId); + + // Relay to Frontend clients subscribed to this user + await _hubContext.Clients.Group($"user_{notification.UserId}") + .SendAsync("ForceRefreshToken", new + { + notification.UserId, + notification.Timestamp + }); + }); + + // Handle BroadcastMessage event from CMS + _cmsHubConnection.On("BroadcastMessage", async notification => + { + _logger.LogInformation("Received BroadcastMessage: {Message}", notification.Message); + + // Relay to all Frontend clients + await _hubContext.Clients.All.SendAsync("BroadcastMessage", new + { + notification.Message, + notification.Timestamp + }); + }); + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + if (_cmsHubConnection != null) + { + await _cmsHubConnection.DisposeAsync(); + } + await base.StopAsync(cancellationToken); + } +} + +/// +/// Notification payload for token revoked event (received from CMS) +/// +public class TokenRevokedNotification +{ + public long UserId { get; set; } + public string Reason { get; set; } = string.Empty; + public DateTime Timestamp { get; set; } +} + +/// +/// Notification payload for force refresh event (received from CMS) +/// +public class ForceRefreshNotification +{ + public long UserId { get; set; } + public DateTime Timestamp { get; set; } +} + +/// +/// Notification payload for broadcast message (received from CMS) +/// +public class BroadcastNotification +{ + public string Message { get; set; } = string.Empty; + public DateTime Timestamp { get; set; } +} diff --git a/src/FrontOffice.BFF.WebApi/Common/Mappings/CityProfile.cs b/src/FrontOffice.BFF.WebApi/Common/Mappings/CityProfile.cs new file mode 100644 index 0000000..962912f --- /dev/null +++ b/src/FrontOffice.BFF.WebApi/Common/Mappings/CityProfile.cs @@ -0,0 +1,51 @@ +using FrontOffice.BFF.Application.CityCQ.Queries.GetAllCitiesByFilter; +using FrontOffice.BFF.City.Protobuf; +using Mapster; + +namespace FrontOffice.BFF.WebApi.Common.Mappings; + +public class CityProfile : IRegister +{ + void IRegister.Register(TypeAdapterConfig config) + { + // Request: Proto → Application + config.NewConfig() + .Map(dest => dest.PaginationState, src => src.PaginationState) + .Map(dest => dest.SortBy, src => src.SortBy) + .Map(dest => dest.Filter, src => src.Filter); + + config.NewConfig() + .Map(dest => dest.PageNumber, src => src.PageNumber) + .Map(dest => dest.PageSize, src => src.PageSize); + + config.NewConfig() + .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() + .Map(dest => dest.MetaData, src => src.MetaData) + .Map(dest => dest.Models, src => src.Models); + + config.NewConfig() + .Map(dest => dest.CurrentPage, src => src.CurrentPage) + .Map(dest => dest.TotalPage, src => src.TotalPage) + .Map(dest => dest.PageSize, src => src.PageSize) + .Map(dest => dest.TotalCount, src => src.TotalCount) + .Map(dest => dest.HasPrevious, src => src.HasPrevious) + .Map(dest => dest.HasNext, src => src.HasNext); + + config.NewConfig() + .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); + } +} diff --git a/src/FrontOffice.BFF.WebApi/Common/Mappings/ClubMembershipProfile.cs b/src/FrontOffice.BFF.WebApi/Common/Mappings/ClubMembershipProfile.cs index f8f24dd..684eb7f 100644 --- a/src/FrontOffice.BFF.WebApi/Common/Mappings/ClubMembershipProfile.cs +++ b/src/FrontOffice.BFF.WebApi/Common/Mappings/ClubMembershipProfile.cs @@ -1,5 +1,7 @@ using FrontOffice.BFF.Application.ClubMembershipCQ.Queries.GetMyClubMembership; using FrontOffice.BFF.Application.ClubMembershipCQ.Commands.ActivateMyClubMembership; +using FrontOffice.BFF.Application.ClubMembershipCQ.Commands.RequestClubContractOtp; +using FrontOffice.BFF.Application.ClubMembershipCQ.Commands.AcceptClubMembershipContract; using Google.Protobuf.WellKnownTypes; using ProtoDto = FrontOffice.BFF.ClubMembership.Protobuf.Protos.ClubMembership; @@ -38,5 +40,27 @@ public class ClubMembershipProfile : IRegister .Map(dest => dest.ActivationDate, src => Timestamp.FromDateTime(DateTime.SpecifyKind(src.ActivationDate, DateTimeKind.Utc))) .Map(dest => dest.ExpirationDate, src => Timestamp.FromDateTime(DateTime.SpecifyKind(src.ExpirationDate, DateTimeKind.Utc))) .Map(dest => dest.AmountPaid, src => src.AmountPaid); + + // RequestClubContractOtp mappings + config.NewConfig() + .Map(dest => dest.SignGuid, src => src.SignGuid); + + config.NewConfig() + .Map(dest => dest.Success, src => src.Success) + .Map(dest => dest.Message, src => src.Message) + .Map(dest => dest.RemainingAttempts, src => src.RemainingAttempts) + .Map(dest => dest.RemainingSeconds, src => src.RemainingSeconds); + + // AcceptClubMembershipContract mappings + config.NewConfig() + .Map(dest => dest.OtpCode, src => src.OtpCode) + .Map(dest => dest.SignGuid, src => src.SignGuid) + .Map(dest => dest.ContractHtml, src => src.ContractHtml); + + config.NewConfig() + .Map(dest => dest.Success, src => src.Success) + .Map(dest => dest.Message, src => src.Message) + .Map(dest => dest.ContractId, src => src.ContractId) + .Map(dest => dest.Token, src => src.Token ?? ""); } } diff --git a/src/FrontOffice.BFF.WebApi/Common/Mappings/NetworkMembershipProfile.cs b/src/FrontOffice.BFF.WebApi/Common/Mappings/NetworkMembershipProfile.cs index eb98596..5a62b29 100644 --- a/src/FrontOffice.BFF.WebApi/Common/Mappings/NetworkMembershipProfile.cs +++ b/src/FrontOffice.BFF.WebApi/Common/Mappings/NetworkMembershipProfile.cs @@ -29,7 +29,11 @@ public class NetworkMembershipProfile : IRegister .Map(dest => dest.LeftChild, src => src.LeftChild) .Map(dest => dest.RightChild, src => src.RightChild) .Map(dest => dest.Level, src => src.Level) - .Map(dest => dest.HasChildren, src => src.HasChildren); + .Map(dest => dest.HasChildren, src => src.HasChildren) + .Map(dest => dest.IsActive, src => src.IsActive) + .Map(dest => dest.JoinedAt, src => src.JoinedAt.HasValue ? Timestamp.FromDateTime(DateTime.SpecifyKind(src.JoinedAt.Value, DateTimeKind.Utc)) : null) + .Map(dest => dest.IsClubActive, src => src.IsClubActive) + .Map(dest => dest.ActivationWeekNumber, src => src.ActivationWeekNumber); // Response mappings - Statistics config.NewConfig() diff --git a/src/FrontOffice.BFF.WebApi/ConfigureServices.cs b/src/FrontOffice.BFF.WebApi/ConfigureServices.cs index 7d7154b..39d8af9 100644 --- a/src/FrontOffice.BFF.WebApi/ConfigureServices.cs +++ b/src/FrontOffice.BFF.WebApi/ConfigureServices.cs @@ -1,4 +1,5 @@ using FrontOffice.BFF.Application.Common.Interfaces; +using FrontOffice.BFF.WebApi.BackgroundServices; using FrontOffice.BFF.WebApi.Common.Services; using MapsterMapper; using Microsoft.Extensions.Configuration; @@ -15,6 +16,13 @@ public static class ConfigureServices services.AddTransient(); services.AddTransient(); services.AddScoped(); + + // Add SignalR services + services.AddSignalR(); + + // Add background service for CMS SignalR client + services.AddHostedService(); + return services; } diff --git a/src/FrontOffice.BFF.WebApi/FrontOffice.BFF.WebApi.csproj b/src/FrontOffice.BFF.WebApi/FrontOffice.BFF.WebApi.csproj index 166626e..47791de 100644 --- a/src/FrontOffice.BFF.WebApi/FrontOffice.BFF.WebApi.csproj +++ b/src/FrontOffice.BFF.WebApi/FrontOffice.BFF.WebApi.csproj @@ -15,6 +15,8 @@ + + @@ -35,6 +37,7 @@ + diff --git a/src/FrontOffice.BFF.WebApi/Hubs/TokenRelayHub.cs b/src/FrontOffice.BFF.WebApi/Hubs/TokenRelayHub.cs new file mode 100644 index 0000000..31ebda2 --- /dev/null +++ b/src/FrontOffice.BFF.WebApi/Hubs/TokenRelayHub.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; + +namespace FrontOffice.BFF.WebApi.Hubs; + +/// +/// SignalR Hub for relaying token notifications from CMS to Frontend clients. +/// This hub is used by Frontend to receive token revocation/refresh notifications. +/// +[Authorize(Roles = "user")] +public class TokenRelayHub : Hub +{ + private readonly ILogger _logger; + + public TokenRelayHub(ILogger logger) + { + _logger = logger; + } + + public override async Task OnConnectedAsync() + { + var userId = Context.User?.FindFirst("userId")?.Value; + if (!string.IsNullOrEmpty(userId)) + { + // Subscribe this connection to the user's group for notifications + await Groups.AddToGroupAsync(Context.ConnectionId, $"user_{userId}"); + _logger.LogInformation("Frontend client connected to TokenRelayHub: {ConnectionId}, UserId: {UserId}", + Context.ConnectionId, userId); + } + else + { + _logger.LogWarning("Frontend client connected without userId: {ConnectionId}", Context.ConnectionId); + } + + await base.OnConnectedAsync(); + } + + public override async Task OnDisconnectedAsync(Exception? exception) + { + var userId = Context.User?.FindFirst("userId")?.Value; + if (!string.IsNullOrEmpty(userId)) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"user_{userId}"); + } + + _logger.LogInformation("Frontend client disconnected from TokenRelayHub: {ConnectionId}, Exception: {Exception}", + Context.ConnectionId, exception?.Message); + await base.OnDisconnectedAsync(exception); + } +} diff --git a/src/FrontOffice.BFF.WebApi/Program.cs b/src/FrontOffice.BFF.WebApi/Program.cs index 11a9af8..cedd76d 100644 --- a/src/FrontOffice.BFF.WebApi/Program.cs +++ b/src/FrontOffice.BFF.WebApi/Program.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Runtime.InteropServices; +using FrontOffice.BFF.WebApi.Hubs; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Server.Kestrel.Core; @@ -94,6 +95,10 @@ app.UseCors("AllowAll"); app.UseAuthentication(); app.UseAuthorization(); app.UseGrpcWeb(new GrpcWebOptions { DefaultEnabled = true }); // Configure the HTTP request pipeline. + +// Map SignalR Hub for token notifications to Frontend clients +app.MapHub("/hubs/token-relay"); + app.ConfigureGrpcEndpoints(Assembly.GetExecutingAssembly(), endpoints => { // endpoints.MapGrpcService(); diff --git a/src/FrontOffice.BFF.WebApi/Services/CityService.cs b/src/FrontOffice.BFF.WebApi/Services/CityService.cs new file mode 100644 index 0000000..1758438 --- /dev/null +++ b/src/FrontOffice.BFF.WebApi/Services/CityService.cs @@ -0,0 +1,25 @@ +using FrontOffice.BFF.Application.CityCQ.Queries.GetAllCitiesByFilter; +using FrontOffice.BFF.City.Protobuf; +using FrontOffice.BFF.WebApi.Common.Services; + +namespace FrontOffice.BFF.WebApi.Services; + +public class CityService : CityContract.CityContractBase +{ + private readonly IDispatchRequestToCQRS _dispatchRequestToCQRS; + + public CityService(IDispatchRequestToCQRS dispatchRequestToCQRS) + { + _dispatchRequestToCQRS = dispatchRequestToCQRS; + } + + public override async Task GetAllCitiesByFilter( + GetAllCitiesByFilterRequest request, + ServerCallContext context) + { + return await _dispatchRequestToCQRS.Handle< + GetAllCitiesByFilterRequest, + GetAllCitiesByFilterQuery, + GetAllCitiesByFilterResponse>(request, context); + } +} diff --git a/src/FrontOffice.BFF.WebApi/Services/ClubMembershipGrpcService.cs b/src/FrontOffice.BFF.WebApi/Services/ClubMembershipGrpcService.cs index 9457ade..cb83e6f 100644 --- a/src/FrontOffice.BFF.WebApi/Services/ClubMembershipGrpcService.cs +++ b/src/FrontOffice.BFF.WebApi/Services/ClubMembershipGrpcService.cs @@ -1,6 +1,8 @@ using FrontOffice.BFF.WebApi.Common.Services; using FrontOffice.BFF.Application.ClubMembershipCQ.Queries.GetMyClubMembership; using FrontOffice.BFF.Application.ClubMembershipCQ.Commands.ActivateMyClubMembership; +using FrontOffice.BFF.Application.ClubMembershipCQ.Commands.RequestClubContractOtp; +using FrontOffice.BFF.Application.ClubMembershipCQ.Commands.AcceptClubMembershipContract; using FrontOffice.BFF.ClubMembership.Protobuf.Protos.ClubMembership; namespace FrontOffice.BFF.WebApi.Services; @@ -32,4 +34,20 @@ public class ClubMembershipGrpcService : ClubMembershipContract.ClubMembershipCo { return await _dispatchRequestToCQRS.Handle(request, context); } + + /// + /// ارسال OTP برای امضای قرارداد باشگاه مشتریان + /// + public override async Task RequestClubContractOtp(RequestClubContractOtpRequest request, ServerCallContext context) + { + return await _dispatchRequestToCQRS.Handle(request, context); + } + + /// + /// امضای قرارداد باشگاه مشتریان و فعال‌سازی + /// + public override async Task AcceptClubMembershipContract(AcceptClubMembershipContractRequest request, ServerCallContext context) + { + return await _dispatchRequestToCQRS.Handle(request, context); + } } diff --git a/src/FrontOffice.BFF.WebApi/Services/UserService.cs b/src/FrontOffice.BFF.WebApi/Services/UserService.cs index a6bf9a1..d110aa3 100644 --- a/src/FrontOffice.BFF.WebApi/Services/UserService.cs +++ b/src/FrontOffice.BFF.WebApi/Services/UserService.cs @@ -8,6 +8,7 @@ using FrontOffice.BFF.Application.UserCQ.Commands.CreateNewOtpToken; using FrontOffice.BFF.Application.UserCQ.Commands.VerifyOtpToken; using FrontOffice.BFF.Application.UserCQ.Queries.AdminGetJwtToken; using FrontOffice.BFF.Application.UserCQ.Commands.SetPasswordForUser; +using FrontOffice.BFF.Application.UserCQ.Commands.RefreshToken; using FrontOffice.BFF.User.Protobuf.Protos.User; namespace FrontOffice.BFF.WebApi.Services; @@ -59,4 +60,10 @@ public class UserService : UserContract.UserContractBase { return await _dispatchRequestToCQRS.Handle(request, context); } + + [Authorize(Roles = "user")] + public override async Task RefreshToken(RefreshTokenRequest request, ServerCallContext context) + { + return await _dispatchRequestToCQRS.Handle(request, context); + } } diff --git a/src/FrontOffice.BFF.WebApi/appsettings.json b/src/FrontOffice.BFF.WebApi/appsettings.json index 7291698..54ed769 100644 --- a/src/FrontOffice.BFF.WebApi/appsettings.json +++ b/src/FrontOffice.BFF.WebApi/appsettings.json @@ -10,9 +10,14 @@ } }, "GrpcChannelOptions": { - "CMSMSAddress": "http://cms-svc", +// "CMSMSAddress": "http://cms-svc", +// "CMSMSAddress": "https://cms.foursat.afrino.co", + "CMSMSAddress": "https://localhost:32846/", "PYMSMSAddress": "https://ipg.afrino.co" }, + "CmsSignalR": { + "HubPath": "/hubs/token-notification" + }, "Authentication": { "Authority": "https://ids.domain.com/", "Audience": "domain_api" diff --git a/src/FrontOffice.BFF.sln b/src/FrontOffice.BFF.sln index 6fdb68c..877af94 100644 --- a/src/FrontOffice.BFF.sln +++ b/src/FrontOffice.BFF.sln @@ -39,6 +39,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FrontOffice.BFF.ClubMembers EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FrontOffice.BFF.NetworkMembership.Protobuf", "Protobufs\FrontOffice.BFF.NetworkMembership.Protobuf\FrontOffice.BFF.NetworkMembership.Protobuf.csproj", "{CCA23A57-4BC4-4C53-9A96-41FCFF5407F5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FrontOffice.BFF.City.Protobuf", "Protobufs\FrontOffice.BFF.City.Protobuf\FrontOffice.BFF.City.Protobuf.csproj", "{41403EBB-E67B-413B-8B7A-64D67E221984}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -253,6 +255,18 @@ Global {CCA23A57-4BC4-4C53-9A96-41FCFF5407F5}.Release|x64.Build.0 = Release|Any CPU {CCA23A57-4BC4-4C53-9A96-41FCFF5407F5}.Release|x86.ActiveCfg = Release|Any CPU {CCA23A57-4BC4-4C53-9A96-41FCFF5407F5}.Release|x86.Build.0 = Release|Any CPU + {41403EBB-E67B-413B-8B7A-64D67E221984}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {41403EBB-E67B-413B-8B7A-64D67E221984}.Debug|Any CPU.Build.0 = Debug|Any CPU + {41403EBB-E67B-413B-8B7A-64D67E221984}.Debug|x64.ActiveCfg = Debug|Any CPU + {41403EBB-E67B-413B-8B7A-64D67E221984}.Debug|x64.Build.0 = Debug|Any CPU + {41403EBB-E67B-413B-8B7A-64D67E221984}.Debug|x86.ActiveCfg = Debug|Any CPU + {41403EBB-E67B-413B-8B7A-64D67E221984}.Debug|x86.Build.0 = Debug|Any CPU + {41403EBB-E67B-413B-8B7A-64D67E221984}.Release|Any CPU.ActiveCfg = Release|Any CPU + {41403EBB-E67B-413B-8B7A-64D67E221984}.Release|Any CPU.Build.0 = Release|Any CPU + {41403EBB-E67B-413B-8B7A-64D67E221984}.Release|x64.ActiveCfg = Release|Any CPU + {41403EBB-E67B-413B-8B7A-64D67E221984}.Release|x64.Build.0 = Release|Any CPU + {41403EBB-E67B-413B-8B7A-64D67E221984}.Release|x86.ActiveCfg = Release|Any CPU + {41403EBB-E67B-413B-8B7A-64D67E221984}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -271,5 +285,6 @@ Global {B1380466-18E7-4CAD-88F8-E1419D2B6300} = {CA9BF4D6-6729-4011-888E-48F5F739B469} {B6EAE0A3-3427-4D86-B2BA-B185F476B74F} = {CA9BF4D6-6729-4011-888E-48F5F739B469} {CCA23A57-4BC4-4C53-9A96-41FCFF5407F5} = {CA9BF4D6-6729-4011-888E-48F5F739B469} + {41403EBB-E67B-413B-8B7A-64D67E221984} = {CA9BF4D6-6729-4011-888E-48F5F739B469} EndGlobalSection EndGlobal diff --git a/src/Protobufs/FrontOffice.BFF.City.Protobuf/FrontOffice.BFF.City.Protobuf.csproj b/src/Protobufs/FrontOffice.BFF.City.Protobuf/FrontOffice.BFF.City.Protobuf.csproj new file mode 100644 index 0000000..3251377 --- /dev/null +++ b/src/Protobufs/FrontOffice.BFF.City.Protobuf/FrontOffice.BFF.City.Protobuf.csproj @@ -0,0 +1,35 @@ + + + net7.0 + enable + enable + 0.0.1 + Foursat.FrontOffice.BFF.City.Protobuf + False + False + None + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + $(PackageOutputPath)$(PackageId).$(Version).nupkg + + dotnet nuget push **/*.nupkg --source https://git.afrino.co/api/packages/FourSat/nuget/index.json --api-key 061a5cb15517c6da39c16cfce8556c55ae104d0d --skip-duplicate + + + + + diff --git a/src/Protobufs/FrontOffice.BFF.City.Protobuf/Protos/city.proto b/src/Protobufs/FrontOffice.BFF.City.Protobuf/Protos/city.proto new file mode 100644 index 0000000..504b4d8 --- /dev/null +++ b/src/Protobufs/FrontOffice.BFF.City.Protobuf/Protos/city.proto @@ -0,0 +1,62 @@ +syntax = "proto3"; + +package city; + +import "google/protobuf/empty.proto"; +import "google/protobuf/wrappers.proto"; +import "google/protobuf/timestamp.proto"; +import "google/api/annotations.proto"; + +option csharp_namespace = "FrontOffice.BFF.City.Protobuf"; + +service CityContract { + rpc GetAllCitiesByFilter(GetAllCitiesByFilterRequest) returns (GetAllCitiesByFilterResponse){ + option (google.api.http) = { + get: "/GetAllCitiesByFilter" + }; + }; +} + +message GetAllCitiesByFilterRequest { + 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 { + 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; +} + +message PaginationState { + int32 page_number = 1; + int32 page_size = 2; +} + +message MetaData { + int64 current_page = 1; + int64 total_page = 2; + int64 page_size = 3; + int64 total_count = 4; + bool has_previous = 5; + bool has_next = 6; +} diff --git a/src/Protobufs/FrontOffice.BFF.City.Protobuf/Protos/google/api/annotations.proto b/src/Protobufs/FrontOffice.BFF.City.Protobuf/Protos/google/api/annotations.proto new file mode 100644 index 0000000..85c361b --- /dev/null +++ b/src/Protobufs/FrontOffice.BFF.City.Protobuf/Protos/google/api/annotations.proto @@ -0,0 +1,31 @@ +// Copyright (c) 2015, Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package google.api; + +import "google/api/http.proto"; +import "google/protobuf/descriptor.proto"; + +option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations"; +option java_multiple_files = true; +option java_outer_classname = "AnnotationsProto"; +option java_package = "com.google.api"; +option objc_class_prefix = "GAPI"; + +extend google.protobuf.MethodOptions { + // See `HttpRule`. + HttpRule http = 72295728; +} diff --git a/src/Protobufs/FrontOffice.BFF.City.Protobuf/Protos/google/api/http.proto b/src/Protobufs/FrontOffice.BFF.City.Protobuf/Protos/google/api/http.proto new file mode 100644 index 0000000..b8426ba --- /dev/null +++ b/src/Protobufs/FrontOffice.BFF.City.Protobuf/Protos/google/api/http.proto @@ -0,0 +1,377 @@ +// Copyright 2019 Google LLC. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +syntax = "proto3"; + +package google.api; + +option cc_enable_arenas = true; +option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations"; +option java_multiple_files = true; +option java_outer_classname = "HttpProto"; +option java_package = "com.google.api"; +option objc_class_prefix = "GAPI"; + +// Defines the HTTP configuration for an API service. It contains a list of +// [HttpRule][google.api.HttpRule], each specifying the mapping of an RPC method +// to one or more HTTP REST API methods. +message Http { + // A list of HTTP configuration rules that apply to individual API methods. + // + // **NOTE:** All service configuration rules follow "last one wins" order. + repeated HttpRule rules = 1; + + // When set to true, URL path parameters will be fully URI-decoded except in + // cases of single segment matches in reserved expansion, where "%2F" will be + // left encoded. + // + // The default behavior is to not decode RFC 6570 reserved characters in multi + // segment matches. + bool fully_decode_reserved_expansion = 2; +} + +// # gRPC Transcoding +// +// gRPC Transcoding is a feature for mapping between a gRPC method and one or +// more HTTP REST endpoints. It allows developers to build a single API service +// that supports both gRPC APIs and REST APIs. Many systems, including [Google +// APIs](https://github.com/googleapis/googleapis), +// [Cloud Endpoints](https://cloud.google.com/endpoints), [gRPC +// Gateway](https://github.com/grpc-ecosystem/grpc-gateway), +// and [Envoy](https://github.com/envoyproxy/envoy) proxy support this feature +// and use it for large scale production services. +// +// `HttpRule` defines the schema of the gRPC/REST mapping. The mapping specifies +// how different portions of the gRPC request message are mapped to the URL +// path, URL query parameters, and HTTP request body. It also controls how the +// gRPC response message is mapped to the HTTP response body. `HttpRule` is +// typically specified as an `google.api.http` annotation on the gRPC method. +// +// Each mapping specifies a URL path template and an HTTP method. The path +// template may refer to one or more fields in the gRPC request message, as long +// as each field is a non-repeated field with a primitive (non-message) type. +// The path template controls how fields of the request message are mapped to +// the URL path. +// +// Example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get: "/v1/{name=messages/*}" +// }; +// } +// } +// message GetMessageRequest { +// string name = 1; // Mapped to URL path. +// } +// message Message { +// string text = 1; // The resource content. +// } +// +// This enables an HTTP REST to gRPC mapping as below: +// +// HTTP | gRPC +// -----|----- +// `GET /v1/messages/123456` | `GetMessage(name: "messages/123456")` +// +// Any fields in the request message which are not bound by the path template +// automatically become HTTP query parameters if there is no HTTP request body. +// For example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get:"/v1/messages/{message_id}" +// }; +// } +// } +// message GetMessageRequest { +// message SubMessage { +// string subfield = 1; +// } +// string message_id = 1; // Mapped to URL path. +// int64 revision = 2; // Mapped to URL query parameter `revision`. +// SubMessage sub = 3; // Mapped to URL query parameter `sub.subfield`. +// } +// +// This enables a HTTP JSON to RPC mapping as below: +// +// HTTP | gRPC +// -----|----- +// `GET /v1/messages/123456?revision=2&sub.subfield=foo` | +// `GetMessage(message_id: "123456" revision: 2 sub: SubMessage(subfield: +// "foo"))` +// +// Note that fields which are mapped to URL query parameters must have a +// primitive type or a repeated primitive type or a non-repeated message type. +// In the case of a repeated type, the parameter can be repeated in the URL +// as `...?param=A¶m=B`. In the case of a message type, each field of the +// message is mapped to a separate parameter, such as +// `...?foo.a=A&foo.b=B&foo.c=C`. +// +// For HTTP methods that allow a request body, the `body` field +// specifies the mapping. Consider a REST update method on the +// message resource collection: +// +// service Messaging { +// rpc UpdateMessage(UpdateMessageRequest) returns (Message) { +// option (google.api.http) = { +// patch: "/v1/messages/{message_id}" +// body: "message" +// }; +// } +// } +// message UpdateMessageRequest { +// string message_id = 1; // mapped to the URL +// Message message = 2; // mapped to the body +// } +// +// The following HTTP JSON to RPC mapping is enabled, where the +// representation of the JSON in the request body is determined by +// protos JSON encoding: +// +// HTTP | gRPC +// -----|----- +// `PATCH /v1/messages/123456 { "text": "Hi!" }` | `UpdateMessage(message_id: +// "123456" message { text: "Hi!" })` +// +// The special name `*` can be used in the body mapping to define that +// every field not bound by the path template should be mapped to the +// request body. This enables the following alternative definition of +// the update method: +// +// service Messaging { +// rpc UpdateMessage(Message) returns (Message) { +// option (google.api.http) = { +// patch: "/v1/messages/{message_id}" +// body: "*" +// }; +// } +// } +// message Message { +// string message_id = 1; +// string text = 2; +// } +// +// +// The following HTTP JSON to RPC mapping is enabled: +// +// HTTP | gRPC +// -----|----- +// `PATCH /v1/messages/123456 { "text": "Hi!" }` | `UpdateMessage(message_id: +// "123456" text: "Hi!")` +// +// Note that when using `*` in the body mapping, it is not possible to +// have HTTP parameters, as all fields not bound by the path end in +// the body. This makes this option more rarely used in practice when +// defining REST APIs. The common usage of `*` is in custom methods +// which don't use the URL at all for transferring data. +// +// It is possible to define multiple HTTP methods for one RPC by using +// the `additional_bindings` option. Example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get: "/v1/messages/{message_id}" +// additional_bindings { +// get: "/v1/users/{user_id}/messages/{message_id}" +// } +// }; +// } +// } +// message GetMessageRequest { +// string message_id = 1; +// string user_id = 2; +// } +// +// This enables the following two alternative HTTP JSON to RPC mappings: +// +// HTTP | gRPC +// -----|----- +// `GET /v1/messages/123456` | `GetMessage(message_id: "123456")` +// `GET /v1/users/me/messages/123456` | `GetMessage(user_id: "me" message_id: +// "123456")` +// +// ## Rules for HTTP mapping +// +// 1. Leaf request fields (recursive expansion nested messages in the request +// message) are classified into three categories: +// - Fields referred by the path template. They are passed via the URL path. +// - Fields referred by the [HttpRule.body][google.api.HttpRule.body]. They are passed via the HTTP +// request body. +// - All other fields are passed via the URL query parameters, and the +// parameter name is the field path in the request message. A repeated +// field can be represented as multiple query parameters under the same +// name. +// 2. If [HttpRule.body][google.api.HttpRule.body] is "*", there is no URL query parameter, all fields +// are passed via URL path and HTTP request body. +// 3. If [HttpRule.body][google.api.HttpRule.body] is omitted, there is no HTTP request body, all +// fields are passed via URL path and URL query parameters. +// +// ### Path template syntax +// +// Template = "/" Segments [ Verb ] ; +// Segments = Segment { "/" Segment } ; +// Segment = "*" | "**" | LITERAL | Variable ; +// Variable = "{" FieldPath [ "=" Segments ] "}" ; +// FieldPath = IDENT { "." IDENT } ; +// Verb = ":" LITERAL ; +// +// The syntax `*` matches a single URL path segment. The syntax `**` matches +// zero or more URL path segments, which must be the last part of the URL path +// except the `Verb`. +// +// The syntax `Variable` matches part of the URL path as specified by its +// template. A variable template must not contain other variables. If a variable +// matches a single path segment, its template may be omitted, e.g. `{var}` +// is equivalent to `{var=*}`. +// +// The syntax `LITERAL` matches literal text in the URL path. If the `LITERAL` +// contains any reserved character, such characters should be percent-encoded +// before the matching. +// +// If a variable contains exactly one path segment, such as `"{var}"` or +// `"{var=*}"`, when such a variable is expanded into a URL path on the client +// side, all characters except `[-_.~0-9a-zA-Z]` are percent-encoded. The +// server side does the reverse decoding. Such variables show up in the +// [Discovery +// Document](https://developers.google.com/discovery/v1/reference/apis) as +// `{var}`. +// +// If a variable contains multiple path segments, such as `"{var=foo/*}"` +// or `"{var=**}"`, when such a variable is expanded into a URL path on the +// client side, all characters except `[-_.~/0-9a-zA-Z]` are percent-encoded. +// The server side does the reverse decoding, except "%2F" and "%2f" are left +// unchanged. Such variables show up in the +// [Discovery +// Document](https://developers.google.com/discovery/v1/reference/apis) as +// `{+var}`. +// +// ## Using gRPC API Service Configuration +// +// gRPC API Service Configuration (service config) is a configuration language +// for configuring a gRPC service to become a user-facing product. The +// service config is simply the YAML representation of the `google.api.Service` +// proto message. +// +// As an alternative to annotating your proto file, you can configure gRPC +// transcoding in your service config YAML files. You do this by specifying a +// `HttpRule` that maps the gRPC method to a REST endpoint, achieving the same +// effect as the proto annotation. This can be particularly useful if you +// have a proto that is reused in multiple services. Note that any transcoding +// specified in the service config will override any matching transcoding +// configuration in the proto. +// +// Example: +// +// http: +// rules: +// # Selects a gRPC method and applies HttpRule to it. +// - selector: example.v1.Messaging.GetMessage +// get: /v1/messages/{message_id}/{sub.subfield} +// +// ## Special notes +// +// When gRPC Transcoding is used to map a gRPC to JSON REST endpoints, the +// proto to JSON conversion must follow the [proto3 +// specification](https://developers.google.com/protocol-buffers/docs/proto3#json). +// +// While the single segment variable follows the semantics of +// [RFC 6570](https://tools.ietf.org/html/rfc6570) Section 3.2.2 Simple String +// Expansion, the multi segment variable **does not** follow RFC 6570 Section +// 3.2.3 Reserved Expansion. The reason is that the Reserved Expansion +// does not expand special characters like `?` and `#`, which would lead +// to invalid URLs. As the result, gRPC Transcoding uses a custom encoding +// for multi segment variables. +// +// The path variables **must not** refer to any repeated or mapped field, +// because client libraries are not capable of handling such variable expansion. +// +// The path variables **must not** capture the leading "/" character. The reason +// is that the most common use case "{var}" does not capture the leading "/" +// character. For consistency, all path variables must share the same behavior. +// +// Repeated message fields must not be mapped to URL query parameters, because +// no client library can support such complicated mapping. +// +// If an API needs to use a JSON array for request or response body, it can map +// the request or response body to a repeated field. However, some gRPC +// Transcoding implementations may not support this feature. +message HttpRule { + // Selects a method to which this rule applies. + // + // Refer to [selector][google.api.DocumentationRule.selector] for syntax details. + string selector = 1; + + // Determines the URL pattern is matched by this rules. This pattern can be + // used with any of the {get|put|post|delete|patch} methods. A custom method + // can be defined using the 'custom' field. + oneof pattern { + // Maps to HTTP GET. Used for listing and getting information about + // resources. + string get = 2; + + // Maps to HTTP PUT. Used for replacing a resource. + string put = 3; + + // Maps to HTTP POST. Used for creating a resource or performing an action. + string post = 4; + + // Maps to HTTP DELETE. Used for deleting a resource. + string delete = 5; + + // Maps to HTTP PATCH. Used for updating a resource. + string patch = 6; + + // The custom pattern is used for specifying an HTTP method that is not + // included in the `pattern` field, such as HEAD, or "*" to leave the + // HTTP method unspecified for this rule. The wild-card rule is useful + // for services that provide content to Web (HTML) clients. + CustomHttpPattern custom = 8; + } + + // The name of the request field whose value is mapped to the HTTP request + // body, or `*` for mapping all request fields not captured by the path + // pattern to the HTTP body, or omitted for not having any HTTP request body. + // + // NOTE: the referred field must be present at the top-level of the request + // message type. + string body = 7; + + // Optional. The name of the response field whose value is mapped to the HTTP + // response body. When omitted, the entire response message will be used + // as the HTTP response body. + // + // NOTE: The referred field must be present at the top-level of the response + // message type. + string response_body = 12; + + // Additional HTTP bindings for the selector. Nested bindings must + // not contain an `additional_bindings` field themselves (that is, + // the nesting may only be one level deep). + repeated HttpRule additional_bindings = 11; +} + +// A custom pattern is used for defining custom HTTP verb. +message CustomHttpPattern { + // The name of this custom HTTP verb. + string kind = 1; + + // The path matched by this custom verb. + string path = 2; +} + diff --git a/src/Protobufs/FrontOffice.BFF.ClubMembership.Protobuf/Protos/clubmembership.proto b/src/Protobufs/FrontOffice.BFF.ClubMembership.Protobuf/Protos/clubmembership.proto index e2b6953..349d0c0 100644 --- a/src/Protobufs/FrontOffice.BFF.ClubMembership.Protobuf/Protos/clubmembership.proto +++ b/src/Protobufs/FrontOffice.BFF.ClubMembership.Protobuf/Protos/clubmembership.proto @@ -26,6 +26,22 @@ service ClubMembershipContract body: "*" }; }; + + // ارسال OTP برای امضای قرارداد باشگاه مشتریان + rpc RequestClubContractOtp(RequestClubContractOtpRequest) returns (RequestClubContractOtpResponse){ + option (google.api.http) = { + post: "/ClubMembership/RequestContractOtp" + body: "*" + }; + }; + + // امضای قرارداد باشگاه مشتریان و فعال‌سازی + rpc AcceptClubMembershipContract(AcceptClubMembershipContractRequest) returns (AcceptClubMembershipContractResponse){ + option (google.api.http) = { + post: "/ClubMembership/AcceptContract" + body: "*" + }; + }; } // ============ GetMyClubMembership ============ @@ -59,3 +75,35 @@ message ActivateMyClubMembershipResponse google.protobuf.Timestamp expiration_date = 4; int64 amount_paid = 5; } + +// ============ RequestClubContractOtp ============ +// درخواست ارسال کد OTP برای امضای قرارداد باشگاه مشتریان +message RequestClubContractOtpRequest +{ + string sign_guid = 1; // شناسه یکتای امضا (GUID) +} + +message RequestClubContractOtpResponse +{ + bool success = 1; + string message = 2; + int32 remaining_attempts = 3; + int32 remaining_seconds = 4; +} + +// ============ AcceptClubMembershipContract ============ +// امضای قرارداد باشگاه مشتریان +message AcceptClubMembershipContractRequest +{ + string otp_code = 1; // کد OTP دریافتی + string sign_guid = 2; // شناسه یکتای امضا + string contract_html = 3; // محتوای HTML قرارداد +} + +message AcceptClubMembershipContractResponse +{ + bool success = 1; + string message = 2; + int64 contract_id = 3; + string token = 4; // توکن جدید با claims به‌روز شده +} diff --git a/src/Protobufs/FrontOffice.BFF.NetworkMembership.Protobuf/Protos/networkmembership.proto b/src/Protobufs/FrontOffice.BFF.NetworkMembership.Protobuf/Protos/networkmembership.proto index f2edf8f..7a386a3 100644 --- a/src/Protobufs/FrontOffice.BFF.NetworkMembership.Protobuf/Protos/networkmembership.proto +++ b/src/Protobufs/FrontOffice.BFF.NetworkMembership.Protobuf/Protos/networkmembership.proto @@ -58,6 +58,10 @@ message NetworkNodeModel NetworkNodeModel right_child = 7; int32 level = 8; bool has_children = 9; + bool is_active = 10; + google.protobuf.Timestamp joined_at = 11; + bool is_club_active = 12; + google.protobuf.StringValue activation_week_number = 13; } // ============ GetMyNetworkStatistics ============ diff --git a/src/Protobufs/FrontOffice.BFF.User.Protobuf/Protos/user.proto b/src/Protobufs/FrontOffice.BFF.User.Protobuf/Protos/user.proto index 1eedbb0..188a140 100644 --- a/src/Protobufs/FrontOffice.BFF.User.Protobuf/Protos/user.proto +++ b/src/Protobufs/FrontOffice.BFF.User.Protobuf/Protos/user.proto @@ -66,6 +66,12 @@ service UserContract body: "*" }; }; + rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse){ + option (google.api.http) = { + post: "/RefreshToken" + body: "*" + }; + }; } message UpdateUserRequest { @@ -205,6 +211,16 @@ message AcceptContractRequestResponse { string token = 1; } +message RefreshTokenRequest +{ + string current_token = 1; +} +message RefreshTokenResponse +{ + string token = 1; + bool success = 2; + string message = 3; +} message PaginationState {