diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminPricingEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminPricingEndpoints.cs new file mode 100644 index 0000000..1f3567c --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/AdminPricingEndpoints.cs @@ -0,0 +1,37 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Admin.Pricing; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Infrastructure.Identity; + +namespace PnvPanel.Api.Endpoints; + +public static class AdminPricingEndpoints +{ + public static IEndpointRouteBuilder MapAdminPricingEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/pricing") + .WithTags("Admin.Pricing") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapGet("", GetPricing).Produces(); + admin.MapPut("", UpdatePricing).Produces(); + + return app; + } + + private static async Task GetPricing(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new GetPricingSettingsQuery(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task UpdatePricing( + UpdatePricingSettingsCommand command, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } +} diff --git a/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs index f14bf72..f36f2c0 100644 --- a/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/RoleEndpoints.cs @@ -53,13 +53,7 @@ public static class RoleEndpoints ) { var result = await sender.Send( - new UpdateRoleCommand( - id, - body.MaxConfigs, - body.MaxIpLimit, - body.PricePerConfigPerQuarter, - body.PricePerConfigPerYear - ), + new UpdateRoleCommand(id, body.MaxConfigs, body.MaxIpLimit), cancellationToken ); return result.ToHttpResult(); @@ -90,11 +84,6 @@ public static class RoleEndpoints } } -public sealed record UpdateRoleBody( - int MaxConfigs, - int MaxIpLimit, - int? PricePerConfigPerQuarter, - int? PricePerConfigPerYear -); +public sealed record UpdateRoleBody(int MaxConfigs, int MaxIpLimit); public sealed record ChangeUserRoleBody(Guid RoleId); diff --git a/backend/src/PnvPanel.Api/Endpoints/SupportEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/SupportEndpoints.cs index a74f894..f29a7e9 100644 --- a/backend/src/PnvPanel.Api/Endpoints/SupportEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/SupportEndpoints.cs @@ -22,7 +22,7 @@ public static class SupportEndpoints { var group = app.MapGroup("/api/support").WithTags("Support").RequireAuthorization(); - group.MapGet("/roles", ListSelectableRoles).Produces>(); + group.MapGet("/roles", ListSelectableRoles).Produces>(); group .MapPost("/tickets/bug-reports", CreateBugReport) .DisableAntiforgery() diff --git a/backend/src/PnvPanel.Api/Program.cs b/backend/src/PnvPanel.Api/Program.cs index 28766d3..c823a2a 100644 --- a/backend/src/PnvPanel.Api/Program.cs +++ b/backend/src/PnvPanel.Api/Program.cs @@ -191,6 +191,7 @@ app.MapAdminStatsEndpoints(); app.MapAdminAppEndpoints(); app.MapAdminNewsEndpoints(); app.MapAdminInstructionEndpoints(); +app.MapAdminPricingEndpoints(); app.MapSupportEndpoints(); app.MapAdminSupportEndpoints(); app.MapAdminMaintenanceEndpoints(); diff --git a/backend/src/PnvPanel.Application/Admin/Maintenance/FactoryResetCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Maintenance/FactoryResetCommandHandler.cs index 1967fe3..a6493ce 100644 --- a/backend/src/PnvPanel.Application/Admin/Maintenance/FactoryResetCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Maintenance/FactoryResetCommandHandler.cs @@ -16,6 +16,7 @@ public sealed class FactoryResetCommandHandler( IFileStorage fileStorage, IClientAppCatalogSeeder catalogSeeder, IInstructionIntroSeeder instructionIntroSeeder, + IPricingSettingsSeeder pricingSettingsSeeder, ICurrentUser currentUser ) : ICommandHandler { @@ -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); } } diff --git a/backend/src/PnvPanel.Application/Admin/Pricing/GetPricingSettingsQuery.cs b/backend/src/PnvPanel.Application/Admin/Pricing/GetPricingSettingsQuery.cs new file mode 100644 index 0000000..89e0a35 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Pricing/GetPricingSettingsQuery.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Pricing; + +public sealed record GetPricingSettingsQuery : IQuery>; diff --git a/backend/src/PnvPanel.Application/Admin/Pricing/GetPricingSettingsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Pricing/GetPricingSettingsQueryHandler.cs new file mode 100644 index 0000000..fa95b6b --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Pricing/GetPricingSettingsQueryHandler.cs @@ -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> +{ + public async Task> 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) + ); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Pricing/PricingSettingsDto.cs b/backend/src/PnvPanel.Application/Admin/Pricing/PricingSettingsDto.cs new file mode 100644 index 0000000..e13f387 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Pricing/PricingSettingsDto.cs @@ -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); +} diff --git a/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommand.cs b/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommand.cs new file mode 100644 index 0000000..e11fcba --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommand.cs @@ -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>; diff --git a/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandHandler.cs new file mode 100644 index 0000000..6bc3197 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandHandler.cs @@ -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> +{ + public async Task> 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)); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandValidator.cs new file mode 100644 index 0000000..d8e6926 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.Pricing; + +public sealed class UpdatePricingSettingsCommandValidator + : AbstractValidator +{ + 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); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs index 30ada39..3fec0d8 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommand.cs @@ -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>; +public sealed record CreateRoleCommand(string Name, int MaxConfigs, int MaxIpLimit) + : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs index 33daa84..8d6efff 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandHandler.cs @@ -15,8 +15,6 @@ public sealed class CreateRoleCommandHandler(IRoleService roleService) command.Name, command.MaxConfigs, command.MaxIpLimit, - command.PricePerConfigPerQuarter, - command.PricePerConfigPerYear, cancellationToken ); } diff --git a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandValidator.cs index 27d1b09..5c20099 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandValidator.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/CreateRoleCommandValidator.cs @@ -10,12 +10,5 @@ public sealed class CreateRoleCommandValidator : AbstractValidator 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); } } diff --git a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs index 53f338d..de07ab2 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommand.cs @@ -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>; +public sealed record UpdateRoleCommand(Guid RoleId, int MaxConfigs, int MaxIpLimit) + : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs index 1b37a34..a2dc00b 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandHandler.cs @@ -15,8 +15,6 @@ public sealed class UpdateRoleCommandHandler(IRoleService roleService) command.RoleId, command.MaxConfigs, command.MaxIpLimit, - command.PricePerConfigPerQuarter, - command.PricePerConfigPerYear, cancellationToken ); } diff --git a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandValidator.cs index fe3ec6f..f436e57 100644 --- a/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandValidator.cs +++ b/backend/src/PnvPanel.Application/Admin/Roles/UpdateRoleCommandValidator.cs @@ -8,12 +8,5 @@ public sealed class UpdateRoleCommandValidator : AbstractValidator 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); } } diff --git a/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs index 4a3913a..ef5d00f 100644 --- a/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs @@ -49,8 +49,6 @@ public sealed class ApproveRoleRequestCommandHandler( ticket.ProposedRoleName!, ticket.ProposedMaxConfigs!.Value, ticket.ProposedMaxIpLimit!.Value, - pricePerConfigPerQuarter: null, - pricePerConfigPerYear: null, cancellationToken ); if (!createResult.IsSuccess) diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs index 115cb48..408ea78 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs @@ -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 InstructionTabs { get; } + DbSet PricingSettings { get; } + /// Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler). DatabaseFacade Database { get; } diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IPricingSettingsSeeder.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IPricingSettingsSeeder.cs new file mode 100644 index 0000000..7eacd2c --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IPricingSettingsSeeder.cs @@ -0,0 +1,10 @@ +namespace PnvPanel.Application.Common.Interfaces; + +/// +/// Сидинг дефолтной (пустой) строки глобальных настроек цены. Идемпотентно — не трогает таблицу, если +/// в ней уже есть строка (используется и при старте, и после полного сброса панели). +/// +public interface IPricingSettingsSeeder +{ + Task SeedIfEmptyAsync(CancellationToken cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs index e29414a..272608b 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IRoleService.cs @@ -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 ); diff --git a/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQuery.cs b/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQuery.cs index bea5f6d..4e2c212 100644 --- a/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQuery.cs +++ b/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQuery.cs @@ -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; /// Список ролей для выбора в заявке на роль (без admin) — в отличие от ListRolesQuery /// (Admin/Roles), доступен любому активированному пользователю. public sealed record ListSelectableRolesQuery - : IQuery>>, + : IQuery>>, IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQueryHandler.cs b/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQueryHandler.cs index fe9c242..e910896 100644 --- a/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQueryHandler.cs +++ b/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQueryHandler.cs @@ -9,31 +9,29 @@ public sealed class ListSelectableRolesQueryHandler( IRoleService roleService, IIdentityService identityService, ICurrentUser currentUser -) : IQueryHandler>> +) : IQueryHandler>> { // Совпадает со значением Infrastructure.Identity.RoleNames.Admin — см. пояснение в // CreateRoleRequestTicketCommandHandler (Application не может ссылаться на Infrastructure). private const string AdminRoleName = "admin"; - public async Task>> Handle( + public async Task>> Handle( ListSelectableRolesQuery query, CancellationToken cancellationToken ) { if (currentUser.UserId is not { } userId) - return Result.Failure>(AuthErrors.Unauthorized); + return Result.Failure>(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>(selectable); + return Result.Success>(selectable); } } diff --git a/backend/src/PnvPanel.Application/Support/ListSelectableRoles/SelectableRoleDto.cs b/backend/src/PnvPanel.Application/Support/ListSelectableRoles/SelectableRoleDto.cs deleted file mode 100644 index ba91c82..0000000 --- a/backend/src/PnvPanel.Application/Support/ListSelectableRoles/SelectableRoleDto.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace PnvPanel.Application.Support.ListSelectableRoles; - -/// Роль для выбора в заявке на роль. Без справочной цены (RoleDto.PricePerConfigPer*) — -/// она видна только админу, этот эндпоинт доступен любому активированному пользователю. -public sealed record SelectableRoleDto(Guid Id, string Name, int MaxConfigs, int MaxIpLimit); diff --git a/backend/src/PnvPanel.Domain/Pricing/PricingSettings.cs b/backend/src/PnvPanel.Domain/Pricing/PricingSettings.cs new file mode 100644 index 0000000..de6e773 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Pricing/PricingSettings.cs @@ -0,0 +1,29 @@ +using PnvPanel.Domain.Common; + +namespace PnvPanel.Domain.Pricing; + +/// +/// Единственная строка в таблице — глобальная справочная цена за один конфиг, редактируется админом. +/// Не биллинг: без статусов оплаты, дат окончания, интеграций с платёжными системами. Итоговая цена +/// для роли (цена × MaxConfigs) считается на фронте, здесь не хранится. +/// +public sealed class PricingSettings : Entity +{ + public int? PricePerConfigPerQuarter { get; private set; } + public int? PricePerConfigPerYear { get; private set; } + public DateTimeOffset UpdatedAt { get; private set; } + + private PricingSettings() { } + + public static PricingSettings CreateDefault() + { + return new PricingSettings { Id = Guid.NewGuid(), UpdatedAt = DateTimeOffset.UtcNow }; + } + + public void Update(int? pricePerConfigPerQuarter, int? pricePerConfigPerYear) + { + PricePerConfigPerQuarter = pricePerConfigPerQuarter; + PricePerConfigPerYear = pricePerConfigPerYear; + UpdatedAt = DateTimeOffset.UtcNow; + } +} diff --git a/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs b/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs index f819b79..a95b2d5 100644 --- a/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs +++ b/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs @@ -12,6 +12,7 @@ using PnvPanel.Infrastructure.BackgroundJobs; using PnvPanel.Infrastructure.Identity; using PnvPanel.Infrastructure.Instructions; using PnvPanel.Infrastructure.Persistence; +using PnvPanel.Infrastructure.Pricing; using PnvPanel.Infrastructure.Security; using PnvPanel.Infrastructure.Storage; using PnvPanel.Infrastructure.Telegram; @@ -139,6 +140,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Один и тот же экземпляр CurrentUser на scope — и как ICurrentUser (чтение), и как // ICurrentUserSetter (запись, только для Telegram-бота, см. TelegramBotHostedService). services.AddScoped(); diff --git a/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs b/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs index d1b4217..2515534 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/AppRole.cs @@ -18,14 +18,6 @@ public class AppRole : IdentityRole public bool IsSystem { get; set; } - /// Справочная цена за конфиг за минимальный период оплаты (3 месяца), в рублях. Только для - /// отображения админу — без биллинга/статусов оплаты. У роли `admin` всегда null. - public int? PricePerConfigPerQuarter { get; set; } - - /// Справочная цена за конфиг за год, в рублях. Задаётся независимо от квартальной цены - /// (не производная — позволяет админу задать скидку за годовую оплату). У роли `admin` всегда null. - public int? PricePerConfigPerYear { get; set; } - public AppRole() { } public AppRole(string name) diff --git a/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs b/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs index d2cffbf..775843f 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs @@ -12,6 +12,7 @@ public sealed class DbInitializer( UserManager userManager, IClientAppCatalogSeeder clientAppCatalogSeeder, IInstructionIntroSeeder instructionIntroSeeder, + IPricingSettingsSeeder pricingSettingsSeeder, IOptions adminSeedOptions, IOptions rolesOptions, ILogger logger @@ -34,6 +35,7 @@ public sealed class DbInitializer( await SeedAdminAsync(); await clientAppCatalogSeeder.SeedIfEmptyAsync(cancellationToken); await instructionIntroSeeder.SeedIfEmptyAsync(cancellationToken); + await pricingSettingsSeeder.SeedIfEmptyAsync(cancellationToken); } private async Task EnsureRoleAsync(string name, int maxConfigs, int maxIpLimit, bool isSystem) diff --git a/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs b/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs index c55381f..fb5e7f4 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/RoleService.cs @@ -16,8 +16,6 @@ internal sealed class RoleService( string name, int maxConfigs, int maxIpLimit, - int? pricePerConfigPerQuarter, - int? pricePerConfigPerYear, CancellationToken cancellationToken ) { @@ -29,8 +27,6 @@ internal sealed class RoleService( MaxConfigs = maxConfigs, MaxIpLimit = maxIpLimit, IsSystem = false, - PricePerConfigPerQuarter = pricePerConfigPerQuarter, - PricePerConfigPerYear = pricePerConfigPerYear, }; var result = await roleManager.CreateAsync(role); if (!result.Succeeded) @@ -50,8 +46,6 @@ internal sealed class RoleService( Guid roleId, int maxConfigs, int maxIpLimit, - int? pricePerConfigPerQuarter, - int? pricePerConfigPerYear, CancellationToken cancellationToken ) { @@ -59,13 +53,8 @@ internal sealed class RoleService( if (role is null) return Result.Failure(RoleErrors.NotFound); - var isAdmin = role.Name!.Equals(RoleNames.Admin, StringComparison.OrdinalIgnoreCase); - role.MaxConfigs = maxConfigs; role.MaxIpLimit = maxIpLimit; - // Роль admin оплаты не имеет — цена не задаётся, даже если пришла в запросе. - role.PricePerConfigPerQuarter = isAdmin ? null : pricePerConfigPerQuarter; - role.PricePerConfigPerYear = isAdmin ? null : pricePerConfigPerYear; await roleManager.UpdateAsync(role); return Result.Success(ToDto(role)); @@ -92,15 +81,7 @@ internal sealed class RoleService( { return await roleManager .Roles.OrderBy(r => r.Name) - .Select(r => new RoleDto( - r.Id, - r.Name!, - r.MaxConfigs, - r.MaxIpLimit, - r.IsSystem, - r.PricePerConfigPerQuarter, - r.PricePerConfigPerYear - )) + .Select(r => new RoleDto(r.Id, r.Name!, r.MaxConfigs, r.MaxIpLimit, r.IsSystem)) .ToListAsync(cancellationToken); } @@ -137,13 +118,5 @@ internal sealed class RoleService( } private static RoleDto ToDto(AppRole role) => - new( - role.Id, - role.Name!, - role.MaxConfigs, - role.MaxIpLimit, - role.IsSystem, - role.PricePerConfigPerQuarter, - role.PricePerConfigPerYear - ); + new(role.Id, role.Name!, role.MaxConfigs, role.MaxIpLimit, role.IsSystem); } diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs b/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs index c15c3ff..2a27411 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs @@ -9,6 +9,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; using PnvPanel.Infrastructure.Identity; @@ -55,6 +56,8 @@ public class AppDbContext(DbContextOptions options) public DbSet InstructionTabs => Set(); + public DbSet PricingSettings => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/PricingSettingsConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/PricingSettingsConfiguration.cs new file mode 100644 index 0000000..3169a74 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/PricingSettingsConfiguration.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PnvPanel.Domain.Pricing; + +namespace PnvPanel.Infrastructure.Persistence.Configurations; + +public class PricingSettingsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("PricingSettings"); + builder.HasKey(x => x.Id); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718162052_RemoveRolePricing.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718162052_RemoveRolePricing.Designer.cs new file mode 100644 index 0000000..0475d43 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718162052_RemoveRolePricing.Designer.cs @@ -0,0 +1,938 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PnvPanel.Infrastructure.Persistence; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260718162052_RemoveRolePricing")] + partial class RemoveRolePricing + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("ActivationRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DownloadUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("IsRecommended") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OperatingSystem") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClientApps", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .HasColumnType("jsonb"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigId") + .HasColumnType("uuid"); + + b.Property("DownBytes") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ConfigId", "Timestamp"); + + b.ToTable("TrafficSamples", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InboundId") + .HasColumnType("uuid"); + + b.Property("Label") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UsedDownBytes") + .HasColumnType("bigint"); + + b.Property("UsedUpBytes") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("InboundId"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("UserId", "Status"); + + b.ToTable("VpnConfigs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("AllowedRoleIds") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RemoteInboundId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "RemoteInboundId") + .IsUnique(); + + b.ToTable("Inbounds", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionIntro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("InstructionIntros", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionTab", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("InstructionTabs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.News.NewsPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("NewsPosts", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.ToTable("Nodes", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProposedMaxConfigs") + .HasColumnType("integer"); + + b.Property("ProposedMaxIpLimit") + .HasColumnType("integer"); + + b.Property("ProposedRoleName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Type", "Status"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("SupportTickets", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CommentId") + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CommentId"); + + b.HasIndex("StoredFileName") + .IsUnique(); + + b.ToTable("TicketAttachments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TicketId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TicketId", "CreatedAt"); + + b.ToTable("TicketComments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("TelegramLinkTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("TelegramLoginRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxConfigs") + .HasColumnType("integer"); + + b.Property("MaxIpLimit") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("TelegramLinkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TelegramUserId") + .HasColumnType("bigint"); + + b.Property("TelegramUsername") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("TelegramUserId") + .IsUnique(); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 => + { + b1.Property("NodeId") + .HasColumnType("uuid"); + + b1.Property("ProtectedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CredentialsProtectedPassword"); + + b1.Property("Username") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("CredentialsUsername"); + + b1.HasKey("NodeId"); + + b1.ToTable("Nodes"); + + b1.WithOwner() + .HasForeignKey("NodeId"); + }); + + b.Navigation("Credentials") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718162052_RemoveRolePricing.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718162052_RemoveRolePricing.cs new file mode 100644 index 0000000..ee0753c --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718162052_RemoveRolePricing.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class RemoveRolePricing : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PricePerConfigPerQuarter", + table: "AspNetRoles"); + + migrationBuilder.DropColumn( + name: "PricePerConfigPerYear", + table: "AspNetRoles"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PricePerConfigPerQuarter", + table: "AspNetRoles", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "PricePerConfigPerYear", + table: "AspNetRoles", + type: "integer", + nullable: true); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718162434_AddPricingSettings.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718162434_AddPricingSettings.Designer.cs new file mode 100644 index 0000000..e965aa6 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718162434_AddPricingSettings.Designer.cs @@ -0,0 +1,958 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PnvPanel.Infrastructure.Persistence; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260718162434_AddPricingSettings")] + partial class AddPricingSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("ActivationRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DownloadUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("IsRecommended") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OperatingSystem") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClientApps", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .HasColumnType("jsonb"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigId") + .HasColumnType("uuid"); + + b.Property("DownBytes") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ConfigId", "Timestamp"); + + b.ToTable("TrafficSamples", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InboundId") + .HasColumnType("uuid"); + + b.Property("Label") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UsedDownBytes") + .HasColumnType("bigint"); + + b.Property("UsedUpBytes") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("InboundId"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("UserId", "Status"); + + b.ToTable("VpnConfigs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("AllowedRoleIds") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RemoteInboundId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "RemoteInboundId") + .IsUnique(); + + b.ToTable("Inbounds", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionIntro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("InstructionIntros", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionTab", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("InstructionTabs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.News.NewsPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("NewsPosts", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.ToTable("Nodes", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PricePerConfigPerQuarter") + .HasColumnType("integer"); + + b.Property("PricePerConfigPerYear") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("PricingSettings", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProposedMaxConfigs") + .HasColumnType("integer"); + + b.Property("ProposedMaxIpLimit") + .HasColumnType("integer"); + + b.Property("ProposedRoleName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Type", "Status"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("SupportTickets", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CommentId") + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CommentId"); + + b.HasIndex("StoredFileName") + .IsUnique(); + + b.ToTable("TicketAttachments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TicketId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TicketId", "CreatedAt"); + + b.ToTable("TicketComments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("TelegramLinkTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("TelegramLoginRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxConfigs") + .HasColumnType("integer"); + + b.Property("MaxIpLimit") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("TelegramLinkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TelegramUserId") + .HasColumnType("bigint"); + + b.Property("TelegramUsername") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("TelegramUserId") + .IsUnique(); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 => + { + b1.Property("NodeId") + .HasColumnType("uuid"); + + b1.Property("ProtectedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CredentialsProtectedPassword"); + + b1.Property("Username") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("CredentialsUsername"); + + b1.HasKey("NodeId"); + + b1.ToTable("Nodes"); + + b1.WithOwner() + .HasForeignKey("NodeId"); + }); + + b.Navigation("Credentials") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718162434_AddPricingSettings.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718162434_AddPricingSettings.cs new file mode 100644 index 0000000..76260cc --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718162434_AddPricingSettings.cs @@ -0,0 +1,36 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddPricingSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PricingSettings", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + PricePerConfigPerQuarter = table.Column(type: "integer", nullable: true), + PricePerConfigPerYear = table.Column(type: "integer", nullable: true), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PricingSettings", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PricingSettings"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 86167a0..a3a9f1b 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -513,6 +513,26 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations b.ToTable("Nodes", (string)null); }); + modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PricePerConfigPerQuarter") + .HasColumnType("integer"); + + b.Property("PricePerConfigPerYear") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("PricingSettings", (string)null); + }); + modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b => { b.Property("Id") @@ -708,12 +728,6 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations .HasMaxLength(256) .HasColumnType("character varying(256)"); - b.Property("PricePerConfigPerQuarter") - .HasColumnType("integer"); - - b.Property("PricePerConfigPerYear") - .HasColumnType("integer"); - b.HasKey("Id"); b.HasIndex("NormalizedName") diff --git a/backend/src/PnvPanel.Infrastructure/Pricing/PricingSettingsSeeder.cs b/backend/src/PnvPanel.Infrastructure/Pricing/PricingSettingsSeeder.cs new file mode 100644 index 0000000..e5912f7 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Pricing/PricingSettingsSeeder.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Domain.Pricing; +using PnvPanel.Infrastructure.Persistence; + +namespace PnvPanel.Infrastructure.Pricing; + +internal sealed class PricingSettingsSeeder( + AppDbContext dbContext, + ILogger logger +) : IPricingSettingsSeeder +{ + public async Task SeedIfEmptyAsync(CancellationToken cancellationToken) + { + if (await dbContext.PricingSettings.AnyAsync(cancellationToken)) + return; + + dbContext.PricingSettings.Add(PricingSettings.CreateDefault()); + await dbContext.SaveChangesAsync(cancellationToken); + logger.LogInformation("Seeded default pricing settings"); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/FactoryResetCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/FactoryResetCommandHandlerTests.cs index 162d6c9..458e84a 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/FactoryResetCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/FactoryResetCommandHandlerTests.cs @@ -23,6 +23,8 @@ public class FactoryResetCommandHandlerTests Substitute.For(); private readonly IInstructionIntroSeeder _instructionIntroSeeder = Substitute.For(); + private readonly IPricingSettingsSeeder _pricingSettingsSeeder = + Substitute.For(); [Fact] public async Task Handle_WipesEverythingExceptCurrentAdminAndSystemRoles() @@ -104,6 +106,7 @@ public class FactoryResetCommandHandlerTests _fileStorage, _catalogSeeder, _instructionIntroSeeder, + _pricingSettingsSeeder, currentUser ); @@ -144,5 +147,6 @@ public class FactoryResetCommandHandlerTests await _fileStorage.Received(1).DeleteAsync("stored-name", Arg.Any()); await _catalogSeeder.Received(1).SeedIfEmptyAsync(Arg.Any()); await _instructionIntroSeeder.Received(1).SeedIfEmptyAsync(Arg.Any()); + await _pricingSettingsSeeder.Received(1).SeedIfEmptyAsync(Arg.Any()); } } diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs index 8a65a5e..2715c5d 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs @@ -27,7 +27,7 @@ public class ApproveRoleRequestCommandHandlerTests var newRoleId = Guid.NewGuid(); _roleService - .CreateRoleAsync("premium", 10, 5, null, null, Arg.Any()) + .CreateRoleAsync("premium", 10, 5, Arg.Any()) .Returns(Result.Success(new RoleDto(newRoleId, "premium", 10, 5, false))); _roleService .ChangeUserRoleAsync(userId, newRoleId, Arg.Any()) @@ -51,7 +51,7 @@ public class ApproveRoleRequestCommandHandlerTests Assert.Equal(TicketStatus.Resolved, ticket.Status); await _roleService .Received(1) - .CreateRoleAsync("premium", 10, 5, null, null, Arg.Any()); + .CreateRoleAsync("premium", 10, 5, Arg.Any()); await _roleService .Received(1) .ChangeUserRoleAsync(userId, newRoleId, Arg.Any()); @@ -100,8 +100,6 @@ public class ApproveRoleRequestCommandHandlerTests Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any(), - Arg.Any(), Arg.Any() ); } diff --git a/docs/api-design.md b/docs/api-design.md index 6078959..338e914 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -244,10 +244,12 @@ reject/approve владением тикета не ограничены. Еди | POST | `/api/admin/activation-requests/{id}/approve` | admin | — | `204 No Content` | | POST | `/api/admin/activation-requests/{id}/reject` | admin | `{ reason? }` | `204 No Content` | | GET | `/api/admin/roles` | admin | — | `RoleDto[]` | -| POST | `/api/admin/roles` | admin | `{ name, maxConfigs, maxIpLimit, pricePerConfigPerQuarter?, pricePerConfigPerYear? }` | `RoleDto` | -| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit, pricePerConfigPerQuarter?, pricePerConfigPerYear? }` | `RoleDto` (для `admin` цена игнорируется, остаётся `null`) | +| POST | `/api/admin/roles` | admin | `{ name, maxConfigs, maxIpLimit }` | `RoleDto` | +| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit }` | `RoleDto` | | DELETE | `/api/admin/roles/{id}` | admin | — | `204 No Content` (системные `admin`/`user` удалить нельзя) | | PATCH | `/api/admin/users/{id}/role` | admin | `{ roleId }` | `204 No Content` (`409 Roles.CannotRemoveLastAdmin`, если у цели сейчас `admin`, новая роль другая, и это единственный админ) | +| GET | `/api/admin/pricing` | admin | — | `PricingSettingsDto` (глобальная справочная цена за конфиг, одна на весь сервис — не per-роль) | +| PUT | `/api/admin/pricing` | admin | `{ pricePerConfigPerQuarter?, pricePerConfigPerYear? }` | `PricingSettingsDto` | Нет отдельного эндпоинта «активировать напрямую без запроса» — активация только через approve/reject над `ActivationRequest`. diff --git a/docs/domain-model.md b/docs/domain-model.md index 4cad0e0..3e245d5 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -5,9 +5,9 @@ `IdentityRole`); чистый `PnvPanel.Domain` ссылается на пользователя/роль только по `Guid`. Тарифы `Plan` и лимиты трафика на конфиг (`TrafficLimit`) не реализованы — единственная квота: -число активных конфигов на роль (`AppRole.MaxConfigs`). У роли есть справочная цена за конфиг -(`AppRole.PricePerConfigPer*`, видна только админу) — это не биллинг: без статусов оплаты, дат -окончания и интеграций с платёжными системами, см. ниже. +число активных конфигов на роль (`AppRole.MaxConfigs`). Есть глобальная справочная цена за один +конфиг (`PricingSettings`, видна только админу) — это не биллинг: без статусов оплаты, дат окончания +и интеграций с платёжными системами, см. ниже. ## Диаграмма связей @@ -280,16 +280,6 @@ UI **настойчиво напоминает** привязать его (ед | `MaxConfigs` | `int` | Квота активных конфигов (-1 = без лимита; для `admin` — без лимита) | | `MaxIpLimit` | `int` | Лимит одновременных IP на клиента (`limitIp` в 3x-ui; -1 = без лимита; для `admin` — без лимита) | | `IsSystem` | `bool` | Системная (`admin`, `user`) — нельзя удалить/переименовать | -| `PricePerConfigPerQuarter` | `int?` | Справочная цена за конфиг за минимальный период оплаты (3 месяца), руб. Видна только админу (Admin-only эндпоинты); у `admin` всегда `null` | -| `PricePerConfigPerYear` | `int?` | Справочная цена за конфиг за год, руб. Задаётся независимо от квартальной (не производная — позволяет скидку); у `admin` всегда `null` | - -Итоговая цена роли для админа — `Price × MaxConfigs` (напр. `user` с `MaxConfigs=3`, 200₽/конфиг/3мес → -600₽/3мес; 400₽/конфиг/год → 1200₽/год); считается на фронте, не хранится отдельно. Чисто -информационное поле: без биллинга, статусов оплаты, дат окончания подписки, интеграций с платёжными -системами. `IRoleService.UpdateRoleAsync` игнорирует цену при обновлении роли `admin` (остаётся -`null`), даже если она передана в запросе. `GET /api/support/roles` (список ролей для заявки на роль, -доступен любому активированному пользователю) отдаёт отдельный `SelectableRoleDto` без цены — она не -протекает за пределы `/api/admin/*`. Сидируются: `admin` (оба лимита без ограничения) и `user` (`MaxConfigs` = `Roles__DefaultUserMaxConfigs`, по умолчанию 3; `MaxIpLimit` = `Roles__DefaultUserMaxIpLimit`, по умолчанию 2). @@ -307,6 +297,28 @@ UI **настойчиво напоминает** привязать его (ед одобряет заявку на понижение самому себе — этот путь специально не блокируется отдельно, чтобы не плодить тикеты, которые некому обработать, если админ единственный. +### PricingSettings — глобальная справочная цена конфига +Единственная строка в таблице (singleton) — цена за один конфиг, редактируется админом. Не привязана +к роли: одна цена на весь сервис. Не биллинг — без статусов оплаты, дат окончания, интеграций с +платёжными системами. + +| Поле | Тип | Заметки | +| --------------------------- | ----------------- | --------------------------------------------------- | +| `Id` | `Guid` | PK | +| `PricePerConfigPerQuarter` | `int?` | Цена за конфиг за минимальный период оплаты (3 месяца), руб. | +| `PricePerConfigPerYear` | `int?` | Цена за конфиг за год, руб. Задаётся независимо от квартальной (не производная — позволяет скидку) | +| `UpdatedAt` | `DateTimeOffset` | | + +Итоговая цена для роли = `цена × AppRole.MaxConfigs` (напр. `user` с `MaxConfigs=3`, 200₽/конфиг/3мес → +600₽/3мес; 400₽/конфиг/год → 1200₽/год) — считается на фронте (таблица ролей в админке), нигде не +хранится. Для ролей с `MaxConfigs = -1` (unlimited, в т.ч. `admin`) итог не считается — отображается +как «не задано». + +`GET/PUT /api/admin/pricing` — только `admin` (в отличие от `RoleDto`, цена никогда не попадает в +`GET /api/support/roles`, доступный любому активированному пользователю, — это два независимых DTO). +Сидируется пустой строкой при старте (`IPricingSettingsSeeder`, если таблица пуста) и заново после +полного сброса панели (см. «Полный сброс панели» выше). + ### ActivationRequest — запрос активации Пользователь просит активацию у админа; админ одобряет/отклоняет на сайте или в Telegram. diff --git a/docs/tech-stack.md b/docs/tech-stack.md index 7b246dd..603c51b 100644 --- a/docs/tech-stack.md +++ b/docs/tech-stack.md @@ -75,7 +75,7 @@ | -------------------------- | --------------------------------------------------------------------------------- | | Ролей у пользователя | Ровно одна роль (квота = `MaxConfigs` роли) | | Секреты нод | ASP.NET Core Data Protection (шифрование at-rest, key-ring на томе) | -| Тарифы/лимиты трафика | Не реализованы — конфиги без лимитов трафика/срока. У роли есть справочная цена за конфиг (`AppRole.PricePerConfigPer*`, видна только админу) — без биллинг-логики | +| Тарифы/лимиты трафика | Не реализованы — конфиги без лимитов трафика/срока. Есть глобальная справочная цена за конфиг (`PricingSettings`, видна только админу) — без биллинг-логики | | i18n | RU + EN (react-i18next) | | Telegram-транспорт | Long polling | | Регистрация через Telegram | Поддержана (логин — Telegram `@username`/id, пароль генерируется и присылается в чат) | diff --git a/frontend/src/features/admin/pricing/PricingSettingsEditor.tsx b/frontend/src/features/admin/pricing/PricingSettingsEditor.tsx new file mode 100644 index 0000000..28cab2d --- /dev/null +++ b/frontend/src/features/admin/pricing/PricingSettingsEditor.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { toast } from '@/shared/ui/toast-store' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' +import { HttpError } from '@/shared/api/client' +import type { PricingSettingsDto } from '@/shared/api/types' +import { updatePricingSettings } from './api' + +export function PricingSettingsEditor({ settings }: { settings: PricingSettingsDto }) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [pricePerConfigPerQuarter, setPricePerConfigPerQuarter] = useState( + settings.pricePerConfigPerQuarter != null ? String(settings.pricePerConfigPerQuarter) : '', + ) + const [pricePerConfigPerYear, setPricePerConfigPerYear] = useState( + settings.pricePerConfigPerYear != null ? String(settings.pricePerConfigPerYear) : '', + ) + + const mutation = useMutation({ + mutationFn: () => + updatePricingSettings( + pricePerConfigPerQuarter === '' ? null : Number(pricePerConfigPerQuarter), + pricePerConfigPerYear === '' ? null : Number(pricePerConfigPerYear), + ), + onSuccess: async () => { + toast.success(t('admin.pricing.updated')) + await queryClient.invalidateQueries({ queryKey: ['admin-pricing'] }) + }, + onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')), + }) + + return ( +
{ + e.preventDefault() + mutation.mutate() + }} + > +
+ + setPricePerConfigPerQuarter(e.target.value)} + /> +

{t('admin.pricing.pricePerConfigPerQuarterHint')}

+
+
+ + setPricePerConfigPerYear(e.target.value)} + /> +

{t('admin.pricing.pricePerConfigPerYearHint')}

+
+
+ +
+
+ ) +} diff --git a/frontend/src/features/admin/pricing/api.ts b/frontend/src/features/admin/pricing/api.ts new file mode 100644 index 0000000..811bf6f --- /dev/null +++ b/frontend/src/features/admin/pricing/api.ts @@ -0,0 +1,13 @@ +import { apiRequest } from '@/shared/api/client' +import type { PricingSettingsDto } from '@/shared/api/types' + +export function getPricingSettings() { + return apiRequest('/admin/pricing') +} + +export function updatePricingSettings(pricePerConfigPerQuarter: number | null, pricePerConfigPerYear: number | null) { + return apiRequest('/admin/pricing', { + method: 'PUT', + body: { pricePerConfigPerQuarter, pricePerConfigPerYear }, + }) +} diff --git a/frontend/src/features/admin/roles/RoleFormDialog.tsx b/frontend/src/features/admin/roles/RoleFormDialog.tsx index e25ad0b..1dc838a 100644 --- a/frontend/src/features/admin/roles/RoleFormDialog.tsx +++ b/frontend/src/features/admin/roles/RoleFormDialog.tsx @@ -24,38 +24,17 @@ export function RoleFormDialog({ const [name, setName] = useState(role?.name ?? '') const [maxConfigs, setMaxConfigs] = useState(String(role?.maxConfigs ?? 3)) const [maxIpLimit, setMaxIpLimit] = useState(String(role?.maxIpLimit ?? 2)) - const [pricePerConfigPerQuarter, setPricePerConfigPerQuarter] = useState( - role?.pricePerConfigPerQuarter != null ? String(role.pricePerConfigPerQuarter) : '', - ) - const [pricePerConfigPerYear, setPricePerConfigPerYear] = useState( - role?.pricePerConfigPerYear != null ? String(role.pricePerConfigPerYear) : '', - ) const [internalOpen, setInternalOpen] = useState(false) const isControlled = open !== undefined const dialogOpen = isControlled ? open : internalOpen const setDialogOpen = isControlled ? onOpenChange! : setInternalOpen - // Роль admin оплаты не имеет — поля цены для неё не показываются и не отправляются. - const isAdmin = role?.name === 'admin' - const mutation = useMutation({ mutationFn: () => role - ? updateRole( - role.id, - Number(maxConfigs), - Number(maxIpLimit), - pricePerConfigPerQuarter === '' ? null : Number(pricePerConfigPerQuarter), - pricePerConfigPerYear === '' ? null : Number(pricePerConfigPerYear), - ) - : createRole( - name.trim(), - Number(maxConfigs), - Number(maxIpLimit), - pricePerConfigPerQuarter === '' ? null : Number(pricePerConfigPerQuarter), - pricePerConfigPerYear === '' ? null : Number(pricePerConfigPerYear), - ), + ? updateRole(role.id, Number(maxConfigs), Number(maxIpLimit)) + : createRole(name.trim(), Number(maxConfigs), Number(maxIpLimit)), onSuccess: async () => { toast.success(role ? t('admin.roles.updated') : t('admin.roles.created')) await queryClient.invalidateQueries({ queryKey: ['admin-roles'] }) @@ -63,17 +42,10 @@ export function RoleFormDialog({ setName('') setMaxConfigs('3') setMaxIpLimit('2') - setPricePerConfigPerQuarter('') - setPricePerConfigPerYear('') }, onError: () => toast.error(t('auth.genericError')), }) - const totalFor = (price: string) => { - if (price === '' || Number(maxConfigs) < 0) return null - return Number(price) * Number(maxConfigs) - } - return ( {!isControlled && ( @@ -108,40 +80,6 @@ export function RoleFormDialog({ setMaxIpLimit(e.target.value)} />

{t('admin.roles.maxIpLimitHint')}

- {!isAdmin && ( - <> -
- - setPricePerConfigPerQuarter(e.target.value)} - /> -

- {t('admin.roles.pricePerConfigPerQuarterHint')} - {totalFor(pricePerConfigPerQuarter) !== null && - ` ${t('admin.roles.totalValue', { total: totalFor(pricePerConfigPerQuarter) })}`} -

-
-
- - setPricePerConfigPerYear(e.target.value)} - /> -

- {t('admin.roles.pricePerConfigPerYearHint')} - {totalFor(pricePerConfigPerYear) !== null && - ` ${t('admin.roles.totalValue', { total: totalFor(pricePerConfigPerYear) })}`} -

-
- - )} diff --git a/frontend/src/features/admin/roles/api.ts b/frontend/src/features/admin/roles/api.ts index 86e7d9e..a965f0a 100644 --- a/frontend/src/features/admin/roles/api.ts +++ b/frontend/src/features/admin/roles/api.ts @@ -5,30 +5,12 @@ export function listRoles() { return apiRequest('/admin/roles') } -export function createRole( - name: string, - maxConfigs: number, - maxIpLimit: number, - pricePerConfigPerQuarter: number | null, - pricePerConfigPerYear: number | null, -) { - return apiRequest('/admin/roles', { - method: 'POST', - body: { name, maxConfigs, maxIpLimit, pricePerConfigPerQuarter, pricePerConfigPerYear }, - }) +export function createRole(name: string, maxConfigs: number, maxIpLimit: number) { + return apiRequest('/admin/roles', { method: 'POST', body: { name, maxConfigs, maxIpLimit } }) } -export function updateRole( - id: string, - maxConfigs: number, - maxIpLimit: number, - pricePerConfigPerQuarter: number | null, - pricePerConfigPerYear: number | null, -) { - return apiRequest(`/admin/roles/${id}`, { - method: 'PUT', - body: { maxConfigs, maxIpLimit, pricePerConfigPerQuarter, pricePerConfigPerYear }, - }) +export function updateRole(id: string, maxConfigs: number, maxIpLimit: number) { + return apiRequest(`/admin/roles/${id}`, { method: 'PUT', body: { maxConfigs, maxIpLimit } }) } export function deleteRole(id: string) { diff --git a/frontend/src/features/support/api.ts b/frontend/src/features/support/api.ts index c469331..6f3f166 100644 --- a/frontend/src/features/support/api.ts +++ b/frontend/src/features/support/api.ts @@ -1,7 +1,7 @@ import { apiRequest, apiUpload, getAccessToken } from '@/shared/api/client' import type { PagedList, - SelectableRoleDto, + RoleDto, TicketCommentDto, TicketDetailDto, TicketStatus, @@ -25,7 +25,7 @@ export function getTicket(id: string) { } export function listSelectableRoles() { - return apiRequest('/support/roles') + return apiRequest('/support/roles') } export function createBugReportTicket(message: string, files: File[]) { diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 277c5ab..0bfa97c 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -22,6 +22,7 @@ import { Route as AdminIndexRouteImport } from './routes/admin/index' import { Route as AdminUsersRouteImport } from './routes/admin/users' import { Route as AdminSupportRouteImport } from './routes/admin/support' import { Route as AdminRolesRouteImport } from './routes/admin/roles' +import { Route as AdminPricingRouteImport } from './routes/admin/pricing' import { Route as AdminNodesRouteImport } from './routes/admin/nodes' import { Route as AdminNewsRouteImport } from './routes/admin/news' import { Route as AdminMaintenanceRouteImport } from './routes/admin/maintenance' @@ -96,6 +97,11 @@ const AdminRolesRoute = AdminRolesRouteImport.update({ path: '/roles', getParentRoute: () => AdminRoute, } as any) +const AdminPricingRoute = AdminPricingRouteImport.update({ + id: '/pricing', + path: '/pricing', + getParentRoute: () => AdminRoute, +} as any) const AdminNodesRoute = AdminNodesRouteImport.update({ id: '/nodes', path: '/nodes', @@ -155,6 +161,7 @@ export interface FileRoutesByFullPath { '/admin/maintenance': typeof AdminMaintenanceRoute '/admin/news': typeof AdminNewsRoute '/admin/nodes': typeof AdminNodesRoute + '/admin/pricing': typeof AdminPricingRoute '/admin/roles': typeof AdminRolesRoute '/admin/support': typeof AdminSupportRoute '/admin/users': typeof AdminUsersRoute @@ -177,6 +184,7 @@ export interface FileRoutesByTo { '/admin/maintenance': typeof AdminMaintenanceRoute '/admin/news': typeof AdminNewsRoute '/admin/nodes': typeof AdminNodesRoute + '/admin/pricing': typeof AdminPricingRoute '/admin/roles': typeof AdminRolesRoute '/admin/support': typeof AdminSupportRoute '/admin/users': typeof AdminUsersRoute @@ -201,6 +209,7 @@ export interface FileRoutesById { '/admin/maintenance': typeof AdminMaintenanceRoute '/admin/news': typeof AdminNewsRoute '/admin/nodes': typeof AdminNodesRoute + '/admin/pricing': typeof AdminPricingRoute '/admin/roles': typeof AdminRolesRoute '/admin/support': typeof AdminSupportRoute '/admin/users': typeof AdminUsersRoute @@ -226,6 +235,7 @@ export interface FileRouteTypes { | '/admin/maintenance' | '/admin/news' | '/admin/nodes' + | '/admin/pricing' | '/admin/roles' | '/admin/support' | '/admin/users' @@ -248,6 +258,7 @@ export interface FileRouteTypes { | '/admin/maintenance' | '/admin/news' | '/admin/nodes' + | '/admin/pricing' | '/admin/roles' | '/admin/support' | '/admin/users' @@ -271,6 +282,7 @@ export interface FileRouteTypes { | '/admin/maintenance' | '/admin/news' | '/admin/nodes' + | '/admin/pricing' | '/admin/roles' | '/admin/support' | '/admin/users' @@ -382,6 +394,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminRolesRouteImport parentRoute: typeof AdminRoute } + '/admin/pricing': { + id: '/admin/pricing' + path: '/pricing' + fullPath: '/admin/pricing' + preLoaderRoute: typeof AdminPricingRouteImport + parentRoute: typeof AdminRoute + } '/admin/nodes': { id: '/admin/nodes' path: '/nodes' @@ -450,6 +469,7 @@ interface AdminRouteChildren { AdminMaintenanceRoute: typeof AdminMaintenanceRoute AdminNewsRoute: typeof AdminNewsRoute AdminNodesRoute: typeof AdminNodesRoute + AdminPricingRoute: typeof AdminPricingRoute AdminRolesRoute: typeof AdminRolesRoute AdminSupportRoute: typeof AdminSupportRoute AdminUsersRoute: typeof AdminUsersRoute @@ -465,6 +485,7 @@ const AdminRouteChildren: AdminRouteChildren = { AdminMaintenanceRoute: AdminMaintenanceRoute, AdminNewsRoute: AdminNewsRoute, AdminNodesRoute: AdminNodesRoute, + AdminPricingRoute: AdminPricingRoute, AdminRolesRoute: AdminRolesRoute, AdminSupportRoute: AdminSupportRoute, AdminUsersRoute: AdminUsersRoute, diff --git a/frontend/src/routes/admin.tsx b/frontend/src/routes/admin.tsx index 040a7bb..72dcdcc 100644 --- a/frontend/src/routes/admin.tsx +++ b/frontend/src/routes/admin.tsx @@ -11,6 +11,7 @@ const TABS = [ { to: '/admin/users', key: 'users' }, { to: '/admin/configs', key: 'configs' }, { to: '/admin/roles', key: 'roles' }, + { to: '/admin/pricing', key: 'pricing' }, { to: '/admin/nodes', key: 'nodes' }, { to: '/admin/apps', key: 'apps' }, { to: '/admin/instructions', key: 'instructions' }, diff --git a/frontend/src/routes/admin/pricing.tsx b/frontend/src/routes/admin/pricing.tsx new file mode 100644 index 0000000..e767fd7 --- /dev/null +++ b/frontend/src/routes/admin/pricing.tsx @@ -0,0 +1,36 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useQuery } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { Button } from '@/shared/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' +import { getPricingSettings } from '@/features/admin/pricing/api' +import { PricingSettingsEditor } from '@/features/admin/pricing/PricingSettingsEditor' + +export const Route = createFileRoute('/admin/pricing')({ component: AdminPricingPage }) + +function AdminPricingPage() { + const { t } = useTranslation() + const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-pricing'], queryFn: getPricingSettings }) + + return ( + + + {t('admin.pricing.title')} + + + {isLoading &&

} + + {isError && ( +
+

{t('auth.genericError')}

+ +
+ )} + + {data && } +
+
+ ) +} diff --git a/frontend/src/routes/admin/roles.tsx b/frontend/src/routes/admin/roles.tsx index f7390aa..53eb2bc 100644 --- a/frontend/src/routes/admin/roles.tsx +++ b/frontend/src/routes/admin/roles.tsx @@ -7,6 +7,7 @@ import { Button } from '@/shared/ui/button' import { Badge } from '@/shared/ui/badge' import { listRoles, deleteRole } from '@/features/admin/roles/api' import { RoleFormDialog } from '@/features/admin/roles/RoleFormDialog' +import { getPricingSettings } from '@/features/admin/pricing/api' import type { RoleDto } from '@/shared/api/types' export const Route = createFileRoute('/admin/roles')({ component: AdminRolesPage }) @@ -17,8 +18,9 @@ function AdminRolesPage() { const [editing, setEditing] = useState(null) const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles }) + const { data: pricing } = useQuery({ queryKey: ['admin-pricing'], queryFn: getPricingSettings }) - const totalPrice = (price: number | null, maxConfigs: number) => + const totalPrice = (price: number | null | undefined, maxConfigs: number) => price == null || maxConfigs < 0 ? t('admin.roles.noPrice') : `${price * maxConfigs} ₽` const deleteMutation = useMutation({ @@ -69,8 +71,8 @@ function AdminRolesPage() { {role.maxConfigs < 0 ? t('unlimited') : role.maxConfigs} {role.maxIpLimit < 0 ? t('unlimited') : role.maxIpLimit} - {totalPrice(role.pricePerConfigPerQuarter, role.maxConfigs)} - {totalPrice(role.pricePerConfigPerYear, role.maxConfigs)} + {totalPrice(pricing?.pricePerConfigPerQuarter, role.maxConfigs)} + {totalPrice(pricing?.pricePerConfigPerYear, role.maxConfigs)}