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
@@ -73,11 +73,6 @@ public sealed class CreateBugReportTicketCommandHandler(
ticket.Type,
ticket.Status,
null,
null,
null,
null,
null,
null,
ticket.CreatedAt,
[commentDto]
);
@@ -95,11 +95,6 @@ public sealed class CreateExtensionRequestTicketCommandHandler(
userName,
ticket.Type,
ticket.Status,
null,
null,
null,
null,
null,
ticket.RequestedDays,
ticket.CreatedAt,
[commentDto]
@@ -1,14 +0,0 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Support.CreateRoleRequest;
/// <summary>Ровно один из вариантов: ExistingRoleId, либо NewRoleName+NewRoleMaxConfigs+NewRoleMaxIpLimit
/// (проверяется валидатором).</summary>
public sealed record CreateRoleRequestTicketCommand(
Guid? ExistingRoleId,
string? NewRoleName,
int? NewRoleMaxConfigs,
int? NewRoleMaxIpLimit,
string Justification
) : ICommand<Result<TicketDetailDto>>, IRequiresActivation;
@@ -1,131 +0,0 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Concurrency;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Support.CreateRoleRequest;
/// <summary>Проверка "нет висящей заявки" + создание — под AdvisoryLock (по UserId), см.
/// CreatePaymentRequestCommandHandler для полного обоснования.</summary>
public sealed class CreateRoleRequestTicketCommandHandler(
IAppDbContext dbContext,
IRoleService roleService,
IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier,
ICurrentUser currentUser
) : ICommandHandler<CreateRoleRequestTicketCommand, Result<TicketDetailDto>>
{
// Совпадает со значением Infrastructure.Identity.RoleNames.Admin — Application не может ссылаться
// на Infrastructure (направление зависимостей), поэтому системное имя роли продублировано здесь.
private const string AdminRoleName = "admin";
public async Task<Result<TicketDetailDto>> Handle(
CreateRoleRequestTicketCommand command,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<TicketDetailDto>(AuthErrors.Unauthorized);
string? requestedRoleName = null;
if (command.ExistingRoleId is { } existingRoleId)
{
var roles = await roleService.ListRolesAsync(cancellationToken);
var role = roles.FirstOrDefault(r => r.Id == existingRoleId);
if (role is null)
return Result.Failure<TicketDetailDto>(SupportErrors.RoleNotFound);
if (role.Name.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase))
return Result.Failure<TicketDetailDto>(SupportErrors.CannotRequestAdminRole);
requestedRoleName = role.Name;
}
var claimed = await AdvisoryLock.RunAsync(
dbContext,
userId,
async lockedCancellationToken =>
{
var hasPending = await dbContext.SupportTickets.AnyAsync(
t =>
t.UserId == userId
&& t.Type == TicketType.RoleRequest
&& t.Status == TicketStatus.Open,
lockedCancellationToken
);
if (hasPending)
return Result.Failure<(SupportTicket, TicketComment)>(
SupportErrors.RoleRequestAlreadyPending
);
var newTicket =
command.ExistingRoleId is { } roleId
? SupportTicket.CreateRoleRequestForExistingRole(userId, roleId)
: SupportTicket.CreateRoleRequestForNewRole(
userId,
command.NewRoleName!,
command.NewRoleMaxConfigs!.Value,
command.NewRoleMaxIpLimit!.Value
);
dbContext.SupportTickets.Add(newTicket);
var newComment = TicketComment.Create(newTicket.Id, userId, command.Justification);
dbContext.TicketComments.Add(newComment);
return Result.Success((newTicket, newComment));
},
cancellationToken
);
if (!claimed.IsSuccess)
return Result.Failure<TicketDetailDto>(claimed.Error);
var (ticket, comment) = claimed.Value;
var userName = currentUser.UserName ?? userId.ToString();
var roleDescription =
requestedRoleName
?? $"новая роль «{command.NewRoleName}» (конфигов: {command.NewRoleMaxConfigs}, IP: {command.NewRoleMaxIpLimit})";
await notifier.NotifyTicketCreatedAsync(
ticket.Id,
userId,
userName,
ticket.Type,
cancellationToken
);
await telegramNotifier.NotifyAdminsRoleRequestCreatedAsync(
ticket.Id,
userName,
roleDescription,
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,
ticket.RequestedRoleId,
requestedRoleName,
ticket.ProposedRoleName,
ticket.ProposedMaxConfigs,
ticket.ProposedMaxIpLimit,
ticket.RequestedDays,
ticket.CreatedAt,
[commentDto]
);
return Result.Success(dto);
}
}
@@ -1,39 +0,0 @@
using FluentValidation;
namespace PnvPanel.Application.Support.CreateRoleRequest;
public sealed class CreateRoleRequestTicketCommandValidator
: AbstractValidator<CreateRoleRequestTicketCommand>
{
public CreateRoleRequestTicketCommandValidator()
{
RuleFor(x => x.Justification).NotEmpty().MaximumLength(4000);
RuleFor(x => x)
.Must(HaveExactlyOnePayload)
.WithMessage(
"Укажите либо существующую роль, либо параметры новой (не оба варианта и не ни одного)."
);
When(
x => x.ExistingRoleId is null,
() =>
{
RuleFor(x => x.NewRoleName).NotEmpty().MaximumLength(100);
RuleFor(x => x.NewRoleMaxConfigs).NotNull().GreaterThanOrEqualTo(-1);
RuleFor(x => x.NewRoleMaxIpLimit).NotNull().GreaterThanOrEqualTo(-1);
}
);
}
private static bool HaveExactlyOnePayload(CreateRoleRequestTicketCommand command)
{
var hasExisting = command.ExistingRoleId is not null;
var hasNew =
!string.IsNullOrWhiteSpace(command.NewRoleName)
&& command.NewRoleMaxConfigs is not null
&& command.NewRoleMaxIpLimit is not null;
return hasExisting ^ hasNew;
}
}
@@ -9,7 +9,6 @@ namespace PnvPanel.Application.Support.GetTicket;
public sealed class GetTicketQueryHandler(
IAppDbContext dbContext,
IIdentityService identityService,
IRoleService roleService,
ICurrentUser currentUser
) : IQueryHandler<GetTicketQuery, Result<TicketDetailDto>>
{
@@ -33,7 +32,6 @@ public sealed class GetTicketQueryHandler(
var dto = await TicketMapping.ToDetailDtoAsync(
dbContext,
identityService,
roleService,
ticket,
cancellationToken
);
@@ -1,11 +0,0 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Support.ListSelectableRoles;
/// <summary>Список ролей для выбора в заявке на роль (без admin) — в отличие от ListRolesQuery
/// (Admin/Roles), доступен любому активированному пользователю.</summary>
public sealed record ListSelectableRolesQuery
: IQuery<Result<IReadOnlyList<RoleDto>>>,
IRequiresActivation;
@@ -1,37 +0,0 @@
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Support.ListSelectableRoles;
public sealed class ListSelectableRolesQueryHandler(
IRoleService roleService,
IIdentityService identityService,
ICurrentUser currentUser
) : IQueryHandler<ListSelectableRolesQuery, Result<IReadOnlyList<RoleDto>>>
{
// Совпадает со значением Infrastructure.Identity.RoleNames.Admin — см. пояснение в
// CreateRoleRequestTicketCommandHandler (Application не может ссылаться на Infrastructure).
private const string AdminRoleName = "admin";
public async Task<Result<IReadOnlyList<RoleDto>>> Handle(
ListSelectableRolesQuery query,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<IReadOnlyList<RoleDto>>(AuthErrors.Unauthorized);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
var roles = await roleService.ListRolesAsync(cancellationToken);
// Без admin (нельзя запросить) и без текущей роли пользователя (уже есть — нечего запрашивать).
var selectable = roles
.Where(r => !r.Name.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase))
.Where(r => profile is null || r.Id != profile.RoleId)
.ToList();
return Result.Success<IReadOnlyList<RoleDto>>(selectable);
}
}
@@ -9,15 +9,6 @@ public static class SupportErrors
"Support.AttachmentNotFound",
"Вложение не найдено."
);
public static readonly Error RoleNotFound = Error.NotFound(
"Support.RoleNotFound",
"Роль не найдена."
);
public static readonly Error CannotRequestAdminRole = Error.Forbidden(
"Support.CannotRequestAdminRole",
"Роль администратора нельзя запросить через заявку."
);
public static readonly Error TicketClosed = Error.Conflict(
"Support.TicketClosed",
@@ -38,34 +29,24 @@ public static class SupportErrors
"Переоткрыть можно только решённый тикет."
);
/// <summary>Заявка на роль/продление решается только через approve/reject — там же выполняется
/// сама выдача роли/дней. Общий resolve/close без этого шага молча "проглотил" бы заявку, ничего
/// не выдав пользователю.</summary>
/// <summary>Заявка на продление решается только через approve/reject — там же выполняется сама
/// выдача дней. Общий resolve/close без этого шага молча "проглотил" бы заявку, ничего не выдав
/// пользователю.</summary>
public static readonly Error OnlyBugReportCanBeResolvedDirectly = Error.Validation(
"Support.OnlyBugReportCanBeResolvedDirectly",
"Заявку на роль или продление можно только одобрить/отклонить, а не решить напрямую."
"Заявку на продление можно только одобрить/отклонить, а не решить напрямую."
);
public static readonly Error OnlyBugReportCanBeClosedDirectly = Error.Validation(
"Support.OnlyBugReportCanBeClosedDirectly",
"Заявку на роль или продление можно только одобрить/отклонить, а не закрыть напрямую."
"Заявку на продление можно только одобрить/отклонить, а не закрыть напрямую."
);
/// <summary>Заявка на роль/продление одноразовая: повторное одобрение начислило бы дни/роль ещё
/// раз. Новый запрос — новый тикет, не переоткрытие старого.</summary>
/// <summary>Заявка на продление одноразовая: повторное одобрение начислило бы дни ещё раз. Новый
/// запрос — новый тикет, не переоткрытие старого.</summary>
public static readonly Error OnlyBugReportCanBeReopened = Error.Validation(
"Support.OnlyBugReportCanBeReopened",
"Заявку на роль или продление нельзя переоткрыть — оформите новую."
);
public static readonly Error RoleRequestAlreadyPending = Error.Conflict(
"Support.RoleRequestAlreadyPending",
"У вас уже есть необработанная заявка на роль."
);
public static readonly Error NotRoleRequest = Error.Validation(
"Support.NotRoleRequest",
"Это не заявка на роль."
"Заявку на продление нельзя переоткрыть — оформите новую."
);
public static readonly Error ExtensionRequestAlreadyPending = Error.Conflict(
@@ -4,8 +4,8 @@ namespace PnvPanel.Application.Support;
internal static class TicketAuthorization
{
// Совпадает со значением Infrastructure.Identity.RoleNames.Admin — см. пояснение в
// CreateRoleRequestTicketCommandHandler (Application не может ссылаться на Infrastructure).
// Совпадает со значением Infrastructure.Identity.RoleNames.Admin (Application не может
// ссылаться на Infrastructure).
private const string AdminRoleName = "admin";
public static bool IsAdmin(CurrentUserProfile? profile) =>
@@ -3,8 +3,6 @@ using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Support;
/// <summary>
/// RequestedRoleName — имя существующей роли, если RequestedRoleId задан (резолвится хендлером,
/// на SupportTicket хранится только Id). Для новой роли имя лежит прямо в ProposedRoleName.
/// RequestedDays — только для ExtensionRequest (продление оплаченного периода).
/// </summary>
public sealed record TicketDetailDto(
@@ -13,11 +11,6 @@ public sealed record TicketDetailDto(
string UserName,
TicketType Type,
TicketStatus Status,
Guid? RequestedRoleId,
string? RequestedRoleName,
string? ProposedRoleName,
int? ProposedMaxConfigs,
int? ProposedMaxIpLimit,
int? RequestedDays,
DateTimeOffset CreatedAt,
IReadOnlyList<TicketCommentDto> Comments
@@ -15,7 +15,6 @@ internal static class TicketMapping
public static async Task<TicketDetailDto> ToDetailDtoAsync(
IAppDbContext dbContext,
IIdentityService identityService,
IRoleService roleService,
SupportTicket ticket,
CancellationToken cancellationToken
)
@@ -39,13 +38,6 @@ internal static class TicketMapping
var userIds = comments.Select(c => c.AuthorId).Append(ticket.UserId).Distinct().ToList();
var userNames = await identityService.GetUserNamesAsync(userIds, cancellationToken);
string? requestedRoleName = null;
if (ticket.RequestedRoleId is { } roleId)
{
var roles = await roleService.ListRolesAsync(cancellationToken);
requestedRoleName = roles.FirstOrDefault(r => r.Id == roleId)?.Name;
}
var commentDtos = comments
.Select(c => new TicketCommentDto(
c.Id,
@@ -71,11 +63,6 @@ internal static class TicketMapping
userNames.GetValueOrDefault(ticket.UserId, "?"),
ticket.Type,
ticket.Status,
ticket.RequestedRoleId,
requestedRoleName,
ticket.ProposedRoleName,
ticket.ProposedMaxConfigs,
ticket.ProposedMaxIpLimit,
ticket.RequestedDays,
ticket.CreatedAt,
commentDtos