Add news feature with CRUD operations and real-time notifications
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s

- 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.
This commit is contained in:
Leonid Pershin
2026-07-03 15:28:33 +03:00
parent bea2b5fcf7
commit b6637a1c03
47 changed files with 2523 additions and 9 deletions
@@ -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<Result<NewsPostDto>>;
@@ -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<CreatePostCommand, Result<NewsPostDto>>
{
public async Task<Result<NewsPostDto>> 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));
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace PnvPanel.Application.Admin.News;
public sealed class CreatePostCommandValidator : AbstractValidator<CreatePostCommand>
{
public CreatePostCommandValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(200);
RuleFor(x => x.Body).NotEmpty().MaximumLength(20000);
}
}
@@ -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<Result>;
@@ -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<DeletePostCommand, Result>
{
public async Task<Result> 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();
}
}
@@ -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<Result<PagedList<NewsPostDto>>>;
@@ -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<ListAdminNewsQuery, Result<PagedList<NewsPostDto>>>
{
public async Task<Result<PagedList<NewsPostDto>>> 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);
}
}
@@ -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", "Новость не найдена.");
}
@@ -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<Result<NewsPostDto>>;
@@ -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<UpdatePostCommand, Result<NewsPostDto>>
{
public async Task<Result<NewsPostDto>> Handle(UpdatePostCommand command, CancellationToken cancellationToken)
{
var post = await dbContext.NewsPosts.FirstOrDefaultAsync(p => p.Id == command.PostId, cancellationToken);
if (post is null)
return Result.Failure<NewsPostDto>(NewsErrors.NotFound);
post.Update(command.Title, command.Body);
return Result.Success(NewsPostDto.FromDomain(post));
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace PnvPanel.Application.Admin.News;
public sealed class UpdatePostCommandValidator : AbstractValidator<UpdatePostCommand>
{
public UpdatePostCommandValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(200);
RuleFor(x => x.Body).NotEmpty().MaximumLength(20000);
}
}
@@ -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<ClientApp> ClientApps { get; }
DbSet<NewsPost> NewsPosts { get; }
/// <summary>Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler).</summary>
DatabaseFacade Database { get; }
@@ -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);
/// <summary>Единственное широковещательное событие (всем подключенным клиентам), а не по группе.</summary>
Task NotifyNewsPublishedAsync(Guid postId, string title, DateTimeOffset createdAt, CancellationToken cancellationToken);
}
@@ -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<Result<PagedList<NewsPostDto>>>;
@@ -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<ListNewsQuery, Result<PagedList<NewsPostDto>>>
{
public async Task<Result<PagedList<NewsPostDto>>> 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);
}
}
@@ -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);
}