Implement support ticket system with role request and bug report functionalities
- 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:
@@ -0,0 +1,79 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PnvPanel.Api.Common;
|
||||
using PnvPanel.Application.Admin.Support;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Support;
|
||||
using PnvPanel.Application.Support.AddComment;
|
||||
using PnvPanel.Infrastructure.Identity;
|
||||
|
||||
namespace PnvPanel.Api.Endpoints;
|
||||
|
||||
public static class AdminSupportEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAdminSupportEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var admin = app.MapGroup("/api/admin/support")
|
||||
.WithTags("Admin.Support")
|
||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||
|
||||
admin.MapGet("/tickets", ListTickets).Produces<PagedList<TicketSummaryDto>>();
|
||||
admin.MapGet("/tickets/{id:guid}", GetTicket).Produces<TicketDetailDto>();
|
||||
admin.MapPost("/tickets/{id:guid}/comments", AddComment).DisableAntiforgery().Produces<TicketCommentDto>();
|
||||
admin.MapPost("/tickets/{id:guid}/resolve", Resolve).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPost("/tickets/{id:guid}/close", Close).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPost("/tickets/{id:guid}/approve", ApproveRoleRequest).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPost("/tickets/{id:guid}/reject", RejectRoleRequest).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListTickets(
|
||||
[AsParameters] ListTicketsRequest request, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = new ListAllTicketsQuery(request.Type, request.Status, request.Page, request.PageSize);
|
||||
var result = await sender.Send(query, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTicket(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new GetTicketAdminQuery(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddComment(
|
||||
Guid id, [FromForm] string body, IFormFileCollection? files, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var command = new AddTicketCommentCommand(id, body, SupportEndpoints.ToUploads(files));
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Resolve(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new ResolveTicketCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Close(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new CloseTicketCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ApproveRoleRequest(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new ApproveRoleRequestCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RejectRoleRequest(
|
||||
Guid id, RejectRoleRequestBody body, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new RejectRoleRequestCommand(id, body.Reason), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RejectRoleRequestBody(string? Reason);
|
||||
@@ -0,0 +1,111 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PnvPanel.Api.Common;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Support;
|
||||
using PnvPanel.Application.Support.AddComment;
|
||||
using PnvPanel.Application.Support.CreateBugReport;
|
||||
using PnvPanel.Application.Support.CreateRoleRequest;
|
||||
using PnvPanel.Application.Support.GetAttachment;
|
||||
using PnvPanel.Application.Support.GetTicket;
|
||||
using PnvPanel.Application.Support.ListMyTickets;
|
||||
using PnvPanel.Application.Support.ListSelectableRoles;
|
||||
using PnvPanel.Application.Support.Reopen;
|
||||
using PnvPanel.Domain.Support;
|
||||
|
||||
namespace PnvPanel.Api.Endpoints;
|
||||
|
||||
public static class SupportEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSupportEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/support").WithTags("Support").RequireAuthorization();
|
||||
|
||||
group.MapGet("/roles", ListSelectableRoles).Produces<IReadOnlyList<RoleDto>>();
|
||||
group.MapPost("/tickets/bug-reports", CreateBugReport).DisableAntiforgery().Produces<TicketDetailDto>();
|
||||
group.MapPost("/tickets/role-requests", CreateRoleRequest).Produces<TicketDetailDto>();
|
||||
group.MapGet("/tickets", ListMyTickets).Produces<PagedList<TicketSummaryDto>>();
|
||||
group.MapGet("/tickets/{id:guid}", GetTicket).Produces<TicketDetailDto>();
|
||||
group.MapPost("/tickets/{id:guid}/comments", AddComment).DisableAntiforgery().Produces<TicketCommentDto>();
|
||||
group.MapPost("/tickets/{id:guid}/reopen", Reopen).Produces(StatusCodes.Status204NoContent);
|
||||
group.MapGet("/attachments/{id:guid}", GetAttachment);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListSelectableRoles(ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new ListSelectableRolesQuery(), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateBugReport(
|
||||
[FromForm] string message, IFormFileCollection? files, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var command = new CreateBugReportTicketCommand(message, ToUploads(files));
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateRoleRequest(
|
||||
CreateRoleRequestBody body, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var command = new CreateRoleRequestTicketCommand(
|
||||
body.ExistingRoleId, body.NewRoleName, body.NewRoleMaxConfigs, body.NewRoleMaxIpLimit, body.Justification);
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListMyTickets(
|
||||
[AsParameters] ListTicketsRequest request, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = new ListMyTicketsQuery(request.Type, request.Status, request.Page, request.PageSize);
|
||||
var result = await sender.Send(query, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTicket(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new GetTicketQuery(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddComment(
|
||||
Guid id, [FromForm] string body, IFormFileCollection? files, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var command = new AddTicketCommentCommand(id, body, ToUploads(files));
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Reopen(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new ReopenTicketCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAttachment(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new GetTicketAttachmentQuery(id), cancellationToken);
|
||||
if (!result.IsSuccess)
|
||||
return result.ToHttpResult();
|
||||
|
||||
return Results.File(result.Value.Content, result.Value.ContentType, result.Value.FileName);
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<TicketAttachmentUpload> ToUploads(IFormFileCollection? files)
|
||||
{
|
||||
if (files is null || files.Count == 0)
|
||||
return [];
|
||||
|
||||
return files
|
||||
.Select(f => new TicketAttachmentUpload(f.OpenReadStream(), f.FileName, f.ContentType, f.Length))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record CreateRoleRequestBody(
|
||||
Guid? ExistingRoleId, string? NewRoleName, int? NewRoleMaxConfigs, int? NewRoleMaxIpLimit, string Justification);
|
||||
|
||||
public sealed record ListTicketsRequest(TicketType? Type, TicketStatus? Status, int Page = 1, int PageSize = 20);
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.SignalR;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Domain.Configs;
|
||||
using PnvPanel.Domain.Nodes;
|
||||
using PnvPanel.Domain.Support;
|
||||
|
||||
namespace PnvPanel.Api.Hubs;
|
||||
|
||||
@@ -56,4 +57,20 @@ internal sealed class SignalRRealtimeNotifier(IHubContext<PanelHub> hubContext)
|
||||
new { id = postId, title, createdAt },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task NotifyTicketCreatedAsync(Guid ticketId, Guid userId, string userName, TicketType type, CancellationToken cancellationToken)
|
||||
{
|
||||
return hubContext.Clients.Group(GroupNames.Admins).SendAsync(
|
||||
"ticketCreated",
|
||||
new { ticketId, userId, userName, type = type.ToString() },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task NotifyTicketUpdatedAsync(Guid ticketId, Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync(
|
||||
"ticketUpdated",
|
||||
new { ticketId },
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +156,8 @@ app.MapAdminUserEndpoints();
|
||||
app.MapAdminStatsEndpoints();
|
||||
app.MapAdminAppEndpoints();
|
||||
app.MapAdminNewsEndpoints();
|
||||
app.MapSupportEndpoints();
|
||||
app.MapAdminSupportEndpoints();
|
||||
app.MapTelegramEndpoints();
|
||||
|
||||
app.MapHub<PanelHub>("/hubs/panel");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using PnvPanel.Application.Admin.Activation;
|
||||
using PnvPanel.Application.Admin.Support;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Configs.GetConfigLink;
|
||||
@@ -222,6 +223,29 @@ public sealed class PnvBotUpdateHandler(
|
||||
|
||||
break;
|
||||
}
|
||||
case "rrq":
|
||||
{
|
||||
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.AnswerCallbackQuery(callback.Id, "Недостаточно прав.", cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = parts[1] == "approve"
|
||||
? await sender.Send(new ApproveRoleRequestCommand(requestId), cancellationToken)
|
||||
: await sender.Send(new RejectRoleRequestCommand(requestId, Reason: null), cancellationToken);
|
||||
|
||||
await botClient.AnswerCallbackQuery(callback.Id, result.IsSuccess ? "Готово" : result.Error.Message, cancellationToken: cancellationToken);
|
||||
if (result.IsSuccess && callback.Message is not null)
|
||||
{
|
||||
var statusText = parts[1] == "approve" ? "✅ Заявка одобрена, роль выдана." : "❌ Заявка отклонена.";
|
||||
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
|
||||
await botClient.EditMessageText(
|
||||
chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "cfg":
|
||||
{
|
||||
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
|
||||
|
||||
@@ -39,6 +39,64 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
|
||||
}
|
||||
}
|
||||
|
||||
public async Task NotifyAdminsBugReportCreatedAsync(Guid ticketId, string userName, string message, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||
return;
|
||||
|
||||
var preview = message.Length > 300 ? message[..300] + "…" : message;
|
||||
var text = $"🐞 Новый тикет (баг/предложение) от <b>{Escape(userName)}</b>\n{Escape(preview)}";
|
||||
|
||||
// Только ссылка на сайт — переписка и картинки удобнее там, инлайн-действий для баг-тикетов нет.
|
||||
InlineKeyboardMarkup? keyboard = null;
|
||||
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
|
||||
{
|
||||
var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/admin/support/{ticketId}";
|
||||
keyboard = new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) });
|
||||
}
|
||||
|
||||
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
|
||||
{
|
||||
try
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task NotifyAdminsRoleRequestCreatedAsync(
|
||||
Guid ticketId, string userName, string roleDescription, string justification, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||
return;
|
||||
|
||||
var text = $"🆕 Заявка на роль от <b>{Escape(userName)}</b>\n{Escape(roleDescription)}\nОбоснование: {Escape(justification)}";
|
||||
|
||||
var keyboard = new InlineKeyboardMarkup(new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData("✅ Одобрить", $"rrq:approve:{ticketId}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"rrq:reject:{ticketId}"),
|
||||
});
|
||||
|
||||
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
|
||||
{
|
||||
try
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task NotifyUserAsync(Guid userId, string message, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
@@ -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);
|
||||
}
|
||||
}
|
||||
+7
@@ -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;
|
||||
+46
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
+10
@@ -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;
|
||||
+73
@@ -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);
|
||||
}
|
||||
}
|
||||
+31
@@ -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;
|
||||
+39
@@ -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));
|
||||
}
|
||||
}
|
||||
+9
@@ -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;
|
||||
+21
@@ -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);
|
||||
@@ -0,0 +1,94 @@
|
||||
using PnvPanel.Domain.Common;
|
||||
using PnvPanel.Domain.Exceptions;
|
||||
|
||||
namespace PnvPanel.Domain.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Обращение в поддержку: баг/предложение (свободная форма) либо заявка на роль (существующая роль
|
||||
/// или параметры новой). Текст обращения и переписка — в TicketComment, отдельной таблицей (не
|
||||
/// навигационная коллекция — см. конвенцию проекта на плоских сущностях, ср. TrafficSample/VpnConfig).
|
||||
/// Для RoleRequest заполнен либо RequestedRoleId, либо Proposed* — гарантируется отдельными фабриками,
|
||||
/// а не runtime-проверкой одного универсального конструктора.
|
||||
/// </summary>
|
||||
public sealed class SupportTicket : Entity
|
||||
{
|
||||
public Guid UserId { get; private set; }
|
||||
public TicketType Type { get; private set; }
|
||||
public TicketStatus Status { get; private set; }
|
||||
public Guid? RequestedRoleId { get; private set; }
|
||||
public string? ProposedRoleName { get; private set; }
|
||||
public int? ProposedMaxConfigs { get; private set; }
|
||||
public int? ProposedMaxIpLimit { get; private set; }
|
||||
public DateTimeOffset CreatedAt { get; private set; }
|
||||
|
||||
private SupportTicket()
|
||||
{
|
||||
}
|
||||
|
||||
public static SupportTicket CreateBugReport(Guid userId)
|
||||
{
|
||||
return new SupportTicket
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
Type = TicketType.BugReport,
|
||||
Status = TicketStatus.Open,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
}
|
||||
|
||||
public static SupportTicket CreateRoleRequestForExistingRole(Guid userId, Guid roleId)
|
||||
{
|
||||
return new SupportTicket
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
Type = TicketType.RoleRequest,
|
||||
Status = TicketStatus.Open,
|
||||
RequestedRoleId = roleId,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
}
|
||||
|
||||
public static SupportTicket CreateRoleRequestForNewRole(Guid userId, string name, int maxConfigs, int maxIpLimit)
|
||||
{
|
||||
return new SupportTicket
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
Type = TicketType.RoleRequest,
|
||||
Status = TicketStatus.Open,
|
||||
ProposedRoleName = name,
|
||||
ProposedMaxConfigs = maxConfigs,
|
||||
ProposedMaxIpLimit = maxIpLimit,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Решено (в т.ч. заявка на роль одобрена — роль выдаётся оркестрацией на уровне Application).</summary>
|
||||
public void Resolve()
|
||||
{
|
||||
if (Status != TicketStatus.Open)
|
||||
throw new DomainException($"Нельзя перевести в Resolved тикет в статусе {Status}.");
|
||||
|
||||
Status = TicketStatus.Resolved;
|
||||
}
|
||||
|
||||
/// <summary>Финальное состояние — обратного пути нет (в т.ч. заявка на роль отклонена).</summary>
|
||||
public void Close()
|
||||
{
|
||||
if (Status == TicketStatus.Closed)
|
||||
throw new DomainException("Тикет уже закрыт.");
|
||||
|
||||
Status = TicketStatus.Closed;
|
||||
}
|
||||
|
||||
/// <summary>Только из Resolved — Closed финален и не переоткрывается.</summary>
|
||||
public void Reopen()
|
||||
{
|
||||
if (Status != TicketStatus.Resolved)
|
||||
throw new DomainException("Переоткрыть можно только решённый тикет.");
|
||||
|
||||
Status = TicketStatus.Open;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using PnvPanel.Domain.Common;
|
||||
|
||||
namespace PnvPanel.Domain.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Вложение (изображение) к сообщению тикета. StoredFileName — серверное имя на диске (GUID-based),
|
||||
/// FileName — оригинальное имя только для отображения (не участвует в построении пути — не доверяем
|
||||
/// пользовательскому вводу для файловой системы).
|
||||
/// </summary>
|
||||
public sealed class TicketAttachment : Entity
|
||||
{
|
||||
public Guid CommentId { get; private set; }
|
||||
public string FileName { get; private set; } = string.Empty;
|
||||
public string StoredFileName { get; private set; } = string.Empty;
|
||||
public string ContentType { get; private set; } = string.Empty;
|
||||
public long SizeBytes { get; private set; }
|
||||
public DateTimeOffset CreatedAt { get; private set; }
|
||||
|
||||
private TicketAttachment()
|
||||
{
|
||||
}
|
||||
|
||||
public static TicketAttachment Create(Guid commentId, string fileName, string storedFileName, string contentType, long sizeBytes)
|
||||
{
|
||||
return new TicketAttachment
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CommentId = commentId,
|
||||
FileName = fileName,
|
||||
StoredFileName = storedFileName,
|
||||
ContentType = contentType,
|
||||
SizeBytes = sizeBytes,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using PnvPanel.Domain.Common;
|
||||
|
||||
namespace PnvPanel.Domain.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Сообщение в переписке по тикету — первое сообщение при создании тикета одновременно служит
|
||||
/// описанием бага/обоснованием заявки на роль (отдельного поля под это на SupportTicket нет).
|
||||
/// Вложения — TicketAttachment, отдельной таблицей по CommentId.
|
||||
/// </summary>
|
||||
public sealed class TicketComment : Entity
|
||||
{
|
||||
public Guid TicketId { get; private set; }
|
||||
public Guid AuthorId { get; private set; }
|
||||
public string Body { get; private set; } = string.Empty;
|
||||
public DateTimeOffset CreatedAt { get; private set; }
|
||||
|
||||
private TicketComment()
|
||||
{
|
||||
}
|
||||
|
||||
public static TicketComment Create(Guid ticketId, Guid authorId, string body)
|
||||
{
|
||||
return new TicketComment
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TicketId = ticketId,
|
||||
AuthorId = authorId,
|
||||
Body = body,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace PnvPanel.Domain.Support;
|
||||
|
||||
public enum TicketStatus
|
||||
{
|
||||
Open,
|
||||
Resolved,
|
||||
Closed,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace PnvPanel.Domain.Support;
|
||||
|
||||
public enum TicketType
|
||||
{
|
||||
BugReport,
|
||||
RoleRequest,
|
||||
}
|
||||
@@ -11,6 +11,7 @@ using PnvPanel.Infrastructure.BackgroundJobs;
|
||||
using PnvPanel.Infrastructure.Identity;
|
||||
using PnvPanel.Infrastructure.Persistence;
|
||||
using PnvPanel.Infrastructure.Security;
|
||||
using PnvPanel.Infrastructure.Storage;
|
||||
using PnvPanel.Infrastructure.Telegram;
|
||||
using PnvPanel.Infrastructure.Xui;
|
||||
using ThreeXui.ConnectionStrings;
|
||||
@@ -129,6 +130,9 @@ public static class DependencyInjection
|
||||
|
||||
services.Configure<TelegramOptions>(configuration.GetSection(TelegramOptions.SectionName));
|
||||
|
||||
services.Configure<FileStorageOptions>(configuration.GetSection(FileStorageOptions.SectionName));
|
||||
services.AddSingleton<IFileStorage, DiskFileStorage>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,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;
|
||||
using PnvPanel.Infrastructure.Identity;
|
||||
|
||||
@@ -42,6 +43,12 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
|
||||
public DbSet<NewsPost> NewsPosts => Set<NewsPost>();
|
||||
|
||||
public DbSet<SupportTicket> SupportTickets => Set<SupportTicket>();
|
||||
|
||||
public DbSet<TicketComment> TicketComments => Set<TicketComment>();
|
||||
|
||||
public DbSet<TicketAttachment> TicketAttachments => Set<TicketAttachment>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using PnvPanel.Domain.Support;
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Configurations;
|
||||
|
||||
public class SupportTicketConfiguration : IEntityTypeConfiguration<SupportTicket>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SupportTicket> builder)
|
||||
{
|
||||
builder.ToTable("SupportTickets");
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.Type).HasConversion<string>().HasMaxLength(32);
|
||||
builder.Property(x => x.Status).HasConversion<string>().HasMaxLength(32);
|
||||
builder.Property(x => x.ProposedRoleName).HasMaxLength(100);
|
||||
|
||||
builder.HasIndex(x => new { x.UserId, x.Status });
|
||||
builder.HasIndex(x => new { x.Type, x.Status });
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using PnvPanel.Domain.Support;
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Configurations;
|
||||
|
||||
public class TicketAttachmentConfiguration : IEntityTypeConfiguration<TicketAttachment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TicketAttachment> builder)
|
||||
{
|
||||
builder.ToTable("TicketAttachments");
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.FileName).IsRequired().HasMaxLength(255);
|
||||
builder.Property(x => x.StoredFileName).IsRequired().HasMaxLength(100);
|
||||
builder.Property(x => x.ContentType).IsRequired().HasMaxLength(100);
|
||||
|
||||
builder.HasIndex(x => x.StoredFileName).IsUnique();
|
||||
builder.HasIndex(x => x.CommentId);
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using PnvPanel.Domain.Support;
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Configurations;
|
||||
|
||||
public class TicketCommentConfiguration : IEntityTypeConfiguration<TicketComment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TicketComment> builder)
|
||||
{
|
||||
builder.ToTable("TicketComments");
|
||||
builder.HasKey(x => x.Id);
|
||||
|
||||
builder.Property(x => x.Body).IsRequired().HasMaxLength(4000);
|
||||
|
||||
builder.HasIndex(x => new { x.TicketId, x.CreatedAt });
|
||||
}
|
||||
}
|
||||
+887
@@ -0,0 +1,887 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using PnvPanel.Infrastructure.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260714031703_AddSupportTickets")]
|
||||
partial class AddSupportTickets
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Comment")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("DecidedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("DecidedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("RejectionReason")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "Status");
|
||||
|
||||
b.ToTable("ActivationRequests", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("DownloadUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("IconUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("OperatingSystem")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ClientApps", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid?>("ActorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Metadata")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("TargetId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("TargetType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("AuditLogs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<Guid>("ConfigId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<long>("DownBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTimeOffset>("Timestamp")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("UpBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ConfigId", "Timestamp");
|
||||
|
||||
b.ToTable("TrafficSamples", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ClientEmail")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("ClientExternalId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("InboundId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("SubscriptionToken")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<long>("UsedDownBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("UsedUpBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InboundId");
|
||||
|
||||
b.HasIndex("SubscriptionToken")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId", "Status");
|
||||
|
||||
b.ToTable("VpnConfigs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.PrimitiveCollection<Guid[]>("AllowedRoleIds")
|
||||
.IsRequired()
|
||||
.HasColumnType("uuid[]");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("IsPublished")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("MaxClients")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("NodeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Port")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Protocol")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Remark")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("RemoteInboundId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NodeId", "RemoteInboundId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Inbounds", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.News.NewsPost", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20000)
|
||||
.HasColumnType("character varying(20000)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CreatedAt");
|
||||
|
||||
b.ToTable("NewsPosts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BaseAddress")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSyncAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Nodes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("ProposedMaxConfigs")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("ProposedMaxIpLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ProposedRoleName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid?>("RequestedRoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Type", "Status");
|
||||
|
||||
b.HasIndex("UserId", "Status");
|
||||
|
||||
b.ToTable("SupportTickets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Support.TicketAttachment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CommentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StoredFileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CommentId");
|
||||
|
||||
b.HasIndex("StoredFileName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("TicketAttachments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Support.TicketComment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AuthorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("TicketId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TicketId", "CreatedAt");
|
||||
|
||||
b.ToTable("TicketComments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("ConsumedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("TelegramLinkTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Context")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid?>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("TelegramLoginRequests", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsSystem")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("MaxConfigs")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("MaxIpLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("ActivatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("ActivatedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsActivated")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsBlocked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SubscriptionToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("TelegramLinkedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("TelegramUserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("TelegramUsername")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.HasIndex("SubscriptionToken")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TelegramUserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ReplacedByTokenHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("RefreshTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b =>
|
||||
{
|
||||
b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("NodeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("ProtectedPassword")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("CredentialsProtectedPassword");
|
||||
|
||||
b1.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("CredentialsUsername");
|
||||
|
||||
b1.HasKey("NodeId");
|
||||
|
||||
b1.ToTable("Nodes");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("NodeId");
|
||||
});
|
||||
|
||||
b.Navigation("Credentials")
|
||||
.IsRequired();
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSupportTickets : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SupportTickets",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
RequestedRoleId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
ProposedRoleName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
ProposedMaxConfigs = table.Column<int>(type: "integer", nullable: true),
|
||||
ProposedMaxIpLimit = table.Column<int>(type: "integer", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SupportTickets", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TicketAttachments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CommentId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FileName = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
|
||||
StoredFileName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
ContentType = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TicketAttachments", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TicketComments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
TicketId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
AuthorId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Body = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TicketComments", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SupportTickets_Type_Status",
|
||||
table: "SupportTickets",
|
||||
columns: new[] { "Type", "Status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SupportTickets_UserId_Status",
|
||||
table: "SupportTickets",
|
||||
columns: new[] { "UserId", "Status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TicketAttachments_CommentId",
|
||||
table: "TicketAttachments",
|
||||
column: "CommentId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TicketAttachments_StoredFileName",
|
||||
table: "TicketAttachments",
|
||||
column: "StoredFileName",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TicketComments_TicketId_CreatedAt",
|
||||
table: "TicketComments",
|
||||
columns: new[] { "TicketId", "CreatedAt" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "SupportTickets");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TicketAttachments");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TicketComments");
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
@@ -462,6 +462,117 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("Nodes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("ProposedMaxConfigs")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("ProposedMaxIpLimit")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ProposedRoleName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid?>("RequestedRoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Type", "Status");
|
||||
|
||||
b.HasIndex("UserId", "Status");
|
||||
|
||||
b.ToTable("SupportTickets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Support.TicketAttachment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("CommentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("StoredFileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CommentId");
|
||||
|
||||
b.HasIndex("StoredFileName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("TicketAttachments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Support.TicketComment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AuthorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("TicketId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TicketId", "CreatedAt");
|
||||
|
||||
b.ToTable("TicketComments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
|
||||
namespace PnvPanel.Infrastructure.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Хранит вложения как файлы на диске под GUID-именем (не оригинальным именем пользователя —
|
||||
/// исключает path traversal и коллизии). Оригинальное имя/тип — в TicketAttachment (Application/Domain),
|
||||
/// это хранилище знает только про байты по непрозрачному ключу.
|
||||
/// </summary>
|
||||
internal sealed class DiskFileStorage(IOptions<FileStorageOptions> options) : IFileStorage
|
||||
{
|
||||
public async Task<string> SaveAsync(Stream content, CancellationToken cancellationToken)
|
||||
{
|
||||
Directory.CreateDirectory(options.Value.RootPath);
|
||||
|
||||
var storedFileName = Guid.NewGuid().ToString("N");
|
||||
var path = Path.Combine(options.Value.RootPath, storedFileName);
|
||||
|
||||
await using var fileStream = File.Create(path);
|
||||
await content.CopyToAsync(fileStream, cancellationToken);
|
||||
|
||||
return storedFileName;
|
||||
}
|
||||
|
||||
public Task<Stream?> OpenReadAsync(string storedFileName, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = Path.Combine(options.Value.RootPath, storedFileName);
|
||||
if (!File.Exists(path))
|
||||
return Task.FromResult<Stream?>(null);
|
||||
|
||||
return Task.FromResult<Stream?>(File.OpenRead(path));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace PnvPanel.Infrastructure.Storage;
|
||||
|
||||
public sealed class FileStorageOptions
|
||||
{
|
||||
public const string SectionName = "FileStorage";
|
||||
|
||||
/// <summary>Каталог на диске для вложений тикетов. Должен указывать на постоянный volume —
|
||||
/// иначе вложения теряются при пересоздании контейнера (см. docker-compose.yml).</summary>
|
||||
public string RootPath { get; init; } = "./uploads";
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Admin.Support;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Support;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Support;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Admin.Support;
|
||||
|
||||
public class ApproveRoleRequestCommandHandlerTests
|
||||
{
|
||||
private readonly IRoleService _roleService = Substitute.For<IRoleService>();
|
||||
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
|
||||
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ForNewRoleRequest_CreatesRoleAssignsAndResolves()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
var ticket = SupportTicket.CreateRoleRequestForNewRole(userId, "premium", 10, 5);
|
||||
dbContext.SupportTickets.Add(ticket);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var newRoleId = Guid.NewGuid();
|
||||
_roleService.CreateRoleAsync("premium", 10, 5, Arg.Any<CancellationToken>())
|
||||
.Returns(Result.Success(new RoleDto(newRoleId, "premium", 10, 5, false)));
|
||||
_roleService.ChangeUserRoleAsync(userId, newRoleId, Arg.Any<CancellationToken>()).Returns(Result.Success());
|
||||
|
||||
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin");
|
||||
var handler = new ApproveRoleRequestCommandHandler(dbContext, _roleService, _notifier, _telegramNotifier, currentUser);
|
||||
|
||||
var result = await handler.Handle(new ApproveRoleRequestCommand(ticket.Id), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(TicketStatus.Resolved, ticket.Status);
|
||||
await _roleService.Received(1).CreateRoleAsync("premium", 10, 5, Arg.Any<CancellationToken>());
|
||||
await _roleService.Received(1).ChangeUserRoleAsync(userId, newRoleId, Arg.Any<CancellationToken>());
|
||||
await _telegramNotifier.Received(1).NotifyUserAsync(userId, Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ForExistingRoleRequest_SkipsRoleCreation()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
var roleId = Guid.NewGuid();
|
||||
var ticket = SupportTicket.CreateRoleRequestForExistingRole(userId, roleId);
|
||||
dbContext.SupportTickets.Add(ticket);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_roleService.ChangeUserRoleAsync(userId, roleId, Arg.Any<CancellationToken>()).Returns(Result.Success());
|
||||
|
||||
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid());
|
||||
var handler = new ApproveRoleRequestCommandHandler(dbContext, _roleService, _notifier, _telegramNotifier, currentUser);
|
||||
|
||||
var result = await handler.Handle(new ApproveRoleRequestCommand(ticket.Id), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await _roleService.DidNotReceive().CreateRoleAsync(
|
||||
Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenNotRoleRequestType_ReturnsError()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var ticket = SupportTicket.CreateBugReport(Guid.NewGuid());
|
||||
dbContext.SupportTickets.Add(ticket);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid());
|
||||
var handler = new ApproveRoleRequestCommandHandler(dbContext, _roleService, _notifier, _telegramNotifier, currentUser);
|
||||
|
||||
var result = await handler.Handle(new ApproveRoleRequestCommand(ticket.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(SupportErrors.NotRoleRequest, result.Error);
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Support;
|
||||
using PnvPanel.Application.Support.AddComment;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Support;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Support;
|
||||
|
||||
public class AddTicketCommentCommandHandlerTests
|
||||
{
|
||||
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||
private readonly IFileStorage _fileStorage = Substitute.For<IFileStorage>();
|
||||
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
|
||||
|
||||
private static CurrentUserProfile Profile(Guid userId, string role) =>
|
||||
new(userId, "user", Guid.NewGuid(), role, IsActivated: true, IsBlocked: false, MaxConfigs: 3,
|
||||
MaxIpLimit: RoleQuota.Unlimited, SubscriptionToken: "token");
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenOwnerComments_AddsCommentAndDoesNotNotifySelf()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
var ticket = SupportTicket.CreateBugReport(userId);
|
||||
dbContext.SupportTickets.Add(ticket);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(Profile(userId, "user"));
|
||||
var currentUser = FakeCurrentUser.Authenticated(userId, "alice");
|
||||
|
||||
var handler = new AddTicketCommentCommandHandler(dbContext, _identityService, _fileStorage, _notifier, currentUser);
|
||||
|
||||
var result = await handler.Handle(new AddTicketCommentCommand(ticket.Id, "апдейт", []), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("апдейт", result.Value.Body);
|
||||
await _notifier.DidNotReceive().NotifyTicketUpdatedAsync(Arg.Any<Guid>(), Arg.Any<Guid>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenAdminComments_NotifiesOwner()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var ownerId = Guid.NewGuid();
|
||||
var adminId = Guid.NewGuid();
|
||||
var ticket = SupportTicket.CreateBugReport(ownerId);
|
||||
dbContext.SupportTickets.Add(ticket);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_identityService.GetProfileAsync(adminId, Arg.Any<CancellationToken>()).Returns(Profile(adminId, "admin"));
|
||||
var currentUser = FakeCurrentUser.Authenticated(adminId, "admin");
|
||||
|
||||
var handler = new AddTicketCommentCommandHandler(dbContext, _identityService, _fileStorage, _notifier, currentUser);
|
||||
|
||||
var result = await handler.Handle(new AddTicketCommentCommand(ticket.Id, "ответ админа", []), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
await _notifier.Received(1).NotifyTicketUpdatedAsync(ticket.Id, ownerId, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenNotOwnerAndNotAdmin_ReturnsNotFound()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var ownerId = Guid.NewGuid();
|
||||
var strangerId = Guid.NewGuid();
|
||||
var ticket = SupportTicket.CreateBugReport(ownerId);
|
||||
dbContext.SupportTickets.Add(ticket);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_identityService.GetProfileAsync(strangerId, Arg.Any<CancellationToken>()).Returns(Profile(strangerId, "user"));
|
||||
var currentUser = FakeCurrentUser.Authenticated(strangerId);
|
||||
|
||||
var handler = new AddTicketCommentCommandHandler(dbContext, _identityService, _fileStorage, _notifier, currentUser);
|
||||
|
||||
var result = await handler.Handle(new AddTicketCommentCommand(ticket.Id, "текст", []), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(SupportErrors.NotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenTicketClosed_ReturnsTicketClosedError()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
var ticket = SupportTicket.CreateBugReport(userId);
|
||||
ticket.Close();
|
||||
dbContext.SupportTickets.Add(ticket);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
_identityService.GetProfileAsync(userId, Arg.Any<CancellationToken>()).Returns(Profile(userId, "user"));
|
||||
var currentUser = FakeCurrentUser.Authenticated(userId);
|
||||
|
||||
var handler = new AddTicketCommentCommandHandler(dbContext, _identityService, _fileStorage, _notifier, currentUser);
|
||||
|
||||
var result = await handler.Handle(new AddTicketCommentCommand(ticket.Id, "текст", []), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(SupportErrors.TicketClosed, result.Error);
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Support;
|
||||
using PnvPanel.Application.Support.CreateBugReport;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Support;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Support;
|
||||
|
||||
public class CreateBugReportTicketCommandHandlerTests
|
||||
{
|
||||
private readonly IFileStorage _fileStorage = Substitute.For<IFileStorage>();
|
||||
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
|
||||
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidMessage_CreatesTicketAndComment()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
var currentUser = FakeCurrentUser.Authenticated(userId, "alice");
|
||||
|
||||
var handler = new CreateBugReportTicketCommandHandler(dbContext, _fileStorage, _notifier, _telegramNotifier, currentUser);
|
||||
|
||||
var result = await handler.Handle(new CreateBugReportTicketCommand("Что-то сломалось", []), CancellationToken.None);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(TicketType.BugReport, result.Value.Type);
|
||||
Assert.Equal(TicketStatus.Open, result.Value.Status);
|
||||
Assert.Single(result.Value.Comments);
|
||||
Assert.Equal("Что-то сломалось", result.Value.Comments[0].Body);
|
||||
Assert.Single(dbContext.SupportTickets);
|
||||
Assert.Single(dbContext.TicketComments);
|
||||
await _telegramNotifier.Received(1).NotifyAdminsBugReportCreatedAsync(
|
||||
Arg.Any<Guid>(), "alice", "Что-то сломалось", Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithTooManyAttachments_ReturnsValidationError()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid());
|
||||
var attachments = Enumerable.Range(0, 6)
|
||||
.Select(_ => new TicketAttachmentUpload(new MemoryStream(), "a.png", "image/png", 10))
|
||||
.ToList();
|
||||
|
||||
var handler = new CreateBugReportTicketCommandHandler(dbContext, _fileStorage, _notifier, _telegramNotifier, currentUser);
|
||||
|
||||
var result = await handler.Handle(new CreateBugReportTicketCommand("текст", attachments), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(SupportErrors.TooManyAttachments, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithUnsupportedAttachmentType_ReturnsValidationError()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid());
|
||||
var attachments = new[] { new TicketAttachmentUpload(new MemoryStream(), "a.exe", "application/x-msdownload", 10) };
|
||||
|
||||
var handler = new CreateBugReportTicketCommandHandler(dbContext, _fileStorage, _notifier, _telegramNotifier, currentUser);
|
||||
|
||||
var result = await handler.Handle(new CreateBugReportTicketCommand("текст", attachments), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(SupportErrors.UnsupportedAttachmentType, result.Error);
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Support;
|
||||
using PnvPanel.Application.Support.CreateRoleRequest;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Support;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Support;
|
||||
|
||||
public class CreateRoleRequestTicketCommandHandlerTests
|
||||
{
|
||||
private readonly IRoleService _roleService = Substitute.For<IRoleService>();
|
||||
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
|
||||
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ForExistingRole_CreatesTicket()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
var roleId = Guid.NewGuid();
|
||||
var currentUser = FakeCurrentUser.Authenticated(userId, "alice");
|
||||
_roleService.ListRolesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<RoleDto> { new(roleId, "premium", 5, 2, false) });
|
||||
|
||||
var handler = new CreateRoleRequestTicketCommandHandler(dbContext, _roleService, _notifier, _telegramNotifier, currentUser);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new CreateRoleRequestTicketCommand(roleId, null, null, null, "нужно больше конфигов"), CancellationToken.None);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(roleId, result.Value.RequestedRoleId);
|
||||
Assert.Equal("premium", result.Value.RequestedRoleName);
|
||||
await _telegramNotifier.Received(1).NotifyAdminsRoleRequestCreatedAsync(
|
||||
Arg.Any<Guid>(), "alice", "premium", "нужно больше конфигов", Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ForAdminRole_ReturnsForbidden()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var roleId = Guid.NewGuid();
|
||||
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid());
|
||||
_roleService.ListRolesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<RoleDto> { new(roleId, "admin", -1, -1, true) });
|
||||
|
||||
var handler = new CreateRoleRequestTicketCommandHandler(dbContext, _roleService, _notifier, _telegramNotifier, currentUser);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new CreateRoleRequestTicketCommand(roleId, null, null, null, "хочу быть админом"), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(SupportErrors.CannotRequestAdminRole, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ForNewRole_SetsProposedFields()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid(), "bob");
|
||||
|
||||
var handler = new CreateRoleRequestTicketCommandHandler(dbContext, _roleService, _notifier, _telegramNotifier, currentUser);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new CreateRoleRequestTicketCommand(null, "custom", 10, 4, "нужна кастомная роль"), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("custom", result.Value.ProposedRoleName);
|
||||
Assert.Equal(10, result.Value.ProposedMaxConfigs);
|
||||
Assert.Equal(4, result.Value.ProposedMaxIpLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenPendingRoleRequestExists_ReturnsConflict()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
dbContext.SupportTickets.Add(SupportTicket.CreateRoleRequestForNewRole(userId, "x", 1, 1));
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var currentUser = FakeCurrentUser.Authenticated(userId);
|
||||
var handler = new CreateRoleRequestTicketCommandHandler(dbContext, _roleService, _notifier, _telegramNotifier, currentUser);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new CreateRoleRequestTicketCommand(null, "y", 2, 2, "ещё заявка"), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(SupportErrors.RoleRequestAlreadyPending, result.Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using PnvPanel.Application.Support;
|
||||
using PnvPanel.Application.Support.Reopen;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Support;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Support;
|
||||
|
||||
public class ReopenTicketCommandHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Handle_WhenResolved_SetsOpen()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
var ticket = SupportTicket.CreateBugReport(userId);
|
||||
ticket.Resolve();
|
||||
dbContext.SupportTickets.Add(ticket);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var currentUser = FakeCurrentUser.Authenticated(userId);
|
||||
var handler = new ReopenTicketCommandHandler(dbContext, currentUser);
|
||||
|
||||
var result = await handler.Handle(new ReopenTicketCommand(ticket.Id), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(TicketStatus.Open, ticket.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenOpen_ReturnsNotResolved()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var userId = Guid.NewGuid();
|
||||
var ticket = SupportTicket.CreateBugReport(userId);
|
||||
dbContext.SupportTickets.Add(ticket);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var currentUser = FakeCurrentUser.Authenticated(userId);
|
||||
var handler = new ReopenTicketCommandHandler(dbContext, currentUser);
|
||||
|
||||
var result = await handler.Handle(new ReopenTicketCommand(ticket.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(SupportErrors.NotResolved, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WhenNotOwner_ReturnsNotFound()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var ticket = SupportTicket.CreateBugReport(Guid.NewGuid());
|
||||
ticket.Resolve();
|
||||
dbContext.SupportTickets.Add(ticket);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var currentUser = FakeCurrentUser.Authenticated(Guid.NewGuid());
|
||||
var handler = new ReopenTicketCommandHandler(dbContext, currentUser);
|
||||
|
||||
var result = await handler.Handle(new ReopenTicketCommand(ticket.Id), CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(SupportErrors.NotFound, result.Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using PnvPanel.Domain.Exceptions;
|
||||
using PnvPanel.Domain.Support;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Domain.Tests.Support;
|
||||
|
||||
public class SupportTicketTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateBugReport_SetsOpenStatus()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var ticket = SupportTicket.CreateBugReport(userId);
|
||||
|
||||
Assert.Equal(userId, ticket.UserId);
|
||||
Assert.Equal(TicketType.BugReport, ticket.Type);
|
||||
Assert.Equal(TicketStatus.Open, ticket.Status);
|
||||
Assert.Null(ticket.RequestedRoleId);
|
||||
Assert.Null(ticket.ProposedRoleName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateRoleRequestForExistingRole_SetsRequestedRoleId()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var roleId = Guid.NewGuid();
|
||||
|
||||
var ticket = SupportTicket.CreateRoleRequestForExistingRole(userId, roleId);
|
||||
|
||||
Assert.Equal(TicketType.RoleRequest, ticket.Type);
|
||||
Assert.Equal(roleId, ticket.RequestedRoleId);
|
||||
Assert.Null(ticket.ProposedRoleName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateRoleRequestForNewRole_SetsProposedFields()
|
||||
{
|
||||
var ticket = SupportTicket.CreateRoleRequestForNewRole(Guid.NewGuid(), "premium", 5, 3);
|
||||
|
||||
Assert.Equal(TicketType.RoleRequest, ticket.Type);
|
||||
Assert.Null(ticket.RequestedRoleId);
|
||||
Assert.Equal("premium", ticket.ProposedRoleName);
|
||||
Assert.Equal(5, ticket.ProposedMaxConfigs);
|
||||
Assert.Equal(3, ticket.ProposedMaxIpLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_WhenOpen_SetsResolved()
|
||||
{
|
||||
var ticket = SupportTicket.CreateBugReport(Guid.NewGuid());
|
||||
|
||||
ticket.Resolve();
|
||||
|
||||
Assert.Equal(TicketStatus.Resolved, ticket.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_WhenNotOpen_Throws()
|
||||
{
|
||||
var ticket = SupportTicket.CreateBugReport(Guid.NewGuid());
|
||||
ticket.Resolve();
|
||||
|
||||
Assert.Throws<DomainException>(() => ticket.Resolve());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Close_WhenOpen_SetsClosed()
|
||||
{
|
||||
var ticket = SupportTicket.CreateBugReport(Guid.NewGuid());
|
||||
|
||||
ticket.Close();
|
||||
|
||||
Assert.Equal(TicketStatus.Closed, ticket.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Close_WhenResolved_SetsClosed()
|
||||
{
|
||||
var ticket = SupportTicket.CreateBugReport(Guid.NewGuid());
|
||||
ticket.Resolve();
|
||||
|
||||
ticket.Close();
|
||||
|
||||
Assert.Equal(TicketStatus.Closed, ticket.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Close_WhenAlreadyClosed_Throws()
|
||||
{
|
||||
var ticket = SupportTicket.CreateBugReport(Guid.NewGuid());
|
||||
ticket.Close();
|
||||
|
||||
Assert.Throws<DomainException>(() => ticket.Close());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reopen_WhenResolved_SetsOpen()
|
||||
{
|
||||
var ticket = SupportTicket.CreateBugReport(Guid.NewGuid());
|
||||
ticket.Resolve();
|
||||
|
||||
ticket.Reopen();
|
||||
|
||||
Assert.Equal(TicketStatus.Open, ticket.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reopen_WhenOpen_Throws()
|
||||
{
|
||||
var ticket = SupportTicket.CreateBugReport(Guid.NewGuid());
|
||||
|
||||
Assert.Throws<DomainException>(() => ticket.Reopen());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reopen_WhenClosed_Throws()
|
||||
{
|
||||
var ticket = SupportTicket.CreateBugReport(Guid.NewGuid());
|
||||
ticket.Close();
|
||||
|
||||
Assert.Throws<DomainException>(() => ticket.Reopen());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user