Implement support ticket system with role request and bug report functionalities
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 31s

- Introduced a new support ticket system allowing users to submit bug reports and role requests.
- Implemented endpoints for creating, updating, and managing support tickets, including file attachments.
- Enhanced Telegram bot integration to handle role requests directly within the bot, enabling admins to approve or reject requests without accessing the website.
- Updated database schema to include support ticket entities and their relationships.
- Improved API documentation to reflect new support ticket endpoints and their usage.
- Added necessary localization for support ticket features in both Russian and English.
This commit is contained in:
Leonid Pershin
2026-07-14 06:49:05 +03:00
parent 14b64a3140
commit b5630b2685
98 changed files with 4463 additions and 6 deletions
@@ -0,0 +1,8 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Support;
/// <summary>Одобрение — при новой роли сначала создаёт её (IRoleService.CreateRoleAsync), затем в
/// любом случае назначает пользователю (ChangeUserRoleAsync) и переводит тикет в Resolved.</summary>
public sealed record ApproveRoleRequestCommand(Guid TicketId) : ICommand<Result>;
@@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Support;
using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Admin.Support;
public sealed class ApproveRoleRequestCommandHandler(
IAppDbContext dbContext, IRoleService roleService, IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
: ICommandHandler<ApproveRoleRequestCommand, Result>
{
public async Task<Result> Handle(ApproveRoleRequestCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized);
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken);
if (ticket is null)
return Result.Failure(SupportErrors.NotFound);
if (ticket.Type != TicketType.RoleRequest)
return Result.Failure(SupportErrors.NotRoleRequest);
if (ticket.Status != TicketStatus.Open)
return Result.Failure(SupportErrors.NotOpen);
Guid roleId;
if (ticket.RequestedRoleId is { } existingRoleId)
{
roleId = existingRoleId;
}
else
{
var createResult = await roleService.CreateRoleAsync(
ticket.ProposedRoleName!, ticket.ProposedMaxConfigs!.Value, ticket.ProposedMaxIpLimit!.Value, cancellationToken);
if (!createResult.IsSuccess)
return Result.Failure(createResult.Error);
roleId = createResult.Value.Id;
}
var assignResult = await roleService.ChangeUserRoleAsync(ticket.UserId, roleId, cancellationToken);
if (!assignResult.IsSuccess)
return assignResult;
ticket.Resolve();
dbContext.AuditLogs.Add(AuditLog.Create(
adminId, "RoleRequestApproved", "SupportTicket", ticket.Id.ToString(), metadata: null, AuditSource.Web));
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
await telegramNotifier.NotifyUserAsync(ticket.UserId, "✅ Ваша заявка на роль одобрена.", cancellationToken);
return Result.Success();
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Support;
public sealed record CloseTicketCommand(Guid TicketId) : ICommand<Result>;
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Support;
using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Admin.Support;
public sealed class CloseTicketCommandHandler(IAppDbContext dbContext, IRealtimeNotifier notifier, ICurrentUser currentUser)
: ICommandHandler<CloseTicketCommand, Result>
{
public async Task<Result> Handle(CloseTicketCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized);
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken);
if (ticket is null)
return Result.Failure(SupportErrors.NotFound);
if (ticket.Status == TicketStatus.Closed)
return Result.Failure(SupportErrors.AlreadyClosed);
ticket.Close();
dbContext.AuditLogs.Add(AuditLog.Create(
adminId, "TicketClosed", "SupportTicket", ticket.Id.ToString(), metadata: null, AuditSource.Web));
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
return Result.Success();
}
}
@@ -0,0 +1,7 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Support;
namespace PnvPanel.Application.Admin.Support;
public sealed record GetTicketAdminQuery(Guid TicketId) : IQuery<Result<TicketDetailDto>>;
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Support;
namespace PnvPanel.Application.Admin.Support;
public sealed class GetTicketAdminQueryHandler(IAppDbContext dbContext, IIdentityService identityService, IRoleService roleService)
: IQueryHandler<GetTicketAdminQuery, Result<TicketDetailDto>>
{
public async Task<Result<TicketDetailDto>> Handle(GetTicketAdminQuery query, CancellationToken cancellationToken)
{
var ticket = await dbContext.SupportTickets.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == query.TicketId, cancellationToken);
if (ticket is null)
return Result.Failure<TicketDetailDto>(SupportErrors.NotFound);
var dto = await TicketMapping.ToDetailDtoAsync(dbContext, identityService, roleService, ticket, cancellationToken);
return Result.Success(dto);
}
}
@@ -0,0 +1,9 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Support;
using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Admin.Support;
public sealed record ListAllTicketsQuery(TicketType? TypeFilter, TicketStatus? StatusFilter, int Page, int PageSize)
: IQuery<Result<PagedList<TicketSummaryDto>>>;
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Support;
namespace PnvPanel.Application.Admin.Support;
public sealed class ListAllTicketsQueryHandler(IAppDbContext dbContext, IIdentityService identityService)
: IQueryHandler<ListAllTicketsQuery, Result<PagedList<TicketSummaryDto>>>
{
public async Task<Result<PagedList<TicketSummaryDto>>> Handle(ListAllTicketsQuery query, CancellationToken cancellationToken)
{
var page = query.Page <= 0 ? 1 : query.Page;
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
var ticketsQuery = dbContext.SupportTickets.AsNoTracking();
if (query.TypeFilter is { } type)
ticketsQuery = ticketsQuery.Where(t => t.Type == type);
if (query.StatusFilter is { } status)
ticketsQuery = ticketsQuery.Where(t => t.Status == status);
var page1 = await ticketsQuery.OrderByDescending(t => t.CreatedAt).ToPagedListAsync(page, pageSize, cancellationToken);
var items = await TicketMapping.ToSummaryDtosAsync(dbContext, identityService, page1.Items, cancellationToken);
return Result.Success(new PagedList<TicketSummaryDto>(items, page1.Total, page1.Page, page1.PageSize));
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Support;
public sealed record RejectRoleRequestCommand(Guid TicketId, string? Reason) : ICommand<Result>;
@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Support;
using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Admin.Support;
public sealed class RejectRoleRequestCommandHandler(
IAppDbContext dbContext, IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
: ICommandHandler<RejectRoleRequestCommand, Result>
{
public async Task<Result> Handle(RejectRoleRequestCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized);
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken);
if (ticket is null)
return Result.Failure(SupportErrors.NotFound);
if (ticket.Type != TicketType.RoleRequest)
return Result.Failure(SupportErrors.NotRoleRequest);
if (ticket.Status == TicketStatus.Closed)
return Result.Failure(SupportErrors.AlreadyClosed);
if (!string.IsNullOrWhiteSpace(command.Reason))
dbContext.TicketComments.Add(TicketComment.Create(ticket.Id, adminId, command.Reason));
ticket.Close();
dbContext.AuditLogs.Add(AuditLog.Create(
adminId, "RoleRequestRejected", "SupportTicket", ticket.Id.ToString(), metadata: null, AuditSource.Web));
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
await telegramNotifier.NotifyUserAsync(ticket.UserId, "❌ Ваша заявка на роль отклонена.", cancellationToken);
return Result.Success();
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Admin.Support;
public sealed record ResolveTicketCommand(Guid TicketId) : ICommand<Result>;
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Application.Support;
using PnvPanel.Domain.Audit;
using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Admin.Support;
public sealed class ResolveTicketCommandHandler(IAppDbContext dbContext, IRealtimeNotifier notifier, ICurrentUser currentUser)
: ICommandHandler<ResolveTicketCommand, Result>
{
public async Task<Result> Handle(ResolveTicketCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } adminId)
return Result.Failure(AuthErrors.Unauthorized);
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken);
if (ticket is null)
return Result.Failure(SupportErrors.NotFound);
if (ticket.Status != TicketStatus.Open)
return Result.Failure(SupportErrors.NotOpen);
ticket.Resolve();
dbContext.AuditLogs.Add(AuditLog.Create(
adminId, "TicketResolved", "SupportTicket", ticket.Id.ToString(), metadata: null, AuditSource.Web));
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
return Result.Success();
}
}
@@ -7,6 +7,7 @@ using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Inbounds;
using PnvPanel.Domain.News;
using PnvPanel.Domain.Nodes;
using PnvPanel.Domain.Support;
using PnvPanel.Domain.Telegram;
namespace PnvPanel.Application.Common.Interfaces;
@@ -33,6 +34,12 @@ public interface IAppDbContext
DbSet<NewsPost> NewsPosts { get; }
DbSet<SupportTicket> SupportTickets { get; }
DbSet<TicketComment> TicketComments { get; }
DbSet<TicketAttachment> TicketAttachments { get; }
/// <summary>Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler).</summary>
DatabaseFacade Database { get; }
@@ -0,0 +1,15 @@
namespace PnvPanel.Application.Common.Interfaces;
/// <summary>
/// Хранилище бинарных вложений (скриншоты к тикетам поддержки) — физический диск в контейнере
/// (см. DiskFileStorage), volume в docker-compose. Реализация сама генерирует непрозрачное имя файла
/// на диске (не доверяет пользовательскому имени) и возвращает его — это же имя передаётся обратно
/// в OpenReadAsync, вызывающая сторона его не парсит и не строит из него пути.
/// </summary>
public interface IFileStorage
{
Task<string> SaveAsync(Stream content, CancellationToken cancellationToken);
/// <summary>Null, если файла с таким именем нет на диске (например, удалён вручную).</summary>
Task<Stream?> OpenReadAsync(string storedFileName, CancellationToken cancellationToken);
}
@@ -1,5 +1,6 @@
using PnvPanel.Domain.Configs;
using PnvPanel.Domain.Nodes;
using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Common.Interfaces;
@@ -24,4 +25,9 @@ public interface IRealtimeNotifier
/// <summary>Единственное широковещательное событие (всем подключенным клиентам), а не по группе.</summary>
Task NotifyNewsPublishedAsync(Guid postId, string title, DateTimeOffset createdAt, CancellationToken cancellationToken);
Task NotifyTicketCreatedAsync(Guid ticketId, Guid userId, string userName, TicketType type, CancellationToken cancellationToken);
/// <summary>Новый комментарий или смена статуса — пушится автору тикета (не всем участникам треда).</summary>
Task NotifyTicketUpdatedAsync(Guid ticketId, Guid userId, CancellationToken cancellationToken);
}
@@ -12,4 +12,12 @@ public interface ITelegramNotifier
/// <summary>Личное сообщение пользователю, если у него привязан Telegram (иначе no-op).</summary>
Task NotifyUserAsync(Guid userId, string message, CancellationToken cancellationToken);
/// <summary>Баг-репорт/предложение — только кнопка-ссылка на сайт (переписка и картинки — там),
/// без инлайн-действий.</summary>
Task NotifyAdminsBugReportCreatedAsync(Guid ticketId, string userName, string message, CancellationToken cancellationToken);
/// <summary>Заявка на роль — инлайн-кнопки «Одобрить/Отклонить», решается полностью в Telegram.</summary>
Task NotifyAdminsRoleRequestCreatedAsync(
Guid ticketId, string userName, string roleDescription, string justification, CancellationToken cancellationToken);
}
@@ -0,0 +1,9 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Support.AddComment;
/// <summary>Общая команда для реплики владельца тикета и админа — используется и с
/// /api/support/tickets/{id}/comments, и с /api/admin/support/tickets/{id}/comments.</summary>
public sealed record AddTicketCommentCommand(Guid TicketId, string Body, IReadOnlyList<TicketAttachmentUpload> Attachments)
: ICommand<Result<TicketCommentDto>>, IRequiresActivation;
@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Support.AddComment;
public sealed class AddTicketCommentCommandHandler(
IAppDbContext dbContext, IIdentityService identityService, IFileStorage fileStorage,
IRealtimeNotifier notifier, ICurrentUser currentUser)
: ICommandHandler<AddTicketCommentCommand, Result<TicketCommentDto>>
{
public async Task<Result<TicketCommentDto>> Handle(AddTicketCommentCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<TicketCommentDto>(AuthErrors.Unauthorized);
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(t => t.Id == command.TicketId, cancellationToken);
if (ticket is null)
return Result.Failure<TicketCommentDto>(SupportErrors.NotFound);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
if (profile is null)
return Result.Failure<TicketCommentDto>(AuthErrors.Unauthorized);
// Не свой тикет и не админ — как будто тикета не существует (не палим чужие тикеты Forbidden'ом).
if (ticket.UserId != userId && !TicketAuthorization.IsAdmin(profile))
return Result.Failure<TicketCommentDto>(SupportErrors.NotFound);
if (ticket.Status == TicketStatus.Closed)
return Result.Failure<TicketCommentDto>(SupportErrors.TicketClosed);
if (TicketAttachmentValidation.Validate(command.Attachments) is { } validationError)
return Result.Failure<TicketCommentDto>(validationError);
var comment = TicketComment.Create(ticket.Id, userId, command.Body);
dbContext.TicketComments.Add(comment);
var attachments = await TicketAttachmentPersistence.SaveAllAsync(fileStorage, comment.Id, command.Attachments, cancellationToken);
dbContext.TicketAttachments.AddRange(attachments);
// Пушим автору тикета, только если комментирует не он сам (иначе он и так это видит у себя).
if (ticket.UserId != userId)
await notifier.NotifyTicketUpdatedAsync(ticket.Id, ticket.UserId, cancellationToken);
var userName = currentUser.UserName ?? userId.ToString();
var dto = new TicketCommentDto(
comment.Id, userId, userName, comment.Body, comment.CreatedAt,
attachments.Select(a => new TicketAttachmentDto(a.Id, a.FileName, a.ContentType, a.SizeBytes)).ToList());
return Result.Success(dto);
}
}
@@ -0,0 +1,12 @@
using FluentValidation;
namespace PnvPanel.Application.Support.AddComment;
public sealed class AddTicketCommentCommandValidator : AbstractValidator<AddTicketCommentCommand>
{
public AddTicketCommentCommandValidator()
{
RuleFor(x => x.TicketId).NotEmpty();
RuleFor(x => x.Body).NotEmpty().MaximumLength(4000);
}
}
@@ -0,0 +1,7 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Support.CreateBugReport;
public sealed record CreateBugReportTicketCommand(string Message, IReadOnlyList<TicketAttachmentUpload> Attachments)
: ICommand<Result<TicketDetailDto>>, IRequiresActivation;
@@ -0,0 +1,46 @@
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Support.CreateBugReport;
public sealed class CreateBugReportTicketCommandHandler(
IAppDbContext dbContext, IFileStorage fileStorage, IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
: ICommandHandler<CreateBugReportTicketCommand, Result<TicketDetailDto>>
{
public async Task<Result<TicketDetailDto>> Handle(CreateBugReportTicketCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<TicketDetailDto>(AuthErrors.Unauthorized);
if (TicketAttachmentValidation.Validate(command.Attachments) is { } validationError)
return Result.Failure<TicketDetailDto>(validationError);
var ticket = SupportTicket.CreateBugReport(userId);
dbContext.SupportTickets.Add(ticket);
var comment = TicketComment.Create(ticket.Id, userId, command.Message);
dbContext.TicketComments.Add(comment);
var attachments = await TicketAttachmentPersistence.SaveAllAsync(fileStorage, comment.Id, command.Attachments, cancellationToken);
dbContext.TicketAttachments.AddRange(attachments);
var userName = currentUser.UserName ?? userId.ToString();
await notifier.NotifyTicketCreatedAsync(ticket.Id, userId, userName, ticket.Type, cancellationToken);
await telegramNotifier.NotifyAdminsBugReportCreatedAsync(ticket.Id, userName, command.Message, cancellationToken);
var commentDto = new TicketCommentDto(
comment.Id, userId, userName, comment.Body, comment.CreatedAt,
attachments.Select(a => new TicketAttachmentDto(a.Id, a.FileName, a.ContentType, a.SizeBytes)).ToList());
var dto = new TicketDetailDto(
ticket.Id, ticket.UserId, userName, ticket.Type, ticket.Status,
null, null, null, null, null, ticket.CreatedAt, [commentDto]);
return Result.Success(dto);
}
}
@@ -0,0 +1,11 @@
using FluentValidation;
namespace PnvPanel.Application.Support.CreateBugReport;
public sealed class CreateBugReportTicketCommandValidator : AbstractValidator<CreateBugReportTicketCommand>
{
public CreateBugReportTicketCommandValidator()
{
RuleFor(x => x.Message).NotEmpty().MaximumLength(4000);
}
}
@@ -0,0 +1,10 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Support.CreateRoleRequest;
/// <summary>Ровно один из вариантов: ExistingRoleId, либо NewRoleName+NewRoleMaxConfigs+NewRoleMaxIpLimit
/// (проверяется валидатором).</summary>
public sealed record CreateRoleRequestTicketCommand(
Guid? ExistingRoleId, string? NewRoleName, int? NewRoleMaxConfigs, int? NewRoleMaxIpLimit, string Justification)
: ICommand<Result<TicketDetailDto>>, IRequiresActivation;
@@ -0,0 +1,73 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Support.CreateRoleRequest;
public sealed class CreateRoleRequestTicketCommandHandler(
IAppDbContext dbContext, IRoleService roleService, IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
: ICommandHandler<CreateRoleRequestTicketCommand, Result<TicketDetailDto>>
{
// Совпадает со значением Infrastructure.Identity.RoleNames.Admin — Application не может ссылаться
// на Infrastructure (направление зависимостей), поэтому системное имя роли продублировано здесь.
private const string AdminRoleName = "admin";
public async Task<Result<TicketDetailDto>> Handle(CreateRoleRequestTicketCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<TicketDetailDto>(AuthErrors.Unauthorized);
var hasPending = await dbContext.SupportTickets.AnyAsync(
t => t.UserId == userId && t.Type == TicketType.RoleRequest && t.Status == TicketStatus.Open,
cancellationToken);
if (hasPending)
return Result.Failure<TicketDetailDto>(SupportErrors.RoleRequestAlreadyPending);
SupportTicket ticket;
string? requestedRoleName = null;
if (command.ExistingRoleId is { } roleId)
{
var roles = await roleService.ListRolesAsync(cancellationToken);
var role = roles.FirstOrDefault(r => r.Id == roleId);
if (role is null)
return Result.Failure<TicketDetailDto>(SupportErrors.RoleNotFound);
if (role.Name.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase))
return Result.Failure<TicketDetailDto>(SupportErrors.CannotRequestAdminRole);
ticket = SupportTicket.CreateRoleRequestForExistingRole(userId, roleId);
requestedRoleName = role.Name;
}
else
{
ticket = SupportTicket.CreateRoleRequestForNewRole(
userId, command.NewRoleName!, command.NewRoleMaxConfigs!.Value, command.NewRoleMaxIpLimit!.Value);
}
dbContext.SupportTickets.Add(ticket);
var comment = TicketComment.Create(ticket.Id, userId, command.Justification);
dbContext.TicketComments.Add(comment);
var userName = currentUser.UserName ?? userId.ToString();
var roleDescription = requestedRoleName
?? $"новая роль «{command.NewRoleName}» (конфигов: {command.NewRoleMaxConfigs}, IP: {command.NewRoleMaxIpLimit})";
await notifier.NotifyTicketCreatedAsync(ticket.Id, userId, userName, ticket.Type, cancellationToken);
await telegramNotifier.NotifyAdminsRoleRequestCreatedAsync(ticket.Id, userName, roleDescription, command.Justification, cancellationToken);
var commentDto = new TicketCommentDto(comment.Id, userId, userName, comment.Body, comment.CreatedAt, []);
var dto = new TicketDetailDto(
ticket.Id, ticket.UserId, userName, ticket.Type, ticket.Status,
ticket.RequestedRoleId, requestedRoleName, ticket.ProposedRoleName, ticket.ProposedMaxConfigs,
ticket.ProposedMaxIpLimit, ticket.CreatedAt, [commentDto]);
return Result.Success(dto);
}
}
@@ -0,0 +1,31 @@
using FluentValidation;
namespace PnvPanel.Application.Support.CreateRoleRequest;
public sealed class CreateRoleRequestTicketCommandValidator : AbstractValidator<CreateRoleRequestTicketCommand>
{
public CreateRoleRequestTicketCommandValidator()
{
RuleFor(x => x.Justification).NotEmpty().MaximumLength(4000);
RuleFor(x => x)
.Must(HaveExactlyOnePayload)
.WithMessage("Укажите либо существующую роль, либо параметры новой (не оба варианта и не ни одного).");
When(x => x.ExistingRoleId is null, () =>
{
RuleFor(x => x.NewRoleName).NotEmpty().MaximumLength(100);
RuleFor(x => x.NewRoleMaxConfigs).NotNull().GreaterThanOrEqualTo(-1);
RuleFor(x => x.NewRoleMaxIpLimit).NotNull().GreaterThanOrEqualTo(-1);
});
}
private static bool HaveExactlyOnePayload(CreateRoleRequestTicketCommand command)
{
var hasExisting = command.ExistingRoleId is not null;
var hasNew = !string.IsNullOrWhiteSpace(command.NewRoleName)
&& command.NewRoleMaxConfigs is not null && command.NewRoleMaxIpLimit is not null;
return hasExisting ^ hasNew;
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Support.GetAttachment;
public sealed record GetTicketAttachmentQuery(Guid AttachmentId) : IQuery<Result<TicketAttachmentContent>>, IRequiresActivation;
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Support.GetAttachment;
public sealed class GetTicketAttachmentQueryHandler(
IAppDbContext dbContext, IIdentityService identityService, IFileStorage fileStorage, ICurrentUser currentUser)
: IQueryHandler<GetTicketAttachmentQuery, Result<TicketAttachmentContent>>
{
public async Task<Result<TicketAttachmentContent>> Handle(GetTicketAttachmentQuery query, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<TicketAttachmentContent>(AuthErrors.Unauthorized);
var info = await (
from a in dbContext.TicketAttachments.AsNoTracking()
join c in dbContext.TicketComments.AsNoTracking() on a.CommentId equals c.Id
join t in dbContext.SupportTickets.AsNoTracking() on c.TicketId equals t.Id
where a.Id == query.AttachmentId
select new { Attachment = a, TicketUserId = t.UserId }
).FirstOrDefaultAsync(cancellationToken);
if (info is null)
return Result.Failure<TicketAttachmentContent>(SupportErrors.AttachmentNotFound);
var profile = await identityService.GetProfileAsync(userId, cancellationToken);
if (info.TicketUserId != userId && !TicketAuthorization.IsAdmin(profile))
return Result.Failure<TicketAttachmentContent>(SupportErrors.AttachmentNotFound);
var stream = await fileStorage.OpenReadAsync(info.Attachment.StoredFileName, cancellationToken);
if (stream is null)
return Result.Failure<TicketAttachmentContent>(SupportErrors.AttachmentNotFound);
return Result.Success(new TicketAttachmentContent(stream, info.Attachment.ContentType, info.Attachment.FileName));
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Support.GetTicket;
public sealed record GetTicketQuery(Guid TicketId) : IQuery<Result<TicketDetailDto>>, IRequiresActivation;
@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Support.GetTicket;
public sealed class GetTicketQueryHandler(
IAppDbContext dbContext, IIdentityService identityService, IRoleService roleService, ICurrentUser currentUser)
: IQueryHandler<GetTicketQuery, Result<TicketDetailDto>>
{
public async Task<Result<TicketDetailDto>> Handle(GetTicketQuery query, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<TicketDetailDto>(AuthErrors.Unauthorized);
var ticket = await dbContext.SupportTickets.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == query.TicketId && t.UserId == userId, cancellationToken);
if (ticket is null)
return Result.Failure<TicketDetailDto>(SupportErrors.NotFound);
var dto = await TicketMapping.ToDetailDtoAsync(dbContext, identityService, roleService, ticket, cancellationToken);
return Result.Success(dto);
}
}
@@ -0,0 +1,8 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Support.ListMyTickets;
public sealed record ListMyTicketsQuery(TicketType? TypeFilter, TicketStatus? StatusFilter, int Page, int PageSize)
: IQuery<Result<PagedList<TicketSummaryDto>>>, IRequiresActivation;
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Support.ListMyTickets;
public sealed class ListMyTicketsQueryHandler(IAppDbContext dbContext, IIdentityService identityService, ICurrentUser currentUser)
: IQueryHandler<ListMyTicketsQuery, Result<PagedList<TicketSummaryDto>>>
{
public async Task<Result<PagedList<TicketSummaryDto>>> Handle(ListMyTicketsQuery query, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Result.Failure<PagedList<TicketSummaryDto>>(AuthErrors.Unauthorized);
var page = query.Page <= 0 ? 1 : query.Page;
var pageSize = query.PageSize is <= 0 or > 100 ? 20 : query.PageSize;
var ticketsQuery = dbContext.SupportTickets.AsNoTracking().Where(t => t.UserId == userId);
if (query.TypeFilter is { } type)
ticketsQuery = ticketsQuery.Where(t => t.Type == type);
if (query.StatusFilter is { } status)
ticketsQuery = ticketsQuery.Where(t => t.Status == status);
var page1 = await ticketsQuery.OrderByDescending(t => t.CreatedAt).ToPagedListAsync(page, pageSize, cancellationToken);
var items = await TicketMapping.ToSummaryDtosAsync(dbContext, identityService, page1.Items, cancellationToken);
return Result.Success(new PagedList<TicketSummaryDto>(items, page1.Total, page1.Page, page1.PageSize));
}
}
@@ -0,0 +1,9 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Support.ListSelectableRoles;
/// <summary>Список ролей для выбора в заявке на роль (без admin) — в отличие от ListRolesQuery
/// (Admin/Roles), доступен любому активированному пользователю.</summary>
public sealed record ListSelectableRolesQuery : IQuery<Result<IReadOnlyList<RoleDto>>>, IRequiresActivation;
@@ -0,0 +1,21 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Support.ListSelectableRoles;
public sealed class ListSelectableRolesQueryHandler(IRoleService roleService)
: IQueryHandler<ListSelectableRolesQuery, Result<IReadOnlyList<RoleDto>>>
{
// Совпадает со значением Infrastructure.Identity.RoleNames.Admin — см. пояснение в
// CreateRoleRequestTicketCommandHandler (Application не может ссылаться на Infrastructure).
private const string AdminRoleName = "admin";
public async Task<Result<IReadOnlyList<RoleDto>>> Handle(ListSelectableRolesQuery query, CancellationToken cancellationToken)
{
var roles = await roleService.ListRolesAsync(cancellationToken);
var selectable = roles.Where(r => !r.Name.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase)).ToList();
return Result.Success<IReadOnlyList<RoleDto>>(selectable);
}
}
@@ -0,0 +1,6 @@
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Support.Reopen;
public sealed record ReopenTicketCommand(Guid TicketId) : ICommand<Result>, IRequiresActivation;
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Auth;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Application.Common.Messaging;
using PnvPanel.Application.Common.Models;
using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Support.Reopen;
public sealed class ReopenTicketCommandHandler(IAppDbContext dbContext, ICurrentUser currentUser)
: ICommandHandler<ReopenTicketCommand, Result>
{
public async Task<Result> Handle(ReopenTicketCommand command, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return Result.Failure(AuthErrors.Unauthorized);
var ticket = await dbContext.SupportTickets
.FirstOrDefaultAsync(t => t.Id == command.TicketId && t.UserId == userId, cancellationToken);
if (ticket is null)
return Result.Failure(SupportErrors.NotFound);
if (ticket.Status != TicketStatus.Resolved)
return Result.Failure(SupportErrors.NotResolved);
ticket.Reopen();
return Result.Success();
}
}
@@ -0,0 +1,36 @@
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Support;
public static class SupportErrors
{
public static readonly Error NotFound = Error.NotFound("Support.NotFound", "Тикет не найден.");
public static readonly Error AttachmentNotFound = Error.NotFound("Support.AttachmentNotFound", "Вложение не найдено.");
public static readonly Error RoleNotFound = Error.NotFound("Support.RoleNotFound", "Роль не найдена.");
public static readonly Error CannotRequestAdminRole =
Error.Forbidden("Support.CannotRequestAdminRole", "Роль администратора нельзя запросить через заявку.");
public static readonly Error TicketClosed =
Error.Conflict("Support.TicketClosed", "Тикет закрыт — комментарии больше не принимаются.");
public static readonly Error NotOpen = Error.Conflict("Support.NotOpen", "Тикет уже обработан.");
public static readonly Error AlreadyClosed = Error.Conflict("Support.AlreadyClosed", "Тикет уже закрыт.");
public static readonly Error NotResolved =
Error.Conflict("Support.NotResolved", "Переоткрыть можно только решённый тикет.");
public static readonly Error RoleRequestAlreadyPending =
Error.Conflict("Support.RoleRequestAlreadyPending", "У вас уже есть необработанная заявка на роль.");
public static readonly Error NotRoleRequest = Error.Validation("Support.NotRoleRequest", "Это не заявка на роль.");
public static readonly Error TooManyAttachments =
Error.Validation("Support.TooManyAttachments", $"Слишком много вложений (максимум {TicketAttachmentValidation.MaxAttachments}).");
public static readonly Error AttachmentTooLarge =
Error.Validation("Support.AttachmentTooLarge", "Файл превышает лимит 5 МБ.");
public static readonly Error UnsupportedAttachmentType =
Error.Validation("Support.UnsupportedAttachmentType", "Поддерживаются только изображения (JPEG/PNG/WEBP/GIF).");
}
@@ -0,0 +1,4 @@
namespace PnvPanel.Application.Support;
/// <summary>Результат отдачи вложения — Api-слой стримит Content с заголовками из ContentType/FileName.</summary>
public sealed record TicketAttachmentContent(Stream Content, string ContentType, string FileName);
@@ -0,0 +1,3 @@
namespace PnvPanel.Application.Support;
public sealed record TicketAttachmentDto(Guid Id, string FileName, string ContentType, long SizeBytes);
@@ -0,0 +1,20 @@
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Support;
internal static class TicketAttachmentPersistence
{
public static async Task<List<TicketAttachment>> SaveAllAsync(
IFileStorage fileStorage, Guid commentId, IReadOnlyList<TicketAttachmentUpload> uploads, CancellationToken cancellationToken)
{
var attachments = new List<TicketAttachment>();
foreach (var upload in uploads)
{
var storedFileName = await fileStorage.SaveAsync(upload.Content, cancellationToken);
attachments.Add(TicketAttachment.Create(commentId, upload.FileName, storedFileName, upload.ContentType, upload.SizeBytes));
}
return attachments;
}
}
@@ -0,0 +1,5 @@
namespace PnvPanel.Application.Support;
/// <summary>Вложение на входе команды — Api-слой парсит multipart и передаёт сюда открытый поток;
/// Application не знает про HTTP/IFormFile.</summary>
public sealed record TicketAttachmentUpload(Stream Content, string FileName, string ContentType, long SizeBytes);
@@ -0,0 +1,31 @@
using PnvPanel.Application.Common.Models;
namespace PnvPanel.Application.Support;
internal static class TicketAttachmentValidation
{
public const int MaxAttachments = 5;
public const long MaxSizeBytes = 5 * 1024 * 1024;
private static readonly HashSet<string> AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase)
{
"image/jpeg", "image/png", "image/webp", "image/gif",
};
public static Error? Validate(IReadOnlyList<TicketAttachmentUpload> attachments)
{
if (attachments.Count > MaxAttachments)
return SupportErrors.TooManyAttachments;
foreach (var attachment in attachments)
{
if (attachment.SizeBytes > MaxSizeBytes)
return SupportErrors.AttachmentTooLarge;
if (!AllowedContentTypes.Contains(attachment.ContentType))
return SupportErrors.UnsupportedAttachmentType;
}
return null;
}
}
@@ -0,0 +1,13 @@
using PnvPanel.Application.Common.Interfaces;
namespace PnvPanel.Application.Support;
internal static class TicketAuthorization
{
// Совпадает со значением Infrastructure.Identity.RoleNames.Admin — см. пояснение в
// CreateRoleRequestTicketCommandHandler (Application не может ссылаться на Infrastructure).
private const string AdminRoleName = "admin";
public static bool IsAdmin(CurrentUserProfile? profile) =>
profile is not null && profile.Role.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase);
}
@@ -0,0 +1,5 @@
namespace PnvPanel.Application.Support;
public sealed record TicketCommentDto(
Guid Id, Guid AuthorId, string AuthorName, string Body, DateTimeOffset CreatedAt,
IReadOnlyList<TicketAttachmentDto> Attachments);
@@ -0,0 +1,13 @@
using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Support;
/// <summary>
/// RequestedRoleName — имя существующей роли, если RequestedRoleId задан (резолвится хендлером,
/// на SupportTicket хранится только Id). Для новой роли имя лежит прямо в ProposedRoleName.
/// </summary>
public sealed record TicketDetailDto(
Guid Id, Guid UserId, string UserName, TicketType Type, TicketStatus Status,
Guid? RequestedRoleId, string? RequestedRoleName,
string? ProposedRoleName, int? ProposedMaxConfigs, int? ProposedMaxIpLimit,
DateTimeOffset CreatedAt, IReadOnlyList<TicketCommentDto> Comments);
@@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore;
using PnvPanel.Application.Common.Interfaces;
using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Support;
/// <summary>
/// Сборка DTO из уже сохранённых тикетов (реальные запросы к БД — только для Get/List; при создании
/// тикета/комментария DTO собирается вручную из только что созданных объектов в памяти, см.
/// соответствующие хендлеры — свежедобавленные строки не видны через новый AsNoTracking-запрос до
/// SaveChangesAsync из UnitOfWorkBehavior).
/// </summary>
internal static class TicketMapping
{
public static async Task<TicketDetailDto> ToDetailDtoAsync(
IAppDbContext dbContext, IIdentityService identityService, IRoleService roleService,
SupportTicket ticket, CancellationToken cancellationToken)
{
var comments = await dbContext.TicketComments.AsNoTracking()
.Where(c => c.TicketId == ticket.Id)
.OrderBy(c => c.CreatedAt)
.ToListAsync(cancellationToken);
var commentIds = comments.Select(c => c.Id).ToList();
var attachmentsByComment = (await dbContext.TicketAttachments.AsNoTracking()
.Where(a => commentIds.Contains(a.CommentId))
.ToListAsync(cancellationToken))
.GroupBy(a => a.CommentId)
.ToDictionary(g => g.Key, g => g.ToList());
var userIds = comments.Select(c => c.AuthorId).Append(ticket.UserId).Distinct().ToList();
var userNames = await identityService.GetUserNamesAsync(userIds, cancellationToken);
string? requestedRoleName = null;
if (ticket.RequestedRoleId is { } roleId)
{
var roles = await roleService.ListRolesAsync(cancellationToken);
requestedRoleName = roles.FirstOrDefault(r => r.Id == roleId)?.Name;
}
var commentDtos = comments
.Select(c => new TicketCommentDto(
c.Id, c.AuthorId, userNames.GetValueOrDefault(c.AuthorId, "?"), c.Body, c.CreatedAt,
attachmentsByComment.GetValueOrDefault(c.Id, [])
.Select(a => new TicketAttachmentDto(a.Id, a.FileName, a.ContentType, a.SizeBytes))
.ToList()))
.ToList();
return new TicketDetailDto(
ticket.Id, ticket.UserId, userNames.GetValueOrDefault(ticket.UserId, "?"), ticket.Type, ticket.Status,
ticket.RequestedRoleId, requestedRoleName, ticket.ProposedRoleName, ticket.ProposedMaxConfigs,
ticket.ProposedMaxIpLimit, ticket.CreatedAt, commentDtos);
}
public static async Task<List<TicketSummaryDto>> ToSummaryDtosAsync(
IAppDbContext dbContext, IIdentityService identityService, IReadOnlyList<SupportTicket> tickets,
CancellationToken cancellationToken)
{
if (tickets.Count == 0)
return [];
var ticketIds = tickets.Select(t => t.Id).ToList();
var lastActivity = await dbContext.TicketComments.AsNoTracking()
.Where(c => ticketIds.Contains(c.TicketId))
.GroupBy(c => c.TicketId)
.Select(g => new { TicketId = g.Key, Last = g.Max(c => c.CreatedAt) })
.ToDictionaryAsync(x => x.TicketId, x => x.Last, cancellationToken);
var userIds = tickets.Select(t => t.UserId).Distinct().ToList();
var userNames = await identityService.GetUserNamesAsync(userIds, cancellationToken);
return tickets
.Select(t => new TicketSummaryDto(
t.Id, t.UserId, userNames.GetValueOrDefault(t.UserId, "?"), t.Type, t.Status, t.CreatedAt,
lastActivity.GetValueOrDefault(t.Id, t.CreatedAt)))
.ToList();
}
}
@@ -0,0 +1,9 @@
using PnvPanel.Domain.Support;
namespace PnvPanel.Application.Support;
/// <summary>Строка списка тикетов — свой (ListMyTicketsQuery) и админский (ListAllTicketsQuery) списки
/// используют один и тот же DTO (владелец видит только свои UserId/UserName — не секрет для себя).</summary>
public sealed record TicketSummaryDto(
Guid Id, Guid UserId, string UserName, TicketType Type, TicketStatus Status,
DateTimeOffset CreatedAt, DateTimeOffset LastActivityAt);