Implement billing status notification and enhance user management integration
CI / Backend (build + test) (push) Successful in 1m30s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s

- 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.
This commit is contained in:
Leonid Pershin
2026-07-19 23:22:57 +03:00
parent b32756d5bc
commit e19860ba46
49 changed files with 1195 additions and 233 deletions
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Billing;
using PnvPanel.Application.Common.Concurrency;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
@@ -8,6 +9,8 @@ using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Support.CreateExtensionRequest;
/// <summary>Проверка "нет висящей заявки" + создание — под AdvisoryLock (по UserId), см.
/// CreatePaymentRequestCommandHandler для полного обоснования.</summary>
public sealed class CreateExtensionRequestTicketCommandHandler(
IAppDbContext dbContext,
IIdentityService identityService,
@@ -31,22 +34,35 @@ public sealed class CreateExtensionRequestTicketCommandHandler(
if (!profile.BillingEnabled)
return Result.Failure<TicketDetailDto>(BillingErrors.NotEnabled);
var hasPending = await dbContext.SupportTickets.AnyAsync(
t =>
t.UserId == userId
&& t.Type == TicketType.ExtensionRequest
&& t.Status == TicketStatus.Open,
var claimed = await AdvisoryLock.RunAsync(
dbContext,
userId,
async lockedCancellationToken =>
{
var hasPending = await dbContext.SupportTickets.AnyAsync(
t =>
t.UserId == userId
&& t.Type == TicketType.ExtensionRequest
&& t.Status == TicketStatus.Open,
lockedCancellationToken
);
if (hasPending)
return Result.Failure<(SupportTicket, TicketComment)>(
SupportErrors.ExtensionRequestAlreadyPending
);
var newTicket = SupportTicket.CreateExtensionRequest(userId, command.RequestedDays);
dbContext.SupportTickets.Add(newTicket);
var newComment = TicketComment.Create(newTicket.Id, userId, command.Justification);
dbContext.TicketComments.Add(newComment);
return Result.Success((newTicket, newComment));
},
cancellationToken
);
if (hasPending)
return Result.Failure<TicketDetailDto>(SupportErrors.ExtensionRequestAlreadyPending);
var ticket = SupportTicket.CreateExtensionRequest(userId, command.RequestedDays);
dbContext.SupportTickets.Add(ticket);
var comment = TicketComment.Create(ticket.Id, userId, command.Justification);
dbContext.TicketComments.Add(comment);
if (!claimed.IsSuccess)
return Result.Failure<TicketDetailDto>(claimed.Error);
var (ticket, comment) = claimed.Value;
var userName = currentUser.UserName ?? userId.ToString();
await notifier.NotifyTicketCreatedAsync(
@@ -1,5 +1,6 @@
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;
@@ -7,6 +8,8 @@ using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Support.CreateRoleRequest;
/// <summary>Проверка "нет висящей заявки" + создание — под AdvisoryLock (по UserId), см.
/// CreatePaymentRequestCommandHandler для полного обоснования.</summary>
public sealed class CreateRoleRequestTicketCommandHandler(
IAppDbContext dbContext,
IRoleService roleService,
@@ -27,47 +30,57 @@ public sealed class CreateRoleRequestTicketCommandHandler(
if (currentUser.UserId is not { } userId)
return Result.Failure<TicketDetailDto>(AuthErrors.Unauthorized);
var hasPending = await dbContext.SupportTickets.AnyAsync(
t =>
t.UserId == userId
&& t.Type == TicketType.RoleRequest
&& t.Status == TicketStatus.Open,
cancellationToken
);
if (hasPending)
return Result.Failure<TicketDetailDto>(SupportErrors.RoleRequestAlreadyPending);
SupportTicket ticket;
string? requestedRoleName = null;
if (command.ExistingRoleId is { } roleId)
if (command.ExistingRoleId is { } existingRoleId)
{
var roles = await roleService.ListRolesAsync(cancellationToken);
var role = roles.FirstOrDefault(r => r.Id == roleId);
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);
ticket = SupportTicket.CreateRoleRequestForExistingRole(userId, roleId);
requestedRoleName = role.Name;
}
else
{
ticket = SupportTicket.CreateRoleRequestForNewRole(
userId,
command.NewRoleName!,
command.NewRoleMaxConfigs!.Value,
command.NewRoleMaxIpLimit!.Value
);
}
dbContext.SupportTickets.Add(ticket);
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 comment = TicketComment.Create(ticket.Id, userId, command.Justification);
dbContext.TicketComments.Add(comment);
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
@@ -28,6 +28,9 @@ public sealed class ReopenTicketCommandHandler(
if (ticket is null)
return Result.Failure(SupportErrors.NotFound);
if (ticket.Type != TicketType.BugReport)
return Result.Failure(SupportErrors.OnlyBugReportCanBeReopened);
if (ticket.Status != TicketStatus.Resolved)
return Result.Failure(SupportErrors.NotResolved);
@@ -38,6 +38,26 @@ public static class SupportErrors
"Переоткрыть можно только решённый тикет."
);
/// <summary>Заявка на роль/продление решается только через approve/reject — там же выполняется
/// сама выдача роли/дней. Общий resolve/close без этого шага молча "проглотил" бы заявку, ничего
/// не выдав пользователю.</summary>
public static readonly Error OnlyBugReportCanBeResolvedDirectly = Error.Validation(
"Support.OnlyBugReportCanBeResolvedDirectly",
"Заявку на роль или продление можно только одобрить/отклонить, а не решить напрямую."
);
public static readonly Error OnlyBugReportCanBeClosedDirectly = Error.Validation(
"Support.OnlyBugReportCanBeClosedDirectly",
"Заявку на роль или продление можно только одобрить/отклонить, а не закрыть напрямую."
);
/// <summary>Заявка на роль/продление одноразовая: повторное одобрение начислило бы дни/роль ещё
/// раз. Новый запрос — новый тикет, не переоткрытие старого.</summary>
public static readonly Error OnlyBugReportCanBeReopened = Error.Validation(
"Support.OnlyBugReportCanBeReopened",
"Заявку на роль или продление нельзя переоткрыть — оформите новую."
);
public static readonly Error RoleRequestAlreadyPending = Error.Conflict(
"Support.RoleRequestAlreadyPending",
"У вас уже есть необработанная заявка на роль."