From bef38805939de9bd50ea0b7150eb07604f93706c Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Tue, 14 Jul 2026 22:20:10 +0300 Subject: [PATCH] Add instructions management functionality and update related components - Introduced new endpoints for managing instruction intros and tabs, allowing admins to create, update, and delete instructional content. - Enhanced the FactoryResetCommandHandler to include the seeding of instruction data during a factory reset. - Updated the database schema to include InstructionIntro and InstructionTab entities, with corresponding migrations. - Improved frontend routing and components to support the new instructions section, including a dedicated page for displaying instructions and tabs. - Enhanced API documentation to reflect the new instruction management features and their expected request/response formats. - Added localization support for the new instructions functionality in both Russian and English. --- .../Endpoints/AdminInstructionEndpoints.cs | 68 ++ .../Endpoints/InstructionEndpoints.cs | 32 + backend/src/PnvPanel.Api/Program.cs | 2 + .../CreateInstructionTabCommand.cs | 8 + .../CreateInstructionTabCommandHandler.cs | 22 + .../CreateInstructionTabCommandValidator.cs | 13 + .../DeleteInstructionTabCommand.cs | 6 + .../DeleteInstructionTabCommandHandler.cs | 26 + .../Admin/Instructions/InstructionErrors.cs | 11 + .../UpdateInstructionIntroCommand.cs | 8 + .../UpdateInstructionIntroCommandHandler.cs | 31 + .../UpdateInstructionIntroCommandValidator.cs | 12 + .../UpdateInstructionTabCommand.cs | 12 + .../UpdateInstructionTabCommandHandler.cs | 28 + .../UpdateInstructionTabCommandValidator.cs | 13 + .../Maintenance/FactoryResetCommandHandler.cs | 6 +- .../Common/Interfaces/IAppDbContext.cs | 5 + .../Interfaces/IInstructionIntroSeeder.cs | 10 + .../Instructions/GetInstructionIntroQuery.cs | 8 + .../GetInstructionIntroQueryHandler.cs | 27 + .../Instructions/InstructionIntroDto.cs | 9 + .../Instructions/InstructionTabDto.cs | 18 + .../Instructions/ListInstructionTabsQuery.cs | 8 + .../ListInstructionTabsQueryHandler.cs | 31 + .../Instructions/InstructionIntro.cs | 31 + .../Instructions/InstructionTab.cs | 39 + .../DependencyInjection.cs | 2 + .../Identity/DbInitializer.cs | 2 + .../Instructions/InstructionIntroSeeder.cs | 31 + .../Persistence/AppDbContext.cs | 5 + .../InstructionIntroConfiguration.cs | 16 + .../InstructionTabConfiguration.cs | 19 + ...20260714174326_AddInstructions.Designer.cs | 941 ++++++++++++++++++ .../20260714174326_AddInstructions.cs | 59 ++ .../Migrations/AppDbContextModelSnapshot.cs | 51 + .../InstructionTabCommandHandlerTests.cs | 97 ++ ...dateInstructionIntroCommandHandlerTests.cs | 49 + .../FactoryResetCommandHandlerTests.cs | 4 + .../GetInstructionIntroQueryHandlerTests.cs | 37 + .../ListInstructionTabsQueryHandlerTests.cs | 40 + docs/api-design.md | 29 +- docs/domain-model.md | 39 +- .../instructions/InstructionIntroEditor.tsx | 52 + .../instructions/InstructionTabFormDialog.tsx | 100 ++ .../src/features/admin/instructions/api.ts | 24 + frontend/src/features/instructions/api.ts | 10 + frontend/src/routeTree.gen.ts | 21 + frontend/src/routes/admin.tsx | 1 + frontend/src/routes/admin/instructions.tsx | 98 ++ frontend/src/routes/instructions.tsx | 62 +- frontend/src/shared/api/types.ts | 16 + frontend/src/shared/lib/i18n.ts | 38 +- 52 files changed, 2303 insertions(+), 24 deletions(-) create mode 100644 backend/src/PnvPanel.Api/Endpoints/AdminInstructionEndpoints.cs create mode 100644 backend/src/PnvPanel.Api/Endpoints/InstructionEndpoints.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Instructions/CreateInstructionTabCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Instructions/CreateInstructionTabCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Instructions/CreateInstructionTabCommandValidator.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Instructions/DeleteInstructionTabCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Instructions/DeleteInstructionTabCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Instructions/InstructionErrors.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Instructions/UpdateInstructionIntroCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Instructions/UpdateInstructionIntroCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Instructions/UpdateInstructionIntroCommandValidator.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Instructions/UpdateInstructionTabCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Instructions/UpdateInstructionTabCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Instructions/UpdateInstructionTabCommandValidator.cs create mode 100644 backend/src/PnvPanel.Application/Common/Interfaces/IInstructionIntroSeeder.cs create mode 100644 backend/src/PnvPanel.Application/Instructions/GetInstructionIntroQuery.cs create mode 100644 backend/src/PnvPanel.Application/Instructions/GetInstructionIntroQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Instructions/InstructionIntroDto.cs create mode 100644 backend/src/PnvPanel.Application/Instructions/InstructionTabDto.cs create mode 100644 backend/src/PnvPanel.Application/Instructions/ListInstructionTabsQuery.cs create mode 100644 backend/src/PnvPanel.Application/Instructions/ListInstructionTabsQueryHandler.cs create mode 100644 backend/src/PnvPanel.Domain/Instructions/InstructionIntro.cs create mode 100644 backend/src/PnvPanel.Domain/Instructions/InstructionTab.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Instructions/InstructionIntroSeeder.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Configurations/InstructionIntroConfiguration.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Configurations/InstructionTabConfiguration.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714174326_AddInstructions.Designer.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714174326_AddInstructions.cs create mode 100644 backend/tests/PnvPanel.Application.Tests/Admin/Instructions/InstructionTabCommandHandlerTests.cs create mode 100644 backend/tests/PnvPanel.Application.Tests/Admin/Instructions/UpdateInstructionIntroCommandHandlerTests.cs create mode 100644 backend/tests/PnvPanel.Application.Tests/Instructions/GetInstructionIntroQueryHandlerTests.cs create mode 100644 backend/tests/PnvPanel.Application.Tests/Instructions/ListInstructionTabsQueryHandlerTests.cs create mode 100644 frontend/src/features/admin/instructions/InstructionIntroEditor.tsx create mode 100644 frontend/src/features/admin/instructions/InstructionTabFormDialog.tsx create mode 100644 frontend/src/features/admin/instructions/api.ts create mode 100644 frontend/src/features/instructions/api.ts create mode 100644 frontend/src/routes/admin/instructions.tsx diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminInstructionEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminInstructionEndpoints.cs new file mode 100644 index 0000000..9e4ba4a --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/AdminInstructionEndpoints.cs @@ -0,0 +1,68 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Admin.Instructions; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Instructions; +using PnvPanel.Infrastructure.Identity; + +namespace PnvPanel.Api.Endpoints; + +public static class AdminInstructionEndpoints +{ + public static IEndpointRouteBuilder MapAdminInstructionEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/instructions") + .WithTags("Admin.Instructions") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapPut("/intro", UpdateIntro).Produces(); + admin.MapPost("/tabs", CreateTab).Produces(); + admin.MapPut("/tabs/{id:guid}", UpdateTab).Produces(); + admin.MapDelete("/tabs/{id:guid}", DeleteTab).Produces(StatusCodes.Status204NoContent); + + return app; + } + + private static async Task UpdateIntro( + UpdateInstructionIntroCommand command, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task CreateTab( + CreateInstructionTabCommand command, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task UpdateTab( + Guid id, + UpdateInstructionTabBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var command = new UpdateInstructionTabCommand(id, body.Title, body.Body, body.SortOrder); + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task DeleteTab( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new DeleteInstructionTabCommand(id), cancellationToken); + return result.ToHttpResult(); + } +} + +public sealed record UpdateInstructionTabBody(string Title, string Body, int SortOrder); diff --git a/backend/src/PnvPanel.Api/Endpoints/InstructionEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/InstructionEndpoints.cs new file mode 100644 index 0000000..77c0462 --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/InstructionEndpoints.cs @@ -0,0 +1,32 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Instructions; + +namespace PnvPanel.Api.Endpoints; + +public static class InstructionEndpoints +{ + public static IEndpointRouteBuilder MapInstructionEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/instructions") + .WithTags("Instructions") + .RequireAuthorization(); + + group.MapGet("/intro", GetIntro).Produces(); + group.MapGet("/tabs", ListTabs).Produces>(); + + return app; + } + + private static async Task GetIntro(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new GetInstructionIntroQuery(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task ListTabs(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ListInstructionTabsQuery(), cancellationToken); + return result.ToHttpResult(); + } +} diff --git a/backend/src/PnvPanel.Api/Program.cs b/backend/src/PnvPanel.Api/Program.cs index 5148b02..28766d3 100644 --- a/backend/src/PnvPanel.Api/Program.cs +++ b/backend/src/PnvPanel.Api/Program.cs @@ -185,10 +185,12 @@ app.MapConfigEndpoints(); app.MapSubscriptionEndpoints(); app.MapAppEndpoints(); app.MapNewsEndpoints(); +app.MapInstructionEndpoints(); app.MapAdminUserEndpoints(); app.MapAdminStatsEndpoints(); app.MapAdminAppEndpoints(); app.MapAdminNewsEndpoints(); +app.MapAdminInstructionEndpoints(); app.MapSupportEndpoints(); app.MapAdminSupportEndpoints(); app.MapAdminMaintenanceEndpoints(); diff --git a/backend/src/PnvPanel.Application/Admin/Instructions/CreateInstructionTabCommand.cs b/backend/src/PnvPanel.Application/Admin/Instructions/CreateInstructionTabCommand.cs new file mode 100644 index 0000000..246f63b --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Instructions/CreateInstructionTabCommand.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Instructions; + +namespace PnvPanel.Application.Admin.Instructions; + +public sealed record CreateInstructionTabCommand(string Title, string Body, int SortOrder) + : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Instructions/CreateInstructionTabCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Instructions/CreateInstructionTabCommandHandler.cs new file mode 100644 index 0000000..8fbe1be --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Instructions/CreateInstructionTabCommandHandler.cs @@ -0,0 +1,22 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Instructions; +using PnvPanel.Domain.Instructions; + +namespace PnvPanel.Application.Admin.Instructions; + +public sealed class CreateInstructionTabCommandHandler(IAppDbContext dbContext) + : ICommandHandler> +{ + public Task> Handle( + CreateInstructionTabCommand command, + CancellationToken cancellationToken + ) + { + var tab = InstructionTab.Create(command.Title, command.Body, command.SortOrder); + dbContext.InstructionTabs.Add(tab); + + return Task.FromResult(Result.Success(InstructionTabDto.FromDomain(tab))); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Instructions/CreateInstructionTabCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Instructions/CreateInstructionTabCommandValidator.cs new file mode 100644 index 0000000..43833a2 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Instructions/CreateInstructionTabCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.Instructions; + +public sealed class CreateInstructionTabCommandValidator + : AbstractValidator +{ + public CreateInstructionTabCommandValidator() + { + RuleFor(x => x.Title).NotEmpty().MaximumLength(100); + RuleFor(x => x.Body).NotEmpty().MaximumLength(20000); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Instructions/DeleteInstructionTabCommand.cs b/backend/src/PnvPanel.Application/Admin/Instructions/DeleteInstructionTabCommand.cs new file mode 100644 index 0000000..696a525 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Instructions/DeleteInstructionTabCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Instructions; + +public sealed record DeleteInstructionTabCommand(Guid TabId) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Instructions/DeleteInstructionTabCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Instructions/DeleteInstructionTabCommandHandler.cs new file mode 100644 index 0000000..3d2d8b9 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Instructions/DeleteInstructionTabCommandHandler.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Instructions; + +public sealed class DeleteInstructionTabCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + DeleteInstructionTabCommand command, + CancellationToken cancellationToken + ) + { + var tab = await dbContext.InstructionTabs.FirstOrDefaultAsync( + t => t.Id == command.TabId, + cancellationToken + ); + if (tab is null) + return Result.Failure(InstructionErrors.TabNotFound); + + dbContext.InstructionTabs.Remove(tab); + return Result.Success(); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Instructions/InstructionErrors.cs b/backend/src/PnvPanel.Application/Admin/Instructions/InstructionErrors.cs new file mode 100644 index 0000000..ffb7161 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Instructions/InstructionErrors.cs @@ -0,0 +1,11 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Instructions; + +public static class InstructionErrors +{ + public static readonly Error TabNotFound = Error.NotFound( + "Instructions.TabNotFound", + "Вкладка инструкций не найдена." + ); +} diff --git a/backend/src/PnvPanel.Application/Admin/Instructions/UpdateInstructionIntroCommand.cs b/backend/src/PnvPanel.Application/Admin/Instructions/UpdateInstructionIntroCommand.cs new file mode 100644 index 0000000..da4c15d --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Instructions/UpdateInstructionIntroCommand.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Instructions; + +namespace PnvPanel.Application.Admin.Instructions; + +public sealed record UpdateInstructionIntroCommand(string Body) + : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Instructions/UpdateInstructionIntroCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Instructions/UpdateInstructionIntroCommandHandler.cs new file mode 100644 index 0000000..6a86e6f --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Instructions/UpdateInstructionIntroCommandHandler.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Instructions; +using PnvPanel.Domain.Instructions; + +namespace PnvPanel.Application.Admin.Instructions; + +public sealed class UpdateInstructionIntroCommandHandler(IAppDbContext dbContext) + : ICommandHandler> +{ + public async Task> Handle( + UpdateInstructionIntroCommand command, + CancellationToken cancellationToken + ) + { + var intro = await dbContext.InstructionIntros.FirstOrDefaultAsync(cancellationToken); + if (intro is null) + { + intro = InstructionIntro.Create(command.Body); + dbContext.InstructionIntros.Add(intro); + } + else + { + intro.Update(command.Body); + } + + return Result.Success(InstructionIntroDto.FromDomain(intro)); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Instructions/UpdateInstructionIntroCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Instructions/UpdateInstructionIntroCommandValidator.cs new file mode 100644 index 0000000..995bb33 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Instructions/UpdateInstructionIntroCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.Instructions; + +public sealed class UpdateInstructionIntroCommandValidator + : AbstractValidator +{ + public UpdateInstructionIntroCommandValidator() + { + RuleFor(x => x.Body).NotEmpty().MaximumLength(20000); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Instructions/UpdateInstructionTabCommand.cs b/backend/src/PnvPanel.Application/Admin/Instructions/UpdateInstructionTabCommand.cs new file mode 100644 index 0000000..2f06b09 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Instructions/UpdateInstructionTabCommand.cs @@ -0,0 +1,12 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Instructions; + +namespace PnvPanel.Application.Admin.Instructions; + +public sealed record UpdateInstructionTabCommand( + Guid TabId, + string Title, + string Body, + int SortOrder +) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Instructions/UpdateInstructionTabCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Instructions/UpdateInstructionTabCommandHandler.cs new file mode 100644 index 0000000..13952f2 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Instructions/UpdateInstructionTabCommandHandler.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Instructions; + +namespace PnvPanel.Application.Admin.Instructions; + +public sealed class UpdateInstructionTabCommandHandler(IAppDbContext dbContext) + : ICommandHandler> +{ + public async Task> Handle( + UpdateInstructionTabCommand command, + CancellationToken cancellationToken + ) + { + var tab = await dbContext.InstructionTabs.FirstOrDefaultAsync( + t => t.Id == command.TabId, + cancellationToken + ); + if (tab is null) + return Result.Failure(InstructionErrors.TabNotFound); + + tab.Update(command.Title, command.Body, command.SortOrder); + + return Result.Success(InstructionTabDto.FromDomain(tab)); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Instructions/UpdateInstructionTabCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Instructions/UpdateInstructionTabCommandValidator.cs new file mode 100644 index 0000000..7f2003e --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Instructions/UpdateInstructionTabCommandValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.Instructions; + +public sealed class UpdateInstructionTabCommandValidator + : AbstractValidator +{ + public UpdateInstructionTabCommandValidator() + { + RuleFor(x => x.Title).NotEmpty().MaximumLength(100); + RuleFor(x => x.Body).NotEmpty().MaximumLength(20000); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Maintenance/FactoryResetCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Maintenance/FactoryResetCommandHandler.cs index 025f5d1..1967fe3 100644 --- a/backend/src/PnvPanel.Application/Admin/Maintenance/FactoryResetCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Maintenance/FactoryResetCommandHandler.cs @@ -15,6 +15,7 @@ public sealed class FactoryResetCommandHandler( IXuiPanelGateway gateway, IFileStorage fileStorage, IClientAppCatalogSeeder catalogSeeder, + IInstructionIntroSeeder instructionIntroSeeder, ICurrentUser currentUser ) : ICommandHandler { @@ -44,8 +45,9 @@ public sealed class FactoryResetCommandHandler( foreach (var role in roles.Where(r => !r.IsSystem)) await roleService.DeleteRoleAsync(role.Id, cancellationToken); - // ClientApps уже пуста (удалена в WipeApplicationData + сохранена выше) — пересеиваем. + // ClientApps/InstructionIntros уже пусты (удалены в WipeApplicationData + сохранено выше) — пересеиваем. await catalogSeeder.SeedIfEmptyAsync(cancellationToken); + await instructionIntroSeeder.SeedIfEmptyAsync(cancellationToken); // Финальная запись — уже после очистки самого журнала, чтобы отметить факт сброса. dbContext.AuditLogs.Add( @@ -116,6 +118,8 @@ public sealed class FactoryResetCommandHandler( dbContext.TelegramLinkTokens.RemoveRange(dbContext.TelegramLinkTokens); dbContext.NewsPosts.RemoveRange(dbContext.NewsPosts); dbContext.ClientApps.RemoveRange(dbContext.ClientApps); + dbContext.InstructionIntros.RemoveRange(dbContext.InstructionIntros); + dbContext.InstructionTabs.RemoveRange(dbContext.InstructionTabs); dbContext.AuditLogs.RemoveRange(dbContext.AuditLogs); } } diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs index cae7f7d..115cb48 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs @@ -5,6 +5,7 @@ using PnvPanel.Domain.Apps; using PnvPanel.Domain.Audit; using PnvPanel.Domain.Configs; using PnvPanel.Domain.Inbounds; +using PnvPanel.Domain.Instructions; using PnvPanel.Domain.News; using PnvPanel.Domain.Nodes; using PnvPanel.Domain.Support; @@ -40,6 +41,10 @@ public interface IAppDbContext DbSet TicketAttachments { get; } + DbSet InstructionIntros { get; } + + DbSet InstructionTabs { get; } + /// Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler). DatabaseFacade Database { get; } diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IInstructionIntroSeeder.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IInstructionIntroSeeder.cs new file mode 100644 index 0000000..213bf94 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IInstructionIntroSeeder.cs @@ -0,0 +1,10 @@ +namespace PnvPanel.Application.Common.Interfaces; + +/// +/// Сидинг дефолтного вводного текста страницы инструкций. Идемпотентно — не трогает таблицу, если +/// в ней уже есть строка (используется и при старте, и после полного сброса панели). +/// +public interface IInstructionIntroSeeder +{ + Task SeedIfEmptyAsync(CancellationToken cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Instructions/GetInstructionIntroQuery.cs b/backend/src/PnvPanel.Application/Instructions/GetInstructionIntroQuery.cs new file mode 100644 index 0000000..d20ab1a --- /dev/null +++ b/backend/src/PnvPanel.Application/Instructions/GetInstructionIntroQuery.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Instructions; + +public sealed record GetInstructionIntroQuery + : IQuery>, + IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Instructions/GetInstructionIntroQueryHandler.cs b/backend/src/PnvPanel.Application/Instructions/GetInstructionIntroQueryHandler.cs new file mode 100644 index 0000000..75643c9 --- /dev/null +++ b/backend/src/PnvPanel.Application/Instructions/GetInstructionIntroQueryHandler.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Instructions; + +public sealed class GetInstructionIntroQueryHandler(IAppDbContext dbContext) + : IQueryHandler> +{ + public async Task> Handle( + GetInstructionIntroQuery query, + CancellationToken cancellationToken + ) + { + var intro = await dbContext + .InstructionIntros.AsNoTracking() + .FirstOrDefaultAsync(cancellationToken); + + // Ещё не сидировано/не сохранено ни разу — пустой текст, а не ошибка (страница не должна падать). + return Result.Success( + intro is null + ? new InstructionIntroDto(Guid.Empty, string.Empty, DateTimeOffset.MinValue) + : InstructionIntroDto.FromDomain(intro) + ); + } +} diff --git a/backend/src/PnvPanel.Application/Instructions/InstructionIntroDto.cs b/backend/src/PnvPanel.Application/Instructions/InstructionIntroDto.cs new file mode 100644 index 0000000..4e7a188 --- /dev/null +++ b/backend/src/PnvPanel.Application/Instructions/InstructionIntroDto.cs @@ -0,0 +1,9 @@ +using PnvPanel.Domain.Instructions; + +namespace PnvPanel.Application.Instructions; + +public sealed record InstructionIntroDto(Guid Id, string Body, DateTimeOffset UpdatedAt) +{ + public static InstructionIntroDto FromDomain(InstructionIntro intro) => + new(intro.Id, intro.Body, intro.UpdatedAt); +} diff --git a/backend/src/PnvPanel.Application/Instructions/InstructionTabDto.cs b/backend/src/PnvPanel.Application/Instructions/InstructionTabDto.cs new file mode 100644 index 0000000..6f5da91 --- /dev/null +++ b/backend/src/PnvPanel.Application/Instructions/InstructionTabDto.cs @@ -0,0 +1,18 @@ +using PnvPanel.Domain.Instructions; + +namespace PnvPanel.Application.Instructions; + +/// Один DTO на пользовательскую страницу и админку — у вкладки нет полей, скрытых от юзера +/// (нет статуса черновик/опубликовано, см. InstructionTab). +public sealed record InstructionTabDto( + Guid Id, + string Title, + string Body, + int SortOrder, + DateTimeOffset CreatedAt, + DateTimeOffset? UpdatedAt +) +{ + public static InstructionTabDto FromDomain(InstructionTab tab) => + new(tab.Id, tab.Title, tab.Body, tab.SortOrder, tab.CreatedAt, tab.UpdatedAt); +} diff --git a/backend/src/PnvPanel.Application/Instructions/ListInstructionTabsQuery.cs b/backend/src/PnvPanel.Application/Instructions/ListInstructionTabsQuery.cs new file mode 100644 index 0000000..bc3ef8d --- /dev/null +++ b/backend/src/PnvPanel.Application/Instructions/ListInstructionTabsQuery.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Instructions; + +public sealed record ListInstructionTabsQuery + : IQuery>>, + IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Instructions/ListInstructionTabsQueryHandler.cs b/backend/src/PnvPanel.Application/Instructions/ListInstructionTabsQueryHandler.cs new file mode 100644 index 0000000..1692770 --- /dev/null +++ b/backend/src/PnvPanel.Application/Instructions/ListInstructionTabsQueryHandler.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Instructions; + +public sealed class ListInstructionTabsQueryHandler(IAppDbContext dbContext) + : IQueryHandler>> +{ + public async Task>> Handle( + ListInstructionTabsQuery query, + CancellationToken cancellationToken + ) + { + var tabs = await dbContext + .InstructionTabs.AsNoTracking() + .OrderBy(t => t.SortOrder) + .Select(t => new InstructionTabDto( + t.Id, + t.Title, + t.Body, + t.SortOrder, + t.CreatedAt, + t.UpdatedAt + )) + .ToListAsync(cancellationToken); + + return Result.Success>(tabs); + } +} diff --git a/backend/src/PnvPanel.Domain/Instructions/InstructionIntro.cs b/backend/src/PnvPanel.Domain/Instructions/InstructionIntro.cs new file mode 100644 index 0000000..af95d43 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Instructions/InstructionIntro.cs @@ -0,0 +1,31 @@ +using PnvPanel.Domain.Common; + +namespace PnvPanel.Domain.Instructions; + +/// +/// Единственная строка в таблице — вводный markdown-текст на странице инструкций (над каталогом +/// приложений), редактируется админом. Никакой поддержки нескольких версий/языков нет намеренно. +/// +public sealed class InstructionIntro : Entity +{ + public string Body { get; private set; } = string.Empty; + public DateTimeOffset UpdatedAt { get; private set; } + + private InstructionIntro() { } + + public static InstructionIntro Create(string body) + { + return new InstructionIntro + { + Id = Guid.NewGuid(), + Body = body, + UpdatedAt = DateTimeOffset.UtcNow, + }; + } + + public void Update(string body) + { + Body = body; + UpdatedAt = DateTimeOffset.UtcNow; + } +} diff --git a/backend/src/PnvPanel.Domain/Instructions/InstructionTab.cs b/backend/src/PnvPanel.Domain/Instructions/InstructionTab.cs new file mode 100644 index 0000000..9e4acc7 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Instructions/InstructionTab.cs @@ -0,0 +1,39 @@ +using PnvPanel.Domain.Common; + +namespace PnvPanel.Domain.Instructions; + +/// +/// Дополнительная вкладка на странице инструкций — заголовок + markdown-текст, ведёт админ. +/// Порядок среди вкладок — SortOrder (та же конвенция, что у ClientApp). Публикация мгновенная, +/// как у NewsPost — нет статуса черновик/опубликовано. +/// +public sealed class InstructionTab : Entity +{ + public string Title { get; private set; } = string.Empty; + public string Body { get; private set; } = string.Empty; + public int SortOrder { get; private set; } + public DateTimeOffset CreatedAt { get; private set; } + public DateTimeOffset? UpdatedAt { get; private set; } + + private InstructionTab() { } + + public static InstructionTab Create(string title, string body, int sortOrder) + { + return new InstructionTab + { + Id = Guid.NewGuid(), + Title = title, + Body = body, + SortOrder = sortOrder, + CreatedAt = DateTimeOffset.UtcNow, + }; + } + + public void Update(string title, string body, int sortOrder) + { + Title = title; + Body = body; + SortOrder = sortOrder; + UpdatedAt = DateTimeOffset.UtcNow; + } +} diff --git a/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs b/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs index 9c12b8f..f819b79 100644 --- a/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs +++ b/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs @@ -10,6 +10,7 @@ using PnvPanel.Application.Common.Interfaces; using PnvPanel.Infrastructure.Apps; using PnvPanel.Infrastructure.BackgroundJobs; using PnvPanel.Infrastructure.Identity; +using PnvPanel.Infrastructure.Instructions; using PnvPanel.Infrastructure.Persistence; using PnvPanel.Infrastructure.Security; using PnvPanel.Infrastructure.Storage; @@ -137,6 +138,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Один и тот же экземпляр CurrentUser на scope — и как ICurrentUser (чтение), и как // ICurrentUserSetter (запись, только для Telegram-бота, см. TelegramBotHostedService). services.AddScoped(); diff --git a/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs b/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs index 14975a8..d2cffbf 100644 --- a/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs +++ b/backend/src/PnvPanel.Infrastructure/Identity/DbInitializer.cs @@ -11,6 +11,7 @@ public sealed class DbInitializer( RoleManager roleManager, UserManager userManager, IClientAppCatalogSeeder clientAppCatalogSeeder, + IInstructionIntroSeeder instructionIntroSeeder, IOptions adminSeedOptions, IOptions rolesOptions, ILogger logger @@ -32,6 +33,7 @@ public sealed class DbInitializer( ); await SeedAdminAsync(); await clientAppCatalogSeeder.SeedIfEmptyAsync(cancellationToken); + await instructionIntroSeeder.SeedIfEmptyAsync(cancellationToken); } private async Task EnsureRoleAsync(string name, int maxConfigs, int maxIpLimit, bool isSystem) diff --git a/backend/src/PnvPanel.Infrastructure/Instructions/InstructionIntroSeeder.cs b/backend/src/PnvPanel.Infrastructure/Instructions/InstructionIntroSeeder.cs new file mode 100644 index 0000000..0518ecb --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Instructions/InstructionIntroSeeder.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Domain.Instructions; +using PnvPanel.Infrastructure.Persistence; + +namespace PnvPanel.Infrastructure.Instructions; + +internal sealed class InstructionIntroSeeder( + AppDbContext dbContext, + ILogger logger +) : IInstructionIntroSeeder +{ + private const string DefaultBody = """ + Как подключиться за три шага — на любом устройстве. + + 1. Установите приложение для вашей ОС из списка ниже. + 2. На странице «Мои конфиги» скопируйте ссылку или откройте QR-код нужного конфига. + 3. Импортируйте ссылку или отсканируйте QR в приложении — готово. + """; + + public async Task SeedIfEmptyAsync(CancellationToken cancellationToken) + { + if (await dbContext.InstructionIntros.AnyAsync(cancellationToken)) + return; + + dbContext.InstructionIntros.Add(InstructionIntro.Create(DefaultBody)); + await dbContext.SaveChangesAsync(cancellationToken); + logger.LogInformation("Seeded default instruction intro"); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs b/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs index bd9d5fe..c15c3ff 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs @@ -6,6 +6,7 @@ using PnvPanel.Domain.Apps; using PnvPanel.Domain.Audit; using PnvPanel.Domain.Configs; using PnvPanel.Domain.Inbounds; +using PnvPanel.Domain.Instructions; using PnvPanel.Domain.News; using PnvPanel.Domain.Nodes; using PnvPanel.Domain.Support; @@ -50,6 +51,10 @@ public class AppDbContext(DbContextOptions options) public DbSet TicketAttachments => Set(); + public DbSet InstructionIntros => Set(); + + public DbSet InstructionTabs => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/InstructionIntroConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/InstructionIntroConfiguration.cs new file mode 100644 index 0000000..6aefe5b --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/InstructionIntroConfiguration.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PnvPanel.Domain.Instructions; + +namespace PnvPanel.Infrastructure.Persistence.Configurations; + +public class InstructionIntroConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("InstructionIntros"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Body).IsRequired().HasMaxLength(20000); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/InstructionTabConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/InstructionTabConfiguration.cs new file mode 100644 index 0000000..87132aa --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/InstructionTabConfiguration.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PnvPanel.Domain.Instructions; + +namespace PnvPanel.Infrastructure.Persistence.Configurations; + +public class InstructionTabConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("InstructionTabs"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Title).IsRequired().HasMaxLength(100); + builder.Property(x => x.Body).IsRequired().HasMaxLength(20000); + + builder.HasIndex(x => x.SortOrder); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714174326_AddInstructions.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714174326_AddInstructions.Designer.cs new file mode 100644 index 0000000..89736a7 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714174326_AddInstructions.Designer.cs @@ -0,0 +1,941 @@ +// +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("20260714174326_AddInstructions")] + partial class AddInstructions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("ActivationRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DownloadUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("IsRecommended") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OperatingSystem") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClientApps", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .HasColumnType("jsonb"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigId") + .HasColumnType("uuid"); + + b.Property("DownBytes") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ConfigId", "Timestamp"); + + b.ToTable("TrafficSamples", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InboundId") + .HasColumnType("uuid"); + + b.Property("Label") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UsedDownBytes") + .HasColumnType("bigint"); + + b.Property("UsedUpBytes") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("InboundId"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("UserId", "Status"); + + b.ToTable("VpnConfigs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("AllowedRoleIds") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxClients") + .HasColumnType("integer"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RemoteInboundId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "RemoteInboundId") + .IsUnique(); + + b.ToTable("Inbounds", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionIntro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("InstructionIntros", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionTab", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("InstructionTabs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.News.NewsPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("NewsPosts", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.ToTable("Nodes", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProposedMaxConfigs") + .HasColumnType("integer"); + + b.Property("ProposedMaxIpLimit") + .HasColumnType("integer"); + + b.Property("ProposedRoleName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Type", "Status"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("SupportTickets", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CommentId") + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CommentId"); + + b.HasIndex("StoredFileName") + .IsUnique(); + + b.ToTable("TicketAttachments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TicketId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TicketId", "CreatedAt"); + + b.ToTable("TicketComments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("TelegramLinkTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("TelegramLoginRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxConfigs") + .HasColumnType("integer"); + + b.Property("MaxIpLimit") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("TelegramLinkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TelegramUserId") + .HasColumnType("bigint"); + + b.Property("TelegramUsername") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("TelegramUserId") + .IsUnique(); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 => + { + b1.Property("NodeId") + .HasColumnType("uuid"); + + b1.Property("ProtectedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CredentialsProtectedPassword"); + + b1.Property("Username") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("CredentialsUsername"); + + b1.HasKey("NodeId"); + + b1.ToTable("Nodes"); + + b1.WithOwner() + .HasForeignKey("NodeId"); + }); + + b.Navigation("Credentials") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714174326_AddInstructions.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714174326_AddInstructions.cs new file mode 100644 index 0000000..5fa7a1e --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714174326_AddInstructions.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddInstructions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "InstructionIntros", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Body = table.Column(type: "character varying(20000)", maxLength: 20000, nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_InstructionIntros", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "InstructionTabs", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Title = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Body = table.Column(type: "character varying(20000)", maxLength: 20000, nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_InstructionTabs", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_InstructionTabs_SortOrder", + table: "InstructionTabs", + column: "SortOrder"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "InstructionIntros"); + + migrationBuilder.DropTable( + name: "InstructionTabs"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 1124ffe..5160307 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -397,6 +397,57 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations b.ToTable("Inbounds", (string)null); }); + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionIntro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("InstructionIntros", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionTab", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("InstructionTabs", (string)null); + }); + modelBuilder.Entity("PnvPanel.Domain.News.NewsPost", b => { b.Property("Id") diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Instructions/InstructionTabCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Instructions/InstructionTabCommandHandlerTests.cs new file mode 100644 index 0000000..ba2d294 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Instructions/InstructionTabCommandHandlerTests.cs @@ -0,0 +1,97 @@ +using PnvPanel.Application.Admin.Instructions; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Instructions; +using Xunit; + +namespace PnvPanel.Application.Tests.Admin.Instructions; + +public class InstructionTabCommandHandlerTests +{ + [Fact] + public async Task Create_AddsTab() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var handler = new CreateInstructionTabCommandHandler(dbContext); + + var result = await handler.Handle( + new CreateInstructionTabCommand("Настройка роутера", "текст", 10), + CancellationToken.None + ); + await dbContext.SaveChangesAsync(CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("Настройка роутера", result.Value.Title); + Assert.Single(dbContext.InstructionTabs); + } + + [Fact] + public async Task Update_ReplacesFields() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var tab = InstructionTab.Create("Старое", "старый текст", 10); + dbContext.InstructionTabs.Add(tab); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new UpdateInstructionTabCommandHandler(dbContext); + + var result = await handler.Handle( + new UpdateInstructionTabCommand(tab.Id, "Новое", "новый текст", 20), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Equal("Новое", result.Value.Title); + Assert.Equal("новый текст", result.Value.Body); + Assert.Equal(20, result.Value.SortOrder); + } + + [Fact] + public async Task Update_WhenTabMissing_ReturnsNotFound() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var handler = new UpdateInstructionTabCommandHandler(dbContext); + + var result = await handler.Handle( + new UpdateInstructionTabCommand(Guid.NewGuid(), "Новое", "текст", 10), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(InstructionErrors.TabNotFound, result.Error); + } + + [Fact] + public async Task Delete_RemovesTab() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var tab = InstructionTab.Create("Удалить меня", "текст", 10); + dbContext.InstructionTabs.Add(tab); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new DeleteInstructionTabCommandHandler(dbContext); + + var result = await handler.Handle( + new DeleteInstructionTabCommand(tab.Id), + CancellationToken.None + ); + await dbContext.SaveChangesAsync(CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Empty(dbContext.InstructionTabs); + } + + [Fact] + public async Task Delete_WhenTabMissing_ReturnsNotFound() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var handler = new DeleteInstructionTabCommandHandler(dbContext); + + var result = await handler.Handle( + new DeleteInstructionTabCommand(Guid.NewGuid()), + CancellationToken.None + ); + + Assert.False(result.IsSuccess); + Assert.Equal(InstructionErrors.TabNotFound, result.Error); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Instructions/UpdateInstructionIntroCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Instructions/UpdateInstructionIntroCommandHandlerTests.cs new file mode 100644 index 0000000..f2f760f --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Instructions/UpdateInstructionIntroCommandHandlerTests.cs @@ -0,0 +1,49 @@ +using PnvPanel.Application.Admin.Instructions; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Instructions; +using Xunit; + +namespace PnvPanel.Application.Tests.Admin.Instructions; + +public class UpdateInstructionIntroCommandHandlerTests +{ + [Fact] + public async Task Handle_WhenNoIntroExists_CreatesIt() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var handler = new UpdateInstructionIntroCommandHandler(dbContext); + + var result = await handler.Handle( + new UpdateInstructionIntroCommand("Новый текст"), + CancellationToken.None + ); + await dbContext.SaveChangesAsync(CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("Новый текст", result.Value.Body); + Assert.Single(dbContext.InstructionIntros); + } + + [Fact] + public async Task Handle_WhenIntroExists_UpdatesItInPlace() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var intro = InstructionIntro.Create("Старый текст"); + dbContext.InstructionIntros.Add(intro); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new UpdateInstructionIntroCommandHandler(dbContext); + + var result = await handler.Handle( + new UpdateInstructionIntroCommand("Обновлённый текст"), + CancellationToken.None + ); + await dbContext.SaveChangesAsync(CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(intro.Id, result.Value.Id); + Assert.Equal("Обновлённый текст", result.Value.Body); + // Не создаётся вторая строка — обновляется существующая (singleton). + Assert.Single(dbContext.InstructionIntros); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/FactoryResetCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/FactoryResetCommandHandlerTests.cs index b81609f..162d6c9 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/FactoryResetCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/FactoryResetCommandHandlerTests.cs @@ -21,6 +21,8 @@ public class FactoryResetCommandHandlerTests private readonly IFileStorage _fileStorage = Substitute.For(); private readonly IClientAppCatalogSeeder _catalogSeeder = Substitute.For(); + private readonly IInstructionIntroSeeder _instructionIntroSeeder = + Substitute.For(); [Fact] public async Task Handle_WipesEverythingExceptCurrentAdminAndSystemRoles() @@ -101,6 +103,7 @@ public class FactoryResetCommandHandlerTests _gateway, _fileStorage, _catalogSeeder, + _instructionIntroSeeder, currentUser ); @@ -140,5 +143,6 @@ public class FactoryResetCommandHandlerTests ); await _fileStorage.Received(1).DeleteAsync("stored-name", Arg.Any()); await _catalogSeeder.Received(1).SeedIfEmptyAsync(Arg.Any()); + await _instructionIntroSeeder.Received(1).SeedIfEmptyAsync(Arg.Any()); } } diff --git a/backend/tests/PnvPanel.Application.Tests/Instructions/GetInstructionIntroQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Instructions/GetInstructionIntroQueryHandlerTests.cs new file mode 100644 index 0000000..23712b1 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Instructions/GetInstructionIntroQueryHandlerTests.cs @@ -0,0 +1,37 @@ +using PnvPanel.Application.Instructions; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Instructions; +using Xunit; + +namespace PnvPanel.Application.Tests.Instructions; + +public class GetInstructionIntroQueryHandlerTests +{ + [Fact] + public async Task Handle_WhenNoIntroSeeded_ReturnsEmptyDefault() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var handler = new GetInstructionIntroQueryHandler(dbContext); + + var result = await handler.Handle(new GetInstructionIntroQuery(), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(string.Empty, result.Value.Body); + } + + [Fact] + public async Task Handle_WhenIntroExists_ReturnsIt() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var intro = InstructionIntro.Create("Привет, это инструкция."); + dbContext.InstructionIntros.Add(intro); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new GetInstructionIntroQueryHandler(dbContext); + + var result = await handler.Handle(new GetInstructionIntroQuery(), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("Привет, это инструкция.", result.Value.Body); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Instructions/ListInstructionTabsQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Instructions/ListInstructionTabsQueryHandlerTests.cs new file mode 100644 index 0000000..8abc074 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Instructions/ListInstructionTabsQueryHandlerTests.cs @@ -0,0 +1,40 @@ +using PnvPanel.Application.Instructions; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Instructions; +using Xunit; + +namespace PnvPanel.Application.Tests.Instructions; + +public class ListInstructionTabsQueryHandlerTests +{ + [Fact] + public async Task Handle_ReturnsTabsOrderedBySortOrder() + { + using var dbContext = InMemoryDbContextFactory.Create(); + dbContext.InstructionTabs.AddRange( + InstructionTab.Create("Второй", "текст", 20), + InstructionTab.Create("Первый", "текст", 10), + InstructionTab.Create("Третий", "текст", 30) + ); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new ListInstructionTabsQueryHandler(dbContext); + + var result = await handler.Handle(new ListInstructionTabsQuery(), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(["Первый", "Второй", "Третий"], result.Value.Select(t => t.Title)); + } + + [Fact] + public async Task Handle_WhenNoTabs_ReturnsEmptyList() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var handler = new ListInstructionTabsQueryHandler(dbContext); + + var result = await handler.Handle(new ListInstructionTabsQuery(), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Empty(result.Value); + } +} diff --git a/docs/api-design.md b/docs/api-design.md index 7978916..be61846 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -105,6 +105,27 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро ``` Значение `OsPlatform` в C#/JSON — `IOS` (не `iOS`). +## Instructions — страница инструкций + +Вводный markdown-текст (singleton) над вкладками + дополнительные вкладки, обе части редактируются +из админки. Встроенная вкладка «Приложения» (каталог `ClientApp`) в этот API не входит — фронт +всегда рисует её первой, сама её достаёт через `GET /api/apps`. + +| Метод | Путь | Роль | Тело запроса | Тело ответа | +| ------ | ------------------------------------- | ----- | ----------------------------------- | ------------- | +| GET | `/api/instructions/intro` | user | — | `InstructionIntroDto` | +| GET | `/api/instructions/tabs` | user | — | `InstructionTabDto[]` (сортировка `SortOrder asc`) | +| PUT | `/api/admin/instructions/intro` | admin | `{ body }` | `InstructionIntroDto` | +| POST | `/api/admin/instructions/tabs` | admin | `{ title, body, sortOrder }` | `InstructionTabDto` | +| PUT | `/api/admin/instructions/tabs/{id}` | admin | `{ title, body, sortOrder }` | `InstructionTabDto` | +| DELETE | `/api/admin/instructions/tabs/{id}` | admin | — | `204 No Content` | + +`PUT /api/admin/instructions/intro` — get-or-create (строка одна на всю систему; если её ещё нет, +создаётся, иначе обновляется на месте). `GET /api/instructions/intro` никогда не 404-ит — если строка +ещё не создана, отдаёт `{ id: "00000000-0000-0000-0000-000000000000", body: "", updatedAt: }`, +чтобы публичная страница не падала. Вкладки — обычный CRUD без статуса черновик/опубликовано, как +у `NewsPostDto`. + ## News — лента новостей | Метод | Путь | Роль | Тело запроса | Тело ответа | @@ -200,9 +221,11 @@ Support.CannotRequestAdminRole`), либо все три поля новой р («Опасная зона») и требует ввести фразу-подтверждение в диалоге (не просто `confirm()`). Удаляет: всех пользователей кроме текущего админа, все `VpnConfig`/`TrafficSample` (конфиги сначала best-effort отзываются на нодах через `IXuiPanelGateway.RemoveClientAsync` — недоступная нода не блокирует сброс), -все `Node`/`Inbound`, тикеты с перепиской/вложениями (+файлы), `NewsPost`, весь `AuditLog`, все -кастомные роли (`AppRole.IsSystem == false`) и `ClientApp` — каталог приложений затем пересеивается -через `IClientAppCatalogSeeder` (тот же сервис, что использует `DbInitializer` при первом старте). +все `Node`/`Inbound`, тикеты с перепиской/вложениями (+файлы), `NewsPost`, `InstructionIntro`/ +`InstructionTab`, весь `AuditLog`, все кастомные роли (`AppRole.IsSystem == false`) и `ClientApp` — +каталог приложений и вводный текст инструкций затем пересеиваются дефолтными значениями через +`IClientAppCatalogSeeder`/`IInstructionIntroSeeder` (те же сервисы, что использует `DbInitializer` +при первом старте); вкладки инструкций дефолтами не пересеиваются, как и новости. Не атомарно целиком (несколько `SaveChangesAsync` внутри хендлера, как и в `DeleteUserCommandHandler`) — при сбое посередине возможно частичное состояние, компенсации нет, это осознанный компромисс для редкой ручной админской операции. Финальная запись `FactoryReset` в аудит добавляется уже после diff --git a/docs/domain-model.md b/docs/domain-model.md index e7277c7..5e789f6 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -159,6 +159,38 @@ AppUser Массовая очистка отключённых (`IsEnabled = false`) — вкладка «Обслуживание», `DELETE /api/admin/maintenance/apps/disabled`. +### InstructionIntro — вводный текст страницы инструкций +Единственная строка в таблице (singleton) — markdown-текст над вкладками на странице «Инструкции», +редактируется админом. Никакой поддержки нескольких версий/языков нет. + +| Поле | Тип | Заметки | +| ----------- | ----------------- | ------------------------------------------------ | +| `Id` | `Guid` | PK | +| `Body` | `string` | Markdown-текст | +| `UpdatedAt` | `DateTimeOffset` | | + +`GET /api/instructions/intro` (активированным) читает; `PUT /api/admin/instructions/intro` (админ) +делает get-or-create — если строки ещё нет (не сидировано), создаёт, иначе обновляет на месте. +Сидируется дефолтным текстом при старте (`IInstructionIntroSeeder`, если таблица пуста) и заново +после полного сброса панели (см. «Полный сброс панели» выше). + +### InstructionTab — дополнительные вкладки инструкций +Заголовок + markdown-текст, ведёт админ; на странице «Инструкции» отображаются вкладками рядом с +встроенной вкладкой «Приложения» (каталог `ClientApp`, не хранится как `InstructionTab`). + +| Поле | Тип | Заметки | +| ----------- | ----------------- | ------------------------------------------------------- | +| `Id` | `Guid` | PK | +| `Title` | `string` | Заголовок вкладки | +| `Body` | `string` | Markdown-текст | +| `SortOrder` | `int` | Порядок вкладок (та же конвенция, что у `ClientApp`) | +| `CreatedAt` | `DateTimeOffset` | | +| `UpdatedAt` | `DateTimeOffset?` | | + +Нет статуса черновик/опубликовано — публикация мгновенная, как у `NewsPost`. Полный CRUD только +у админа (`/api/admin/instructions/tabs`); чтение — `GET /api/instructions/tabs` (активированным). +Не пересеивается дефолтными вкладками — при полном сбросе панели просто удаляются. + ### NewsPost — новости для пользователей Публикуются админом немедленно, видны всем залогиненным пользователям в хронологической ленте. @@ -197,9 +229,10 @@ AppUser Вкладка «Обслуживание» → «Опасная зона» (спойлер + подтверждение фразой в диалоге, не просто `confirm()`) — `DELETE /api/admin/maintenance/factory-reset`. Возвращает панель к состоянию свежего деплоя: удаляет всех пользователей кроме текущего админа, конфиги (сначала best-effort отзываются -на нодах 3x-ui), ноды/инбаунды, тикеты, новости, весь аудит и кастомные роли; каталог приложений -пересеивается из `seed/client-apps.json`. Необратимо, не атомарно целиком — подробности и полный -список удаляемого см. [api-design.md](api-design.md#admin--maintenance). +на нодах 3x-ui), ноды/инбаунды, тикеты, новости, вводный текст и вкладки инструкций, весь аудит и +кастомные роли; каталог приложений и вводный текст инструкций пересеиваются дефолтными значениями, +вкладки инструкций — нет (пусто, как у новостей). Необратимо, не атомарно целиком — подробности и +полный список удаляемого см. [api-design.md](api-design.md#admin--maintenance). ### AppUser — расширения (Identity) `AppUser` живёт в Identity (`Infrastructure`). **Логин — по `UserName`** (уникальный, обязательный). diff --git a/frontend/src/features/admin/instructions/InstructionIntroEditor.tsx b/frontend/src/features/admin/instructions/InstructionIntroEditor.tsx new file mode 100644 index 0000000..a7a4c56 --- /dev/null +++ b/frontend/src/features/admin/instructions/InstructionIntroEditor.tsx @@ -0,0 +1,52 @@ +import { useState } from 'react' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { toast } from '@/shared/ui/toast-store' +import { Button } from '@/shared/ui/button' +import { Textarea } from '@/shared/ui/textarea' +import { HttpError } from '@/shared/api/client' +import type { InstructionIntroDto } from '@/shared/api/types' +import { updateInstructionIntro } from './api' + +export function InstructionIntroEditor({ intro }: { intro: InstructionIntroDto }) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [body, setBody] = useState(intro.body) + const [previewMode, setPreviewMode] = useState(false) + + const mutation = useMutation({ + mutationFn: () => updateInstructionIntro(body.trim()), + onSuccess: async () => { + toast.success(t('admin.instructions.introSaved')) + await queryClient.invalidateQueries({ queryKey: ['instruction-intro'] }) + }, + onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')), + }) + + const isDirty = body.trim() !== intro.body.trim() + + return ( +
+
+

{t('admin.instructions.introHint')}

+ +
+ {previewMode ? ( +
+ {body || t('admin.instructions.tabBody')} +
+ ) : ( +