Implement pricing management functionality and update related components
- Added new endpoints for managing global pricing settings, including retrieval and updates for `PricePerConfigPerQuarter` and `PricePerConfigPerYear`. - Updated `RoleService` and related commands to remove pricing fields from role management, ensuring a clear separation between role configurations and global pricing. - Enhanced the `FactoryResetCommandHandler` to include seeding of pricing settings during a factory reset. - Modified frontend components to support new pricing settings, including forms for creating and updating pricing information. - Updated API documentation to reflect changes in pricing management endpoints and their expected request/response formats. - Adjusted tests to ensure proper coverage for new pricing functionalities and their integration with existing role management features.
This commit is contained in:
@@ -16,6 +16,7 @@ public sealed class FactoryResetCommandHandler(
|
||||
IFileStorage fileStorage,
|
||||
IClientAppCatalogSeeder catalogSeeder,
|
||||
IInstructionIntroSeeder instructionIntroSeeder,
|
||||
IPricingSettingsSeeder pricingSettingsSeeder,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<FactoryResetCommand, Result>
|
||||
{
|
||||
@@ -45,9 +46,11 @@ public sealed class FactoryResetCommandHandler(
|
||||
foreach (var role in roles.Where(r => !r.IsSystem))
|
||||
await roleService.DeleteRoleAsync(role.Id, cancellationToken);
|
||||
|
||||
// ClientApps/InstructionIntros уже пусты (удалены в WipeApplicationData + сохранено выше) — пересеиваем.
|
||||
// ClientApps/InstructionIntros/PricingSettings уже пусты (удалены в WipeApplicationData +
|
||||
// сохранено выше) — пересеиваем.
|
||||
await catalogSeeder.SeedIfEmptyAsync(cancellationToken);
|
||||
await instructionIntroSeeder.SeedIfEmptyAsync(cancellationToken);
|
||||
await pricingSettingsSeeder.SeedIfEmptyAsync(cancellationToken);
|
||||
|
||||
// Финальная запись — уже после очистки самого журнала, чтобы отметить факт сброса.
|
||||
dbContext.AuditLogs.Add(
|
||||
@@ -120,6 +123,7 @@ public sealed class FactoryResetCommandHandler(
|
||||
dbContext.ClientApps.RemoveRange(dbContext.ClientApps);
|
||||
dbContext.InstructionIntros.RemoveRange(dbContext.InstructionIntros);
|
||||
dbContext.InstructionTabs.RemoveRange(dbContext.InstructionTabs);
|
||||
dbContext.PricingSettings.RemoveRange(dbContext.PricingSettings);
|
||||
dbContext.AuditLogs.RemoveRange(dbContext.AuditLogs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Pricing;
|
||||
|
||||
public sealed record GetPricingSettingsQuery : IQuery<Result<PricingSettingsDto>>;
|
||||
@@ -0,0 +1,27 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Pricing;
|
||||
|
||||
public sealed class GetPricingSettingsQueryHandler(IAppDbContext dbContext)
|
||||
: IQueryHandler<GetPricingSettingsQuery, Result<PricingSettingsDto>>
|
||||
{
|
||||
public async Task<Result<PricingSettingsDto>> Handle(
|
||||
GetPricingSettingsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var settings = await dbContext
|
||||
.PricingSettings.AsNoTracking()
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
// Ещё не сидировано/не сохранено ни разу — цена не задана, а не ошибка.
|
||||
return Result.Success(
|
||||
settings is null
|
||||
? new PricingSettingsDto(null, null)
|
||||
: PricingSettingsDto.FromDomain(settings)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using PnvPanel.Domain.Pricing;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Pricing;
|
||||
|
||||
public sealed record PricingSettingsDto(int? PricePerConfigPerQuarter, int? PricePerConfigPerYear)
|
||||
{
|
||||
public static PricingSettingsDto FromDomain(PricingSettings settings) =>
|
||||
new(settings.PricePerConfigPerQuarter, settings.PricePerConfigPerYear);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Pricing;
|
||||
|
||||
public sealed record UpdatePricingSettingsCommand(
|
||||
int? PricePerConfigPerQuarter,
|
||||
int? PricePerConfigPerYear
|
||||
) : ICommand<Result<PricingSettingsDto>>;
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Domain.Pricing;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Pricing;
|
||||
|
||||
public sealed class UpdatePricingSettingsCommandHandler(IAppDbContext dbContext)
|
||||
: ICommandHandler<UpdatePricingSettingsCommand, Result<PricingSettingsDto>>
|
||||
{
|
||||
public async Task<Result<PricingSettingsDto>> Handle(
|
||||
UpdatePricingSettingsCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var settings = await dbContext.PricingSettings.FirstOrDefaultAsync(cancellationToken);
|
||||
if (settings is null)
|
||||
{
|
||||
settings = PricingSettings.CreateDefault();
|
||||
dbContext.PricingSettings.Add(settings);
|
||||
}
|
||||
|
||||
settings.Update(command.PricePerConfigPerQuarter, command.PricePerConfigPerYear);
|
||||
|
||||
return Result.Success(PricingSettingsDto.FromDomain(settings));
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Pricing;
|
||||
|
||||
public sealed class UpdatePricingSettingsCommandValidator
|
||||
: AbstractValidator<UpdatePricingSettingsCommand>
|
||||
{
|
||||
public UpdatePricingSettingsCommandValidator()
|
||||
{
|
||||
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,10 +4,5 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Roles;
|
||||
|
||||
public sealed record CreateRoleCommand(
|
||||
string Name,
|
||||
int MaxConfigs,
|
||||
int MaxIpLimit,
|
||||
int? PricePerConfigPerQuarter,
|
||||
int? PricePerConfigPerYear
|
||||
) : ICommand<Result<RoleDto>>;
|
||||
public sealed record CreateRoleCommand(string Name, int MaxConfigs, int MaxIpLimit)
|
||||
: ICommand<Result<RoleDto>>;
|
||||
|
||||
@@ -15,8 +15,6 @@ public sealed class CreateRoleCommandHandler(IRoleService roleService)
|
||||
command.Name,
|
||||
command.MaxConfigs,
|
||||
command.MaxIpLimit,
|
||||
command.PricePerConfigPerQuarter,
|
||||
command.PricePerConfigPerYear,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,12 +10,5 @@ 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,10 +4,5 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Roles;
|
||||
|
||||
public sealed record UpdateRoleCommand(
|
||||
Guid RoleId,
|
||||
int MaxConfigs,
|
||||
int MaxIpLimit,
|
||||
int? PricePerConfigPerQuarter,
|
||||
int? PricePerConfigPerYear
|
||||
) : ICommand<Result<RoleDto>>;
|
||||
public sealed record UpdateRoleCommand(Guid RoleId, int MaxConfigs, int MaxIpLimit)
|
||||
: ICommand<Result<RoleDto>>;
|
||||
|
||||
@@ -15,8 +15,6 @@ public sealed class UpdateRoleCommandHandler(IRoleService roleService)
|
||||
command.RoleId,
|
||||
command.MaxConfigs,
|
||||
command.MaxIpLimit,
|
||||
command.PricePerConfigPerQuarter,
|
||||
command.PricePerConfigPerYear,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,12 +8,5 @@ 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,8 +49,6 @@ public sealed class ApproveRoleRequestCommandHandler(
|
||||
ticket.ProposedRoleName!,
|
||||
ticket.ProposedMaxConfigs!.Value,
|
||||
ticket.ProposedMaxIpLimit!.Value,
|
||||
pricePerConfigPerQuarter: null,
|
||||
pricePerConfigPerYear: null,
|
||||
cancellationToken
|
||||
);
|
||||
if (!createResult.IsSuccess)
|
||||
|
||||
@@ -8,6 +8,7 @@ using PnvPanel.Domain.Inbounds;
|
||||
using PnvPanel.Domain.Instructions;
|
||||
using PnvPanel.Domain.News;
|
||||
using PnvPanel.Domain.Nodes;
|
||||
using PnvPanel.Domain.Pricing;
|
||||
using PnvPanel.Domain.Support;
|
||||
using PnvPanel.Domain.Telegram;
|
||||
|
||||
@@ -45,6 +46,8 @@ public interface IAppDbContext
|
||||
|
||||
DbSet<InstructionTab> InstructionTabs { get; }
|
||||
|
||||
DbSet<PricingSettings> PricingSettings { get; }
|
||||
|
||||
/// <summary>Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler).</summary>
|
||||
DatabaseFacade Database { get; }
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace PnvPanel.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Сидинг дефолтной (пустой) строки глобальных настроек цены. Идемпотентно — не трогает таблицу, если
|
||||
/// в ней уже есть строка (используется и при старте, и после полного сброса панели).
|
||||
/// </summary>
|
||||
public interface IPricingSettingsSeeder
|
||||
{
|
||||
Task SeedIfEmptyAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -2,15 +2,7 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Common.Interfaces;
|
||||
|
||||
public sealed record RoleDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
int MaxConfigs,
|
||||
int MaxIpLimit,
|
||||
bool IsSystem,
|
||||
int? PricePerConfigPerQuarter = null,
|
||||
int? PricePerConfigPerYear = null
|
||||
);
|
||||
public sealed record RoleDto(Guid Id, string Name, int MaxConfigs, int MaxIpLimit, bool IsSystem);
|
||||
|
||||
public interface IRoleService
|
||||
{
|
||||
@@ -18,8 +10,6 @@ public interface IRoleService
|
||||
string name,
|
||||
int maxConfigs,
|
||||
int maxIpLimit,
|
||||
int? pricePerConfigPerQuarter,
|
||||
int? pricePerConfigPerYear,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
@@ -27,8 +17,6 @@ public interface IRoleService
|
||||
Guid roleId,
|
||||
int maxConfigs,
|
||||
int maxIpLimit,
|
||||
int? pricePerConfigPerQuarter,
|
||||
int? pricePerConfigPerYear,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
@@ -6,5 +7,5 @@ namespace PnvPanel.Application.Support.ListSelectableRoles;
|
||||
/// <summary>Список ролей для выбора в заявке на роль (без admin) — в отличие от ListRolesQuery
|
||||
/// (Admin/Roles), доступен любому активированному пользователю.</summary>
|
||||
public sealed record ListSelectableRolesQuery
|
||||
: IQuery<Result<IReadOnlyList<SelectableRoleDto>>>,
|
||||
: IQuery<Result<IReadOnlyList<RoleDto>>>,
|
||||
IRequiresActivation;
|
||||
|
||||
+4
-6
@@ -9,31 +9,29 @@ public sealed class ListSelectableRolesQueryHandler(
|
||||
IRoleService roleService,
|
||||
IIdentityService identityService,
|
||||
ICurrentUser currentUser
|
||||
) : IQueryHandler<ListSelectableRolesQuery, Result<IReadOnlyList<SelectableRoleDto>>>
|
||||
) : IQueryHandler<ListSelectableRolesQuery, Result<IReadOnlyList<RoleDto>>>
|
||||
{
|
||||
// Совпадает со значением Infrastructure.Identity.RoleNames.Admin — см. пояснение в
|
||||
// CreateRoleRequestTicketCommandHandler (Application не может ссылаться на Infrastructure).
|
||||
private const string AdminRoleName = "admin";
|
||||
|
||||
public async Task<Result<IReadOnlyList<SelectableRoleDto>>> Handle(
|
||||
public async Task<Result<IReadOnlyList<RoleDto>>> Handle(
|
||||
ListSelectableRolesQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return Result.Failure<IReadOnlyList<SelectableRoleDto>>(AuthErrors.Unauthorized);
|
||||
return Result.Failure<IReadOnlyList<RoleDto>>(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<SelectableRoleDto>>(selectable);
|
||||
return Result.Success<IReadOnlyList<RoleDto>>(selectable);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
namespace PnvPanel.Application.Support.ListSelectableRoles;
|
||||
|
||||
/// <summary>Роль для выбора в заявке на роль. Без справочной цены (RoleDto.PricePerConfigPer*) —
|
||||
/// она видна только админу, этот эндпоинт доступен любому активированному пользователю.</summary>
|
||||
public sealed record SelectableRoleDto(Guid Id, string Name, int MaxConfigs, int MaxIpLimit);
|
||||
Reference in New Issue
Block a user