Refactor project files for improved readability and structure
- Cleaned up whitespace in Directory.Build.props and Directory.Packages.props for consistency. - Reformatted project file references in PnvPanel.Api.csproj for better clarity. - Enhanced code readability in various endpoint files by adjusting line breaks and indentation. - Standardized method signatures and improved formatting in ResultExtensions and multiple endpoint classes for better maintainability.
This commit is contained in:
@@ -5,5 +5,8 @@ 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;
|
||||
public sealed record AddTicketCommentCommand(
|
||||
Guid TicketId,
|
||||
string Body,
|
||||
IReadOnlyList<TicketAttachmentUpload> Attachments
|
||||
) : ICommand<Result<TicketCommentDto>>, IRequiresActivation;
|
||||
|
||||
+29
-8
@@ -8,16 +8,25 @@ 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>>
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
IFileStorage fileStorage,
|
||||
IRealtimeNotifier notifier,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<AddTicketCommentCommand, Result<TicketCommentDto>>
|
||||
{
|
||||
public async Task<Result<TicketCommentDto>> Handle(AddTicketCommentCommand command, CancellationToken cancellationToken)
|
||||
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);
|
||||
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(
|
||||
t => t.Id == command.TicketId,
|
||||
cancellationToken
|
||||
);
|
||||
if (ticket is null)
|
||||
return Result.Failure<TicketCommentDto>(SupportErrors.NotFound);
|
||||
|
||||
@@ -38,7 +47,12 @@ public sealed class AddTicketCommentCommandHandler(
|
||||
var comment = TicketComment.Create(ticket.Id, userId, command.Body);
|
||||
dbContext.TicketComments.Add(comment);
|
||||
|
||||
var attachments = await TicketAttachmentPersistence.SaveAllAsync(fileStorage, comment.Id, command.Attachments, cancellationToken);
|
||||
var attachments = await TicketAttachmentPersistence.SaveAllAsync(
|
||||
fileStorage,
|
||||
comment.Id,
|
||||
command.Attachments,
|
||||
cancellationToken
|
||||
);
|
||||
dbContext.TicketAttachments.AddRange(attachments);
|
||||
|
||||
// Пушим автору тикета, только если комментирует не он сам (иначе он и так это видит у себя).
|
||||
@@ -47,8 +61,15 @@ public sealed class AddTicketCommentCommandHandler(
|
||||
|
||||
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());
|
||||
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);
|
||||
}
|
||||
|
||||
+4
-2
@@ -3,5 +3,7 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Support.CreateBugReport;
|
||||
|
||||
public sealed record CreateBugReportTicketCommand(string Message, IReadOnlyList<TicketAttachmentUpload> Attachments)
|
||||
: ICommand<Result<TicketDetailDto>>, IRequiresActivation;
|
||||
public sealed record CreateBugReportTicketCommand(
|
||||
string Message,
|
||||
IReadOnlyList<TicketAttachmentUpload> Attachments
|
||||
) : ICommand<Result<TicketDetailDto>>, IRequiresActivation;
|
||||
|
||||
+51
-11
@@ -7,11 +7,17 @@ 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>>
|
||||
IAppDbContext dbContext,
|
||||
IFileStorage fileStorage,
|
||||
IRealtimeNotifier notifier,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<CreateBugReportTicketCommand, Result<TicketDetailDto>>
|
||||
{
|
||||
public async Task<Result<TicketDetailDto>> Handle(CreateBugReportTicketCommand command, CancellationToken cancellationToken)
|
||||
public async Task<Result<TicketDetailDto>> Handle(
|
||||
CreateBugReportTicketCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return Result.Failure<TicketDetailDto>(AuthErrors.Unauthorized);
|
||||
@@ -25,21 +31,55 @@ public sealed class CreateBugReportTicketCommandHandler(
|
||||
var comment = TicketComment.Create(ticket.Id, userId, command.Message);
|
||||
dbContext.TicketComments.Add(comment);
|
||||
|
||||
var attachments = await TicketAttachmentPersistence.SaveAllAsync(fileStorage, comment.Id, command.Attachments, cancellationToken);
|
||||
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);
|
||||
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());
|
||||
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]);
|
||||
ticket.Id,
|
||||
ticket.UserId,
|
||||
userName,
|
||||
ticket.Type,
|
||||
ticket.Status,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
ticket.CreatedAt,
|
||||
[commentDto]
|
||||
);
|
||||
|
||||
return Result.Success(dto);
|
||||
}
|
||||
|
||||
+2
-1
@@ -2,7 +2,8 @@ using FluentValidation;
|
||||
|
||||
namespace PnvPanel.Application.Support.CreateBugReport;
|
||||
|
||||
public sealed class CreateBugReportTicketCommandValidator : AbstractValidator<CreateBugReportTicketCommand>
|
||||
public sealed class CreateBugReportTicketCommandValidator
|
||||
: AbstractValidator<CreateBugReportTicketCommand>
|
||||
{
|
||||
public CreateBugReportTicketCommandValidator()
|
||||
{
|
||||
|
||||
+6
-2
@@ -6,5 +6,9 @@ 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;
|
||||
Guid? ExistingRoleId,
|
||||
string? NewRoleName,
|
||||
int? NewRoleMaxConfigs,
|
||||
int? NewRoleMaxIpLimit,
|
||||
string Justification
|
||||
) : ICommand<Result<TicketDetailDto>>, IRequiresActivation;
|
||||
|
||||
+58
-14
@@ -8,22 +8,32 @@ 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>>
|
||||
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)
|
||||
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);
|
||||
t =>
|
||||
t.UserId == userId
|
||||
&& t.Type == TicketType.RoleRequest
|
||||
&& t.Status == TicketStatus.Open,
|
||||
cancellationToken
|
||||
);
|
||||
if (hasPending)
|
||||
return Result.Failure<TicketDetailDto>(SupportErrors.RoleRequestAlreadyPending);
|
||||
|
||||
@@ -46,7 +56,11 @@ public sealed class CreateRoleRequestTicketCommandHandler(
|
||||
else
|
||||
{
|
||||
ticket = SupportTicket.CreateRoleRequestForNewRole(
|
||||
userId, command.NewRoleName!, command.NewRoleMaxConfigs!.Value, command.NewRoleMaxIpLimit!.Value);
|
||||
userId,
|
||||
command.NewRoleName!,
|
||||
command.NewRoleMaxConfigs!.Value,
|
||||
command.NewRoleMaxIpLimit!.Value
|
||||
);
|
||||
}
|
||||
|
||||
dbContext.SupportTickets.Add(ticket);
|
||||
@@ -55,18 +69,48 @@ public sealed class CreateRoleRequestTicketCommandHandler(
|
||||
dbContext.TicketComments.Add(comment);
|
||||
|
||||
var userName = currentUser.UserName ?? userId.ToString();
|
||||
var roleDescription = requestedRoleName
|
||||
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);
|
||||
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 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]);
|
||||
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);
|
||||
}
|
||||
|
||||
+18
-10
@@ -2,7 +2,8 @@ using FluentValidation;
|
||||
|
||||
namespace PnvPanel.Application.Support.CreateRoleRequest;
|
||||
|
||||
public sealed class CreateRoleRequestTicketCommandValidator : AbstractValidator<CreateRoleRequestTicketCommand>
|
||||
public sealed class CreateRoleRequestTicketCommandValidator
|
||||
: AbstractValidator<CreateRoleRequestTicketCommand>
|
||||
{
|
||||
public CreateRoleRequestTicketCommandValidator()
|
||||
{
|
||||
@@ -10,21 +11,28 @@ public sealed class CreateRoleRequestTicketCommandValidator : AbstractValidator<
|
||||
|
||||
RuleFor(x => x)
|
||||
.Must(HaveExactlyOnePayload)
|
||||
.WithMessage("Укажите либо существующую роль, либо параметры новой (не оба варианта и не ни одного).");
|
||||
.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);
|
||||
});
|
||||
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;
|
||||
var hasNew =
|
||||
!string.IsNullOrWhiteSpace(command.NewRoleName)
|
||||
&& command.NewRoleMaxConfigs is not null
|
||||
&& command.NewRoleMaxIpLimit is not null;
|
||||
|
||||
return hasExisting ^ hasNew;
|
||||
}
|
||||
|
||||
@@ -3,4 +3,6 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Support.GetAttachment;
|
||||
|
||||
public sealed record GetTicketAttachmentQuery(Guid AttachmentId) : IQuery<Result<TicketAttachmentContent>>, IRequiresActivation;
|
||||
public sealed record GetTicketAttachmentQuery(Guid AttachmentId)
|
||||
: IQuery<Result<TicketAttachmentContent>>,
|
||||
IRequiresActivation;
|
||||
|
||||
+20
-5
@@ -7,10 +7,16 @@ 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>>
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
IFileStorage fileStorage,
|
||||
ICurrentUser currentUser
|
||||
) : IQueryHandler<GetTicketAttachmentQuery, Result<TicketAttachmentContent>>
|
||||
{
|
||||
public async Task<Result<TicketAttachmentContent>> Handle(GetTicketAttachmentQuery query, CancellationToken cancellationToken)
|
||||
public async Task<Result<TicketAttachmentContent>> Handle(
|
||||
GetTicketAttachmentQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return Result.Failure<TicketAttachmentContent>(AuthErrors.Unauthorized);
|
||||
@@ -30,10 +36,19 @@ public sealed class GetTicketAttachmentQueryHandler(
|
||||
if (info.TicketUserId != userId && !TicketAuthorization.IsAdmin(profile))
|
||||
return Result.Failure<TicketAttachmentContent>(SupportErrors.AttachmentNotFound);
|
||||
|
||||
var stream = await fileStorage.OpenReadAsync(info.Attachment.StoredFileName, cancellationToken);
|
||||
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));
|
||||
return Result.Success(
|
||||
new TicketAttachmentContent(
|
||||
stream,
|
||||
info.Attachment.ContentType,
|
||||
info.Attachment.FileName
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,6 @@ using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Support.GetTicket;
|
||||
|
||||
public sealed record GetTicketQuery(Guid TicketId) : IQuery<Result<TicketDetailDto>>, IRequiresActivation;
|
||||
public sealed record GetTicketQuery(Guid TicketId)
|
||||
: IQuery<Result<TicketDetailDto>>,
|
||||
IRequiresActivation;
|
||||
|
||||
@@ -7,20 +7,36 @@ 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>>
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
IRoleService roleService,
|
||||
ICurrentUser currentUser
|
||||
) : IQueryHandler<GetTicketQuery, Result<TicketDetailDto>>
|
||||
{
|
||||
public async Task<Result<TicketDetailDto>> Handle(GetTicketQuery query, CancellationToken cancellationToken)
|
||||
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);
|
||||
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);
|
||||
var dto = await TicketMapping.ToDetailDtoAsync(
|
||||
dbContext,
|
||||
identityService,
|
||||
roleService,
|
||||
ticket,
|
||||
cancellationToken
|
||||
);
|
||||
return Result.Success(dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,9 @@ 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;
|
||||
public sealed record ListMyTicketsQuery(
|
||||
TicketType? TypeFilter,
|
||||
TicketStatus? StatusFilter,
|
||||
int Page,
|
||||
int PageSize
|
||||
) : IQuery<Result<PagedList<TicketSummaryDto>>>, IRequiresActivation;
|
||||
|
||||
+21
-6
@@ -6,10 +6,16 @@ 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 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)
|
||||
public async Task<Result<PagedList<TicketSummaryDto>>> Handle(
|
||||
ListMyTicketsQuery query,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return Result.Failure<PagedList<TicketSummaryDto>>(AuthErrors.Unauthorized);
|
||||
@@ -23,9 +29,18 @@ public sealed class ListMyTicketsQueryHandler(IAppDbContext dbContext, IIdentity
|
||||
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);
|
||||
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));
|
||||
return Result.Success(
|
||||
new PagedList<TicketSummaryDto>(items, page1.Total, page1.Page, page1.PageSize)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -6,4 +6,6 @@ namespace PnvPanel.Application.Support.ListSelectableRoles;
|
||||
|
||||
/// <summary>Список ролей для выбора в заявке на роль (без admin) — в отличие от ListRolesQuery
|
||||
/// (Admin/Roles), доступен любому активированному пользователю.</summary>
|
||||
public sealed record ListSelectableRolesQuery : IQuery<Result<IReadOnlyList<RoleDto>>>, IRequiresActivation;
|
||||
public sealed record ListSelectableRolesQuery
|
||||
: IQuery<Result<IReadOnlyList<RoleDto>>>,
|
||||
IRequiresActivation;
|
||||
|
||||
+7
-2
@@ -11,10 +11,15 @@ public sealed class ListSelectableRolesQueryHandler(IRoleService roleService)
|
||||
// CreateRoleRequestTicketCommandHandler (Application не может ссылаться на Infrastructure).
|
||||
private const string AdminRoleName = "admin";
|
||||
|
||||
public async Task<Result<IReadOnlyList<RoleDto>>> Handle(ListSelectableRolesQuery query, CancellationToken cancellationToken)
|
||||
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();
|
||||
var selectable = roles
|
||||
.Where(r => !r.Name.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
return Result.Success<IReadOnlyList<RoleDto>>(selectable);
|
||||
}
|
||||
|
||||
@@ -7,16 +7,24 @@ using PnvPanel.Domain.Support;
|
||||
|
||||
namespace PnvPanel.Application.Support.Reopen;
|
||||
|
||||
public sealed class ReopenTicketCommandHandler(IAppDbContext dbContext, ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
|
||||
: ICommandHandler<ReopenTicketCommand, Result>
|
||||
public sealed class ReopenTicketCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
ITelegramNotifier telegramNotifier,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<ReopenTicketCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(ReopenTicketCommand command, CancellationToken cancellationToken)
|
||||
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);
|
||||
var ticket = await dbContext.SupportTickets.FirstOrDefaultAsync(
|
||||
t => t.Id == command.TicketId && t.UserId == userId,
|
||||
cancellationToken
|
||||
);
|
||||
if (ticket is null)
|
||||
return Result.Failure(SupportErrors.NotFound);
|
||||
|
||||
@@ -26,7 +34,12 @@ public sealed class ReopenTicketCommandHandler(IAppDbContext dbContext, ITelegra
|
||||
ticket.Reopen();
|
||||
|
||||
var userName = currentUser.UserName ?? userId.ToString();
|
||||
await telegramNotifier.NotifyAdminsTicketReopenedAsync(ticket.Id, userName, ticket.Type, cancellationToken);
|
||||
await telegramNotifier.NotifyAdminsTicketReopenedAsync(
|
||||
ticket.Id,
|
||||
userName,
|
||||
ticket.Type,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
@@ -5,32 +5,61 @@ 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 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 CannotRequestAdminRole = Error.Forbidden(
|
||||
"Support.CannotRequestAdminRole",
|
||||
"Роль администратора нельзя запросить через заявку."
|
||||
);
|
||||
|
||||
public static readonly Error TicketClosed =
|
||||
Error.Conflict("Support.TicketClosed", "Тикет закрыт — комментарии больше не принимаются.");
|
||||
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 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 NotResolved = Error.Conflict(
|
||||
"Support.NotResolved",
|
||||
"Переоткрыть можно только решённый тикет."
|
||||
);
|
||||
|
||||
public static readonly Error RoleRequestAlreadyPending =
|
||||
Error.Conflict("Support.RoleRequestAlreadyPending", "У вас уже есть необработанная заявка на роль.");
|
||||
public static readonly Error RoleRequestAlreadyPending = Error.Conflict(
|
||||
"Support.RoleRequestAlreadyPending",
|
||||
"У вас уже есть необработанная заявка на роль."
|
||||
);
|
||||
|
||||
public static readonly Error NotRoleRequest = Error.Validation("Support.NotRoleRequest", "Это не заявка на роль.");
|
||||
public static readonly Error NotRoleRequest = Error.Validation(
|
||||
"Support.NotRoleRequest",
|
||||
"Это не заявка на роль."
|
||||
);
|
||||
|
||||
public static readonly Error TooManyAttachments =
|
||||
Error.Validation("Support.TooManyAttachments", $"Слишком много вложений (максимум {TicketAttachmentValidation.MaxAttachments}).");
|
||||
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 AttachmentTooLarge = Error.Validation(
|
||||
"Support.AttachmentTooLarge",
|
||||
"Файл превышает лимит 5 МБ."
|
||||
);
|
||||
|
||||
public static readonly Error UnsupportedAttachmentType =
|
||||
Error.Validation("Support.UnsupportedAttachmentType", "Поддерживаются только изображения (JPEG/PNG/WEBP/GIF).");
|
||||
public static readonly Error UnsupportedAttachmentType = Error.Validation(
|
||||
"Support.UnsupportedAttachmentType",
|
||||
"Поддерживаются только изображения (JPEG/PNG/WEBP/GIF)."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
namespace PnvPanel.Application.Support;
|
||||
|
||||
public sealed record TicketAttachmentDto(Guid Id, string FileName, string ContentType, long SizeBytes);
|
||||
public sealed record TicketAttachmentDto(
|
||||
Guid Id,
|
||||
string FileName,
|
||||
string ContentType,
|
||||
long SizeBytes
|
||||
);
|
||||
|
||||
@@ -6,13 +6,25 @@ namespace PnvPanel.Application.Support;
|
||||
internal static class TicketAttachmentPersistence
|
||||
{
|
||||
public static async Task<List<TicketAttachment>> SaveAllAsync(
|
||||
IFileStorage fileStorage, Guid commentId, IReadOnlyList<TicketAttachmentUpload> uploads, CancellationToken cancellationToken)
|
||||
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));
|
||||
attachments.Add(
|
||||
TicketAttachment.Create(
|
||||
commentId,
|
||||
upload.FileName,
|
||||
storedFileName,
|
||||
upload.ContentType,
|
||||
upload.SizeBytes
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return attachments;
|
||||
|
||||
@@ -2,4 +2,9 @@ namespace PnvPanel.Application.Support;
|
||||
|
||||
/// <summary>Вложение на входе команды — Api-слой парсит multipart и передаёт сюда открытый поток;
|
||||
/// Application не знает про HTTP/IFormFile.</summary>
|
||||
public sealed record TicketAttachmentUpload(Stream Content, string FileName, string ContentType, long SizeBytes);
|
||||
public sealed record TicketAttachmentUpload(
|
||||
Stream Content,
|
||||
string FileName,
|
||||
string ContentType,
|
||||
long SizeBytes
|
||||
);
|
||||
|
||||
@@ -7,9 +7,14 @@ 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)
|
||||
private static readonly HashSet<string> AllowedContentTypes = new(
|
||||
StringComparer.OrdinalIgnoreCase
|
||||
)
|
||||
{
|
||||
"image/jpeg", "image/png", "image/webp", "image/gif",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
};
|
||||
|
||||
public static Error? Validate(IReadOnlyList<TicketAttachmentUpload> attachments)
|
||||
|
||||
@@ -9,5 +9,6 @@ internal static class TicketAuthorization
|
||||
private const string AdminRoleName = "admin";
|
||||
|
||||
public static bool IsAdmin(CurrentUserProfile? profile) =>
|
||||
profile is not null && profile.Role.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase);
|
||||
profile is not null
|
||||
&& profile.Role.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
namespace PnvPanel.Application.Support;
|
||||
|
||||
public sealed record TicketCommentDto(
|
||||
Guid Id, Guid AuthorId, string AuthorName, string Body, DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<TicketAttachmentDto> Attachments);
|
||||
Guid Id,
|
||||
Guid AuthorId,
|
||||
string AuthorName,
|
||||
string Body,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<TicketAttachmentDto> Attachments
|
||||
);
|
||||
|
||||
@@ -7,7 +7,16 @@ namespace PnvPanel.Application.Support;
|
||||
/// на 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);
|
||||
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
|
||||
);
|
||||
|
||||
@@ -13,18 +13,26 @@ namespace PnvPanel.Application.Support;
|
||||
internal static class TicketMapping
|
||||
{
|
||||
public static async Task<TicketDetailDto> ToDetailDtoAsync(
|
||||
IAppDbContext dbContext, IIdentityService identityService, IRoleService roleService,
|
||||
SupportTicket ticket, CancellationToken cancellationToken)
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
IRoleService roleService,
|
||||
SupportTicket ticket,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var comments = await dbContext.TicketComments.AsNoTracking()
|
||||
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()
|
||||
var attachmentsByComment = (
|
||||
await dbContext
|
||||
.TicketAttachments.AsNoTracking()
|
||||
.Where(a => commentIds.Contains(a.CommentId))
|
||||
.ToListAsync(cancellationToken))
|
||||
.ToListAsync(cancellationToken)
|
||||
)
|
||||
.GroupBy(a => a.CommentId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
@@ -40,27 +48,52 @@ internal static class TicketMapping
|
||||
|
||||
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()))
|
||||
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);
|
||||
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)
|
||||
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()
|
||||
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) })
|
||||
@@ -71,8 +104,14 @@ internal static class TicketMapping
|
||||
|
||||
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)))
|
||||
t.Id,
|
||||
t.UserId,
|
||||
userNames.GetValueOrDefault(t.UserId, "?"),
|
||||
t.Type,
|
||||
t.Status,
|
||||
t.CreatedAt,
|
||||
lastActivity.GetValueOrDefault(t.Id, t.CreatedAt)
|
||||
))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,11 @@ 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);
|
||||
Guid Id,
|
||||
Guid UserId,
|
||||
string UserName,
|
||||
TicketType Type,
|
||||
TicketStatus Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset LastActivityAt
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user