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:
-5
@@ -73,11 +73,6 @@ public sealed class CreateBugReportTicketCommandHandler(
|
||||
ticket.Type,
|
||||
ticket.Status,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
ticket.CreatedAt,
|
||||
[commentDto]
|
||||
);
|
||||
|
||||
-5
@@ -95,11 +95,6 @@ public sealed class CreateExtensionRequestTicketCommandHandler(
|
||||
userName,
|
||||
ticket.Type,
|
||||
ticket.Status,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
ticket.RequestedDays,
|
||||
ticket.CreatedAt,
|
||||
[commentDto]
|
||||
|
||||
-14
@@ -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;
|
||||
-131
@@ -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);
|
||||
}
|
||||
}
|
||||
-39
@@ -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
|
||||
);
|
||||
|
||||
-11
@@ -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;
|
||||
-37
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user