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,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;
@@ -9,6 +10,8 @@ using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Admin.Support;
/// <summary>Проверка статуса + Close() — под AdvisoryLock (по Id тикета), см.
/// ApproveRoleRequestCommandHandler для полного обоснования.</summary>
public sealed class RejectRoleRequestCommandHandler(
IAppDbContext dbContext,
IRealtimeNotifier notifier,
@@ -24,23 +27,36 @@ public sealed class RejectRoleRequestCommandHandler(
if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized);
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(
t => t.Id == command.TicketId,
var claimed = await AdvisoryLock.RunAsync(
dbContext,
command.TicketId,
async lockedCancellationToken =>
{
var fresh = await dbContext.SupportTickets.FirstOrDefaultAsync(
t => t.Id == command.TicketId,
lockedCancellationToken
);
if (fresh is null)
return Result.Failure<SupportTicket>(SupportErrors.NotFound);
if (fresh.Type != TicketType.RoleRequest)
return Result.Failure<SupportTicket>(SupportErrors.NotRoleRequest);
if (fresh.Status == TicketStatus.Closed)
return Result.Failure<SupportTicket>(SupportErrors.AlreadyClosed);
if (!string.IsNullOrWhiteSpace(command.Reason))
dbContext.TicketComments.Add(TicketComment.Create(fresh.Id, adminId, command.Reason));
fresh.Close();
return Result.Success(fresh);
},
cancellationToken
);
if (ticket is null)
return Result.Failure(SupportErrors.NotFound);
if (!claimed.IsSuccess)
return Result.Failure(claimed.Error);
if (ticket.Type != TicketType.RoleRequest)
return Result.Failure(SupportErrors.NotRoleRequest);
if (ticket.Status == TicketStatus.Closed)
return Result.Failure(SupportErrors.AlreadyClosed);
if (!string.IsNullOrWhiteSpace(command.Reason))
dbContext.TicketComments.Add(TicketComment.Create(ticket.Id, adminId, command.Reason));
ticket.Close();
var ticket = claimed.Value;
dbContext.AuditLogs.Add(
AuditLog.Create(