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.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user