feat: add club membership contract signing and city services

This commit is contained in:
masoodafar-web
2025-12-18 00:44:44 +03:30
parent 63f05e0883
commit 4330ec3726
38 changed files with 1583 additions and 25 deletions
@@ -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;
/// <summary>
/// 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.
/// </summary>
public class CmsSignalRClientService : BackgroundService
{
private readonly ILogger<CmsSignalRClientService> _logger;
private readonly IConfiguration _configuration;
private readonly IHubContext<TokenRelayHub> _hubContext;
private HubConnection? _cmsHubConnection;
public CmsSignalRClientService(
ILogger<CmsSignalRClientService> logger,
IConfiguration configuration,
IHubContext<TokenRelayHub> 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<TokenRevokedNotification>("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<ForceRefreshNotification>("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<BroadcastNotification>("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);
}
}
/// <summary>
/// Notification payload for token revoked event (received from CMS)
/// </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 (received from CMS)
/// </summary>
public class ForceRefreshNotification
{
public long UserId { get; set; }
public DateTime Timestamp { get; set; }
}
/// <summary>
/// Notification payload for broadcast message (received from CMS)
/// </summary>
public class BroadcastNotification
{
public string Message { get; set; } = string.Empty;
public DateTime Timestamp { get; set; }
}
@@ -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<GetAllCitiesByFilterRequest, GetAllCitiesByFilterQuery>()
.Map(dest => dest.PaginationState, src => src.PaginationState)
.Map(dest => dest.SortBy, src => src.SortBy)
.Map(dest => dest.Filter, src => src.Filter);
config.NewConfig<PaginationState, PaginationStateDto>()
.Map(dest => dest.PageNumber, src => src.PageNumber)
.Map(dest => dest.PageSize, src => src.PageSize);
config.NewConfig<GetAllCitiesByFilterFilter, GetAllCitiesByFilterFilterDto>()
.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<GetAllCitiesByFilterResponseDto, GetAllCitiesByFilterResponse>()
.Map(dest => dest.MetaData, src => src.MetaData)
.Map(dest => dest.Models, src => src.Models);
config.NewConfig<MetaDataDto, MetaData>()
.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<CityDto, 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,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<ProtoDto.RequestClubContractOtpRequest, RequestClubContractOtpCommand>()
.Map(dest => dest.SignGuid, src => src.SignGuid);
config.NewConfig<RequestClubContractOtpResponseDto, ProtoDto.RequestClubContractOtpResponse>()
.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<ProtoDto.AcceptClubMembershipContractRequest, AcceptClubMembershipContractCommand>()
.Map(dest => dest.OtpCode, src => src.OtpCode)
.Map(dest => dest.SignGuid, src => src.SignGuid)
.Map(dest => dest.ContractHtml, src => src.ContractHtml);
config.NewConfig<AcceptClubMembershipContractResponseDto, ProtoDto.AcceptClubMembershipContractResponse>()
.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 ?? "");
}
}
@@ -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<GetMyNetworkStatisticsResponseDto, ProtoDto.GetMyNetworkStatisticsResponse>()
@@ -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<ICurrentUserService, CurrentUserService>();
services.AddTransient<ITokenProvider, AppTokenProvider>();
services.AddScoped<IDispatchRequestToCQRS, DispatchRequestToCQRS>();
// Add SignalR services
services.AddSignalR();
// Add background service for CMS SignalR client
services.AddHostedService<CmsSignalRClientService>();
return services;
}
@@ -15,6 +15,8 @@
<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.Client" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
@@ -35,6 +37,7 @@
<ProjectReference Include="..\Protobufs\FrontOffice.BFF.Commission.Protobuf\FrontOffice.BFF.Commission.Protobuf.csproj" />
<ProjectReference Include="..\Protobufs\FrontOffice.BFF.ClubMembership.Protobuf\FrontOffice.BFF.ClubMembership.Protobuf.csproj" />
<ProjectReference Include="..\Protobufs\FrontOffice.BFF.NetworkMembership.Protobuf\FrontOffice.BFF.NetworkMembership.Protobuf.csproj" />
<ProjectReference Include="..\Protobufs\FrontOffice.BFF.City.Protobuf\FrontOffice.BFF.City.Protobuf.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace FrontOffice.BFF.WebApi.Hubs;
/// <summary>
/// SignalR Hub for relaying token notifications from CMS to Frontend clients.
/// This hub is used by Frontend to receive token revocation/refresh notifications.
/// </summary>
[Authorize(Roles = "user")]
public class TokenRelayHub : Hub
{
private readonly ILogger<TokenRelayHub> _logger;
public TokenRelayHub(ILogger<TokenRelayHub> 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);
}
}
+5
View File
@@ -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<TokenRelayHub>("/hubs/token-relay");
app.ConfigureGrpcEndpoints(Assembly.GetExecutingAssembly(), endpoints =>
{
// endpoints.MapGrpcService<ProductService>();
@@ -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<GetAllCitiesByFilterResponse> GetAllCitiesByFilter(
GetAllCitiesByFilterRequest request,
ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<
GetAllCitiesByFilterRequest,
GetAllCitiesByFilterQuery,
GetAllCitiesByFilterResponse>(request, context);
}
}
@@ -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<ActivateMyClubMembershipRequest, ActivateMyClubMembershipCommand, ActivateMyClubMembershipResponse>(request, context);
}
/// <summary>
/// ارسال OTP برای امضای قرارداد باشگاه مشتریان
/// </summary>
public override async Task<RequestClubContractOtpResponse> RequestClubContractOtp(RequestClubContractOtpRequest request, ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<RequestClubContractOtpRequest, RequestClubContractOtpCommand, RequestClubContractOtpResponse>(request, context);
}
/// <summary>
/// امضای قرارداد باشگاه مشتریان و فعال‌سازی
/// </summary>
public override async Task<AcceptClubMembershipContractResponse> AcceptClubMembershipContract(AcceptClubMembershipContractRequest request, ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<AcceptClubMembershipContractRequest, AcceptClubMembershipContractCommand, AcceptClubMembershipContractResponse>(request, context);
}
}
@@ -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<AcceptContractRequest, AcceptContractCommand, AcceptContractRequestResponse>(request, context);
}
[Authorize(Roles = "user")]
public override async Task<RefreshTokenResponse> RefreshToken(RefreshTokenRequest request, ServerCallContext context)
{
return await _dispatchRequestToCQRS.Handle<RefreshTokenRequest, RefreshTokenCommand, RefreshTokenResponse>(request, context);
}
}
+6 -1
View File
@@ -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"