From b6637a1c032a06cc4bf31eb02057d7d6196e52d2 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 3 Jul 2026 15:28:33 +0300 Subject: [PATCH] Add news feature with CRUD operations and real-time notifications - Implemented news management functionality, allowing admins to create, read, update, and delete news posts. - Introduced a new SignalR event for broadcasting news updates to all connected clients. - Updated API documentation to include new endpoints for news management. - Enhanced frontend with a dedicated news page and admin interface for managing news posts. - Added necessary localization for news-related terms in both Russian and English. --- .../Endpoints/AdminNewsEndpoints.cs | 52 ++ .../PnvPanel.Api/Endpoints/NewsEndpoints.cs | 24 + .../Hubs/SignalRRealtimeNotifier.cs | 8 + backend/src/PnvPanel.Api/Program.cs | 2 + .../Admin/News/CreatePostCommand.cs | 7 + .../Admin/News/CreatePostCommandHandler.cs | 21 + .../Admin/News/CreatePostCommandValidator.cs | 12 + .../Admin/News/DeletePostCommand.cs | 6 + .../Admin/News/DeletePostCommandHandler.cs | 19 + .../Admin/News/ListAdminNewsQuery.cs | 7 + .../Admin/News/ListAdminNewsQueryHandler.cs | 23 + .../Admin/News/NewsErrors.cs | 8 + .../Admin/News/UpdatePostCommand.cs | 7 + .../Admin/News/UpdatePostCommandHandler.cs | 21 + .../Admin/News/UpdatePostCommandValidator.cs | 12 + .../Common/Interfaces/IAppDbContext.cs | 3 + .../Common/Interfaces/IRealtimeNotifier.cs | 3 + .../News/ListNewsQuery.cs | 6 + .../News/ListNewsQueryHandler.cs | 22 + .../PnvPanel.Application/News/NewsPostDto.cs | 8 + backend/src/PnvPanel.Domain/News/NewsPost.cs | 34 + .../Persistence/AppDbContext.cs | 3 + .../Configurations/NewsPostConfiguration.cs | 19 + .../20260703114324_AddNewsPosts.Designer.cs | 773 +++++++++++++++ .../Migrations/20260703114324_AddNewsPosts.cs | 42 + .../Migrations/AppDbContextModelSnapshot.cs | 29 + docs/api-design.md | 15 + docs/architecture.md | 4 +- docs/backend-conventions.md | 5 +- docs/domain-model.md | 16 + docs/frontend.md | 17 +- docs/vision.md | 1 + frontend/package.json | 2 + frontend/pnpm-lock.yaml | 883 +++++++++++++++++- .../features/admin/news/NewsFormDialog.tsx | 92 ++ frontend/src/features/admin/news/api.ts | 18 + frontend/src/features/news/NewsFeed.tsx | 67 ++ frontend/src/features/news/api.ts | 6 + frontend/src/routeTree.gen.ts | 42 + frontend/src/routes/__root.tsx | 3 + frontend/src/routes/admin.tsx | 1 + frontend/src/routes/admin/news.tsx | 99 ++ frontend/src/routes/news.tsx | 22 + frontend/src/shared/api/types.ts | 9 + frontend/src/shared/lib/i18n.ts | 36 + .../src/shared/realtime/RealtimeProvider.tsx | 7 + frontend/src/shared/ui/textarea.tsx | 16 + 47 files changed, 2523 insertions(+), 9 deletions(-) create mode 100644 backend/src/PnvPanel.Api/Endpoints/AdminNewsEndpoints.cs create mode 100644 backend/src/PnvPanel.Api/Endpoints/NewsEndpoints.cs create mode 100644 backend/src/PnvPanel.Application/Admin/News/CreatePostCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/News/CreatePostCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/News/CreatePostCommandValidator.cs create mode 100644 backend/src/PnvPanel.Application/Admin/News/DeletePostCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/News/DeletePostCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/News/ListAdminNewsQuery.cs create mode 100644 backend/src/PnvPanel.Application/Admin/News/ListAdminNewsQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/News/NewsErrors.cs create mode 100644 backend/src/PnvPanel.Application/Admin/News/UpdatePostCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/News/UpdatePostCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/News/UpdatePostCommandValidator.cs create mode 100644 backend/src/PnvPanel.Application/News/ListNewsQuery.cs create mode 100644 backend/src/PnvPanel.Application/News/ListNewsQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/News/NewsPostDto.cs create mode 100644 backend/src/PnvPanel.Domain/News/NewsPost.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Configurations/NewsPostConfiguration.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260703114324_AddNewsPosts.Designer.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260703114324_AddNewsPosts.cs create mode 100644 frontend/src/features/admin/news/NewsFormDialog.tsx create mode 100644 frontend/src/features/admin/news/api.ts create mode 100644 frontend/src/features/news/NewsFeed.tsx create mode 100644 frontend/src/features/news/api.ts create mode 100644 frontend/src/routes/admin/news.tsx create mode 100644 frontend/src/routes/news.tsx create mode 100644 frontend/src/shared/ui/textarea.tsx diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminNewsEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminNewsEndpoints.cs new file mode 100644 index 0000000..b47f06c --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/AdminNewsEndpoints.cs @@ -0,0 +1,52 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Admin.News; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.News; +using PnvPanel.Infrastructure.Identity; + +namespace PnvPanel.Api.Endpoints; + +public static class AdminNewsEndpoints +{ + public static IEndpointRouteBuilder MapAdminNewsEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/news") + .WithTags("Admin.News") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapGet("", ListAdminNews).Produces>(); + admin.MapPost("", CreatePost).Produces(); + admin.MapPut("/{id:guid}", UpdatePost).Produces(); + admin.MapDelete("/{id:guid}", DeletePost).Produces(StatusCodes.Status204NoContent); + + return app; + } + + private static async Task ListAdminNews(int page, int pageSize, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ListAdminNewsQuery(page, pageSize), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task CreatePost(CreatePostCommand command, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task UpdatePost(Guid id, UpdatePostBody body, ISender sender, CancellationToken cancellationToken) + { + var command = new UpdatePostCommand(id, body.Title, body.Body); + var result = await sender.Send(command, cancellationToken); + return result.ToHttpResult(); + } + + private static async Task DeletePost(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new DeletePostCommand(id), cancellationToken); + return result.ToHttpResult(); + } +} + +public sealed record UpdatePostBody(string Title, string Body); diff --git a/backend/src/PnvPanel.Api/Endpoints/NewsEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/NewsEndpoints.cs new file mode 100644 index 0000000..4dcc9cd --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/NewsEndpoints.cs @@ -0,0 +1,24 @@ +using PnvPanel.Api.Common; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.News; + +namespace PnvPanel.Api.Endpoints; + +public static class NewsEndpoints +{ + public static IEndpointRouteBuilder MapNewsEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/api/news", ListNews) + .WithTags("News") + .RequireAuthorization() + .Produces>(); + return app; + } + + private static async Task ListNews(int page, int pageSize, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ListNewsQuery(page, pageSize), cancellationToken); + return result.ToHttpResult(); + } +} diff --git a/backend/src/PnvPanel.Api/Hubs/SignalRRealtimeNotifier.cs b/backend/src/PnvPanel.Api/Hubs/SignalRRealtimeNotifier.cs index 876244e..c51daaa 100644 --- a/backend/src/PnvPanel.Api/Hubs/SignalRRealtimeNotifier.cs +++ b/backend/src/PnvPanel.Api/Hubs/SignalRRealtimeNotifier.cs @@ -48,4 +48,12 @@ internal sealed class SignalRRealtimeNotifier(IHubContext hubContext) new { userId }, cancellationToken); } + + public Task NotifyNewsPublishedAsync(Guid postId, string title, DateTimeOffset createdAt, CancellationToken cancellationToken) + { + return hubContext.Clients.All.SendAsync( + "newsPublished", + new { id = postId, title, createdAt }, + cancellationToken); + } } diff --git a/backend/src/PnvPanel.Api/Program.cs b/backend/src/PnvPanel.Api/Program.cs index abee04e..7331a92 100644 --- a/backend/src/PnvPanel.Api/Program.cs +++ b/backend/src/PnvPanel.Api/Program.cs @@ -151,9 +151,11 @@ app.MapInboundEndpoints(); app.MapConfigEndpoints(); app.MapSubscriptionEndpoints(); app.MapAppEndpoints(); +app.MapNewsEndpoints(); app.MapAdminUserEndpoints(); app.MapAdminStatsEndpoints(); app.MapAdminAppEndpoints(); +app.MapAdminNewsEndpoints(); app.MapTelegramEndpoints(); app.MapHub("/hubs/panel"); diff --git a/backend/src/PnvPanel.Application/Admin/News/CreatePostCommand.cs b/backend/src/PnvPanel.Application/Admin/News/CreatePostCommand.cs new file mode 100644 index 0000000..80bdd8a --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/News/CreatePostCommand.cs @@ -0,0 +1,7 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.News; + +namespace PnvPanel.Application.Admin.News; + +public sealed record CreatePostCommand(string Title, string Body) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/News/CreatePostCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/News/CreatePostCommandHandler.cs new file mode 100644 index 0000000..4d6f73a --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/News/CreatePostCommandHandler.cs @@ -0,0 +1,21 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.News; +using PnvPanel.Domain.News; + +namespace PnvPanel.Application.Admin.News; + +public sealed class CreatePostCommandHandler(IAppDbContext dbContext, IRealtimeNotifier notifier) + : ICommandHandler> +{ + public async Task> Handle(CreatePostCommand command, CancellationToken cancellationToken) + { + var post = NewsPost.Create(command.Title, command.Body); + dbContext.NewsPosts.Add(post); + + await notifier.NotifyNewsPublishedAsync(post.Id, post.Title, post.CreatedAt, cancellationToken); + + return Result.Success(NewsPostDto.FromDomain(post)); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/News/CreatePostCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/News/CreatePostCommandValidator.cs new file mode 100644 index 0000000..cff11b3 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/News/CreatePostCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.News; + +public sealed class CreatePostCommandValidator : AbstractValidator +{ + public CreatePostCommandValidator() + { + RuleFor(x => x.Title).NotEmpty().MaximumLength(200); + RuleFor(x => x.Body).NotEmpty().MaximumLength(20000); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/News/DeletePostCommand.cs b/backend/src/PnvPanel.Application/Admin/News/DeletePostCommand.cs new file mode 100644 index 0000000..837fd8a --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/News/DeletePostCommand.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.News; + +public sealed record DeletePostCommand(Guid PostId) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/News/DeletePostCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/News/DeletePostCommandHandler.cs new file mode 100644 index 0000000..4217794 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/News/DeletePostCommandHandler.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.News; + +public sealed class DeletePostCommandHandler(IAppDbContext dbContext) : ICommandHandler +{ + public async Task Handle(DeletePostCommand command, CancellationToken cancellationToken) + { + var post = await dbContext.NewsPosts.FirstOrDefaultAsync(p => p.Id == command.PostId, cancellationToken); + if (post is null) + return Result.Failure(NewsErrors.NotFound); + + dbContext.NewsPosts.Remove(post); + return Result.Success(); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/News/ListAdminNewsQuery.cs b/backend/src/PnvPanel.Application/Admin/News/ListAdminNewsQuery.cs new file mode 100644 index 0000000..db07567 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/News/ListAdminNewsQuery.cs @@ -0,0 +1,7 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.News; + +namespace PnvPanel.Application.Admin.News; + +public sealed record ListAdminNewsQuery(int Page, int PageSize) : IQuery>>; diff --git a/backend/src/PnvPanel.Application/Admin/News/ListAdminNewsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/News/ListAdminNewsQueryHandler.cs new file mode 100644 index 0000000..7bf3d8f --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/News/ListAdminNewsQueryHandler.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.News; + +namespace PnvPanel.Application.Admin.News; + +public sealed class ListAdminNewsQueryHandler(IAppDbContext dbContext) : IQueryHandler>> +{ + public async Task>> Handle(ListAdminNewsQuery query, CancellationToken cancellationToken) + { + var page = query.Page <= 0 ? 1 : query.Page; + var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize; + + var result = await dbContext.NewsPosts.AsNoTracking() + .OrderByDescending(p => p.CreatedAt) + .Select(p => new NewsPostDto(p.Id, p.Title, p.Body, p.CreatedAt, p.UpdatedAt)) + .ToPagedListAsync(page, pageSize, cancellationToken); + + return Result.Success(result); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/News/NewsErrors.cs b/backend/src/PnvPanel.Application/Admin/News/NewsErrors.cs new file mode 100644 index 0000000..8844e0a --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/News/NewsErrors.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.News; + +public static class NewsErrors +{ + public static readonly Error NotFound = Error.NotFound("News.NotFound", "Новость не найдена."); +} diff --git a/backend/src/PnvPanel.Application/Admin/News/UpdatePostCommand.cs b/backend/src/PnvPanel.Application/Admin/News/UpdatePostCommand.cs new file mode 100644 index 0000000..db67b58 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/News/UpdatePostCommand.cs @@ -0,0 +1,7 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.News; + +namespace PnvPanel.Application.Admin.News; + +public sealed record UpdatePostCommand(Guid PostId, string Title, string Body) : ICommand>; diff --git a/backend/src/PnvPanel.Application/Admin/News/UpdatePostCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/News/UpdatePostCommandHandler.cs new file mode 100644 index 0000000..65db338 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/News/UpdatePostCommandHandler.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.News; + +namespace PnvPanel.Application.Admin.News; + +public sealed class UpdatePostCommandHandler(IAppDbContext dbContext) : ICommandHandler> +{ + public async Task> Handle(UpdatePostCommand command, CancellationToken cancellationToken) + { + var post = await dbContext.NewsPosts.FirstOrDefaultAsync(p => p.Id == command.PostId, cancellationToken); + if (post is null) + return Result.Failure(NewsErrors.NotFound); + + post.Update(command.Title, command.Body); + + return Result.Success(NewsPostDto.FromDomain(post)); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/News/UpdatePostCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/News/UpdatePostCommandValidator.cs new file mode 100644 index 0000000..a2efe2f --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/News/UpdatePostCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace PnvPanel.Application.Admin.News; + +public sealed class UpdatePostCommandValidator : AbstractValidator +{ + public UpdatePostCommandValidator() + { + RuleFor(x => x.Title).NotEmpty().MaximumLength(200); + RuleFor(x => x.Body).NotEmpty().MaximumLength(20000); + } +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs index 760e5e7..b6a5f19 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.News; using PnvPanel.Domain.Nodes; using PnvPanel.Domain.Telegram; @@ -30,6 +31,8 @@ public interface IAppDbContext DbSet ClientApps { get; } + DbSet NewsPosts { get; } + /// Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler). DatabaseFacade Database { get; } diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IRealtimeNotifier.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IRealtimeNotifier.cs index 50f2529..172efda 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IRealtimeNotifier.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IRealtimeNotifier.cs @@ -21,4 +21,7 @@ public interface IRealtimeNotifier Guid requestId, Guid userId, string userName, string? comment, DateTimeOffset createdAt, CancellationToken cancellationToken); Task NotifyUserActivatedAsync(Guid userId, CancellationToken cancellationToken); + + /// Единственное широковещательное событие (всем подключенным клиентам), а не по группе. + Task NotifyNewsPublishedAsync(Guid postId, string title, DateTimeOffset createdAt, CancellationToken cancellationToken); } diff --git a/backend/src/PnvPanel.Application/News/ListNewsQuery.cs b/backend/src/PnvPanel.Application/News/ListNewsQuery.cs new file mode 100644 index 0000000..fda6b8c --- /dev/null +++ b/backend/src/PnvPanel.Application/News/ListNewsQuery.cs @@ -0,0 +1,6 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.News; + +public sealed record ListNewsQuery(int Page, int PageSize) : IQuery>>; diff --git a/backend/src/PnvPanel.Application/News/ListNewsQueryHandler.cs b/backend/src/PnvPanel.Application/News/ListNewsQueryHandler.cs new file mode 100644 index 0000000..386eb96 --- /dev/null +++ b/backend/src/PnvPanel.Application/News/ListNewsQueryHandler.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.News; + +public sealed class ListNewsQueryHandler(IAppDbContext dbContext) : IQueryHandler>> +{ + public async Task>> Handle(ListNewsQuery query, CancellationToken cancellationToken) + { + var page = query.Page <= 0 ? 1 : query.Page; + var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize; + + var result = await dbContext.NewsPosts.AsNoTracking() + .OrderByDescending(p => p.CreatedAt) + .Select(p => new NewsPostDto(p.Id, p.Title, p.Body, p.CreatedAt, p.UpdatedAt)) + .ToPagedListAsync(page, pageSize, cancellationToken); + + return Result.Success(result); + } +} diff --git a/backend/src/PnvPanel.Application/News/NewsPostDto.cs b/backend/src/PnvPanel.Application/News/NewsPostDto.cs new file mode 100644 index 0000000..2bf3ff2 --- /dev/null +++ b/backend/src/PnvPanel.Application/News/NewsPostDto.cs @@ -0,0 +1,8 @@ +using PnvPanel.Domain.News; + +namespace PnvPanel.Application.News; + +public sealed record NewsPostDto(Guid Id, string Title, string Body, DateTimeOffset CreatedAt, DateTimeOffset? UpdatedAt) +{ + public static NewsPostDto FromDomain(NewsPost post) => new(post.Id, post.Title, post.Body, post.CreatedAt, post.UpdatedAt); +} diff --git a/backend/src/PnvPanel.Domain/News/NewsPost.cs b/backend/src/PnvPanel.Domain/News/NewsPost.cs new file mode 100644 index 0000000..afda70a --- /dev/null +++ b/backend/src/PnvPanel.Domain/News/NewsPost.cs @@ -0,0 +1,34 @@ +using PnvPanel.Domain.Common; + +namespace PnvPanel.Domain.News; + +/// Новость, которую публикует админ. Публикация мгновенная — нет статуса черновик/опубликовано. +public sealed class NewsPost : Entity +{ + public string Title { get; private set; } = string.Empty; + public string Body { get; private set; } = string.Empty; + public DateTimeOffset CreatedAt { get; private set; } + public DateTimeOffset? UpdatedAt { get; private set; } + + private NewsPost() + { + } + + public static NewsPost Create(string title, string body) + { + return new NewsPost + { + Id = Guid.NewGuid(), + Title = title, + Body = body, + CreatedAt = DateTimeOffset.UtcNow, + }; + } + + public void Update(string title, string body) + { + Title = title; + Body = body; + UpdatedAt = DateTimeOffset.UtcNow; + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs b/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs index f7006d1..3522c52 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.News; using PnvPanel.Domain.Nodes; using PnvPanel.Domain.Telegram; using PnvPanel.Infrastructure.Identity; @@ -39,6 +40,8 @@ public class AppDbContext(DbContextOptions options) public DbSet ClientApps => Set(); + public DbSet NewsPosts => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/NewsPostConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/NewsPostConfiguration.cs new file mode 100644 index 0000000..f494ae3 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/NewsPostConfiguration.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PnvPanel.Domain.News; + +namespace PnvPanel.Infrastructure.Persistence.Configurations; + +public class NewsPostConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("NewsPosts"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Title).IsRequired().HasMaxLength(200); + builder.Property(x => x.Body).IsRequired().HasMaxLength(20000); + + builder.HasIndex(x => x.CreatedAt); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260703114324_AddNewsPosts.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260703114324_AddNewsPosts.Designer.cs new file mode 100644 index 0000000..70094b2 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260703114324_AddNewsPosts.Designer.cs @@ -0,0 +1,773 @@ +// +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("20260703114324_AddNewsPosts")] + partial class AddNewsPosts + { + /// + 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("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.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("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/20260703114324_AddNewsPosts.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260703114324_AddNewsPosts.cs new file mode 100644 index 0000000..c0339d9 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260703114324_AddNewsPosts.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddNewsPosts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "NewsPosts", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Body = table.Column(type: "character varying(20000)", maxLength: 20000, 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_NewsPosts", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_NewsPosts_CreatedAt", + table: "NewsPosts", + column: "CreatedAt"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "NewsPosts"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 78347c0..3512ec1 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -394,6 +394,35 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations 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") diff --git a/docs/api-design.md b/docs/api-design.md index 54640eb..1dcc965 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -101,6 +101,20 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро ``` Значение `OsPlatform` в C#/JSON — `IOS` (не `iOS`). +## News — лента новостей + +| Метод | Путь | Роль | Тело запроса | Тело ответа | +| ------ | ------------------------ | ----- | ------------------------ | ------------- | +| GET | `/api/news` | user | query: `page, pageSize` | `PagedList` | +| GET | `/api/admin/news` | admin | query: `page, pageSize` | `PagedList` | +| POST | `/api/admin/news` | admin | `{ title, body }` | `NewsPostDto` | +| PUT | `/api/admin/news/{id}` | admin | `{ title, body }` | `NewsPostDto` | +| DELETE | `/api/admin/news/{id}` | admin | — | `204 No Content` | + +Нет черновиков/отложенной публикации — `POST` сразу видна всем аутентифицированным пользователям +и триггерит SignalR-событие `newsPublished` (см. ниже). `NewsPostDto`: +`{ id, title, body, createdAt, updatedAt }`. + ## Activation (пользователь) | Метод | Путь | Роль | Тело запроса | Тело ответа | @@ -190,6 +204,7 @@ totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes }` — счи | `nodeStatusChanged` | `{ nodeId, status, lastSyncAt }` | `admins` | | `activationRequested` | `{ requestId, userId, userName, comment, createdAt }` | `admins` | | `userActivated` | `{ userId }` | владельцу | +| `newsPublished` | `{ id, title, createdAt }` | все (broadcast) | ### Client → Server Клиент только слушает; группировка по пользователю происходит на сервере при подключении, по diff --git a/docs/architecture.md b/docs/architecture.md index 56ce97c..dac2bb9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -189,7 +189,9 @@ POST /api/configs (события нод/системы/активации) — пользователь при подключении добавляется в свою `user:{userId}` и, если он админ, дополнительно в `admins`. - **События сервер→клиент**: `configTrafficUpdated`, `configStatusChanged`, `nodeStatusChanged`, - `activationRequested`, `userActivated` — точные payload'ы см. [api-design.md](api-design.md#signalr--hub-hubspanel). + `activationRequested`, `userActivated`, `newsPublished` — точные payload'ы см. + [api-design.md](api-design.md#signalr--hub-hubspanel). `newsPublished` — единственное + широковещательное событие (`Clients.All`), а не по группе — новости видны всем без исключения. - Пуш выполняет `SignalRRealtimeNotifier` (порт `IRealtimeNotifier`, реализация в `Api/Hubs/`), вызываемый из хендлеров и фоновых сервисов — Application-слой не зависит от SignalR напрямую. diff --git a/docs/backend-conventions.md b/docs/backend-conventions.md index af62340..61b27f1 100644 --- a/docs/backend-conventions.md +++ b/docs/backend-conventions.md @@ -12,6 +12,7 @@ backend/ Common/ # Entity (единственный базовый класс — без AggregateRoot/IDomainEvent) Activation/ # ActivationRequest, ActivationStatus Apps/ # ClientApp, OsPlatform + News/ # NewsPost Audit/ # AuditLog, AuditSource Configs/ # VpnConfig, ConfigStatus, TrafficSample Inbounds/ # Inbound, VpnProtocol @@ -29,6 +30,7 @@ backend/ Admin/ Activation/ # ListActivationRequestsQuery, Approve/RejectActivationCommand Apps/ # CRUD ClientApp + News/ # CRUD NewsPost Audit/ # ListAuditLogsQuery Inbounds/ # ListInbounds, PublishInbound Nodes/ # RegisterNode, UpdateNode, DeleteNode, SyncNode, ProbeNode, ListNodes @@ -37,6 +39,7 @@ backend/ Users/ # ListUsers, BlockUser/UnblockUser, ChangeUserRole, ResetUserPassword, # ForceRevokeConfig, GetUserConfigs Apps/ # ListAppsQuery (по ОС, для юзера) + News/ # ListNewsQuery (пагинировано, для юзера) Auth/ ChangePassword/, DeleteMyAccount/, Login/, Logout/, Me/, Refresh/, Register/ Configs/ @@ -59,7 +62,7 @@ backend/ Telegram/ # TelegramNotifier, TelegramOptions DependencyInjection.cs # AddInfrastructure(...) PnvPanel.Api/ - Endpoints/ # 12 файлов, см. backend-conventions.md ниже и api-design.md + Endpoints/ # 14 файлов, см. backend-conventions.md ниже и api-design.md Hubs/ # PanelHub, SignalRRealtimeNotifier (реализация IRealtimeNotifier — здесь, # не в Infrastructure, т.к. нужен IHubContext) Telegram/ # TelegramBotHostedService, PnvBotUpdateHandler, TelegramNotifier diff --git a/docs/domain-model.md b/docs/domain-model.md index 2652ed5..77bbd9e 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -21,6 +21,7 @@ AppUser (Identity) [+ IsActivated, IsBlocked, TelegramUserId, SubscriptionToken VpnConfig ─*─ TrafficSample (история трафика; пишется TrafficSyncService) AuditLog (append-only журнал действий; ссылается на ActorId/TargetId) ClientApp (каталог приложений-клиентов; группируется по OperatingSystem) +NewsPost (лента новостей; публикуется админом, видна всем аутентифицированным пользователям) ``` ## Сущности @@ -143,6 +144,21 @@ ClientApp (каталог приложений-клиен Управляется админом (CRUD). Пользователю отдаётся только `IsEnabled`, сгруппировано по `OperatingSystem`. Стартовый набор сидируется из [`seed/client-apps.json`](../seed/client-apps.json), если таблица пуста. +### NewsPost — новости для пользователей +Публикуются админом немедленно, видны всем залогиненным пользователям в хронологической ленте. + +| Поле | Тип | Заметки | +| ----------- | ----------------- | ------------------------------------------------------- | +| `Id` | `Guid` | PK | +| `Title` | `string` | Заголовок | +| `Body` | `string` | Markdown-текст | +| `CreatedAt` | `DateTimeOffset` | Момент публикации (= момент создания, нет черновиков) | +| `UpdatedAt` | `DateTimeOffset?` | Момент последней правки (опц.) | + +Нет статуса черновик/запланировано — публикация мгновенная. Нет видимости по ролям — доступно +всем аутентифицированным пользователям. Realtime-оповещение о новом посте — `newsPublished` +(SignalR, широковещательно всем подключенным клиентам), см. [architecture.md](architecture.md#realtime-signalr). + ### AuditLog — журнал действий Аудит значимых действий (прежде всего админских) для расследований и прозрачности. diff --git a/docs/frontend.md b/docs/frontend.md index 713ca9a..56ffa94 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -23,6 +23,7 @@ SPA на **React 19 + Vite + TypeScript**. Общается с бэком по R | Формы | react-hook-form + zod | | Realtime | @microsoft/signalr | | QR-коды | qrcode.react (рендерит QR из готовой строки на клиенте) | +| Markdown | react-markdown + remark-gfm (лента новостей; без rehype-raw — сырой HTML не рендерится) | | Типы API | openapi-typescript (`pnpm gen:api`) — генерирует `schema.gen.ts` для сверки; фичи импортируют руками написанный `shared/api/types.ts` | | i18n | react-i18next (RU + EN) | | Линт | oxlint (не ESLint) | @@ -44,19 +45,20 @@ frontend/ index.css # Tailwind v4 (@import), без отдельной styles/-папки routes/ # файловый роутинг TanStack Router __root.tsx # шапка (лого, нав, переключатель языка/темы), Outlet - index.tsx, login.tsx, register.tsx, dashboard.tsx, instructions.tsx, settings.tsx + index.tsx, login.tsx, register.tsx, dashboard.tsx, instructions.tsx, news.tsx, settings.tsx admin.tsx # layout админки (вкладки) + Outlet admin/ - index.tsx, activation.tsx, users.tsx, roles.tsx, nodes.tsx, apps.tsx, audit.tsx + index.tsx, activation.tsx, users.tsx, roles.tsx, nodes.tsx, apps.tsx, news.tsx, audit.tsx features/ auth/ # api.ts, store.ts (zustand), guards.ts, LoginForm.tsx, RegisterForm.tsx activation/ # api.ts, ActivationGate.tsx (экран "запросить активацию") configs/ # api.ts, ConfigCard.tsx, CreateConfigDialog.tsx, SubscriptionCard.tsx apps/ # api.ts, AppsCatalog.tsx (для /instructions) + news/ # api.ts, NewsFeed.tsx (для /news) telegram/ # api.ts, TelegramLoginButton.tsx settings/ # ChangePasswordForm.tsx, TelegramLinkCard.tsx, DeleteAccountSection.tsx admin/ - users/, roles/, activation/, nodes/, inbounds/, apps/, audit/, stats/ # api.ts + диалоги CRUD в каждой + users/, roles/, activation/, nodes/, inbounds/, apps/, news/, audit/, stats/ # api.ts + диалоги CRUD в каждой theme/ ThemeProvider.tsx # React Context + localStorage (`pnv-theme`), НЕ zustand shared/ @@ -69,7 +71,7 @@ frontend/ realtime/ connection.ts, RealtimeProvider.tsx ui/ - button.tsx, input.tsx, label.tsx, card.tsx, dialog.tsx, select.tsx, badge.tsx, + button.tsx, input.tsx, textarea.tsx, label.tsx, card.tsx, dialog.tsx, select.tsx, badge.tsx, progress.tsx, toast-store.tsx, toaster.tsx index.html vite.config.ts @@ -93,6 +95,10 @@ frontend/ - **Страница инструкций** (`/instructions`): статичные шаги + каталог приложений (`GET /api/apps`), сгруппированный по ОС и показан вкладками (по одной ОС за раз); клик по приложению открывает ссылку на скачивание. +- **Лента новостей** (`/news`): хронологический список постов админа (заголовок + Markdown-тело, + рендерится через `react-markdown` + `remark-gfm`), пагинация (`GET /api/news`), живое обновление + по SignalR (`newsPublished`, широковещательно всем). Админка (`/admin/news`): CRUD, обычный + `