Implement pricing management functionality and update related components
- Added new endpoints for managing global pricing settings, including retrieval and updates for `PricePerConfigPerQuarter` and `PricePerConfigPerYear`. - Updated `RoleService` and related commands to remove pricing fields from role management, ensuring a clear separation between role configurations and global pricing. - Enhanced the `FactoryResetCommandHandler` to include seeding of pricing settings during a factory reset. - Modified frontend components to support new pricing settings, including forms for creating and updating pricing information. - Updated API documentation to reflect changes in pricing management endpoints and their expected request/response formats. - Adjusted tests to ensure proper coverage for new pricing functionalities and their integration with existing role management features.
This commit is contained in:
@@ -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<PricingSettingsDto>();
|
||||||
|
admin.MapPut("", UpdatePricing).Produces<PricingSettingsDto>();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetPricing(ISender sender, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetPricingSettingsQuery(), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UpdatePricing(
|
||||||
|
UpdatePricingSettingsCommand command,
|
||||||
|
ISender sender,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(command, cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,13 +53,7 @@ public static class RoleEndpoints
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new UpdateRoleCommand(
|
new UpdateRoleCommand(id, body.MaxConfigs, body.MaxIpLimit),
|
||||||
id,
|
|
||||||
body.MaxConfigs,
|
|
||||||
body.MaxIpLimit,
|
|
||||||
body.PricePerConfigPerQuarter,
|
|
||||||
body.PricePerConfigPerYear
|
|
||||||
),
|
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
@@ -90,11 +84,6 @@ public static class RoleEndpoints
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record UpdateRoleBody(
|
public sealed record UpdateRoleBody(int MaxConfigs, int MaxIpLimit);
|
||||||
int MaxConfigs,
|
|
||||||
int MaxIpLimit,
|
|
||||||
int? PricePerConfigPerQuarter,
|
|
||||||
int? PricePerConfigPerYear
|
|
||||||
);
|
|
||||||
|
|
||||||
public sealed record ChangeUserRoleBody(Guid RoleId);
|
public sealed record ChangeUserRoleBody(Guid RoleId);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public static class SupportEndpoints
|
|||||||
{
|
{
|
||||||
var group = app.MapGroup("/api/support").WithTags("Support").RequireAuthorization();
|
var group = app.MapGroup("/api/support").WithTags("Support").RequireAuthorization();
|
||||||
|
|
||||||
group.MapGet("/roles", ListSelectableRoles).Produces<IReadOnlyList<SelectableRoleDto>>();
|
group.MapGet("/roles", ListSelectableRoles).Produces<IReadOnlyList<RoleDto>>();
|
||||||
group
|
group
|
||||||
.MapPost("/tickets/bug-reports", CreateBugReport)
|
.MapPost("/tickets/bug-reports", CreateBugReport)
|
||||||
.DisableAntiforgery()
|
.DisableAntiforgery()
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ app.MapAdminStatsEndpoints();
|
|||||||
app.MapAdminAppEndpoints();
|
app.MapAdminAppEndpoints();
|
||||||
app.MapAdminNewsEndpoints();
|
app.MapAdminNewsEndpoints();
|
||||||
app.MapAdminInstructionEndpoints();
|
app.MapAdminInstructionEndpoints();
|
||||||
|
app.MapAdminPricingEndpoints();
|
||||||
app.MapSupportEndpoints();
|
app.MapSupportEndpoints();
|
||||||
app.MapAdminSupportEndpoints();
|
app.MapAdminSupportEndpoints();
|
||||||
app.MapAdminMaintenanceEndpoints();
|
app.MapAdminMaintenanceEndpoints();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public sealed class FactoryResetCommandHandler(
|
|||||||
IFileStorage fileStorage,
|
IFileStorage fileStorage,
|
||||||
IClientAppCatalogSeeder catalogSeeder,
|
IClientAppCatalogSeeder catalogSeeder,
|
||||||
IInstructionIntroSeeder instructionIntroSeeder,
|
IInstructionIntroSeeder instructionIntroSeeder,
|
||||||
|
IPricingSettingsSeeder pricingSettingsSeeder,
|
||||||
ICurrentUser currentUser
|
ICurrentUser currentUser
|
||||||
) : ICommandHandler<FactoryResetCommand, Result>
|
) : ICommandHandler<FactoryResetCommand, Result>
|
||||||
{
|
{
|
||||||
@@ -45,9 +46,11 @@ public sealed class FactoryResetCommandHandler(
|
|||||||
foreach (var role in roles.Where(r => !r.IsSystem))
|
foreach (var role in roles.Where(r => !r.IsSystem))
|
||||||
await roleService.DeleteRoleAsync(role.Id, cancellationToken);
|
await roleService.DeleteRoleAsync(role.Id, cancellationToken);
|
||||||
|
|
||||||
// ClientApps/InstructionIntros уже пусты (удалены в WipeApplicationData + сохранено выше) — пересеиваем.
|
// ClientApps/InstructionIntros/PricingSettings уже пусты (удалены в WipeApplicationData +
|
||||||
|
// сохранено выше) — пересеиваем.
|
||||||
await catalogSeeder.SeedIfEmptyAsync(cancellationToken);
|
await catalogSeeder.SeedIfEmptyAsync(cancellationToken);
|
||||||
await instructionIntroSeeder.SeedIfEmptyAsync(cancellationToken);
|
await instructionIntroSeeder.SeedIfEmptyAsync(cancellationToken);
|
||||||
|
await pricingSettingsSeeder.SeedIfEmptyAsync(cancellationToken);
|
||||||
|
|
||||||
// Финальная запись — уже после очистки самого журнала, чтобы отметить факт сброса.
|
// Финальная запись — уже после очистки самого журнала, чтобы отметить факт сброса.
|
||||||
dbContext.AuditLogs.Add(
|
dbContext.AuditLogs.Add(
|
||||||
@@ -120,6 +123,7 @@ public sealed class FactoryResetCommandHandler(
|
|||||||
dbContext.ClientApps.RemoveRange(dbContext.ClientApps);
|
dbContext.ClientApps.RemoveRange(dbContext.ClientApps);
|
||||||
dbContext.InstructionIntros.RemoveRange(dbContext.InstructionIntros);
|
dbContext.InstructionIntros.RemoveRange(dbContext.InstructionIntros);
|
||||||
dbContext.InstructionTabs.RemoveRange(dbContext.InstructionTabs);
|
dbContext.InstructionTabs.RemoveRange(dbContext.InstructionTabs);
|
||||||
|
dbContext.PricingSettings.RemoveRange(dbContext.PricingSettings);
|
||||||
dbContext.AuditLogs.RemoveRange(dbContext.AuditLogs);
|
dbContext.AuditLogs.RemoveRange(dbContext.AuditLogs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Pricing;
|
||||||
|
|
||||||
|
public sealed record GetPricingSettingsQuery : IQuery<Result<PricingSettingsDto>>;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Pricing;
|
||||||
|
|
||||||
|
public sealed class GetPricingSettingsQueryHandler(IAppDbContext dbContext)
|
||||||
|
: IQueryHandler<GetPricingSettingsQuery, Result<PricingSettingsDto>>
|
||||||
|
{
|
||||||
|
public async Task<Result<PricingSettingsDto>> Handle(
|
||||||
|
GetPricingSettingsQuery query,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var settings = await dbContext
|
||||||
|
.PricingSettings.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Ещё не сидировано/не сохранено ни разу — цена не задана, а не ошибка.
|
||||||
|
return Result.Success(
|
||||||
|
settings is null
|
||||||
|
? new PricingSettingsDto(null, null)
|
||||||
|
: PricingSettingsDto.FromDomain(settings)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using PnvPanel.Domain.Pricing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Pricing;
|
||||||
|
|
||||||
|
public sealed record PricingSettingsDto(int? PricePerConfigPerQuarter, int? PricePerConfigPerYear)
|
||||||
|
{
|
||||||
|
public static PricingSettingsDto FromDomain(PricingSettings settings) =>
|
||||||
|
new(settings.PricePerConfigPerQuarter, settings.PricePerConfigPerYear);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Pricing;
|
||||||
|
|
||||||
|
public sealed record UpdatePricingSettingsCommand(
|
||||||
|
int? PricePerConfigPerQuarter,
|
||||||
|
int? PricePerConfigPerYear
|
||||||
|
) : ICommand<Result<PricingSettingsDto>>;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
|
using PnvPanel.Application.Common.Messaging;
|
||||||
|
using PnvPanel.Application.Common.Models;
|
||||||
|
using PnvPanel.Domain.Pricing;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Pricing;
|
||||||
|
|
||||||
|
public sealed class UpdatePricingSettingsCommandHandler(IAppDbContext dbContext)
|
||||||
|
: ICommandHandler<UpdatePricingSettingsCommand, Result<PricingSettingsDto>>
|
||||||
|
{
|
||||||
|
public async Task<Result<PricingSettingsDto>> Handle(
|
||||||
|
UpdatePricingSettingsCommand command,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var settings = await dbContext.PricingSettings.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (settings is null)
|
||||||
|
{
|
||||||
|
settings = PricingSettings.CreateDefault();
|
||||||
|
dbContext.PricingSettings.Add(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.Update(command.PricePerConfigPerQuarter, command.PricePerConfigPerYear);
|
||||||
|
|
||||||
|
return Result.Success(PricingSettingsDto.FromDomain(settings));
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace PnvPanel.Application.Admin.Pricing;
|
||||||
|
|
||||||
|
public sealed class UpdatePricingSettingsCommandValidator
|
||||||
|
: AbstractValidator<UpdatePricingSettingsCommand>
|
||||||
|
{
|
||||||
|
public UpdatePricingSettingsCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.PricePerConfigPerQuarter!.Value)
|
||||||
|
.GreaterThanOrEqualTo(0)
|
||||||
|
.When(x => x.PricePerConfigPerQuarter.HasValue);
|
||||||
|
RuleFor(x => x.PricePerConfigPerYear!.Value)
|
||||||
|
.GreaterThanOrEqualTo(0)
|
||||||
|
.When(x => x.PricePerConfigPerYear.HasValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,5 @@ using PnvPanel.Application.Common.Models;
|
|||||||
|
|
||||||
namespace PnvPanel.Application.Admin.Roles;
|
namespace PnvPanel.Application.Admin.Roles;
|
||||||
|
|
||||||
public sealed record CreateRoleCommand(
|
public sealed record CreateRoleCommand(string Name, int MaxConfigs, int MaxIpLimit)
|
||||||
string Name,
|
: ICommand<Result<RoleDto>>;
|
||||||
int MaxConfigs,
|
|
||||||
int MaxIpLimit,
|
|
||||||
int? PricePerConfigPerQuarter,
|
|
||||||
int? PricePerConfigPerYear
|
|
||||||
) : ICommand<Result<RoleDto>>;
|
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ public sealed class CreateRoleCommandHandler(IRoleService roleService)
|
|||||||
command.Name,
|
command.Name,
|
||||||
command.MaxConfigs,
|
command.MaxConfigs,
|
||||||
command.MaxIpLimit,
|
command.MaxIpLimit,
|
||||||
command.PricePerConfigPerQuarter,
|
|
||||||
command.PricePerConfigPerYear,
|
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,5 @@ public sealed class CreateRoleCommandValidator : AbstractValidator<CreateRoleCom
|
|||||||
|
|
||||||
RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1);
|
RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1);
|
||||||
RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1);
|
RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1);
|
||||||
|
|
||||||
RuleFor(x => x.PricePerConfigPerQuarter!.Value)
|
|
||||||
.GreaterThanOrEqualTo(0)
|
|
||||||
.When(x => x.PricePerConfigPerQuarter.HasValue);
|
|
||||||
RuleFor(x => x.PricePerConfigPerYear!.Value)
|
|
||||||
.GreaterThanOrEqualTo(0)
|
|
||||||
.When(x => x.PricePerConfigPerYear.HasValue);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,5 @@ using PnvPanel.Application.Common.Models;
|
|||||||
|
|
||||||
namespace PnvPanel.Application.Admin.Roles;
|
namespace PnvPanel.Application.Admin.Roles;
|
||||||
|
|
||||||
public sealed record UpdateRoleCommand(
|
public sealed record UpdateRoleCommand(Guid RoleId, int MaxConfigs, int MaxIpLimit)
|
||||||
Guid RoleId,
|
: ICommand<Result<RoleDto>>;
|
||||||
int MaxConfigs,
|
|
||||||
int MaxIpLimit,
|
|
||||||
int? PricePerConfigPerQuarter,
|
|
||||||
int? PricePerConfigPerYear
|
|
||||||
) : ICommand<Result<RoleDto>>;
|
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ public sealed class UpdateRoleCommandHandler(IRoleService roleService)
|
|||||||
command.RoleId,
|
command.RoleId,
|
||||||
command.MaxConfigs,
|
command.MaxConfigs,
|
||||||
command.MaxIpLimit,
|
command.MaxIpLimit,
|
||||||
command.PricePerConfigPerQuarter,
|
|
||||||
command.PricePerConfigPerYear,
|
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,5 @@ public sealed class UpdateRoleCommandValidator : AbstractValidator<UpdateRoleCom
|
|||||||
{
|
{
|
||||||
RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1);
|
RuleFor(x => x.MaxConfigs).GreaterThanOrEqualTo(-1);
|
||||||
RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1);
|
RuleFor(x => x.MaxIpLimit).GreaterThanOrEqualTo(-1);
|
||||||
|
|
||||||
RuleFor(x => x.PricePerConfigPerQuarter!.Value)
|
|
||||||
.GreaterThanOrEqualTo(0)
|
|
||||||
.When(x => x.PricePerConfigPerQuarter.HasValue);
|
|
||||||
RuleFor(x => x.PricePerConfigPerYear!.Value)
|
|
||||||
.GreaterThanOrEqualTo(0)
|
|
||||||
.When(x => x.PricePerConfigPerYear.HasValue);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,8 +49,6 @@ public sealed class ApproveRoleRequestCommandHandler(
|
|||||||
ticket.ProposedRoleName!,
|
ticket.ProposedRoleName!,
|
||||||
ticket.ProposedMaxConfigs!.Value,
|
ticket.ProposedMaxConfigs!.Value,
|
||||||
ticket.ProposedMaxIpLimit!.Value,
|
ticket.ProposedMaxIpLimit!.Value,
|
||||||
pricePerConfigPerQuarter: null,
|
|
||||||
pricePerConfigPerYear: null,
|
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
if (!createResult.IsSuccess)
|
if (!createResult.IsSuccess)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using PnvPanel.Domain.Inbounds;
|
|||||||
using PnvPanel.Domain.Instructions;
|
using PnvPanel.Domain.Instructions;
|
||||||
using PnvPanel.Domain.News;
|
using PnvPanel.Domain.News;
|
||||||
using PnvPanel.Domain.Nodes;
|
using PnvPanel.Domain.Nodes;
|
||||||
|
using PnvPanel.Domain.Pricing;
|
||||||
using PnvPanel.Domain.Support;
|
using PnvPanel.Domain.Support;
|
||||||
using PnvPanel.Domain.Telegram;
|
using PnvPanel.Domain.Telegram;
|
||||||
|
|
||||||
@@ -45,6 +46,8 @@ public interface IAppDbContext
|
|||||||
|
|
||||||
DbSet<InstructionTab> InstructionTabs { get; }
|
DbSet<InstructionTab> InstructionTabs { get; }
|
||||||
|
|
||||||
|
DbSet<PricingSettings> PricingSettings { get; }
|
||||||
|
|
||||||
/// <summary>Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler).</summary>
|
/// <summary>Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler).</summary>
|
||||||
DatabaseFacade Database { get; }
|
DatabaseFacade Database { get; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace PnvPanel.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Сидинг дефолтной (пустой) строки глобальных настроек цены. Идемпотентно — не трогает таблицу, если
|
||||||
|
/// в ней уже есть строка (используется и при старте, и после полного сброса панели).
|
||||||
|
/// </summary>
|
||||||
|
public interface IPricingSettingsSeeder
|
||||||
|
{
|
||||||
|
Task SeedIfEmptyAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -2,15 +2,7 @@ using PnvPanel.Application.Common.Models;
|
|||||||
|
|
||||||
namespace PnvPanel.Application.Common.Interfaces;
|
namespace PnvPanel.Application.Common.Interfaces;
|
||||||
|
|
||||||
public sealed record RoleDto(
|
public sealed record RoleDto(Guid Id, string Name, int MaxConfigs, int MaxIpLimit, bool IsSystem);
|
||||||
Guid Id,
|
|
||||||
string Name,
|
|
||||||
int MaxConfigs,
|
|
||||||
int MaxIpLimit,
|
|
||||||
bool IsSystem,
|
|
||||||
int? PricePerConfigPerQuarter = null,
|
|
||||||
int? PricePerConfigPerYear = null
|
|
||||||
);
|
|
||||||
|
|
||||||
public interface IRoleService
|
public interface IRoleService
|
||||||
{
|
{
|
||||||
@@ -18,8 +10,6 @@ public interface IRoleService
|
|||||||
string name,
|
string name,
|
||||||
int maxConfigs,
|
int maxConfigs,
|
||||||
int maxIpLimit,
|
int maxIpLimit,
|
||||||
int? pricePerConfigPerQuarter,
|
|
||||||
int? pricePerConfigPerYear,
|
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -27,8 +17,6 @@ public interface IRoleService
|
|||||||
Guid roleId,
|
Guid roleId,
|
||||||
int maxConfigs,
|
int maxConfigs,
|
||||||
int maxIpLimit,
|
int maxIpLimit,
|
||||||
int? pricePerConfigPerQuarter,
|
|
||||||
int? pricePerConfigPerYear,
|
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using PnvPanel.Application.Common.Messaging;
|
||||||
using PnvPanel.Application.Common.Models;
|
using PnvPanel.Application.Common.Models;
|
||||||
|
|
||||||
@@ -6,5 +7,5 @@ namespace PnvPanel.Application.Support.ListSelectableRoles;
|
|||||||
/// <summary>Список ролей для выбора в заявке на роль (без admin) — в отличие от ListRolesQuery
|
/// <summary>Список ролей для выбора в заявке на роль (без admin) — в отличие от ListRolesQuery
|
||||||
/// (Admin/Roles), доступен любому активированному пользователю.</summary>
|
/// (Admin/Roles), доступен любому активированному пользователю.</summary>
|
||||||
public sealed record ListSelectableRolesQuery
|
public sealed record ListSelectableRolesQuery
|
||||||
: IQuery<Result<IReadOnlyList<SelectableRoleDto>>>,
|
: IQuery<Result<IReadOnlyList<RoleDto>>>,
|
||||||
IRequiresActivation;
|
IRequiresActivation;
|
||||||
|
|||||||
+4
-6
@@ -9,31 +9,29 @@ public sealed class ListSelectableRolesQueryHandler(
|
|||||||
IRoleService roleService,
|
IRoleService roleService,
|
||||||
IIdentityService identityService,
|
IIdentityService identityService,
|
||||||
ICurrentUser currentUser
|
ICurrentUser currentUser
|
||||||
) : IQueryHandler<ListSelectableRolesQuery, Result<IReadOnlyList<SelectableRoleDto>>>
|
) : IQueryHandler<ListSelectableRolesQuery, Result<IReadOnlyList<RoleDto>>>
|
||||||
{
|
{
|
||||||
// Совпадает со значением Infrastructure.Identity.RoleNames.Admin — см. пояснение в
|
// Совпадает со значением Infrastructure.Identity.RoleNames.Admin — см. пояснение в
|
||||||
// CreateRoleRequestTicketCommandHandler (Application не может ссылаться на Infrastructure).
|
// CreateRoleRequestTicketCommandHandler (Application не может ссылаться на Infrastructure).
|
||||||
private const string AdminRoleName = "admin";
|
private const string AdminRoleName = "admin";
|
||||||
|
|
||||||
public async Task<Result<IReadOnlyList<SelectableRoleDto>>> Handle(
|
public async Task<Result<IReadOnlyList<RoleDto>>> Handle(
|
||||||
ListSelectableRolesQuery query,
|
ListSelectableRolesQuery query,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (currentUser.UserId is not { } userId)
|
if (currentUser.UserId is not { } userId)
|
||||||
return Result.Failure<IReadOnlyList<SelectableRoleDto>>(AuthErrors.Unauthorized);
|
return Result.Failure<IReadOnlyList<RoleDto>>(AuthErrors.Unauthorized);
|
||||||
|
|
||||||
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
|
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
|
||||||
var roles = await roleService.ListRolesAsync(cancellationToken);
|
var roles = await roleService.ListRolesAsync(cancellationToken);
|
||||||
|
|
||||||
// Без admin (нельзя запросить) и без текущей роли пользователя (уже есть — нечего запрашивать).
|
// Без admin (нельзя запросить) и без текущей роли пользователя (уже есть — нечего запрашивать).
|
||||||
// Цена (RoleDto.PricePerConfigPer*) намеренно не пробрасывается — видна только админу.
|
|
||||||
var selectable = roles
|
var selectable = roles
|
||||||
.Where(r => !r.Name.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase))
|
.Where(r => !r.Name.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase))
|
||||||
.Where(r => profile is null || r.Id != profile.RoleId)
|
.Where(r => profile is null || r.Id != profile.RoleId)
|
||||||
.Select(r => new SelectableRoleDto(r.Id, r.Name, r.MaxConfigs, r.MaxIpLimit))
|
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return Result.Success<IReadOnlyList<SelectableRoleDto>>(selectable);
|
return Result.Success<IReadOnlyList<RoleDto>>(selectable);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
namespace PnvPanel.Application.Support.ListSelectableRoles;
|
|
||||||
|
|
||||||
/// <summary>Роль для выбора в заявке на роль. Без справочной цены (RoleDto.PricePerConfigPer*) —
|
|
||||||
/// она видна только админу, этот эндпоинт доступен любому активированному пользователю.</summary>
|
|
||||||
public sealed record SelectableRoleDto(Guid Id, string Name, int MaxConfigs, int MaxIpLimit);
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using PnvPanel.Domain.Common;
|
||||||
|
|
||||||
|
namespace PnvPanel.Domain.Pricing;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Единственная строка в таблице — глобальная справочная цена за один конфиг, редактируется админом.
|
||||||
|
/// Не биллинг: без статусов оплаты, дат окончания, интеграций с платёжными системами. Итоговая цена
|
||||||
|
/// для роли (цена × MaxConfigs) считается на фронте, здесь не хранится.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ using PnvPanel.Infrastructure.BackgroundJobs;
|
|||||||
using PnvPanel.Infrastructure.Identity;
|
using PnvPanel.Infrastructure.Identity;
|
||||||
using PnvPanel.Infrastructure.Instructions;
|
using PnvPanel.Infrastructure.Instructions;
|
||||||
using PnvPanel.Infrastructure.Persistence;
|
using PnvPanel.Infrastructure.Persistence;
|
||||||
|
using PnvPanel.Infrastructure.Pricing;
|
||||||
using PnvPanel.Infrastructure.Security;
|
using PnvPanel.Infrastructure.Security;
|
||||||
using PnvPanel.Infrastructure.Storage;
|
using PnvPanel.Infrastructure.Storage;
|
||||||
using PnvPanel.Infrastructure.Telegram;
|
using PnvPanel.Infrastructure.Telegram;
|
||||||
@@ -139,6 +140,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IRoleService, RoleService>();
|
services.AddScoped<IRoleService, RoleService>();
|
||||||
services.AddScoped<IClientAppCatalogSeeder, ClientAppCatalogSeeder>();
|
services.AddScoped<IClientAppCatalogSeeder, ClientAppCatalogSeeder>();
|
||||||
services.AddScoped<IInstructionIntroSeeder, InstructionIntroSeeder>();
|
services.AddScoped<IInstructionIntroSeeder, InstructionIntroSeeder>();
|
||||||
|
services.AddScoped<IPricingSettingsSeeder, PricingSettingsSeeder>();
|
||||||
// Один и тот же экземпляр CurrentUser на scope — и как ICurrentUser (чтение), и как
|
// Один и тот же экземпляр CurrentUser на scope — и как ICurrentUser (чтение), и как
|
||||||
// ICurrentUserSetter (запись, только для Telegram-бота, см. TelegramBotHostedService).
|
// ICurrentUserSetter (запись, только для Telegram-бота, см. TelegramBotHostedService).
|
||||||
services.AddScoped<CurrentUser>();
|
services.AddScoped<CurrentUser>();
|
||||||
|
|||||||
@@ -18,14 +18,6 @@ public class AppRole : IdentityRole<Guid>
|
|||||||
|
|
||||||
public bool IsSystem { get; set; }
|
public bool IsSystem { get; set; }
|
||||||
|
|
||||||
/// <summary>Справочная цена за конфиг за минимальный период оплаты (3 месяца), в рублях. Только для
|
|
||||||
/// отображения админу — без биллинга/статусов оплаты. У роли `admin` всегда null.</summary>
|
|
||||||
public int? PricePerConfigPerQuarter { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Справочная цена за конфиг за год, в рублях. Задаётся независимо от квартальной цены
|
|
||||||
/// (не производная — позволяет админу задать скидку за годовую оплату). У роли `admin` всегда null.</summary>
|
|
||||||
public int? PricePerConfigPerYear { get; set; }
|
|
||||||
|
|
||||||
public AppRole() { }
|
public AppRole() { }
|
||||||
|
|
||||||
public AppRole(string name)
|
public AppRole(string name)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public sealed class DbInitializer(
|
|||||||
UserManager<AppUser> userManager,
|
UserManager<AppUser> userManager,
|
||||||
IClientAppCatalogSeeder clientAppCatalogSeeder,
|
IClientAppCatalogSeeder clientAppCatalogSeeder,
|
||||||
IInstructionIntroSeeder instructionIntroSeeder,
|
IInstructionIntroSeeder instructionIntroSeeder,
|
||||||
|
IPricingSettingsSeeder pricingSettingsSeeder,
|
||||||
IOptions<AdminSeedOptions> adminSeedOptions,
|
IOptions<AdminSeedOptions> adminSeedOptions,
|
||||||
IOptions<RolesOptions> rolesOptions,
|
IOptions<RolesOptions> rolesOptions,
|
||||||
ILogger<DbInitializer> logger
|
ILogger<DbInitializer> logger
|
||||||
@@ -34,6 +35,7 @@ public sealed class DbInitializer(
|
|||||||
await SeedAdminAsync();
|
await SeedAdminAsync();
|
||||||
await clientAppCatalogSeeder.SeedIfEmptyAsync(cancellationToken);
|
await clientAppCatalogSeeder.SeedIfEmptyAsync(cancellationToken);
|
||||||
await instructionIntroSeeder.SeedIfEmptyAsync(cancellationToken);
|
await instructionIntroSeeder.SeedIfEmptyAsync(cancellationToken);
|
||||||
|
await pricingSettingsSeeder.SeedIfEmptyAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task EnsureRoleAsync(string name, int maxConfigs, int maxIpLimit, bool isSystem)
|
private async Task EnsureRoleAsync(string name, int maxConfigs, int maxIpLimit, bool isSystem)
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ internal sealed class RoleService(
|
|||||||
string name,
|
string name,
|
||||||
int maxConfigs,
|
int maxConfigs,
|
||||||
int maxIpLimit,
|
int maxIpLimit,
|
||||||
int? pricePerConfigPerQuarter,
|
|
||||||
int? pricePerConfigPerYear,
|
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@@ -29,8 +27,6 @@ internal sealed class RoleService(
|
|||||||
MaxConfigs = maxConfigs,
|
MaxConfigs = maxConfigs,
|
||||||
MaxIpLimit = maxIpLimit,
|
MaxIpLimit = maxIpLimit,
|
||||||
IsSystem = false,
|
IsSystem = false,
|
||||||
PricePerConfigPerQuarter = pricePerConfigPerQuarter,
|
|
||||||
PricePerConfigPerYear = pricePerConfigPerYear,
|
|
||||||
};
|
};
|
||||||
var result = await roleManager.CreateAsync(role);
|
var result = await roleManager.CreateAsync(role);
|
||||||
if (!result.Succeeded)
|
if (!result.Succeeded)
|
||||||
@@ -50,8 +46,6 @@ internal sealed class RoleService(
|
|||||||
Guid roleId,
|
Guid roleId,
|
||||||
int maxConfigs,
|
int maxConfigs,
|
||||||
int maxIpLimit,
|
int maxIpLimit,
|
||||||
int? pricePerConfigPerQuarter,
|
|
||||||
int? pricePerConfigPerYear,
|
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@@ -59,13 +53,8 @@ internal sealed class RoleService(
|
|||||||
if (role is null)
|
if (role is null)
|
||||||
return Result.Failure<RoleDto>(RoleErrors.NotFound);
|
return Result.Failure<RoleDto>(RoleErrors.NotFound);
|
||||||
|
|
||||||
var isAdmin = role.Name!.Equals(RoleNames.Admin, StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
role.MaxConfigs = maxConfigs;
|
role.MaxConfigs = maxConfigs;
|
||||||
role.MaxIpLimit = maxIpLimit;
|
role.MaxIpLimit = maxIpLimit;
|
||||||
// Роль admin оплаты не имеет — цена не задаётся, даже если пришла в запросе.
|
|
||||||
role.PricePerConfigPerQuarter = isAdmin ? null : pricePerConfigPerQuarter;
|
|
||||||
role.PricePerConfigPerYear = isAdmin ? null : pricePerConfigPerYear;
|
|
||||||
await roleManager.UpdateAsync(role);
|
await roleManager.UpdateAsync(role);
|
||||||
|
|
||||||
return Result.Success(ToDto(role));
|
return Result.Success(ToDto(role));
|
||||||
@@ -92,15 +81,7 @@ internal sealed class RoleService(
|
|||||||
{
|
{
|
||||||
return await roleManager
|
return await roleManager
|
||||||
.Roles.OrderBy(r => r.Name)
|
.Roles.OrderBy(r => r.Name)
|
||||||
.Select(r => new RoleDto(
|
.Select(r => new RoleDto(r.Id, r.Name!, r.MaxConfigs, r.MaxIpLimit, r.IsSystem))
|
||||||
r.Id,
|
|
||||||
r.Name!,
|
|
||||||
r.MaxConfigs,
|
|
||||||
r.MaxIpLimit,
|
|
||||||
r.IsSystem,
|
|
||||||
r.PricePerConfigPerQuarter,
|
|
||||||
r.PricePerConfigPerYear
|
|
||||||
))
|
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,13 +118,5 @@ internal sealed class RoleService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static RoleDto ToDto(AppRole role) =>
|
private static RoleDto ToDto(AppRole role) =>
|
||||||
new(
|
new(role.Id, role.Name!, role.MaxConfigs, role.MaxIpLimit, role.IsSystem);
|
||||||
role.Id,
|
|
||||||
role.Name!,
|
|
||||||
role.MaxConfigs,
|
|
||||||
role.MaxIpLimit,
|
|
||||||
role.IsSystem,
|
|
||||||
role.PricePerConfigPerQuarter,
|
|
||||||
role.PricePerConfigPerYear
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using PnvPanel.Domain.Inbounds;
|
|||||||
using PnvPanel.Domain.Instructions;
|
using PnvPanel.Domain.Instructions;
|
||||||
using PnvPanel.Domain.News;
|
using PnvPanel.Domain.News;
|
||||||
using PnvPanel.Domain.Nodes;
|
using PnvPanel.Domain.Nodes;
|
||||||
|
using PnvPanel.Domain.Pricing;
|
||||||
using PnvPanel.Domain.Support;
|
using PnvPanel.Domain.Support;
|
||||||
using PnvPanel.Domain.Telegram;
|
using PnvPanel.Domain.Telegram;
|
||||||
using PnvPanel.Infrastructure.Identity;
|
using PnvPanel.Infrastructure.Identity;
|
||||||
@@ -55,6 +56,8 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
|
|
||||||
public DbSet<InstructionTab> InstructionTabs => Set<InstructionTab>();
|
public DbSet<InstructionTab> InstructionTabs => Set<InstructionTab>();
|
||||||
|
|
||||||
|
public DbSet<PricingSettings> PricingSettings => Set<PricingSettings>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|||||||
+14
@@ -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<PricingSettings>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<PricingSettings> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("PricingSettings");
|
||||||
|
builder.HasKey(x => x.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
+938
@@ -0,0 +1,938 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("RoleId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoleClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderKey")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderDisplayName")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("LoginProvider", "ProviderKey");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserLogins", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("RoleId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "RoleId");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "LoginProvider", "Name");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserTokens", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Comment")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DecidedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DecidedBy")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("RejectionReason")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "Status");
|
||||||
|
|
||||||
|
b.ToTable("ActivationRequests", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("character varying(300)");
|
||||||
|
|
||||||
|
b.Property<string>("DownloadUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("IconUrl")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsRecommended")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("OperatingSystem")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("ClientApps", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Action")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ActorId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Metadata")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("TargetId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("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<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<Guid>("ConfigId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<long>("DownBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("Timestamp")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<long>("UpBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ConfigId", "Timestamp");
|
||||||
|
|
||||||
|
b.ToTable("TrafficSamples", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ClientEmail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("ClientExternalId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("InboundId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Label")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Protocol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("SubscriptionToken")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<long>("UsedDownBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<long>("UsedUpBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<Guid>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<Guid[]>("AllowedRoleIds")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("uuid[]");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublished")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("NodeId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("Port")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Protocol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("Remark")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Body")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20000)
|
||||||
|
.HasColumnType("character varying(20000)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("InstructionIntros", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionTab", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Body")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20000)
|
||||||
|
.HasColumnType("character varying(20000)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Body")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20000)
|
||||||
|
.HasColumnType("character varying(20000)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("BaseAddress")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("IsEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Location")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int?>("ProposedMaxConfigs")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("ProposedMaxIpLimit")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ProposedRoleName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("RequestedRoleId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<Guid>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("CommentId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ContentType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)");
|
||||||
|
|
||||||
|
b.Property<long>("SizeBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("AuthorId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Body")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4000)
|
||||||
|
.HasColumnType("character varying(4000)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("TicketId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TicketId", "CreatedAt");
|
||||||
|
|
||||||
|
b.ToTable("TicketComments", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ConsumedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Token")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Token")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("TelegramLinkTokens", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Context")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("TelegramLoginRequests", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSystem")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("MaxConfigs")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("MaxIpLimit")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("AccessFailedCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ActivatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ActivatedBy")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<bool>("EmailConfirmed")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActivated")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBlocked")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("LockoutEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("PhoneNumberConfirmed")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("SecurityStamp")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("SubscriptionToken")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("TelegramLinkedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<long?>("TelegramUserId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("TelegramUsername")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("TwoFactorEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ReplacedByTokenHash")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("RevokedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", 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<System.Guid>", 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<Guid>("NodeId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.Property<string>("ProtectedPassword")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("CredentialsProtectedPassword");
|
||||||
|
|
||||||
|
b1.Property<string>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class RemoveRolePricing : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PricePerConfigPerQuarter",
|
||||||
|
table: "AspNetRoles");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PricePerConfigPerYear",
|
||||||
|
table: "AspNetRoles");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "PricePerConfigPerQuarter",
|
||||||
|
table: "AspNetRoles",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "PricePerConfigPerYear",
|
||||||
|
table: "AspNetRoles",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+958
@@ -0,0 +1,958 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("RoleId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoleClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderKey")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderDisplayName")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("LoginProvider", "ProviderKey");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserLogins", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("RoleId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "RoleId");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "LoginProvider", "Name");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserTokens", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Comment")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DecidedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DecidedBy")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("RejectionReason")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "Status");
|
||||||
|
|
||||||
|
b.ToTable("ActivationRequests", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("character varying(300)");
|
||||||
|
|
||||||
|
b.Property<string>("DownloadUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("IconUrl")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsRecommended")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("OperatingSystem")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("ClientApps", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Action")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ActorId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Metadata")
|
||||||
|
.HasColumnType("jsonb");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("TargetId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("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<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<Guid>("ConfigId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<long>("DownBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("Timestamp")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<long>("UpBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ConfigId", "Timestamp");
|
||||||
|
|
||||||
|
b.ToTable("TrafficSamples", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ClientEmail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("ClientExternalId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("InboundId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Label")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Protocol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("SubscriptionToken")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<long>("UsedDownBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<long>("UsedUpBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<Guid>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<Guid[]>("AllowedRoleIds")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("uuid[]");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublished")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("NodeId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("Port")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Protocol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("Remark")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Body")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20000)
|
||||||
|
.HasColumnType("character varying(20000)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("InstructionIntros", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionTab", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Body")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20000)
|
||||||
|
.HasColumnType("character varying(20000)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Body")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20000)
|
||||||
|
.HasColumnType("character varying(20000)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("BaseAddress")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("IsEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Location")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int?>("PricePerConfigPerQuarter")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("PricePerConfigPerYear")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("PricingSettings", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int?>("ProposedMaxConfigs")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("ProposedMaxIpLimit")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ProposedRoleName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("RequestedRoleId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<Guid>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("CommentId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ContentType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)");
|
||||||
|
|
||||||
|
b.Property<long>("SizeBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("AuthorId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Body")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4000)
|
||||||
|
.HasColumnType("character varying(4000)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("TicketId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TicketId", "CreatedAt");
|
||||||
|
|
||||||
|
b.ToTable("TicketComments", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ConsumedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Token")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Token")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("TelegramLinkTokens", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Context")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("TelegramLoginRequests", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSystem")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("MaxConfigs")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("MaxIpLimit")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("AccessFailedCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ActivatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ActivatedBy")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<bool>("EmailConfirmed")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActivated")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBlocked")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("LockoutEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("PhoneNumberConfirmed")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("SecurityStamp")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("SubscriptionToken")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("TelegramLinkedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<long?>("TelegramUserId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("TelegramUsername")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("TwoFactorEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ReplacedByTokenHash")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("RevokedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", 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<System.Guid>", 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<Guid>("NodeId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.Property<string>("ProtectedPassword")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("CredentialsProtectedPassword");
|
||||||
|
|
||||||
|
b1.Property<string>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPricingSettings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PricingSettings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
PricePerConfigPerQuarter = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
PricePerConfigPerYear = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_PricingSettings", x => x.Id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PricingSettings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-6
@@ -513,6 +513,26 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("Nodes", (string)null);
|
b.ToTable("Nodes", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingSettings", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int?>("PricePerConfigPerQuarter")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("PricePerConfigPerYear")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("PricingSettings", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b =>
|
modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -708,12 +728,6 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
|
|||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("character varying(256)");
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
b.Property<int?>("PricePerConfigPerQuarter")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<int?>("PricePerConfigPerYear")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("NormalizedName")
|
b.HasIndex("NormalizedName")
|
||||||
|
|||||||
@@ -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<PricingSettingsSeeder> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
@@ -23,6 +23,8 @@ public class FactoryResetCommandHandlerTests
|
|||||||
Substitute.For<IClientAppCatalogSeeder>();
|
Substitute.For<IClientAppCatalogSeeder>();
|
||||||
private readonly IInstructionIntroSeeder _instructionIntroSeeder =
|
private readonly IInstructionIntroSeeder _instructionIntroSeeder =
|
||||||
Substitute.For<IInstructionIntroSeeder>();
|
Substitute.For<IInstructionIntroSeeder>();
|
||||||
|
private readonly IPricingSettingsSeeder _pricingSettingsSeeder =
|
||||||
|
Substitute.For<IPricingSettingsSeeder>();
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_WipesEverythingExceptCurrentAdminAndSystemRoles()
|
public async Task Handle_WipesEverythingExceptCurrentAdminAndSystemRoles()
|
||||||
@@ -104,6 +106,7 @@ public class FactoryResetCommandHandlerTests
|
|||||||
_fileStorage,
|
_fileStorage,
|
||||||
_catalogSeeder,
|
_catalogSeeder,
|
||||||
_instructionIntroSeeder,
|
_instructionIntroSeeder,
|
||||||
|
_pricingSettingsSeeder,
|
||||||
currentUser
|
currentUser
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -144,5 +147,6 @@ public class FactoryResetCommandHandlerTests
|
|||||||
await _fileStorage.Received(1).DeleteAsync("stored-name", Arg.Any<CancellationToken>());
|
await _fileStorage.Received(1).DeleteAsync("stored-name", Arg.Any<CancellationToken>());
|
||||||
await _catalogSeeder.Received(1).SeedIfEmptyAsync(Arg.Any<CancellationToken>());
|
await _catalogSeeder.Received(1).SeedIfEmptyAsync(Arg.Any<CancellationToken>());
|
||||||
await _instructionIntroSeeder.Received(1).SeedIfEmptyAsync(Arg.Any<CancellationToken>());
|
await _instructionIntroSeeder.Received(1).SeedIfEmptyAsync(Arg.Any<CancellationToken>());
|
||||||
|
await _pricingSettingsSeeder.Received(1).SeedIfEmptyAsync(Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-4
@@ -27,7 +27,7 @@ public class ApproveRoleRequestCommandHandlerTests
|
|||||||
|
|
||||||
var newRoleId = Guid.NewGuid();
|
var newRoleId = Guid.NewGuid();
|
||||||
_roleService
|
_roleService
|
||||||
.CreateRoleAsync("premium", 10, 5, null, null, Arg.Any<CancellationToken>())
|
.CreateRoleAsync("premium", 10, 5, Arg.Any<CancellationToken>())
|
||||||
.Returns(Result.Success(new RoleDto(newRoleId, "premium", 10, 5, false)));
|
.Returns(Result.Success(new RoleDto(newRoleId, "premium", 10, 5, false)));
|
||||||
_roleService
|
_roleService
|
||||||
.ChangeUserRoleAsync(userId, newRoleId, Arg.Any<CancellationToken>())
|
.ChangeUserRoleAsync(userId, newRoleId, Arg.Any<CancellationToken>())
|
||||||
@@ -51,7 +51,7 @@ public class ApproveRoleRequestCommandHandlerTests
|
|||||||
Assert.Equal(TicketStatus.Resolved, ticket.Status);
|
Assert.Equal(TicketStatus.Resolved, ticket.Status);
|
||||||
await _roleService
|
await _roleService
|
||||||
.Received(1)
|
.Received(1)
|
||||||
.CreateRoleAsync("premium", 10, 5, null, null, Arg.Any<CancellationToken>());
|
.CreateRoleAsync("premium", 10, 5, Arg.Any<CancellationToken>());
|
||||||
await _roleService
|
await _roleService
|
||||||
.Received(1)
|
.Received(1)
|
||||||
.ChangeUserRoleAsync(userId, newRoleId, Arg.Any<CancellationToken>());
|
.ChangeUserRoleAsync(userId, newRoleId, Arg.Any<CancellationToken>());
|
||||||
@@ -100,8 +100,6 @@ public class ApproveRoleRequestCommandHandlerTests
|
|||||||
Arg.Any<string>(),
|
Arg.Any<string>(),
|
||||||
Arg.Any<int>(),
|
Arg.Any<int>(),
|
||||||
Arg.Any<int>(),
|
Arg.Any<int>(),
|
||||||
Arg.Any<int?>(),
|
|
||||||
Arg.Any<int?>(),
|
|
||||||
Arg.Any<CancellationToken>()
|
Arg.Any<CancellationToken>()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -244,10 +244,12 @@ reject/approve владением тикета не ограничены. Еди
|
|||||||
| POST | `/api/admin/activation-requests/{id}/approve` | admin | — | `204 No Content` |
|
| POST | `/api/admin/activation-requests/{id}/approve` | admin | — | `204 No Content` |
|
||||||
| POST | `/api/admin/activation-requests/{id}/reject` | admin | `{ reason? }` | `204 No Content` |
|
| POST | `/api/admin/activation-requests/{id}/reject` | admin | `{ reason? }` | `204 No Content` |
|
||||||
| GET | `/api/admin/roles` | admin | — | `RoleDto[]` |
|
| GET | `/api/admin/roles` | admin | — | `RoleDto[]` |
|
||||||
| POST | `/api/admin/roles` | admin | `{ name, maxConfigs, maxIpLimit, pricePerConfigPerQuarter?, pricePerConfigPerYear? }` | `RoleDto` |
|
| POST | `/api/admin/roles` | admin | `{ name, maxConfigs, maxIpLimit }` | `RoleDto` |
|
||||||
| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit, pricePerConfigPerQuarter?, pricePerConfigPerYear? }` | `RoleDto` (для `admin` цена игнорируется, остаётся `null`) |
|
| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit }` | `RoleDto` |
|
||||||
| DELETE | `/api/admin/roles/{id}` | admin | — | `204 No Content` (системные `admin`/`user` удалить нельзя) |
|
| 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`, новая роль другая, и это единственный админ) |
|
| 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`.
|
approve/reject над `ActivationRequest`.
|
||||||
|
|||||||
+25
-13
@@ -5,9 +5,9 @@
|
|||||||
`IdentityRole<Guid>`); чистый `PnvPanel.Domain` ссылается на пользователя/роль только по `Guid`.
|
`IdentityRole<Guid>`); чистый `PnvPanel.Domain` ссылается на пользователя/роль только по `Guid`.
|
||||||
|
|
||||||
Тарифы `Plan` и лимиты трафика на конфиг (`TrafficLimit`) не реализованы — единственная квота:
|
Тарифы `Plan` и лимиты трафика на конфиг (`TrafficLimit`) не реализованы — единственная квота:
|
||||||
число активных конфигов на роль (`AppRole.MaxConfigs`). У роли есть справочная цена за конфиг
|
число активных конфигов на роль (`AppRole.MaxConfigs`). Есть глобальная справочная цена за один
|
||||||
(`AppRole.PricePerConfigPer*`, видна только админу) — это не биллинг: без статусов оплаты, дат
|
конфиг (`PricingSettings`, видна только админу) — это не биллинг: без статусов оплаты, дат окончания
|
||||||
окончания и интеграций с платёжными системами, см. ниже.
|
и интеграций с платёжными системами, см. ниже.
|
||||||
|
|
||||||
## Диаграмма связей
|
## Диаграмма связей
|
||||||
|
|
||||||
@@ -280,16 +280,6 @@ UI **настойчиво напоминает** привязать его (ед
|
|||||||
| `MaxConfigs` | `int` | Квота активных конфигов (-1 = без лимита; для `admin` — без лимита) |
|
| `MaxConfigs` | `int` | Квота активных конфигов (-1 = без лимита; для `admin` — без лимита) |
|
||||||
| `MaxIpLimit` | `int` | Лимит одновременных IP на клиента (`limitIp` в 3x-ui; -1 = без лимита; для `admin` — без лимита) |
|
| `MaxIpLimit` | `int` | Лимит одновременных IP на клиента (`limitIp` в 3x-ui; -1 = без лимита; для `admin` — без лимита) |
|
||||||
| `IsSystem` | `bool` | Системная (`admin`, `user`) — нельзя удалить/переименовать |
|
| `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`,
|
Сидируются: `admin` (оба лимита без ограничения) и `user` (`MaxConfigs` = `Roles__DefaultUserMaxConfigs`,
|
||||||
по умолчанию 3; `MaxIpLimit` = `Roles__DefaultUserMaxIpLimit`, по умолчанию 2).
|
по умолчанию 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 — запрос активации
|
### ActivationRequest — запрос активации
|
||||||
Пользователь просит активацию у админа; админ одобряет/отклоняет на сайте или в Telegram.
|
Пользователь просит активацию у админа; админ одобряет/отклоняет на сайте или в Telegram.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -75,7 +75,7 @@
|
|||||||
| -------------------------- | --------------------------------------------------------------------------------- |
|
| -------------------------- | --------------------------------------------------------------------------------- |
|
||||||
| Ролей у пользователя | Ровно одна роль (квота = `MaxConfigs` роли) |
|
| Ролей у пользователя | Ровно одна роль (квота = `MaxConfigs` роли) |
|
||||||
| Секреты нод | ASP.NET Core Data Protection (шифрование at-rest, key-ring на томе) |
|
| Секреты нод | ASP.NET Core Data Protection (шифрование at-rest, key-ring на томе) |
|
||||||
| Тарифы/лимиты трафика | Не реализованы — конфиги без лимитов трафика/срока. У роли есть справочная цена за конфиг (`AppRole.PricePerConfigPer*`, видна только админу) — без биллинг-логики |
|
| Тарифы/лимиты трафика | Не реализованы — конфиги без лимитов трафика/срока. Есть глобальная справочная цена за конфиг (`PricingSettings`, видна только админу) — без биллинг-логики |
|
||||||
| i18n | RU + EN (react-i18next) |
|
| i18n | RU + EN (react-i18next) |
|
||||||
| Telegram-транспорт | Long polling |
|
| Telegram-транспорт | Long polling |
|
||||||
| Регистрация через Telegram | Поддержана (логин — Telegram `@username`/id, пароль генерируется и присылается в чат) |
|
| Регистрация через Telegram | Поддержана (логин — Telegram `@username`/id, пароль генерируется и присылается в чат) |
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
mutation.mutate()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="pricePerConfigPerQuarter">{t('admin.pricing.pricePerConfigPerQuarter')}</Label>
|
||||||
|
<Input
|
||||||
|
id="pricePerConfigPerQuarter"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={pricePerConfigPerQuarter}
|
||||||
|
onChange={(e) => setPricePerConfigPerQuarter(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.pricing.pricePerConfigPerQuarterHint')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="pricePerConfigPerYear">{t('admin.pricing.pricePerConfigPerYear')}</Label>
|
||||||
|
<Input
|
||||||
|
id="pricePerConfigPerYear"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={pricePerConfigPerYear}
|
||||||
|
onChange={(e) => setPricePerConfigPerYear(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.pricing.pricePerConfigPerYearHint')}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{t('admin.roles.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { apiRequest } from '@/shared/api/client'
|
||||||
|
import type { PricingSettingsDto } from '@/shared/api/types'
|
||||||
|
|
||||||
|
export function getPricingSettings() {
|
||||||
|
return apiRequest<PricingSettingsDto>('/admin/pricing')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePricingSettings(pricePerConfigPerQuarter: number | null, pricePerConfigPerYear: number | null) {
|
||||||
|
return apiRequest<PricingSettingsDto>('/admin/pricing', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { pricePerConfigPerQuarter, pricePerConfigPerYear },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -24,38 +24,17 @@ export function RoleFormDialog({
|
|||||||
const [name, setName] = useState(role?.name ?? '')
|
const [name, setName] = useState(role?.name ?? '')
|
||||||
const [maxConfigs, setMaxConfigs] = useState(String(role?.maxConfigs ?? 3))
|
const [maxConfigs, setMaxConfigs] = useState(String(role?.maxConfigs ?? 3))
|
||||||
const [maxIpLimit, setMaxIpLimit] = useState(String(role?.maxIpLimit ?? 2))
|
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 [internalOpen, setInternalOpen] = useState(false)
|
||||||
|
|
||||||
const isControlled = open !== undefined
|
const isControlled = open !== undefined
|
||||||
const dialogOpen = isControlled ? open : internalOpen
|
const dialogOpen = isControlled ? open : internalOpen
|
||||||
const setDialogOpen = isControlled ? onOpenChange! : setInternalOpen
|
const setDialogOpen = isControlled ? onOpenChange! : setInternalOpen
|
||||||
|
|
||||||
// Роль admin оплаты не имеет — поля цены для неё не показываются и не отправляются.
|
|
||||||
const isAdmin = role?.name === 'admin'
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
role
|
role
|
||||||
? updateRole(
|
? updateRole(role.id, Number(maxConfigs), Number(maxIpLimit))
|
||||||
role.id,
|
: createRole(name.trim(), Number(maxConfigs), Number(maxIpLimit)),
|
||||||
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),
|
|
||||||
),
|
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
toast.success(role ? t('admin.roles.updated') : t('admin.roles.created'))
|
toast.success(role ? t('admin.roles.updated') : t('admin.roles.created'))
|
||||||
await queryClient.invalidateQueries({ queryKey: ['admin-roles'] })
|
await queryClient.invalidateQueries({ queryKey: ['admin-roles'] })
|
||||||
@@ -63,17 +42,10 @@ export function RoleFormDialog({
|
|||||||
setName('')
|
setName('')
|
||||||
setMaxConfigs('3')
|
setMaxConfigs('3')
|
||||||
setMaxIpLimit('2')
|
setMaxIpLimit('2')
|
||||||
setPricePerConfigPerQuarter('')
|
|
||||||
setPricePerConfigPerYear('')
|
|
||||||
},
|
},
|
||||||
onError: () => toast.error(t('auth.genericError')),
|
onError: () => toast.error(t('auth.genericError')),
|
||||||
})
|
})
|
||||||
|
|
||||||
const totalFor = (price: string) => {
|
|
||||||
if (price === '' || Number(maxConfigs) < 0) return null
|
|
||||||
return Number(price) * Number(maxConfigs)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
{!isControlled && (
|
{!isControlled && (
|
||||||
@@ -108,40 +80,6 @@ export function RoleFormDialog({
|
|||||||
<Input id="maxIpLimit" type="number" value={maxIpLimit} onChange={(e) => setMaxIpLimit(e.target.value)} />
|
<Input id="maxIpLimit" type="number" value={maxIpLimit} onChange={(e) => setMaxIpLimit(e.target.value)} />
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.roles.maxIpLimitHint')}</p>
|
<p className="text-xs text-muted-foreground">{t('admin.roles.maxIpLimitHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
{!isAdmin && (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="pricePerConfigPerQuarter">{t('admin.roles.pricePerConfigPerQuarter')}</Label>
|
|
||||||
<Input
|
|
||||||
id="pricePerConfigPerQuarter"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={pricePerConfigPerQuarter}
|
|
||||||
onChange={(e) => setPricePerConfigPerQuarter(e.target.value)}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t('admin.roles.pricePerConfigPerQuarterHint')}
|
|
||||||
{totalFor(pricePerConfigPerQuarter) !== null &&
|
|
||||||
` ${t('admin.roles.totalValue', { total: totalFor(pricePerConfigPerQuarter) })}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="pricePerConfigPerYear">{t('admin.roles.pricePerConfigPerYear')}</Label>
|
|
||||||
<Input
|
|
||||||
id="pricePerConfigPerYear"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={pricePerConfigPerYear}
|
|
||||||
onChange={(e) => setPricePerConfigPerYear(e.target.value)}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t('admin.roles.pricePerConfigPerYearHint')}
|
|
||||||
{totalFor(pricePerConfigPerYear) !== null &&
|
|
||||||
` ${t('admin.roles.totalValue', { total: totalFor(pricePerConfigPerYear) })}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Button type="submit" disabled={mutation.isPending || (!role && !name.trim())}>
|
<Button type="submit" disabled={mutation.isPending || (!role && !name.trim())}>
|
||||||
{role ? t('admin.roles.save') : t('admin.roles.create')}
|
{role ? t('admin.roles.save') : t('admin.roles.create')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -5,30 +5,12 @@ export function listRoles() {
|
|||||||
return apiRequest<RoleDto[]>('/admin/roles')
|
return apiRequest<RoleDto[]>('/admin/roles')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRole(
|
export function createRole(name: string, maxConfigs: number, maxIpLimit: number) {
|
||||||
name: string,
|
return apiRequest<RoleDto>('/admin/roles', { method: 'POST', body: { name, maxConfigs, maxIpLimit } })
|
||||||
maxConfigs: number,
|
|
||||||
maxIpLimit: number,
|
|
||||||
pricePerConfigPerQuarter: number | null,
|
|
||||||
pricePerConfigPerYear: number | null,
|
|
||||||
) {
|
|
||||||
return apiRequest<RoleDto>('/admin/roles', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { name, maxConfigs, maxIpLimit, pricePerConfigPerQuarter, pricePerConfigPerYear },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateRole(
|
export function updateRole(id: string, maxConfigs: number, maxIpLimit: number) {
|
||||||
id: string,
|
return apiRequest<RoleDto>(`/admin/roles/${id}`, { method: 'PUT', body: { maxConfigs, maxIpLimit } })
|
||||||
maxConfigs: number,
|
|
||||||
maxIpLimit: number,
|
|
||||||
pricePerConfigPerQuarter: number | null,
|
|
||||||
pricePerConfigPerYear: number | null,
|
|
||||||
) {
|
|
||||||
return apiRequest<RoleDto>(`/admin/roles/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: { maxConfigs, maxIpLimit, pricePerConfigPerQuarter, pricePerConfigPerYear },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteRole(id: string) {
|
export function deleteRole(id: string) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { apiRequest, apiUpload, getAccessToken } from '@/shared/api/client'
|
import { apiRequest, apiUpload, getAccessToken } from '@/shared/api/client'
|
||||||
import type {
|
import type {
|
||||||
PagedList,
|
PagedList,
|
||||||
SelectableRoleDto,
|
RoleDto,
|
||||||
TicketCommentDto,
|
TicketCommentDto,
|
||||||
TicketDetailDto,
|
TicketDetailDto,
|
||||||
TicketStatus,
|
TicketStatus,
|
||||||
@@ -25,7 +25,7 @@ export function getTicket(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function listSelectableRoles() {
|
export function listSelectableRoles() {
|
||||||
return apiRequest<SelectableRoleDto[]>('/support/roles')
|
return apiRequest<RoleDto[]>('/support/roles')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBugReportTicket(message: string, files: File[]) {
|
export function createBugReportTicket(message: string, files: File[]) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { Route as AdminIndexRouteImport } from './routes/admin/index'
|
|||||||
import { Route as AdminUsersRouteImport } from './routes/admin/users'
|
import { Route as AdminUsersRouteImport } from './routes/admin/users'
|
||||||
import { Route as AdminSupportRouteImport } from './routes/admin/support'
|
import { Route as AdminSupportRouteImport } from './routes/admin/support'
|
||||||
import { Route as AdminRolesRouteImport } from './routes/admin/roles'
|
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 AdminNodesRouteImport } from './routes/admin/nodes'
|
||||||
import { Route as AdminNewsRouteImport } from './routes/admin/news'
|
import { Route as AdminNewsRouteImport } from './routes/admin/news'
|
||||||
import { Route as AdminMaintenanceRouteImport } from './routes/admin/maintenance'
|
import { Route as AdminMaintenanceRouteImport } from './routes/admin/maintenance'
|
||||||
@@ -96,6 +97,11 @@ const AdminRolesRoute = AdminRolesRouteImport.update({
|
|||||||
path: '/roles',
|
path: '/roles',
|
||||||
getParentRoute: () => AdminRoute,
|
getParentRoute: () => AdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AdminPricingRoute = AdminPricingRouteImport.update({
|
||||||
|
id: '/pricing',
|
||||||
|
path: '/pricing',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
const AdminNodesRoute = AdminNodesRouteImport.update({
|
const AdminNodesRoute = AdminNodesRouteImport.update({
|
||||||
id: '/nodes',
|
id: '/nodes',
|
||||||
path: '/nodes',
|
path: '/nodes',
|
||||||
@@ -155,6 +161,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/admin/maintenance': typeof AdminMaintenanceRoute
|
'/admin/maintenance': typeof AdminMaintenanceRoute
|
||||||
'/admin/news': typeof AdminNewsRoute
|
'/admin/news': typeof AdminNewsRoute
|
||||||
'/admin/nodes': typeof AdminNodesRoute
|
'/admin/nodes': typeof AdminNodesRoute
|
||||||
|
'/admin/pricing': typeof AdminPricingRoute
|
||||||
'/admin/roles': typeof AdminRolesRoute
|
'/admin/roles': typeof AdminRolesRoute
|
||||||
'/admin/support': typeof AdminSupportRoute
|
'/admin/support': typeof AdminSupportRoute
|
||||||
'/admin/users': typeof AdminUsersRoute
|
'/admin/users': typeof AdminUsersRoute
|
||||||
@@ -177,6 +184,7 @@ export interface FileRoutesByTo {
|
|||||||
'/admin/maintenance': typeof AdminMaintenanceRoute
|
'/admin/maintenance': typeof AdminMaintenanceRoute
|
||||||
'/admin/news': typeof AdminNewsRoute
|
'/admin/news': typeof AdminNewsRoute
|
||||||
'/admin/nodes': typeof AdminNodesRoute
|
'/admin/nodes': typeof AdminNodesRoute
|
||||||
|
'/admin/pricing': typeof AdminPricingRoute
|
||||||
'/admin/roles': typeof AdminRolesRoute
|
'/admin/roles': typeof AdminRolesRoute
|
||||||
'/admin/support': typeof AdminSupportRoute
|
'/admin/support': typeof AdminSupportRoute
|
||||||
'/admin/users': typeof AdminUsersRoute
|
'/admin/users': typeof AdminUsersRoute
|
||||||
@@ -201,6 +209,7 @@ export interface FileRoutesById {
|
|||||||
'/admin/maintenance': typeof AdminMaintenanceRoute
|
'/admin/maintenance': typeof AdminMaintenanceRoute
|
||||||
'/admin/news': typeof AdminNewsRoute
|
'/admin/news': typeof AdminNewsRoute
|
||||||
'/admin/nodes': typeof AdminNodesRoute
|
'/admin/nodes': typeof AdminNodesRoute
|
||||||
|
'/admin/pricing': typeof AdminPricingRoute
|
||||||
'/admin/roles': typeof AdminRolesRoute
|
'/admin/roles': typeof AdminRolesRoute
|
||||||
'/admin/support': typeof AdminSupportRoute
|
'/admin/support': typeof AdminSupportRoute
|
||||||
'/admin/users': typeof AdminUsersRoute
|
'/admin/users': typeof AdminUsersRoute
|
||||||
@@ -226,6 +235,7 @@ export interface FileRouteTypes {
|
|||||||
| '/admin/maintenance'
|
| '/admin/maintenance'
|
||||||
| '/admin/news'
|
| '/admin/news'
|
||||||
| '/admin/nodes'
|
| '/admin/nodes'
|
||||||
|
| '/admin/pricing'
|
||||||
| '/admin/roles'
|
| '/admin/roles'
|
||||||
| '/admin/support'
|
| '/admin/support'
|
||||||
| '/admin/users'
|
| '/admin/users'
|
||||||
@@ -248,6 +258,7 @@ export interface FileRouteTypes {
|
|||||||
| '/admin/maintenance'
|
| '/admin/maintenance'
|
||||||
| '/admin/news'
|
| '/admin/news'
|
||||||
| '/admin/nodes'
|
| '/admin/nodes'
|
||||||
|
| '/admin/pricing'
|
||||||
| '/admin/roles'
|
| '/admin/roles'
|
||||||
| '/admin/support'
|
| '/admin/support'
|
||||||
| '/admin/users'
|
| '/admin/users'
|
||||||
@@ -271,6 +282,7 @@ export interface FileRouteTypes {
|
|||||||
| '/admin/maintenance'
|
| '/admin/maintenance'
|
||||||
| '/admin/news'
|
| '/admin/news'
|
||||||
| '/admin/nodes'
|
| '/admin/nodes'
|
||||||
|
| '/admin/pricing'
|
||||||
| '/admin/roles'
|
| '/admin/roles'
|
||||||
| '/admin/support'
|
| '/admin/support'
|
||||||
| '/admin/users'
|
| '/admin/users'
|
||||||
@@ -382,6 +394,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AdminRolesRouteImport
|
preLoaderRoute: typeof AdminRolesRouteImport
|
||||||
parentRoute: typeof AdminRoute
|
parentRoute: typeof AdminRoute
|
||||||
}
|
}
|
||||||
|
'/admin/pricing': {
|
||||||
|
id: '/admin/pricing'
|
||||||
|
path: '/pricing'
|
||||||
|
fullPath: '/admin/pricing'
|
||||||
|
preLoaderRoute: typeof AdminPricingRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
'/admin/nodes': {
|
'/admin/nodes': {
|
||||||
id: '/admin/nodes'
|
id: '/admin/nodes'
|
||||||
path: '/nodes'
|
path: '/nodes'
|
||||||
@@ -450,6 +469,7 @@ interface AdminRouteChildren {
|
|||||||
AdminMaintenanceRoute: typeof AdminMaintenanceRoute
|
AdminMaintenanceRoute: typeof AdminMaintenanceRoute
|
||||||
AdminNewsRoute: typeof AdminNewsRoute
|
AdminNewsRoute: typeof AdminNewsRoute
|
||||||
AdminNodesRoute: typeof AdminNodesRoute
|
AdminNodesRoute: typeof AdminNodesRoute
|
||||||
|
AdminPricingRoute: typeof AdminPricingRoute
|
||||||
AdminRolesRoute: typeof AdminRolesRoute
|
AdminRolesRoute: typeof AdminRolesRoute
|
||||||
AdminSupportRoute: typeof AdminSupportRoute
|
AdminSupportRoute: typeof AdminSupportRoute
|
||||||
AdminUsersRoute: typeof AdminUsersRoute
|
AdminUsersRoute: typeof AdminUsersRoute
|
||||||
@@ -465,6 +485,7 @@ const AdminRouteChildren: AdminRouteChildren = {
|
|||||||
AdminMaintenanceRoute: AdminMaintenanceRoute,
|
AdminMaintenanceRoute: AdminMaintenanceRoute,
|
||||||
AdminNewsRoute: AdminNewsRoute,
|
AdminNewsRoute: AdminNewsRoute,
|
||||||
AdminNodesRoute: AdminNodesRoute,
|
AdminNodesRoute: AdminNodesRoute,
|
||||||
|
AdminPricingRoute: AdminPricingRoute,
|
||||||
AdminRolesRoute: AdminRolesRoute,
|
AdminRolesRoute: AdminRolesRoute,
|
||||||
AdminSupportRoute: AdminSupportRoute,
|
AdminSupportRoute: AdminSupportRoute,
|
||||||
AdminUsersRoute: AdminUsersRoute,
|
AdminUsersRoute: AdminUsersRoute,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const TABS = [
|
|||||||
{ to: '/admin/users', key: 'users' },
|
{ to: '/admin/users', key: 'users' },
|
||||||
{ to: '/admin/configs', key: 'configs' },
|
{ to: '/admin/configs', key: 'configs' },
|
||||||
{ to: '/admin/roles', key: 'roles' },
|
{ to: '/admin/roles', key: 'roles' },
|
||||||
|
{ to: '/admin/pricing', key: 'pricing' },
|
||||||
{ to: '/admin/nodes', key: 'nodes' },
|
{ to: '/admin/nodes', key: 'nodes' },
|
||||||
{ to: '/admin/apps', key: 'apps' },
|
{ to: '/admin/apps', key: 'apps' },
|
||||||
{ to: '/admin/instructions', key: 'instructions' },
|
{ to: '/admin/instructions', key: 'instructions' },
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t('admin.pricing.title')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading && <p className="text-sm text-muted-foreground">…</p>}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void refetch()}>
|
||||||
|
{t('activation.retry')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && <PricingSettingsEditor settings={data} />}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Button } from '@/shared/ui/button'
|
|||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
import { listRoles, deleteRole } from '@/features/admin/roles/api'
|
import { listRoles, deleteRole } from '@/features/admin/roles/api'
|
||||||
import { RoleFormDialog } from '@/features/admin/roles/RoleFormDialog'
|
import { RoleFormDialog } from '@/features/admin/roles/RoleFormDialog'
|
||||||
|
import { getPricingSettings } from '@/features/admin/pricing/api'
|
||||||
import type { RoleDto } from '@/shared/api/types'
|
import type { RoleDto } from '@/shared/api/types'
|
||||||
|
|
||||||
export const Route = createFileRoute('/admin/roles')({ component: AdminRolesPage })
|
export const Route = createFileRoute('/admin/roles')({ component: AdminRolesPage })
|
||||||
@@ -17,8 +18,9 @@ function AdminRolesPage() {
|
|||||||
const [editing, setEditing] = useState<RoleDto | null>(null)
|
const [editing, setEditing] = useState<RoleDto | null>(null)
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles })
|
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} ₽`
|
price == null || maxConfigs < 0 ? t('admin.roles.noPrice') : `${price * maxConfigs} ₽`
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
@@ -69,8 +71,8 @@ function AdminRolesPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-2">{role.maxConfigs < 0 ? t('unlimited') : role.maxConfigs}</td>
|
<td className="py-2">{role.maxConfigs < 0 ? t('unlimited') : role.maxConfigs}</td>
|
||||||
<td className="py-2">{role.maxIpLimit < 0 ? t('unlimited') : role.maxIpLimit}</td>
|
<td className="py-2">{role.maxIpLimit < 0 ? t('unlimited') : role.maxIpLimit}</td>
|
||||||
<td className="py-2">{totalPrice(role.pricePerConfigPerQuarter, role.maxConfigs)}</td>
|
<td className="py-2">{totalPrice(pricing?.pricePerConfigPerQuarter, role.maxConfigs)}</td>
|
||||||
<td className="py-2">{totalPrice(role.pricePerConfigPerYear, role.maxConfigs)}</td>
|
<td className="py-2">{totalPrice(pricing?.pricePerConfigPerYear, role.maxConfigs)}</td>
|
||||||
<td className="py-2 text-right">
|
<td className="py-2 text-right">
|
||||||
<Button size="sm" variant="outline" onClick={() => setEditing(role)}>
|
<Button size="sm" variant="outline" onClick={() => setEditing(role)}>
|
||||||
{t('admin.roles.edit')}
|
{t('admin.roles.edit')}
|
||||||
|
|||||||
@@ -2086,6 +2086,64 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/admin/pricing": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PricingSettingsDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
put: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UpdatePricingSettingsCommand"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PricingSettingsDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/support/roles": {
|
"/api/support/roles": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2108,7 +2166,7 @@ export interface paths {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["SelectableRoleDto"][];
|
"application/json": components["schemas"]["RoleDto"][];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -3087,10 +3145,6 @@ export interface components {
|
|||||||
maxConfigs: number | string;
|
maxConfigs: number | string;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
maxIpLimit: number | string;
|
maxIpLimit: number | string;
|
||||||
/** Format: int32 */
|
|
||||||
pricePerConfigPerQuarter: null | number | string;
|
|
||||||
/** Format: int32 */
|
|
||||||
pricePerConfigPerYear: null | number | string;
|
|
||||||
};
|
};
|
||||||
CreateRoleRequestBody: {
|
CreateRoleRequestBody: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -3257,6 +3311,12 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
pageSize: number | string;
|
pageSize: number | string;
|
||||||
};
|
};
|
||||||
|
PricingSettingsDto: {
|
||||||
|
/** Format: int32 */
|
||||||
|
pricePerConfigPerQuarter: null | number | string;
|
||||||
|
/** Format: int32 */
|
||||||
|
pricePerConfigPerYear: null | number | string;
|
||||||
|
};
|
||||||
PublishInboundBody: {
|
PublishInboundBody: {
|
||||||
isPublished: boolean;
|
isPublished: boolean;
|
||||||
displayName: null | string;
|
displayName: null | string;
|
||||||
@@ -3299,19 +3359,6 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
maxIpLimit: number | string;
|
maxIpLimit: number | string;
|
||||||
isSystem: boolean;
|
isSystem: boolean;
|
||||||
/** Format: int32 */
|
|
||||||
pricePerConfigPerQuarter?: null | number | string;
|
|
||||||
/** Format: int32 */
|
|
||||||
pricePerConfigPerYear?: null | number | string;
|
|
||||||
};
|
|
||||||
SelectableRoleDto: {
|
|
||||||
/** Format: uuid */
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
/** Format: int32 */
|
|
||||||
maxConfigs: number | string;
|
|
||||||
/** Format: int32 */
|
|
||||||
maxIpLimit: number | string;
|
|
||||||
};
|
};
|
||||||
StatsDto: {
|
StatsDto: {
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
@@ -3442,15 +3489,17 @@ export interface components {
|
|||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
};
|
};
|
||||||
|
UpdatePricingSettingsCommand: {
|
||||||
|
/** Format: int32 */
|
||||||
|
pricePerConfigPerQuarter: null | number | string;
|
||||||
|
/** Format: int32 */
|
||||||
|
pricePerConfigPerYear: null | number | string;
|
||||||
|
};
|
||||||
UpdateRoleBody: {
|
UpdateRoleBody: {
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
maxConfigs: number | string;
|
maxConfigs: number | string;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
maxIpLimit: number | string;
|
maxIpLimit: number | string;
|
||||||
/** Format: int32 */
|
|
||||||
pricePerConfigPerQuarter: null | number | string;
|
|
||||||
/** Format: int32 */
|
|
||||||
pricePerConfigPerYear: null | number | string;
|
|
||||||
};
|
};
|
||||||
UserSummaryDto: {
|
UserSummaryDto: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
|
|||||||
@@ -173,16 +173,12 @@ export type RoleDto = {
|
|||||||
maxConfigs: number
|
maxConfigs: number
|
||||||
maxIpLimit: number
|
maxIpLimit: number
|
||||||
isSystem: boolean
|
isSystem: boolean
|
||||||
pricePerConfigPerQuarter: number | null
|
|
||||||
pricePerConfigPerYear: number | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Роль для выбора в заявке на роль (GET /support/roles) — без цены, она видна только админу. */
|
/** Глобальная справочная цена за конфиг (видна только админу) — одна на весь сервис, не per-роль. */
|
||||||
export type SelectableRoleDto = {
|
export type PricingSettingsDto = {
|
||||||
id: string
|
pricePerConfigPerQuarter: number | null
|
||||||
name: string
|
pricePerConfigPerYear: number | null
|
||||||
maxConfigs: number
|
|
||||||
maxIpLimit: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ActivationRequestAdminDto = {
|
export type ActivationRequestAdminDto = {
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ const resources = {
|
|||||||
users: 'Пользователи',
|
users: 'Пользователи',
|
||||||
configs: 'Все конфиги',
|
configs: 'Все конфиги',
|
||||||
roles: 'Роли',
|
roles: 'Роли',
|
||||||
|
pricing: 'Цены',
|
||||||
nodes: 'Ноды',
|
nodes: 'Ноды',
|
||||||
apps: 'Приложения',
|
apps: 'Приложения',
|
||||||
instructions: 'Инструкции',
|
instructions: 'Инструкции',
|
||||||
@@ -253,13 +254,8 @@ const resources = {
|
|||||||
maxConfigsHint: '−1 = без лимита.',
|
maxConfigsHint: '−1 = без лимита.',
|
||||||
maxIpLimit: 'Лимит IP на конфиг',
|
maxIpLimit: 'Лимит IP на конфиг',
|
||||||
maxIpLimitHint: '−1 = без лимита. Применяется только к новым конфигам.',
|
maxIpLimitHint: '−1 = без лимита. Применяется только к новым конфигам.',
|
||||||
pricePerConfigPerQuarter: 'Цена за конфиг / 3 мес',
|
|
||||||
pricePerConfigPerQuarterHint: 'Справочно, видно только админу. Минимальный период оплаты.',
|
|
||||||
pricePerConfigPerYear: 'Цена за конфиг / год',
|
|
||||||
pricePerConfigPerYearHint: 'Справочно, видно только админу. Задаётся отдельно от квартальной цены.',
|
|
||||||
totalPerQuarter: 'Итого / 3 мес',
|
totalPerQuarter: 'Итого / 3 мес',
|
||||||
totalPerYear: 'Итого / год',
|
totalPerYear: 'Итого / год',
|
||||||
totalValue: 'Итого для роли: {{total}} ₽.',
|
|
||||||
noPrice: '—',
|
noPrice: '—',
|
||||||
system: 'системная',
|
system: 'системная',
|
||||||
edit: 'Изменить',
|
edit: 'Изменить',
|
||||||
@@ -270,6 +266,14 @@ const resources = {
|
|||||||
updated: 'Квота обновлена.',
|
updated: 'Квота обновлена.',
|
||||||
deleted: 'Роль удалена.',
|
deleted: 'Роль удалена.',
|
||||||
},
|
},
|
||||||
|
pricing: {
|
||||||
|
title: 'Справочная цена конфига',
|
||||||
|
pricePerConfigPerQuarter: 'Цена за конфиг / 3 мес',
|
||||||
|
pricePerConfigPerQuarterHint: 'Минимальный период оплаты. Видно только админу.',
|
||||||
|
pricePerConfigPerYear: 'Цена за конфиг / год',
|
||||||
|
pricePerConfigPerYearHint: 'Задаётся независимо от квартальной цены (можно сделать скидку за год).',
|
||||||
|
updated: 'Цена обновлена.',
|
||||||
|
},
|
||||||
nodes: {
|
nodes: {
|
||||||
create: 'Добавить ноду',
|
create: 'Добавить ноду',
|
||||||
name: 'Название',
|
name: 'Название',
|
||||||
@@ -617,6 +621,7 @@ const resources = {
|
|||||||
users: 'Users',
|
users: 'Users',
|
||||||
configs: 'All configs',
|
configs: 'All configs',
|
||||||
roles: 'Roles',
|
roles: 'Roles',
|
||||||
|
pricing: 'Pricing',
|
||||||
nodes: 'Nodes',
|
nodes: 'Nodes',
|
||||||
apps: 'Apps',
|
apps: 'Apps',
|
||||||
instructions: 'Instructions',
|
instructions: 'Instructions',
|
||||||
@@ -679,13 +684,8 @@ const resources = {
|
|||||||
maxConfigsHint: '−1 = unlimited.',
|
maxConfigsHint: '−1 = unlimited.',
|
||||||
maxIpLimit: 'IP limit per config',
|
maxIpLimit: 'IP limit per config',
|
||||||
maxIpLimitHint: '−1 = unlimited. Applies to new configs only.',
|
maxIpLimitHint: '−1 = unlimited. Applies to new configs only.',
|
||||||
pricePerConfigPerQuarter: 'Price per config / 3 months',
|
|
||||||
pricePerConfigPerQuarterHint: 'Reference only, visible to admin only. Minimum billing period.',
|
|
||||||
pricePerConfigPerYear: 'Price per config / year',
|
|
||||||
pricePerConfigPerYearHint: 'Reference only, visible to admin only. Set independently from the quarterly price.',
|
|
||||||
totalPerQuarter: 'Total / 3 months',
|
totalPerQuarter: 'Total / 3 months',
|
||||||
totalPerYear: 'Total / year',
|
totalPerYear: 'Total / year',
|
||||||
totalValue: 'Total for role: {{total}} ₽.',
|
|
||||||
noPrice: '—',
|
noPrice: '—',
|
||||||
system: 'system',
|
system: 'system',
|
||||||
edit: 'Edit',
|
edit: 'Edit',
|
||||||
@@ -696,6 +696,14 @@ const resources = {
|
|||||||
updated: 'Quota updated.',
|
updated: 'Quota updated.',
|
||||||
deleted: 'Role deleted.',
|
deleted: 'Role deleted.',
|
||||||
},
|
},
|
||||||
|
pricing: {
|
||||||
|
title: 'Reference config price',
|
||||||
|
pricePerConfigPerQuarter: 'Price per config / 3 months',
|
||||||
|
pricePerConfigPerQuarterHint: 'Minimum billing period. Visible to admin only.',
|
||||||
|
pricePerConfigPerYear: 'Price per config / year',
|
||||||
|
pricePerConfigPerYearHint: 'Set independently from the quarterly price (allows an annual discount).',
|
||||||
|
updated: 'Pricing updated.',
|
||||||
|
},
|
||||||
nodes: {
|
nodes: {
|
||||||
create: 'Add node',
|
create: 'Add node',
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
|
|||||||
Reference in New Issue
Block a user