Implement support ticket system with role request and bug report functionalities
CI / Backend (build + test) (push) Successful in 1m18s
CI / Frontend (lint + typecheck + build) (push) Successful in 31s

- 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:
Leonid Pershin
2026-07-14 06:49:05 +03:00
parent 14b64a3140
commit b5630b2685
98 changed files with 4463 additions and 6 deletions
@@ -0,0 +1,94 @@
using PnvPanel.Domain.Common;
using PnvPanel.Domain.Exceptions;
namespace PnvPanel.Domain.Support;
/// <summary>
/// Обращение в поддержку: баг/предложение (свободная форма) либо заявка на роль (существующая роль
/// или параметры новой). Текст обращения и переписка — в TicketComment, отдельной таблицей (не
/// навигационная коллекция — см. конвенцию проекта на плоских сущностях, ср. TrafficSample/VpnConfig).
/// Для RoleRequest заполнен либо RequestedRoleId, либо Proposed* — гарантируется отдельными фабриками,
/// а не runtime-проверкой одного универсального конструктора.
/// </summary>
public sealed class SupportTicket : Entity
{
public Guid UserId { get; private set; }
public TicketType Type { get; private set; }
public TicketStatus Status { get; private set; }
public Guid? RequestedRoleId { get; private set; }
public string? ProposedRoleName { get; private set; }
public int? ProposedMaxConfigs { get; private set; }
public int? ProposedMaxIpLimit { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
private SupportTicket()
{
}
public static SupportTicket CreateBugReport(Guid userId)
{
return new SupportTicket
{
Id = Guid.NewGuid(),
UserId = userId,
Type = TicketType.BugReport,
Status = TicketStatus.Open,
CreatedAt = DateTimeOffset.UtcNow,
};
}
public static SupportTicket CreateRoleRequestForExistingRole(Guid userId, Guid roleId)
{
return new SupportTicket
{
Id = Guid.NewGuid(),
UserId = userId,
Type = TicketType.RoleRequest,
Status = TicketStatus.Open,
RequestedRoleId = roleId,
CreatedAt = DateTimeOffset.UtcNow,
};
}
public static SupportTicket CreateRoleRequestForNewRole(Guid userId, string name, int maxConfigs, int maxIpLimit)
{
return new SupportTicket
{
Id = Guid.NewGuid(),
UserId = userId,
Type = TicketType.RoleRequest,
Status = TicketStatus.Open,
ProposedRoleName = name,
ProposedMaxConfigs = maxConfigs,
ProposedMaxIpLimit = maxIpLimit,
CreatedAt = DateTimeOffset.UtcNow,
};
}
/// <summary>Решено (в т.ч. заявка на роль одобрена — роль выдаётся оркестрацией на уровне Application).</summary>
public void Resolve()
{
if (Status != TicketStatus.Open)
throw new DomainException($"Нельзя перевести в Resolved тикет в статусе {Status}.");
Status = TicketStatus.Resolved;
}
/// <summary>Финальное состояние — обратного пути нет (в т.ч. заявка на роль отклонена).</summary>
public void Close()
{
if (Status == TicketStatus.Closed)
throw new DomainException("Тикет уже закрыт.");
Status = TicketStatus.Closed;
}
/// <summary>Только из Resolved — Closed финален и не переоткрывается.</summary>
public void Reopen()
{
if (Status != TicketStatus.Resolved)
throw new DomainException("Переоткрыть можно только решённый тикет.");
Status = TicketStatus.Open;
}
}
@@ -0,0 +1,36 @@
using PnvPanel.Domain.Common;
namespace PnvPanel.Domain.Support;
/// <summary>
/// Вложение (изображение) к сообщению тикета. StoredFileName — серверное имя на диске (GUID-based),
/// FileName — оригинальное имя только для отображения (не участвует в построении пути — не доверяем
/// пользовательскому вводу для файловой системы).
/// </summary>
public sealed class TicketAttachment : Entity
{
public Guid CommentId { get; private set; }
public string FileName { get; private set; } = string.Empty;
public string StoredFileName { get; private set; } = string.Empty;
public string ContentType { get; private set; } = string.Empty;
public long SizeBytes { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
private TicketAttachment()
{
}
public static TicketAttachment Create(Guid commentId, string fileName, string storedFileName, string contentType, long sizeBytes)
{
return new TicketAttachment
{
Id = Guid.NewGuid(),
CommentId = commentId,
FileName = fileName,
StoredFileName = storedFileName,
ContentType = contentType,
SizeBytes = sizeBytes,
CreatedAt = DateTimeOffset.UtcNow,
};
}
}
@@ -0,0 +1,32 @@
using PnvPanel.Domain.Common;
namespace PnvPanel.Domain.Support;
/// <summary>
/// Сообщение в переписке по тикету — первое сообщение при создании тикета одновременно служит
/// описанием бага/обоснованием заявки на роль (отдельного поля под это на SupportTicket нет).
/// Вложения — TicketAttachment, отдельной таблицей по CommentId.
/// </summary>
public sealed class TicketComment : Entity
{
public Guid TicketId { get; private set; }
public Guid AuthorId { get; private set; }
public string Body { get; private set; } = string.Empty;
public DateTimeOffset CreatedAt { get; private set; }
private TicketComment()
{
}
public static TicketComment Create(Guid ticketId, Guid authorId, string body)
{
return new TicketComment
{
Id = Guid.NewGuid(),
TicketId = ticketId,
AuthorId = authorId,
Body = body,
CreatedAt = DateTimeOffset.UtcNow,
};
}
}
@@ -0,0 +1,8 @@
namespace PnvPanel.Domain.Support;
public enum TicketStatus
{
Open,
Resolved,
Closed,
}
@@ -0,0 +1,7 @@
namespace PnvPanel.Domain.Support;
public enum TicketType
{
BugReport,
RoleRequest,
}