Implement extension request and gift functionalities in billing system
- Added new endpoints for creating and managing extension requests, allowing users to request billing period extensions. - Implemented admin approval processes for extension requests via Telegram, including inline buttons for approval and rejection. - Introduced a gifting feature for admins to grant additional billing days directly to users without a request. - Updated the support ticket model to accommodate extension requests and their associated properties. - Enhanced the Telegram notifier to inform admins of new extension requests and notify users of approval or rejection. - Updated frontend components to support the new extension request and gifting functionalities, including user interfaces for managing these features. - Revised API documentation to reflect the new endpoints and their usage in the billing context.
This commit is contained in:
@@ -26,6 +26,7 @@ public static class AdminBillingEndpoints
|
||||
admin
|
||||
.MapPost("/requests/{id:guid}/reject", RejectRequest)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPost("/gift", GrantGift).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -80,6 +81,19 @@ public static class AdminBillingEndpoints
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GrantGift(
|
||||
GrantGiftBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new GrantBillingGiftCommand(body.UserId, body.Days),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ListPaymentRequestsRequest(
|
||||
@@ -89,3 +103,5 @@ public sealed record ListPaymentRequestsRequest(
|
||||
);
|
||||
|
||||
public sealed record RejectPaymentRequestBody(string? Reason);
|
||||
|
||||
public sealed record GrantGiftBody(Guid UserId, int Days);
|
||||
|
||||
@@ -33,6 +33,12 @@ public static class AdminSupportEndpoints
|
||||
admin
|
||||
.MapPost("/tickets/{id:guid}/reject", RejectRoleRequest)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapPost("/tickets/{id:guid}/approve-extension", ApproveExtensionRequest)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapPost("/tickets/{id:guid}/reject-extension", RejectExtensionRequest)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -119,6 +125,32 @@ public static class AdminSupportEndpoints
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ApproveExtensionRequest(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ApproveExtensionRequestCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RejectExtensionRequest(
|
||||
Guid id,
|
||||
RejectExtensionRequestBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new RejectExtensionRequestCommand(id, body.Reason),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RejectRoleRequestBody(string? Reason);
|
||||
|
||||
public sealed record RejectExtensionRequestBody(string? Reason);
|
||||
|
||||
@@ -6,6 +6,7 @@ using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Support;
|
||||
using PnvPanel.Application.Support.AddComment;
|
||||
using PnvPanel.Application.Support.CreateBugReport;
|
||||
using PnvPanel.Application.Support.CreateExtensionRequest;
|
||||
using PnvPanel.Application.Support.CreateRoleRequest;
|
||||
using PnvPanel.Application.Support.GetAttachment;
|
||||
using PnvPanel.Application.Support.GetSupportPricing;
|
||||
@@ -30,6 +31,9 @@ public static class SupportEndpoints
|
||||
.DisableAntiforgery()
|
||||
.Produces<TicketDetailDto>();
|
||||
group.MapPost("/tickets/role-requests", CreateRoleRequest).Produces<TicketDetailDto>();
|
||||
group
|
||||
.MapPost("/tickets/extension-requests", CreateExtensionRequest)
|
||||
.Produces<TicketDetailDto>();
|
||||
group.MapGet("/tickets", ListMyTickets).Produces<PagedList<TicketSummaryDto>>();
|
||||
group.MapGet("/tickets/{id:guid}", GetTicket).Produces<TicketDetailDto>();
|
||||
group
|
||||
@@ -89,6 +93,17 @@ public static class SupportEndpoints
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateExtensionRequest(
|
||||
CreateExtensionRequestBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var command = new CreateExtensionRequestTicketCommand(body.RequestedDays, body.Justification);
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListMyTickets(
|
||||
[AsParameters] ListTicketsRequest request,
|
||||
ISender sender,
|
||||
@@ -175,6 +190,8 @@ public sealed record CreateRoleRequestBody(
|
||||
string Justification
|
||||
);
|
||||
|
||||
public sealed record CreateExtensionRequestBody(int RequestedDays, string Justification);
|
||||
|
||||
public sealed record ListTicketsRequest(
|
||||
TicketType? Type,
|
||||
TicketStatus? Status,
|
||||
|
||||
@@ -489,6 +489,55 @@ public sealed class PnvBotUpdateHandler(
|
||||
|
||||
break;
|
||||
}
|
||||
case "erq":
|
||||
{
|
||||
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
"Недостаточно прав.",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var result =
|
||||
parts[1] == "approve"
|
||||
? await sender.Send(
|
||||
new ApproveExtensionRequestCommand(requestId),
|
||||
cancellationToken
|
||||
)
|
||||
: await sender.Send(
|
||||
new RejectExtensionRequestCommand(requestId, Reason: null),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
result.IsSuccess ? "Готово" : result.Error.Message,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
if (callback.Message is not null)
|
||||
{
|
||||
var statusText = result.IsSuccess
|
||||
? (
|
||||
parts[1] == "approve"
|
||||
? "✅ Заявка на продление одобрена."
|
||||
: "❌ Заявка отклонена."
|
||||
)
|
||||
: $"⚠️ {result.Error.Message}";
|
||||
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
|
||||
await botClient.EditMessageText(
|
||||
chatId.Value,
|
||||
callback.Message.Id,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "pay":
|
||||
{
|
||||
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
||||
|
||||
@@ -144,6 +144,47 @@ internal sealed class TelegramNotifier(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task NotifyAdminsExtensionRequestCreatedAsync(
|
||||
Guid ticketId,
|
||||
string userName,
|
||||
int requestedDays,
|
||||
string justification,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||
return;
|
||||
|
||||
var text =
|
||||
$"🆕 Заявка на продление от <b>{Escape(userName)}</b> — {requestedDays} дн.\nОбоснование: {Escape(justification)}";
|
||||
|
||||
var keyboard = new InlineKeyboardMarkup(
|
||||
new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData("✅ Одобрить", $"erq:approve:{ticketId}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"erq:reject:{ticketId}"),
|
||||
}
|
||||
);
|
||||
|
||||
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
|
||||
{
|
||||
try
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
adminId,
|
||||
text,
|
||||
parseMode: ParseMode.Html,
|
||||
replyMarkup: keyboard,
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task NotifyAdminsTicketReopenedAsync(
|
||||
Guid ticketId,
|
||||
string userName,
|
||||
|
||||
Reference in New Issue
Block a user