Enhance user plan management and update related endpoints
CI / Backend (build + test) (push) Failing after 1m23s
CI / Frontend (lint + typecheck + build) (push) Successful in 34s

- 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:
Leonid Pershin
2026-07-23 22:52:20 +03:00
parent 2c5b730500
commit fad03c2834
152 changed files with 4060 additions and 2240 deletions
@@ -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(
+2
View File
@@ -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(