Implement extension request and gift functionalities in billing system
- Added new endpoints for creating and managing extension requests, allowing users to request billing period extensions. - Implemented admin approval processes for extension requests via Telegram, including inline buttons for approval and rejection. - Introduced a gifting feature for admins to grant additional billing days directly to users without a request. - Updated the support ticket model to accommodate extension requests and their associated properties. - Enhanced the Telegram notifier to inform admins of new extension requests and notify users of approval or rejection. - Updated frontend components to support the new extension request and gifting functionalities, including user interfaces for managing these features. - Revised API documentation to reflect the new endpoints and their usage in the billing context.
This commit is contained in:
@@ -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<IResult> 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);
|
||||
|
||||
@@ -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<IResult> ApproveExtensionRequest(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ApproveExtensionRequestCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> 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);
|
||||
|
||||
@@ -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<TicketDetailDto>();
|
||||
group.MapPost("/tickets/role-requests", CreateRoleRequest).Produces<TicketDetailDto>();
|
||||
group
|
||||
.MapPost("/tickets/extension-requests", CreateExtensionRequest)
|
||||
.Produces<TicketDetailDto>();
|
||||
group.MapGet("/tickets", ListMyTickets).Produces<PagedList<TicketSummaryDto>>();
|
||||
group.MapGet("/tickets/{id:guid}", GetTicket).Produces<TicketDetailDto>();
|
||||
group
|
||||
@@ -89,6 +93,17 @@ public static class SupportEndpoints
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> 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<IResult> 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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 =
|
||||
$"🆕 Заявка на продление от <b>{Escape(userName)}</b> — {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,
|
||||
|
||||
+9
-59
@@ -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(
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Billing;
|
||||
|
||||
/// <summary>Админ дарит пользователю N дней подписки — продлевает BillingPaidUntil от
|
||||
/// max(текущий, сейчас), возвращает приостановленные конфиги, шлёт уведомление пользователю.</summary>
|
||||
public sealed record GrantBillingGiftCommand(Guid UserId, int Days) : ICommand<Result>;
|
||||
@@ -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<GrantBillingGiftCommandHandler> logger
|
||||
) : ICommandHandler<GrantBillingGiftCommand, Result>
|
||||
{
|
||||
public async Task<Result> 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Billing;
|
||||
|
||||
public sealed class GrantBillingGiftCommandValidator : AbstractValidator<GrantBillingGiftCommand>
|
||||
{
|
||||
public GrantBillingGiftCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Days).GreaterThan(0).LessThanOrEqualTo(365);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Support;
|
||||
|
||||
/// <summary>Одобрение продлевает AppUser.BillingPaidUntil на RequestedDays (от max(текущий, сейчас))
|
||||
/// и возвращает в Active конфиги, приостановленные за неуплату — см. BillingConfigResumer.</summary>
|
||||
public sealed record ApproveExtensionRequestCommand(Guid TicketId) : ICommand<Result>;
|
||||
+94
@@ -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<ApproveExtensionRequestCommandHandler> logger
|
||||
) : ICommandHandler<ApproveExtensionRequestCommand, Result>
|
||||
{
|
||||
public async Task<Result> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<Result>;
|
||||
+66
@@ -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<RejectExtensionRequestCommand, Result>
|
||||
{
|
||||
public async Task<Result> 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Domain.Configs;
|
||||
|
||||
namespace PnvPanel.Application.Billing;
|
||||
|
||||
/// <summary>
|
||||
/// Общая часть "продлить оплату" — возвращает в Active конфиги, приостановленные за неуплату
|
||||
/// (Expired), и синхронизирует ExpiresAt на все конфиги пользователя. Используется при подтверждении
|
||||
/// заявки на оплату, одобрении заявки на продление и выдаче гифт-дней админом — расчёт самого
|
||||
/// newPaidUntil (AddMonths для платежа, AddDays для продления/гифта) остаётся на вызывающей стороне,
|
||||
/// как и AppUser.BillingPaidUntil (см. IIdentityService.ExtendBillingPaidUntilAsync, зовётся отдельно
|
||||
/// до этого helper'а).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,4 +62,14 @@ public interface ITelegramNotifier
|
||||
int amount,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
/// <summary>Заявка на продление оплаченного периода — инлайн-кнопки «Одобрить/Отклонить», решается
|
||||
/// полностью в Telegram (аналогично заявке на роль).</summary>
|
||||
Task NotifyAdminsExtensionRequestCreatedAsync(
|
||||
Guid ticketId,
|
||||
string userName,
|
||||
int requestedDays,
|
||||
string justification,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
+1
@@ -77,6 +77,7 @@ public sealed class CreateBugReportTicketCommandHandler(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
ticket.CreatedAt,
|
||||
[commentDto]
|
||||
);
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Support.CreateExtensionRequest;
|
||||
|
||||
/// <summary>Заявка на продление оплаченного периода — только для ролей с включённым биллингом
|
||||
/// (AppRole.BillingEnabled), см. CreateExtensionRequestTicketCommandHandler.</summary>
|
||||
public sealed record CreateExtensionRequestTicketCommand(int RequestedDays, string Justification)
|
||||
: ICommand<Result<TicketDetailDto>>,
|
||||
IRequiresActivation;
|
||||
+94
@@ -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<CreateExtensionRequestTicketCommand, Result<TicketDetailDto>>
|
||||
{
|
||||
public async Task<Result<TicketDetailDto>> Handle(
|
||||
CreateExtensionRequestTicketCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return Result.Failure<TicketDetailDto>(AuthErrors.Unauthorized);
|
||||
|
||||
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
|
||||
if (profile is null)
|
||||
return Result.Failure<TicketDetailDto>(AuthErrors.Unauthorized);
|
||||
|
||||
if (!profile.BillingEnabled)
|
||||
return Result.Failure<TicketDetailDto>(BillingErrors.NotEnabled);
|
||||
|
||||
var hasPending = await dbContext.SupportTickets.AnyAsync(
|
||||
t =>
|
||||
t.UserId == userId
|
||||
&& t.Type == TicketType.ExtensionRequest
|
||||
&& t.Status == TicketStatus.Open,
|
||||
cancellationToken
|
||||
);
|
||||
if (hasPending)
|
||||
return Result.Failure<TicketDetailDto>(SupportErrors.ExtensionRequestAlreadyPending);
|
||||
|
||||
var ticket = SupportTicket.CreateExtensionRequest(userId, command.RequestedDays);
|
||||
dbContext.SupportTickets.Add(ticket);
|
||||
|
||||
var comment = TicketComment.Create(ticket.Id, userId, command.Justification);
|
||||
dbContext.TicketComments.Add(comment);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace PnvPanel.Application.Support.CreateExtensionRequest;
|
||||
|
||||
public sealed class CreateExtensionRequestTicketCommandValidator
|
||||
: AbstractValidator<CreateExtensionRequestTicketCommand>
|
||||
{
|
||||
public CreateExtensionRequestTicketCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.RequestedDays).GreaterThan(0).LessThanOrEqualTo(365);
|
||||
RuleFor(x => x.Justification).NotEmpty().MaximumLength(4000);
|
||||
}
|
||||
}
|
||||
+1
@@ -108,6 +108,7 @@ public sealed class CreateRoleRequestTicketCommandHandler(
|
||||
ticket.ProposedRoleName,
|
||||
ticket.ProposedMaxConfigs,
|
||||
ticket.ProposedMaxIpLimit,
|
||||
ticket.RequestedDays,
|
||||
ticket.CreatedAt,
|
||||
[commentDto]
|
||||
);
|
||||
|
||||
@@ -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})."
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace PnvPanel.Application.Support;
|
||||
/// <summary>
|
||||
/// RequestedRoleName — имя существующей роли, если RequestedRoleId задан (резолвится хендлером,
|
||||
/// на SupportTicket хранится только Id). Для новой роли имя лежит прямо в ProposedRoleName.
|
||||
/// RequestedDays — только для ExtensionRequest (продление оплаченного периода).
|
||||
/// </summary>
|
||||
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<TicketCommentDto> Comments
|
||||
);
|
||||
|
||||
@@ -76,6 +76,7 @@ internal static class TicketMapping
|
||||
ticket.ProposedRoleName,
|
||||
ticket.ProposedMaxConfigs,
|
||||
ticket.ProposedMaxIpLimit,
|
||||
ticket.RequestedDays,
|
||||
ticket.CreatedAt,
|
||||
commentDtos
|
||||
);
|
||||
|
||||
@@ -8,7 +8,8 @@ namespace PnvPanel.Domain.Support;
|
||||
/// или параметры новой). Текст обращения и переписка — в TicketComment, отдельной таблицей (не
|
||||
/// навигационная коллекция — см. конвенцию проекта на плоских сущностях, ср. TrafficSample/VpnConfig).
|
||||
/// Для RoleRequest заполнен либо RequestedRoleId, либо Proposed* — гарантируется отдельными фабриками,
|
||||
/// а не runtime-проверкой одного универсального конструктора.
|
||||
/// а не runtime-проверкой одного универсального конструктора. Для ExtensionRequest (продление
|
||||
/// оплаченного периода — только для billing-ролей, см. AppRole.BillingEnabled) заполнен RequestedDays.
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Решено (в т.ч. заявка на роль одобрена — роль выдаётся оркестрацией на уровне Application).</summary>
|
||||
public void Resolve()
|
||||
{
|
||||
|
||||
@@ -4,4 +4,5 @@ public enum TicketType
|
||||
{
|
||||
BugReport,
|
||||
RoleRequest,
|
||||
ExtensionRequest,
|
||||
}
|
||||
|
||||
+1046
File diff suppressed because it is too large
Load Diff
+28
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddExtensionRequest : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "RequestedDays",
|
||||
table: "SupportTickets",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RequestedDays",
|
||||
table: "SupportTickets");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -625,6 +625,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<int?>("RequestedDays")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("RequestedRoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
|
||||
+132
@@ -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<IIdentityService>();
|
||||
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
|
||||
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
|
||||
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
|
||||
private readonly ILogger<GrantBillingGiftCommandHandler> _logger = Substitute.For<
|
||||
ILogger<GrantBillingGiftCommandHandler>
|
||||
>();
|
||||
|
||||
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<CancellationToken>())
|
||||
.Returns(Profile(userId, billingEnabled: true, paidUntil: null));
|
||||
_identityService
|
||||
.ExtendBillingPaidUntilAsync(userId, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.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<DateTimeOffset>(d => d >= before.AddDays(30).AddMinutes(-1)),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
await _telegramNotifier
|
||||
.Received(1)
|
||||
.NotifyUserAsync(
|
||||
userId,
|
||||
Arg.Is<string>(m => m.Contains("30")),
|
||||
Arg.Any<string?>(),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenBillingNotEnabled_ReturnsNotEnabled()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
_identityService
|
||||
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||
.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<CancellationToken>())
|
||||
.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);
|
||||
}
|
||||
}
|
||||
+189
@@ -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<IIdentityService>();
|
||||
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
|
||||
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
|
||||
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
|
||||
private readonly ILogger<ApproveExtensionRequestCommandHandler> _logger = Substitute.For<
|
||||
ILogger<ApproveExtensionRequestCommandHandler>
|
||||
>();
|
||||
|
||||
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<CancellationToken>())
|
||||
.Returns(Profile(userId, paidUntil: null));
|
||||
_identityService
|
||||
.ExtendBillingPaidUntilAsync(userId, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.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<DateTimeOffset>(d => d >= before.AddDays(10).AddMinutes(-1)),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
}
|
||||
|
||||
[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<CancellationToken>())
|
||||
.Returns(Profile(userId, paidUntil: null));
|
||||
_identityService
|
||||
.ExtendBillingPaidUntilAsync(userId, Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Result.Success());
|
||||
_gateway
|
||||
.UpdateClientAsync(
|
||||
Arg.Any<Node>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<VpnProtocol>(),
|
||||
Arg.Any<string>(),
|
||||
enable: true,
|
||||
Arg.Any<CancellationToken>()
|
||||
)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
+73
@@ -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<IRealtimeNotifier>();
|
||||
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
|
||||
|
||||
[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<string>(),
|
||||
Arg.Any<string?>(),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
+124
@@ -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<IIdentityService>();
|
||||
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
|
||||
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
|
||||
|
||||
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<CancellationToken>())
|
||||
.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<Guid>(),
|
||||
"alice",
|
||||
14,
|
||||
"нужно продлить",
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenBillingNotEnabled_ReturnsNotEnabled()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
_identityService
|
||||
.GetProfileAsync(userId, Arg.Any<CancellationToken>())
|
||||
.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<CancellationToken>())
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
+10
-4
@@ -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`. Вложения отдаются не статикой — `<img src>` не может передать
|
||||
`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<AdminPaymentRequestDto>` (включает `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
|
||||
|
||||
|
||||
+28
-9
@@ -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) и
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -37,3 +37,7 @@ export function rejectPaymentRequest(id: string, reason?: string) {
|
||||
body: { reason: reason ?? null },
|
||||
})
|
||||
}
|
||||
|
||||
export function grantBillingGift(userId: string, days: number) {
|
||||
return apiRequest<void>('/admin/billing/gift', { method: 'POST', body: { userId, days } })
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.type === 'ExtensionRequest' && (
|
||||
<div className="rounded-md border border-border p-3 text-sm">
|
||||
{t('support.requestedExtension', { days: data.requestedDays })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{data.comments.map((comment) => (
|
||||
<div key={comment.id} className="rounded-md border border-border p-3">
|
||||
@@ -126,7 +143,7 @@ export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId:
|
||||
|
||||
{data.status !== 'Closed' && (
|
||||
<div className="flex flex-wrap gap-2 border-t border-border pt-3">
|
||||
{data.type === 'RoleRequest' && data.status === 'Open' ? (
|
||||
{(data.type === 'RoleRequest' || data.type === 'ExtensionRequest') && data.status === 'Open' ? (
|
||||
<>
|
||||
<Button size="sm" disabled={approveMutation.isPending} onClick={() => approveMutation.mutate()}>
|
||||
{t('admin.support.approve')}
|
||||
|
||||
@@ -41,3 +41,14 @@ export function approveRoleRequest(ticketId: string) {
|
||||
export function rejectRoleRequest(ticketId: string, reason?: string) {
|
||||
return apiRequest<void>(`/admin/support/tickets/${ticketId}/reject`, { method: 'POST', body: { reason } })
|
||||
}
|
||||
|
||||
export function approveExtensionRequest(ticketId: string) {
|
||||
return apiRequest<void>(`/admin/support/tickets/${ticketId}/approve-extension`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function rejectExtensionRequest(ticketId: string, reason?: string) {
|
||||
return apiRequest<void>(`/admin/support/tickets/${ticketId}/reject-extension`, {
|
||||
method: 'POST',
|
||||
body: { reason },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { Label } from '@/shared/ui/label'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { listRoles } from '@/features/admin/roles/api'
|
||||
import { grantBillingGift } from '@/features/admin/billing/api'
|
||||
import { PaidUntilBadge } from '@/features/billing/PaidUntilBadge'
|
||||
import { useAuthStore } from '@/features/auth/store'
|
||||
import type { UserSummaryDto } from '@/shared/api/types'
|
||||
import {
|
||||
@@ -25,6 +27,7 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [giftDays, setGiftDays] = useState('')
|
||||
const currentUserId = useAuthStore((state) => state.user?.id)
|
||||
|
||||
const rolesQuery = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles, enabled: open })
|
||||
@@ -59,6 +62,16 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma
|
||||
onError: () => toast.error(t('auth.genericError')),
|
||||
})
|
||||
|
||||
const giftMutation = useMutation({
|
||||
mutationFn: () => grantBillingGift(user.id, Number(giftDays)),
|
||||
onSuccess: async () => {
|
||||
toast.success(t('admin.users.giftGranted'))
|
||||
setGiftDays('')
|
||||
await invalidateUsers()
|
||||
},
|
||||
onError: () => toast.error(t('auth.genericError')),
|
||||
})
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (configId: string) => forceRevokeConfig(configId),
|
||||
onSuccess: async () => {
|
||||
@@ -139,6 +152,30 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.billingEnabled && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.users.billingLabel')}</Label>
|
||||
<PaidUntilBadge paidUntil={user.billingPaidUntil} />
|
||||
<div className="mt-1 flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={giftDays}
|
||||
onChange={(e) => setGiftDays(e.target.value)}
|
||||
placeholder={t('admin.users.giftDaysPlaceholder')}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!(Number(giftDays) > 0) || giftMutation.isPending}
|
||||
onClick={() => giftMutation.mutate()}
|
||||
>
|
||||
{t('admin.users.giftGrant')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>{t('admin.users.configs')}</Label>
|
||||
{configsQuery.data?.length === 0 && <p className="text-sm text-muted-foreground">{t('configs.empty')}</p>}
|
||||
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">{t('support.requestExtension')}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('support.requestExtension')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (canSubmit) mutation.mutate()
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="requested-days">{t('support.requestedDaysLabel')}</Label>
|
||||
<Input
|
||||
id="requested-days"
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={requestedDays}
|
||||
onChange={(e) => setRequestedDays(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="extension-justification">{t('support.justification')}</Label>
|
||||
<Textarea
|
||||
id="extension-justification"
|
||||
value={justification}
|
||||
onChange={(e) => setJustification(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={!canSubmit || mutation.isPending}>
|
||||
{t('support.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { getMyBillingStatus } from '@/features/billing/api'
|
||||
import { CreateBugReportDialog } from './CreateBugReportDialog'
|
||||
import { CreateExtensionRequestDialog } from './CreateExtensionRequestDialog'
|
||||
import { CreateRoleRequestDialog } from './CreateRoleRequestDialog'
|
||||
import { TicketDetailDialog } from './TicketDetailDialog'
|
||||
import { TicketStatusBadge } from './TicketStatusBadge'
|
||||
@@ -23,12 +25,14 @@ export function SupportTicketList() {
|
||||
queryKey: ['my-tickets', page],
|
||||
queryFn: () => listMyTickets(undefined, undefined, page, PAGE_SIZE),
|
||||
})
|
||||
const billingStatusQuery = useQuery({ queryKey: ['my-billing-status'], queryFn: getMyBillingStatus })
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<CreateBugReportDialog />
|
||||
<CreateRoleRequestDialog />
|
||||
{billingStatusQuery.data?.billingEnabled && <CreateExtensionRequestDialog />}
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-sm text-muted-foreground">…</p>}
|
||||
|
||||
@@ -75,6 +75,12 @@ export function TicketDetailDialog({ ticketId, onOpenChange }: { ticketId: strin
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.type === 'ExtensionRequest' && (
|
||||
<div className="rounded-md border border-border p-3 text-sm">
|
||||
{t('support.requestedExtension', { days: data.requestedDays })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{data.comments.map((comment) => (
|
||||
<div key={comment.id} className="rounded-md border border-border p-3">
|
||||
|
||||
@@ -50,6 +50,13 @@ export function createRoleRequestTicket(payload: {
|
||||
return apiRequest<TicketDetailDto>('/support/tickets/role-requests', { method: 'POST', body: payload })
|
||||
}
|
||||
|
||||
export function createExtensionRequestTicket(requestedDays: number, justification: string) {
|
||||
return apiRequest<TicketDetailDto>('/support/tickets/extension-requests', {
|
||||
method: 'POST',
|
||||
body: { requestedDays, justification },
|
||||
})
|
||||
}
|
||||
|
||||
export function addTicketComment(ticketId: string, body: string, files: File[]) {
|
||||
const formData = new FormData()
|
||||
formData.set('body', body)
|
||||
|
||||
@@ -310,7 +310,7 @@ export type AuditLogDto = {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type TicketType = 'BugReport' | 'RoleRequest'
|
||||
export type TicketType = 'BugReport' | 'RoleRequest' | 'ExtensionRequest'
|
||||
export type TicketStatus = 'Open' | 'Resolved' | 'Closed'
|
||||
|
||||
export type TicketAttachmentDto = {
|
||||
@@ -356,6 +356,7 @@ export type TicketDetailDto = {
|
||||
proposedRoleName: string | null
|
||||
proposedMaxConfigs: number | null
|
||||
proposedMaxIpLimit: number | null
|
||||
requestedDays: number | null
|
||||
createdAt: string
|
||||
comments: TicketCommentDto[]
|
||||
}
|
||||
|
||||
@@ -165,6 +165,10 @@ const resources = {
|
||||
empty: 'У вас пока нет обращений.',
|
||||
reportBug: 'Сообщить об ошибке',
|
||||
requestRole: 'Запросить роль',
|
||||
requestExtension: 'Попросить о продлении',
|
||||
requestedDaysLabel: 'Сколько дней нужно',
|
||||
requestedExtension: 'Запрошено продление на {{days}} дн.',
|
||||
extensionRequestPending: 'У вас уже есть необработанная заявка на продление.',
|
||||
submit: 'Отправить',
|
||||
messageLabel: 'Опишите проблему или предложение',
|
||||
attachmentsLabel: 'Скриншоты (необязательно, до 5)',
|
||||
@@ -194,6 +198,7 @@ const resources = {
|
||||
type: {
|
||||
BugReport: 'Ошибка/предложение',
|
||||
RoleRequest: 'Заявка на роль',
|
||||
ExtensionRequest: 'Заявка на продление',
|
||||
},
|
||||
status: {
|
||||
Open: 'Открыт',
|
||||
@@ -274,6 +279,9 @@ const resources = {
|
||||
delete: 'Удалить пользователя',
|
||||
confirmDelete: 'Удалить пользователя? Все его конфиги будут отозваны в 3x-ui, действие необратимо.',
|
||||
deleted: 'Пользователь удалён.',
|
||||
giftDaysPlaceholder: 'Дней',
|
||||
giftGrant: 'Подарить',
|
||||
giftGranted: 'Дни подписки подарены пользователю.',
|
||||
},
|
||||
configs: {
|
||||
searchPlaceholder: 'Поиск по email в панели или метке',
|
||||
@@ -681,6 +689,10 @@ const resources = {
|
||||
empty: 'You have no tickets yet.',
|
||||
reportBug: 'Report a bug',
|
||||
requestRole: 'Request a role',
|
||||
requestExtension: 'Request an extension',
|
||||
requestedDaysLabel: 'How many days you need',
|
||||
requestedExtension: 'Requested a {{days}}-day extension',
|
||||
extensionRequestPending: 'You already have a pending extension request.',
|
||||
submit: 'Submit',
|
||||
messageLabel: 'Describe the issue or suggestion',
|
||||
attachmentsLabel: 'Screenshots (optional, up to 5)',
|
||||
@@ -710,6 +722,7 @@ const resources = {
|
||||
type: {
|
||||
BugReport: 'Bug/suggestion',
|
||||
RoleRequest: 'Role request',
|
||||
ExtensionRequest: 'Extension request',
|
||||
},
|
||||
status: {
|
||||
Open: 'Open',
|
||||
@@ -790,6 +803,9 @@ const resources = {
|
||||
delete: 'Delete user',
|
||||
confirmDelete: 'Delete this user? All their configs will be revoked in 3x-ui — this cannot be undone.',
|
||||
deleted: 'User deleted.',
|
||||
giftDaysPlaceholder: 'Days',
|
||||
giftGrant: 'Grant',
|
||||
giftGranted: 'Subscription days granted to the user.',
|
||||
},
|
||||
configs: {
|
||||
searchPlaceholder: 'Search by panel email or label',
|
||||
|
||||
Reference in New Issue
Block a user