diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminAppEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminAppEndpoints.cs index 744d0bc..04efb93 100644 --- a/backend/src/PnvPanel.Api/Endpoints/AdminAppEndpoints.cs +++ b/backend/src/PnvPanel.Api/Endpoints/AdminAppEndpoints.cs @@ -53,7 +53,8 @@ public static class AdminAppEndpoints body.Description, body.IconUrl, body.SortOrder, - body.IsEnabled + body.IsEnabled, + body.IsRecommended ); var result = await sender.Send(command, cancellationToken); return result.ToHttpResult(); @@ -77,5 +78,6 @@ public sealed record UpdateAppBody( string? Description, string? IconUrl, int SortOrder, - bool IsEnabled + bool IsEnabled, + bool IsRecommended ); diff --git a/backend/src/PnvPanel.Application/Admin/Apps/AdminAppDto.cs b/backend/src/PnvPanel.Application/Admin/Apps/AdminAppDto.cs index 06da96e..d567903 100644 --- a/backend/src/PnvPanel.Application/Admin/Apps/AdminAppDto.cs +++ b/backend/src/PnvPanel.Application/Admin/Apps/AdminAppDto.cs @@ -10,7 +10,8 @@ public sealed record AdminAppDto( string? Description, string? IconUrl, int SortOrder, - bool IsEnabled + bool IsEnabled, + bool IsRecommended ) { public static AdminAppDto FromDomain(ClientApp app) => @@ -22,6 +23,7 @@ public sealed record AdminAppDto( app.Description, app.IconUrl, app.SortOrder, - app.IsEnabled + app.IsEnabled, + app.IsRecommended ); } diff --git a/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommand.cs b/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommand.cs index ee0215f..78ab20d 100644 --- a/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommand.cs +++ b/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommand.cs @@ -10,5 +10,6 @@ public sealed record CreateAppCommand( OsPlatform OperatingSystem, string? Description, string? IconUrl, - int SortOrder + int SortOrder, + bool IsRecommended ) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommandHandler.cs index dc18865..ae09bb5 100644 --- a/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Apps/CreateAppCommandHandler.cs @@ -19,7 +19,8 @@ public sealed class CreateAppCommandHandler(IAppDbContext dbContext) command.OperatingSystem, command.Description, command.IconUrl, - command.SortOrder + command.SortOrder, + command.IsRecommended ); dbContext.ClientApps.Add(app); diff --git a/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommand.cs b/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommand.cs index e01a8fa..7cd6a0a 100644 --- a/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommand.cs +++ b/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommand.cs @@ -12,5 +12,6 @@ public sealed record UpdateAppCommand( string? Description, string? IconUrl, int SortOrder, - bool IsEnabled + bool IsEnabled, + bool IsRecommended ) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommandHandler.cs index 0069f85..40d41e4 100644 --- a/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Apps/UpdateAppCommandHandler.cs @@ -27,7 +27,8 @@ public sealed class UpdateAppCommandHandler(IAppDbContext dbContext) command.Description, command.IconUrl, command.SortOrder, - command.IsEnabled + command.IsEnabled, + command.IsRecommended ); return Result.Success(AdminAppDto.FromDomain(app)); diff --git a/backend/src/PnvPanel.Application/Apps/ClientAppDto.cs b/backend/src/PnvPanel.Application/Apps/ClientAppDto.cs index 26ad78f..f51c751 100644 --- a/backend/src/PnvPanel.Application/Apps/ClientAppDto.cs +++ b/backend/src/PnvPanel.Application/Apps/ClientAppDto.cs @@ -7,9 +7,17 @@ public sealed record ClientAppDto( string Name, string DownloadUrl, string? Description, - string? IconUrl + string? IconUrl, + bool IsRecommended ) { public static ClientAppDto FromDomain(ClientApp app) => - new(app.Id, app.Name, app.DownloadUrl.ToString(), app.Description, app.IconUrl); + new( + app.Id, + app.Name, + app.DownloadUrl.ToString(), + app.Description, + app.IconUrl, + app.IsRecommended + ); } diff --git a/backend/src/PnvPanel.Application/Apps/ListAppsQueryHandler.cs b/backend/src/PnvPanel.Application/Apps/ListAppsQueryHandler.cs index 9ebacdf..0b76a9a 100644 --- a/backend/src/PnvPanel.Application/Apps/ListAppsQueryHandler.cs +++ b/backend/src/PnvPanel.Application/Apps/ListAppsQueryHandler.cs @@ -20,7 +20,8 @@ public sealed class ListAppsQueryHandler(IAppDbContext dbContext) var apps = await dbContext .ClientApps.AsNoTracking() .Where(a => a.IsEnabled) - .OrderBy(a => a.SortOrder) + .OrderByDescending(a => a.IsRecommended) + .ThenBy(a => a.SortOrder) .ToListAsync(cancellationToken); var grouped = apps.GroupBy(a => a.OperatingSystem) diff --git a/backend/src/PnvPanel.Domain/Apps/ClientApp.cs b/backend/src/PnvPanel.Domain/Apps/ClientApp.cs index 6abae43..210d9d0 100644 --- a/backend/src/PnvPanel.Domain/Apps/ClientApp.cs +++ b/backend/src/PnvPanel.Domain/Apps/ClientApp.cs @@ -13,6 +13,10 @@ public sealed class ClientApp : Entity public int SortOrder { get; private set; } public bool IsEnabled { get; private set; } + /// Рекомендованные приложения показываются первыми внутри своей группы ОС и + /// помечаются значком на странице инструкций. + public bool IsRecommended { get; private set; } + private ClientApp() { } public static ClientApp Create( @@ -21,7 +25,8 @@ public sealed class ClientApp : Entity OsPlatform operatingSystem, string? description, string? iconUrl, - int sortOrder + int sortOrder, + bool isRecommended ) { return new ClientApp @@ -34,6 +39,7 @@ public sealed class ClientApp : Entity IconUrl = iconUrl, SortOrder = sortOrder, IsEnabled = true, + IsRecommended = isRecommended, }; } @@ -44,7 +50,8 @@ public sealed class ClientApp : Entity string? description, string? iconUrl, int sortOrder, - bool isEnabled + bool isEnabled, + bool isRecommended ) { Name = name; @@ -54,5 +61,6 @@ public sealed class ClientApp : Entity IconUrl = iconUrl; SortOrder = sortOrder; IsEnabled = isEnabled; + IsRecommended = isRecommended; } } diff --git a/backend/src/PnvPanel.Infrastructure/Apps/ClientAppCatalogSeeder.cs b/backend/src/PnvPanel.Infrastructure/Apps/ClientAppCatalogSeeder.cs index 046a841..0fd7acc 100644 --- a/backend/src/PnvPanel.Infrastructure/Apps/ClientAppCatalogSeeder.cs +++ b/backend/src/PnvPanel.Infrastructure/Apps/ClientAppCatalogSeeder.cs @@ -49,7 +49,8 @@ internal sealed class ClientAppCatalogSeeder( os, entry.Description, iconUrl: null, - entry.SortOrder + entry.SortOrder, + entry.IsRecommended ); dbContext.ClientApps.Add(app); } @@ -64,6 +65,7 @@ internal sealed class ClientAppCatalogSeeder( string DownloadUrl, string? Description, int SortOrder, - bool IsEnabled + bool IsEnabled, + bool IsRecommended = false ); } diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714160950_AddIsRecommendedToClientApp.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714160950_AddIsRecommendedToClientApp.Designer.cs new file mode 100644 index 0000000..2cd0f34 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714160950_AddIsRecommendedToClientApp.Designer.cs @@ -0,0 +1,890 @@ +// +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("20260714160950_AddIsRecommendedToClientApp")] + partial class AddIsRecommendedToClientApp + { + /// + 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.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/20260714160950_AddIsRecommendedToClientApp.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714160950_AddIsRecommendedToClientApp.cs new file mode 100644 index 0000000..c662dc2 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714160950_AddIsRecommendedToClientApp.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddIsRecommendedToClientApp : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsRecommended", + table: "ClientApps", + type: "boolean", + nullable: false, + defaultValue: false + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "IsRecommended", table: "ClientApps"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 6acad94..1124ffe 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -185,6 +185,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations b.Property("IsEnabled") .HasColumnType("boolean"); + b.Property("IsRecommended") + .HasColumnType("boolean"); + b.Property("Name") .IsRequired() .HasMaxLength(100) diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/DeleteDisabledAppsCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/DeleteDisabledAppsCommandHandlerTests.cs index 32f84d3..cadf8b2 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/DeleteDisabledAppsCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Maintenance/DeleteDisabledAppsCommandHandlerTests.cs @@ -18,7 +18,8 @@ public class DeleteDisabledAppsCommandHandlerTests OsPlatform.Android, null, null, - 0 + 0, + isRecommended: false ); disabledApp.Update( disabledApp.Name, @@ -27,7 +28,8 @@ public class DeleteDisabledAppsCommandHandlerTests null, null, 0, - isEnabled: false + isEnabled: false, + isRecommended: false ); var enabledApp = ClientApp.Create( @@ -36,7 +38,8 @@ public class DeleteDisabledAppsCommandHandlerTests OsPlatform.IOS, null, null, - 0 + 0, + isRecommended: false ); dbContext.ClientApps.AddRange(disabledApp, enabledApp); @@ -65,7 +68,8 @@ public class DeleteDisabledAppsCommandHandlerTests OsPlatform.IOS, null, null, - 0 + 0, + isRecommended: false ); dbContext.ClientApps.Add(enabledApp); await dbContext.SaveChangesAsync(CancellationToken.None); diff --git a/backend/tests/PnvPanel.Application.Tests/Apps/ListAppsQueryHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Apps/ListAppsQueryHandlerTests.cs new file mode 100644 index 0000000..db2d135 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Apps/ListAppsQueryHandlerTests.cs @@ -0,0 +1,100 @@ +using PnvPanel.Application.Apps; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Apps; +using Xunit; + +namespace PnvPanel.Application.Tests.Apps; + +public class ListAppsQueryHandlerTests +{ + [Fact] + public async Task Handle_SortsRecommendedFirstWithinEachOsGroup() + { + using var dbContext = InMemoryDbContextFactory.Create(); + + var regular1 = ClientApp.Create( + "Regular 1", + new Uri("https://a.example.com"), + OsPlatform.Android, + null, + null, + 10, + isRecommended: false + ); + var recommended = ClientApp.Create( + "Recommended", + new Uri("https://b.example.com"), + OsPlatform.Android, + null, + null, + 20, + isRecommended: true + ); + var regular2 = ClientApp.Create( + "Regular 2", + new Uri("https://c.example.com"), + OsPlatform.Android, + null, + null, + 30, + isRecommended: false + ); + + dbContext.ClientApps.AddRange(regular1, recommended, regular2); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new ListAppsQueryHandler(dbContext); + + var result = await handler.Handle(new ListAppsQuery(), CancellationToken.None); + + Assert.True(result.IsSuccess); + var androidApps = result.Value[OsPlatform.Android]; + Assert.Equal(["Recommended", "Regular 1", "Regular 2"], androidApps.Select(a => a.Name)); + } + + [Fact] + public async Task Handle_ExcludesDisabledApps() + { + using var dbContext = InMemoryDbContextFactory.Create(); + + var enabled = ClientApp.Create( + "Enabled", + new Uri("https://a.example.com"), + OsPlatform.IOS, + null, + null, + 10, + isRecommended: false + ); + var disabled = ClientApp.Create( + "Disabled", + new Uri("https://b.example.com"), + OsPlatform.IOS, + null, + null, + 20, + isRecommended: false + ); + disabled.Update( + disabled.Name, + disabled.DownloadUrl, + disabled.OperatingSystem, + null, + null, + disabled.SortOrder, + isEnabled: false, + isRecommended: false + ); + + dbContext.ClientApps.AddRange(enabled, disabled); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new ListAppsQueryHandler(dbContext); + + var result = await handler.Handle(new ListAppsQuery(), CancellationToken.None); + + Assert.True(result.IsSuccess); + var iosApps = result.Value[OsPlatform.IOS]; + Assert.Equal(["Enabled"], iosApps.Select(a => a.Name)); + } +} diff --git a/backend/tests/PnvPanel.Domain.Tests/Apps/ClientAppTests.cs b/backend/tests/PnvPanel.Domain.Tests/Apps/ClientAppTests.cs index a1e104f..12353d5 100644 --- a/backend/tests/PnvPanel.Domain.Tests/Apps/ClientAppTests.cs +++ b/backend/tests/PnvPanel.Domain.Tests/Apps/ClientAppTests.cs @@ -14,13 +14,15 @@ public class ClientAppTests OsPlatform.Android, "desc", null, - 1 + 1, + isRecommended: false ); Assert.Equal("v2rayNG", app.Name); Assert.Equal(OsPlatform.Android, app.OperatingSystem); Assert.True(app.IsEnabled); Assert.Equal(1, app.SortOrder); + Assert.False(app.IsRecommended); } [Fact] @@ -32,7 +34,8 @@ public class ClientAppTests OsPlatform.IOS, "old", "old-icon", - 1 + 1, + isRecommended: false ); app.Update( @@ -42,7 +45,8 @@ public class ClientAppTests "new", "new-icon", 2, - isEnabled: false + isEnabled: false, + isRecommended: true ); Assert.Equal("New", app.Name); @@ -52,5 +56,6 @@ public class ClientAppTests Assert.Equal("new-icon", app.IconUrl); Assert.Equal(2, app.SortOrder); Assert.False(app.IsEnabled); + Assert.True(app.IsRecommended); } } diff --git a/docs/api-design.md b/docs/api-design.md index 83b3a23..7978916 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -88,10 +88,14 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро | ------ | -------------------------- | ----- | --------------------------------------------------------------------------------- | ------------- | | GET | `/api/apps` | user | — | `Record` | | GET | `/api/admin/apps` | admin | — | `AdminAppDto[]` (вкл. выключенные) | -| POST | `/api/admin/apps` | admin | `{ name, downloadUrl, operatingSystem, description?, iconUrl?, sortOrder }` | `AdminAppDto` | -| PUT | `/api/admin/apps/{id}` | admin | `{ name, downloadUrl, operatingSystem, description?, iconUrl?, sortOrder, isEnabled }` | `AdminAppDto` | +| POST | `/api/admin/apps` | admin | `{ name, downloadUrl, operatingSystem, description?, iconUrl?, sortOrder, isRecommended }` | `AdminAppDto` | +| PUT | `/api/admin/apps/{id}` | admin | `{ name, downloadUrl, operatingSystem, description?, iconUrl?, sortOrder, isEnabled, isRecommended }` | `AdminAppDto` | | DELETE | `/api/admin/apps/{id}` | admin | — | `204 No Content` | +`IsRecommended` — рекомендованные приложения идут первыми внутри своей группы ОС на `/api/apps` +(сортировка `IsRecommended desc, SortOrder asc`, применяется после фильтра `IsEnabled`), помечаются +значком-звездой на странице инструкций и в списке приложений в админке. + `GET /api/apps` → пример (только `isEnabled == true`, ОС без приложений в ответе отсутствует): ```json { diff --git a/docs/domain-model.md b/docs/domain-model.md index ac85fb1..e7277c7 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -151,8 +151,10 @@ AppUser | `IconUrl` | `string?` | Иконка (опц.) | | `SortOrder` | `int` | Порядок внутри группы ОС | | `IsEnabled` | `bool` | Показывать пользователям | +| `IsRecommended` | `bool` | Показывать первыми в группе ОС + значок на фронте | -Управляется админом (CRUD). Пользователю отдаётся только `IsEnabled`, сгруппировано по `OperatingSystem`. +Управляется админом (CRUD). Пользователю отдаётся только `IsEnabled`/`IsRecommended`, сгруппировано +по `OperatingSystem`; внутри группы `IsRecommended` (сначала true) → `SortOrder`. Стартовый набор сидируется из [`seed/client-apps.json`](../seed/client-apps.json), если таблица пуста. Массовая очистка отключённых (`IsEnabled = false`) — вкладка «Обслуживание», `DELETE /api/admin/maintenance/apps/disabled`. diff --git a/frontend/src/features/admin/apps/AppFormDialog.tsx b/frontend/src/features/admin/apps/AppFormDialog.tsx index ca5e6cf..ce26f32 100644 --- a/frontend/src/features/admin/apps/AppFormDialog.tsx +++ b/frontend/src/features/admin/apps/AppFormDialog.tsx @@ -24,6 +24,7 @@ export function AppFormDialog({ app, open, onOpenChange }: { app?: AdminAppDto; const [iconUrl, setIconUrl] = useState(app?.iconUrl ?? '') const [sortOrder, setSortOrder] = useState(String(app?.sortOrder ?? 0)) const [isEnabled, setIsEnabled] = useState(app?.isEnabled ?? true) + const [isRecommended, setIsRecommended] = useState(app?.isRecommended ?? false) const isControlled = open !== undefined const dialogOpen = isControlled ? open : internalOpen @@ -32,8 +33,26 @@ export function AppFormDialog({ app, open, onOpenChange }: { app?: AdminAppDto; const mutation = useMutation({ mutationFn: () => app - ? updateApp(app.id, name.trim(), downloadUrl.trim(), operatingSystem, description.trim() || undefined, iconUrl.trim() || undefined, Number(sortOrder), isEnabled) - : createApp(name.trim(), downloadUrl.trim(), operatingSystem, description.trim() || undefined, iconUrl.trim() || undefined, Number(sortOrder)), + ? updateApp( + app.id, + name.trim(), + downloadUrl.trim(), + operatingSystem, + description.trim() || undefined, + iconUrl.trim() || undefined, + Number(sortOrder), + isEnabled, + isRecommended, + ) + : createApp( + name.trim(), + downloadUrl.trim(), + operatingSystem, + description.trim() || undefined, + iconUrl.trim() || undefined, + Number(sortOrder), + isRecommended, + ), onSuccess: async () => { toast.success(app ? t('admin.apps.updated') : t('admin.apps.created')) await queryClient.invalidateQueries({ queryKey: ['admin-apps'] }) @@ -97,6 +116,10 @@ export function AppFormDialog({ app, open, onOpenChange }: { app?: AdminAppDto; setSortOrder(e.target.value)} /> + {app && (