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
+33
View File
@@ -91,3 +91,36 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
const text = await response.text()
return (text ? JSON.parse(text) : undefined) as T
}
type UploadOptions = {
method?: 'POST' | 'PUT'
skipRefresh?: boolean
}
/** Как apiRequest, но для multipart/form-data (вложения к тикетам) — без JSON.stringify и
* без Content-Type (браузер сам проставляет boundary). */
export async function apiUpload<T>(path: string, formData: FormData, options: UploadOptions = {}): Promise<T> {
const headers: Record<string, string> = {}
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
const response = await fetch(`/api${path}`, {
method: options.method ?? 'POST',
headers,
credentials: 'include',
body: formData,
})
if (response.status === 401 && !options.skipRefresh) {
const refreshed = await refreshAccessToken()
if (refreshed) return apiUpload<T>(path, formData, { ...options, skipRefresh: true })
onUnauthorized?.()
throw await parseError(response)
}
if (!response.ok) throw await parseError(response)
if (response.status === 204) return undefined as T
const text = await response.text()
return (text ? JSON.parse(text) : undefined) as T
}
+45
View File
@@ -238,3 +238,48 @@ export type AuditLogDto = {
source: AuditSource
createdAt: string
}
export type TicketType = 'BugReport' | 'RoleRequest'
export type TicketStatus = 'Open' | 'Resolved' | 'Closed'
export type TicketAttachmentDto = {
id: string
fileName: string
contentType: string
sizeBytes: number
}
export type TicketCommentDto = {
id: string
authorId: string
authorName: string
body: string
createdAt: string
attachments: TicketAttachmentDto[]
}
/** Строка списка тикетов — один DTO для своего списка и админского (видит только свои userId/userName). */
export type TicketSummaryDto = {
id: string
userId: string
userName: string
type: TicketType
status: TicketStatus
createdAt: string
lastActivityAt: string
}
export type TicketDetailDto = {
id: string
userId: string
userName: string
type: TicketType
status: TicketStatus
requestedRoleId: string | null
requestedRoleName: string | null
proposedRoleName: string | null
proposedMaxConfigs: number | null
proposedMaxIpLimit: number | null
createdAt: string
comments: TicketCommentDto[]
}
+98
View File
@@ -49,6 +49,7 @@ const resources = {
dashboard: 'Мои конфиги',
instructions: 'Инструкции',
news: 'Новости',
support: 'Поддержка',
settings: 'Настройки',
admin: 'Админка',
logout: 'Выйти',
@@ -121,6 +122,42 @@ const resources = {
empty: 'Пока нет новостей.',
},
support: {
title: 'Поддержка',
empty: 'У вас пока нет обращений.',
reportBug: 'Сообщить об ошибке',
requestRole: 'Запросить роль',
submit: 'Отправить',
messageLabel: 'Опишите проблему или предложение',
attachmentsLabel: 'Скриншоты (необязательно, до 5)',
filesSelected: '{{count}} файл(ов) выбрано',
existingRole: 'Существующая роль',
newRole: 'Новая роль',
selectRole: 'Выберите роль',
newRoleName: 'Название роли',
newRoleMaxConfigs: 'Количество конфигов (-1 — без лимита)',
newRoleMaxIpLimit: 'Количество IP (-1 — без лимита)',
justification: 'Обоснование',
ticketCreated: 'Обращение отправлено.',
roleRequestPending: 'У вас уже есть необработанная заявка на роль.',
reply: 'Ответить',
replyPlaceholder: 'Написать комментарий…',
reopen: 'Переоткрыть',
reopened: 'Тикет переоткрыт.',
lastActivity: 'Последняя активность: {{date}}',
requestedExistingRole: 'Запрошена роль: {{role}}',
requestedNewRole: 'Запрошена новая роль «{{name}}» (конфигов: {{configs}}, IP: {{ip}})',
type: {
BugReport: 'Ошибка/предложение',
RoleRequest: 'Заявка на роль',
},
status: {
Open: 'Открыт',
Resolved: 'Решён',
Closed: 'Закрыт',
},
},
settings: {
changePassword: 'Сменить пароль',
currentPassword: 'Текущий пароль',
@@ -159,6 +196,7 @@ const resources = {
nodes: 'Ноды',
apps: 'Приложения',
news: 'Новости',
support: 'Поддержка',
audit: 'Аудит',
},
users: {
@@ -289,6 +327,17 @@ const resources = {
deleted: 'Новость удалена.',
confirmDelete: 'Удалить новость?',
},
support: {
empty: 'Обращений пока нет.',
resolve: 'Решено',
resolved: 'Тикет отмечен как решённый.',
close: 'Закрыть',
closed: 'Тикет закрыт.',
approve: 'Одобрить',
approved: 'Заявка одобрена, роль выдана.',
reject: 'Отклонить',
rejected: 'Заявка отклонена.',
},
audit: {
time: 'Время',
action: 'Действие',
@@ -356,6 +405,7 @@ const resources = {
dashboard: 'My configs',
instructions: 'Instructions',
news: 'News',
support: 'Support',
settings: 'Settings',
admin: 'Admin',
logout: 'Log out',
@@ -428,6 +478,42 @@ const resources = {
empty: 'No news yet.',
},
support: {
title: 'Support',
empty: 'You have no tickets yet.',
reportBug: 'Report a bug',
requestRole: 'Request a role',
submit: 'Submit',
messageLabel: 'Describe the issue or suggestion',
attachmentsLabel: 'Screenshots (optional, up to 5)',
filesSelected: '{{count}} file(s) selected',
existingRole: 'Existing role',
newRole: 'New role',
selectRole: 'Select a role',
newRoleName: 'Role name',
newRoleMaxConfigs: 'Max configs (-1 = unlimited)',
newRoleMaxIpLimit: 'Max IPs (-1 = unlimited)',
justification: 'Justification',
ticketCreated: 'Ticket submitted.',
roleRequestPending: 'You already have a pending role request.',
reply: 'Reply',
replyPlaceholder: 'Write a comment…',
reopen: 'Reopen',
reopened: 'Ticket reopened.',
lastActivity: 'Last activity: {{date}}',
requestedExistingRole: 'Requested role: {{role}}',
requestedNewRole: 'Requested new role "{{name}}" (configs: {{configs}}, IPs: {{ip}})',
type: {
BugReport: 'Bug/suggestion',
RoleRequest: 'Role request',
},
status: {
Open: 'Open',
Resolved: 'Resolved',
Closed: 'Closed',
},
},
settings: {
changePassword: 'Change password',
currentPassword: 'Current password',
@@ -466,6 +552,7 @@ const resources = {
nodes: 'Nodes',
apps: 'Apps',
news: 'News',
support: 'Support',
audit: 'Audit',
},
users: {
@@ -596,6 +683,17 @@ const resources = {
deleted: 'Post deleted.',
confirmDelete: 'Delete this post?',
},
support: {
empty: 'No tickets yet.',
resolve: 'Resolve',
resolved: 'Ticket marked as resolved.',
close: 'Close',
closed: 'Ticket closed.',
approve: 'Approve',
approved: 'Request approved, role granted.',
reject: 'Reject',
rejected: 'Request rejected.',
},
audit: {
time: 'Time',
action: 'Action',