diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminBillingEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminBillingEndpoints.cs index 2e63a00..eafe66c 100644 --- a/backend/src/PnvPanel.Api/Endpoints/AdminBillingEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/AdminBillingEndpoints.cs @@ -26,6 +26,7 @@ public static class AdminBillingEndpoints admin .MapPost("/requests/{id:guid}/reject", RejectRequest) .Produces(StatusCodes.Status204NoContent); + admin.MapPost("/gift", GrantGift).Produces(StatusCodes.Status204NoContent); return app; } @@ -80,6 +81,19 @@ public static class AdminBillingEndpoints ); return result.ToHttpResult(); } + + private static async Task GrantGift( + GrantGiftBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new GrantBillingGiftCommand(body.UserId, body.Days), + cancellationToken + ); + return result.ToHttpResult(); + } } public sealed record ListPaymentRequestsRequest( @@ -89,3 +103,5 @@ public sealed record ListPaymentRequestsRequest( ); public sealed record RejectPaymentRequestBody(string? Reason); + +public sealed record GrantGiftBody(Guid UserId, int Days); diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminSupportEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminSupportEndpoints.cs index 20acc4c..8aa6506 100644 --- a/backend/src/PnvPanel.Api/Endpoints/AdminSupportEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/AdminSupportEndpoints.cs @@ -33,6 +33,12 @@ public static class AdminSupportEndpoints admin .MapPost("/tickets/{id:guid}/reject", RejectRoleRequest) .Produces(StatusCodes.Status204NoContent); + admin + .MapPost("/tickets/{id:guid}/approve-extension", ApproveExtensionRequest) + .Produces(StatusCodes.Status204NoContent); + admin + .MapPost("/tickets/{id:guid}/reject-extension", RejectExtensionRequest) + .Produces(StatusCodes.Status204NoContent); return app; } @@ -119,6 +125,32 @@ public static class AdminSupportEndpoints ); return result.ToHttpResult(); } + + private static async Task ApproveExtensionRequest( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new ApproveExtensionRequestCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task RejectExtensionRequest( + Guid id, + RejectExtensionRequestBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new RejectExtensionRequestCommand(id, body.Reason), + cancellationToken + ); + return result.ToHttpResult(); + } } public sealed record RejectRoleRequestBody(string? Reason); + +public sealed record RejectExtensionRequestBody(string? Reason); diff --git a/backend/src/PnvPanel.Api/Endpoints/SupportEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/SupportEndpoints.cs index 53b1fd9..4c9015d 100644 --- a/backend/src/PnvPanel.Api/Endpoints/SupportEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/SupportEndpoints.cs @@ -6,6 +6,7 @@ using PnvPanel.Application.Common.Models; using PnvPanel.Application.Support; using PnvPanel.Application.Support.AddComment; using PnvPanel.Application.Support.CreateBugReport; +using PnvPanel.Application.Support.CreateExtensionRequest; using PnvPanel.Application.Support.CreateRoleRequest; using PnvPanel.Application.Support.GetAttachment; using PnvPanel.Application.Support.GetSupportPricing; @@ -30,6 +31,9 @@ public static class SupportEndpoints .DisableAntiforgery() .Produces(); group.MapPost("/tickets/role-requests", CreateRoleRequest).Produces(); + group + .MapPost("/tickets/extension-requests", CreateExtensionRequest) + .Produces(); group.MapGet("/tickets", ListMyTickets).Produces>(); group.MapGet("/tickets/{id:guid}", GetTicket).Produces(); group @@ -89,6 +93,17 @@ public static class SupportEndpoints return result.ToHttpResult(); } + private static async Task CreateExtensionRequest( + CreateExtensionRequestBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var command = new CreateExtensionRequestTicketCommand(body.RequestedDays, body.Justification); + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + private static async Task ListMyTickets( [AsParameters] ListTicketsRequest request, ISender sender, @@ -175,6 +190,8 @@ public sealed record CreateRoleRequestBody( string Justification ); +public sealed record CreateExtensionRequestBody(int RequestedDays, string Justification); + public sealed record ListTicketsRequest( TicketType? Type, TicketStatus? Status, diff --git a/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs b/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs index 84b328e..0ad2813 100644 --- a/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs +++ b/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs @@ -489,6 +489,55 @@ public sealed class PnvBotUpdateHandler( break; } + case "erq": + { + if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken)) + { + await botClient.AnswerCallbackQuery( + callback.Id, + "Недостаточно прав.", + cancellationToken: cancellationToken + ); + return; + } + + var result = + parts[1] == "approve" + ? await sender.Send( + new ApproveExtensionRequestCommand(requestId), + cancellationToken + ) + : await sender.Send( + new RejectExtensionRequestCommand(requestId, Reason: null), + cancellationToken + ); + + await botClient.AnswerCallbackQuery( + callback.Id, + result.IsSuccess ? "Готово" : result.Error.Message, + cancellationToken: cancellationToken + ); + if (callback.Message is not null) + { + var statusText = result.IsSuccess + ? ( + parts[1] == "approve" + ? "✅ Заявка на продление одобрена." + : "❌ Заявка отклонена." + ) + : $"⚠️ {result.Error.Message}"; + var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}"; + await botClient.EditMessageText( + chatId.Value, + callback.Message.Id, + text, + parseMode: ParseMode.Html, + cancellationToken: cancellationToken + ); + } + + break; + } case "pay": { if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken)) diff --git a/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs b/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs index ad0543f..77233a7 100644 --- a/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs +++ b/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs @@ -144,6 +144,47 @@ internal sealed class TelegramNotifier( } } + public async Task NotifyAdminsExtensionRequestCreatedAsync( + Guid ticketId, + string userName, + int requestedDays, + string justification, + CancellationToken cancellationToken + ) + { + if (string.IsNullOrWhiteSpace(options.Value.BotToken)) + return; + + var text = + $"🆕 Заявка на продление от {Escape(userName)} — {requestedDays} дн.\nОбоснование: {Escape(justification)}"; + + var keyboard = new InlineKeyboardMarkup( + new[] + { + InlineKeyboardButton.WithCallbackData("✅ Одобрить", $"erq:approve:{ticketId}"), + InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"erq:reject:{ticketId}"), + } + ); + + foreach (var adminId in options.Value.ParseAdminTelegramUserIds()) + { + try + { + await botClient.SendMessage( + adminId, + text, + parseMode: ParseMode.Html, + replyMarkup: keyboard, + cancellationToken: cancellationToken + ); + } + catch + { + // Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду. + } + } + } + public async Task NotifyAdminsTicketReopenedAsync( Guid ticketId, string userName, diff --git a/backend/src/PnvPanel.Application/Admin/Billing/ConfirmPaymentRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Billing/ConfirmPaymentRequestCommandHandler.cs index 73ef6f6..c840455 100644 --- a/backend/src/PnvPanel.Application/Admin/Billing/ConfirmPaymentRequestCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Billing/ConfirmPaymentRequestCommandHandler.cs @@ -7,7 +7,6 @@ using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Models; using PnvPanel.Domain.Audit; using PnvPanel.Domain.Billing; -using PnvPanel.Domain.Configs; namespace PnvPanel.Application.Admin.Billing; @@ -65,64 +64,15 @@ public sealed class ConfirmPaymentRequestCommandHandler( request.Confirm(adminId); - var configs = await dbContext - .VpnConfigs.Where(c => - c.UserId == request.UserId - && (c.Status == ConfigStatus.Active || c.Status == ConfigStatus.Expired) - ) - .ToListAsync(cancellationToken); - - foreach (var config in configs) - { - if (config.Status == ConfigStatus.Expired) - { - var inbound = await dbContext - .Inbounds.AsNoTracking() - .FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken); - var node = inbound is null - ? null - : await dbContext - .Nodes.AsNoTracking() - .FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); - - if (inbound is not null && node is not null) - { - var updateResult = await gateway.UpdateClientAsync( - node, - inbound.RemoteInboundId, - config.ClientExternalId, - config.Protocol, - config.Label ?? config.ClientEmail, - enable: true, - cancellationToken - ); - - if (!updateResult.IsSuccess) - { - // Нода недоступна — не трогаем локальный статус, подхватится следующим - // подтверждением/циклом BillingService (идемпотентно). - logger.LogWarning( - "Failed to enable client for config {ConfigId} on node {NodeId} while confirming payment {RequestId}: {Error}", - config.Id, - node.Id, - request.Id, - updateResult.Error - ); - continue; - } - } - - config.Resume(); - await notifier.NotifyConfigStatusChangedAsync( - config.UserId, - config.Id, - config.Status, - cancellationToken - ); - } - - config.SetBillingExpiry(newPaidUntil); - } + await BillingConfigResumer.ResumeConfigsAsync( + dbContext, + gateway, + notifier, + logger, + request.UserId, + newPaidUntil, + cancellationToken + ); dbContext.AuditLogs.Add( AuditLog.Create( diff --git a/backend/src/PnvPanel.Application/Admin/Billing/GrantBillingGiftCommand.cs b/backend/src/PnvPanel.Application/Admin/Billing/GrantBillingGiftCommand.cs new file mode 100644 index 0000000..80e9f47 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Billing/GrantBillingGiftCommand.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Billing; + +/// Админ дарит пользователю N дней подписки — продлевает BillingPaidUntil от +/// max(текущий, сейчас), возвращает приостановленные конфиги, шлёт уведомление пользователю. +public sealed record GrantBillingGiftCommand(Guid UserId, int Days) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Billing/GrantBillingGiftCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Billing/GrantBillingGiftCommandHandler.cs new file mode 100644 index 0000000..7beb9d6 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Billing/GrantBillingGiftCommandHandler.cs @@ -0,0 +1,76 @@ +using Microsoft.Extensions.Logging; +using PnvPanel.Application.Admin.Users; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Billing; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Audit; + +namespace PnvPanel.Application.Admin.Billing; + +public sealed class GrantBillingGiftCommandHandler( + IAppDbContext dbContext, + IIdentityService identityService, + IXuiPanelGateway gateway, + IRealtimeNotifier notifier, + ITelegramNotifier telegramNotifier, + ICurrentUser currentUser, + ILogger logger +) : ICommandHandler +{ + public async Task Handle(GrantBillingGiftCommand command, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } adminId) + return Result.Failure(AuthErrors.Unauthorized); + + var profile = await identityService.GetProfileAsync(command.UserId, cancellationToken); + if (profile is null) + return Result.Failure(UserErrors.NotFound); + + if (!profile.BillingEnabled) + return Result.Failure(BillingErrors.NotEnabled); + + var now = DateTimeOffset.UtcNow; + var baseline = profile.BillingPaidUntil is { } paidUntil && paidUntil > now ? paidUntil : now; + var newPaidUntil = baseline.AddDays(command.Days); + + var extendResult = await identityService.ExtendBillingPaidUntilAsync( + command.UserId, + newPaidUntil, + cancellationToken + ); + if (!extendResult.IsSuccess) + return extendResult; + + await BillingConfigResumer.ResumeConfigsAsync( + dbContext, + gateway, + notifier, + logger, + command.UserId, + newPaidUntil, + cancellationToken + ); + + dbContext.AuditLogs.Add( + AuditLog.Create( + adminId, + "BillingGiftGranted", + "User", + command.UserId.ToString(), + metadata: $"{{\"days\":{command.Days}}}", + AuditSource.Web + ) + ); + + await telegramNotifier.NotifyUserAsync( + command.UserId, + $"🎁 Вам подарено {command.Days} дн. подписки! Доступ продлён до {newPaidUntil:dd.MM.yyyy}.", + "/billing", + cancellationToken + ); + + return Result.Success(); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Billing/GrantBillingGiftCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Billing/GrantBillingGiftCommandValidator.cs new file mode 100644 index 0000000..8fd74d0 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Billing/GrantBillingGiftCommandValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.Billing; + +public sealed class GrantBillingGiftCommandValidator : AbstractValidator +{ + public GrantBillingGiftCommandValidator() + { + RuleFor(x => x.Days).GreaterThan(0).LessThanOrEqualTo(365); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Support/ApproveExtensionRequestCommand.cs b/backend/src/PnvPanel.Application/Admin/Support/ApproveExtensionRequestCommand.cs new file mode 100644 index 0000000..1f9d34c --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Support/ApproveExtensionRequestCommand.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Support; + +/// Одобрение продлевает AppUser.BillingPaidUntil на RequestedDays (от max(текущий, сейчас)) +/// и возвращает в Active конфиги, приостановленные за неуплату — см. BillingConfigResumer. +public sealed record ApproveExtensionRequestCommand(Guid TicketId) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Support/ApproveExtensionRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/ApproveExtensionRequestCommandHandler.cs new file mode 100644 index 0000000..60fa033 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Support/ApproveExtensionRequestCommandHandler.cs @@ -0,0 +1,94 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Billing; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Support; +using PnvPanel.Domain.Audit; +using PnvPanel.Domain.Support; + +namespace PnvPanel.Application.Admin.Support; + +public sealed class ApproveExtensionRequestCommandHandler( + IAppDbContext dbContext, + IIdentityService identityService, + IXuiPanelGateway gateway, + IRealtimeNotifier notifier, + ITelegramNotifier telegramNotifier, + ICurrentUser currentUser, + ILogger logger +) : ICommandHandler +{ + public async Task Handle( + ApproveExtensionRequestCommand command, + CancellationToken cancellationToken + ) + { + if (currentUser.UserId is not { } adminId) + return Result.Failure(AuthErrors.Unauthorized); + + var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync( + t => t.Id == command.TicketId, + cancellationToken + ); + if (ticket is null) + return Result.Failure(SupportErrors.NotFound); + + if (ticket.Type != TicketType.ExtensionRequest) + return Result.Failure(SupportErrors.NotExtensionRequest); + + if (ticket.Status != TicketStatus.Open) + return Result.Failure(SupportErrors.NotOpen); + + var profile = await identityService.GetProfileAsync(ticket.UserId, cancellationToken); + if (profile is null) + return Result.Failure(AuthErrors.Unauthorized); + + var now = DateTimeOffset.UtcNow; + var baseline = profile.BillingPaidUntil is { } paidUntil && paidUntil > now ? paidUntil : now; + var newPaidUntil = baseline.AddDays(ticket.RequestedDays!.Value); + + var extendResult = await identityService.ExtendBillingPaidUntilAsync( + ticket.UserId, + newPaidUntil, + cancellationToken + ); + if (!extendResult.IsSuccess) + return extendResult; + + await BillingConfigResumer.ResumeConfigsAsync( + dbContext, + gateway, + notifier, + logger, + ticket.UserId, + newPaidUntil, + cancellationToken + ); + + ticket.Resolve(); + + dbContext.AuditLogs.Add( + AuditLog.Create( + adminId, + "ExtensionRequestApproved", + "SupportTicket", + ticket.Id.ToString(), + metadata: null, + AuditSource.Web + ) + ); + + await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken); + await telegramNotifier.NotifyUserAsync( + ticket.UserId, + $"✅ Заявка на продление одобрена. Доступ продлён до {newPaidUntil:dd.MM.yyyy}.", + $"/support?ticket={ticket.Id}", + cancellationToken + ); + + return Result.Success(); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Support/RejectExtensionRequestCommand.cs b/backend/src/PnvPanel.Application/Admin/Support/RejectExtensionRequestCommand.cs new file mode 100644 index 0000000..3a5e662 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Support/RejectExtensionRequestCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Support; + +public sealed record RejectExtensionRequestCommand(Guid TicketId, string? Reason) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Support/RejectExtensionRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/RejectExtensionRequestCommandHandler.cs new file mode 100644 index 0000000..9b2f640 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Support/RejectExtensionRequestCommandHandler.cs @@ -0,0 +1,66 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Support; +using PnvPanel.Domain.Audit; +using PnvPanel.Domain.Support; + +namespace PnvPanel.Application.Admin.Support; + +public sealed class RejectExtensionRequestCommandHandler( + IAppDbContext dbContext, + IRealtimeNotifier notifier, + ITelegramNotifier telegramNotifier, + ICurrentUser currentUser +) : ICommandHandler +{ + public async Task Handle( + RejectExtensionRequestCommand command, + CancellationToken cancellationToken + ) + { + if (currentUser.UserId is not { } adminId) + return Result.Failure(AuthErrors.Unauthorized); + + var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync( + t => t.Id == command.TicketId, + cancellationToken + ); + if (ticket is null) + return Result.Failure(SupportErrors.NotFound); + + if (ticket.Type != TicketType.ExtensionRequest) + return Result.Failure(SupportErrors.NotExtensionRequest); + + 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(); + + dbContext.AuditLogs.Add( + AuditLog.Create( + adminId, + "ExtensionRequestRejected", + "SupportTicket", + ticket.Id.ToString(), + metadata: null, + AuditSource.Web + ) + ); + + await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken); + await telegramNotifier.NotifyUserAsync( + ticket.UserId, + "❌ Ваша заявка на продление отклонена.", + $"/support?ticket={ticket.Id}", + cancellationToken + ); + + return Result.Success(); + } +} diff --git a/backend/src/PnvPanel.Application/Billing/BillingConfigResumer.cs b/backend/src/PnvPanel.Application/Billing/BillingConfigResumer.cs new file mode 100644 index 0000000..427c3f5 --- /dev/null +++ b/backend/src/PnvPanel.Application/Billing/BillingConfigResumer.cs @@ -0,0 +1,87 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Domain.Configs; + +namespace PnvPanel.Application.Billing; + +/// +/// Общая часть "продлить оплату" — возвращает в Active конфиги, приостановленные за неуплату +/// (Expired), и синхронизирует ExpiresAt на все конфиги пользователя. Используется при подтверждении +/// заявки на оплату, одобрении заявки на продление и выдаче гифт-дней админом — расчёт самого +/// newPaidUntil (AddMonths для платежа, AddDays для продления/гифта) остаётся на вызывающей стороне, +/// как и AppUser.BillingPaidUntil (см. IIdentityService.ExtendBillingPaidUntilAsync, зовётся отдельно +/// до этого helper'а). +/// +internal static class BillingConfigResumer +{ + public static async Task ResumeConfigsAsync( + IAppDbContext dbContext, + IXuiPanelGateway gateway, + IRealtimeNotifier notifier, + ILogger logger, + Guid userId, + DateTimeOffset newPaidUntil, + CancellationToken cancellationToken + ) + { + var configs = await dbContext + .VpnConfigs.Where(c => + c.UserId == userId + && (c.Status == ConfigStatus.Active || c.Status == ConfigStatus.Expired) + ) + .ToListAsync(cancellationToken); + + foreach (var config in configs) + { + if (config.Status == ConfigStatus.Expired) + { + var inbound = await dbContext + .Inbounds.AsNoTracking() + .FirstOrDefaultAsync(i => i.Id == config.InboundId, cancellationToken); + var node = inbound is null + ? null + : await dbContext + .Nodes.AsNoTracking() + .FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); + + if (inbound is not null && node is not null) + { + var updateResult = await gateway.UpdateClientAsync( + node, + inbound.RemoteInboundId, + config.ClientExternalId, + config.Protocol, + config.Label ?? config.ClientEmail, + enable: true, + cancellationToken + ); + + if (!updateResult.IsSuccess) + { + // Нода недоступна — не трогаем локальный статус, подхватится следующим + // продлением/циклом BillingService (идемпотентно). + logger.LogWarning( + "Failed to enable client for config {ConfigId} on node {NodeId} while extending billing for user {UserId}: {Error}", + config.Id, + node.Id, + userId, + updateResult.Error + ); + continue; + } + } + + config.Resume(); + await notifier.NotifyConfigStatusChangedAsync( + config.UserId, + config.Id, + config.Status, + cancellationToken + ); + } + + config.SetBillingExpiry(newPaidUntil); + } + } +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs b/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs index ce00b2b..fab32da 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs @@ -62,4 +62,14 @@ public interface ITelegramNotifier int amount, CancellationToken cancellationToken ); + + /// Заявка на продление оплаченного периода — инлайн-кнопки «Одобрить/Отклонить», решается + /// полностью в Telegram (аналогично заявке на роль). + Task NotifyAdminsExtensionRequestCreatedAsync( + Guid ticketId, + string userName, + int requestedDays, + string justification, + CancellationToken cancellationToken + ); } diff --git a/backend/src/PnvPanel.Application/Support/CreateBugReport/CreateBugReportTicketCommandHandler.cs b/backend/src/PnvPanel.Application/Support/CreateBugReport/CreateBugReportTicketCommandHandler.cs index 943d22d..a133551 100644 --- a/backend/src/PnvPanel.Application/Support/CreateBugReport/CreateBugReportTicketCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Support/CreateBugReport/CreateBugReportTicketCommandHandler.cs @@ -77,6 +77,7 @@ public sealed class CreateBugReportTicketCommandHandler( null, null, null, + null, ticket.CreatedAt, [commentDto] ); diff --git a/backend/src/PnvPanel.Application/Support/CreateExtensionRequest/CreateExtensionRequestTicketCommand.cs b/backend/src/PnvPanel.Application/Support/CreateExtensionRequest/CreateExtensionRequestTicketCommand.cs new file mode 100644 index 0000000..d5a166a --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/CreateExtensionRequest/CreateExtensionRequestTicketCommand.cs @@ -0,0 +1,10 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Support.CreateExtensionRequest; + +/// Заявка на продление оплаченного периода — только для ролей с включённым биллингом +/// (AppRole.BillingEnabled), см. CreateExtensionRequestTicketCommandHandler. +public sealed record CreateExtensionRequestTicketCommand(int RequestedDays, string Justification) + : ICommand>, + IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Support/CreateExtensionRequest/CreateExtensionRequestTicketCommandHandler.cs b/backend/src/PnvPanel.Application/Support/CreateExtensionRequest/CreateExtensionRequestTicketCommandHandler.cs new file mode 100644 index 0000000..39c4238 --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/CreateExtensionRequest/CreateExtensionRequestTicketCommandHandler.cs @@ -0,0 +1,94 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Auth; +using PnvPanel.Application.Billing; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Domain.Support; + +namespace PnvPanel.Application.Support.CreateExtensionRequest; + +public sealed class CreateExtensionRequestTicketCommandHandler( + IAppDbContext dbContext, + IIdentityService identityService, + IRealtimeNotifier notifier, + ITelegramNotifier telegramNotifier, + ICurrentUser currentUser +) : ICommandHandler> +{ + public async Task> Handle( + CreateExtensionRequestTicketCommand command, + CancellationToken cancellationToken + ) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + var profile = await identityService.GetProfileAsync(userId, cancellationToken); + if (profile is null) + return Result.Failure(AuthErrors.Unauthorized); + + if (!profile.BillingEnabled) + return Result.Failure(BillingErrors.NotEnabled); + + var hasPending = await dbContext.SupportTickets.AnyAsync( + t => + t.UserId == userId + && t.Type == TicketType.ExtensionRequest + && t.Status == TicketStatus.Open, + cancellationToken + ); + if (hasPending) + return Result.Failure(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); + + var userName = currentUser.UserName ?? userId.ToString(); + + await notifier.NotifyTicketCreatedAsync( + ticket.Id, + userId, + userName, + ticket.Type, + cancellationToken + ); + await telegramNotifier.NotifyAdminsExtensionRequestCreatedAsync( + ticket.Id, + userName, + command.RequestedDays, + 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, + null, + null, + null, + null, + null, + ticket.RequestedDays, + ticket.CreatedAt, + [commentDto] + ); + + return Result.Success(dto); + } +} diff --git a/backend/src/PnvPanel.Application/Support/CreateExtensionRequest/CreateExtensionRequestTicketCommandValidator.cs b/backend/src/PnvPanel.Application/Support/CreateExtensionRequest/CreateExtensionRequestTicketCommandValidator.cs new file mode 100644 index 0000000..9043851 --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/CreateExtensionRequest/CreateExtensionRequestTicketCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace PnvPanel.Application.Support.CreateExtensionRequest; + +public sealed class CreateExtensionRequestTicketCommandValidator + : AbstractValidator +{ + public CreateExtensionRequestTicketCommandValidator() + { + RuleFor(x => x.RequestedDays).GreaterThan(0).LessThanOrEqualTo(365); + RuleFor(x => x.Justification).NotEmpty().MaximumLength(4000); + } +} diff --git a/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommandHandler.cs b/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommandHandler.cs index 079599e..ad284b7 100644 --- a/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommandHandler.cs @@ -108,6 +108,7 @@ public sealed class CreateRoleRequestTicketCommandHandler( ticket.ProposedRoleName, ticket.ProposedMaxConfigs, ticket.ProposedMaxIpLimit, + ticket.RequestedDays, ticket.CreatedAt, [commentDto] ); diff --git a/backend/src/PnvPanel.Application/Support/SupportErrors.cs b/backend/src/PnvPanel.Application/Support/SupportErrors.cs index 6cd738f..0f70b87 100644 --- a/backend/src/PnvPanel.Application/Support/SupportErrors.cs +++ b/backend/src/PnvPanel.Application/Support/SupportErrors.cs @@ -48,6 +48,16 @@ public static class SupportErrors "Это не заявка на роль." ); + public static readonly Error ExtensionRequestAlreadyPending = Error.Conflict( + "Support.ExtensionRequestAlreadyPending", + "У вас уже есть необработанная заявка на продление." + ); + + public static readonly Error NotExtensionRequest = Error.Validation( + "Support.NotExtensionRequest", + "Это не заявка на продление." + ); + public static readonly Error TooManyAttachments = Error.Validation( "Support.TooManyAttachments", $"Слишком много вложений (максимум {TicketAttachmentValidation.MaxAttachments})." diff --git a/backend/src/PnvPanel.Application/Support/TicketDetailDto.cs b/backend/src/PnvPanel.Application/Support/TicketDetailDto.cs index a61e326..108c0b7 100644 --- a/backend/src/PnvPanel.Application/Support/TicketDetailDto.cs +++ b/backend/src/PnvPanel.Application/Support/TicketDetailDto.cs @@ -5,6 +5,7 @@ namespace PnvPanel.Application.Support; /// /// RequestedRoleName — имя существующей роли, если RequestedRoleId задан (резолвится хендлером, /// на SupportTicket хранится только Id). Для новой роли имя лежит прямо в ProposedRoleName. +/// RequestedDays — только для ExtensionRequest (продление оплаченного периода). /// public sealed record TicketDetailDto( Guid Id, @@ -17,6 +18,7 @@ public sealed record TicketDetailDto( string? ProposedRoleName, int? ProposedMaxConfigs, int? ProposedMaxIpLimit, + int? RequestedDays, DateTimeOffset CreatedAt, IReadOnlyList Comments ); diff --git a/backend/src/PnvPanel.Application/Support/TicketMapping.cs b/backend/src/PnvPanel.Application/Support/TicketMapping.cs index 21bb0f5..2450bc7 100644 --- a/backend/src/PnvPanel.Application/Support/TicketMapping.cs +++ b/backend/src/PnvPanel.Application/Support/TicketMapping.cs @@ -76,6 +76,7 @@ internal static class TicketMapping ticket.ProposedRoleName, ticket.ProposedMaxConfigs, ticket.ProposedMaxIpLimit, + ticket.RequestedDays, ticket.CreatedAt, commentDtos ); diff --git a/backend/src/PnvPanel.Domain/Support/SupportTicket.cs b/backend/src/PnvPanel.Domain/Support/SupportTicket.cs index 6a0d82d..20a32e7 100644 --- a/backend/src/PnvPanel.Domain/Support/SupportTicket.cs +++ b/backend/src/PnvPanel.Domain/Support/SupportTicket.cs @@ -8,7 +8,8 @@ namespace PnvPanel.Domain.Support; /// или параметры новой). Текст обращения и переписка — в TicketComment, отдельной таблицей (не /// навигационная коллекция — см. конвенцию проекта на плоских сущностях, ср. TrafficSample/VpnConfig). /// Для RoleRequest заполнен либо RequestedRoleId, либо Proposed* — гарантируется отдельными фабриками, -/// а не runtime-проверкой одного универсального конструктора. +/// а не runtime-проверкой одного универсального конструктора. Для ExtensionRequest (продление +/// оплаченного периода — только для billing-ролей, см. AppRole.BillingEnabled) заполнен RequestedDays. /// public sealed class SupportTicket : Entity { @@ -19,6 +20,7 @@ public sealed class SupportTicket : Entity public string? ProposedRoleName { get; private set; } public int? ProposedMaxConfigs { get; private set; } public int? ProposedMaxIpLimit { get; private set; } + public int? RequestedDays { get; private set; } public DateTimeOffset CreatedAt { get; private set; } private SupportTicket() { } @@ -68,6 +70,19 @@ public sealed class SupportTicket : Entity }; } + public static SupportTicket CreateExtensionRequest(Guid userId, int requestedDays) + { + return new SupportTicket + { + Id = Guid.NewGuid(), + UserId = userId, + Type = TicketType.ExtensionRequest, + Status = TicketStatus.Open, + RequestedDays = requestedDays, + CreatedAt = DateTimeOffset.UtcNow, + }; + } + /// Решено (в т.ч. заявка на роль одобрена — роль выдаётся оркестрацией на уровне Application). public void Resolve() { diff --git a/backend/src/PnvPanel.Domain/Support/TicketType.cs b/backend/src/PnvPanel.Domain/Support/TicketType.cs index 2b4e9a3..78dac3c 100644 --- a/backend/src/PnvPanel.Domain/Support/TicketType.cs +++ b/backend/src/PnvPanel.Domain/Support/TicketType.cs @@ -4,4 +4,5 @@ public enum TicketType { BugReport, RoleRequest, + ExtensionRequest, } diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260719021751_AddExtensionRequest.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260719021751_AddExtensionRequest.Designer.cs new file mode 100644 index 0000000..8abadc0 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260719021751_AddExtensionRequest.Designer.cs @@ -0,0 +1,1046 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PnvPanel.Infrastructure.Persistence; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260719021751_AddExtensionRequest")] + partial class AddExtensionRequest + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("ActivationRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DownloadUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("IsRecommended") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OperatingSystem") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClientApps", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .HasColumnType("jsonb"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Billing.BillingSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultBillingEnabledForNewRoles") + .HasColumnType("boolean"); + + b.Property("GraceDays") + .HasColumnType("integer"); + + b.Property("RequisitesText") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("BillingSettings", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Billing.PaymentRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AmountSnapshot") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("Period") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("PaymentRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigId") + .HasColumnType("uuid"); + + b.Property("DownBytes") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ConfigId", "Timestamp"); + + b.ToTable("TrafficSamples", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InboundId") + .HasColumnType("uuid"); + + b.Property("Label") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UsedDownBytes") + .HasColumnType("bigint"); + + b.Property("UsedUpBytes") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("InboundId"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("UserId", "Status"); + + b.ToTable("VpnConfigs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("AllowedRoleIds") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RemoteInboundId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "RemoteInboundId") + .IsUnique(); + + b.ToTable("Inbounds", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionIntro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("InstructionIntros", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionTab", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("InstructionTabs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.News.NewsPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("NewsPosts", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.ToTable("Nodes", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PricePerConfigPerHalfYear") + .HasColumnType("integer"); + + b.Property("PricePerConfigPerQuarter") + .HasColumnType("integer"); + + b.Property("PricePerConfigPerYear") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("PricingSettings", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProposedMaxConfigs") + .HasColumnType("integer"); + + b.Property("ProposedMaxIpLimit") + .HasColumnType("integer"); + + b.Property("ProposedRoleName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequestedDays") + .HasColumnType("integer"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Type", "Status"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("SupportTickets", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CommentId") + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CommentId"); + + b.HasIndex("StoredFileName") + .IsUnique(); + + b.ToTable("TicketAttachments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TicketId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TicketId", "CreatedAt"); + + b.ToTable("TicketComments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("TelegramLinkTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("TelegramLoginRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BillingEnabled") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxConfigs") + .HasColumnType("integer"); + + b.Property("MaxIpLimit") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("BillingLastWarnedForPaidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("BillingPaidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("BillingSuspended") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("TelegramLinkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TelegramUserId") + .HasColumnType("bigint"); + + b.Property("TelegramUsername") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("TelegramUserId") + .IsUnique(); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 => + { + b1.Property("NodeId") + .HasColumnType("uuid"); + + b1.Property("ProtectedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CredentialsProtectedPassword"); + + b1.Property("Username") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("CredentialsUsername"); + + b1.HasKey("NodeId"); + + b1.ToTable("Nodes"); + + b1.WithOwner() + .HasForeignKey("NodeId"); + }); + + b.Navigation("Credentials") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260719021751_AddExtensionRequest.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260719021751_AddExtensionRequest.cs new file mode 100644 index 0000000..5929095 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260719021751_AddExtensionRequest.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddExtensionRequest : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RequestedDays", + table: "SupportTickets", + type: "integer", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RequestedDays", + table: "SupportTickets"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 226872d..a038cfa 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -625,6 +625,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations .HasMaxLength(100) .HasColumnType("character varying(100)"); + b.Property("RequestedDays") + .HasColumnType("integer"); + b.Property("RequestedRoleId") .HasColumnType("uuid"); diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Billing/GrantBillingGiftCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/GrantBillingGiftCommandHandlerTests.cs new file mode 100644 index 0000000..5322ff9 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Billing/GrantBillingGiftCommandHandlerTests.cs @@ -0,0 +1,132 @@ +using Microsoft.Extensions.Logging; +using NSubstitute; +using PnvPanel.Application.Admin.Billing; +using PnvPanel.Application.Admin.Users; +using PnvPanel.Application.Billing; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Tests.TestSupport; +using Xunit; + +namespace PnvPanel.Application.Tests.Admin.Billing; + +public class GrantBillingGiftCommandHandlerTests +{ + private readonly IIdentityService _identityService = Substitute.For(); + private readonly IXuiPanelGateway _gateway = Substitute.For(); + private readonly IRealtimeNotifier _notifier = Substitute.For(); + private readonly ITelegramNotifier _telegramNotifier = Substitute.For(); + private readonly ILogger _logger = Substitute.For< + ILogger + >(); + + private static CurrentUserProfile Profile(Guid userId, bool billingEnabled, DateTimeOffset? paidUntil) => + new( + userId, + "alice", + Guid.NewGuid(), + "premium", + IsActivated: true, + IsBlocked: false, + MaxConfigs: 5, + MaxIpLimit: 3, + SubscriptionToken: "sub-token", + BillingEnabled: billingEnabled, + BillingPaidUntil: paidUntil, + BillingSuspended: false + ); + + [Fact] + public async Task Handle_WhenBillingEnabled_ExtendsPaidUntilAndNotifiesUser() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var adminId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, billingEnabled: true, paidUntil: null)); + _identityService + .ExtendBillingPaidUntilAsync(userId, Arg.Any(), Arg.Any()) + .Returns(Result.Success()); + + var handler = new GrantBillingGiftCommandHandler( + dbContext, + _identityService, + _gateway, + _notifier, + _telegramNotifier, + FakeCurrentUser.Authenticated(adminId, "admin"), + _logger + ); + + var before = DateTimeOffset.UtcNow; + var result = await handler.Handle(new GrantBillingGiftCommand(userId, 30), CancellationToken.None); + + Assert.True(result.IsSuccess); + await _identityService + .Received(1) + .ExtendBillingPaidUntilAsync( + userId, + Arg.Is(d => d >= before.AddDays(30).AddMinutes(-1)), + Arg.Any() + ); + await _telegramNotifier + .Received(1) + .NotifyUserAsync( + userId, + Arg.Is(m => m.Contains("30")), + Arg.Any(), + Arg.Any() + ); + } + + [Fact] + public async Task Handle_WhenBillingNotEnabled_ReturnsNotEnabled() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, billingEnabled: false, paidUntil: null)); + + var handler = new GrantBillingGiftCommandHandler( + dbContext, + _identityService, + _gateway, + _notifier, + _telegramNotifier, + FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin"), + _logger + ); + + var result = await handler.Handle(new GrantBillingGiftCommand(userId, 30), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(BillingErrors.NotEnabled, result.Error); + } + + [Fact] + public async Task Handle_WhenUserNotFound_ReturnsUserNotFound() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns((CurrentUserProfile?)null); + + var handler = new GrantBillingGiftCommandHandler( + dbContext, + _identityService, + _gateway, + _notifier, + _telegramNotifier, + FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin"), + _logger + ); + + var result = await handler.Handle(new GrantBillingGiftCommand(userId, 30), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal(UserErrors.NotFound, result.Error); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveExtensionRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveExtensionRequestCommandHandlerTests.cs new file mode 100644 index 0000000..221e22a --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveExtensionRequestCommandHandlerTests.cs @@ -0,0 +1,189 @@ +using Microsoft.Extensions.Logging; +using NSubstitute; +using PnvPanel.Application.Admin.Support; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Support; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Configs; +using PnvPanel.Domain.Inbounds; +using PnvPanel.Domain.Nodes; +using PnvPanel.Domain.Support; +using Xunit; + +namespace PnvPanel.Application.Tests.Admin.Support; + +public class ApproveExtensionRequestCommandHandlerTests +{ + private readonly IIdentityService _identityService = Substitute.For(); + private readonly IXuiPanelGateway _gateway = Substitute.For(); + private readonly IRealtimeNotifier _notifier = Substitute.For(); + private readonly ITelegramNotifier _telegramNotifier = Substitute.For(); + private readonly ILogger _logger = Substitute.For< + ILogger + >(); + + private static CurrentUserProfile Profile(Guid userId, DateTimeOffset? paidUntil) => + new( + userId, + "alice", + Guid.NewGuid(), + "premium", + IsActivated: true, + IsBlocked: false, + MaxConfigs: 5, + MaxIpLimit: 3, + SubscriptionToken: "sub-token", + BillingEnabled: true, + BillingPaidUntil: paidUntil, + BillingSuspended: paidUntil is null + ); + + private ApproveExtensionRequestCommandHandler CreateHandler( + PnvPanel.Infrastructure.Persistence.AppDbContext dbContext, + Guid adminId + ) => + new( + dbContext, + _identityService, + _gateway, + _notifier, + _telegramNotifier, + FakeCurrentUser.Authenticated(adminId, "admin"), + _logger + ); + + [Fact] + public async Task Handle_WhenNoPriorPayment_ExtendsFromNowAndResolves() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var adminId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var ticket = SupportTicket.CreateExtensionRequest(userId, 10); + dbContext.SupportTickets.Add(ticket); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, paidUntil: null)); + _identityService + .ExtendBillingPaidUntilAsync(userId, Arg.Any(), Arg.Any()) + .Returns(Result.Success()); + + var handler = CreateHandler(dbContext, adminId); + var before = DateTimeOffset.UtcNow; + + var result = await handler.Handle( + new ApproveExtensionRequestCommand(ticket.Id), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Equal(TicketStatus.Resolved, ticket.Status); + await _identityService + .Received(1) + .ExtendBillingPaidUntilAsync( + userId, + Arg.Is(d => d >= before.AddDays(10).AddMinutes(-1)), + Arg.Any() + ); + } + + [Fact] + public async Task Handle_ResumesExpiredConfigs() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var adminId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + + var node = Node.Register( + "node-1", + new Uri("https://node1.example.com"), + new NodeCredentials("admin", "protected"), + null + ); + var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443); + var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null); + config.AssignRemoteClient("ext-1"); + config.Suspend(); + + dbContext.Nodes.Add(node); + dbContext.Inbounds.Add(inbound); + dbContext.VpnConfigs.Add(config); + + var ticket = SupportTicket.CreateExtensionRequest(userId, 5); + dbContext.SupportTickets.Add(ticket); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, paidUntil: null)); + _identityService + .ExtendBillingPaidUntilAsync(userId, Arg.Any(), Arg.Any()) + .Returns(Result.Success()); + _gateway + .UpdateClientAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + enable: true, + Arg.Any() + ) + .Returns(Result.Success()); + + var handler = CreateHandler(dbContext, adminId); + + var result = await handler.Handle( + new ApproveExtensionRequestCommand(ticket.Id), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Equal(ConfigStatus.Active, config.Status); + Assert.NotNull(config.ExpiresAt); + } + + [Fact] + public async Task Handle_WhenTicketNotOpen_ReturnsNotOpen() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var adminId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var ticket = SupportTicket.CreateExtensionRequest(userId, 5); + ticket.Close(); + dbContext.SupportTickets.Add(ticket); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = CreateHandler(dbContext, adminId); + + var result = await handler.Handle( + new ApproveExtensionRequestCommand(ticket.Id), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(SupportErrors.NotOpen, result.Error); + } + + [Fact] + public async Task Handle_WhenNotExtensionRequest_ReturnsNotExtensionRequest() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var adminId = Guid.NewGuid(); + var ticket = SupportTicket.CreateBugReport(Guid.NewGuid()); + dbContext.SupportTickets.Add(ticket); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = CreateHandler(dbContext, adminId); + + var result = await handler.Handle( + new ApproveExtensionRequestCommand(ticket.Id), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(SupportErrors.NotExtensionRequest, result.Error); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Support/RejectExtensionRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Support/RejectExtensionRequestCommandHandlerTests.cs new file mode 100644 index 0000000..06ce401 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Support/RejectExtensionRequestCommandHandlerTests.cs @@ -0,0 +1,73 @@ +using NSubstitute; +using PnvPanel.Application.Admin.Support; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Support; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Support; +using Xunit; + +namespace PnvPanel.Application.Tests.Admin.Support; + +public class RejectExtensionRequestCommandHandlerTests +{ + private readonly IRealtimeNotifier _notifier = Substitute.For(); + private readonly ITelegramNotifier _telegramNotifier = Substitute.For(); + + [Fact] + public async Task Handle_WhenOpen_ClosesAndNotifiesUser() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var adminId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var ticket = SupportTicket.CreateExtensionRequest(userId, 5); + dbContext.SupportTickets.Add(ticket); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new RejectExtensionRequestCommandHandler( + dbContext, + _notifier, + _telegramNotifier, + FakeCurrentUser.Authenticated(adminId, "admin") + ); + + var result = await handler.Handle( + new RejectExtensionRequestCommand(ticket.Id, "Недостаточно оснований"), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Equal(TicketStatus.Closed, ticket.Status); + await _telegramNotifier + .Received(1) + .NotifyUserAsync( + userId, + Arg.Any(), + Arg.Any(), + Arg.Any() + ); + } + + [Fact] + public async Task Handle_WhenNotExtensionRequest_ReturnsNotExtensionRequest() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var ticket = SupportTicket.CreateBugReport(Guid.NewGuid()); + dbContext.SupportTickets.Add(ticket); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new RejectExtensionRequestCommandHandler( + dbContext, + _notifier, + _telegramNotifier, + FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin") + ); + + var result = await handler.Handle( + new RejectExtensionRequestCommand(ticket.Id, null), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(SupportErrors.NotExtensionRequest, result.Error); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Support/CreateExtensionRequestTicketCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Support/CreateExtensionRequestTicketCommandHandlerTests.cs new file mode 100644 index 0000000..0a291d5 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Support/CreateExtensionRequestTicketCommandHandlerTests.cs @@ -0,0 +1,124 @@ +using NSubstitute; +using PnvPanel.Application.Billing; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Support; +using PnvPanel.Application.Support.CreateExtensionRequest; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Support; +using Xunit; + +namespace PnvPanel.Application.Tests.Support; + +public class CreateExtensionRequestTicketCommandHandlerTests +{ + private readonly IIdentityService _identityService = Substitute.For(); + private readonly IRealtimeNotifier _notifier = Substitute.For(); + private readonly ITelegramNotifier _telegramNotifier = Substitute.For(); + + private static CurrentUserProfile Profile(Guid userId, bool billingEnabled) => + new( + userId, + "alice", + Guid.NewGuid(), + "premium", + IsActivated: true, + IsBlocked: false, + MaxConfigs: 5, + MaxIpLimit: 3, + SubscriptionToken: "sub-token", + BillingEnabled: billingEnabled, + BillingPaidUntil: null, + BillingSuspended: false + ); + + [Fact] + public async Task Handle_WhenBillingEnabled_CreatesTicketAndNotifiesAdmins() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, billingEnabled: true)); + + var handler = new CreateExtensionRequestTicketCommandHandler( + dbContext, + _identityService, + _notifier, + _telegramNotifier, + FakeCurrentUser.Authenticated(userId, "alice") + ); + + var result = await handler.Handle( + new CreateExtensionRequestTicketCommand(14, "нужно продлить"), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Equal(TicketType.ExtensionRequest, result.Value.Type); + Assert.Equal(14, result.Value.RequestedDays); + await _telegramNotifier + .Received(1) + .NotifyAdminsExtensionRequestCreatedAsync( + Arg.Any(), + "alice", + 14, + "нужно продлить", + Arg.Any() + ); + } + + [Fact] + public async Task Handle_WhenBillingNotEnabled_ReturnsNotEnabled() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, billingEnabled: false)); + + var handler = new CreateExtensionRequestTicketCommandHandler( + dbContext, + _identityService, + _notifier, + _telegramNotifier, + FakeCurrentUser.Authenticated(userId) + ); + + var result = await handler.Handle( + new CreateExtensionRequestTicketCommand(14, "нужно продлить"), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(BillingErrors.NotEnabled, result.Error); + } + + [Fact] + public async Task Handle_WhenPendingExtensionRequestExists_ReturnsConflict() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + dbContext.SupportTickets.Add(SupportTicket.CreateExtensionRequest(userId, 7)); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _identityService + .GetProfileAsync(userId, Arg.Any()) + .Returns(Profile(userId, billingEnabled: true)); + + var handler = new CreateExtensionRequestTicketCommandHandler( + dbContext, + _identityService, + _notifier, + _telegramNotifier, + FakeCurrentUser.Authenticated(userId) + ); + + var result = await handler.Handle( + new CreateExtensionRequestTicketCommand(3, "ещё заявка"), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(SupportErrors.ExtensionRequestAlreadyPending, result.Error); + } +} diff --git a/backend/tests/PnvPanel.Domain.Tests/Support/SupportTicketTests.cs b/backend/tests/PnvPanel.Domain.Tests/Support/SupportTicketTests.cs index c958968..1b0b2f8 100644 --- a/backend/tests/PnvPanel.Domain.Tests/Support/SupportTicketTests.cs +++ b/backend/tests/PnvPanel.Domain.Tests/Support/SupportTicketTests.cs @@ -45,6 +45,21 @@ public class SupportTicketTests Assert.Equal(3, ticket.ProposedMaxIpLimit); } + [Fact] + public void CreateExtensionRequest_SetsRequestedDays() + { + var userId = Guid.NewGuid(); + + var ticket = SupportTicket.CreateExtensionRequest(userId, 14); + + Assert.Equal(userId, ticket.UserId); + Assert.Equal(TicketType.ExtensionRequest, ticket.Type); + Assert.Equal(TicketStatus.Open, ticket.Status); + Assert.Equal(14, ticket.RequestedDays); + Assert.Null(ticket.RequestedRoleId); + Assert.Null(ticket.ProposedRoleName); + } + [Fact] public void Resolve_WhenOpen_SetsResolved() { diff --git a/docs/api-design.md b/docs/api-design.md index b025ce3..88f4633 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -174,18 +174,20 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро | GET | `/api/support/tickets/{id}` | — | `TicketDetailDto` (404, если не свой) | | POST | `/api/support/tickets/bug-reports` | multipart: `message` + `files[]` (до 5, изображения до 5 МБ) | `TicketDetailDto` | | POST | `/api/support/tickets/role-requests` | `{ existingRoleId? \| (newRoleName, newRoleMaxConfigs, newRoleMaxIpLimit), justification }` | `TicketDetailDto` | +| POST | `/api/support/tickets/extension-requests` | `{ requestedDays, justification }` | `TicketDetailDto` (`403 Billing.NotEnabled`, если роль не billing; `409 Support.ExtensionRequestAlreadyPending`) | | POST | `/api/support/tickets/{id}/comments` | multipart: `body` + `files[]` | `TicketCommentDto` | | POST | `/api/support/tickets/{id}/reopen` | — | `204 No Content` (только владелец, только из `Resolved`) | | GET | `/api/support/attachments/{id}` | — | бинарный поток с `Content-Type` вложения | `TicketSummaryDto`: `{ id, userId, userName, type, status, createdAt, lastActivityAt }` — один DTO для своего и админского списков. `TicketDetailDto` добавляет `requestedRoleId, requestedRoleName, -proposedRoleName, proposedMaxConfigs, proposedMaxIpLimit, comments: TicketCommentDto[]`. +proposedRoleName, proposedMaxConfigs, proposedMaxIpLimit, requestedDays, comments: TicketCommentDto[]`. `TicketCommentDto`: `{ id, authorId, authorName, body, createdAt, attachments: TicketAttachmentDto[] }`. Ровно одна из двух заявок на роль: либо `existingRoleId` (роль `admin` запрещена — `403 Support.CannotRequestAdminRole`), либо все три поля новой роли. Заявка при существующем открытом -запросе на роль → `409 Support.RoleRequestAlreadyPending`. `POST …/comments` на `Closed`-тикете → +запросе на роль → `409 Support.RoleRequestAlreadyPending`; аналогично для продления → +`409 Support.ExtensionRequestAlreadyPending`. `POST …/comments` на `Closed`-тикете → `409 Support.TicketClosed`. Вложения отдаются не статикой — `` не может передать `Authorization`-заголовок, фронт качает их как `Blob` через `fetch` и рендерит `Object URL`. @@ -203,6 +205,8 @@ Support.CannotRequestAdminRole`), либо все три поля новой р | POST | `/api/admin/support/tickets/{id}/close` | — | `204 No Content` (любой тип, финал) | | POST | `/api/admin/support/tickets/{id}/approve` | — | `204 No Content` (только `RoleRequest`/`Open`; создаёt/назначает роль) | | POST | `/api/admin/support/tickets/{id}/reject` | `{ reason? }` | `204 No Content` (только `RoleRequest`; `reason` уходит комментарием) | +| POST | `/api/admin/support/tickets/{id}/approve-extension` | — | `204 No Content` (только `ExtensionRequest`/`Open`; продлевает `BillingPaidUntil` на `RequestedDays`) | +| POST | `/api/admin/support/tickets/{id}/reject-extension` | `{ reason? }` | `204 No Content` (только `ExtensionRequest`; `reason` уходит комментарием) | Обработать **собственный** тикет админу можно (в т.ч. одобрить свою же заявку на роль) — resolve/close/ reject/approve владением тикета не ограничены. Единственное реальное ограничение — `approve` вернёт @@ -272,14 +276,16 @@ approve/reject над `ActivationRequest`. | Метод | Путь | Роль | Тело запроса | Тело ответа | | ----- | -------------------------------------------- | ----- | ---------------------------- | ------------- | -| GET | `/api/admin/billing/settings` | admin | — | `BillingSettingsDto { requisitesText, graceDays }` | -| PUT | `/api/admin/billing/settings` | admin | `{ requisitesText, graceDays }` | `BillingSettingsDto` | +| GET | `/api/admin/billing/settings` | admin | — | `BillingSettingsDto { requisitesText, graceDays, defaultBillingEnabledForNewRoles }` | +| PUT | `/api/admin/billing/settings` | admin | `{ requisitesText, graceDays, defaultBillingEnabledForNewRoles }` | `BillingSettingsDto` | | GET | `/api/admin/billing/requests` | admin | query: `status?, page=1, pageSize=20` | `PagedList` (включает `userName`) | | POST | `/api/admin/billing/requests/{id}/confirm` | admin | — | `204 No Content` (продлевает `BillingPaidUntil`, возвращает приостановленные конфиги в `Active`) | | POST | `/api/admin/billing/requests/{id}/reject` | admin | `{ reason? }` | `204 No Content` | +| POST | `/api/admin/billing/gift` | admin | `{ userId, days }` | `204 No Content` (продлевает `BillingPaidUntil` на `days` от `max(текущий, сейчас)`, возвращает приостановленные конфиги, шлёт Telegram-уведомление пользователю; `403 Billing.NotEnabled`, если роль пользователя не billing) | То же подтверждение/отклонение доступно **из Telegram, не заходя на сайт** — инлайн-кнопки на уведомлении о заявке (`pay:approve:{id}`/`pay:reject:{id}`, см. [telegram-bot.md](telegram-bot.md)). +Гифт — только на сайте, в боте не решается. ## Admin — Nodes diff --git a/docs/domain-model.md b/docs/domain-model.md index 0cf221f..35f0e70 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -434,7 +434,11 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты `GET/POST /api/billing/*` — пользователь (статус, создание/отмена заявки, «я оплатил», отправка реквизитов в свой Telegram). `GET/PUT/POST /api/admin/billing/*` — админ (настройки, список заявок, -подтверждение/отклонение), только `admin`. +подтверждение/отклонение, `POST /gift` — выдать N дней конкретному пользователю без заявки), только +`admin`. Продление PaidUntil попадает в панель тремя путями — подтверждённая `PaymentRequest`, +одобренная `SupportTicket(ExtensionRequest)` и прямой гифт от админа — все три используют один и тот +же `BillingConfigResumer` (см. Application/Billing), различается только вычисление `newPaidUntil` +(месяцы для оплаты, дни для продления/гифта) и триггер (пользователь vs админ). ### ActivationRequest — запрос активации Пользователь просит активацию у админа; админ одобряет/отклоняет на сайте или в Telegram. @@ -480,33 +484,44 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты После `Consumed`/`Expired` — не переиспользуется. ### SupportTicket — обращение в поддержку -Два вида: `BugReport` (свободная форма, с вложениями) и `RoleRequest` (запрос существующей роли — -кроме `admin` — либо параметров новой). Текст/обоснование не хранится отдельным полем — это первое -сообщение в переписке (`TicketComment`), созданное вместе с тикетом в одной операции. +Три вида: `BugReport` (свободная форма, с вложениями), `RoleRequest` (запрос существующей роли — +кроме `admin` — либо параметров новой) и `ExtensionRequest` (продление оплаченного периода на N +дней — только для billing-ролей, см. Billing выше). Текст/обоснование не хранится отдельным полем — +это первое сообщение в переписке (`TicketComment`), созданное вместе с тикетом в одной операции. | Поле | Тип | Заметки | | ------------------- | ----------------- | ---------------------------------------------------------------- | | `Id` | `Guid` | PK | | `UserId` | `Guid` | FK → AppUser (автор) | -| `Type` | `TicketType` | `BugReport` / `RoleRequest` | +| `Type` | `TicketType` | `BugReport` / `RoleRequest` / `ExtensionRequest` | | `Status` | `TicketStatus` | `Open` / `Resolved` / `Closed` | | `RequestedRoleId` | `Guid?` | Заполнено для `RoleRequest` при выборе существующей роли | | `ProposedRoleName` | `string?` | Заполнено для `RoleRequest` при запросе новой роли | | `ProposedMaxConfigs`| `int?` | Параметры новой роли (см. `AppRole.MaxConfigs`) | | `ProposedMaxIpLimit`| `int?` | Параметры новой роли (см. `AppRole.MaxIpLimit`) | +| `RequestedDays` | `int?` | Заполнено для `ExtensionRequest` — сколько дней просит пользователь (1–365) | | `CreatedAt` | `DateTimeOffset` | | Инварианты и переходы (`backend/src/PnvPanel.Domain/Support/SupportTicket.cs`): `RequestedRoleId` и `Proposed*` никогда не заполнены одновременно — гарантируется отдельными фабриками -(`CreateRoleRequestForExistingRole`/`CreateRoleRequestForNewRole`), а не runtime-проверкой. +(`CreateRoleRequestForExistingRole`/`CreateRoleRequestForNewRole`), а не runtime-проверкой. Аналогично +`RequestedDays` заполняется только фабрикой `CreateExtensionRequest`. - `Resolve()` — только из `Open`. Для `RoleRequest` одобрение — оркестрация в Application (`ApproveRoleRequestCommandHandler`): при новой роли сначала `IRoleService.CreateRoleAsync`, затем - в любом случае `ChangeUserRoleAsync` пользователю, и только потом `ticket.Resolve()`. -- `Close()` — из `Open` или `Resolved`, **финал** (обратного пути нет). Для `RoleRequest` — отклонение. + в любом случае `ChangeUserRoleAsync` пользователю, и только потом `ticket.Resolve()`. Для + `ExtensionRequest` — `ApproveExtensionRequestCommandHandler` продлевает `AppUser.BillingPaidUntil` + на `RequestedDays` (от `max(текущий, сейчас)`, как и у `PaymentRequest`) и возвращает в `Active` + конфиги, приостановленные за неуплату (`BillingConfigResumer`, тот же helper, что и у подтверждения + оплаты и гифт-дней от админа). +- `Close()` — из `Open` или `Resolved`, **финал** (обратного пути нет). Для `RoleRequest`/ + `ExtensionRequest` — отклонение. - `Reopen()` — только из `Resolved` (владелец тикета); `Closed` не переоткрывается. - Одновременно не более одной **открытой** заявки на роль (`Type == RoleRequest && Status == Open`) + и отдельно не более одной открытой заявки на продление (`Type == ExtensionRequest && Status == Open`) на пользователя — проверяется в Application, аналогично `ActivationRequest.AlreadyPending`. Баг-репорты такого ограничения не имеют. +- Создать `ExtensionRequest` может только пользователь с billing-ролью + (`CurrentUserProfile.BillingEnabled`) — иначе `Billing.NotEnabled`. - Доступ — только активированному пользователю (`IRequiresActivation`, как и у конфигов/новостей); админские действия (resolve/close/approve/reject) идут по отдельным `/api/admin/support/*` с ролевой проверкой, без завязки на активацию. @@ -572,7 +587,7 @@ enum TelegramLoginStatus { Pending, Approved, Rejected, Expired, Consumed } enum ActivationStatus { Pending, Approved, Rejected } enum AuditSource { Web, Telegram, System } enum OsPlatform { IOS, Android, Windows, MacOS, Linux } -enum TicketType { BugReport, RoleRequest } +enum TicketType { BugReport, RoleRequest, ExtensionRequest } enum TicketStatus { Open, Resolved, Closed } ``` @@ -604,6 +619,10 @@ enum TicketStatus { Open, Resolved, Closed } | `AddTicketCommentCommandHandler` | Realtime `ticketUpdated` владельцу, только если комментирует не он сам | | `ApproveRoleRequestCommandHandler` | Создаёт роль (если новая) + назначает пользователю; `AuditLog` (`RoleRequestApproved`); Telegram-DM владельцу | | `RejectRoleRequestCommandHandler` | `AuditLog` (`RoleRequestRejected`); Telegram-DM владельцу | +| `CreateExtensionRequestTicketCommandHandler` | Realtime `ticketCreated` группе `admins`; Telegram админам — инлайн-кнопки «Одобрить/Отклонить» | +| `ApproveExtensionRequestCommandHandler` | Продлевает `BillingPaidUntil`, возвращает приостановленные конфиги; `AuditLog` (`ExtensionRequestApproved`); Telegram-DM владельцу | +| `RejectExtensionRequestCommandHandler` | `AuditLog` (`ExtensionRequestRejected`); Telegram-DM владельцу | +| `GrantBillingGiftCommandHandler` | Админ дарит N дней (`/admin/billing/gift`) — та же логика продления/возврата конфигов, что и у заявок; `AuditLog` (`BillingGiftGranted`); Telegram-DM владельцу | | `ResolveTicketCommandHandler` / `CloseTicketCommandHandler` | `AuditLog` (`TicketResolved`/`TicketClosed`); realtime `ticketUpdated` владельцу | SignalR-события и группы — см. [architecture.md](architecture.md#realtime-signalr) и diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index d6e7fc8..31bd69c 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -197,6 +197,18 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl 4. Пользователю (если Telegram привязан) — DM «✅ Ваша заявка на роль одобрена.» / «❌ Ваша заявка на роль отклонена.». +**Заявка на продление** (`ExtensionRequest`, только для billing-ролей) — тот же паттерн, что и заявка +на роль, инлайн-кнопки с префиксом `erq:`: +1. `CreateExtensionRequestTicketCommandHandler` вызывает + `ITelegramNotifier.NotifyAdminsExtensionRequestCreatedAsync(ticketId, userName, requestedDays, justification, ct)`. +2. Кнопки **«✅ Одобрить» / «❌ Отклонить»** (callback `erq:approve:{id}`/`erq:reject:{id}`). +3. Нажатие → `TrySetAdminCurrentUserAsync` → `ApproveExtensionRequestCommand`/`RejectExtensionRequestCommand` + (те же команды, что `POST /api/admin/support/tickets/{id}/approve-extension|reject-extension`). + Одобрение продлевает `AppUser.BillingPaidUntil` на `RequestedDays` и возвращает приостановленные + конфиги в `Active` (`BillingConfigResumer`). +4. Пользователю — DM «✅ Заявка на продление одобрена. Доступ продлён до {дата}.» / «❌ Ваша заявка на + продление отклонена.». + ## Флоу 7 — Оплата подписки (биллинг) Только для ролей с `AppRole.BillingEnabled` (см. [domain-model.md](domain-model.md#billing--подписка-по-сроку)). @@ -221,6 +233,10 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl оплаты и уведомление о приостановке конфигов при просрочке — оба через `NotifyUserAsync`, без инлайн-кнопок. +**Гифт от админа** (`POST /api/admin/billing/gift`, только на сайте — в боте не решается) — админ +выдаёт пользователю N дней напрямую, без заявки. Пользователю (если Telegram привязан) — DM +«🎁 Вам подарено N дн. подписки! Доступ продлён до {дата}.». + **Статус оплаты по кнопке**: для пользователей с billing-ролью (`AppRole.BillingEnabled`) в главном меню бота появляется «💳 Статус оплаты» (`menu:billing`, либо команда `/billing`) — показывает дату, до которой оплачено, и остаток в человекочитаемом виде: дни, если их ≥ 1 (`N дн.`), иначе часы и @@ -246,6 +262,7 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl | «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env | | `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env | | «✅ Одобрить»/«❌ Отклонить» (`rrq:*`) | (admin) решение по заявке на роль — создаёт/назначает роль | админ по env | +| «✅ Одобрить»/«❌ Отклонить» (`erq:*`) | (admin) решение по заявке на продление — продлевает `BillingPaidUntil` | админ по env | | «✅ Подтвердить»/«❌ Отклонить» (`pay:*`) | (admin) решение по заявке на оплату — продлевает `BillingPaidUntil` | админ по env | | «🌐 Открыть на сайте» | Ссылка на баг-репорт на сайте (только если задан `Telegram__PublicSiteUrl`) | админ по env | diff --git a/frontend/src/features/admin/billing/api.ts b/frontend/src/features/admin/billing/api.ts index f64a837..807e0db 100644 --- a/frontend/src/features/admin/billing/api.ts +++ b/frontend/src/features/admin/billing/api.ts @@ -37,3 +37,7 @@ export function rejectPaymentRequest(id: string, reason?: string) { body: { reason: reason ?? null }, }) } + +export function grantBillingGift(userId: string, days: number) { + return apiRequest('/admin/billing/gift', { method: 'POST', body: { userId, days } }) +} diff --git a/frontend/src/features/admin/support/AdminTicketDetailDialog.tsx b/frontend/src/features/admin/support/AdminTicketDetailDialog.tsx index 61b0982..b891dbc 100644 --- a/frontend/src/features/admin/support/AdminTicketDetailDialog.tsx +++ b/frontend/src/features/admin/support/AdminTicketDetailDialog.tsx @@ -9,7 +9,16 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/di import { HttpError } from '@/shared/api/client' import { TicketStatusBadge } from '@/features/support/TicketStatusBadge' import { TicketAttachmentImage } from '@/features/support/TicketAttachmentImage' -import { addAdminComment, approveRoleRequest, closeTicket, getAdminTicket, rejectRoleRequest, resolveTicket } from './api' +import { + addAdminComment, + approveExtensionRequest, + approveRoleRequest, + closeTicket, + getAdminTicket, + rejectExtensionRequest, + rejectRoleRequest, + resolveTicket, +} from './api' const MAX_FILES = 5 @@ -57,7 +66,8 @@ export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId: }) const approveMutation = useMutation({ - mutationFn: () => approveRoleRequest(ticketId), + mutationFn: () => + data?.type === 'ExtensionRequest' ? approveExtensionRequest(ticketId) : approveRoleRequest(ticketId), onSuccess: async () => { toast.success(t('admin.support.approved')) await invalidate() @@ -66,7 +76,8 @@ export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId: }) const rejectMutation = useMutation({ - mutationFn: () => rejectRoleRequest(ticketId), + mutationFn: () => + data?.type === 'ExtensionRequest' ? rejectExtensionRequest(ticketId) : rejectRoleRequest(ticketId), onSuccess: async () => { toast.success(t('admin.support.rejected')) await invalidate() @@ -105,6 +116,12 @@ export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId: )} + {data.type === 'ExtensionRequest' && ( +
+ {t('support.requestedExtension', { days: data.requestedDays })} +
+ )} +
{data.comments.map((comment) => (
@@ -126,7 +143,7 @@ export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId: {data.status !== 'Closed' && (
- {data.type === 'RoleRequest' && data.status === 'Open' ? ( + {(data.type === 'RoleRequest' || data.type === 'ExtensionRequest') && data.status === 'Open' ? ( <>
+ {user.billingEnabled && ( +
+ + +
+ setGiftDays(e.target.value)} + placeholder={t('admin.users.giftDaysPlaceholder')} + /> + +
+
+ )} +
{configsQuery.data?.length === 0 &&

{t('configs.empty')}

} diff --git a/frontend/src/features/support/CreateExtensionRequestDialog.tsx b/frontend/src/features/support/CreateExtensionRequestDialog.tsx new file mode 100644 index 0000000..eaa7154 --- /dev/null +++ b/frontend/src/features/support/CreateExtensionRequestDialog.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { toast } from '@/shared/ui/toast-store' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { Textarea } from '@/shared/ui/textarea' +import { Label } from '@/shared/ui/label' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog' +import { HttpError } from '@/shared/api/client' +import { createExtensionRequestTicket } from './api' + +export function CreateExtensionRequestDialog() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [open, setOpen] = useState(false) + const [requestedDays, setRequestedDays] = useState('') + const [justification, setJustification] = useState('') + + const resetForm = () => { + setRequestedDays('') + setJustification('') + } + + const mutation = useMutation({ + mutationFn: () => createExtensionRequestTicket(Number(requestedDays), justification.trim()), + onSuccess: async () => { + toast.success(t('support.ticketCreated')) + await queryClient.invalidateQueries({ queryKey: ['my-tickets'] }) + setOpen(false) + resetForm() + }, + onError: (error) => { + const message = + error instanceof HttpError && error.status === 409 + ? t('support.extensionRequestPending') + : error instanceof HttpError + ? error.detail + : t('auth.genericError') + toast.error(message) + }, + }) + + const canSubmit = Number(requestedDays) > 0 && justification.trim().length > 0 + + return ( + + + + + + + {t('support.requestExtension')} + +
{ + e.preventDefault() + if (canSubmit) mutation.mutate() + }} + > +
+ + setRequestedDays(e.target.value)} + /> +
+
+ +