Implement extension request and gift functionalities in billing system
CI / Backend (build + test) (push) Successful in 1m27s
CI / Frontend (lint + typecheck + build) (push) Successful in 33s

- 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:
Leonid Pershin
2026-07-19 05:30:11 +03:00
parent e088e302e9
commit 24cee9bb78
46 changed files with 2541 additions and 78 deletions
@@ -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,
@@ -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>;
@@ -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>;
@@ -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
);
} }
@@ -77,6 +77,7 @@ public sealed class CreateBugReportTicketCommandHandler(
null, null,
null, null,
null, null,
null,
ticket.CreatedAt, ticket.CreatedAt,
[commentDto] [commentDto]
); );
@@ -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;
@@ -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);
}
}
@@ -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);
}
}
@@ -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,
} }
@@ -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");
}
}
}
@@ -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");
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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
View File
@@ -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
View File
@@ -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) и
+17
View File
@@ -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">
+7
View File
@@ -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)
+2 -1
View File
@@ -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[]
} }
+16
View File
@@ -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',