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
|
admin
|
||||||
.MapPost("/requests/{id:guid}/reject", RejectRequest)
|
.MapPost("/requests/{id:guid}/reject", RejectRequest)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
admin.MapPost("/gift", GrantGift).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
@@ -80,6 +81,19 @@ public static class AdminBillingEndpoints
|
|||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
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(
|
public sealed record ListPaymentRequestsRequest(
|
||||||
@@ -89,3 +103,5 @@ public sealed record ListPaymentRequestsRequest(
|
|||||||
);
|
);
|
||||||
|
|
||||||
public sealed record RejectPaymentRequestBody(string? Reason);
|
public sealed record RejectPaymentRequestBody(string? Reason);
|
||||||
|
|
||||||
|
public sealed record GrantGiftBody(Guid UserId, int Days);
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ public static class AdminSupportEndpoints
|
|||||||
admin
|
admin
|
||||||
.MapPost("/tickets/{id:guid}/reject", RejectRoleRequest)
|
.MapPost("/tickets/{id:guid}/reject", RejectRoleRequest)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.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;
|
return app;
|
||||||
}
|
}
|
||||||
@@ -119,6 +125,32 @@ public static class AdminSupportEndpoints
|
|||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
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 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;
|
||||||
using PnvPanel.Application.Support.AddComment;
|
using PnvPanel.Application.Support.AddComment;
|
||||||
using PnvPanel.Application.Support.CreateBugReport;
|
using PnvPanel.Application.Support.CreateBugReport;
|
||||||
|
using PnvPanel.Application.Support.CreateExtensionRequest;
|
||||||
using PnvPanel.Application.Support.CreateRoleRequest;
|
using PnvPanel.Application.Support.CreateRoleRequest;
|
||||||
using PnvPanel.Application.Support.GetAttachment;
|
using PnvPanel.Application.Support.GetAttachment;
|
||||||
using PnvPanel.Application.Support.GetSupportPricing;
|
using PnvPanel.Application.Support.GetSupportPricing;
|
||||||
@@ -30,6 +31,9 @@ public static class SupportEndpoints
|
|||||||
.DisableAntiforgery()
|
.DisableAntiforgery()
|
||||||
.Produces<TicketDetailDto>();
|
.Produces<TicketDetailDto>();
|
||||||
group.MapPost("/tickets/role-requests", CreateRoleRequest).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", ListMyTickets).Produces<PagedList<TicketSummaryDto>>();
|
||||||
group.MapGet("/tickets/{id:guid}", GetTicket).Produces<TicketDetailDto>();
|
group.MapGet("/tickets/{id:guid}", GetTicket).Produces<TicketDetailDto>();
|
||||||
group
|
group
|
||||||
@@ -89,6 +93,17 @@ public static class SupportEndpoints
|
|||||||
return result.ToHttpResult();
|
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(
|
private static async Task<IResult> ListMyTickets(
|
||||||
[AsParameters] ListTicketsRequest request,
|
[AsParameters] ListTicketsRequest request,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
@@ -175,6 +190,8 @@ public sealed record CreateRoleRequestBody(
|
|||||||
string Justification
|
string Justification
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public sealed record CreateExtensionRequestBody(int RequestedDays, string Justification);
|
||||||
|
|
||||||
public sealed record ListTicketsRequest(
|
public sealed record ListTicketsRequest(
|
||||||
TicketType? Type,
|
TicketType? Type,
|
||||||
TicketStatus? Status,
|
TicketStatus? Status,
|
||||||
|
|||||||
@@ -489,6 +489,55 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
|
|
||||||
break;
|
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":
|
case "pay":
|
||||||
{
|
{
|
||||||
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
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(
|
public async Task NotifyAdminsTicketReopenedAsync(
|
||||||
Guid ticketId,
|
Guid ticketId,
|
||||||
string userName,
|
string userName,
|
||||||
|
|||||||
+9
-59
@@ -7,7 +7,6 @@ using PnvPanel.Application.Common.Messaging;
|
|||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
using PnvPanel.Domain.Audit;
|
using PnvPanel.Domain.Audit;
|
||||||
using PnvPanel.Domain.Billing;
|
using PnvPanel.Domain.Billing;
|
||||||
using PnvPanel.Domain.Configs;
|
|
||||||
|
|
||||||
namespace PnvPanel.Application.Admin.Billing;
|
namespace PnvPanel.Application.Admin.Billing;
|
||||||
|
|
||||||
@@ -65,64 +64,15 @@ public sealed class ConfirmPaymentRequestCommandHandler(
|
|||||||
|
|
||||||
request.Confirm(adminId);
|
request.Confirm(adminId);
|
||||||
|
|
||||||
var configs = await dbContext
|
await BillingConfigResumer.ResumeConfigsAsync(
|
||||||
.VpnConfigs.Where(c =>
|
dbContext,
|
||||||
c.UserId == request.UserId
|
gateway,
|
||||||
&& (c.Status == ConfigStatus.Active || c.Status == ConfigStatus.Expired)
|
notifier,
|
||||||
)
|
logger,
|
||||||
.ToListAsync(cancellationToken);
|
request.UserId,
|
||||||
|
newPaidUntil,
|
||||||
foreach (var config in configs)
|
cancellationToken
|
||||||
{
|
);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
dbContext.AuditLogs.Add(
|
dbContext.AuditLogs.Add(
|
||||||
AuditLog.Create(
|
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,
|
int amount,
|
||||||
CancellationToken cancellationToken
|
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,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
ticket.CreatedAt,
|
ticket.CreatedAt,
|
||||||
[commentDto]
|
[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.ProposedRoleName,
|
||||||
ticket.ProposedMaxConfigs,
|
ticket.ProposedMaxConfigs,
|
||||||
ticket.ProposedMaxIpLimit,
|
ticket.ProposedMaxIpLimit,
|
||||||
|
ticket.RequestedDays,
|
||||||
ticket.CreatedAt,
|
ticket.CreatedAt,
|
||||||
[commentDto]
|
[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(
|
public static readonly Error TooManyAttachments = Error.Validation(
|
||||||
"Support.TooManyAttachments",
|
"Support.TooManyAttachments",
|
||||||
$"Слишком много вложений (максимум {TicketAttachmentValidation.MaxAttachments})."
|
$"Слишком много вложений (максимум {TicketAttachmentValidation.MaxAttachments})."
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace PnvPanel.Application.Support;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// RequestedRoleName — имя существующей роли, если RequestedRoleId задан (резолвится хендлером,
|
/// RequestedRoleName — имя существующей роли, если RequestedRoleId задан (резолвится хендлером,
|
||||||
/// на SupportTicket хранится только Id). Для новой роли имя лежит прямо в ProposedRoleName.
|
/// на SupportTicket хранится только Id). Для новой роли имя лежит прямо в ProposedRoleName.
|
||||||
|
/// RequestedDays — только для ExtensionRequest (продление оплаченного периода).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record TicketDetailDto(
|
public sealed record TicketDetailDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -17,6 +18,7 @@ public sealed record TicketDetailDto(
|
|||||||
string? ProposedRoleName,
|
string? ProposedRoleName,
|
||||||
int? ProposedMaxConfigs,
|
int? ProposedMaxConfigs,
|
||||||
int? ProposedMaxIpLimit,
|
int? ProposedMaxIpLimit,
|
||||||
|
int? RequestedDays,
|
||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
IReadOnlyList<TicketCommentDto> Comments
|
IReadOnlyList<TicketCommentDto> Comments
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ internal static class TicketMapping
|
|||||||
ticket.ProposedRoleName,
|
ticket.ProposedRoleName,
|
||||||
ticket.ProposedMaxConfigs,
|
ticket.ProposedMaxConfigs,
|
||||||
ticket.ProposedMaxIpLimit,
|
ticket.ProposedMaxIpLimit,
|
||||||
|
ticket.RequestedDays,
|
||||||
ticket.CreatedAt,
|
ticket.CreatedAt,
|
||||||
commentDtos
|
commentDtos
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ namespace PnvPanel.Domain.Support;
|
|||||||
/// или параметры новой). Текст обращения и переписка — в TicketComment, отдельной таблицей (не
|
/// или параметры новой). Текст обращения и переписка — в TicketComment, отдельной таблицей (не
|
||||||
/// навигационная коллекция — см. конвенцию проекта на плоских сущностях, ср. TrafficSample/VpnConfig).
|
/// навигационная коллекция — см. конвенцию проекта на плоских сущностях, ср. TrafficSample/VpnConfig).
|
||||||
/// Для RoleRequest заполнен либо RequestedRoleId, либо Proposed* — гарантируется отдельными фабриками,
|
/// Для RoleRequest заполнен либо RequestedRoleId, либо Proposed* — гарантируется отдельными фабриками,
|
||||||
/// а не runtime-проверкой одного универсального конструктора.
|
/// а не runtime-проверкой одного универсального конструктора. Для ExtensionRequest (продление
|
||||||
|
/// оплаченного периода — только для billing-ролей, см. AppRole.BillingEnabled) заполнен RequestedDays.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SupportTicket : Entity
|
public sealed class SupportTicket : Entity
|
||||||
{
|
{
|
||||||
@@ -19,6 +20,7 @@ public sealed class SupportTicket : Entity
|
|||||||
public string? ProposedRoleName { get; private set; }
|
public string? ProposedRoleName { get; private set; }
|
||||||
public int? ProposedMaxConfigs { get; private set; }
|
public int? ProposedMaxConfigs { get; private set; }
|
||||||
public int? ProposedMaxIpLimit { get; private set; }
|
public int? ProposedMaxIpLimit { get; private set; }
|
||||||
|
public int? RequestedDays { get; private set; }
|
||||||
public DateTimeOffset CreatedAt { get; private set; }
|
public DateTimeOffset CreatedAt { get; private set; }
|
||||||
|
|
||||||
private SupportTicket() { }
|
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>
|
/// <summary>Решено (в т.ч. заявка на роль одобрена — роль выдаётся оркестрацией на уровне Application).</summary>
|
||||||
public void Resolve()
|
public void Resolve()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ public enum TicketType
|
|||||||
{
|
{
|
||||||
BugReport,
|
BugReport,
|
||||||
RoleRequest,
|
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)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("character varying(100)");
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<int?>("RequestedDays")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<Guid?>("RequestedRoleId")
|
b.Property<Guid?>("RequestedRoleId")
|
||||||
.HasColumnType("uuid");
|
.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);
|
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]
|
[Fact]
|
||||||
public void Resolve_WhenOpen_SetsResolved()
|
public void Resolve_WhenOpen_SetsResolved()
|
||||||
{
|
{
|
||||||
|
|||||||
+10
-4
@@ -174,18 +174,20 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро
|
|||||||
| GET | `/api/support/tickets/{id}` | — | `TicketDetailDto` (404, если не свой) |
|
| GET | `/api/support/tickets/{id}` | — | `TicketDetailDto` (404, если не свой) |
|
||||||
| POST | `/api/support/tickets/bug-reports` | multipart: `message` + `files[]` (до 5, изображения до 5 МБ) | `TicketDetailDto` |
|
| 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/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}/comments` | multipart: `body` + `files[]` | `TicketCommentDto` |
|
||||||
| POST | `/api/support/tickets/{id}/reopen` | — | `204 No Content` (только владелец, только из `Resolved`) |
|
| POST | `/api/support/tickets/{id}/reopen` | — | `204 No Content` (только владелец, только из `Resolved`) |
|
||||||
| GET | `/api/support/attachments/{id}` | — | бинарный поток с `Content-Type` вложения |
|
| GET | `/api/support/attachments/{id}` | — | бинарный поток с `Content-Type` вложения |
|
||||||
|
|
||||||
`TicketSummaryDto`: `{ id, userId, userName, type, status, createdAt, lastActivityAt }` — один DTO для
|
`TicketSummaryDto`: `{ id, userId, userName, type, status, createdAt, lastActivityAt }` — один DTO для
|
||||||
своего и админского списков. `TicketDetailDto` добавляет `requestedRoleId, requestedRoleName,
|
своего и админского списков. `TicketDetailDto` добавляет `requestedRoleId, requestedRoleName,
|
||||||
proposedRoleName, proposedMaxConfigs, proposedMaxIpLimit, comments: TicketCommentDto[]`.
|
proposedRoleName, proposedMaxConfigs, proposedMaxIpLimit, requestedDays, comments: TicketCommentDto[]`.
|
||||||
`TicketCommentDto`: `{ id, authorId, authorName, body, createdAt, attachments: TicketAttachmentDto[] }`.
|
`TicketCommentDto`: `{ id, authorId, authorName, body, createdAt, attachments: TicketAttachmentDto[] }`.
|
||||||
|
|
||||||
Ровно одна из двух заявок на роль: либо `existingRoleId` (роль `admin` запрещена — `403
|
Ровно одна из двух заявок на роль: либо `existingRoleId` (роль `admin` запрещена — `403
|
||||||
Support.CannotRequestAdminRole`), либо все три поля новой роли. Заявка при существующем открытом
|
Support.CannotRequestAdminRole`), либо все три поля новой роли. Заявка при существующем открытом
|
||||||
запросе на роль → `409 Support.RoleRequestAlreadyPending`. `POST …/comments` на `Closed`-тикете →
|
запросе на роль → `409 Support.RoleRequestAlreadyPending`; аналогично для продления →
|
||||||
|
`409 Support.ExtensionRequestAlreadyPending`. `POST …/comments` на `Closed`-тикете →
|
||||||
`409 Support.TicketClosed`. Вложения отдаются не статикой — `<img src>` не может передать
|
`409 Support.TicketClosed`. Вложения отдаются не статикой — `<img src>` не может передать
|
||||||
`Authorization`-заголовок, фронт качает их как `Blob` через `fetch` и рендерит `Object URL`.
|
`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}/close` | — | `204 No Content` (любой тип, финал) |
|
||||||
| POST | `/api/admin/support/tickets/{id}/approve` | — | `204 No Content` (только `RoleRequest`/`Open`; создаёt/назначает роль) |
|
| 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}/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/
|
Обработать **собственный** тикет админу можно (в т.ч. одобрить свою же заявку на роль) — resolve/close/
|
||||||
reject/approve владением тикета не ограничены. Единственное реальное ограничение — `approve` вернёт
|
reject/approve владением тикета не ограничены. Единственное реальное ограничение — `approve` вернёт
|
||||||
@@ -272,14 +276,16 @@ approve/reject над `ActivationRequest`.
|
|||||||
|
|
||||||
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
||||||
| ----- | -------------------------------------------- | ----- | ---------------------------- | ------------- |
|
| ----- | -------------------------------------------- | ----- | ---------------------------- | ------------- |
|
||||||
| GET | `/api/admin/billing/settings` | admin | — | `BillingSettingsDto { requisitesText, graceDays }` |
|
| GET | `/api/admin/billing/settings` | admin | — | `BillingSettingsDto { requisitesText, graceDays, defaultBillingEnabledForNewRoles }` |
|
||||||
| PUT | `/api/admin/billing/settings` | admin | `{ requisitesText, graceDays }` | `BillingSettingsDto` |
|
| 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`) |
|
| 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}/confirm` | admin | — | `204 No Content` (продлевает `BillingPaidUntil`, возвращает приостановленные конфиги в `Active`) |
|
||||||
| POST | `/api/admin/billing/requests/{id}/reject` | admin | `{ reason? }` | `204 No Content` |
|
| 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, не заходя на сайт** — инлайн-кнопки на
|
То же подтверждение/отклонение доступно **из Telegram, не заходя на сайт** — инлайн-кнопки на
|
||||||
уведомлении о заявке (`pay:approve:{id}`/`pay:reject:{id}`, см. [telegram-bot.md](telegram-bot.md)).
|
уведомлении о заявке (`pay:approve:{id}`/`pay:reject:{id}`, см. [telegram-bot.md](telegram-bot.md)).
|
||||||
|
Гифт — только на сайте, в боте не решается.
|
||||||
|
|
||||||
## Admin — Nodes
|
## Admin — Nodes
|
||||||
|
|
||||||
|
|||||||
+28
-9
@@ -434,7 +434,11 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты
|
|||||||
|
|
||||||
`GET/POST /api/billing/*` — пользователь (статус, создание/отмена заявки, «я оплатил», отправка
|
`GET/POST /api/billing/*` — пользователь (статус, создание/отмена заявки, «я оплатил», отправка
|
||||||
реквизитов в свой Telegram). `GET/PUT/POST /api/admin/billing/*` — админ (настройки, список заявок,
|
реквизитов в свой Telegram). `GET/PUT/POST /api/admin/billing/*` — админ (настройки, список заявок,
|
||||||
подтверждение/отклонение), только `admin`.
|
подтверждение/отклонение, `POST /gift` — выдать N дней конкретному пользователю без заявки), только
|
||||||
|
`admin`. Продление PaidUntil попадает в панель тремя путями — подтверждённая `PaymentRequest`,
|
||||||
|
одобренная `SupportTicket(ExtensionRequest)` и прямой гифт от админа — все три используют один и тот
|
||||||
|
же `BillingConfigResumer` (см. Application/Billing), различается только вычисление `newPaidUntil`
|
||||||
|
(месяцы для оплаты, дни для продления/гифта) и триггер (пользователь vs админ).
|
||||||
|
|
||||||
### ActivationRequest — запрос активации
|
### ActivationRequest — запрос активации
|
||||||
Пользователь просит активацию у админа; админ одобряет/отклоняет на сайте или в Telegram.
|
Пользователь просит активацию у админа; админ одобряет/отклоняет на сайте или в Telegram.
|
||||||
@@ -480,33 +484,44 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты
|
|||||||
После `Consumed`/`Expired` — не переиспользуется.
|
После `Consumed`/`Expired` — не переиспользуется.
|
||||||
|
|
||||||
### SupportTicket — обращение в поддержку
|
### SupportTicket — обращение в поддержку
|
||||||
Два вида: `BugReport` (свободная форма, с вложениями) и `RoleRequest` (запрос существующей роли —
|
Три вида: `BugReport` (свободная форма, с вложениями), `RoleRequest` (запрос существующей роли —
|
||||||
кроме `admin` — либо параметров новой). Текст/обоснование не хранится отдельным полем — это первое
|
кроме `admin` — либо параметров новой) и `ExtensionRequest` (продление оплаченного периода на N
|
||||||
сообщение в переписке (`TicketComment`), созданное вместе с тикетом в одной операции.
|
дней — только для billing-ролей, см. Billing выше). Текст/обоснование не хранится отдельным полем —
|
||||||
|
это первое сообщение в переписке (`TicketComment`), созданное вместе с тикетом в одной операции.
|
||||||
|
|
||||||
| Поле | Тип | Заметки |
|
| Поле | Тип | Заметки |
|
||||||
| ------------------- | ----------------- | ---------------------------------------------------------------- |
|
| ------------------- | ----------------- | ---------------------------------------------------------------- |
|
||||||
| `Id` | `Guid` | PK |
|
| `Id` | `Guid` | PK |
|
||||||
| `UserId` | `Guid` | FK → AppUser (автор) |
|
| `UserId` | `Guid` | FK → AppUser (автор) |
|
||||||
| `Type` | `TicketType` | `BugReport` / `RoleRequest` |
|
| `Type` | `TicketType` | `BugReport` / `RoleRequest` / `ExtensionRequest` |
|
||||||
| `Status` | `TicketStatus` | `Open` / `Resolved` / `Closed` |
|
| `Status` | `TicketStatus` | `Open` / `Resolved` / `Closed` |
|
||||||
| `RequestedRoleId` | `Guid?` | Заполнено для `RoleRequest` при выборе существующей роли |
|
| `RequestedRoleId` | `Guid?` | Заполнено для `RoleRequest` при выборе существующей роли |
|
||||||
| `ProposedRoleName` | `string?` | Заполнено для `RoleRequest` при запросе новой роли |
|
| `ProposedRoleName` | `string?` | Заполнено для `RoleRequest` при запросе новой роли |
|
||||||
| `ProposedMaxConfigs`| `int?` | Параметры новой роли (см. `AppRole.MaxConfigs`) |
|
| `ProposedMaxConfigs`| `int?` | Параметры новой роли (см. `AppRole.MaxConfigs`) |
|
||||||
| `ProposedMaxIpLimit`| `int?` | Параметры новой роли (см. `AppRole.MaxIpLimit`) |
|
| `ProposedMaxIpLimit`| `int?` | Параметры новой роли (см. `AppRole.MaxIpLimit`) |
|
||||||
|
| `RequestedDays` | `int?` | Заполнено для `ExtensionRequest` — сколько дней просит пользователь (1–365) |
|
||||||
| `CreatedAt` | `DateTimeOffset` | |
|
| `CreatedAt` | `DateTimeOffset` | |
|
||||||
|
|
||||||
Инварианты и переходы (`backend/src/PnvPanel.Domain/Support/SupportTicket.cs`): `RequestedRoleId`
|
Инварианты и переходы (`backend/src/PnvPanel.Domain/Support/SupportTicket.cs`): `RequestedRoleId`
|
||||||
и `Proposed*` никогда не заполнены одновременно — гарантируется отдельными фабриками
|
и `Proposed*` никогда не заполнены одновременно — гарантируется отдельными фабриками
|
||||||
(`CreateRoleRequestForExistingRole`/`CreateRoleRequestForNewRole`), а не runtime-проверкой.
|
(`CreateRoleRequestForExistingRole`/`CreateRoleRequestForNewRole`), а не runtime-проверкой. Аналогично
|
||||||
|
`RequestedDays` заполняется только фабрикой `CreateExtensionRequest`.
|
||||||
- `Resolve()` — только из `Open`. Для `RoleRequest` одобрение — оркестрация в Application
|
- `Resolve()` — только из `Open`. Для `RoleRequest` одобрение — оркестрация в Application
|
||||||
(`ApproveRoleRequestCommandHandler`): при новой роли сначала `IRoleService.CreateRoleAsync`, затем
|
(`ApproveRoleRequestCommandHandler`): при новой роли сначала `IRoleService.CreateRoleAsync`, затем
|
||||||
в любом случае `ChangeUserRoleAsync` пользователю, и только потом `ticket.Resolve()`.
|
в любом случае `ChangeUserRoleAsync` пользователю, и только потом `ticket.Resolve()`. Для
|
||||||
- `Close()` — из `Open` или `Resolved`, **финал** (обратного пути нет). Для `RoleRequest` — отклонение.
|
`ExtensionRequest` — `ApproveExtensionRequestCommandHandler` продлевает `AppUser.BillingPaidUntil`
|
||||||
|
на `RequestedDays` (от `max(текущий, сейчас)`, как и у `PaymentRequest`) и возвращает в `Active`
|
||||||
|
конфиги, приостановленные за неуплату (`BillingConfigResumer`, тот же helper, что и у подтверждения
|
||||||
|
оплаты и гифт-дней от админа).
|
||||||
|
- `Close()` — из `Open` или `Resolved`, **финал** (обратного пути нет). Для `RoleRequest`/
|
||||||
|
`ExtensionRequest` — отклонение.
|
||||||
- `Reopen()` — только из `Resolved` (владелец тикета); `Closed` не переоткрывается.
|
- `Reopen()` — только из `Resolved` (владелец тикета); `Closed` не переоткрывается.
|
||||||
- Одновременно не более одной **открытой** заявки на роль (`Type == RoleRequest && Status == Open`)
|
- Одновременно не более одной **открытой** заявки на роль (`Type == RoleRequest && Status == Open`)
|
||||||
|
и отдельно не более одной открытой заявки на продление (`Type == ExtensionRequest && Status == Open`)
|
||||||
на пользователя — проверяется в Application, аналогично `ActivationRequest.AlreadyPending`.
|
на пользователя — проверяется в Application, аналогично `ActivationRequest.AlreadyPending`.
|
||||||
Баг-репорты такого ограничения не имеют.
|
Баг-репорты такого ограничения не имеют.
|
||||||
|
- Создать `ExtensionRequest` может только пользователь с billing-ролью
|
||||||
|
(`CurrentUserProfile.BillingEnabled`) — иначе `Billing.NotEnabled`.
|
||||||
- Доступ — только активированному пользователю (`IRequiresActivation`, как и у конфигов/новостей);
|
- Доступ — только активированному пользователю (`IRequiresActivation`, как и у конфигов/новостей);
|
||||||
админские действия (resolve/close/approve/reject) идут по отдельным `/api/admin/support/*` с
|
админские действия (resolve/close/approve/reject) идут по отдельным `/api/admin/support/*` с
|
||||||
ролевой проверкой, без завязки на активацию.
|
ролевой проверкой, без завязки на активацию.
|
||||||
@@ -572,7 +587,7 @@ enum TelegramLoginStatus { Pending, Approved, Rejected, Expired, Consumed }
|
|||||||
enum ActivationStatus { Pending, Approved, Rejected }
|
enum ActivationStatus { Pending, Approved, Rejected }
|
||||||
enum AuditSource { Web, Telegram, System }
|
enum AuditSource { Web, Telegram, System }
|
||||||
enum OsPlatform { IOS, Android, Windows, MacOS, Linux }
|
enum OsPlatform { IOS, Android, Windows, MacOS, Linux }
|
||||||
enum TicketType { BugReport, RoleRequest }
|
enum TicketType { BugReport, RoleRequest, ExtensionRequest }
|
||||||
enum TicketStatus { Open, Resolved, Closed }
|
enum TicketStatus { Open, Resolved, Closed }
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -604,6 +619,10 @@ enum TicketStatus { Open, Resolved, Closed }
|
|||||||
| `AddTicketCommentCommandHandler` | Realtime `ticketUpdated` владельцу, только если комментирует не он сам |
|
| `AddTicketCommentCommandHandler` | Realtime `ticketUpdated` владельцу, только если комментирует не он сам |
|
||||||
| `ApproveRoleRequestCommandHandler` | Создаёт роль (если новая) + назначает пользователю; `AuditLog` (`RoleRequestApproved`); Telegram-DM владельцу |
|
| `ApproveRoleRequestCommandHandler` | Создаёт роль (если новая) + назначает пользователю; `AuditLog` (`RoleRequestApproved`); Telegram-DM владельцу |
|
||||||
| `RejectRoleRequestCommandHandler` | `AuditLog` (`RoleRequestRejected`); 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` владельцу |
|
| `ResolveTicketCommandHandler` / `CloseTicketCommandHandler` | `AuditLog` (`TicketResolved`/`TicketClosed`); realtime `ticketUpdated` владельцу |
|
||||||
|
|
||||||
SignalR-события и группы — см. [architecture.md](architecture.md#realtime-signalr) и
|
SignalR-события и группы — см. [architecture.md](architecture.md#realtime-signalr) и
|
||||||
|
|||||||
@@ -197,6 +197,18 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
|
|||||||
4. Пользователю (если Telegram привязан) — DM «✅ Ваша заявка на роль одобрена.» / «❌ Ваша заявка на
|
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 — Оплата подписки (биллинг)
|
## Флоу 7 — Оплата подписки (биллинг)
|
||||||
|
|
||||||
Только для ролей с `AppRole.BillingEnabled` (см. [domain-model.md](domain-model.md#billing--подписка-по-сроку)).
|
Только для ролей с `AppRole.BillingEnabled` (см. [domain-model.md](domain-model.md#billing--подписка-по-сроку)).
|
||||||
@@ -221,6 +233,10 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
|
|||||||
оплаты и уведомление о приостановке конфигов при просрочке — оба через `NotifyUserAsync`, без
|
оплаты и уведомление о приостановке конфигов при просрочке — оба через `NotifyUserAsync`, без
|
||||||
инлайн-кнопок.
|
инлайн-кнопок.
|
||||||
|
|
||||||
|
**Гифт от админа** (`POST /api/admin/billing/gift`, только на сайте — в боте не решается) — админ
|
||||||
|
выдаёт пользователю N дней напрямую, без заявки. Пользователю (если Telegram привязан) — DM
|
||||||
|
«🎁 Вам подарено N дн. подписки! Доступ продлён до {дата}.».
|
||||||
|
|
||||||
**Статус оплаты по кнопке**: для пользователей с billing-ролью (`AppRole.BillingEnabled`) в главном
|
**Статус оплаты по кнопке**: для пользователей с billing-ролью (`AppRole.BillingEnabled`) в главном
|
||||||
меню бота появляется «💳 Статус оплаты» (`menu:billing`, либо команда `/billing`) — показывает дату,
|
меню бота появляется «💳 Статус оплаты» (`menu:billing`, либо команда `/billing`) — показывает дату,
|
||||||
до которой оплачено, и остаток в человекочитаемом виде: дни, если их ≥ 1 (`N дн.`), иначе часы и
|
до которой оплачено, и остаток в человекочитаемом виде: дни, если их ≥ 1 (`N дн.`), иначе часы и
|
||||||
@@ -246,6 +262,7 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
|
|||||||
| «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env |
|
| «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env |
|
||||||
| `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env |
|
| `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env |
|
||||||
| «✅ Одобрить»/«❌ Отклонить» (`rrq:*`) | (admin) решение по заявке на роль — создаёт/назначает роль | админ по env |
|
| «✅ Одобрить»/«❌ Отклонить» (`rrq:*`) | (admin) решение по заявке на роль — создаёт/назначает роль | админ по env |
|
||||||
|
| «✅ Одобрить»/«❌ Отклонить» (`erq:*`) | (admin) решение по заявке на продление — продлевает `BillingPaidUntil` | админ по env |
|
||||||
| «✅ Подтвердить»/«❌ Отклонить» (`pay:*`) | (admin) решение по заявке на оплату — продлевает `BillingPaidUntil` | админ по env |
|
| «✅ Подтвердить»/«❌ Отклонить» (`pay:*`) | (admin) решение по заявке на оплату — продлевает `BillingPaidUntil` | админ по env |
|
||||||
| «🌐 Открыть на сайте» | Ссылка на баг-репорт на сайте (только если задан `Telegram__PublicSiteUrl`) | админ по env |
|
| «🌐 Открыть на сайте» | Ссылка на баг-репорт на сайте (только если задан `Telegram__PublicSiteUrl`) | админ по env |
|
||||||
|
|
||||||
|
|||||||
@@ -37,3 +37,7 @@ export function rejectPaymentRequest(id: string, reason?: string) {
|
|||||||
body: { reason: reason ?? null },
|
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 { HttpError } from '@/shared/api/client'
|
||||||
import { TicketStatusBadge } from '@/features/support/TicketStatusBadge'
|
import { TicketStatusBadge } from '@/features/support/TicketStatusBadge'
|
||||||
import { TicketAttachmentImage } from '@/features/support/TicketAttachmentImage'
|
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
|
const MAX_FILES = 5
|
||||||
|
|
||||||
@@ -57,7 +66,8 @@ export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId:
|
|||||||
})
|
})
|
||||||
|
|
||||||
const approveMutation = useMutation({
|
const approveMutation = useMutation({
|
||||||
mutationFn: () => approveRoleRequest(ticketId),
|
mutationFn: () =>
|
||||||
|
data?.type === 'ExtensionRequest' ? approveExtensionRequest(ticketId) : approveRoleRequest(ticketId),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
toast.success(t('admin.support.approved'))
|
toast.success(t('admin.support.approved'))
|
||||||
await invalidate()
|
await invalidate()
|
||||||
@@ -66,7 +76,8 @@ export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId:
|
|||||||
})
|
})
|
||||||
|
|
||||||
const rejectMutation = useMutation({
|
const rejectMutation = useMutation({
|
||||||
mutationFn: () => rejectRoleRequest(ticketId),
|
mutationFn: () =>
|
||||||
|
data?.type === 'ExtensionRequest' ? rejectExtensionRequest(ticketId) : rejectRoleRequest(ticketId),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
toast.success(t('admin.support.rejected'))
|
toast.success(t('admin.support.rejected'))
|
||||||
await invalidate()
|
await invalidate()
|
||||||
@@ -105,6 +116,12 @@ export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId:
|
|||||||
</div>
|
</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">
|
<div className="flex flex-col gap-3">
|
||||||
{data.comments.map((comment) => (
|
{data.comments.map((comment) => (
|
||||||
<div key={comment.id} className="rounded-md border border-border p-3">
|
<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' && (
|
{data.status !== 'Closed' && (
|
||||||
<div className="flex flex-wrap gap-2 border-t border-border pt-3">
|
<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()}>
|
<Button size="sm" disabled={approveMutation.isPending} onClick={() => approveMutation.mutate()}>
|
||||||
{t('admin.support.approve')}
|
{t('admin.support.approve')}
|
||||||
|
|||||||
@@ -41,3 +41,14 @@ export function approveRoleRequest(ticketId: string) {
|
|||||||
export function rejectRoleRequest(ticketId: string, reason?: string) {
|
export function rejectRoleRequest(ticketId: string, reason?: string) {
|
||||||
return apiRequest<void>(`/admin/support/tickets/${ticketId}/reject`, { method: 'POST', body: { reason } })
|
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 { Badge } from '@/shared/ui/badge'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
import { listRoles } from '@/features/admin/roles/api'
|
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 { useAuthStore } from '@/features/auth/store'
|
||||||
import type { UserSummaryDto } from '@/shared/api/types'
|
import type { UserSummaryDto } from '@/shared/api/types'
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +27,7 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [newPassword, setNewPassword] = useState('')
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [giftDays, setGiftDays] = useState('')
|
||||||
const currentUserId = useAuthStore((state) => state.user?.id)
|
const currentUserId = useAuthStore((state) => state.user?.id)
|
||||||
|
|
||||||
const rolesQuery = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles, enabled: open })
|
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')),
|
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({
|
const revokeMutation = useMutation({
|
||||||
mutationFn: (configId: string) => forceRevokeConfig(configId),
|
mutationFn: (configId: string) => forceRevokeConfig(configId),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -139,6 +152,30 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>{t('admin.users.configs')}</Label>
|
<Label>{t('admin.users.configs')}</Label>
|
||||||
{configsQuery.data?.length === 0 && <p className="text-sm text-muted-foreground">{t('configs.empty')}</p>}
|
{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 { useTranslation } from 'react-i18next'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||||
|
import { getMyBillingStatus } from '@/features/billing/api'
|
||||||
import { CreateBugReportDialog } from './CreateBugReportDialog'
|
import { CreateBugReportDialog } from './CreateBugReportDialog'
|
||||||
|
import { CreateExtensionRequestDialog } from './CreateExtensionRequestDialog'
|
||||||
import { CreateRoleRequestDialog } from './CreateRoleRequestDialog'
|
import { CreateRoleRequestDialog } from './CreateRoleRequestDialog'
|
||||||
import { TicketDetailDialog } from './TicketDetailDialog'
|
import { TicketDetailDialog } from './TicketDetailDialog'
|
||||||
import { TicketStatusBadge } from './TicketStatusBadge'
|
import { TicketStatusBadge } from './TicketStatusBadge'
|
||||||
@@ -23,12 +25,14 @@ export function SupportTicketList() {
|
|||||||
queryKey: ['my-tickets', page],
|
queryKey: ['my-tickets', page],
|
||||||
queryFn: () => listMyTickets(undefined, undefined, page, PAGE_SIZE),
|
queryFn: () => listMyTickets(undefined, undefined, page, PAGE_SIZE),
|
||||||
})
|
})
|
||||||
|
const billingStatusQuery = useQuery({ queryKey: ['my-billing-status'], queryFn: getMyBillingStatus })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<CreateBugReportDialog />
|
<CreateBugReportDialog />
|
||||||
<CreateRoleRequestDialog />
|
<CreateRoleRequestDialog />
|
||||||
|
{billingStatusQuery.data?.billingEnabled && <CreateExtensionRequestDialog />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && <p className="text-sm text-muted-foreground">…</p>}
|
{isLoading && <p className="text-sm text-muted-foreground">…</p>}
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ export function TicketDetailDialog({ ticketId, onOpenChange }: { ticketId: strin
|
|||||||
</div>
|
</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">
|
<div className="flex flex-col gap-3">
|
||||||
{data.comments.map((comment) => (
|
{data.comments.map((comment) => (
|
||||||
<div key={comment.id} className="rounded-md border border-border p-3">
|
<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 })
|
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[]) {
|
export function addTicketComment(ticketId: string, body: string, files: File[]) {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.set('body', body)
|
formData.set('body', body)
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ export type AuditLogDto = {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TicketType = 'BugReport' | 'RoleRequest'
|
export type TicketType = 'BugReport' | 'RoleRequest' | 'ExtensionRequest'
|
||||||
export type TicketStatus = 'Open' | 'Resolved' | 'Closed'
|
export type TicketStatus = 'Open' | 'Resolved' | 'Closed'
|
||||||
|
|
||||||
export type TicketAttachmentDto = {
|
export type TicketAttachmentDto = {
|
||||||
@@ -356,6 +356,7 @@ export type TicketDetailDto = {
|
|||||||
proposedRoleName: string | null
|
proposedRoleName: string | null
|
||||||
proposedMaxConfigs: number | null
|
proposedMaxConfigs: number | null
|
||||||
proposedMaxIpLimit: number | null
|
proposedMaxIpLimit: number | null
|
||||||
|
requestedDays: number | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
comments: TicketCommentDto[]
|
comments: TicketCommentDto[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,10 @@ const resources = {
|
|||||||
empty: 'У вас пока нет обращений.',
|
empty: 'У вас пока нет обращений.',
|
||||||
reportBug: 'Сообщить об ошибке',
|
reportBug: 'Сообщить об ошибке',
|
||||||
requestRole: 'Запросить роль',
|
requestRole: 'Запросить роль',
|
||||||
|
requestExtension: 'Попросить о продлении',
|
||||||
|
requestedDaysLabel: 'Сколько дней нужно',
|
||||||
|
requestedExtension: 'Запрошено продление на {{days}} дн.',
|
||||||
|
extensionRequestPending: 'У вас уже есть необработанная заявка на продление.',
|
||||||
submit: 'Отправить',
|
submit: 'Отправить',
|
||||||
messageLabel: 'Опишите проблему или предложение',
|
messageLabel: 'Опишите проблему или предложение',
|
||||||
attachmentsLabel: 'Скриншоты (необязательно, до 5)',
|
attachmentsLabel: 'Скриншоты (необязательно, до 5)',
|
||||||
@@ -194,6 +198,7 @@ const resources = {
|
|||||||
type: {
|
type: {
|
||||||
BugReport: 'Ошибка/предложение',
|
BugReport: 'Ошибка/предложение',
|
||||||
RoleRequest: 'Заявка на роль',
|
RoleRequest: 'Заявка на роль',
|
||||||
|
ExtensionRequest: 'Заявка на продление',
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
Open: 'Открыт',
|
Open: 'Открыт',
|
||||||
@@ -274,6 +279,9 @@ const resources = {
|
|||||||
delete: 'Удалить пользователя',
|
delete: 'Удалить пользователя',
|
||||||
confirmDelete: 'Удалить пользователя? Все его конфиги будут отозваны в 3x-ui, действие необратимо.',
|
confirmDelete: 'Удалить пользователя? Все его конфиги будут отозваны в 3x-ui, действие необратимо.',
|
||||||
deleted: 'Пользователь удалён.',
|
deleted: 'Пользователь удалён.',
|
||||||
|
giftDaysPlaceholder: 'Дней',
|
||||||
|
giftGrant: 'Подарить',
|
||||||
|
giftGranted: 'Дни подписки подарены пользователю.',
|
||||||
},
|
},
|
||||||
configs: {
|
configs: {
|
||||||
searchPlaceholder: 'Поиск по email в панели или метке',
|
searchPlaceholder: 'Поиск по email в панели или метке',
|
||||||
@@ -681,6 +689,10 @@ const resources = {
|
|||||||
empty: 'You have no tickets yet.',
|
empty: 'You have no tickets yet.',
|
||||||
reportBug: 'Report a bug',
|
reportBug: 'Report a bug',
|
||||||
requestRole: 'Request a role',
|
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',
|
submit: 'Submit',
|
||||||
messageLabel: 'Describe the issue or suggestion',
|
messageLabel: 'Describe the issue or suggestion',
|
||||||
attachmentsLabel: 'Screenshots (optional, up to 5)',
|
attachmentsLabel: 'Screenshots (optional, up to 5)',
|
||||||
@@ -710,6 +722,7 @@ const resources = {
|
|||||||
type: {
|
type: {
|
||||||
BugReport: 'Bug/suggestion',
|
BugReport: 'Bug/suggestion',
|
||||||
RoleRequest: 'Role request',
|
RoleRequest: 'Role request',
|
||||||
|
ExtensionRequest: 'Extension request',
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
Open: 'Open',
|
Open: 'Open',
|
||||||
@@ -790,6 +803,9 @@ const resources = {
|
|||||||
delete: 'Delete user',
|
delete: 'Delete user',
|
||||||
confirmDelete: 'Delete this user? All their configs will be revoked in 3x-ui — this cannot be undone.',
|
confirmDelete: 'Delete this user? All their configs will be revoked in 3x-ui — this cannot be undone.',
|
||||||
deleted: 'User deleted.',
|
deleted: 'User deleted.',
|
||||||
|
giftDaysPlaceholder: 'Days',
|
||||||
|
giftGrant: 'Grant',
|
||||||
|
giftGranted: 'Subscription days granted to the user.',
|
||||||
},
|
},
|
||||||
configs: {
|
configs: {
|
||||||
searchPlaceholder: 'Search by panel email or label',
|
searchPlaceholder: 'Search by panel email or label',
|
||||||
|
|||||||
Reference in New Issue
Block a user