Implement support ticket system with role request and bug report functionalities
- Introduced a new support ticket system allowing users to submit bug reports and role requests. - Implemented endpoints for creating, updating, and managing support tickets, including file attachments. - Enhanced Telegram bot integration to handle role requests directly within the bot, enabling admins to approve or reject requests without accessing the website. - Updated database schema to include support ticket entities and their relationships. - Improved API documentation to reflect new support ticket endpoints and their usage. - Added necessary localization for support ticket features in both Russian and English.
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PnvPanel.Api.Common;
|
||||
using PnvPanel.Application.Admin.Support;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Support;
|
||||
using PnvPanel.Application.Support.AddComment;
|
||||
using PnvPanel.Infrastructure.Identity;
|
||||
|
||||
namespace PnvPanel.Api.Endpoints;
|
||||
|
||||
public static class AdminSupportEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAdminSupportEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var admin = app.MapGroup("/api/admin/support")
|
||||
.WithTags("Admin.Support")
|
||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||
|
||||
admin.MapGet("/tickets", ListTickets).Produces<PagedList<TicketSummaryDto>>();
|
||||
admin.MapGet("/tickets/{id:guid}", GetTicket).Produces<TicketDetailDto>();
|
||||
admin.MapPost("/tickets/{id:guid}/comments", AddComment).DisableAntiforgery().Produces<TicketCommentDto>();
|
||||
admin.MapPost("/tickets/{id:guid}/resolve", Resolve).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPost("/tickets/{id:guid}/close", Close).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPost("/tickets/{id:guid}/approve", ApproveRoleRequest).Produces(StatusCodes.Status204NoContent);
|
||||
admin.MapPost("/tickets/{id:guid}/reject", RejectRoleRequest).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListTickets(
|
||||
[AsParameters] ListTicketsRequest request, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = new ListAllTicketsQuery(request.Type, request.Status, request.Page, request.PageSize);
|
||||
var result = await sender.Send(query, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTicket(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new GetTicketAdminQuery(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddComment(
|
||||
Guid id, [FromForm] string body, IFormFileCollection? files, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var command = new AddTicketCommentCommand(id, body, SupportEndpoints.ToUploads(files));
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Resolve(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new ResolveTicketCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Close(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new CloseTicketCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ApproveRoleRequest(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new ApproveRoleRequestCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RejectRoleRequest(
|
||||
Guid id, RejectRoleRequestBody body, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new RejectRoleRequestCommand(id, body.Reason), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RejectRoleRequestBody(string? Reason);
|
||||
@@ -0,0 +1,111 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PnvPanel.Api.Common;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Support;
|
||||
using PnvPanel.Application.Support.AddComment;
|
||||
using PnvPanel.Application.Support.CreateBugReport;
|
||||
using PnvPanel.Application.Support.CreateRoleRequest;
|
||||
using PnvPanel.Application.Support.GetAttachment;
|
||||
using PnvPanel.Application.Support.GetTicket;
|
||||
using PnvPanel.Application.Support.ListMyTickets;
|
||||
using PnvPanel.Application.Support.ListSelectableRoles;
|
||||
using PnvPanel.Application.Support.Reopen;
|
||||
using PnvPanel.Domain.Support;
|
||||
|
||||
namespace PnvPanel.Api.Endpoints;
|
||||
|
||||
public static class SupportEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSupportEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/support").WithTags("Support").RequireAuthorization();
|
||||
|
||||
group.MapGet("/roles", ListSelectableRoles).Produces<IReadOnlyList<RoleDto>>();
|
||||
group.MapPost("/tickets/bug-reports", CreateBugReport).DisableAntiforgery().Produces<TicketDetailDto>();
|
||||
group.MapPost("/tickets/role-requests", CreateRoleRequest).Produces<TicketDetailDto>();
|
||||
group.MapGet("/tickets", ListMyTickets).Produces<PagedList<TicketSummaryDto>>();
|
||||
group.MapGet("/tickets/{id:guid}", GetTicket).Produces<TicketDetailDto>();
|
||||
group.MapPost("/tickets/{id:guid}/comments", AddComment).DisableAntiforgery().Produces<TicketCommentDto>();
|
||||
group.MapPost("/tickets/{id:guid}/reopen", Reopen).Produces(StatusCodes.Status204NoContent);
|
||||
group.MapGet("/attachments/{id:guid}", GetAttachment);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListSelectableRoles(ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new ListSelectableRolesQuery(), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateBugReport(
|
||||
[FromForm] string message, IFormFileCollection? files, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var command = new CreateBugReportTicketCommand(message, ToUploads(files));
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateRoleRequest(
|
||||
CreateRoleRequestBody body, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var command = new CreateRoleRequestTicketCommand(
|
||||
body.ExistingRoleId, body.NewRoleName, body.NewRoleMaxConfigs, body.NewRoleMaxIpLimit, body.Justification);
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListMyTickets(
|
||||
[AsParameters] ListTicketsRequest request, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = new ListMyTicketsQuery(request.Type, request.Status, request.Page, request.PageSize);
|
||||
var result = await sender.Send(query, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTicket(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new GetTicketQuery(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddComment(
|
||||
Guid id, [FromForm] string body, IFormFileCollection? files, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var command = new AddTicketCommentCommand(id, body, ToUploads(files));
|
||||
var result = await sender.Send(command, cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> Reopen(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new ReopenTicketCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAttachment(Guid id, ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new GetTicketAttachmentQuery(id), cancellationToken);
|
||||
if (!result.IsSuccess)
|
||||
return result.ToHttpResult();
|
||||
|
||||
return Results.File(result.Value.Content, result.Value.ContentType, result.Value.FileName);
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<TicketAttachmentUpload> ToUploads(IFormFileCollection? files)
|
||||
{
|
||||
if (files is null || files.Count == 0)
|
||||
return [];
|
||||
|
||||
return files
|
||||
.Select(f => new TicketAttachmentUpload(f.OpenReadStream(), f.FileName, f.ContentType, f.Length))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record CreateRoleRequestBody(
|
||||
Guid? ExistingRoleId, string? NewRoleName, int? NewRoleMaxConfigs, int? NewRoleMaxIpLimit, string Justification);
|
||||
|
||||
public sealed record ListTicketsRequest(TicketType? Type, TicketStatus? Status, int Page = 1, int PageSize = 20);
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.SignalR;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Domain.Configs;
|
||||
using PnvPanel.Domain.Nodes;
|
||||
using PnvPanel.Domain.Support;
|
||||
|
||||
namespace PnvPanel.Api.Hubs;
|
||||
|
||||
@@ -56,4 +57,20 @@ internal sealed class SignalRRealtimeNotifier(IHubContext<PanelHub> hubContext)
|
||||
new { id = postId, title, createdAt },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task NotifyTicketCreatedAsync(Guid ticketId, Guid userId, string userName, TicketType type, CancellationToken cancellationToken)
|
||||
{
|
||||
return hubContext.Clients.Group(GroupNames.Admins).SendAsync(
|
||||
"ticketCreated",
|
||||
new { ticketId, userId, userName, type = type.ToString() },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task NotifyTicketUpdatedAsync(Guid ticketId, Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
return hubContext.Clients.Group(GroupNames.User(userId)).SendAsync(
|
||||
"ticketUpdated",
|
||||
new { ticketId },
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +156,8 @@ app.MapAdminUserEndpoints();
|
||||
app.MapAdminStatsEndpoints();
|
||||
app.MapAdminAppEndpoints();
|
||||
app.MapAdminNewsEndpoints();
|
||||
app.MapSupportEndpoints();
|
||||
app.MapAdminSupportEndpoints();
|
||||
app.MapTelegramEndpoints();
|
||||
|
||||
app.MapHub<PanelHub>("/hubs/panel");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using PnvPanel.Application.Admin.Activation;
|
||||
using PnvPanel.Application.Admin.Support;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Configs.GetConfigLink;
|
||||
@@ -222,6 +223,29 @@ public sealed class PnvBotUpdateHandler(
|
||||
|
||||
break;
|
||||
}
|
||||
case "rrq":
|
||||
{
|
||||
if (!await TrySetAdminCurrentUserAsync(services, fromId, cancellationToken))
|
||||
{
|
||||
await botClient.AnswerCallbackQuery(callback.Id, "Недостаточно прав.", cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = parts[1] == "approve"
|
||||
? await sender.Send(new ApproveRoleRequestCommand(requestId), cancellationToken)
|
||||
: await sender.Send(new RejectRoleRequestCommand(requestId, Reason: null), cancellationToken);
|
||||
|
||||
await botClient.AnswerCallbackQuery(callback.Id, result.IsSuccess ? "Готово" : result.Error.Message, cancellationToken: cancellationToken);
|
||||
if (result.IsSuccess && callback.Message is not null)
|
||||
{
|
||||
var statusText = parts[1] == "approve" ? "✅ Заявка одобрена, роль выдана." : "❌ Заявка отклонена.";
|
||||
var text = $"{Escape(callback.Message.Text ?? "")}\n\n{statusText}";
|
||||
await botClient.EditMessageText(
|
||||
chatId.Value, callback.Message.Id, text, parseMode: ParseMode.Html, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "cfg":
|
||||
{
|
||||
if (!await TrySetCurrentUserAsync(services, fromId, cancellationToken))
|
||||
|
||||
@@ -39,6 +39,64 @@ internal sealed class TelegramNotifier(ITelegramBotClient botClient, IIdentitySe
|
||||
}
|
||||
}
|
||||
|
||||
public async Task NotifyAdminsBugReportCreatedAsync(Guid ticketId, string userName, string message, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||
return;
|
||||
|
||||
var preview = message.Length > 300 ? message[..300] + "…" : message;
|
||||
var text = $"🐞 Новый тикет (баг/предложение) от <b>{Escape(userName)}</b>\n{Escape(preview)}";
|
||||
|
||||
// Только ссылка на сайт — переписка и картинки удобнее там, инлайн-действий для баг-тикетов нет.
|
||||
InlineKeyboardMarkup? keyboard = null;
|
||||
if (!string.IsNullOrWhiteSpace(options.Value.PublicSiteUrl))
|
||||
{
|
||||
var url = $"{options.Value.PublicSiteUrl.TrimEnd('/')}/admin/support/{ticketId}";
|
||||
keyboard = new InlineKeyboardMarkup(new[] { InlineKeyboardButton.WithUrl("🌐 Открыть на сайте", url) });
|
||||
}
|
||||
|
||||
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
|
||||
{
|
||||
try
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task NotifyAdminsRoleRequestCreatedAsync(
|
||||
Guid ticketId, string userName, string roleDescription, string justification, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||
return;
|
||||
|
||||
var text = $"🆕 Заявка на роль от <b>{Escape(userName)}</b>\n{Escape(roleDescription)}\nОбоснование: {Escape(justification)}";
|
||||
|
||||
var keyboard = new InlineKeyboardMarkup(new[]
|
||||
{
|
||||
InlineKeyboardButton.WithCallbackData("✅ Одобрить", $"rrq:approve:{ticketId}"),
|
||||
InlineKeyboardButton.WithCallbackData("❌ Отклонить", $"rrq:reject:{ticketId}"),
|
||||
});
|
||||
|
||||
foreach (var adminId in options.Value.ParseAdminTelegramUserIds())
|
||||
{
|
||||
try
|
||||
{
|
||||
await botClient.SendMessage(
|
||||
adminId, text, parseMode: ParseMode.Html, replyMarkup: keyboard, cancellationToken: cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Админ мог не запускать бота (нет чата с ботом) — пропускаем, не валим команду.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task NotifyUserAsync(Guid userId, string message, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Value.BotToken))
|
||||
|
||||
Reference in New Issue
Block a user