Enhance role management by adding pricing fields and updating related logic
CI / Backend (build + test) (push) Successful in 1m22s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s

- Updated `CreateRoleCommand` and `UpdateRoleCommand` to include optional pricing fields: `PricePerConfigPerQuarter` and `PricePerConfigPerYear`.
- Modified `RoleEndpoints` to handle the new pricing parameters during role creation and updates.
- Enhanced validation logic in `CreateRoleCommandValidator` and `UpdateRoleCommandValidator` to ensure pricing fields are non-negative when provided.
- Updated `RoleDto` and `SelectableRoleDto` to include pricing information, ensuring proper data handling in API responses.
- Adjusted frontend components to support new pricing fields in role forms and display total costs based on configurations.
- Updated API documentation to reflect changes in role management endpoints and pricing structure.
This commit is contained in:
Leonid Pershin
2026-07-18 19:02:12 +03:00
parent 68781ef183
commit 285d8180c8
29 changed files with 2638 additions and 30 deletions
@@ -4,5 +4,10 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Roles;
public sealed record CreateRoleCommand(string Name, int MaxConfigs, int MaxIpLimit)
: ICommand<Result<RoleDto>>;
public sealed record CreateRoleCommand(
string Name,
int MaxConfigs,
int MaxIpLimit,
int? PricePerConfigPerQuarter,
int? PricePerConfigPerYear
) : ICommand<Result<RoleDto>>;
@@ -15,6 +15,8 @@ public sealed class CreateRoleCommandHandler(IRoleService roleService)
command.Name,
command.MaxConfigs,
command.MaxIpLimit,
command.PricePerConfigPerQuarter,
command.PricePerConfigPerYear,
cancellationToken
);
}
@@ -10,5 +10,12 @@ public sealed class CreateRoleCommandValidator : AbstractValidator<CreateRoleCom
RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1);
RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1);
RuleFor(x => x.PricePerConfigPerQuarter!.Value)
.GreaterThanOrEqualTo(0)
.When(x => x.PricePerConfigPerQuarter.HasValue);
RuleFor(x => x.PricePerConfigPerYear!.Value)
.GreaterThanOrEqualTo(0)
.When(x => x.PricePerConfigPerYear.HasValue);
}
}
@@ -4,5 +4,10 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Roles;
public sealed record UpdateRoleCommand(Guid RoleId, int MaxConfigs, int MaxIpLimit)
: ICommand<Result<RoleDto>>;
public sealed record UpdateRoleCommand(
Guid RoleId,
int MaxConfigs,
int MaxIpLimit,
int? PricePerConfigPerQuarter,
int? PricePerConfigPerYear
) : ICommand<Result<RoleDto>>;
@@ -15,6 +15,8 @@ public sealed class UpdateRoleCommandHandler(IRoleService roleService)
command.RoleId,
command.MaxConfigs,
command.MaxIpLimit,
command.PricePerConfigPerQuarter,
command.PricePerConfigPerYear,
cancellationToken
);
}
@@ -8,5 +8,12 @@ public sealed class UpdateRoleCommandValidator : AbstractValidator<UpdateRoleCom
{
RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1);
RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1);
RuleFor(x => x.PricePerConfigPerQuarter!.Value)
.GreaterThanOrEqualTo(0)
.When(x => x.PricePerConfigPerQuarter.HasValue);
RuleFor(x => x.PricePerConfigPerYear!.Value)
.GreaterThanOrEqualTo(0)
.When(x => x.PricePerConfigPerYear.HasValue);
}
}
@@ -49,6 +49,8 @@ public sealed class ApproveRoleRequestCommandHandler(
ticket.ProposedRoleName!,
ticket.ProposedMaxConfigs!.Value,
ticket.ProposedMaxIpLimit!.Value,
pricePerConfigPerQuarter: null,
pricePerConfigPerYear: null,
cancellationToken
);
if (!createResult.IsSuccess)
@@ -2,7 +2,15 @@ using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Common.Interfaces;
public sealed record RoleDto(Guid Id, string Name, int MaxConfigs, int MaxIpLimit, bool IsSystem);
public sealed record RoleDto(
Guid Id,
string Name,
int MaxConfigs,
int MaxIpLimit,
bool IsSystem,
int? PricePerConfigPerQuarter = null,
int? PricePerConfigPerYear = null
);
public interface IRoleService
{
@@ -10,6 +18,8 @@ public interface IRoleService
string name,
int maxConfigs,
int maxIpLimit,
int? pricePerConfigPerQuarter,
int? pricePerConfigPerYear,
CancellationToken cancellationToken
);
@@ -17,6 +27,8 @@ public interface IRoleService
Guid roleId,
int maxConfigs,
int maxIpLimit,
int? pricePerConfigPerQuarter,
int? pricePerConfigPerYear,
CancellationToken cancellationToken
);
@@ -1,4 +1,3 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
@@ -7,5 +6,5 @@ namespace PnvPanel.Application.Support.ListSelectableRoles;
/// <summary>Список ролей для выбора в заявке на роль (без admin) — в отличие от ListRolesQuery
/// (Admin/Roles), доступен любому активированному пользователю.</summary>
public sealed record ListSelectableRolesQuery
: IQuery<Result<IReadOnlyList<RoleDto>>>,
: IQuery<Result<IReadOnlyList<SelectableRoleDto>>>,
IRequiresActivation;
@@ -9,29 +9,31 @@ public sealed class ListSelectableRolesQueryHandler(
IRoleService roleService,
IIdentityService identityService,
ICurrentUser currentUser
) : IQueryHandler<ListSelectableRolesQuery, Result<IReadOnlyList<RoleDto>>>
) : IQueryHandler<ListSelectableRolesQuery, Result<IReadOnlyList<SelectableRoleDto>>>
{
// Совпадает со значением Infrastructure.Identity.RoleNames.Admin — см. пояснение в
// CreateRoleRequestTicketCommandHandler (Application не может ссылаться на Infrastructure).
private const string AdminRoleName = "admin";
public async Task<Result<IReadOnlyList<RoleDto>>> Handle(
public async Task<Result<IReadOnlyList<SelectableRoleDto>>> Handle(
ListSelectableRolesQuery query,
CancellationToken cancellationToken
)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<IReadOnlyList<RoleDto>>(AuthErrors.Unauthorized);
return Result.Failure<IReadOnlyList<SelectableRoleDto>>(AuthErrors.Unauthorized);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
var roles = await roleService.ListRolesAsync(cancellationToken);
// Без admin (нельзя запросить) и без текущей роли пользователя (уже есть — нечего запрашивать).
// Цена (RoleDto.PricePerConfigPer*) намеренно не пробрасывается — видна только админу.
var selectable = roles
.Where(r => !r.Name.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase))
.Where(r => profile is null || r.Id != profile.RoleId)
.Select(r => new SelectableRoleDto(r.Id, r.Name, r.MaxConfigs, r.MaxIpLimit))
.ToList();
return Result.Success<IReadOnlyList<RoleDto>>(selectable);
return Result.Success<IReadOnlyList<SelectableRoleDto>>(selectable);
}
}
@@ -0,0 +1,5 @@
namespace PnvPanel.Application.Support.ListSelectableRoles;
/// <summary>Роль для выбора в заявке на роль. Без справочной цены (RoleDto.PricePerConfigPer*) —
/// она видна только админу, этот эндпоинт доступен любому активированному пользователю.</summary>
public sealed record SelectableRoleDto(Guid Id, string Name, int MaxConfigs, int MaxIpLimit);