From b5630b2685b027c7ff04efa83d35b755f6b4861f Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Tue, 14 Jul 2026 06:49:05 +0300 Subject: [PATCH] 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. --- .env.example | 5 + CLAUDE.md | 5 + .../Endpoints/AdminSupportEndpoints.cs | 79 ++ .../Endpoints/SupportEndpoints.cs | 111 +++ .../Hubs/SignalRRealtimeNotifier.cs | 17 + backend/src/PnvPanel.Api/Program.cs | 2 + .../Telegram/PnvBotUpdateHandler.cs | 24 + .../PnvPanel.Api/Telegram/TelegramNotifier.cs | 58 ++ .../Support/ApproveRoleRequestCommand.cs | 8 + .../ApproveRoleRequestCommandHandler.cs | 61 ++ .../Admin/Support/CloseTicketCommand.cs | 6 + .../Support/CloseTicketCommandHandler.cs | 36 + .../Admin/Support/GetTicketAdminQuery.cs | 7 + .../Support/GetTicketAdminQueryHandler.cs | 22 + .../Admin/Support/ListAllTicketsQuery.cs | 9 + .../Support/ListAllTicketsQueryHandler.cs | 28 + .../Admin/Support/RejectRoleRequestCommand.cs | 6 + .../RejectRoleRequestCommandHandler.cs | 44 + .../Admin/Support/ResolveTicketCommand.cs | 6 + .../Support/ResolveTicketCommandHandler.cs | 36 + .../Common/Interfaces/IAppDbContext.cs | 7 + .../Common/Interfaces/IFileStorage.cs | 15 + .../Common/Interfaces/IRealtimeNotifier.cs | 6 + .../Common/Interfaces/ITelegramNotifier.cs | 8 + .../AddComment/AddTicketCommentCommand.cs | 9 + .../AddTicketCommentCommandHandler.cs | 55 ++ .../AddTicketCommentCommandValidator.cs | 12 + .../CreateBugReportTicketCommand.cs | 7 + .../CreateBugReportTicketCommandHandler.cs | 46 + .../CreateBugReportTicketCommandValidator.cs | 11 + .../CreateRoleRequestTicketCommand.cs | 10 + .../CreateRoleRequestTicketCommandHandler.cs | 73 ++ ...CreateRoleRequestTicketCommandValidator.cs | 31 + .../GetAttachment/GetTicketAttachmentQuery.cs | 6 + .../GetTicketAttachmentQueryHandler.cs | 39 + .../Support/GetTicket/GetTicketQuery.cs | 6 + .../GetTicket/GetTicketQueryHandler.cs | 26 + .../ListMyTickets/ListMyTicketsQuery.cs | 8 + .../ListMyTicketsQueryHandler.cs | 31 + .../ListSelectableRolesQuery.cs | 9 + .../ListSelectableRolesQueryHandler.cs | 21 + .../Support/Reopen/ReopenTicketCommand.cs | 6 + .../Reopen/ReopenTicketCommandHandler.cs | 29 + .../Support/SupportErrors.cs | 36 + .../Support/TicketAttachmentContent.cs | 4 + .../Support/TicketAttachmentDto.cs | 3 + .../Support/TicketAttachmentPersistence.cs | 20 + .../Support/TicketAttachmentUpload.cs | 5 + .../Support/TicketAttachmentValidation.cs | 31 + .../Support/TicketAuthorization.cs | 13 + .../Support/TicketCommentDto.cs | 5 + .../Support/TicketDetailDto.cs | 13 + .../Support/TicketMapping.cs | 78 ++ .../Support/TicketSummaryDto.cs | 9 + .../PnvPanel.Domain/Support/SupportTicket.cs | 94 ++ .../Support/TicketAttachment.cs | 36 + .../PnvPanel.Domain/Support/TicketComment.cs | 32 + .../PnvPanel.Domain/Support/TicketStatus.cs | 8 + .../src/PnvPanel.Domain/Support/TicketType.cs | 7 + .../DependencyInjection.cs | 4 + .../Persistence/AppDbContext.cs | 7 + .../SupportTicketConfiguration.cs | 21 + .../TicketAttachmentConfiguration.cs | 21 + .../TicketCommentConfiguration.cs | 18 + ...260714031703_AddSupportTickets.Designer.cs | 887 ++++++++++++++++++ .../20260714031703_AddSupportTickets.cs | 105 +++ .../Migrations/AppDbContextModelSnapshot.cs | 111 +++ .../Storage/DiskFileStorage.cs | 34 + .../Storage/FileStorageOptions.cs | 10 + .../ApproveRoleRequestCommandHandlerTests.cs | 82 ++ .../AddTicketCommentCommandHandlerTests.cs | 105 +++ ...reateBugReportTicketCommandHandlerTests.cs | 71 ++ ...ateRoleRequestTicketCommandHandlerTests.cs | 92 ++ .../ReopenTicketCommandHandlerTests.cs | 65 ++ .../Support/SupportTicketTests.cs | 124 +++ docker-compose.yml | 4 + docs/api-design.md | 54 +- docs/architecture.md | 17 +- docs/domain-model.md | 77 ++ docs/tech-stack.md | 2 + docs/telegram-bot.md | 37 + .../admin/support/AdminTicketDetailDialog.tsx | 173 ++++ frontend/src/features/admin/support/api.ts | 43 + .../support/CreateBugReportDialog.tsx | 78 ++ .../support/CreateRoleRequestDialog.tsx | 157 ++++ .../features/support/SupportTicketList.tsx | 78 ++ .../support/TicketAttachmentImage.tsx | 33 + .../features/support/TicketDetailDialog.tsx | 123 +++ .../features/support/TicketStatusBadge.tsx | 14 + frontend/src/features/support/api.ts | 69 ++ frontend/src/routeTree.gen.ts | 42 + frontend/src/routes/__root.tsx | 3 + frontend/src/routes/admin.tsx | 1 + frontend/src/routes/admin/support.tsx | 76 ++ frontend/src/routes/support.tsx | 20 + frontend/src/shared/api/client.ts | 33 + frontend/src/shared/api/types.ts | 45 + frontend/src/shared/lib/i18n.ts | 98 ++ 98 files changed, 4463 insertions(+), 6 deletions(-) create mode 100644 backend/src/PnvPanel.Api/Endpoints/AdminSupportEndpoints.cs create mode 100644 backend/src/PnvPanel.Api/Endpoints/SupportEndpoints.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Support/CloseTicketCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Support/CloseTicketCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Support/GetTicketAdminQuery.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Support/GetTicketAdminQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Support/ListAllTicketsQuery.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Support/ListAllTicketsQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Support/RejectRoleRequestCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Support/RejectRoleRequestCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Support/ResolveTicketCommand.cs create mode 100644 backend/src/PnvPanel.Application/Admin/Support/ResolveTicketCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Common/Interfaces/IFileStorage.cs create mode 100644 backend/src/PnvPanel.Application/Support/AddComment/AddTicketCommentCommand.cs create mode 100644 backend/src/PnvPanel.Application/Support/AddComment/AddTicketCommentCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Support/AddComment/AddTicketCommentCommandValidator.cs create mode 100644 backend/src/PnvPanel.Application/Support/CreateBugReport/CreateBugReportTicketCommand.cs create mode 100644 backend/src/PnvPanel.Application/Support/CreateBugReport/CreateBugReportTicketCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Support/CreateBugReport/CreateBugReportTicketCommandValidator.cs create mode 100644 backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommand.cs create mode 100644 backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommandValidator.cs create mode 100644 backend/src/PnvPanel.Application/Support/GetAttachment/GetTicketAttachmentQuery.cs create mode 100644 backend/src/PnvPanel.Application/Support/GetAttachment/GetTicketAttachmentQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Support/GetTicket/GetTicketQuery.cs create mode 100644 backend/src/PnvPanel.Application/Support/GetTicket/GetTicketQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Support/ListMyTickets/ListMyTicketsQuery.cs create mode 100644 backend/src/PnvPanel.Application/Support/ListMyTickets/ListMyTicketsQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQuery.cs create mode 100644 backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQueryHandler.cs create mode 100644 backend/src/PnvPanel.Application/Support/Reopen/ReopenTicketCommand.cs create mode 100644 backend/src/PnvPanel.Application/Support/Reopen/ReopenTicketCommandHandler.cs create mode 100644 backend/src/PnvPanel.Application/Support/SupportErrors.cs create mode 100644 backend/src/PnvPanel.Application/Support/TicketAttachmentContent.cs create mode 100644 backend/src/PnvPanel.Application/Support/TicketAttachmentDto.cs create mode 100644 backend/src/PnvPanel.Application/Support/TicketAttachmentPersistence.cs create mode 100644 backend/src/PnvPanel.Application/Support/TicketAttachmentUpload.cs create mode 100644 backend/src/PnvPanel.Application/Support/TicketAttachmentValidation.cs create mode 100644 backend/src/PnvPanel.Application/Support/TicketAuthorization.cs create mode 100644 backend/src/PnvPanel.Application/Support/TicketCommentDto.cs create mode 100644 backend/src/PnvPanel.Application/Support/TicketDetailDto.cs create mode 100644 backend/src/PnvPanel.Application/Support/TicketMapping.cs create mode 100644 backend/src/PnvPanel.Application/Support/TicketSummaryDto.cs create mode 100644 backend/src/PnvPanel.Domain/Support/SupportTicket.cs create mode 100644 backend/src/PnvPanel.Domain/Support/TicketAttachment.cs create mode 100644 backend/src/PnvPanel.Domain/Support/TicketComment.cs create mode 100644 backend/src/PnvPanel.Domain/Support/TicketStatus.cs create mode 100644 backend/src/PnvPanel.Domain/Support/TicketType.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Configurations/SupportTicketConfiguration.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TicketAttachmentConfiguration.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TicketCommentConfiguration.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714031703_AddSupportTickets.Designer.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714031703_AddSupportTickets.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Storage/DiskFileStorage.cs create mode 100644 backend/src/PnvPanel.Infrastructure/Storage/FileStorageOptions.cs create mode 100644 backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs create mode 100644 backend/tests/PnvPanel.Application.Tests/Support/AddTicketCommentCommandHandlerTests.cs create mode 100644 backend/tests/PnvPanel.Application.Tests/Support/CreateBugReportTicketCommandHandlerTests.cs create mode 100644 backend/tests/PnvPanel.Application.Tests/Support/CreateRoleRequestTicketCommandHandlerTests.cs create mode 100644 backend/tests/PnvPanel.Application.Tests/Support/ReopenTicketCommandHandlerTests.cs create mode 100644 backend/tests/PnvPanel.Domain.Tests/Support/SupportTicketTests.cs create mode 100644 frontend/src/features/admin/support/AdminTicketDetailDialog.tsx create mode 100644 frontend/src/features/admin/support/api.ts create mode 100644 frontend/src/features/support/CreateBugReportDialog.tsx create mode 100644 frontend/src/features/support/CreateRoleRequestDialog.tsx create mode 100644 frontend/src/features/support/SupportTicketList.tsx create mode 100644 frontend/src/features/support/TicketAttachmentImage.tsx create mode 100644 frontend/src/features/support/TicketDetailDialog.tsx create mode 100644 frontend/src/features/support/TicketStatusBadge.tsx create mode 100644 frontend/src/features/support/api.ts create mode 100644 frontend/src/routes/admin/support.tsx create mode 100644 frontend/src/routes/support.tsx diff --git a/.env.example b/.env.example index 0a5dfd8..1bb1d7c 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,11 @@ Jwt__RefreshTokenDays=30 # Путь к тому с key-ring (должен быть примонтирован и переживать перезапуск контейнера) DataProtection__KeyRingPath=/app/keys +# ── Файловое хранилище (вложения тикетов поддержки) ───────────────────────── +# Путь на диске для скриншотов к тикетам — должен быть на постоянном томе (см. docker-compose.yml, +# FileStorage__RootPath там уже задан явно под volume 'ticket_uploads', эту строку трогать не нужно). +# FileStorage__RootPath=/app/uploads + # ── Сид администратора (создаётся при первом старте, если не существует) ─── # Логин в систему — по username. Email в системе не используется. AdminSeed__Username=admin diff --git a/CLAUDE.md b/CLAUDE.md index 6db2de0..621f26b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,6 +81,11 @@ одними командами. - **Инбаунды по ролям** (`Inbound.AllowedRoles`, M:N): создание конфига проверяет активацию + квоту роли (в транзакции — гонки параллельных созданий) + `AllowedRoles` + включённость ноды. +- **Поддержка** (`SupportTicket`, доступна только активированным): баг-репорт/предложение (свободная + форма + вложения-картинки, диск-хранилище `IFileStorage`) либо заявка на роль (существующая роль, + кроме `admin`, либо параметры новой). `Open → Resolved → [Reopen]`, `Closed` — финал без возврата. + Одобрение заявки на роль создаёт/назначает роль автоматически; полностью решается и в Telegram + (инлайн-кнопки), баг-репорты — только уведомление-ссылка на сайт. - **Блокировка** (`AppUser.IsBlocked`): вход запрещён + все конфиги `Disabled` в 3x-ui; в `AuditLog`. - **Удаление пользователя** — свой аккаунт (`DELETE /api/auth/me`) или админом (`DELETE /api/admin/users/{id}`, себя удалить нельзя): отзыв всех конфигов в 3x-ui, затем `AppUser`; diff --git a/backend/src/PnvPanel.Api/Endpoints/AdminSupportEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/AdminSupportEndpoints.cs new file mode 100644 index 0000000..ae92934 --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/AdminSupportEndpoints.cs @@ -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>(); + admin.MapGet("/tickets/{id:guid}", GetTicket).Produces(); + admin.MapPost("/tickets/{id:guid}/comments", AddComment).DisableAntiforgery().Produces(); + 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 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 GetTicket(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new GetTicketAdminQuery(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task 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 Resolve(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ResolveTicketCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task Close(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new CloseTicketCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task ApproveRoleRequest(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ApproveRoleRequestCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task 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); diff --git a/backend/src/PnvPanel.Api/Endpoints/SupportEndpoints.cs b/backend/src/PnvPanel.Api/Endpoints/SupportEndpoints.cs new file mode 100644 index 0000000..1095424 --- /dev/null +++ b/backend/src/PnvPanel.Api/Endpoints/SupportEndpoints.cs @@ -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>(); + group.MapPost("/tickets/bug-reports", CreateBugReport).DisableAntiforgery().Produces(); + group.MapPost("/tickets/role-requests", CreateRoleRequest).Produces(); + group.MapGet("/tickets", ListMyTickets).Produces>(); + group.MapGet("/tickets/{id:guid}", GetTicket).Produces(); + group.MapPost("/tickets/{id:guid}/comments", AddComment).DisableAntiforgery().Produces(); + group.MapPost("/tickets/{id:guid}/reopen", Reopen).Produces(StatusCodes.Status204NoContent); + group.MapGet("/attachments/{id:guid}", GetAttachment); + + return app; + } + + private static async Task ListSelectableRoles(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ListSelectableRolesQuery(), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task 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 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 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 GetTicket(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new GetTicketQuery(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task 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 Reopen(Guid id, ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new ReopenTicketCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task 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 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); diff --git a/backend/src/PnvPanel.Api/Hubs/SignalRRealtimeNotifier.cs b/backend/src/PnvPanel.Api/Hubs/SignalRRealtimeNotifier.cs index c51daaa..bbc2b8d 100644 --- a/backend/src/PnvPanel.Api/Hubs/SignalRRealtimeNotifier.cs +++ b/backend/src/PnvPanel.Api/Hubs/SignalRRealtimeNotifier.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.SignalR; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Domain.Configs; using PnvPanel.Domain.Nodes; +using PnvPanel.Domain.Support; namespace PnvPanel.Api.Hubs; @@ -56,4 +57,20 @@ internal sealed class SignalRRealtimeNotifier(IHubContext hubContext) new { id = postId, title, createdAt }, cancellationToken); } + + public Task NotifyTicketCreatedAsync(Guid ticketId, Guid userId, string userName, TicketType type, CancellationToken cancellationToken) + { + return hubContext.Clients.Group(GroupNames.Admins).SendAsync( + "ticketCreated", + new { ticketId, userId, userName, type = type.ToString() }, + cancellationToken); + } + + public Task NotifyTicketUpdatedAsync(Guid ticketId, Guid userId, CancellationToken cancellationToken) + { + return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync( + "ticketUpdated", + new { ticketId }, + cancellationToken); + } } diff --git a/backend/src/PnvPanel.Api/Program.cs b/backend/src/PnvPanel.Api/Program.cs index 7331a92..85017d8 100644 --- a/backend/src/PnvPanel.Api/Program.cs +++ b/backend/src/PnvPanel.Api/Program.cs @@ -156,6 +156,8 @@ app.MapAdminUserEndpoints(); app.MapAdminStatsEndpoints(); app.MapAdminAppEndpoints(); app.MapAdminNewsEndpoints(); +app.MapSupportEndpoints(); +app.MapAdminSupportEndpoints(); app.MapTelegramEndpoints(); app.MapHub("/hubs/panel"); diff --git a/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs b/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs index 8ddc70d..8a7dbe4 100644 --- a/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs +++ b/backend/src/PnvPanel.Api/Telegram/PnvBotUpdateHandler.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Options; using PnvPanel.Application.Admin.Activation; +using PnvPanel.Application.Admin.Support; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Configs.GetConfigLink; @@ -222,6 +223,29 @@ public sealed class PnvBotUpdateHandler( break; } + case "rrq": + { + if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken)) + { + await botClient.AnswerCallbackQuery(callback.Id, "Недостаточно прав.", cancellationToken: cancellationToken); + return; + } + + var result = parts[1] == "approve" + ? await sender.Send(new ApproveRoleRequestCommand(requestId), cancellationToken) + : await sender.Send(new RejectRoleRequestCommand(requestId, Reason: null), cancellationToken); + + await botClient.AnswerCallbackQuery(callback.Id, result.IsSuccess ? "Готово" : result.Error.Message, cancellationToken: cancellationToken); + if (result.IsSuccess && callback.Message is not null) + { + var statusText = parts[1] == "approve" ? "✅ Заявка одобрена, роль выдана." : "❌ Заявка отклонена."; + var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}"; + await botClient.EditMessageText( + chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html, cancellationToken: cancellationToken); + } + + break; + } case "cfg": { if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken)) diff --git a/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs b/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs index a33b1cb..4d51d97 100644 --- a/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs +++ b/backend/src/PnvPanel.Api/Telegram/TelegramNotifier.cs @@ -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 = $"🐞 Новый тикет (баг/предложение) от {Escape(userName)}\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 = $"🆕 Заявка на роль от {Escape(userName)}\n{Escape(roleDescription)}\nОбоснование: {Escape(justification)}"; + + var keyboard = new InlineKeyboardMarkup(new[] + { + InlineKeyboardButton.WithCallbackData("✅ Одобрить", $"rrq:approve:{ticketId}"), + InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"rrq:reject:{ticketId}"), + }); + + foreach (var adminId in options.Value.ParseAdminTelegramUserIds()) + { + try + { + await botClient.SendMessage( + adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken); + } + catch + { + // Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду. + } + } + } + public async Task NotifyUserAsync(Guid userId, string message, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(options.Value.BotToken)) diff --git a/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommand.cs b/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommand.cs new file mode 100644 index 0000000..561ec72 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommand.cs @@ -0,0 +1,8 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Admin.Support; + +/// Одобрение — при новой роли сначала создаёт её (IRoleService.CreateRoleAsync), затем в +/// любом случае назначает пользователю (ChangeUserRoleAsync) и переводит тикет в Resolved. +public sealed record ApproveRoleRequestCommand(Guid TicketId) : ICommand; diff --git a/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs new file mode 100644 index 0000000..e410156 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Support/ApproveRoleRequestCommandHandler.cs @@ -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 +{ + public async Task 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(); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Support/CloseTicketCommand.cs b/backend/src/PnvPanel.Application/Admin/Support/CloseTicketCommand.cs new file mode 100644 index 0000000..8f46d18 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Support/CloseTicketCommand.cs @@ -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; diff --git a/backend/src/PnvPanel.Application/Admin/Support/CloseTicketCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/CloseTicketCommandHandler.cs new file mode 100644 index 0000000..709fee5 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Support/CloseTicketCommandHandler.cs @@ -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 +{ + public async Task 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(); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Support/GetTicketAdminQuery.cs b/backend/src/PnvPanel.Application/Admin/Support/GetTicketAdminQuery.cs new file mode 100644 index 0000000..17f24d4 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Support/GetTicketAdminQuery.cs @@ -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>; diff --git a/backend/src/PnvPanel.Application/Admin/Support/GetTicketAdminQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/GetTicketAdminQueryHandler.cs new file mode 100644 index 0000000..6877212 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Support/GetTicketAdminQueryHandler.cs @@ -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> +{ + public async Task> 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(SupportErrors.NotFound); + + var dto = await TicketMapping.ToDetailDtoAsync(dbContext, identityService, roleService, ticket, cancellationToken); + return Result.Success(dto); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Support/ListAllTicketsQuery.cs b/backend/src/PnvPanel.Application/Admin/Support/ListAllTicketsQuery.cs new file mode 100644 index 0000000..b0cd9be --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Support/ListAllTicketsQuery.cs @@ -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>>; diff --git a/backend/src/PnvPanel.Application/Admin/Support/ListAllTicketsQueryHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/ListAllTicketsQueryHandler.cs new file mode 100644 index 0000000..b0a9a38 --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Support/ListAllTicketsQueryHandler.cs @@ -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>> +{ + public async Task>> 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(items, page1.Total, page1.Page, page1.PageSize)); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Support/RejectRoleRequestCommand.cs b/backend/src/PnvPanel.Application/Admin/Support/RejectRoleRequestCommand.cs new file mode 100644 index 0000000..64bb9fc --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Support/RejectRoleRequestCommand.cs @@ -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; diff --git a/backend/src/PnvPanel.Application/Admin/Support/RejectRoleRequestCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/RejectRoleRequestCommandHandler.cs new file mode 100644 index 0000000..984acaa --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Support/RejectRoleRequestCommandHandler.cs @@ -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 +{ + public async Task 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(); + } +} diff --git a/backend/src/PnvPanel.Application/Admin/Support/ResolveTicketCommand.cs b/backend/src/PnvPanel.Application/Admin/Support/ResolveTicketCommand.cs new file mode 100644 index 0000000..cca33dd --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Support/ResolveTicketCommand.cs @@ -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; diff --git a/backend/src/PnvPanel.Application/Admin/Support/ResolveTicketCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Support/ResolveTicketCommandHandler.cs new file mode 100644 index 0000000..e50d65a --- /dev/null +++ b/backend/src/PnvPanel.Application/Admin/Support/ResolveTicketCommandHandler.cs @@ -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 +{ + public async Task 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(); + } +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs index b6a5f19..cae7f7d 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IAppDbContext.cs @@ -7,6 +7,7 @@ using PnvPanel.Domain.Configs; using PnvPanel.Domain.Inbounds; using PnvPanel.Domain.News; using PnvPanel.Domain.Nodes; +using PnvPanel.Domain.Support; using PnvPanel.Domain.Telegram; namespace PnvPanel.Application.Common.Interfaces; @@ -33,6 +34,12 @@ public interface IAppDbContext DbSet NewsPosts { get; } + DbSet SupportTickets { get; } + + DbSet TicketComments { get; } + + DbSet TicketAttachments { get; } + /// Нужен для advisory-lock при проверке квоты конфигов (см. CreateVpnConfigCommandHandler). DatabaseFacade Database { get; } diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IFileStorage.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IFileStorage.cs new file mode 100644 index 0000000..5c84304 --- /dev/null +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IFileStorage.cs @@ -0,0 +1,15 @@ +namespace PnvPanel.Application.Common.Interfaces; + +/// +/// Хранилище бинарных вложений (скриншоты к тикетам поддержки) — физический диск в контейнере +/// (см. DiskFileStorage), volume в docker-compose. Реализация сама генерирует непрозрачное имя файла +/// на диске (не доверяет пользовательскому имени) и возвращает его — это же имя передаётся обратно +/// в OpenReadAsync, вызывающая сторона его не парсит и не строит из него пути. +/// +public interface IFileStorage +{ + Task SaveAsync(Stream content, CancellationToken cancellationToken); + + /// Null, если файла с таким именем нет на диске (например, удалён вручную). + Task OpenReadAsync(string storedFileName, CancellationToken cancellationToken); +} diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/IRealtimeNotifier.cs b/backend/src/PnvPanel.Application/Common/Interfaces/IRealtimeNotifier.cs index 172efda..abcb868 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/IRealtimeNotifier.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/IRealtimeNotifier.cs @@ -1,5 +1,6 @@ using PnvPanel.Domain.Configs; using PnvPanel.Domain.Nodes; +using PnvPanel.Domain.Support; namespace PnvPanel.Application.Common.Interfaces; @@ -24,4 +25,9 @@ public interface IRealtimeNotifier /// Единственное широковещательное событие (всем подключенным клиентам), а не по группе. Task NotifyNewsPublishedAsync(Guid postId, string title, DateTimeOffset createdAt, CancellationToken cancellationToken); + + Task NotifyTicketCreatedAsync(Guid ticketId, Guid userId, string userName, TicketType type, CancellationToken cancellationToken); + + /// Новый комментарий или смена статуса — пушится автору тикета (не всем участникам треда). + Task NotifyTicketUpdatedAsync(Guid ticketId, Guid userId, CancellationToken cancellationToken); } diff --git a/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs b/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs index e582420..091c92d 100644 --- a/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs +++ b/backend/src/PnvPanel.Application/Common/Interfaces/ITelegramNotifier.cs @@ -12,4 +12,12 @@ public interface ITelegramNotifier /// Личное сообщение пользователю, если у него привязан Telegram (иначе no-op). Task NotifyUserAsync(Guid userId, string message, CancellationToken cancellationToken); + + /// Баг-репорт/предложение — только кнопка-ссылка на сайт (переписка и картинки — там), + /// без инлайн-действий. + Task NotifyAdminsBugReportCreatedAsync(Guid ticketId, string userName, string message, CancellationToken cancellationToken); + + /// Заявка на роль — инлайн-кнопки «Одобрить/Отклонить», решается полностью в Telegram. + Task NotifyAdminsRoleRequestCreatedAsync( + Guid ticketId, string userName, string roleDescription, string justification, CancellationToken cancellationToken); } diff --git a/backend/src/PnvPanel.Application/Support/AddComment/AddTicketCommentCommand.cs b/backend/src/PnvPanel.Application/Support/AddComment/AddTicketCommentCommand.cs new file mode 100644 index 0000000..51d78f9 --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/AddComment/AddTicketCommentCommand.cs @@ -0,0 +1,9 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Support.AddComment; + +/// Общая команда для реплики владельца тикета и админа — используется и с +/// /api/support/tickets/{id}/comments, и с /api/admin/support/tickets/{id}/comments. +public sealed record AddTicketCommentCommand(Guid TicketId, string Body, IReadOnlyList Attachments) + : ICommand>, IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Support/AddComment/AddTicketCommentCommandHandler.cs b/backend/src/PnvPanel.Application/Support/AddComment/AddTicketCommentCommandHandler.cs new file mode 100644 index 0000000..2c39d56 --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/AddComment/AddTicketCommentCommandHandler.cs @@ -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> +{ + public async Task> Handle(AddTicketCommentCommand 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, cancellationToken); + if (ticket is null) + return Result.Failure(SupportErrors.NotFound); + + var profile = await identityService.GetProfileAsync(userId, cancellationToken); + if (profile is null) + return Result.Failure(AuthErrors.Unauthorized); + + // Не свой тикет и не админ — как будто тикета не существует (не палим чужие тикеты Forbidden'ом). + if (ticket.UserId != userId && !TicketAuthorization.IsAdmin(profile)) + return Result.Failure(SupportErrors.NotFound); + + if (ticket.Status == TicketStatus.Closed) + return Result.Failure(SupportErrors.TicketClosed); + + if (TicketAttachmentValidation.Validate(command.Attachments) is { } validationError) + return Result.Failure(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); + } +} diff --git a/backend/src/PnvPanel.Application/Support/AddComment/AddTicketCommentCommandValidator.cs b/backend/src/PnvPanel.Application/Support/AddComment/AddTicketCommentCommandValidator.cs new file mode 100644 index 0000000..18a13b5 --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/AddComment/AddTicketCommentCommandValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace PnvPanel.Application.Support.AddComment; + +public sealed class AddTicketCommentCommandValidator : AbstractValidator +{ + public AddTicketCommentCommandValidator() + { + RuleFor(x => x.TicketId).NotEmpty(); + RuleFor(x => x.Body).NotEmpty().MaximumLength(4000); + } +} diff --git a/backend/src/PnvPanel.Application/Support/CreateBugReport/CreateBugReportTicketCommand.cs b/backend/src/PnvPanel.Application/Support/CreateBugReport/CreateBugReportTicketCommand.cs new file mode 100644 index 0000000..17b7350 --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/CreateBugReport/CreateBugReportTicketCommand.cs @@ -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 Attachments) + : ICommand>, IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Support/CreateBugReport/CreateBugReportTicketCommandHandler.cs b/backend/src/PnvPanel.Application/Support/CreateBugReport/CreateBugReportTicketCommandHandler.cs new file mode 100644 index 0000000..ca3076b --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/CreateBugReport/CreateBugReportTicketCommandHandler.cs @@ -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> +{ + public async Task> Handle(CreateBugReportTicketCommand command, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(AuthErrors.Unauthorized); + + if (TicketAttachmentValidation.Validate(command.Attachments) is { } validationError) + return Result.Failure(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); + } +} diff --git a/backend/src/PnvPanel.Application/Support/CreateBugReport/CreateBugReportTicketCommandValidator.cs b/backend/src/PnvPanel.Application/Support/CreateBugReport/CreateBugReportTicketCommandValidator.cs new file mode 100644 index 0000000..0235bc1 --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/CreateBugReport/CreateBugReportTicketCommandValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace PnvPanel.Application.Support.CreateBugReport; + +public sealed class CreateBugReportTicketCommandValidator : AbstractValidator +{ + public CreateBugReportTicketCommandValidator() + { + RuleFor(x => x.Message).NotEmpty().MaximumLength(4000); + } +} diff --git a/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommand.cs b/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommand.cs new file mode 100644 index 0000000..954c9c7 --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommand.cs @@ -0,0 +1,10 @@ +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Support.CreateRoleRequest; + +/// Ровно один из вариантов: ExistingRoleId, либо NewRoleName+NewRoleMaxConfigs+NewRoleMaxIpLimit +/// (проверяется валидатором). +public sealed record CreateRoleRequestTicketCommand( + Guid? ExistingRoleId, string? NewRoleName, int? NewRoleMaxConfigs, int? NewRoleMaxIpLimit, string Justification) + : ICommand>, IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommandHandler.cs b/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommandHandler.cs new file mode 100644 index 0000000..e99a46b --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommandHandler.cs @@ -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> +{ + // Совпадает со значением Infrastructure.Identity.RoleNames.Admin — Application не может ссылаться + // на Infrastructure (направление зависимостей), поэтому системное имя роли продублировано здесь. + private const string AdminRoleName = "admin"; + + public async Task> Handle(CreateRoleRequestTicketCommand command, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(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(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(SupportErrors.RoleNotFound); + + if (role.Name.Equals(AdminRoleName, StringComparison.OrdinalIgnoreCase)) + return Result.Failure(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); + } +} diff --git a/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommandValidator.cs b/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommandValidator.cs new file mode 100644 index 0000000..f097b92 --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/CreateRoleRequest/CreateRoleRequestTicketCommandValidator.cs @@ -0,0 +1,31 @@ +using FluentValidation; + +namespace PnvPanel.Application.Support.CreateRoleRequest; + +public sealed class CreateRoleRequestTicketCommandValidator : AbstractValidator +{ + 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; + } +} diff --git a/backend/src/PnvPanel.Application/Support/GetAttachment/GetTicketAttachmentQuery.cs b/backend/src/PnvPanel.Application/Support/GetAttachment/GetTicketAttachmentQuery.cs new file mode 100644 index 0000000..36c716f --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/GetAttachment/GetTicketAttachmentQuery.cs @@ -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>, IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Support/GetAttachment/GetTicketAttachmentQueryHandler.cs b/backend/src/PnvPanel.Application/Support/GetAttachment/GetTicketAttachmentQueryHandler.cs new file mode 100644 index 0000000..94d5c9b --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/GetAttachment/GetTicketAttachmentQueryHandler.cs @@ -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> +{ + public async Task> Handle(GetTicketAttachmentQuery query, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(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(SupportErrors.AttachmentNotFound); + + var profile = await identityService.GetProfileAsync(userId, cancellationToken); + if (info.TicketUserId != userId && !TicketAuthorization.IsAdmin(profile)) + return Result.Failure(SupportErrors.AttachmentNotFound); + + var stream = await fileStorage.OpenReadAsync(info.Attachment.StoredFileName, cancellationToken); + if (stream is null) + return Result.Failure(SupportErrors.AttachmentNotFound); + + return Result.Success(new TicketAttachmentContent(stream, info.Attachment.ContentType, info.Attachment.FileName)); + } +} diff --git a/backend/src/PnvPanel.Application/Support/GetTicket/GetTicketQuery.cs b/backend/src/PnvPanel.Application/Support/GetTicket/GetTicketQuery.cs new file mode 100644 index 0000000..25d85f6 --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/GetTicket/GetTicketQuery.cs @@ -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>, IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Support/GetTicket/GetTicketQueryHandler.cs b/backend/src/PnvPanel.Application/Support/GetTicket/GetTicketQueryHandler.cs new file mode 100644 index 0000000..27e2f0f --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/GetTicket/GetTicketQueryHandler.cs @@ -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> +{ + public async Task> Handle(GetTicketQuery query, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Result.Failure(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(SupportErrors.NotFound); + + var dto = await TicketMapping.ToDetailDtoAsync(dbContext, identityService, roleService, ticket, cancellationToken); + return Result.Success(dto); + } +} diff --git a/backend/src/PnvPanel.Application/Support/ListMyTickets/ListMyTicketsQuery.cs b/backend/src/PnvPanel.Application/Support/ListMyTickets/ListMyTicketsQuery.cs new file mode 100644 index 0000000..6fbb253 --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/ListMyTickets/ListMyTicketsQuery.cs @@ -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>>, IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Support/ListMyTickets/ListMyTicketsQueryHandler.cs b/backend/src/PnvPanel.Application/Support/ListMyTickets/ListMyTicketsQueryHandler.cs new file mode 100644 index 0000000..1e822f6 --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/ListMyTickets/ListMyTicketsQueryHandler.cs @@ -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>> +{ + public async Task>> Handle(ListMyTicketsQuery query, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return Result.Failure>(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(items, page1.Total, page1.Page, page1.PageSize)); + } +} diff --git a/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQuery.cs b/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQuery.cs new file mode 100644 index 0000000..421ca18 --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQuery.cs @@ -0,0 +1,9 @@ +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Messaging; +using PnvPanel.Application.Common.Models; + +namespace PnvPanel.Application.Support.ListSelectableRoles; + +/// Список ролей для выбора в заявке на роль (без admin) — в отличие от ListRolesQuery +/// (Admin/Roles), доступен любому активированному пользователю. +public sealed record ListSelectableRolesQuery : IQuery>>, IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQueryHandler.cs b/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQueryHandler.cs new file mode 100644 index 0000000..2a572c6 --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/ListSelectableRoles/ListSelectableRolesQueryHandler.cs @@ -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>> +{ + // Совпадает со значением Infrastructure.Identity.RoleNames.Admin — см. пояснение в + // CreateRoleRequestTicketCommandHandler (Application не может ссылаться на Infrastructure). + private const string AdminRoleName = "admin"; + + public async Task>> 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>(selectable); + } +} diff --git a/backend/src/PnvPanel.Application/Support/Reopen/ReopenTicketCommand.cs b/backend/src/PnvPanel.Application/Support/Reopen/ReopenTicketCommand.cs new file mode 100644 index 0000000..7b6fb9f --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/Reopen/ReopenTicketCommand.cs @@ -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, IRequiresActivation; diff --git a/backend/src/PnvPanel.Application/Support/Reopen/ReopenTicketCommandHandler.cs b/backend/src/PnvPanel.Application/Support/Reopen/ReopenTicketCommandHandler.cs new file mode 100644 index 0000000..754949c --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/Reopen/ReopenTicketCommandHandler.cs @@ -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 +{ + public async Task 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(); + } +} diff --git a/backend/src/PnvPanel.Application/Support/SupportErrors.cs b/backend/src/PnvPanel.Application/Support/SupportErrors.cs new file mode 100644 index 0000000..a2f28ef --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/SupportErrors.cs @@ -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)."); +} diff --git a/backend/src/PnvPanel.Application/Support/TicketAttachmentContent.cs b/backend/src/PnvPanel.Application/Support/TicketAttachmentContent.cs new file mode 100644 index 0000000..f130eb4 --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/TicketAttachmentContent.cs @@ -0,0 +1,4 @@ +namespace PnvPanel.Application.Support; + +/// Результат отдачи вложения — Api-слой стримит Content с заголовками из ContentType/FileName. +public sealed record TicketAttachmentContent(Stream Content, string ContentType, string FileName); diff --git a/backend/src/PnvPanel.Application/Support/TicketAttachmentDto.cs b/backend/src/PnvPanel.Application/Support/TicketAttachmentDto.cs new file mode 100644 index 0000000..be1ba34 --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/TicketAttachmentDto.cs @@ -0,0 +1,3 @@ +namespace PnvPanel.Application.Support; + +public sealed record TicketAttachmentDto(Guid Id, string FileName, string ContentType, long SizeBytes); diff --git a/backend/src/PnvPanel.Application/Support/TicketAttachmentPersistence.cs b/backend/src/PnvPanel.Application/Support/TicketAttachmentPersistence.cs new file mode 100644 index 0000000..085fe6b --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/TicketAttachmentPersistence.cs @@ -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> SaveAllAsync( + IFileStorage fileStorage, Guid commentId, IReadOnlyList uploads, CancellationToken cancellationToken) + { + var attachments = new List(); + 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; + } +} diff --git a/backend/src/PnvPanel.Application/Support/TicketAttachmentUpload.cs b/backend/src/PnvPanel.Application/Support/TicketAttachmentUpload.cs new file mode 100644 index 0000000..60e35b0 --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/TicketAttachmentUpload.cs @@ -0,0 +1,5 @@ +namespace PnvPanel.Application.Support; + +/// Вложение на входе команды — Api-слой парсит multipart и передаёт сюда открытый поток; +/// Application не знает про HTTP/IFormFile. +public sealed record TicketAttachmentUpload(Stream Content, string FileName, string ContentType, long SizeBytes); diff --git a/backend/src/PnvPanel.Application/Support/TicketAttachmentValidation.cs b/backend/src/PnvPanel.Application/Support/TicketAttachmentValidation.cs new file mode 100644 index 0000000..144cace --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/TicketAttachmentValidation.cs @@ -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 AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase) + { + "image/jpeg", "image/png", "image/webp", "image/gif", + }; + + public static Error? Validate(IReadOnlyList 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; + } +} diff --git a/backend/src/PnvPanel.Application/Support/TicketAuthorization.cs b/backend/src/PnvPanel.Application/Support/TicketAuthorization.cs new file mode 100644 index 0000000..14ac774 --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/TicketAuthorization.cs @@ -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); +} diff --git a/backend/src/PnvPanel.Application/Support/TicketCommentDto.cs b/backend/src/PnvPanel.Application/Support/TicketCommentDto.cs new file mode 100644 index 0000000..0d28b6f --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/TicketCommentDto.cs @@ -0,0 +1,5 @@ +namespace PnvPanel.Application.Support; + +public sealed record TicketCommentDto( + Guid Id, Guid AuthorId, string AuthorName, string Body, DateTimeOffset CreatedAt, + IReadOnlyList Attachments); diff --git a/backend/src/PnvPanel.Application/Support/TicketDetailDto.cs b/backend/src/PnvPanel.Application/Support/TicketDetailDto.cs new file mode 100644 index 0000000..59bf7af --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/TicketDetailDto.cs @@ -0,0 +1,13 @@ +using PnvPanel.Domain.Support; + +namespace PnvPanel.Application.Support; + +/// +/// RequestedRoleName — имя существующей роли, если RequestedRoleId задан (резолвится хендлером, +/// на SupportTicket хранится только Id). Для новой роли имя лежит прямо в ProposedRoleName. +/// +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 Comments); diff --git a/backend/src/PnvPanel.Application/Support/TicketMapping.cs b/backend/src/PnvPanel.Application/Support/TicketMapping.cs new file mode 100644 index 0000000..3b83859 --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/TicketMapping.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Domain.Support; + +namespace PnvPanel.Application.Support; + +/// +/// Сборка DTO из уже сохранённых тикетов (реальные запросы к БД — только для Get/List; при создании +/// тикета/комментария DTO собирается вручную из только что созданных объектов в памяти, см. +/// соответствующие хендлеры — свежедобавленные строки не видны через новый AsNoTracking-запрос до +/// SaveChangesAsync из UnitOfWorkBehavior). +/// +internal static class TicketMapping +{ + public static async Task 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> ToSummaryDtosAsync( + IAppDbContext dbContext, IIdentityService identityService, IReadOnlyList 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(); + } +} diff --git a/backend/src/PnvPanel.Application/Support/TicketSummaryDto.cs b/backend/src/PnvPanel.Application/Support/TicketSummaryDto.cs new file mode 100644 index 0000000..fce8ce1 --- /dev/null +++ b/backend/src/PnvPanel.Application/Support/TicketSummaryDto.cs @@ -0,0 +1,9 @@ +using PnvPanel.Domain.Support; + +namespace PnvPanel.Application.Support; + +/// Строка списка тикетов — свой (ListMyTicketsQuery) и админский (ListAllTicketsQuery) списки +/// используют один и тот же DTO (владелец видит только свои UserId/UserName — не секрет для себя). +public sealed record TicketSummaryDto( + Guid Id, Guid UserId, string UserName, TicketType Type, TicketStatus Status, + DateTimeOffset CreatedAt, DateTimeOffset LastActivityAt); diff --git a/backend/src/PnvPanel.Domain/Support/SupportTicket.cs b/backend/src/PnvPanel.Domain/Support/SupportTicket.cs new file mode 100644 index 0000000..3916cc3 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Support/SupportTicket.cs @@ -0,0 +1,94 @@ +using PnvPanel.Domain.Common; +using PnvPanel.Domain.Exceptions; + +namespace PnvPanel.Domain.Support; + +/// +/// Обращение в поддержку: баг/предложение (свободная форма) либо заявка на роль (существующая роль +/// или параметры новой). Текст обращения и переписка — в TicketComment, отдельной таблицей (не +/// навигационная коллекция — см. конвенцию проекта на плоских сущностях, ср. TrafficSample/VpnConfig). +/// Для RoleRequest заполнен либо RequestedRoleId, либо Proposed* — гарантируется отдельными фабриками, +/// а не runtime-проверкой одного универсального конструктора. +/// +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, + }; + } + + /// Решено (в т.ч. заявка на роль одобрена — роль выдаётся оркестрацией на уровне Application). + public void Resolve() + { + if (Status != TicketStatus.Open) + throw new DomainException($"Нельзя перевести в Resolved тикет в статусе {Status}."); + + Status = TicketStatus.Resolved; + } + + /// Финальное состояние — обратного пути нет (в т.ч. заявка на роль отклонена). + public void Close() + { + if (Status == TicketStatus.Closed) + throw new DomainException("Тикет уже закрыт."); + + Status = TicketStatus.Closed; + } + + /// Только из Resolved — Closed финален и не переоткрывается. + public void Reopen() + { + if (Status != TicketStatus.Resolved) + throw new DomainException("Переоткрыть можно только решённый тикет."); + + Status = TicketStatus.Open; + } +} diff --git a/backend/src/PnvPanel.Domain/Support/TicketAttachment.cs b/backend/src/PnvPanel.Domain/Support/TicketAttachment.cs new file mode 100644 index 0000000..cc925c9 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Support/TicketAttachment.cs @@ -0,0 +1,36 @@ +using PnvPanel.Domain.Common; + +namespace PnvPanel.Domain.Support; + +/// +/// Вложение (изображение) к сообщению тикета. StoredFileName — серверное имя на диске (GUID-based), +/// FileName — оригинальное имя только для отображения (не участвует в построении пути — не доверяем +/// пользовательскому вводу для файловой системы). +/// +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, + }; + } +} diff --git a/backend/src/PnvPanel.Domain/Support/TicketComment.cs b/backend/src/PnvPanel.Domain/Support/TicketComment.cs new file mode 100644 index 0000000..7e40939 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Support/TicketComment.cs @@ -0,0 +1,32 @@ +using PnvPanel.Domain.Common; + +namespace PnvPanel.Domain.Support; + +/// +/// Сообщение в переписке по тикету — первое сообщение при создании тикета одновременно служит +/// описанием бага/обоснованием заявки на роль (отдельного поля под это на SupportTicket нет). +/// Вложения — TicketAttachment, отдельной таблицей по CommentId. +/// +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, + }; + } +} diff --git a/backend/src/PnvPanel.Domain/Support/TicketStatus.cs b/backend/src/PnvPanel.Domain/Support/TicketStatus.cs new file mode 100644 index 0000000..5cf88b2 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Support/TicketStatus.cs @@ -0,0 +1,8 @@ +namespace PnvPanel.Domain.Support; + +public enum TicketStatus +{ + Open, + Resolved, + Closed, +} diff --git a/backend/src/PnvPanel.Domain/Support/TicketType.cs b/backend/src/PnvPanel.Domain/Support/TicketType.cs new file mode 100644 index 0000000..2b4e9a3 --- /dev/null +++ b/backend/src/PnvPanel.Domain/Support/TicketType.cs @@ -0,0 +1,7 @@ +namespace PnvPanel.Domain.Support; + +public enum TicketType +{ + BugReport, + RoleRequest, +} diff --git a/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs b/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs index 6fc3bb6..a596d56 100644 --- a/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs +++ b/backend/src/PnvPanel.Infrastructure/DependencyInjection.cs @@ -11,6 +11,7 @@ using PnvPanel.Infrastructure.BackgroundJobs; using PnvPanel.Infrastructure.Identity; using PnvPanel.Infrastructure.Persistence; using PnvPanel.Infrastructure.Security; +using PnvPanel.Infrastructure.Storage; using PnvPanel.Infrastructure.Telegram; using PnvPanel.Infrastructure.Xui; using ThreeXui.ConnectionStrings; @@ -129,6 +130,9 @@ public static class DependencyInjection services.Configure(configuration.GetSection(TelegramOptions.SectionName)); + services.Configure(configuration.GetSection(FileStorageOptions.SectionName)); + services.AddSingleton(); + return services; } } diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs b/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs index 3522c52..6928178 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/AppDbContext.cs @@ -8,6 +8,7 @@ using PnvPanel.Domain.Configs; using PnvPanel.Domain.Inbounds; using PnvPanel.Domain.News; using PnvPanel.Domain.Nodes; +using PnvPanel.Domain.Support; using PnvPanel.Domain.Telegram; using PnvPanel.Infrastructure.Identity; @@ -42,6 +43,12 @@ public class AppDbContext(DbContextOptions options) public DbSet NewsPosts => Set(); + public DbSet SupportTickets => Set(); + + public DbSet TicketComments => Set(); + + public DbSet TicketAttachments => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/SupportTicketConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/SupportTicketConfiguration.cs new file mode 100644 index 0000000..83a6c83 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/SupportTicketConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("SupportTickets"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Type).HasConversion().HasMaxLength(32); + builder.Property(x => x.Status).HasConversion().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 }); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TicketAttachmentConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TicketAttachmentConfiguration.cs new file mode 100644 index 0000000..2f36eee --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TicketAttachmentConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TicketCommentConfiguration.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TicketCommentConfiguration.cs new file mode 100644 index 0000000..a4a5939 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Configurations/TicketCommentConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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 }); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714031703_AddSupportTickets.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714031703_AddSupportTickets.Designer.cs new file mode 100644 index 0000000..52640b1 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714031703_AddSupportTickets.Designer.cs @@ -0,0 +1,887 @@ +// +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 + { + /// + 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", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("ActivationRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DownloadUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OperatingSystem") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClientApps", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .HasColumnType("jsonb"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigId") + .HasColumnType("uuid"); + + b.Property("DownBytes") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ConfigId", "Timestamp"); + + b.ToTable("TrafficSamples", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InboundId") + .HasColumnType("uuid"); + + b.Property("Label") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UsedDownBytes") + .HasColumnType("bigint"); + + b.Property("UsedUpBytes") + .HasColumnType("bigint"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("AllowedRoleIds") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxClients") + .HasColumnType("integer"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProposedMaxConfigs") + .HasColumnType("integer"); + + b.Property("ProposedMaxIpLimit") + .HasColumnType("integer"); + + b.Property("ProposedRoleName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CommentId") + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TicketId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TicketId", "CreatedAt"); + + b.ToTable("TicketComments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("TelegramLinkTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("TelegramLoginRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxConfigs") + .HasColumnType("integer"); + + b.Property("MaxIpLimit") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("TelegramLinkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TelegramUserId") + .HasColumnType("bigint"); + + b.Property("TelegramUsername") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", 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", 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("NodeId") + .HasColumnType("uuid"); + + b1.Property("ProtectedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CredentialsProtectedPassword"); + + b1.Property("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 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714031703_AddSupportTickets.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714031703_AddSupportTickets.cs new file mode 100644 index 0000000..0f98ecf --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260714031703_AddSupportTickets.cs @@ -0,0 +1,105 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddSupportTickets : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SupportTickets", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Type = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + RequestedRoleId = table.Column(type: "uuid", nullable: true), + ProposedRoleName = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + ProposedMaxConfigs = table.Column(type: "integer", nullable: true), + ProposedMaxIpLimit = table.Column(type: "integer", nullable: true), + CreatedAt = table.Column(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(type: "uuid", nullable: false), + CommentId = table.Column(type: "uuid", nullable: false), + FileName = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + StoredFileName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + ContentType = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + SizeBytes = table.Column(type: "bigint", nullable: false), + CreatedAt = table.Column(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(type: "uuid", nullable: false), + TicketId = table.Column(type: "uuid", nullable: false), + AuthorId = table.Column(type: "uuid", nullable: false), + Body = table.Column(type: "character varying(4000)", maxLength: 4000, nullable: false), + CreatedAt = table.Column(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" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SupportTickets"); + + migrationBuilder.DropTable( + name: "TicketAttachments"); + + migrationBuilder.DropTable( + name: "TicketComments"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 564364c..6acad94 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -462,6 +462,117 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations b.ToTable("Nodes", (string)null); }); + modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProposedMaxConfigs") + .HasColumnType("integer"); + + b.Property("ProposedMaxIpLimit") + .HasColumnType("integer"); + + b.Property("ProposedRoleName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CommentId") + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TicketId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TicketId", "CreatedAt"); + + b.ToTable("TicketComments", (string)null); + }); + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b => { b.Property("Id") diff --git a/backend/src/PnvPanel.Infrastructure/Storage/DiskFileStorage.cs b/backend/src/PnvPanel.Infrastructure/Storage/DiskFileStorage.cs new file mode 100644 index 0000000..d7789b9 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Storage/DiskFileStorage.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Options; +using PnvPanel.Application.Common.Interfaces; + +namespace PnvPanel.Infrastructure.Storage; + +/// +/// Хранит вложения как файлы на диске под GUID-именем (не оригинальным именем пользователя — +/// исключает path traversal и коллизии). Оригинальное имя/тип — в TicketAttachment (Application/Domain), +/// это хранилище знает только про байты по непрозрачному ключу. +/// +internal sealed class DiskFileStorage(IOptions options) : IFileStorage +{ + public async Task 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 OpenReadAsync(string storedFileName, CancellationToken cancellationToken) + { + var path = Path.Combine(options.Value.RootPath, storedFileName); + if (!File.Exists(path)) + return Task.FromResult(null); + + return Task.FromResult(File.OpenRead(path)); + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Storage/FileStorageOptions.cs b/backend/src/PnvPanel.Infrastructure/Storage/FileStorageOptions.cs new file mode 100644 index 0000000..a30c587 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Storage/FileStorageOptions.cs @@ -0,0 +1,10 @@ +namespace PnvPanel.Infrastructure.Storage; + +public sealed class FileStorageOptions +{ + public const string SectionName = "FileStorage"; + + /// Каталог на диске для вложений тикетов. Должен указывать на постоянный volume — + /// иначе вложения теряются при пересоздании контейнера (см. docker-compose.yml). + public string RootPath { get; init; } = "./uploads"; +} diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs new file mode 100644 index 0000000..5408aa3 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Support/ApproveRoleRequestCommandHandlerTests.cs @@ -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(); + private readonly IRealtimeNotifier _notifier = Substitute.For(); + private readonly ITelegramNotifier _telegramNotifier = Substitute.For(); + + [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()) + .Returns(Result.Success(new RoleDto(newRoleId, "premium", 10, 5, false))); + _roleService.ChangeUserRoleAsync(userId, newRoleId, Arg.Any()).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()); + await _roleService.Received(1).ChangeUserRoleAsync(userId, newRoleId, Arg.Any()); + await _telegramNotifier.Received(1).NotifyUserAsync(userId, Arg.Any(), Arg.Any()); + } + + [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()).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(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [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); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Support/AddTicketCommentCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Support/AddTicketCommentCommandHandlerTests.cs new file mode 100644 index 0000000..89b4a98 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Support/AddTicketCommentCommandHandlerTests.cs @@ -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(); + private readonly IFileStorage _fileStorage = Substitute.For(); + private readonly IRealtimeNotifier _notifier = Substitute.For(); + + 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()).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(), Arg.Any(), Arg.Any()); + } + + [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()).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()); + } + + [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()).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()).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); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Support/CreateBugReportTicketCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Support/CreateBugReportTicketCommandHandlerTests.cs new file mode 100644 index 0000000..fb0d301 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Support/CreateBugReportTicketCommandHandlerTests.cs @@ -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(); + private readonly IRealtimeNotifier _notifier = Substitute.For(); + private readonly ITelegramNotifier _telegramNotifier = Substitute.For(); + + [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(), "alice", "Что-то сломалось", Arg.Any()); + } + + [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); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Support/CreateRoleRequestTicketCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Support/CreateRoleRequestTicketCommandHandlerTests.cs new file mode 100644 index 0000000..ce87d03 --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Support/CreateRoleRequestTicketCommandHandlerTests.cs @@ -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(); + private readonly IRealtimeNotifier _notifier = Substitute.For(); + private readonly ITelegramNotifier _telegramNotifier = Substitute.For(); + + [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()) + .Returns(new List { 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(), "alice", "premium", "нужно больше конфигов", Arg.Any()); + } + + [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()) + .Returns(new List { 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); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Support/ReopenTicketCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Support/ReopenTicketCommandHandlerTests.cs new file mode 100644 index 0000000..48524cf --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Support/ReopenTicketCommandHandlerTests.cs @@ -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); + } +} diff --git a/backend/tests/PnvPanel.Domain.Tests/Support/SupportTicketTests.cs b/backend/tests/PnvPanel.Domain.Tests/Support/SupportTicketTests.cs new file mode 100644 index 0000000..c958968 --- /dev/null +++ b/backend/tests/PnvPanel.Domain.Tests/Support/SupportTicketTests.cs @@ -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(() => 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(() => 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(() => ticket.Reopen()); + } + + [Fact] + public void Reopen_WhenClosed_Throws() + { + var ticket = SupportTicket.CreateBugReport(Guid.NewGuid()); + ticket.Close(); + + Assert.Throws(() => ticket.Reopen()); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 41837fb..ec4c159 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,10 +30,13 @@ services: ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_HTTP_PORTS: '8085' ConnectionStrings__Default: 'Host=db;Port=5432;Database=${POSTGRES_DB:-pnvpanel};Username=${POSTGRES_USER:-pnvpanel};Password=${POSTGRES_PASSWORD:-pnvpanel}' + FileStorage__RootPath: '/app/uploads' volumes: # Data Protection key-ring (шифрование секретов нод at-rest) должен пережить пересоздание # контейнера — иначе расшифровка паролей нод после рестарта станет невозможна. - dp_keys:/app/keys + # Вложения тикетов поддержки (скриншоты) — обычные файлы на диске, см. DiskFileStorage. + - ticket_uploads:/app/uploads healthcheck: test: ['CMD', 'curl', '-f', 'http://localhost:8085/health'] interval: 15s @@ -47,3 +50,4 @@ services: volumes: pgdata: dp_keys: + ticket_uploads: diff --git a/docs/api-design.md b/docs/api-design.md index 7b39e9f..a5177cc 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -122,6 +122,56 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро | GET | `/api/activation/status` | user | — | `{ isActivated, pendingRequest: { id, comment, createdAt } \| null }` | | 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` (только свои) | +| 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`. Вложения отдаются не статикой — `` не может передать +`Authorization`-заголовок, фронт качает их как `Blob` через `fetch` и рендерит `Object URL`. + +## Admin — Support + +Группа `/api/admin/support`, `RequireAuthorization(RoleNames.Admin)` (активация не проверяется — сеяный +админ активирован всегда). + +| Метод | Путь | Тело запроса | Тело ответа | +| ----- | ------------------------------------------------ | ----------------------------------- | ------------- | +| GET | `/api/admin/support/tickets` | query: `type?, status?, page, pageSize` | `PagedList` (все пользователи) | +| 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 | Метод | Путь | Роль | Тело запроса | Тело ответа | @@ -211,6 +261,8 @@ totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes }` — счи | `activationRequested` | `{ requestId, userId, userName, comment, createdAt }` | `admins` | | `userActivated` | `{ userId }` | владельцу | | `newsPublished` | `{ id, title, createdAt }` | все (broadcast) | +| `ticketCreated` | `{ ticketId, userId, userName, type }` | `admins` | +| `ticketUpdated` | `{ ticketId }` | владельцу | ### Client → Server Клиент только слушает; группировка по пользователю происходит на сервере при подключении, по @@ -224,7 +276,7 @@ totalConfigs, activeConfigs, totalUsedUpBytes, totalUsedDownBytes }` — счи | 401 | Нет/просрочен/невалиден access-токен | | 403 | Нет прав по роли, либо `Auth.NotActivated` | | 404 | Ресурс не найден | -| 409 | Конфликт домена: `Configs.QuotaExceeded`, дубликат имени пользователя при регистрации, уже есть `Pending`-запрос активации | +| 409 | Конфликт домена: `Configs.QuotaExceeded`, дубликат имени пользователя при регистрации, уже есть `Pending`-запрос активации, `Support.RoleRequestAlreadyPending`, `Support.TicketClosed` | | 422 | Прочие управляемые ошибки, не подошедшие под коды выше | | 429 | Rate limit (`/api/auth/*`, `/api/auth/telegram/*`, `/sub/{token}`) | | 500 | Необработанное исключение (перехватывается `UseExceptionHandler()`, тело без деталей) | diff --git a/docs/architecture.md b/docs/architecture.md index 638bda2..be95d67 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -68,15 +68,16 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C - **Commands / Queries** + их **Handlers** (`ICommandHandler<,>` / `IQueryHandler<,>` — свои интерфейсы), организованы по фичам (`Auth/Login/`, `Configs/Create/`, `Admin/Nodes/`, ...). - **Ports (интерфейсы)**: `IAppDbContext`, `IXuiPanelGateway`, `ICurrentUser`, `IIdentityService`, - `ISecretProtector`, `IRealtimeNotifier`, `ITelegramNotifier`, `IRoleService`. + `ISecretProtector`, `IRealtimeNotifier`, `ITelegramNotifier`, `IRoleService`, `IFileStorage` + (вложения тикетов поддержки — диск в контейнере, см. `Infrastructure/Storage/DiskFileStorage`). - **Validators**: FluentValidation на команды, где есть что проверять помимо типов (не на все — см. [backend-conventions.md](backend-conventions.md)). - **DTO**: плоские `record`, конвертация из сущностей — статический метод `FromDomain(...)` на самом DTO, без маппера (Mapster/AutoMapper). - **Pipeline behaviors** (порядок: Logging → Validation → RequireActivation → UnitOfWork): `LoggingBehavior`, `ValidationBehavior`, `RequireActivationBehavior` (403 `Auth.NotActivated` для - запросов с маркером `IRequiresActivation` — конфиги, новости, каталог приложений), `UnitOfWorkBehavior` - (транзакция + `SaveChangesAsync` на команду). Отдельного `AuthorizationBehavior` для ролей нет — + запросов с маркером `IRequiresActivation` — конфиги, новости, каталог приложений, тикеты поддержки), + `UnitOfWorkBehavior` (транзакция + `SaveChangesAsync` на команду). Отдельного `AuthorizationBehavior` для ролей нет — роль проверяется через `RequireAuthorization()`/`RequireRole(...)` на эндпоинте либо явной проверкой в начале хендлера (например, «инбаунд доступен роли пользователя»). - **Result**: явная модель успеха/ошибки (`Result`/`Result`, `Error` с `ErrorType`) вместо @@ -97,6 +98,8 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C - **Secrets**: `DataProtectionSecretProtector : ISecretProtector` (шифрование паролей нод at-rest, ASP.NET Core Data Protection, key-ring на томе `dp_keys`). - **Telegram**: `TelegramNotifier : ITelegramNotifier` — отправка DM-уведомлений через `ITelegramBotClient`. +- **Storage**: `DiskFileStorage : IFileStorage` — вложения тикетов поддержки, файлы на диске под + GUID-именем (`FileStorage:RootPath`, том `ticket_uploads` в docker-compose, как `dp_keys`). > **SignalR-пуш физически лежит в `PnvPanel.Api/Hubs/`, не в `Infrastructure`.** > `SignalRRealtimeNotifier : IRealtimeNotifier` нужен `IHubContext`, а сам `PanelHub` @@ -106,10 +109,14 @@ PnvPanel — backend на **ASP.NET Core (.NET 10)** по принципам **C ### 4. `PnvPanel.Api` (Presentation) Композиционный корень и транспорт. -- **Minimal API**-эндпоинты, сгруппированные по фичам — 12 файлов в `Endpoints/` +- **Minimal API**-эндпоинты, сгруппированные по фичам — файлы в `Endpoints/` (`AuthEndpoints`, `ActivationEndpoints`, `ConfigEndpoints`, `AppEndpoints`, `SubscriptionEndpoints`, `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()`, чтобы OpenAPI-схема полностью описывала тело ответа (нужно для `pnpm gen:api` на фронте). - **SignalR Hubs**: `PanelHub` (`Hubs/`). diff --git a/docs/domain-model.md b/docs/domain-model.md index 9a817d0..f922f75 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -22,6 +22,10 @@ VpnConfig ─*─ TrafficSample (история трафика; пишетс AuditLog (append-only журнал действий; ссылается на ActorId/TargetId) ClientApp (каталог приложений-клиентов; группируется по OperatingSystem) NewsPost (лента новостей; публикуется админом, видна всем аутентифицированным пользователям) +AppUser + └─0..*─ SupportTicket (баг-репорт/предложение либо заявка на роль) + └─1───*─ TicketComment (переписка; первое сообщение = описание/обоснование) + └─0..*─ TicketAttachment (изображения, диск-хранилище) ``` ## Сущности @@ -272,6 +276,71 @@ UI **настойчиво напоминает** привязать его (ед Переходы: `Pending → Approved/Rejected/Expired`; `Approved → Consumed` (после выпуска JWT сайту). После `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 - **NodeCredentials** (`Nodes/NodeCredentials.cs`) — `Username` + `ProtectedPassword` (шифротекст, @@ -291,6 +360,8 @@ enum TelegramLoginStatus { Pending, Approved, Rejected, Expired, Consumed } enum ActivationStatus { Pending, Approved, Rejected } enum AuditSource { Web, Telegram, System } 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`) | | `NodeHealthCheckService` (фон) | Обновляет `NodeStatus`; realtime `nodeStatusChanged` группе `admins` | | `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) и [api-design.md](api-design.md#signalr--hub-hubspanel). diff --git a/docs/tech-stack.md b/docs/tech-stack.md index a4611a1..3e851e0 100644 --- a/docs/tech-stack.md +++ b/docs/tech-stack.md @@ -96,6 +96,8 @@ | Лимит устройств (`limitIp`) | Квота роли (`AppRole.MaxIpLimit`; -1 = без лимита), применяется только к новым клиентам в 3x-ui | | Самоудаление аккаунта | Отзыв всех активных конфигов в 3x-ui + удаление `AppUser` | | Удаление пользователя админом | `DELETE /api/admin/users/{id}` — отзыв всех конфигов в 3x-ui + удаление `AppUser`; себя удалить нельзя | +| Вложения тикетов поддержки | Диск в контейнере (`IFileStorage`/`DiskFileStorage`, volume `ticket_uploads`) — не S3, GUID-имена файлов | +| Заявки на роль из бота | Одобрение/отклонение полностью в Telegram (`rrq:*`); баг-репорты — только ссылка на сайт | | Версионирование API | Без версий (`/api` без `v1`) | | Подписка (заголовки) | `Subscription-Userinfo` (used/total/expire) + `profile-update-interval` | | Тема сайта | Светлая + тёмная (+ системная); выбор в localStorage | diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index 21d2d51..728425e 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -33,6 +33,12 @@ Telegram-бот — **второй канал доставки** (presentation- Зарегистрироваться»; плюс кнопка «🌐 Сайт панели» со ссылкой на сайт, если задан `Telegram__PublicSiteUrl` (пусто — кнопки нет). Слэш-команды `/configs`/`/unlink` продолжают работать как раньше — кнопки лишь вызывают те же обработчики через callback (`menu:configs`/`menu:unlink`/`menu:back`). +8. **Админ: обработка заявок на роль поддержки** — при новой заявке (`SupportTicket.Type == + RoleRequest`) админ получает сообщение с описанием (существующая роль либо параметры новой) и + обоснованием, жмёт «✅ Одобрить / ❌ Отклонить» **прямо в Telegram, без захода на сайт** — одобрение + создаёт роль (если новая) и назначает её пользователю той же командой, что и на сайте. Баг-репорты/ + предложения — только уведомление с кнопкой-ссылкой на сайт, без инлайн-действий (переписка и + вложения удобнее там). **Не реализовано:** - QR-картинкой и агрегированная подписка в самом боте (только текстовая ссылка на конфиг по кнопке). @@ -41,6 +47,8 @@ Telegram-бот — **второй канал доставки** (presentation- - Webhook-транспорт — только long polling, конфигурации режима/URL в коде нет. - Полное самообслуживание (создание/ротация/отзыв конфигов) — бот **read-only** по конфигам (только просмотр списка и показ существующей ссылки по кнопке). +- Баг-репорты/предложения тикетов поддержки **не решаются из бота** (только уведомление-ссылка) — + ответы, вложения, resolve/close только на сайте. ## Размещение в архитектуре @@ -162,6 +170,33 @@ Telegram ──updates──► TelegramBotHostedService → PnvBotUpdateHandl `/requests` — тот же список запросов по требованию (до 10 штук, `Pending`), с теми же кнопками; для кого он доступен — та же проверка админ-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) | нет (нужно, чтобы **не** был привязан) | | «✅ Активировать»/«❌ Отклонить» | (admin) решение по конкретному запросу активации | админ по env | | `/requests` | (admin) список ожидающих запросов активации (до 10) | админ по env | +| «✅ Одобрить»/«❌ Отклонить» (`rrq:*`) | (admin) решение по заявке на роль — создаёт/назначает роль | админ по env | +| «🌐 Открыть на сайте» | Ссылка на баг-репорт на сайте (только если задан `Telegram__PublicSiteUrl`) | админ по env | Главное меню (`/start`/`/help`) — см. пункт 7 в «Возможности» выше. diff --git a/frontend/src/features/admin/support/AdminTicketDetailDialog.tsx b/frontend/src/features/admin/support/AdminTicketDetailDialog.tsx new file mode 100644 index 0000000..61b0982 --- /dev/null +++ b/frontend/src/features/admin/support/AdminTicketDetailDialog.tsx @@ -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([]) + + 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) => { + setFiles(Array.from(e.target.files ?? []).slice(0, MAX_FILES)) + } + + return ( + + + + + {data && } + {data && t(`support.type.${data.type}`)} + {data && — {data.userName}} + + + + {isLoading &&

} + + {data && ( +
+ {data.type === 'RoleRequest' && ( +
+ {data.requestedRoleName + ? t('support.requestedExistingRole', { role: data.requestedRoleName }) + : t('support.requestedNewRole', { + name: data.proposedRoleName, + configs: data.proposedMaxConfigs, + ip: data.proposedMaxIpLimit, + })} +
+ )} + +
+ {data.comments.map((comment) => ( +
+
+ {comment.authorName} + {new Date(comment.createdAt).toLocaleString()} +
+

{comment.body}

+ {comment.attachments.length > 0 && ( +
+ {comment.attachments.map((attachment) => ( + + ))} +
+ )} +
+ ))} +
+ + {data.status !== 'Closed' && ( +
+ {data.type === 'RoleRequest' && data.status === 'Open' ? ( + <> + + + + ) : ( + <> + {data.status === 'Open' && ( + + )} + + + )} +
+ )} + + {data.status !== 'Closed' && ( +
{ + e.preventDefault() + if (reply.trim()) replyMutation.mutate() + }} + > +