Enhance app management with 'IsRecommended' feature
- Added 'IsRecommended' property to app-related data models, allowing apps to be marked as recommended. - Updated API endpoints for creating and updating apps to include 'IsRecommended' in request bodies. - Modified database schema to accommodate the new 'IsRecommended' field. - Enhanced frontend components to display recommended apps with a star icon and updated forms to manage this property. - Improved sorting logic in app listings to prioritize recommended apps. - Updated documentation to reflect changes in API and data models.
This commit is contained in:
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,5 +10,6 @@ public sealed record CreateAppCommand(
|
||||
OsPlatform OperatingSystem,
|
||||
string? Description,
|
||||
string? IconUrl,
|
||||
int SortOrder
|
||||
int SortOrder,
|
||||
bool IsRecommended
|
||||
) : ICommand<Result<AdminAppDto>>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -12,5 +12,6 @@ public sealed record UpdateAppCommand(
|
||||
string? Description,
|
||||
string? IconUrl,
|
||||
int SortOrder,
|
||||
bool IsEnabled
|
||||
bool IsEnabled,
|
||||
bool IsRecommended
|
||||
) : ICommand<Result<AdminAppDto>>;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,6 +13,10 @@ public sealed class ClientApp : Entity
|
||||
public int SortOrder { get; private set; }
|
||||
public bool IsEnabled { get; private set; }
|
||||
|
||||
/// <summary>Рекомендованные приложения показываются первыми внутри своей группы ОС и
|
||||
/// помечаются значком на странице инструкций.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
+890
@@ -0,0 +1,890 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using PnvPanel.Infrastructure.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260714160950_AddIsRecommendedToClientApp")]
|
||||
partial class AddIsRecommendedToClientApp
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Comment")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("DecidedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DecidedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("RejectionReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "Status");
|
||||
|
||||
b.ToTable("ActivationRequests", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("DownloadUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("IconUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsRecommended")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("OperatingSystem")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ClientApps", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid?>("ActorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Metadata")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("TargetId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("TargetType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("AuditLogs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<Guid>("ConfigId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("DownBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTimeOffset>("Timestamp")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("UpBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ConfigId", "Timestamp");
|
||||
|
||||
b.ToTable("TrafficSamples", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ClientEmail")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("ClientExternalId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("InboundId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("SubscriptionToken")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<long>("UsedDownBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("UsedUpBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InboundId");
|
||||
|
||||
b.HasIndex("SubscriptionToken")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId", "Status");
|
||||
|
||||
b.ToTable("VpnConfigs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.PrimitiveCollection<Guid[]>("AllowedRoleIds")
|
||||
.IsRequired()
|
||||
.HasColumnType("uuid[]");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("IsPublished")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("MaxClients")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("NodeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Port")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Remark")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("RemoteInboundId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NodeId", "RemoteInboundId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Inbounds", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.News.NewsPost", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20000)
|
||||
.HasColumnType("character varying(20000)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("NewsPosts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BaseAddress")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Nodes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("ProposedMaxConfigs")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("ProposedMaxIpLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ProposedRoleName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid?>("RequestedRoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Type", "Status");
|
||||
|
||||
b.HasIndex("UserId", "Status");
|
||||
|
||||
b.ToTable("SupportTickets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Support.TicketAttachment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CommentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StoredFileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CommentId");
|
||||
|
||||
b.HasIndex("StoredFileName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("TicketAttachments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Support.TicketComment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AuthorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("TicketId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TicketId", "CreatedAt");
|
||||
|
||||
b.ToTable("TicketComments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("ConsumedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("TelegramLinkTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Context")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid?>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("TelegramLoginRequests", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsSystem")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("MaxConfigs")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("MaxIpLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("ActivatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("ActivatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsActivated")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsBlocked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SubscriptionToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("TelegramLinkedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("TelegramUserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("TelegramUsername")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.HasIndex("SubscriptionToken")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TelegramUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ReplacedByTokenHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("RefreshTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b =>
|
||||
{
|
||||
b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("NodeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("ProtectedPassword")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("CredentialsProtectedPassword");
|
||||
|
||||
b1.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("CredentialsUsername");
|
||||
|
||||
b1.HasKey("NodeId");
|
||||
|
||||
b1.ToTable("Nodes");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("NodeId");
|
||||
});
|
||||
|
||||
b.Navigation("Credentials")
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddIsRecommendedToClientApp : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsRecommended",
|
||||
table: "ClientApps",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(name: "IsRecommended", table: "ClientApps");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -185,6 +185,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsRecommended")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
|
||||
+8
-4
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -88,10 +88,14 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро
|
||||
| ------ | -------------------------- | ----- | --------------------------------------------------------------------------------- | ------------- |
|
||||
| GET | `/api/apps` | user | — | `Record<OsPlatform, ClientAppDto[]>` |
|
||||
| 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
|
||||
{
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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;
|
||||
<Label htmlFor="sortOrder">{t('admin.apps.sortOrder')}</Label>
|
||||
<Input id="sortOrder" type="number" min={0} value={sortOrder} onChange={(e) => setSortOrder(e.target.value)} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={isRecommended} onChange={(e) => setIsRecommended(e.target.checked)} />
|
||||
{t('admin.apps.recommended')}
|
||||
</label>
|
||||
{app && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
|
||||
|
||||
@@ -12,10 +12,19 @@ export function createApp(
|
||||
description: string | undefined,
|
||||
iconUrl: string | undefined,
|
||||
sortOrder: number,
|
||||
isRecommended: boolean,
|
||||
) {
|
||||
return apiRequest<AdminAppDto>('/admin/apps', {
|
||||
method: 'POST',
|
||||
body: { name, downloadUrl, operatingSystem, description: description ?? null, iconUrl: iconUrl ?? null, sortOrder },
|
||||
body: {
|
||||
name,
|
||||
downloadUrl,
|
||||
operatingSystem,
|
||||
description: description ?? null,
|
||||
iconUrl: iconUrl ?? null,
|
||||
sortOrder,
|
||||
isRecommended,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,10 +37,20 @@ export function updateApp(
|
||||
iconUrl: string | undefined,
|
||||
sortOrder: number,
|
||||
isEnabled: boolean,
|
||||
isRecommended: boolean,
|
||||
) {
|
||||
return apiRequest<AdminAppDto>(`/admin/apps/${id}`, {
|
||||
method: 'PUT',
|
||||
body: { name, downloadUrl, operatingSystem, description: description ?? null, iconUrl: iconUrl ?? null, sortOrder, isEnabled },
|
||||
body: {
|
||||
name,
|
||||
downloadUrl,
|
||||
operatingSystem,
|
||||
description: description ?? null,
|
||||
iconUrl: iconUrl ?? null,
|
||||
sortOrder,
|
||||
isEnabled,
|
||||
isRecommended,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Download } from 'lucide-react'
|
||||
import { Download, Star } from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import type { OsPlatform } from '@/shared/api/types'
|
||||
@@ -58,7 +58,15 @@ export function AppsCatalog() {
|
||||
<Download className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
<div>
|
||||
<CardTitle className="text-sm">{app.name}</CardTitle>
|
||||
<CardTitle className="flex items-center gap-1.5 text-sm">
|
||||
{app.name}
|
||||
{app.isRecommended && (
|
||||
<Star
|
||||
className="h-3.5 w-3.5 shrink-0 fill-yellow-400 text-yellow-400"
|
||||
aria-label={t('instructions.recommended')}
|
||||
/>
|
||||
)}
|
||||
</CardTitle>
|
||||
{app.description && <p className="text-xs text-muted-foreground">{app.description}</p>}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Star } from 'lucide-react'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
@@ -58,6 +59,9 @@ function AdminAppsPage() {
|
||||
.map((app) => (
|
||||
<div key={app.id} className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
{app.isRecommended && (
|
||||
<Star className="h-3.5 w-3.5 shrink-0 fill-yellow-400 text-yellow-400" aria-label={t('admin.apps.recommended')} />
|
||||
)}
|
||||
<span>{app.name}</span>
|
||||
{!app.isEnabled && <Badge variant="outline">{t('admin.apps.disabled')}</Badge>}
|
||||
</div>
|
||||
|
||||
@@ -102,6 +102,7 @@ export type ClientAppDto = {
|
||||
downloadUrl: string
|
||||
description: string | null
|
||||
iconUrl: string | null
|
||||
isRecommended: boolean
|
||||
}
|
||||
|
||||
/** GET /api/apps — сгруппировано по ОС на бэкенде; отсутствующая ОС значит "нет приложений". */
|
||||
@@ -212,6 +213,7 @@ export type AdminAppDto = {
|
||||
iconUrl: string | null
|
||||
sortOrder: number
|
||||
isEnabled: boolean
|
||||
isRecommended: boolean
|
||||
}
|
||||
|
||||
export type StatsDto = {
|
||||
|
||||
@@ -108,6 +108,7 @@ const resources = {
|
||||
step3: 'Импортируйте ссылку или отсканируйте QR в приложении — готово.',
|
||||
appsTitle: 'Приложения',
|
||||
noApps: 'Каталог приложений пока пуст.',
|
||||
recommended: 'Рекомендуем',
|
||||
os: {
|
||||
IOS: 'iOS',
|
||||
Android: 'Android',
|
||||
@@ -311,6 +312,7 @@ const resources = {
|
||||
sortOrder: 'Порядок',
|
||||
enabled: 'Включено',
|
||||
disabled: 'отключено',
|
||||
recommended: 'Рекомендовано',
|
||||
empty: 'Каталог приложений пуст.',
|
||||
created: 'Приложение добавлено.',
|
||||
updated: 'Приложение обновлено.',
|
||||
@@ -513,6 +515,7 @@ const resources = {
|
||||
step3: 'Import the link or scan the QR code in the app — done.',
|
||||
appsTitle: 'Apps',
|
||||
noApps: 'The app catalog is empty right now.',
|
||||
recommended: 'Recommended',
|
||||
os: {
|
||||
IOS: 'iOS',
|
||||
Android: 'Android',
|
||||
@@ -716,6 +719,7 @@ const resources = {
|
||||
sortOrder: 'Sort order',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'disabled',
|
||||
recommended: 'Recommended',
|
||||
empty: 'The app catalog is empty.',
|
||||
created: 'App added.',
|
||||
updated: 'App updated.',
|
||||
|
||||
Reference in New Issue
Block a user