Enhance user plan management and update related endpoints
- Added new configuration options for user plans in `.env.example`, including `Plans__MaxCustomConfigCount` and `Plans__MinCustomConfigCount`. - Introduced `MapPlanEndpoints` in `Program.cs` to handle plan-related API routes. - Implemented `SetUserPlan` endpoint in `RoleEndpoints` to allow admins to assign plans to users. - Removed deprecated role request approval endpoints from `AdminSupportEndpoints`. - Updated `ITelegramNotifier` and related classes to reflect changes in role request handling and payment notifications. - Refactored role management commands to remove `MaxConfigs` and focus on `MaxIpLimit` and billing settings. - Enhanced billing request handling to accommodate plan changes instead of role changes. - Updated various interfaces and command handlers to support new plan management features.
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
using PnvPanel.Api.Common;
|
||||
using PnvPanel.Application.Admin.Plans;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Infrastructure.Identity;
|
||||
|
||||
namespace PnvPanel.Api.Endpoints;
|
||||
|
||||
public static class AdminPlanEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAdminPlanEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var admin = app.MapGroup("/api/admin/plans")
|
||||
.WithTags("Admin.Plans")
|
||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||
|
||||
admin.MapGet("", ListPlans).Produces<IReadOnlyList<AdminPlanDto>>();
|
||||
admin.MapPost("", CreatePlan).Produces<AdminPlanDto>();
|
||||
admin.MapPut("/{id:guid}", UpdatePlan).Produces<AdminPlanDto>();
|
||||
admin.MapDelete("/{id:guid}", DeletePlan).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListPlans(ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new ListAdminPlansQuery(), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreatePlan(
|
||||
CreatePlanCommand command,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdatePlan(
|
||||
Guid id,
|
||||
UpdatePlanBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var command = new UpdatePlanCommand(
|
||||
id,
|
||||
body.Name,
|
||||
body.ConfigCount,
|
||||
body.SortOrder,
|
||||
body.IsEnabled
|
||||
);
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeletePlan(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new DeletePlanCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UpdatePlanBody(string Name, int ConfigCount, int SortOrder, bool IsEnabled);
|
||||
@@ -27,12 +27,6 @@ public static class AdminSupportEndpoints
|
||||
.MapPost("/tickets/{id:guid}/resolve", Resolve)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPost("/tickets/{id:guid}/close", Close).Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapPost("/tickets/{id:guid}/approve", ApproveRoleRequest)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapPost("/tickets/{id:guid}/reject", RejectRoleRequest)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapPost("/tickets/{id:guid}/approve-extension", ApproveExtensionRequest)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
@@ -102,30 +96,6 @@ public static class AdminSupportEndpoints
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ApproveRoleRequest(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ApproveRoleRequestCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RejectRoleRequest(
|
||||
Guid id,
|
||||
RejectRoleRequestBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new RejectRoleRequestCommand(id, body.Reason),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ApproveExtensionRequest(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
@@ -151,6 +121,4 @@ public static class AdminSupportEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RejectRoleRequestBody(string? Reason);
|
||||
|
||||
public sealed record RejectExtensionRequestBody(string? Reason);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using PnvPanel.Api.Common;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Plans;
|
||||
|
||||
namespace PnvPanel.Api.Endpoints;
|
||||
|
||||
public static class PlanEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapPlanEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/plans").WithTags("Plans").RequireAuthorization();
|
||||
|
||||
group.MapGet("", ListPlans).Produces<IReadOnlyList<PlanDto>>();
|
||||
group.MapGet("/status", GetMyPlanStatus).Produces<MyPlanStatusDto>();
|
||||
group.MapPost("/change", ChangePlan).Produces<ChangePlanResultDto>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListPlans(ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new ListPlansQuery(), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetMyPlanStatus(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new GetMyPlanStatusQuery(), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ChangePlan(
|
||||
ChangePlanBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var command = new ChangePlanCommand(
|
||||
body.PlanId,
|
||||
body.CustomConfigCount,
|
||||
body.ConfigIdsToRevoke ?? []
|
||||
);
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ChangePlanBody(
|
||||
Guid? PlanId,
|
||||
int? CustomConfigCount,
|
||||
IReadOnlyList<Guid>? ConfigIdsToRevoke
|
||||
);
|
||||
@@ -22,6 +22,9 @@ public static class RoleEndpoints
|
||||
admin
|
||||
.MapPatch("/users/{id:guid}/role", ChangeUserRole)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
admin
|
||||
.MapPatch("/users/{id:guid}/plan", SetUserPlan)
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -53,7 +56,7 @@ public static class RoleEndpoints
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new UpdateRoleCommand(id, body.MaxConfigs, body.MaxIpLimit, body.BillingEnabled),
|
||||
new UpdateRoleCommand(id, body.MaxIpLimit, body.BillingEnabled),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
@@ -82,8 +85,24 @@ public static class RoleEndpoints
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> SetUserPlan(
|
||||
Guid id,
|
||||
SetUserPlanBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new AdminSetUserPlanCommand(id, body.PlanId, body.CustomConfigCount),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UpdateRoleBody(int MaxConfigs, int MaxIpLimit, bool BillingEnabled);
|
||||
public sealed record UpdateRoleBody(int MaxIpLimit, bool BillingEnabled);
|
||||
|
||||
public sealed record ChangeUserRoleBody(Guid RoleId);
|
||||
|
||||
public sealed record SetUserPlanBody(Guid? PlanId, int? CustomConfigCount);
|
||||
|
||||
@@ -7,12 +7,10 @@ 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;
|
||||
using PnvPanel.Application.Support.GetTicket;
|
||||
using PnvPanel.Application.Support.ListMyTickets;
|
||||
using PnvPanel.Application.Support.ListSelectableRoles;
|
||||
using PnvPanel.Application.Support.Reopen;
|
||||
using PnvPanel.Domain.Support;
|
||||
|
||||
@@ -24,13 +22,11 @@ public static class SupportEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/support").WithTags("Support").RequireAuthorization();
|
||||
|
||||
group.MapGet("/roles", ListSelectableRoles).Produces<IReadOnlyList<RoleDto>>();
|
||||
group.MapGet("/pricing", GetSupportPricing).Produces<PricingSettingsDto>();
|
||||
group
|
||||
.MapPost("/tickets/bug-reports", CreateBugReport)
|
||||
.DisableAntiforgery()
|
||||
.Produces<TicketDetailDto>();
|
||||
group.MapPost("/tickets/role-requests", CreateRoleRequest).Produces<TicketDetailDto>();
|
||||
group
|
||||
.MapPost("/tickets/extension-requests", CreateExtensionRequest)
|
||||
.Produces<TicketDetailDto>();
|
||||
@@ -46,15 +42,6 @@ public static class SupportEndpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListSelectableRoles(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new ListSelectableRolesQuery(), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetSupportPricing(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
@@ -76,23 +63,6 @@ public static class SupportEndpoints
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateRoleRequest(
|
||||
CreateRoleRequestBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var command = new CreateRoleRequestTicketCommand(
|
||||
body.ExistingRoleId,
|
||||
body.NewRoleName,
|
||||
body.NewRoleMaxConfigs,
|
||||
body.NewRoleMaxIpLimit,
|
||||
body.Justification
|
||||
);
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateExtensionRequest(
|
||||
CreateExtensionRequestBody body,
|
||||
ISender sender,
|
||||
@@ -182,14 +152,6 @@ public static class SupportEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record CreateRoleRequestBody(
|
||||
Guid? ExistingRoleId,
|
||||
string? NewRoleName,
|
||||
int? NewRoleMaxConfigs,
|
||||
int? NewRoleMaxIpLimit,
|
||||
string Justification
|
||||
);
|
||||
|
||||
public sealed record CreateExtensionRequestBody(int RequestedDays, string Justification);
|
||||
|
||||
public sealed record ListTicketsRequest(
|
||||
|
||||
@@ -184,11 +184,13 @@ app.MapInboundEndpoints();
|
||||
app.MapConfigEndpoints();
|
||||
app.MapSubscriptionEndpoints();
|
||||
app.MapAppEndpoints();
|
||||
app.MapPlanEndpoints();
|
||||
app.MapNewsEndpoints();
|
||||
app.MapInstructionEndpoints();
|
||||
app.MapAdminUserEndpoints();
|
||||
app.MapAdminStatsEndpoints();
|
||||
app.MapAdminAppEndpoints();
|
||||
app.MapAdminPlanEndpoints();
|
||||
app.MapAdminNewsEndpoints();
|
||||
app.MapAdminInstructionEndpoints();
|
||||
app.MapAdminPricingEndpoints();
|
||||
|
||||
@@ -440,55 +440,6 @@ public sealed class PnvBotUpdateHandler(
|
||||
|
||||
break;
|
||||
}
|
||||
case "rrq":
|
||||
{
|
||||
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.AnswerCallbackQuery(
|
||||
callback.Id,
|
||||
"Недостаточно прав.",
|
||||
cancellationToken: cancellationToken
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var result =
|
||||
parts[1] == "approve"
|
||||
? await sender.Send(
|
||||
new ApproveRoleRequestCommand(requestId),
|
||||
cancellationToken
|
||||
)
|
||||
: await sender.Send(
|
||||
new RejectRoleRequestCommand(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 "erq":
|
||||
{
|
||||
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
||||
|
||||
@@ -104,47 +104,6 @@ internal sealed class TelegramNotifier(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task NotifyAdminsRoleRequestCreatedAsync(
|
||||
Guid ticketId,
|
||||
string userName,
|
||||
string roleDescription,
|
||||
string justification,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||
return;
|
||||
|
||||
var text =
|
||||
$"🆕 Заявка на роль от <b>{Escape(userName)}</b>\n{Escape(roleDescription)}\nОбоснование: {Escape(justification)}";
|
||||
|
||||
var keyboard = new InlineKeyboardMarkup(
|
||||
new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData("✅ Одобрить", $"rrq:approve:{ticketId}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"rrq: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 NotifyAdminsExtensionRequestCreatedAsync(
|
||||
Guid ticketId,
|
||||
string userName,
|
||||
@@ -196,8 +155,7 @@ internal sealed class TelegramNotifier(
|
||||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||
return;
|
||||
|
||||
var typeLabel = type == TicketType.RoleRequest ? "заявка на роль" : "баг/предложение";
|
||||
var text = $"🔓 Тикет от <b>{Escape(userName)}</b> переоткрыт ({typeLabel})";
|
||||
var text = $"🔓 Тикет от <b>{Escape(userName)}</b> переоткрыт (баг/предложение)";
|
||||
|
||||
InlineKeyboardMarkup? keyboard = null;
|
||||
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
|
||||
@@ -280,7 +238,7 @@ internal sealed class TelegramNotifier(
|
||||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||
return;
|
||||
|
||||
var reason = kind == PaymentRequestKind.RoleChangeTopUp ? "доплате за смену роли" : $"оплате за {PeriodLabel(period!.Value)}";
|
||||
var reason = kind == PaymentRequestKind.PlanChangeTopUp ? "доплате за смену тарифа" : $"оплате за {PeriodLabel(period!.Value)}";
|
||||
var text = $"💰 <b>{Escape(userName)}</b> заявляет о {reason} — {amount} ₽\nПроверьте поступление и подтвердите.";
|
||||
|
||||
var keyboard = new InlineKeyboardMarkup(
|
||||
|
||||
Reference in New Issue
Block a user