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:
+1
@@ -77,6 +77,7 @@ public sealed class CreateBugReportTicketCommandHandler(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
ticket.CreatedAt,
|
||||
[commentDto]
|
||||
);
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Support.CreateExtensionRequest;
|
||||
|
||||
/// <summary>Заявка на продление оплаченного периода — только для ролей с включённым биллингом
|
||||
/// (AppRole.BillingEnabled), см. CreateExtensionRequestTicketCommandHandler.</summary>
|
||||
public sealed record CreateExtensionRequestTicketCommand(int RequestedDays, string Justification)
|
||||
: ICommand<Result<TicketDetailDto>>,
|
||||
IRequiresActivation;
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Billing;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Domain.Support;
|
||||
|
||||
namespace PnvPanel.Application.Support.CreateExtensionRequest;
|
||||
|
||||
public sealed class CreateExtensionRequestTicketCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<CreateExtensionRequestTicketCommand, Result<TicketDetailDto>>
|
||||
{
|
||||
public async Task<Result<TicketDetailDto>> Handle(
|
||||
CreateExtensionRequestTicketCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return Result.Failure<TicketDetailDto>(AuthErrors.Unauthorized);
|
||||
|
||||
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
|
||||
if (profile is null)
|
||||
return Result.Failure<TicketDetailDto>(AuthErrors.Unauthorized);
|
||||
|
||||
if (!profile.BillingEnabled)
|
||||
return Result.Failure<TicketDetailDto>(BillingErrors.NotEnabled);
|
||||
|
||||
var hasPending = await dbContext.SupportTickets.AnyAsync(
|
||||
t =>
|
||||
t.UserId == userId
|
||||
&& t.Type == TicketType.ExtensionRequest
|
||||
&& t.Status == TicketStatus.Open,
|
||||
cancellationToken
|
||||
);
|
||||
if (hasPending)
|
||||
return Result.Failure<TicketDetailDto>(SupportErrors.ExtensionRequestAlreadyPending);
|
||||
|
||||
var ticket = SupportTicket.CreateExtensionRequest(userId, command.RequestedDays);
|
||||
dbContext.SupportTickets.Add(ticket);
|
||||
|
||||
var comment = TicketComment.Create(ticket.Id, userId, command.Justification);
|
||||
dbContext.TicketComments.Add(comment);
|
||||
|
||||
var userName = currentUser.UserName ?? userId.ToString();
|
||||
|
||||
await notifier.NotifyTicketCreatedAsync(
|
||||
ticket.Id,
|
||||
userId,
|
||||
userName,
|
||||
ticket.Type,
|
||||
cancellationToken
|
||||
);
|
||||
await telegramNotifier.NotifyAdminsExtensionRequestCreatedAsync(
|
||||
ticket.Id,
|
||||
userName,
|
||||
command.RequestedDays,
|
||||
command.Justification,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var commentDto = new TicketCommentDto(
|
||||
comment.Id,
|
||||
userId,
|
||||
userName,
|
||||
comment.Body,
|
||||
comment.CreatedAt,
|
||||
[]
|
||||
);
|
||||
|
||||
var dto = new TicketDetailDto(
|
||||
ticket.Id,
|
||||
ticket.UserId,
|
||||
userName,
|
||||
ticket.Type,
|
||||
ticket.Status,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
ticket.RequestedDays,
|
||||
ticket.CreatedAt,
|
||||
[commentDto]
|
||||
);
|
||||
|
||||
return Result.Success(dto);
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace PnvPanel.Application.Support.CreateExtensionRequest;
|
||||
|
||||
public sealed class CreateExtensionRequestTicketCommandValidator
|
||||
: AbstractValidator<CreateExtensionRequestTicketCommand>
|
||||
{
|
||||
public CreateExtensionRequestTicketCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.RequestedDays).GreaterThan(0).LessThanOrEqualTo(365);
|
||||
RuleFor(x => x.Justification).NotEmpty().MaximumLength(4000);
|
||||
}
|
||||
}
|
||||
+1
@@ -108,6 +108,7 @@ public sealed class CreateRoleRequestTicketCommandHandler(
|
||||
ticket.ProposedRoleName,
|
||||
ticket.ProposedMaxConfigs,
|
||||
ticket.ProposedMaxIpLimit,
|
||||
ticket.RequestedDays,
|
||||
ticket.CreatedAt,
|
||||
[commentDto]
|
||||
);
|
||||
|
||||
@@ -48,6 +48,16 @@ public static class SupportErrors
|
||||
"Это не заявка на роль."
|
||||
);
|
||||
|
||||
public static readonly Error ExtensionRequestAlreadyPending = Error.Conflict(
|
||||
"Support.ExtensionRequestAlreadyPending",
|
||||
"У вас уже есть необработанная заявка на продление."
|
||||
);
|
||||
|
||||
public static readonly Error NotExtensionRequest = Error.Validation(
|
||||
"Support.NotExtensionRequest",
|
||||
"Это не заявка на продление."
|
||||
);
|
||||
|
||||
public static readonly Error TooManyAttachments = Error.Validation(
|
||||
"Support.TooManyAttachments",
|
||||
$"Слишком много вложений (максимум {TicketAttachmentValidation.MaxAttachments})."
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace PnvPanel.Application.Support;
|
||||
/// <summary>
|
||||
/// RequestedRoleName — имя существующей роли, если RequestedRoleId задан (резолвится хендлером,
|
||||
/// на SupportTicket хранится только Id). Для новой роли имя лежит прямо в ProposedRoleName.
|
||||
/// RequestedDays — только для ExtensionRequest (продление оплаченного периода).
|
||||
/// </summary>
|
||||
public sealed record TicketDetailDto(
|
||||
Guid Id,
|
||||
@@ -17,6 +18,7 @@ public sealed record TicketDetailDto(
|
||||
string? ProposedRoleName,
|
||||
int? ProposedMaxConfigs,
|
||||
int? ProposedMaxIpLimit,
|
||||
int? RequestedDays,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<TicketCommentDto> Comments
|
||||
);
|
||||
|
||||
@@ -76,6 +76,7 @@ internal static class TicketMapping
|
||||
ticket.ProposedRoleName,
|
||||
ticket.ProposedMaxConfigs,
|
||||
ticket.ProposedMaxIpLimit,
|
||||
ticket.RequestedDays,
|
||||
ticket.CreatedAt,
|
||||
commentDtos
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user