- Added `NotifyBillingStatusChangedAsync` method to `IRealtimeNotifier` for notifying clients about changes in billing status. - Updated `BillingConfigResumer` to call the new notification method after modifying billing configurations, ensuring users receive real-time updates. - Enhanced `ListUsersQueryHandler` to include a `BillingPendingReview` property in `UserSummaryDto`, indicating if a user has a pending payment request awaiting confirmation. - Refactored various command handlers to utilize `AdvisoryLock` for managing concurrent requests, preventing race conditions in billing operations. - Updated tests to cover new notification behaviors and ensure proper functionality in billing status management.
132 lines
5.0 KiB
C#
132 lines
5.0 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using PnvPanel.Application.Auth;
|
|
using PnvPanel.Application.Common.Concurrency;
|
|
using PnvPanel.Application.Common.Interfaces;
|
|
using PnvPanel.Application.Common.Messaging;
|
|
using PnvPanel.Application.Common.Models;
|
|
using PnvPanel.Domain.Support;
|
|
|
|
namespace PnvPanel.Application.Support.CreateRoleRequest;
|
|
|
|
/// <summary>Проверка "нет висящей заявки" + создание — под AdvisoryLock (по UserId), см.
|
|
/// CreatePaymentRequestCommandHandler для полного обоснования.</summary>
|
|
public sealed class CreateRoleRequestTicketCommandHandler(
|
|
IAppDbContext dbContext,
|
|
IRoleService roleService,
|
|
IRealtimeNotifier notifier,
|
|
ITelegramNotifier telegramNotifier,
|
|
ICurrentUser currentUser
|
|
) : ICommandHandler<CreateRoleRequestTicketCommand, Result<TicketDetailDto>>
|
|
{
|
|
// Совпадает со значением Infrastructure.Identity.RoleNames.Admin — Application не может ссылаться
|
|
// на Infrastructure (направление зависимостей), поэтому системное имя роли продублировано здесь.
|
|
private const string AdminRoleName = "admin";
|
|
|
|
public async Task<Result<TicketDetailDto>> Handle(
|
|
CreateRoleRequestTicketCommand command,
|
|
CancellationToken cancellationToken
|
|
)
|
|
{
|
|
if (currentUser.UserId is not { } userId)
|
|
return Result.Failure<TicketDetailDto>(AuthErrors.Unauthorized);
|
|
|
|
string? requestedRoleName = null;
|
|
if (command.ExistingRoleId is { } existingRoleId)
|
|
{
|
|
var roles = await roleService.ListRolesAsync(cancellationToken);
|
|
var role = roles.FirstOrDefault(r => r.Id == existingRoleId);
|
|
if (role is null)
|
|
return Result.Failure<TicketDetailDto>(SupportErrors.RoleNotFound);
|
|
|
|
if (role.Name.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase))
|
|
return Result.Failure<TicketDetailDto>(SupportErrors.CannotRequestAdminRole);
|
|
|
|
requestedRoleName = role.Name;
|
|
}
|
|
|
|
var claimed = await AdvisoryLock.RunAsync(
|
|
dbContext,
|
|
userId,
|
|
async lockedCancellationToken =>
|
|
{
|
|
var hasPending = await dbContext.SupportTickets.AnyAsync(
|
|
t =>
|
|
t.UserId == userId
|
|
&& t.Type == TicketType.RoleRequest
|
|
&& t.Status == TicketStatus.Open,
|
|
lockedCancellationToken
|
|
);
|
|
if (hasPending)
|
|
return Result.Failure<(SupportTicket, TicketComment)>(
|
|
SupportErrors.RoleRequestAlreadyPending
|
|
);
|
|
|
|
var newTicket =
|
|
command.ExistingRoleId is { } roleId
|
|
? SupportTicket.CreateRoleRequestForExistingRole(userId, roleId)
|
|
: SupportTicket.CreateRoleRequestForNewRole(
|
|
userId,
|
|
command.NewRoleName!,
|
|
command.NewRoleMaxConfigs!.Value,
|
|
command.NewRoleMaxIpLimit!.Value
|
|
);
|
|
dbContext.SupportTickets.Add(newTicket);
|
|
var newComment = TicketComment.Create(newTicket.Id, userId, command.Justification);
|
|
dbContext.TicketComments.Add(newComment);
|
|
return Result.Success((newTicket, newComment));
|
|
},
|
|
cancellationToken
|
|
);
|
|
if (!claimed.IsSuccess)
|
|
return Result.Failure<TicketDetailDto>(claimed.Error);
|
|
|
|
var (ticket, comment) = claimed.Value;
|
|
var userName = currentUser.UserName ?? userId.ToString();
|
|
var roleDescription =
|
|
requestedRoleName
|
|
?? $"новая роль «{command.NewRoleName}» (конфигов: {command.NewRoleMaxConfigs}, IP: {command.NewRoleMaxIpLimit})";
|
|
|
|
await notifier.NotifyTicketCreatedAsync(
|
|
ticket.Id,
|
|
userId,
|
|
userName,
|
|
ticket.Type,
|
|
cancellationToken
|
|
);
|
|
await telegramNotifier.NotifyAdminsRoleRequestCreatedAsync(
|
|
ticket.Id,
|
|
userName,
|
|
roleDescription,
|
|
command.Justification,
|
|
cancellationToken
|
|
);
|
|
|
|
var commentDto = new TicketCommentDto(
|
|
comment.Id,
|
|
userId,
|
|
userName,
|
|
comment.Body,
|
|
comment.CreatedAt,
|
|
[]
|
|
);
|
|
|
|
var dto = new TicketDetailDto(
|
|
ticket.Id,
|
|
ticket.UserId,
|
|
userName,
|
|
ticket.Type,
|
|
ticket.Status,
|
|
ticket.RequestedRoleId,
|
|
requestedRoleName,
|
|
ticket.ProposedRoleName,
|
|
ticket.ProposedMaxConfigs,
|
|
ticket.ProposedMaxIpLimit,
|
|
ticket.RequestedDays,
|
|
ticket.CreatedAt,
|
|
[commentDto]
|
|
);
|
|
|
|
return Result.Success(dto);
|
|
}
|
|
}
|