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:
@@ -21,6 +21,11 @@ Jwt__RefreshTokenDays=30
|
|||||||
# Путь к тому с key-ring (должен быть примонтирован и переживать перезапуск контейнера)
|
# Путь к тому с key-ring (должен быть примонтирован и переживать перезапуск контейнера)
|
||||||
DataProtection__KeyRingPath=/app/keys
|
DataProtection__KeyRingPath=/app/keys
|
||||||
|
|
||||||
|
# ── Файловое хранилище (вложения тикетов поддержки) ─────────────────────────
|
||||||
|
# Путь на диске для скриншотов к тикетам — должен быть на постоянном томе (см. docker-compose.yml,
|
||||||
|
# FileStorage__RootPath там уже задан явно под volume 'ticket_uploads', эту строку трогать не нужно).
|
||||||
|
# FileStorage__RootPath=/app/uploads
|
||||||
|
|
||||||
# ── Сид администратора (создаётся при первом старте, если не существует) ───
|
# ── Сид администратора (создаётся при первом старте, если не существует) ───
|
||||||
# Логин в систему — по username. Email в системе не используется.
|
# Логин в систему — по username. Email в системе не используется.
|
||||||
AdminSeed__Username=admin
|
AdminSeed__Username=admin
|
||||||
|
|||||||
@@ -81,6 +81,11 @@
|
|||||||
одними командами.
|
одними командами.
|
||||||
- **Инбаунды по ролям** (`Inbound.AllowedRoles`, M:N): создание конфига проверяет активацию + квоту роли
|
- **Инбаунды по ролям** (`Inbound.AllowedRoles`, M:N): создание конфига проверяет активацию + квоту роли
|
||||||
(в транзакции — гонки параллельных созданий) + `AllowedRoles` + включённость ноды.
|
(в транзакции — гонки параллельных созданий) + `AllowedRoles` + включённость ноды.
|
||||||
|
- **Поддержка** (`SupportTicket`, доступна только активированным): баг-репорт/предложение (свободная
|
||||||
|
форма + вложения-картинки, диск-хранилище `IFileStorage`) либо заявка на роль (существующая роль,
|
||||||
|
кроме `admin`, либо параметры новой). `Open → Resolved → [Reopen]`, `Closed` — финал без возврата.
|
||||||
|
Одобрение заявки на роль создаёт/назначает роль автоматически; полностью решается и в Telegram
|
||||||
|
(инлайн-кнопки), баг-репорты — только уведомление-ссылка на сайт.
|
||||||
- **Блокировка** (`AppUser.IsBlocked`): вход запрещён + все конфиги `Disabled` в 3x-ui; в `AuditLog`.
|
- **Блокировка** (`AppUser.IsBlocked`): вход запрещён + все конфиги `Disabled` в 3x-ui; в `AuditLog`.
|
||||||
- **Удаление пользователя** — свой аккаунт (`DELETE /api/auth/me`) или админом
|
- **Удаление пользователя** — свой аккаунт (`DELETE /api/auth/me`) или админом
|
||||||
(`DELETE /api/admin/users/{id}`, себя удалить нельзя): отзыв всех конфигов в 3x-ui, затем `AppUser`;
|
(`DELETE /api/admin/users/{id}`, себя удалить нельзя): отзыв всех конфигов в 3x-ui, затем `AppUser`;
|
||||||
|
|||||||
@@ -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.Application.Common.Interfaces;
|
||||||
using PnvPanel.Domain.Configs;
|
using PnvPanel.Domain.Configs;
|
||||||
using PnvPanel.Domain.Nodes;
|
using PnvPanel.Domain.Nodes;
|
||||||
|
using PnvPanel.Domain.Support;
|
||||||
|
|
||||||
namespace PnvPanel.Api.Hubs;
|
namespace PnvPanel.Api.Hubs;
|
||||||
|
|
||||||
@@ -56,4 +57,20 @@ internal sealed class SignalRRealtimeNotifier(IHubContext<PanelHub> hubContext)
|
|||||||
new { id = postId, title, createdAt },
|
new { id = postId, title, createdAt },
|
||||||
cancellationToken);
|
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.MapAdminStatsEndpoints();
|
||||||
app.MapAdminAppEndpoints();
|
app.MapAdminAppEndpoints();
|
||||||
app.MapAdminNewsEndpoints();
|
app.MapAdminNewsEndpoints();
|
||||||
|
app.MapSupportEndpoints();
|
||||||
|
app.MapAdminSupportEndpoints();
|
||||||
app.MapTelegramEndpoints();
|
app.MapTelegramEndpoints();
|
||||||
|
|
||||||
app.MapHub<PanelHub>("/hubs/panel");
|
app.MapHub<PanelHub>("/hubs/panel");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using PnvPanel.Application.Admin.Activation;
|
using PnvPanel.Application.Admin.Activation;
|
||||||
|
using PnvPanel.Application.Admin.Support;
|
||||||
using PnvPanel.Application.Common.Interfaces;
|
using PnvPanel.Application.Common.Interfaces;
|
||||||
using PnvPanel.Application.Common.Messaging;
|
using PnvPanel.Application.Common.Messaging;
|
||||||
using PnvPanel.Application.Configs.GetConfigLink;
|
using PnvPanel.Application.Configs.GetConfigLink;
|
||||||
@@ -222,6 +223,29 @@ public sealed class PnvBotUpdateHandler(
|
|||||||
|
|
||||||
break;
|
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":
|
case "cfg":
|
||||||
{
|
{
|
||||||
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
|
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)
|
public async Task NotifyUserAsync(Guid userId, string message, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
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.Inbounds;
|
||||||
using PnvPanel.Domain.News;
|
using PnvPanel.Domain.News;
|
||||||
using PnvPanel.Domain.Nodes;
|
using PnvPanel.Domain.Nodes;
|
||||||
|
using PnvPanel.Domain.Support;
|
||||||
using PnvPanel.Domain.Telegram;
|
using PnvPanel.Domain.Telegram;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Common.Interfaces;
|
namespace PnvPanel.Application.Common.Interfaces;
|
||||||
@@ -33,6 +34,12 @@ public interface IAppDbContext
|
|||||||
|
|
||||||
DbSet<NewsPost> NewsPosts { get; }
|
DbSet<NewsPost> NewsPosts { get; }
|
||||||
|
|
||||||
|
DbSet<SupportTicket> SupportTickets { get; }
|
||||||
|
|
||||||
|
DbSet<TicketComment> TicketComments { get; }
|
||||||
|
|
||||||
|
DbSet<TicketAttachment> TicketAttachments { get; }
|
||||||
|
|
||||||
/// <summary>Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler).</summary>
|
/// <summary>Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler).</summary>
|
||||||
DatabaseFacade Database { get; }
|
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.Configs;
|
||||||
using PnvPanel.Domain.Nodes;
|
using PnvPanel.Domain.Nodes;
|
||||||
|
using PnvPanel.Domain.Support;
|
||||||
|
|
||||||
namespace PnvPanel.Application.Common.Interfaces;
|
namespace PnvPanel.Application.Common.Interfaces;
|
||||||
|
|
||||||
@@ -24,4 +25,9 @@ public interface IRealtimeNotifier
|
|||||||
|
|
||||||
/// <summary>Единственное широковещательное событие (всем подключенным клиентам), а не по группе.</summary>
|
/// <summary>Единственное широковещательное событие (всем подключенным клиентам), а не по группе.</summary>
|
||||||
Task NotifyNewsPublishedAsync(Guid postId, string title, DateTimeOffset createdAt, CancellationToken cancellationToken);
|
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>
|
/// <summary>Личное сообщение пользователю, если у него привязан Telegram (иначе no-op).</summary>
|
||||||
Task NotifyUserAsync(Guid userId, string message, CancellationToken cancellationToken);
|
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.Identity;
|
||||||
using PnvPanel.Infrastructure.Persistence;
|
using PnvPanel.Infrastructure.Persistence;
|
||||||
using PnvPanel.Infrastructure.Security;
|
using PnvPanel.Infrastructure.Security;
|
||||||
|
using PnvPanel.Infrastructure.Storage;
|
||||||
using PnvPanel.Infrastructure.Telegram;
|
using PnvPanel.Infrastructure.Telegram;
|
||||||
using PnvPanel.Infrastructure.Xui;
|
using PnvPanel.Infrastructure.Xui;
|
||||||
using ThreeXui.ConnectionStrings;
|
using ThreeXui.ConnectionStrings;
|
||||||
@@ -129,6 +130,9 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
services.Configure<TelegramOptions>(configuration.GetSection(TelegramOptions.SectionName));
|
services.Configure<TelegramOptions>(configuration.GetSection(TelegramOptions.SectionName));
|
||||||
|
|
||||||
|
services.Configure<FileStorageOptions>(configuration.GetSection(FileStorageOptions.SectionName));
|
||||||
|
services.AddSingleton<IFileStorage, DiskFileStorage>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using PnvPanel.Domain.Configs;
|
|||||||
using PnvPanel.Domain.Inbounds;
|
using PnvPanel.Domain.Inbounds;
|
||||||
using PnvPanel.Domain.News;
|
using PnvPanel.Domain.News;
|
||||||
using PnvPanel.Domain.Nodes;
|
using PnvPanel.Domain.Nodes;
|
||||||
|
using PnvPanel.Domain.Support;
|
||||||
using PnvPanel.Domain.Telegram;
|
using PnvPanel.Domain.Telegram;
|
||||||
using PnvPanel.Infrastructure.Identity;
|
using PnvPanel.Infrastructure.Identity;
|
||||||
|
|
||||||
@@ -42,6 +43,12 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
|
|
||||||
public DbSet<NewsPost> NewsPosts => Set<NewsPost>();
|
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)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(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);
|
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 =>
|
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,10 +30,13 @@ services:
|
|||||||
ASPNETCORE_ENVIRONMENT: Production
|
ASPNETCORE_ENVIRONMENT: Production
|
||||||
ASPNETCORE_HTTP_PORTS: '8085'
|
ASPNETCORE_HTTP_PORTS: '8085'
|
||||||
ConnectionStrings__Default: 'Host=db;Port=5432;Database=${POSTGRES_DB:-pnvpanel};Username=${POSTGRES_USER:-pnvpanel};Password=${POSTGRES_PASSWORD:-pnvpanel}'
|
ConnectionStrings__Default: 'Host=db;Port=5432;Database=${POSTGRES_DB:-pnvpanel};Username=${POSTGRES_USER:-pnvpanel};Password=${POSTGRES_PASSWORD:-pnvpanel}'
|
||||||
|
FileStorage__RootPath: '/app/uploads'
|
||||||
volumes:
|
volumes:
|
||||||
# Data Protection key-ring (шифрование секретов нод at-rest) должен пережить пересоздание
|
# Data Protection key-ring (шифрование секретов нод at-rest) должен пережить пересоздание
|
||||||
# контейнера — иначе расшифровка паролей нод после рестарта станет невозможна.
|
# контейнера — иначе расшифровка паролей нод после рестарта станет невозможна.
|
||||||
- dp_keys:/app/keys
|
- dp_keys:/app/keys
|
||||||
|
# Вложения тикетов поддержки (скриншоты) — обычные файлы на диске, см. DiskFileStorage.
|
||||||
|
- ticket_uploads:/app/uploads
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD', 'curl', '-f', 'http://localhost:8085/health']
|
test: ['CMD', 'curl', '-f', 'http://localhost:8085/health']
|
||||||
interval: 15s
|
interval: 15s
|
||||||
@@ -47,3 +50,4 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
dp_keys:
|
dp_keys:
|
||||||
|
ticket_uploads:
|
||||||
|
|||||||
+53
-1
@@ -122,6 +122,56 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро
|
|||||||
| GET | `/api/activation/status` | user | — | `{ isActivated, pendingRequest: { id, comment, createdAt } \| null }` |
|
| GET | `/api/activation/status` | user | — | `{ isActivated, pendingRequest: { id, comment, createdAt } \| null }` |
|
||||||
| POST | `/api/activation/request` | user | `{ comment? }` | `{ id, comment, createdAt }` |
|
| POST | `/api/activation/request` | user | `{ comment? }` | `{ id, comment, createdAt }` |
|
||||||
|
|
||||||
|
## Support (пользователь)
|
||||||
|
|
||||||
|
Группа `/api/support`, `RequireAuthorization()` + `IRequiresActivation` (кроме `GET /attachments/{id}`,
|
||||||
|
который тоже требует активации, но не привязан к типу тикета). Создание баг-репорта и добавление
|
||||||
|
комментария — `multipart/form-data` (вложения), остальное — JSON.
|
||||||
|
|
||||||
|
| Метод | Путь | Тело запроса | Тело ответа |
|
||||||
|
| ----- | ----------------------------------------- | ---------------------------------------------------------------------------- | ------------- |
|
||||||
|
| GET | `/api/support/roles` | — | `RoleDto[]` (без `admin`) — для выбора существующей роли в заявке |
|
||||||
|
| GET | `/api/support/tickets` | query: `type?, status?, page=1, pageSize=20` | `PagedList<TicketSummaryDto>` (только свои) |
|
||||||
|
| GET | `/api/support/tickets/{id}` | — | `TicketDetailDto` (404, если не свой) |
|
||||||
|
| POST | `/api/support/tickets/bug-reports` | multipart: `message` + `files[]` (до 5, изображения до 5 МБ) | `TicketDetailDto` |
|
||||||
|
| POST | `/api/support/tickets/role-requests` | `{ existingRoleId? \| (newRoleName, newRoleMaxConfigs, newRoleMaxIpLimit), justification }` | `TicketDetailDto` |
|
||||||
|
| POST | `/api/support/tickets/{id}/comments` | multipart: `body` + `files[]` | `TicketCommentDto` |
|
||||||
|
| POST | `/api/support/tickets/{id}/reopen` | — | `204 No Content` (только владелец, только из `Resolved`) |
|
||||||
|
| GET | `/api/support/attachments/{id}` | — | бинарный поток с `Content-Type` вложения |
|
||||||
|
|
||||||
|
`TicketSummaryDto`: `{ id, userId, userName, type, status, createdAt, lastActivityAt }` — один DTO для
|
||||||
|
своего и админского списков. `TicketDetailDto` добавляет `requestedRoleId, requestedRoleName,
|
||||||
|
proposedRoleName, proposedMaxConfigs, proposedMaxIpLimit, comments: TicketCommentDto[]`.
|
||||||
|
`TicketCommentDto`: `{ id, authorId, authorName, body, createdAt, attachments: TicketAttachmentDto[] }`.
|
||||||
|
|
||||||
|
Ровно одна из двух заявок на роль: либо `existingRoleId` (роль `admin` запрещена — `403
|
||||||
|
Support.CannotRequestAdminRole`), либо все три поля новой роли. Заявка при существующем открытом
|
||||||
|
запросе на роль → `409 Support.RoleRequestAlreadyPending`. `POST …/comments` на `Closed`-тикете →
|
||||||
|
`409 Support.TicketClosed`. Вложения отдаются не статикой — `<img src>` не может передать
|
||||||
|
`Authorization`-заголовок, фронт качает их как `Blob` через `fetch` и рендерит `Object URL`.
|
||||||
|
|
||||||
|
## Admin — Support
|
||||||
|
|
||||||
|
Группа `/api/admin/support`, `RequireAuthorization(RoleNames.Admin)` (активация не проверяется — сеяный
|
||||||
|
админ активирован всегда).
|
||||||
|
|
||||||
|
| Метод | Путь | Тело запроса | Тело ответа |
|
||||||
|
| ----- | ------------------------------------------------ | ----------------------------------- | ------------- |
|
||||||
|
| GET | `/api/admin/support/tickets` | query: `type?, status?, page, pageSize` | `PagedList<TicketSummaryDto>` (все пользователи) |
|
||||||
|
| GET | `/api/admin/support/tickets/{id}` | — | `TicketDetailDto` |
|
||||||
|
| POST | `/api/admin/support/tickets/{id}/comments` | multipart: `body` + `files[]` | `TicketCommentDto` |
|
||||||
|
| POST | `/api/admin/support/tickets/{id}/resolve` | — | `204 No Content` (любой тип, только из `Open`) |
|
||||||
|
| POST | `/api/admin/support/tickets/{id}/close` | — | `204 No Content` (любой тип, финал) |
|
||||||
|
| POST | `/api/admin/support/tickets/{id}/approve` | — | `204 No Content` (только `RoleRequest`/`Open`; создаёt/назначает роль) |
|
||||||
|
| POST | `/api/admin/support/tickets/{id}/reject` | `{ reason? }` | `204 No Content` (только `RoleRequest`; `reason` уходит комментарием) |
|
||||||
|
|
||||||
|
`approve`/`reject` — единственный способ решить заявку на роль (нельзя одобрить через `resolve`).
|
||||||
|
При одобрении: если заявка на существующую роль — сразу `ChangeUserRoleCommand`-эквивалент; если на
|
||||||
|
новую — сперва создаётся `AppRole` (`IRoleService.CreateRoleAsync`), затем назначается. То же самое
|
||||||
|
администратор может сделать **из Telegram, не заходя на сайт** — инлайн-кнопки на уведомлении о
|
||||||
|
заявке (см. [telegram-bot.md](telegram-bot.md)); для баг-репортов в Telegram только кнопка-ссылка
|
||||||
|
на `/admin/support/{id}` — переписка и вложения только на сайте.
|
||||||
|
|
||||||
## Admin — Activation, Roles
|
## Admin — Activation, Roles
|
||||||
|
|
||||||
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
| Метод | Путь | Роль | Тело запроса | Тело ответа |
|
||||||
@@ -211,6 +261,8 @@ totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes }` — счи
|
|||||||
| `activationRequested` | `{ requestId, userId, userName, comment, createdAt }` | `admins` |
|
| `activationRequested` | `{ requestId, userId, userName, comment, createdAt }` | `admins` |
|
||||||
| `userActivated` | `{ userId }` | владельцу |
|
| `userActivated` | `{ userId }` | владельцу |
|
||||||
| `newsPublished` | `{ id, title, createdAt }` | все (broadcast) |
|
| `newsPublished` | `{ id, title, createdAt }` | все (broadcast) |
|
||||||
|
| `ticketCreated` | `{ ticketId, userId, userName, type }` | `admins` |
|
||||||
|
| `ticketUpdated` | `{ ticketId }` | владельцу |
|
||||||
|
|
||||||
### Client → Server
|
### Client → Server
|
||||||
Клиент только слушает; группировка по пользователю происходит на сервере при подключении, по
|
Клиент только слушает; группировка по пользователю происходит на сервере при подключении, по
|
||||||
@@ -224,7 +276,7 @@ totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes }` — счи
|
|||||||
| 401 | Нет/просрочен/невалиден access-токен |
|
| 401 | Нет/просрочен/невалиден access-токен |
|
||||||
| 403 | Нет прав по роли, либо `Auth.NotActivated` |
|
| 403 | Нет прав по роли, либо `Auth.NotActivated` |
|
||||||
| 404 | Ресурс не найден |
|
| 404 | Ресурс не найден |
|
||||||
| 409 | Конфликт домена: `Configs.QuotaExceeded`, дубликат имени пользователя при регистрации, уже есть `Pending`-запрос активации |
|
| 409 | Конфликт домена: `Configs.QuotaExceeded`, дубликат имени пользователя при регистрации, уже есть `Pending`-запрос активации, `Support.RoleRequestAlreadyPending`, `Support.TicketClosed` |
|
||||||
| 422 | Прочие управляемые ошибки, не подошедшие под коды выше |
|
| 422 | Прочие управляемые ошибки, не подошедшие под коды выше |
|
||||||
| 429 | Rate limit (`/api/auth/*`, `/api/auth/telegram/*`, `/sub/{token}`) |
|
| 429 | Rate limit (`/api/auth/*`, `/api/auth/telegram/*`, `/sub/{token}`) |
|
||||||
| 500 | Необработанное исключение (перехватывается `UseExceptionHandler()`, тело без деталей) |
|
| 500 | Необработанное исключение (перехватывается `UseExceptionHandler()`, тело без деталей) |
|
||||||
|
|||||||
+12
-5
@@ -68,15 +68,16 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C
|
|||||||
- **Commands / Queries** + их **Handlers** (`ICommandHandler<,>` / `IQueryHandler<,>` — свои интерфейсы),
|
- **Commands / Queries** + их **Handlers** (`ICommandHandler<,>` / `IQueryHandler<,>` — свои интерфейсы),
|
||||||
организованы по фичам (`Auth/Login/`, `Configs/Create/`, `Admin/Nodes/`, ...).
|
организованы по фичам (`Auth/Login/`, `Configs/Create/`, `Admin/Nodes/`, ...).
|
||||||
- **Ports (интерфейсы)**: `IAppDbContext`, `IXuiPanelGateway`, `ICurrentUser`, `IIdentityService`,
|
- **Ports (интерфейсы)**: `IAppDbContext`, `IXuiPanelGateway`, `ICurrentUser`, `IIdentityService`,
|
||||||
`ISecretProtector`, `IRealtimeNotifier`, `ITelegramNotifier`, `IRoleService`.
|
`ISecretProtector`, `IRealtimeNotifier`, `ITelegramNotifier`, `IRoleService`, `IFileStorage`
|
||||||
|
(вложения тикетов поддержки — диск в контейнере, см. `Infrastructure/Storage/DiskFileStorage`).
|
||||||
- **Validators**: FluentValidation на команды, где есть что проверять помимо типов (не на все — см.
|
- **Validators**: FluentValidation на команды, где есть что проверять помимо типов (не на все — см.
|
||||||
[backend-conventions.md](backend-conventions.md)).
|
[backend-conventions.md](backend-conventions.md)).
|
||||||
- **DTO**: плоские `record`, конвертация из сущностей — статический метод `FromDomain(...)` на самом
|
- **DTO**: плоские `record`, конвертация из сущностей — статический метод `FromDomain(...)` на самом
|
||||||
DTO, без маппера (Mapster/AutoMapper).
|
DTO, без маппера (Mapster/AutoMapper).
|
||||||
- **Pipeline behaviors** (порядок: Logging → Validation → RequireActivation → UnitOfWork):
|
- **Pipeline behaviors** (порядок: Logging → Validation → RequireActivation → UnitOfWork):
|
||||||
`LoggingBehavior`, `ValidationBehavior`, `RequireActivationBehavior` (403 `Auth.NotActivated` для
|
`LoggingBehavior`, `ValidationBehavior`, `RequireActivationBehavior` (403 `Auth.NotActivated` для
|
||||||
запросов с маркером `IRequiresActivation` — конфиги, новости, каталог приложений), `UnitOfWorkBehavior`
|
запросов с маркером `IRequiresActivation` — конфиги, новости, каталог приложений, тикеты поддержки),
|
||||||
(транзакция + `SaveChangesAsync` на команду). Отдельного `AuthorizationBehavior` для ролей нет —
|
`UnitOfWorkBehavior` (транзакция + `SaveChangesAsync` на команду). Отдельного `AuthorizationBehavior` для ролей нет —
|
||||||
роль проверяется через `RequireAuthorization()`/`RequireRole(...)` на эндпоинте либо явной проверкой
|
роль проверяется через `RequireAuthorization()`/`RequireRole(...)` на эндпоинте либо явной проверкой
|
||||||
в начале хендлера (например, «инбаунд доступен роли пользователя»).
|
в начале хендлера (например, «инбаунд доступен роли пользователя»).
|
||||||
- **Result<T>**: явная модель успеха/ошибки (`Result`/`Result<T>`, `Error` с `ErrorType`) вместо
|
- **Result<T>**: явная модель успеха/ошибки (`Result`/`Result<T>`, `Error` с `ErrorType`) вместо
|
||||||
@@ -97,6 +98,8 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C
|
|||||||
- **Secrets**: `DataProtectionSecretProtector : ISecretProtector` (шифрование паролей нод at-rest,
|
- **Secrets**: `DataProtectionSecretProtector : ISecretProtector` (шифрование паролей нод at-rest,
|
||||||
ASP.NET Core Data Protection, key-ring на томе `dp_keys`).
|
ASP.NET Core Data Protection, key-ring на томе `dp_keys`).
|
||||||
- **Telegram**: `TelegramNotifier : ITelegramNotifier` — отправка DM-уведомлений через `ITelegramBotClient`.
|
- **Telegram**: `TelegramNotifier : ITelegramNotifier` — отправка DM-уведомлений через `ITelegramBotClient`.
|
||||||
|
- **Storage**: `DiskFileStorage : IFileStorage` — вложения тикетов поддержки, файлы на диске под
|
||||||
|
GUID-именем (`FileStorage:RootPath`, том `ticket_uploads` в docker-compose, как `dp_keys`).
|
||||||
|
|
||||||
> **SignalR-пуш физически лежит в `PnvPanel.Api/Hubs/`, не в `Infrastructure`.**
|
> **SignalR-пуш физически лежит в `PnvPanel.Api/Hubs/`, не в `Infrastructure`.**
|
||||||
> `SignalRRealtimeNotifier : IRealtimeNotifier` нужен `IHubContext<PanelHub>`, а сам `PanelHub`
|
> `SignalRRealtimeNotifier : IRealtimeNotifier` нужен `IHubContext<PanelHub>`, а сам `PanelHub`
|
||||||
@@ -106,10 +109,14 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C
|
|||||||
### 4. `PnvPanel.Api` (Presentation)
|
### 4. `PnvPanel.Api` (Presentation)
|
||||||
Композиционный корень и транспорт.
|
Композиционный корень и транспорт.
|
||||||
|
|
||||||
- **Minimal API**-эндпоинты, сгруппированные по фичам — 12 файлов в `Endpoints/`
|
- **Minimal API**-эндпоинты, сгруппированные по фичам — файлы в `Endpoints/`
|
||||||
(`AuthEndpoints`, `ActivationEndpoints`, `ConfigEndpoints`, `AppEndpoints`, `SubscriptionEndpoints`,
|
(`AuthEndpoints`, `ActivationEndpoints`, `ConfigEndpoints`, `AppEndpoints`, `SubscriptionEndpoints`,
|
||||||
`TelegramEndpoints`, `AdminUserEndpoints`, `AdminAppEndpoints`, `AdminStatsEndpoints`, `NodeEndpoints`,
|
`TelegramEndpoints`, `AdminUserEndpoints`, `AdminAppEndpoints`, `AdminStatsEndpoints`, `NodeEndpoints`,
|
||||||
`InboundEndpoints`, `RoleEndpoints`); полный список маршрутов — [api-design.md](api-design.md).
|
`InboundEndpoints`, `RoleEndpoints`, `SupportEndpoints`, `AdminSupportEndpoints`); полный список
|
||||||
|
маршрутов — [api-design.md](api-design.md). `SupportEndpoints`/`AdminSupportEndpoints` — первые в
|
||||||
|
проекте с `multipart/form-data` (`[FromForm]` + `IFormFileCollection`, `.DisableAntiforgery()` —
|
||||||
|
антифоржери-мидлварь в пайплайне не подключена, но ASP.NET Core минимал-API требует явно снять
|
||||||
|
требование для form-эндпоинтов).
|
||||||
Каждый эндпоинт аннотирован `.Produces<T>()`, чтобы OpenAPI-схема полностью описывала тело ответа
|
Каждый эндпоинт аннотирован `.Produces<T>()`, чтобы OpenAPI-схема полностью описывала тело ответа
|
||||||
(нужно для `pnpm gen:api` на фронте).
|
(нужно для `pnpm gen:api` на фронте).
|
||||||
- **SignalR Hubs**: `PanelHub` (`Hubs/`).
|
- **SignalR Hubs**: `PanelHub` (`Hubs/`).
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ VpnConfig ─*─ TrafficSample (история трафика; пишетс
|
|||||||
AuditLog (append-only журнал действий; ссылается на ActorId/TargetId)
|
AuditLog (append-only журнал действий; ссылается на ActorId/TargetId)
|
||||||
ClientApp (каталог приложений-клиентов; группируется по OperatingSystem)
|
ClientApp (каталог приложений-клиентов; группируется по OperatingSystem)
|
||||||
NewsPost (лента новостей; публикуется админом, видна всем аутентифицированным пользователям)
|
NewsPost (лента новостей; публикуется админом, видна всем аутентифицированным пользователям)
|
||||||
|
AppUser
|
||||||
|
└─0..*─ SupportTicket (баг-репорт/предложение либо заявка на роль)
|
||||||
|
└─1───*─ TicketComment (переписка; первое сообщение = описание/обоснование)
|
||||||
|
└─0..*─ TicketAttachment (изображения, диск-хранилище)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Сущности
|
## Сущности
|
||||||
@@ -272,6 +276,71 @@ UI **настойчиво напоминает** привязать его (ед
|
|||||||
Переходы: `Pending → Approved/Rejected/Expired`; `Approved → Consumed` (после выпуска JWT сайту).
|
Переходы: `Pending → Approved/Rejected/Expired`; `Approved → Consumed` (после выпуска JWT сайту).
|
||||||
После `Consumed`/`Expired` — не переиспользуется.
|
После `Consumed`/`Expired` — не переиспользуется.
|
||||||
|
|
||||||
|
### SupportTicket — обращение в поддержку
|
||||||
|
Два вида: `BugReport` (свободная форма, с вложениями) и `RoleRequest` (запрос существующей роли —
|
||||||
|
кроме `admin` — либо параметров новой). Текст/обоснование не хранится отдельным полем — это первое
|
||||||
|
сообщение в переписке (`TicketComment`), созданное вместе с тикетом в одной операции.
|
||||||
|
|
||||||
|
| Поле | Тип | Заметки |
|
||||||
|
| ------------------- | ----------------- | ---------------------------------------------------------------- |
|
||||||
|
| `Id` | `Guid` | PK |
|
||||||
|
| `UserId` | `Guid` | FK → AppUser (автор) |
|
||||||
|
| `Type` | `TicketType` | `BugReport` / `RoleRequest` |
|
||||||
|
| `Status` | `TicketStatus` | `Open` / `Resolved` / `Closed` |
|
||||||
|
| `RequestedRoleId` | `Guid?` | Заполнено для `RoleRequest` при выборе существующей роли |
|
||||||
|
| `ProposedRoleName` | `string?` | Заполнено для `RoleRequest` при запросе новой роли |
|
||||||
|
| `ProposedMaxConfigs`| `int?` | Параметры новой роли (см. `AppRole.MaxConfigs`) |
|
||||||
|
| `ProposedMaxIpLimit`| `int?` | Параметры новой роли (см. `AppRole.MaxIpLimit`) |
|
||||||
|
| `CreatedAt` | `DateTimeOffset` | |
|
||||||
|
|
||||||
|
Инварианты и переходы (`backend/src/PnvPanel.Domain/Support/SupportTicket.cs`): `RequestedRoleId`
|
||||||
|
и `Proposed*` никогда не заполнены одновременно — гарантируется отдельными фабриками
|
||||||
|
(`CreateRoleRequestForExistingRole`/`CreateRoleRequestForNewRole`), а не runtime-проверкой.
|
||||||
|
- `Resolve()` — только из `Open`. Для `RoleRequest` одобрение — оркестрация в Application
|
||||||
|
(`ApproveRoleRequestCommandHandler`): при новой роли сначала `IRoleService.CreateRoleAsync`, затем
|
||||||
|
в любом случае `ChangeUserRoleAsync` пользователю, и только потом `ticket.Resolve()`.
|
||||||
|
- `Close()` — из `Open` или `Resolved`, **финал** (обратного пути нет). Для `RoleRequest` — отклонение.
|
||||||
|
- `Reopen()` — только из `Resolved` (владелец тикета); `Closed` не переоткрывается.
|
||||||
|
- Одновременно не более одной **открытой** заявки на роль (`Type == RoleRequest && Status == Open`)
|
||||||
|
на пользователя — проверяется в Application, аналогично `ActivationRequest.AlreadyPending`.
|
||||||
|
Баг-репорты такого ограничения не имеют.
|
||||||
|
- Доступ — только активированному пользователю (`IRequiresActivation`, как и у конфигов/новостей);
|
||||||
|
админские действия (resolve/close/approve/reject) идут по отдельным `/api/admin/support/*` с
|
||||||
|
ролевой проверкой, без завязки на активацию.
|
||||||
|
|
||||||
|
### TicketComment — сообщение в переписке
|
||||||
|
Плоская сущность (не навигационная коллекция на `SupportTicket` — конвенция проекта, см.
|
||||||
|
`TrafficSample`), одна на любое сообщение (включая первое, созданное вместе с тикетом).
|
||||||
|
|
||||||
|
| Поле | Тип | Заметки |
|
||||||
|
| ----------- | ---------------- | --------------------------------------------------------- |
|
||||||
|
| `Id` | `Guid` | PK |
|
||||||
|
| `TicketId` | `Guid` | FK → SupportTicket |
|
||||||
|
| `AuthorId` | `Guid` | FK → AppUser (владелец тикета либо админ) |
|
||||||
|
| `Body` | `string` | |
|
||||||
|
| `CreatedAt` | `DateTimeOffset` | |
|
||||||
|
|
||||||
|
Комментарий запрещён на `Closed`-тикете; на `Open`/`Resolved` — можно (для `Resolved` это не
|
||||||
|
переоткрывает тикет автоматически, переоткрытие — отдельное явное действие пользователя `Reopen()`).
|
||||||
|
|
||||||
|
### TicketAttachment — вложение (изображение)
|
||||||
|
Хранится на диске контейнера (`IFileStorage`/`DiskFileStorage`, volume `ticket_uploads` в
|
||||||
|
docker-compose) — первая в проекте функциональность загрузки файлов. Вайтлист
|
||||||
|
`image/jpeg|png|webp|gif`, до 5 МБ на файл, до 5 файлов на сообщение.
|
||||||
|
|
||||||
|
| Поле | Тип | Заметки |
|
||||||
|
| ---------------- | ---------------- | ------------------------------------------------------------------ |
|
||||||
|
| `Id` | `Guid` | PK |
|
||||||
|
| `CommentId` | `Guid` | FK → TicketComment |
|
||||||
|
| `FileName` | `string` | Оригинальное имя — только для отображения, не участвует в пути на диске |
|
||||||
|
| `StoredFileName` | `string` | Серверное GUID-имя на диске (не доверяем пользовательскому вводу) |
|
||||||
|
| `ContentType` | `string` | |
|
||||||
|
| `SizeBytes` | `long` | |
|
||||||
|
| `CreatedAt` | `DateTimeOffset` | |
|
||||||
|
|
||||||
|
Отдаётся авторизованным эндпоинтом (`GET /api/support/attachments/{id}`, проверка владения тикетом
|
||||||
|
или роли admin), не статикой — вложения могут быть чувствительными.
|
||||||
|
|
||||||
## Value Objects
|
## Value Objects
|
||||||
|
|
||||||
- **NodeCredentials** (`Nodes/NodeCredentials.cs`) — `Username` + `ProtectedPassword` (шифротекст,
|
- **NodeCredentials** (`Nodes/NodeCredentials.cs`) — `Username` + `ProtectedPassword` (шифротекст,
|
||||||
@@ -291,6 +360,8 @@ enum TelegramLoginStatus { Pending, Approved, Rejected, Expired, Consumed }
|
|||||||
enum ActivationStatus { Pending, Approved, Rejected }
|
enum ActivationStatus { Pending, Approved, Rejected }
|
||||||
enum AuditSource { Web, Telegram, System }
|
enum AuditSource { Web, Telegram, System }
|
||||||
enum OsPlatform { IOS, Android, Windows, MacOS, Linux }
|
enum OsPlatform { IOS, Android, Windows, MacOS, Linux }
|
||||||
|
enum TicketType { BugReport, RoleRequest }
|
||||||
|
enum TicketStatus { Open, Resolved, Closed }
|
||||||
```
|
```
|
||||||
|
|
||||||
## Уведомления и аудит (без диспетчера доменных событий)
|
## Уведомления и аудит (без диспетчера доменных событий)
|
||||||
@@ -316,6 +387,12 @@ enum OsPlatform { IOS, Android, Windows, MacOS, Linux }
|
|||||||
| `PublishInboundCommandHandler` | `AuditLog` (`InboundPublished`/`InboundUnpublished`) |
|
| `PublishInboundCommandHandler` | `AuditLog` (`InboundPublished`/`InboundUnpublished`) |
|
||||||
| `NodeHealthCheckService` (фон) | Обновляет `NodeStatus`; realtime `nodeStatusChanged` группе `admins` |
|
| `NodeHealthCheckService` (фон) | Обновляет `NodeStatus`; realtime `nodeStatusChanged` группе `admins` |
|
||||||
| `TrafficSyncService` (фон) | `UpdateTraffic(...)`; realtime `configTrafficUpdated` владельцу |
|
| `TrafficSyncService` (фон) | `UpdateTraffic(...)`; realtime `configTrafficUpdated` владельцу |
|
||||||
|
| `CreateBugReportTicketCommandHandler` | Realtime `ticketCreated` группе `admins`; Telegram админам — превью текста + кнопка-ссылка на сайт |
|
||||||
|
| `CreateRoleRequestTicketCommandHandler` | Realtime `ticketCreated` группе `admins`; Telegram админам — инлайн-кнопки «Одобрить/Отклонить» |
|
||||||
|
| `AddTicketCommentCommandHandler` | Realtime `ticketUpdated` владельцу, только если комментирует не он сам |
|
||||||
|
| `ApproveRoleRequestCommandHandler` | Создаёт роль (если новая) + назначает пользователю; `AuditLog` (`RoleRequestApproved`); Telegram-DM владельцу |
|
||||||
|
| `RejectRoleRequestCommandHandler` | `AuditLog` (`RoleRequestRejected`); Telegram-DM владельцу |
|
||||||
|
| `ResolveTicketCommandHandler` / `CloseTicketCommandHandler` | `AuditLog` (`TicketResolved`/`TicketClosed`); realtime `ticketUpdated` владельцу |
|
||||||
|
|
||||||
SignalR-события и группы — см. [architecture.md](architecture.md#realtime-signalr) и
|
SignalR-события и группы — см. [architecture.md](architecture.md#realtime-signalr) и
|
||||||
[api-design.md](api-design.md#signalr--hub-hubspanel).
|
[api-design.md](api-design.md#signalr--hub-hubspanel).
|
||||||
|
|||||||
@@ -96,6 +96,8 @@
|
|||||||
| Лимит устройств (`limitIp`) | Квота роли (`AppRole.MaxIpLimit`; -1 = без лимита), применяется только к новым клиентам в 3x-ui |
|
| Лимит устройств (`limitIp`) | Квота роли (`AppRole.MaxIpLimit`; -1 = без лимита), применяется только к новым клиентам в 3x-ui |
|
||||||
| Самоудаление аккаунта | Отзыв всех активных конфигов в 3x-ui + удаление `AppUser` |
|
| Самоудаление аккаунта | Отзыв всех активных конфигов в 3x-ui + удаление `AppUser` |
|
||||||
| Удаление пользователя админом | `DELETE /api/admin/users/{id}` — отзыв всех конфигов в 3x-ui + удаление `AppUser`; себя удалить нельзя |
|
| Удаление пользователя админом | `DELETE /api/admin/users/{id}` — отзыв всех конфигов в 3x-ui + удаление `AppUser`; себя удалить нельзя |
|
||||||
|
| Вложения тикетов поддержки | Диск в контейнере (`IFileStorage`/`DiskFileStorage`, volume `ticket_uploads`) — не S3, GUID-имена файлов |
|
||||||
|
| Заявки на роль из бота | Одобрение/отклонение полностью в Telegram (`rrq:*`); баг-репорты — только ссылка на сайт |
|
||||||
| Версионирование API | Без версий (`/api` без `v1`) |
|
| Версионирование API | Без версий (`/api` без `v1`) |
|
||||||
| Подписка (заголовки) | `Subscription-Userinfo` (used/total/expire) + `profile-update-interval` |
|
| Подписка (заголовки) | `Subscription-Userinfo` (used/total/expire) + `profile-update-interval` |
|
||||||
| Тема сайта | Светлая + тёмная (+ системная); выбор в localStorage |
|
| Тема сайта | Светлая + тёмная (+ системная); выбор в localStorage |
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ Telegram-бот — **второй канал доставки** (presentation-
|
|||||||
Зарегистрироваться»; плюс кнопка «🌐 Сайт панели» со ссылкой на сайт, если задан `Telegram__PublicSiteUrl`
|
Зарегистрироваться»; плюс кнопка «🌐 Сайт панели» со ссылкой на сайт, если задан `Telegram__PublicSiteUrl`
|
||||||
(пусто — кнопки нет). Слэш-команды `/configs`/`/unlink` продолжают работать как раньше — кнопки лишь
|
(пусто — кнопки нет). Слэш-команды `/configs`/`/unlink` продолжают работать как раньше — кнопки лишь
|
||||||
вызывают те же обработчики через callback (`menu:configs`/`menu:unlink`/`menu:back`).
|
вызывают те же обработчики через callback (`menu:configs`/`menu:unlink`/`menu:back`).
|
||||||
|
8. **Админ: обработка заявок на роль поддержки** — при новой заявке (`SupportTicket.Type ==
|
||||||
|
RoleRequest`) админ получает сообщение с описанием (существующая роль либо параметры новой) и
|
||||||
|
обоснованием, жмёт «✅ Одобрить / ❌ Отклонить» **прямо в Telegram, без захода на сайт** — одобрение
|
||||||
|
создаёт роль (если новая) и назначает её пользователю той же командой, что и на сайте. Баг-репорты/
|
||||||
|
предложения — только уведомление с кнопкой-ссылкой на сайт, без инлайн-действий (переписка и
|
||||||
|
вложения удобнее там).
|
||||||
|
|
||||||
**Не реализовано:**
|
**Не реализовано:**
|
||||||
- QR-картинкой и агрегированная подписка в самом боте (только текстовая ссылка на конфиг по кнопке).
|
- QR-картинкой и агрегированная подписка в самом боте (только текстовая ссылка на конфиг по кнопке).
|
||||||
@@ -41,6 +47,8 @@ Telegram-бот — **второй канал доставки** (presentation-
|
|||||||
- Webhook-транспорт — только long polling, конфигурации режима/URL в коде нет.
|
- Webhook-транспорт — только long polling, конфигурации режима/URL в коде нет.
|
||||||
- Полное самообслуживание (создание/ротация/отзыв конфигов) — бот **read-only** по конфигам (только
|
- Полное самообслуживание (создание/ротация/отзыв конфигов) — бот **read-only** по конфигам (только
|
||||||
просмотр списка и показ существующей ссылки по кнопке).
|
просмотр списка и показ существующей ссылки по кнопке).
|
||||||
|
- Баг-репорты/предложения тикетов поддержки **не решаются из бота** (только уведомление-ссылка) —
|
||||||
|
ответы, вложения, resolve/close только на сайте.
|
||||||
|
|
||||||
## Размещение в архитектуре
|
## Размещение в архитектуре
|
||||||
|
|
||||||
@@ -162,6 +170,33 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
|
|||||||
`/requests` — тот же список запросов по требованию (до 10 штук, `Pending`), с теми же кнопками; для
|
`/requests` — тот же список запросов по требованию (до 10 штук, `Pending`), с теми же кнопками; для
|
||||||
кого он доступен — та же проверка админ-id.
|
кого он доступен — та же проверка админ-id.
|
||||||
|
|
||||||
|
## Флоу 6 — Обращения в поддержку
|
||||||
|
|
||||||
|
Два разных сценария в зависимости от типа тикета (`SupportTicket.Type`):
|
||||||
|
|
||||||
|
**Баг-репорт/предложение** — только уведомление, без действий в боте:
|
||||||
|
1. `CreateBugReportTicketCommandHandler` вызывает
|
||||||
|
`ITelegramNotifier.NotifyAdminsBugReportCreatedAsync(ticketId, userName, message, ct)`.
|
||||||
|
2. Каждому админу уходит сообщение с превью текста (обрезано до ~300 символов) и, если задан
|
||||||
|
`Telegram__PublicSiteUrl`, **кнопкой-ссылкой** `🌐 Открыть на сайте` на `/admin/support/{ticketId}`
|
||||||
|
(`InlineKeyboardButton.WithUrl`, не callback) — тап открывает страницу тикета в браузере.
|
||||||
|
3. Дальше — только на сайте: переписка, вложения, resolve/close.
|
||||||
|
|
||||||
|
**Заявка на роль** — полностью решается в Telegram:
|
||||||
|
1. `CreateRoleRequestTicketCommandHandler` вызывает
|
||||||
|
`ITelegramNotifier.NotifyAdminsRoleRequestCreatedAsync(ticketId, userName, roleDescription, justification, ct)`
|
||||||
|
— `roleDescription` уже готовая строка (имя существующей роли либо «новая роль «X» (конфигов: N, IP: M)»).
|
||||||
|
2. Сообщение с кнопками **«✅ Одобрить» / «❌ Отклонить»** (callback `rrq:approve:{id}`/`rrq:reject:{id}`
|
||||||
|
— тот же 3-частный формат `prefix:action:guid`, что и `act:*` для активации).
|
||||||
|
3. Нажатие → проверка прав (`TrySetAdminCurrentUserAsync`, тот же, что для активации) →
|
||||||
|
`ApproveRoleRequestCommand`/`RejectRoleRequestCommand` (те же команды, что дёргает
|
||||||
|
`POST /api/admin/support/tickets/{id}/approve|reject` на сайте). При одобрении — если роль новая,
|
||||||
|
сперва создаётся `AppRole`, затем в любом случае назначается пользователю; тикет переходит в
|
||||||
|
`Resolved`/`Closed`. Нажавшему админу — короткое подтверждение, исходное сообщение редактируется
|
||||||
|
(дописывается статус), как и у `act:*`.
|
||||||
|
4. Пользователю (если Telegram привязан) — DM «✅ Ваша заявка на роль одобрена.» / «❌ Ваша заявка на
|
||||||
|
роль отклонена.».
|
||||||
|
|
||||||
## Команды и клавиатуры
|
## Команды и клавиатуры
|
||||||
|
|
||||||
| Команда / кнопка | Действие | Требует привязки |
|
| Команда / кнопка | Действие | Требует привязки |
|
||||||
@@ -177,6 +212,8 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl
|
|||||||
| «📝 Зарегистрироваться» (`reg:new`) | Регистрация нового аккаунта прямо из бота (Флоу 3) | нет (нужно, чтобы **не** был привязан) |
|
| «📝 Зарегистрироваться» (`reg:new`) | Регистрация нового аккаунта прямо из бота (Флоу 3) | нет (нужно, чтобы **не** был привязан) |
|
||||||
| «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env |
|
| «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env |
|
||||||
| `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env |
|
| `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env |
|
||||||
|
| «✅ Одобрить»/«❌ Отклонить» (`rrq:*`) | (admin) решение по заявке на роль — создаёт/назначает роль | админ по env |
|
||||||
|
| «🌐 Открыть на сайте» | Ссылка на баг-репорт на сайте (только если задан `Telegram__PublicSiteUrl`) | админ по env |
|
||||||
|
|
||||||
Главное меню (`/start`/`/help`) — см. пункт 7 в «Возможности» выше.
|
Главное меню (`/start`/`/help`) — см. пункт 7 в «Возможности» выше.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { useState, type ChangeEvent } from 'react'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Textarea } from '@/shared/ui/textarea'
|
||||||
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||||
|
import { HttpError } from '@/shared/api/client'
|
||||||
|
import { TicketStatusBadge } from '@/features/support/TicketStatusBadge'
|
||||||
|
import { TicketAttachmentImage } from '@/features/support/TicketAttachmentImage'
|
||||||
|
import { addAdminComment, approveRoleRequest, closeTicket, getAdminTicket, rejectRoleRequest, resolveTicket } from './api'
|
||||||
|
|
||||||
|
const MAX_FILES = 5
|
||||||
|
|
||||||
|
export function AdminTicketDetailDialog({ ticketId, onOpenChange }: { ticketId: string; onOpenChange: (open: boolean) => void }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [reply, setReply] = useState('')
|
||||||
|
const [files, setFiles] = useState<File[]>([])
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({ queryKey: ['admin-ticket', ticketId], queryFn: () => getAdminTicket(ticketId) })
|
||||||
|
|
||||||
|
const invalidate = async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['admin-ticket', ticketId] })
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['admin-tickets'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyMutation = useMutation({
|
||||||
|
mutationFn: () => addAdminComment(ticketId, reply.trim(), files),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setReply('')
|
||||||
|
setFiles([])
|
||||||
|
await invalidate()
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const onActionError = () => toast.error(t('auth.genericError'))
|
||||||
|
|
||||||
|
const resolveMutation = useMutation({
|
||||||
|
mutationFn: () => resolveTicket(ticketId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success(t('admin.support.resolved'))
|
||||||
|
await invalidate()
|
||||||
|
},
|
||||||
|
onError: onActionError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const closeMutation = useMutation({
|
||||||
|
mutationFn: () => closeTicket(ticketId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success(t('admin.support.closed'))
|
||||||
|
await invalidate()
|
||||||
|
},
|
||||||
|
onError: onActionError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const approveMutation = useMutation({
|
||||||
|
mutationFn: () => approveRoleRequest(ticketId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success(t('admin.support.approved'))
|
||||||
|
await invalidate()
|
||||||
|
},
|
||||||
|
onError: onActionError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const rejectMutation = useMutation({
|
||||||
|
mutationFn: () => rejectRoleRequest(ticketId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success(t('admin.support.rejected'))
|
||||||
|
await invalidate()
|
||||||
|
},
|
||||||
|
onError: onActionError,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleFilesChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFiles(Array.from(e.target.files ?? []).slice(0, MAX_FILES))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
{data && <TicketStatusBadge status={data.status} />}
|
||||||
|
{data && t(`support.type.${data.type}`)}
|
||||||
|
{data && <span className="text-sm font-normal text-muted-foreground">— {data.userName}</span>}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{isLoading && <p className="text-sm text-muted-foreground">…</p>}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<div className="flex max-h-[70vh] flex-col gap-4 overflow-y-auto">
|
||||||
|
{data.type === 'RoleRequest' && (
|
||||||
|
<div className="rounded-md border border-border p-3 text-sm">
|
||||||
|
{data.requestedRoleName
|
||||||
|
? t('support.requestedExistingRole', { role: data.requestedRoleName })
|
||||||
|
: t('support.requestedNewRole', {
|
||||||
|
name: data.proposedRoleName,
|
||||||
|
configs: data.proposedMaxConfigs,
|
||||||
|
ip: data.proposedMaxIpLimit,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{data.comments.map((comment) => (
|
||||||
|
<div key={comment.id} className="rounded-md border border-border p-3">
|
||||||
|
<div className="mb-1 flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">{comment.authorName}</span>
|
||||||
|
<span>{new Date(comment.createdAt).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<p className="whitespace-pre-wrap text-sm">{comment.body}</p>
|
||||||
|
{comment.attachments.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{comment.attachments.map((attachment) => (
|
||||||
|
<TicketAttachmentImage key={attachment.id} attachment={attachment} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.status !== 'Closed' && (
|
||||||
|
<div className="flex flex-wrap gap-2 border-t border-border pt-3">
|
||||||
|
{data.type === 'RoleRequest' && data.status === 'Open' ? (
|
||||||
|
<>
|
||||||
|
<Button size="sm" disabled={approveMutation.isPending} onClick={() => approveMutation.mutate()}>
|
||||||
|
{t('admin.support.approve')}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" disabled={rejectMutation.isPending} onClick={() => rejectMutation.mutate()}>
|
||||||
|
{t('admin.support.reject')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{data.status === 'Open' && (
|
||||||
|
<Button size="sm" disabled={resolveMutation.isPending} onClick={() => resolveMutation.mutate()}>
|
||||||
|
{t('admin.support.resolve')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="outline" disabled={closeMutation.isPending} onClick={() => closeMutation.mutate()}>
|
||||||
|
{t('admin.support.close')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.status !== 'Closed' && (
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-2"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (reply.trim()) replyMutation.mutate()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Textarea value={reply} onChange={(e) => setReply(e.target.value)} placeholder={t('support.replyPlaceholder')} />
|
||||||
|
<Input type="file" accept="image/png,image/jpeg,image/webp,image/gif" multiple onChange={handleFilesChange} />
|
||||||
|
<Button type="submit" size="sm" disabled={!reply.trim() || replyMutation.isPending}>
|
||||||
|
{t('support.reply')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { apiRequest, apiUpload } from '@/shared/api/client'
|
||||||
|
import type {
|
||||||
|
PagedList,
|
||||||
|
TicketCommentDto,
|
||||||
|
TicketDetailDto,
|
||||||
|
TicketStatus,
|
||||||
|
TicketSummaryDto,
|
||||||
|
TicketType,
|
||||||
|
} from '@/shared/api/types'
|
||||||
|
|
||||||
|
export function listAllTickets(type: TicketType | undefined, status: TicketStatus | undefined, page: number, pageSize: number) {
|
||||||
|
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||||
|
if (type) params.set('type', type)
|
||||||
|
if (status) params.set('status', status)
|
||||||
|
return apiRequest<PagedList<TicketSummaryDto>>(`/admin/support/tickets?${params}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminTicket(id: string) {
|
||||||
|
return apiRequest<TicketDetailDto>(`/admin/support/tickets/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addAdminComment(ticketId: string, body: string, files: File[]) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.set('body', body)
|
||||||
|
files.forEach((file) => formData.append('files', file))
|
||||||
|
return apiUpload<TicketCommentDto>(`/admin/support/tickets/${ticketId}/comments`, formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTicket(ticketId: string) {
|
||||||
|
return apiRequest<void>(`/admin/support/tickets/${ticketId}/resolve`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeTicket(ticketId: string) {
|
||||||
|
return apiRequest<void>(`/admin/support/tickets/${ticketId}/close`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function approveRoleRequest(ticketId: string) {
|
||||||
|
return apiRequest<void>(`/admin/support/tickets/${ticketId}/approve`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rejectRoleRequest(ticketId: string, reason?: string) {
|
||||||
|
return apiRequest<void>(`/admin/support/tickets/${ticketId}/reject`, { method: 'POST', body: { reason } })
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useState, type ChangeEvent } from 'react'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { Textarea } from '@/shared/ui/textarea'
|
||||||
|
import { Label } from '@/shared/ui/label'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
|
||||||
|
import { HttpError } from '@/shared/api/client'
|
||||||
|
import { createBugReportTicket } from './api'
|
||||||
|
|
||||||
|
const MAX_FILES = 5
|
||||||
|
|
||||||
|
export function CreateBugReportDialog() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [files, setFiles] = useState<File[]>([])
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => createBugReportTicket(message.trim(), files),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success(t('support.ticketCreated'))
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['my-tickets'] })
|
||||||
|
setOpen(false)
|
||||||
|
setMessage('')
|
||||||
|
setFiles([])
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleFilesChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFiles(Array.from(e.target.files ?? []).slice(0, MAX_FILES))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline">{t('support.reportBug')}</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('support.reportBug')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (message.trim()) mutation.mutate()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="bug-message">{t('support.messageLabel')}</Label>
|
||||||
|
<Textarea id="bug-message" value={message} onChange={(e) => setMessage(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="bug-files">{t('support.attachmentsLabel')}</Label>
|
||||||
|
<Input
|
||||||
|
id="bug-files"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
|
multiple
|
||||||
|
onChange={handleFilesChange}
|
||||||
|
/>
|
||||||
|
{files.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('support.filesSelected', { count: files.length })}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={!message.trim() || mutation.isPending}>
|
||||||
|
{t('support.submit')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { Textarea } from '@/shared/ui/textarea'
|
||||||
|
import { Label } from '@/shared/ui/label'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||||
|
import { HttpError } from '@/shared/api/client'
|
||||||
|
import { createRoleRequestTicket, listSelectableRoles } from './api'
|
||||||
|
|
||||||
|
type Mode = 'existing' | 'new'
|
||||||
|
|
||||||
|
export function CreateRoleRequestDialog() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [mode, setMode] = useState<Mode>('existing')
|
||||||
|
const [roleId, setRoleId] = useState('')
|
||||||
|
const [newRoleName, setNewRoleName] = useState('')
|
||||||
|
const [newRoleMaxConfigs, setNewRoleMaxConfigs] = useState('')
|
||||||
|
const [newRoleMaxIpLimit, setNewRoleMaxIpLimit] = useState('')
|
||||||
|
const [justification, setJustification] = useState('')
|
||||||
|
|
||||||
|
const rolesQuery = useQuery({ queryKey: ['selectable-roles'], queryFn: listSelectableRoles, enabled: open })
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setMode('existing')
|
||||||
|
setRoleId('')
|
||||||
|
setNewRoleName('')
|
||||||
|
setNewRoleMaxConfigs('')
|
||||||
|
setNewRoleMaxIpLimit('')
|
||||||
|
setJustification('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
createRoleRequestTicket(
|
||||||
|
mode === 'existing'
|
||||||
|
? { existingRoleId: roleId, justification: justification.trim() }
|
||||||
|
: {
|
||||||
|
newRoleName: newRoleName.trim(),
|
||||||
|
newRoleMaxConfigs: Number(newRoleMaxConfigs),
|
||||||
|
newRoleMaxIpLimit: Number(newRoleMaxIpLimit),
|
||||||
|
justification: justification.trim(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success(t('support.ticketCreated'))
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['my-tickets'] })
|
||||||
|
setOpen(false)
|
||||||
|
resetForm()
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message =
|
||||||
|
error instanceof HttpError && error.status === 409
|
||||||
|
? t('support.roleRequestPending')
|
||||||
|
: error instanceof HttpError
|
||||||
|
? error.detail
|
||||||
|
: t('auth.genericError')
|
||||||
|
toast.error(message)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
justification.trim().length > 0 &&
|
||||||
|
(mode === 'existing'
|
||||||
|
? roleId.length > 0
|
||||||
|
: newRoleName.trim().length > 0 && newRoleMaxConfigs !== '' && newRoleMaxIpLimit !== '')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline">{t('support.requestRole')}</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('support.requestRole')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (canSubmit) mutation.mutate()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex gap-4 text-sm">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input type="radio" checked={mode === 'existing'} onChange={() => setMode('existing')} />
|
||||||
|
{t('support.existingRole')}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input type="radio" checked={mode === 'new'} onChange={() => setMode('new')} />
|
||||||
|
{t('support.newRole')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'existing' ? (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('support.selectRole')}</Label>
|
||||||
|
<Select value={roleId} onValueChange={setRoleId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t('support.selectRole')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{rolesQuery.data?.map((role) => (
|
||||||
|
<SelectItem key={role.id} value={role.id}>
|
||||||
|
{role.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="new-role-name">{t('support.newRoleName')}</Label>
|
||||||
|
<Input id="new-role-name" value={newRoleName} onChange={(e) => setNewRoleName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="new-role-configs">{t('support.newRoleMaxConfigs')}</Label>
|
||||||
|
<Input
|
||||||
|
id="new-role-configs"
|
||||||
|
type="number"
|
||||||
|
min={-1}
|
||||||
|
value={newRoleMaxConfigs}
|
||||||
|
onChange={(e) => setNewRoleMaxConfigs(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="new-role-ip">{t('support.newRoleMaxIpLimit')}</Label>
|
||||||
|
<Input
|
||||||
|
id="new-role-ip"
|
||||||
|
type="number"
|
||||||
|
min={-1}
|
||||||
|
value={newRoleMaxIpLimit}
|
||||||
|
onChange={(e) => setNewRoleMaxIpLimit(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="justification">{t('support.justification')}</Label>
|
||||||
|
<Textarea id="justification" value={justification} onChange={(e) => setJustification(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={!canSubmit || mutation.isPending}>
|
||||||
|
{t('support.submit')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||||
|
import { CreateBugReportDialog } from './CreateBugReportDialog'
|
||||||
|
import { CreateRoleRequestDialog } from './CreateRoleRequestDialog'
|
||||||
|
import { TicketDetailDialog } from './TicketDetailDialog'
|
||||||
|
import { TicketStatusBadge } from './TicketStatusBadge'
|
||||||
|
import { listMyTickets } from './api'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
export function SupportTicketList() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['my-tickets', page],
|
||||||
|
queryFn: () => listMyTickets(undefined, undefined, page, PAGE_SIZE),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<CreateBugReportDialog />
|
||||||
|
<CreateRoleRequestDialog />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && <p className="text-sm text-muted-foreground">…</p>}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void refetch()}>
|
||||||
|
{t('activation.retry')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data?.items.length === 0 && <p className="text-sm text-muted-foreground">{t('support.empty')}</p>}
|
||||||
|
|
||||||
|
{data && data.items.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{data.items.map((ticket) => (
|
||||||
|
<Card key={ticket.id} className="cursor-pointer" onClick={() => setSelectedTicketId(ticket.id)}>
|
||||||
|
<CardHeader className="flex-row items-center justify-between gap-2">
|
||||||
|
<CardTitle className="text-base">{t(`support.type.${ticket.type}`)}</CardTitle>
|
||||||
|
<TicketStatusBadge status={ticket.status} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('support.lastActivity', { date: new Date(ticket.lastActivityAt).toLocaleString() })}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && data.total > PAGE_SIZE && (
|
||||||
|
<div className="flex justify-end gap-2 text-sm">
|
||||||
|
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||||
|
{t('admin.prev')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={page * PAGE_SIZE >= data.total} onClick={() => setPage((p) => p + 1)}>
|
||||||
|
{t('admin.next')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedTicketId && (
|
||||||
|
<TicketDetailDialog ticketId={selectedTicketId} onOpenChange={(open) => !open && setSelectedTicketId(null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { TicketAttachmentDto } from '@/shared/api/types'
|
||||||
|
import { fetchAttachmentBlob } from './api'
|
||||||
|
|
||||||
|
export function TicketAttachmentImage({ attachment }: { attachment: TicketAttachmentDto }) {
|
||||||
|
const [url, setUrl] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let objectUrl: string | null = null
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
fetchAttachmentBlob(attachment.id)
|
||||||
|
.then((blob) => {
|
||||||
|
if (cancelled) return
|
||||||
|
objectUrl = URL.createObjectURL(blob)
|
||||||
|
setUrl(objectUrl)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl)
|
||||||
|
}
|
||||||
|
}, [attachment.id])
|
||||||
|
|
||||||
|
if (!url) return <div className="h-20 w-20 animate-pulse rounded-md bg-muted" />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={url} target="_blank" rel="noreferrer">
|
||||||
|
<img src={url} alt={attachment.fileName} className="h-20 w-20 rounded-md border border-border object-cover" />
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useState, type ChangeEvent } from 'react'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Textarea } from '@/shared/ui/textarea'
|
||||||
|
import { Input } from '@/shared/ui/input'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||||
|
import { HttpError } from '@/shared/api/client'
|
||||||
|
import { TicketStatusBadge } from './TicketStatusBadge'
|
||||||
|
import { TicketAttachmentImage } from './TicketAttachmentImage'
|
||||||
|
import { addTicketComment, getTicket, reopenTicket } from './api'
|
||||||
|
|
||||||
|
const MAX_FILES = 5
|
||||||
|
|
||||||
|
export function TicketDetailDialog({ ticketId, onOpenChange }: { ticketId: string; onOpenChange: (open: boolean) => void }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [reply, setReply] = useState('')
|
||||||
|
const [files, setFiles] = useState<File[]>([])
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({ queryKey: ['ticket', ticketId], queryFn: () => getTicket(ticketId) })
|
||||||
|
|
||||||
|
const invalidate = async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['ticket', ticketId] })
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['my-tickets'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyMutation = useMutation({
|
||||||
|
mutationFn: () => addTicketComment(ticketId, reply.trim(), files),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setReply('')
|
||||||
|
setFiles([])
|
||||||
|
await invalidate()
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const reopenMutation = useMutation({
|
||||||
|
mutationFn: () => reopenTicket(ticketId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success(t('support.reopened'))
|
||||||
|
await invalidate()
|
||||||
|
},
|
||||||
|
onError: () => toast.error(t('auth.genericError')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleFilesChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFiles(Array.from(e.target.files ?? []).slice(0, MAX_FILES))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
{data && <TicketStatusBadge status={data.status} />}
|
||||||
|
{data && t(`support.type.${data.type}`)}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{isLoading && <p className="text-sm text-muted-foreground">…</p>}
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<div className="flex max-h-[60vh] flex-col gap-4 overflow-y-auto">
|
||||||
|
{data.type === 'RoleRequest' && (
|
||||||
|
<div className="rounded-md border border-border p-3 text-sm">
|
||||||
|
{data.requestedRoleName
|
||||||
|
? t('support.requestedExistingRole', { role: data.requestedRoleName })
|
||||||
|
: t('support.requestedNewRole', {
|
||||||
|
name: data.proposedRoleName,
|
||||||
|
configs: data.proposedMaxConfigs,
|
||||||
|
ip: data.proposedMaxIpLimit,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{data.comments.map((comment) => (
|
||||||
|
<div key={comment.id} className="rounded-md border border-border p-3">
|
||||||
|
<div className="mb-1 flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">{comment.authorName}</span>
|
||||||
|
<span>{new Date(comment.createdAt).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<p className="whitespace-pre-wrap text-sm">{comment.body}</p>
|
||||||
|
{comment.attachments.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{comment.attachments.map((attachment) => (
|
||||||
|
<TicketAttachmentImage key={attachment.id} attachment={attachment} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.status === 'Resolved' && (
|
||||||
|
<Button variant="outline" size="sm" disabled={reopenMutation.isPending} onClick={() => reopenMutation.mutate()}>
|
||||||
|
{t('support.reopen')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.status !== 'Closed' && (
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-2 border-t border-border pt-3"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (reply.trim()) replyMutation.mutate()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Textarea value={reply} onChange={(e) => setReply(e.target.value)} placeholder={t('support.replyPlaceholder')} />
|
||||||
|
<Input type="file" accept="image/png,image/jpeg,image/webp,image/gif" multiple onChange={handleFilesChange} />
|
||||||
|
<Button type="submit" size="sm" disabled={!reply.trim() || replyMutation.isPending}>
|
||||||
|
{t('support.reply')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Badge } from '@/shared/ui/badge'
|
||||||
|
import type { TicketStatus } from '@/shared/api/types'
|
||||||
|
|
||||||
|
const VARIANT: Record<TicketStatus, 'warning' | 'success' | 'outline'> = {
|
||||||
|
Open: 'warning',
|
||||||
|
Resolved: 'success',
|
||||||
|
Closed: 'outline',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketStatusBadge({ status }: { status: TicketStatus }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return <Badge variant={VARIANT[status]}>{t(`support.status.${status}`)}</Badge>
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { apiRequest, apiUpload, getAccessToken } from '@/shared/api/client'
|
||||||
|
import type {
|
||||||
|
PagedList,
|
||||||
|
RoleDto,
|
||||||
|
TicketCommentDto,
|
||||||
|
TicketDetailDto,
|
||||||
|
TicketStatus,
|
||||||
|
TicketSummaryDto,
|
||||||
|
TicketType,
|
||||||
|
} from '@/shared/api/types'
|
||||||
|
|
||||||
|
function ticketsQuery(type: TicketType | undefined, status: TicketStatus | undefined, page: number, pageSize: number) {
|
||||||
|
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||||
|
if (type) params.set('type', type)
|
||||||
|
if (status) params.set('status', status)
|
||||||
|
return params.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listMyTickets(type: TicketType | undefined, status: TicketStatus | undefined, page: number, pageSize: number) {
|
||||||
|
return apiRequest<PagedList<TicketSummaryDto>>(`/support/tickets?${ticketsQuery(type, status, page, pageSize)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTicket(id: string) {
|
||||||
|
return apiRequest<TicketDetailDto>(`/support/tickets/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSelectableRoles() {
|
||||||
|
return apiRequest<RoleDto[]>('/support/roles')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBugReportTicket(message: string, files: File[]) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.set('message', message)
|
||||||
|
files.forEach((file) => formData.append('files', file))
|
||||||
|
return apiUpload<TicketDetailDto>('/support/tickets/bug-reports', formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRoleRequestTicket(payload: {
|
||||||
|
existingRoleId?: string
|
||||||
|
newRoleName?: string
|
||||||
|
newRoleMaxConfigs?: number
|
||||||
|
newRoleMaxIpLimit?: number
|
||||||
|
justification: string
|
||||||
|
}) {
|
||||||
|
return apiRequest<TicketDetailDto>('/support/tickets/role-requests', { method: 'POST', body: payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addTicketComment(ticketId: string, body: string, files: File[]) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.set('body', body)
|
||||||
|
files.forEach((file) => formData.append('files', file))
|
||||||
|
return apiUpload<TicketCommentDto>(`/support/tickets/${ticketId}/comments`, formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reopenTicket(ticketId: string) {
|
||||||
|
return apiRequest<void>(`/support/tickets/${ticketId}/reopen`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Вложения отдаются авторизованным эндпоинтом (не статикой) — обычный <img src> не может передать
|
||||||
|
* Authorization-заголовок, поэтому качаем как Blob и рендерим через Object URL (см. TicketAttachmentImage). */
|
||||||
|
export async function fetchAttachmentBlob(id: string): Promise<Blob> {
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
const token = getAccessToken()
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`
|
||||||
|
|
||||||
|
const response = await fetch(`/api/support/attachments/${id}`, { headers, credentials: 'include' })
|
||||||
|
if (!response.ok) throw new Error('Не удалось загрузить вложение')
|
||||||
|
return response.blob()
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as SupportRouteImport } from './routes/support'
|
||||||
import { Route as SettingsRouteImport } from './routes/settings'
|
import { Route as SettingsRouteImport } from './routes/settings'
|
||||||
import { Route as RegisterRouteImport } from './routes/register'
|
import { Route as RegisterRouteImport } from './routes/register'
|
||||||
import { Route as NewsRouteImport } from './routes/news'
|
import { Route as NewsRouteImport } from './routes/news'
|
||||||
@@ -19,6 +20,7 @@ import { Route as AdminRouteImport } from './routes/admin'
|
|||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as AdminIndexRouteImport } from './routes/admin/index'
|
import { Route as AdminIndexRouteImport } from './routes/admin/index'
|
||||||
import { Route as AdminUsersRouteImport } from './routes/admin/users'
|
import { Route as AdminUsersRouteImport } from './routes/admin/users'
|
||||||
|
import { Route as AdminSupportRouteImport } from './routes/admin/support'
|
||||||
import { Route as AdminRolesRouteImport } from './routes/admin/roles'
|
import { Route as AdminRolesRouteImport } from './routes/admin/roles'
|
||||||
import { Route as AdminNodesRouteImport } from './routes/admin/nodes'
|
import { Route as AdminNodesRouteImport } from './routes/admin/nodes'
|
||||||
import { Route as AdminNewsRouteImport } from './routes/admin/news'
|
import { Route as AdminNewsRouteImport } from './routes/admin/news'
|
||||||
@@ -27,6 +29,11 @@ import { Route as AdminAuditRouteImport } from './routes/admin/audit'
|
|||||||
import { Route as AdminAppsRouteImport } from './routes/admin/apps'
|
import { Route as AdminAppsRouteImport } from './routes/admin/apps'
|
||||||
import { Route as AdminActivationRouteImport } from './routes/admin/activation'
|
import { Route as AdminActivationRouteImport } from './routes/admin/activation'
|
||||||
|
|
||||||
|
const SupportRoute = SupportRouteImport.update({
|
||||||
|
id: '/support',
|
||||||
|
path: '/support',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const SettingsRoute = SettingsRouteImport.update({
|
const SettingsRoute = SettingsRouteImport.update({
|
||||||
id: '/settings',
|
id: '/settings',
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
@@ -77,6 +84,11 @@ const AdminUsersRoute = AdminUsersRouteImport.update({
|
|||||||
path: '/users',
|
path: '/users',
|
||||||
getParentRoute: () => AdminRoute,
|
getParentRoute: () => AdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AdminSupportRoute = AdminSupportRouteImport.update({
|
||||||
|
id: '/support',
|
||||||
|
path: '/support',
|
||||||
|
getParentRoute: () => AdminRoute,
|
||||||
|
} as any)
|
||||||
const AdminRolesRoute = AdminRolesRouteImport.update({
|
const AdminRolesRoute = AdminRolesRouteImport.update({
|
||||||
id: '/roles',
|
id: '/roles',
|
||||||
path: '/roles',
|
path: '/roles',
|
||||||
@@ -122,6 +134,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/news': typeof NewsRoute
|
'/news': typeof NewsRoute
|
||||||
'/register': typeof RegisterRoute
|
'/register': typeof RegisterRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
|
'/support': typeof SupportRoute
|
||||||
'/admin/activation': typeof AdminActivationRoute
|
'/admin/activation': typeof AdminActivationRoute
|
||||||
'/admin/apps': typeof AdminAppsRoute
|
'/admin/apps': typeof AdminAppsRoute
|
||||||
'/admin/audit': typeof AdminAuditRoute
|
'/admin/audit': typeof AdminAuditRoute
|
||||||
@@ -129,6 +142,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/admin/news': typeof AdminNewsRoute
|
'/admin/news': typeof AdminNewsRoute
|
||||||
'/admin/nodes': typeof AdminNodesRoute
|
'/admin/nodes': typeof AdminNodesRoute
|
||||||
'/admin/roles': typeof AdminRolesRoute
|
'/admin/roles': typeof AdminRolesRoute
|
||||||
|
'/admin/support': typeof AdminSupportRoute
|
||||||
'/admin/users': typeof AdminUsersRoute
|
'/admin/users': typeof AdminUsersRoute
|
||||||
'/admin/': typeof AdminIndexRoute
|
'/admin/': typeof AdminIndexRoute
|
||||||
}
|
}
|
||||||
@@ -140,6 +154,7 @@ export interface FileRoutesByTo {
|
|||||||
'/news': typeof NewsRoute
|
'/news': typeof NewsRoute
|
||||||
'/register': typeof RegisterRoute
|
'/register': typeof RegisterRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
|
'/support': typeof SupportRoute
|
||||||
'/admin/activation': typeof AdminActivationRoute
|
'/admin/activation': typeof AdminActivationRoute
|
||||||
'/admin/apps': typeof AdminAppsRoute
|
'/admin/apps': typeof AdminAppsRoute
|
||||||
'/admin/audit': typeof AdminAuditRoute
|
'/admin/audit': typeof AdminAuditRoute
|
||||||
@@ -147,6 +162,7 @@ export interface FileRoutesByTo {
|
|||||||
'/admin/news': typeof AdminNewsRoute
|
'/admin/news': typeof AdminNewsRoute
|
||||||
'/admin/nodes': typeof AdminNodesRoute
|
'/admin/nodes': typeof AdminNodesRoute
|
||||||
'/admin/roles': typeof AdminRolesRoute
|
'/admin/roles': typeof AdminRolesRoute
|
||||||
|
'/admin/support': typeof AdminSupportRoute
|
||||||
'/admin/users': typeof AdminUsersRoute
|
'/admin/users': typeof AdminUsersRoute
|
||||||
'/admin': typeof AdminIndexRoute
|
'/admin': typeof AdminIndexRoute
|
||||||
}
|
}
|
||||||
@@ -160,6 +176,7 @@ export interface FileRoutesById {
|
|||||||
'/news': typeof NewsRoute
|
'/news': typeof NewsRoute
|
||||||
'/register': typeof RegisterRoute
|
'/register': typeof RegisterRoute
|
||||||
'/settings': typeof SettingsRoute
|
'/settings': typeof SettingsRoute
|
||||||
|
'/support': typeof SupportRoute
|
||||||
'/admin/activation': typeof AdminActivationRoute
|
'/admin/activation': typeof AdminActivationRoute
|
||||||
'/admin/apps': typeof AdminAppsRoute
|
'/admin/apps': typeof AdminAppsRoute
|
||||||
'/admin/audit': typeof AdminAuditRoute
|
'/admin/audit': typeof AdminAuditRoute
|
||||||
@@ -167,6 +184,7 @@ export interface FileRoutesById {
|
|||||||
'/admin/news': typeof AdminNewsRoute
|
'/admin/news': typeof AdminNewsRoute
|
||||||
'/admin/nodes': typeof AdminNodesRoute
|
'/admin/nodes': typeof AdminNodesRoute
|
||||||
'/admin/roles': typeof AdminRolesRoute
|
'/admin/roles': typeof AdminRolesRoute
|
||||||
|
'/admin/support': typeof AdminSupportRoute
|
||||||
'/admin/users': typeof AdminUsersRoute
|
'/admin/users': typeof AdminUsersRoute
|
||||||
'/admin/': typeof AdminIndexRoute
|
'/admin/': typeof AdminIndexRoute
|
||||||
}
|
}
|
||||||
@@ -181,6 +199,7 @@ export interface FileRouteTypes {
|
|||||||
| '/news'
|
| '/news'
|
||||||
| '/register'
|
| '/register'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
|
| '/support'
|
||||||
| '/admin/activation'
|
| '/admin/activation'
|
||||||
| '/admin/apps'
|
| '/admin/apps'
|
||||||
| '/admin/audit'
|
| '/admin/audit'
|
||||||
@@ -188,6 +207,7 @@ export interface FileRouteTypes {
|
|||||||
| '/admin/news'
|
| '/admin/news'
|
||||||
| '/admin/nodes'
|
| '/admin/nodes'
|
||||||
| '/admin/roles'
|
| '/admin/roles'
|
||||||
|
| '/admin/support'
|
||||||
| '/admin/users'
|
| '/admin/users'
|
||||||
| '/admin/'
|
| '/admin/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
@@ -199,6 +219,7 @@ export interface FileRouteTypes {
|
|||||||
| '/news'
|
| '/news'
|
||||||
| '/register'
|
| '/register'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
|
| '/support'
|
||||||
| '/admin/activation'
|
| '/admin/activation'
|
||||||
| '/admin/apps'
|
| '/admin/apps'
|
||||||
| '/admin/audit'
|
| '/admin/audit'
|
||||||
@@ -206,6 +227,7 @@ export interface FileRouteTypes {
|
|||||||
| '/admin/news'
|
| '/admin/news'
|
||||||
| '/admin/nodes'
|
| '/admin/nodes'
|
||||||
| '/admin/roles'
|
| '/admin/roles'
|
||||||
|
| '/admin/support'
|
||||||
| '/admin/users'
|
| '/admin/users'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
id:
|
id:
|
||||||
@@ -218,6 +240,7 @@ export interface FileRouteTypes {
|
|||||||
| '/news'
|
| '/news'
|
||||||
| '/register'
|
| '/register'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
|
| '/support'
|
||||||
| '/admin/activation'
|
| '/admin/activation'
|
||||||
| '/admin/apps'
|
| '/admin/apps'
|
||||||
| '/admin/audit'
|
| '/admin/audit'
|
||||||
@@ -225,6 +248,7 @@ export interface FileRouteTypes {
|
|||||||
| '/admin/news'
|
| '/admin/news'
|
||||||
| '/admin/nodes'
|
| '/admin/nodes'
|
||||||
| '/admin/roles'
|
| '/admin/roles'
|
||||||
|
| '/admin/support'
|
||||||
| '/admin/users'
|
| '/admin/users'
|
||||||
| '/admin/'
|
| '/admin/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
@@ -238,10 +262,18 @@ export interface RootRouteChildren {
|
|||||||
NewsRoute: typeof NewsRoute
|
NewsRoute: typeof NewsRoute
|
||||||
RegisterRoute: typeof RegisterRoute
|
RegisterRoute: typeof RegisterRoute
|
||||||
SettingsRoute: typeof SettingsRoute
|
SettingsRoute: typeof SettingsRoute
|
||||||
|
SupportRoute: typeof SupportRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
'/support': {
|
||||||
|
id: '/support'
|
||||||
|
path: '/support'
|
||||||
|
fullPath: '/support'
|
||||||
|
preLoaderRoute: typeof SupportRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/settings': {
|
'/settings': {
|
||||||
id: '/settings'
|
id: '/settings'
|
||||||
path: '/settings'
|
path: '/settings'
|
||||||
@@ -312,6 +344,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AdminUsersRouteImport
|
preLoaderRoute: typeof AdminUsersRouteImport
|
||||||
parentRoute: typeof AdminRoute
|
parentRoute: typeof AdminRoute
|
||||||
}
|
}
|
||||||
|
'/admin/support': {
|
||||||
|
id: '/admin/support'
|
||||||
|
path: '/support'
|
||||||
|
fullPath: '/admin/support'
|
||||||
|
preLoaderRoute: typeof AdminSupportRouteImport
|
||||||
|
parentRoute: typeof AdminRoute
|
||||||
|
}
|
||||||
'/admin/roles': {
|
'/admin/roles': {
|
||||||
id: '/admin/roles'
|
id: '/admin/roles'
|
||||||
path: '/roles'
|
path: '/roles'
|
||||||
@@ -372,6 +411,7 @@ interface AdminRouteChildren {
|
|||||||
AdminNewsRoute: typeof AdminNewsRoute
|
AdminNewsRoute: typeof AdminNewsRoute
|
||||||
AdminNodesRoute: typeof AdminNodesRoute
|
AdminNodesRoute: typeof AdminNodesRoute
|
||||||
AdminRolesRoute: typeof AdminRolesRoute
|
AdminRolesRoute: typeof AdminRolesRoute
|
||||||
|
AdminSupportRoute: typeof AdminSupportRoute
|
||||||
AdminUsersRoute: typeof AdminUsersRoute
|
AdminUsersRoute: typeof AdminUsersRoute
|
||||||
AdminIndexRoute: typeof AdminIndexRoute
|
AdminIndexRoute: typeof AdminIndexRoute
|
||||||
}
|
}
|
||||||
@@ -384,6 +424,7 @@ const AdminRouteChildren: AdminRouteChildren = {
|
|||||||
AdminNewsRoute: AdminNewsRoute,
|
AdminNewsRoute: AdminNewsRoute,
|
||||||
AdminNodesRoute: AdminNodesRoute,
|
AdminNodesRoute: AdminNodesRoute,
|
||||||
AdminRolesRoute: AdminRolesRoute,
|
AdminRolesRoute: AdminRolesRoute,
|
||||||
|
AdminSupportRoute: AdminSupportRoute,
|
||||||
AdminUsersRoute: AdminUsersRoute,
|
AdminUsersRoute: AdminUsersRoute,
|
||||||
AdminIndexRoute: AdminIndexRoute,
|
AdminIndexRoute: AdminIndexRoute,
|
||||||
}
|
}
|
||||||
@@ -399,6 +440,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
NewsRoute: NewsRoute,
|
NewsRoute: NewsRoute,
|
||||||
RegisterRoute: RegisterRoute,
|
RegisterRoute: RegisterRoute,
|
||||||
SettingsRoute: SettingsRoute,
|
SettingsRoute: SettingsRoute,
|
||||||
|
SupportRoute: SupportRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ function RootLayout() {
|
|||||||
<Link to="/news" className="text-muted-foreground hover:text-foreground">
|
<Link to="/news" className="text-muted-foreground hover:text-foreground">
|
||||||
{t('nav.news')}
|
{t('nav.news')}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/support" className="text-muted-foreground hover:text-foreground">
|
||||||
|
{t('nav.support')}
|
||||||
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Link to="/settings" className="text-muted-foreground hover:text-foreground">
|
<Link to="/settings" className="text-muted-foreground hover:text-foreground">
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const TABS = [
|
|||||||
{ to: '/admin/nodes', key: 'nodes' },
|
{ to: '/admin/nodes', key: 'nodes' },
|
||||||
{ to: '/admin/apps', key: 'apps' },
|
{ to: '/admin/apps', key: 'apps' },
|
||||||
{ to: '/admin/news', key: 'news' },
|
{ to: '/admin/news', key: 'news' },
|
||||||
|
{ to: '/admin/support', key: 'support' },
|
||||||
{ to: '/admin/audit', key: 'audit' },
|
{ to: '/admin/audit', key: 'audit' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button } from '@/shared/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||||
|
import { TicketStatusBadge } from '@/features/support/TicketStatusBadge'
|
||||||
|
import { AdminTicketDetailDialog } from '@/features/admin/support/AdminTicketDetailDialog'
|
||||||
|
import { listAllTickets } from '@/features/admin/support/api'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/admin/support')({ component: AdminSupportPage })
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
function AdminSupportPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['admin-tickets', page],
|
||||||
|
queryFn: () => listAllTickets(undefined, undefined, page, PAGE_SIZE),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{isLoading && <p className="text-sm text-muted-foreground">…</p>}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void refetch()}>
|
||||||
|
{t('activation.retry')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data?.items.length === 0 && <p className="text-sm text-muted-foreground">{t('admin.support.empty')}</p>}
|
||||||
|
|
||||||
|
{data && data.items.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{data.items.map((ticket) => (
|
||||||
|
<Card key={ticket.id} className="cursor-pointer" onClick={() => setSelectedTicketId(ticket.id)}>
|
||||||
|
<CardHeader className="flex-row items-center justify-between gap-2">
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{ticket.userName} — {t(`support.type.${ticket.type}`)}
|
||||||
|
</CardTitle>
|
||||||
|
<TicketStatusBadge status={ticket.status} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('support.lastActivity', { date: new Date(ticket.lastActivityAt).toLocaleString() })}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && data.total > PAGE_SIZE && (
|
||||||
|
<div className="flex justify-end gap-2 text-sm">
|
||||||
|
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||||
|
{t('admin.prev')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={page * PAGE_SIZE >= data.total} onClick={() => setPage((p) => p + 1)}>
|
||||||
|
{t('admin.next')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedTicketId && (
|
||||||
|
<AdminTicketDetailDialog ticketId={selectedTicketId} onOpenChange={(open) => !open && setSelectedTicketId(null)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useRequireActivated } from '@/features/auth/guards'
|
||||||
|
import { SupportTicketList } from '@/features/support/SupportTicketList'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/support')({ component: SupportPage })
|
||||||
|
|
||||||
|
function SupportPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { isReady } = useRequireActivated()
|
||||||
|
|
||||||
|
if (!isReady) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6 px-6 py-10">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{t('nav.support')}</h1>
|
||||||
|
<SupportTicketList />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -91,3 +91,36 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
|||||||
const text = await response.text()
|
const text = await response.text()
|
||||||
return (text ? JSON.parse(text) : undefined) as T
|
return (text ? JSON.parse(text) : undefined) as T
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UploadOptions = {
|
||||||
|
method?: 'POST' | 'PUT'
|
||||||
|
skipRefresh?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Как apiRequest, но для multipart/form-data (вложения к тикетам) — без JSON.stringify и
|
||||||
|
* без Content-Type (браузер сам проставляет boundary). */
|
||||||
|
export async function apiUpload<T>(path: string, formData: FormData, options: UploadOptions = {}): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
|
||||||
|
|
||||||
|
const response = await fetch(`/api${path}`, {
|
||||||
|
method: options.method ?? 'POST',
|
||||||
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 401 && !options.skipRefresh) {
|
||||||
|
const refreshed = await refreshAccessToken()
|
||||||
|
if (refreshed) return apiUpload<T>(path, formData, { ...options, skipRefresh: true })
|
||||||
|
onUnauthorized?.()
|
||||||
|
throw await parseError(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) throw await parseError(response)
|
||||||
|
|
||||||
|
if (response.status === 204) return undefined as T
|
||||||
|
|
||||||
|
const text = await response.text()
|
||||||
|
return (text ? JSON.parse(text) : undefined) as T
|
||||||
|
}
|
||||||
|
|||||||
@@ -238,3 +238,48 @@ export type AuditLogDto = {
|
|||||||
source: AuditSource
|
source: AuditSource
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TicketType = 'BugReport' | 'RoleRequest'
|
||||||
|
export type TicketStatus = 'Open' | 'Resolved' | 'Closed'
|
||||||
|
|
||||||
|
export type TicketAttachmentDto = {
|
||||||
|
id: string
|
||||||
|
fileName: string
|
||||||
|
contentType: string
|
||||||
|
sizeBytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TicketCommentDto = {
|
||||||
|
id: string
|
||||||
|
authorId: string
|
||||||
|
authorName: string
|
||||||
|
body: string
|
||||||
|
createdAt: string
|
||||||
|
attachments: TicketAttachmentDto[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строка списка тикетов — один DTO для своего списка и админского (видит только свои userId/userName). */
|
||||||
|
export type TicketSummaryDto = {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
userName: string
|
||||||
|
type: TicketType
|
||||||
|
status: TicketStatus
|
||||||
|
createdAt: string
|
||||||
|
lastActivityAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TicketDetailDto = {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
userName: string
|
||||||
|
type: TicketType
|
||||||
|
status: TicketStatus
|
||||||
|
requestedRoleId: string | null
|
||||||
|
requestedRoleName: string | null
|
||||||
|
proposedRoleName: string | null
|
||||||
|
proposedMaxConfigs: number | null
|
||||||
|
proposedMaxIpLimit: number | null
|
||||||
|
createdAt: string
|
||||||
|
comments: TicketCommentDto[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const resources = {
|
|||||||
dashboard: 'Мои конфиги',
|
dashboard: 'Мои конфиги',
|
||||||
instructions: 'Инструкции',
|
instructions: 'Инструкции',
|
||||||
news: 'Новости',
|
news: 'Новости',
|
||||||
|
support: 'Поддержка',
|
||||||
settings: 'Настройки',
|
settings: 'Настройки',
|
||||||
admin: 'Админка',
|
admin: 'Админка',
|
||||||
logout: 'Выйти',
|
logout: 'Выйти',
|
||||||
@@ -121,6 +122,42 @@ const resources = {
|
|||||||
empty: 'Пока нет новостей.',
|
empty: 'Пока нет новостей.',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
support: {
|
||||||
|
title: 'Поддержка',
|
||||||
|
empty: 'У вас пока нет обращений.',
|
||||||
|
reportBug: 'Сообщить об ошибке',
|
||||||
|
requestRole: 'Запросить роль',
|
||||||
|
submit: 'Отправить',
|
||||||
|
messageLabel: 'Опишите проблему или предложение',
|
||||||
|
attachmentsLabel: 'Скриншоты (необязательно, до 5)',
|
||||||
|
filesSelected: '{{count}} файл(ов) выбрано',
|
||||||
|
existingRole: 'Существующая роль',
|
||||||
|
newRole: 'Новая роль',
|
||||||
|
selectRole: 'Выберите роль',
|
||||||
|
newRoleName: 'Название роли',
|
||||||
|
newRoleMaxConfigs: 'Количество конфигов (-1 — без лимита)',
|
||||||
|
newRoleMaxIpLimit: 'Количество IP (-1 — без лимита)',
|
||||||
|
justification: 'Обоснование',
|
||||||
|
ticketCreated: 'Обращение отправлено.',
|
||||||
|
roleRequestPending: 'У вас уже есть необработанная заявка на роль.',
|
||||||
|
reply: 'Ответить',
|
||||||
|
replyPlaceholder: 'Написать комментарий…',
|
||||||
|
reopen: 'Переоткрыть',
|
||||||
|
reopened: 'Тикет переоткрыт.',
|
||||||
|
lastActivity: 'Последняя активность: {{date}}',
|
||||||
|
requestedExistingRole: 'Запрошена роль: {{role}}',
|
||||||
|
requestedNewRole: 'Запрошена новая роль «{{name}}» (конфигов: {{configs}}, IP: {{ip}})',
|
||||||
|
type: {
|
||||||
|
BugReport: 'Ошибка/предложение',
|
||||||
|
RoleRequest: 'Заявка на роль',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
Open: 'Открыт',
|
||||||
|
Resolved: 'Решён',
|
||||||
|
Closed: 'Закрыт',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
settings: {
|
settings: {
|
||||||
changePassword: 'Сменить пароль',
|
changePassword: 'Сменить пароль',
|
||||||
currentPassword: 'Текущий пароль',
|
currentPassword: 'Текущий пароль',
|
||||||
@@ -159,6 +196,7 @@ const resources = {
|
|||||||
nodes: 'Ноды',
|
nodes: 'Ноды',
|
||||||
apps: 'Приложения',
|
apps: 'Приложения',
|
||||||
news: 'Новости',
|
news: 'Новости',
|
||||||
|
support: 'Поддержка',
|
||||||
audit: 'Аудит',
|
audit: 'Аудит',
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
@@ -289,6 +327,17 @@ const resources = {
|
|||||||
deleted: 'Новость удалена.',
|
deleted: 'Новость удалена.',
|
||||||
confirmDelete: 'Удалить новость?',
|
confirmDelete: 'Удалить новость?',
|
||||||
},
|
},
|
||||||
|
support: {
|
||||||
|
empty: 'Обращений пока нет.',
|
||||||
|
resolve: 'Решено',
|
||||||
|
resolved: 'Тикет отмечен как решённый.',
|
||||||
|
close: 'Закрыть',
|
||||||
|
closed: 'Тикет закрыт.',
|
||||||
|
approve: 'Одобрить',
|
||||||
|
approved: 'Заявка одобрена, роль выдана.',
|
||||||
|
reject: 'Отклонить',
|
||||||
|
rejected: 'Заявка отклонена.',
|
||||||
|
},
|
||||||
audit: {
|
audit: {
|
||||||
time: 'Время',
|
time: 'Время',
|
||||||
action: 'Действие',
|
action: 'Действие',
|
||||||
@@ -356,6 +405,7 @@ const resources = {
|
|||||||
dashboard: 'My configs',
|
dashboard: 'My configs',
|
||||||
instructions: 'Instructions',
|
instructions: 'Instructions',
|
||||||
news: 'News',
|
news: 'News',
|
||||||
|
support: 'Support',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
admin: 'Admin',
|
admin: 'Admin',
|
||||||
logout: 'Log out',
|
logout: 'Log out',
|
||||||
@@ -428,6 +478,42 @@ const resources = {
|
|||||||
empty: 'No news yet.',
|
empty: 'No news yet.',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
support: {
|
||||||
|
title: 'Support',
|
||||||
|
empty: 'You have no tickets yet.',
|
||||||
|
reportBug: 'Report a bug',
|
||||||
|
requestRole: 'Request a role',
|
||||||
|
submit: 'Submit',
|
||||||
|
messageLabel: 'Describe the issue or suggestion',
|
||||||
|
attachmentsLabel: 'Screenshots (optional, up to 5)',
|
||||||
|
filesSelected: '{{count}} file(s) selected',
|
||||||
|
existingRole: 'Existing role',
|
||||||
|
newRole: 'New role',
|
||||||
|
selectRole: 'Select a role',
|
||||||
|
newRoleName: 'Role name',
|
||||||
|
newRoleMaxConfigs: 'Max configs (-1 = unlimited)',
|
||||||
|
newRoleMaxIpLimit: 'Max IPs (-1 = unlimited)',
|
||||||
|
justification: 'Justification',
|
||||||
|
ticketCreated: 'Ticket submitted.',
|
||||||
|
roleRequestPending: 'You already have a pending role request.',
|
||||||
|
reply: 'Reply',
|
||||||
|
replyPlaceholder: 'Write a comment…',
|
||||||
|
reopen: 'Reopen',
|
||||||
|
reopened: 'Ticket reopened.',
|
||||||
|
lastActivity: 'Last activity: {{date}}',
|
||||||
|
requestedExistingRole: 'Requested role: {{role}}',
|
||||||
|
requestedNewRole: 'Requested new role "{{name}}" (configs: {{configs}}, IPs: {{ip}})',
|
||||||
|
type: {
|
||||||
|
BugReport: 'Bug/suggestion',
|
||||||
|
RoleRequest: 'Role request',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
Open: 'Open',
|
||||||
|
Resolved: 'Resolved',
|
||||||
|
Closed: 'Closed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
settings: {
|
settings: {
|
||||||
changePassword: 'Change password',
|
changePassword: 'Change password',
|
||||||
currentPassword: 'Current password',
|
currentPassword: 'Current password',
|
||||||
@@ -466,6 +552,7 @@ const resources = {
|
|||||||
nodes: 'Nodes',
|
nodes: 'Nodes',
|
||||||
apps: 'Apps',
|
apps: 'Apps',
|
||||||
news: 'News',
|
news: 'News',
|
||||||
|
support: 'Support',
|
||||||
audit: 'Audit',
|
audit: 'Audit',
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
@@ -596,6 +683,17 @@ const resources = {
|
|||||||
deleted: 'Post deleted.',
|
deleted: 'Post deleted.',
|
||||||
confirmDelete: 'Delete this post?',
|
confirmDelete: 'Delete this post?',
|
||||||
},
|
},
|
||||||
|
support: {
|
||||||
|
empty: 'No tickets yet.',
|
||||||
|
resolve: 'Resolve',
|
||||||
|
resolved: 'Ticket marked as resolved.',
|
||||||
|
close: 'Close',
|
||||||
|
closed: 'Ticket closed.',
|
||||||
|
approve: 'Approve',
|
||||||
|
approved: 'Request approved, role granted.',
|
||||||
|
reject: 'Reject',
|
||||||
|
rejected: 'Request rejected.',
|
||||||
|
},
|
||||||
audit: {
|
audit: {
|
||||||
time: 'Time',
|
time: 'Time',
|
||||||
action: 'Action',
|
action: 'Action',
|
||||||
|
|||||||
Reference in New Issue
Block a user