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,78 @@
import { useState, type ChangeEvent } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Textarea } from '@/shared/ui/textarea'
import { Label } from '@/shared/ui/label'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
import { HttpError } from '@/shared/api/client'
import { createBugReportTicket } from './api'
const MAX_FILES = 5
export function CreateBugReportDialog() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [open, setOpen] = useState(false)
const [message, setMessage] = useState('')
const [files, setFiles] = useState<File[]>([])
const mutation = useMutation({
mutationFn: () => createBugReportTicket(message.trim(), files),
onSuccess: async () => {
toast.success(t('support.ticketCreated'))
await queryClient.invalidateQueries({ queryKey: ['my-tickets'] })
setOpen(false)
setMessage('')
setFiles([])
},
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
})
const handleFilesChange = (e: ChangeEvent<HTMLInputElement>) => {
setFiles(Array.from(e.target.files ?? []).slice(0, MAX_FILES))
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">{t('support.reportBug')}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('support.reportBug')}</DialogTitle>
</DialogHeader>
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault()
if (message.trim()) mutation.mutate()
}}
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="bug-message">{t('support.messageLabel')}</Label>
<Textarea id="bug-message" value={message} onChange={(e) => setMessage(e.target.value)} required />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="bug-files">{t('support.attachmentsLabel')}</Label>
<Input
id="bug-files"
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
multiple
onChange={handleFilesChange}
/>
{files.length > 0 && (
<p className="text-xs text-muted-foreground">{t('support.filesSelected', { count: files.length })}</p>
)}
</div>
<Button type="submit" disabled={!message.trim() || mutation.isPending}>
{t('support.submit')}
</Button>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,157 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Textarea } from '@/shared/ui/textarea'
import { Label } from '@/shared/ui/label'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { HttpError } from '@/shared/api/client'
import { createRoleRequestTicket, listSelectableRoles } from './api'
type Mode = 'existing' | 'new'
export function CreateRoleRequestDialog() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [open, setOpen] = useState(false)
const [mode, setMode] = useState<Mode>('existing')
const [roleId, setRoleId] = useState('')
const [newRoleName, setNewRoleName] = useState('')
const [newRoleMaxConfigs, setNewRoleMaxConfigs] = useState('')
const [newRoleMaxIpLimit, setNewRoleMaxIpLimit] = useState('')
const [justification, setJustification] = useState('')
const rolesQuery = useQuery({ queryKey: ['selectable-roles'], queryFn: listSelectableRoles, enabled: open })
const resetForm = () => {
setMode('existing')
setRoleId('')
setNewRoleName('')
setNewRoleMaxConfigs('')
setNewRoleMaxIpLimit('')
setJustification('')
}
const mutation = useMutation({
mutationFn: () =>
createRoleRequestTicket(
mode === 'existing'
? { existingRoleId: roleId, justification: justification.trim() }
: {
newRoleName: newRoleName.trim(),
newRoleMaxConfigs: Number(newRoleMaxConfigs),
newRoleMaxIpLimit: Number(newRoleMaxIpLimit),
justification: justification.trim(),
},
),
onSuccess: async () => {
toast.success(t('support.ticketCreated'))
await queryClient.invalidateQueries({ queryKey: ['my-tickets'] })
setOpen(false)
resetForm()
},
onError: (error) => {
const message =
error instanceof HttpError && error.status === 409
? t('support.roleRequestPending')
: error instanceof HttpError
? error.detail
: t('auth.genericError')
toast.error(message)
},
})
const canSubmit =
justification.trim().length > 0 &&
(mode === 'existing'
? roleId.length > 0
: newRoleName.trim().length > 0 && newRoleMaxConfigs !== '' && newRoleMaxIpLimit !== '')
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">{t('support.requestRole')}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('support.requestRole')}</DialogTitle>
</DialogHeader>
<form
className="flex flex-col gap-4"
onSubmit={(e) => {
e.preventDefault()
if (canSubmit) mutation.mutate()
}}
>
<div className="flex gap-4 text-sm">
<label className="flex items-center gap-2">
<input type="radio" checked={mode === 'existing'} onChange={() => setMode('existing')} />
{t('support.existingRole')}
</label>
<label className="flex items-center gap-2">
<input type="radio" checked={mode === 'new'} onChange={() => setMode('new')} />
{t('support.newRole')}
</label>
</div>
{mode === 'existing' ? (
<div className="flex flex-col gap-1.5">
<Label>{t('support.selectRole')}</Label>
<Select value={roleId} onValueChange={setRoleId}>
<SelectTrigger>
<SelectValue placeholder={t('support.selectRole')} />
</SelectTrigger>
<SelectContent>
{rolesQuery.data?.map((role) => (
<SelectItem key={role.id} value={role.id}>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<>
<div className="flex flex-col gap-1.5">
<Label htmlFor="new-role-name">{t('support.newRoleName')}</Label>
<Input id="new-role-name" value={newRoleName} onChange={(e) => setNewRoleName(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="new-role-configs">{t('support.newRoleMaxConfigs')}</Label>
<Input
id="new-role-configs"
type="number"
min={-1}
value={newRoleMaxConfigs}
onChange={(e) => setNewRoleMaxConfigs(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="new-role-ip">{t('support.newRoleMaxIpLimit')}</Label>
<Input
id="new-role-ip"
type="number"
min={-1}
value={newRoleMaxIpLimit}
onChange={(e) => setNewRoleMaxIpLimit(e.target.value)}
/>
</div>
</>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="justification">{t('support.justification')}</Label>
<Textarea id="justification" value={justification} onChange={(e) => setJustification(e.target.value)} required />
</div>
<Button type="submit" disabled={!canSubmit || mutation.isPending}>
{t('support.submit')}
</Button>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,78 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { CreateBugReportDialog } from './CreateBugReportDialog'
import { CreateRoleRequestDialog } from './CreateRoleRequestDialog'
import { TicketDetailDialog } from './TicketDetailDialog'
import { TicketStatusBadge } from './TicketStatusBadge'
import { listMyTickets } from './api'
const PAGE_SIZE = 20
export function SupportTicketList() {
const { t } = useTranslation()
const [page, setPage] = useState(1)
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null)
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['my-tickets', page],
queryFn: () => listMyTickets(undefined, undefined, page, PAGE_SIZE),
})
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap gap-2">
<CreateBugReportDialog />
<CreateRoleRequestDialog />
</div>
{isLoading && <p className="text-sm text-muted-foreground"></p>}
{isError && (
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{t('auth.genericError')}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
{t('activation.retry')}
</Button>
</div>
)}
{data?.items.length === 0 && <p className="text-sm text-muted-foreground">{t('support.empty')}</p>}
{data && data.items.length > 0 && (
<div className="flex flex-col gap-3">
{data.items.map((ticket) => (
<Card key={ticket.id} className="cursor-pointer" onClick={() => setSelectedTicketId(ticket.id)}>
<CardHeader className="flex-row items-center justify-between gap-2">
<CardTitle className="text-base">{t(`support.type.${ticket.type}`)}</CardTitle>
<TicketStatusBadge status={ticket.status} />
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">
{t('support.lastActivity', { date: new Date(ticket.lastActivityAt).toLocaleString() })}
</p>
</CardContent>
</Card>
))}
</div>
)}
{data && data.total > PAGE_SIZE && (
<div className="flex justify-end gap-2 text-sm">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t('admin.prev')}
</Button>
<Button variant="outline" size="sm" disabled={page * PAGE_SIZE >= data.total} onClick={() => setPage((p) => p + 1)}>
{t('admin.next')}
</Button>
</div>
)}
{selectedTicketId && (
<TicketDetailDialog ticketId={selectedTicketId} onOpenChange={(open) => !open && setSelectedTicketId(null)} />
)}
</div>
)
}
@@ -0,0 +1,33 @@
import { useEffect, useState } from 'react'
import type { TicketAttachmentDto } from '@/shared/api/types'
import { fetchAttachmentBlob } from './api'
export function TicketAttachmentImage({ attachment }: { attachment: TicketAttachmentDto }) {
const [url, setUrl] = useState<string | null>(null)
useEffect(() => {
let objectUrl: string | null = null
let cancelled = false
fetchAttachmentBlob(attachment.id)
.then((blob) => {
if (cancelled) return
objectUrl = URL.createObjectURL(blob)
setUrl(objectUrl)
})
.catch(() => {})
return () => {
cancelled = true
if (objectUrl) URL.revokeObjectURL(objectUrl)
}
}, [attachment.id])
if (!url) return <div className="h-20 w-20 animate-pulse rounded-md bg-muted" />
return (
<a href={url} target="_blank" rel="noreferrer">
<img src={url} alt={attachment.fileName} className="h-20 w-20 rounded-md border border-border object-cover" />
</a>
)
}
@@ -0,0 +1,123 @@
import { useState, type ChangeEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from '@/shared/ui/toast-store'
import { Button } from '@/shared/ui/button'
import { Textarea } from '@/shared/ui/textarea'
import { Input } from '@/shared/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { HttpError } from '@/shared/api/client'
import { TicketStatusBadge } from './TicketStatusBadge'
import { TicketAttachmentImage } from './TicketAttachmentImage'
import { addTicketComment, getTicket, reopenTicket } from './api'
const MAX_FILES = 5
export function TicketDetailDialog({ ticketId, onOpenChange }: { ticketId: string; onOpenChange: (open: boolean) => void }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [reply, setReply] = useState('')
const [files, setFiles] = useState<File[]>([])
const { data, isLoading } = useQuery({ queryKey: ['ticket', ticketId], queryFn: () => getTicket(ticketId) })
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: ['ticket', ticketId] })
await queryClient.invalidateQueries({ queryKey: ['my-tickets'] })
}
const replyMutation = useMutation({
mutationFn: () => addTicketComment(ticketId, reply.trim(), files),
onSuccess: async () => {
setReply('')
setFiles([])
await invalidate()
},
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
})
const reopenMutation = useMutation({
mutationFn: () => reopenTicket(ticketId),
onSuccess: async () => {
toast.success(t('support.reopened'))
await invalidate()
},
onError: () => toast.error(t('auth.genericError')),
})
const handleFilesChange = (e: ChangeEvent<HTMLInputElement>) => {
setFiles(Array.from(e.target.files ?? []).slice(0, MAX_FILES))
}
return (
<Dialog open onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{data && <TicketStatusBadge status={data.status} />}
{data && t(`support.type.${data.type}`)}
</DialogTitle>
</DialogHeader>
{isLoading && <p className="text-sm text-muted-foreground"></p>}
{data && (
<div className="flex max-h-[60vh] flex-col gap-4 overflow-y-auto">
{data.type === 'RoleRequest' && (
<div className="rounded-md border border-border p-3 text-sm">
{data.requestedRoleName
? t('support.requestedExistingRole', { role: data.requestedRoleName })
: t('support.requestedNewRole', {
name: data.proposedRoleName,
configs: data.proposedMaxConfigs,
ip: data.proposedMaxIpLimit,
})}
</div>
)}
<div className="flex flex-col gap-3">
{data.comments.map((comment) => (
<div key={comment.id} className="rounded-md border border-border p-3">
<div className="mb-1 flex items-center justify-between text-xs text-muted-foreground">
<span className="font-medium text-foreground">{comment.authorName}</span>
<span>{new Date(comment.createdAt).toLocaleString()}</span>
</div>
<p className="whitespace-pre-wrap text-sm">{comment.body}</p>
{comment.attachments.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{comment.attachments.map((attachment) => (
<TicketAttachmentImage key={attachment.id} attachment={attachment} />
))}
</div>
)}
</div>
))}
</div>
{data.status === 'Resolved' && (
<Button variant="outline" size="sm" disabled={reopenMutation.isPending} onClick={() => reopenMutation.mutate()}>
{t('support.reopen')}
</Button>
)}
{data.status !== 'Closed' && (
<form
className="flex flex-col gap-2 border-t border-border pt-3"
onSubmit={(e) => {
e.preventDefault()
if (reply.trim()) replyMutation.mutate()
}}
>
<Textarea value={reply} onChange={(e) => setReply(e.target.value)} placeholder={t('support.replyPlaceholder')} />
<Input type="file" accept="image/png,image/jpeg,image/webp,image/gif" multiple onChange={handleFilesChange} />
<Button type="submit" size="sm" disabled={!reply.trim() || replyMutation.isPending}>
{t('support.reply')}
</Button>
</form>
)}
</div>
)}
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,14 @@
import { useTranslation } from 'react-i18next'
import { Badge } from '@/shared/ui/badge'
import type { TicketStatus } from '@/shared/api/types'
const VARIANT: Record<TicketStatus, 'warning' | 'success' | 'outline'> = {
Open: 'warning',
Resolved: 'success',
Closed: 'outline',
}
export function TicketStatusBadge({ status }: { status: TicketStatus }) {
const { t } = useTranslation()
return <Badge variant={VARIANT[status]}>{t(`support.status.${status}`)}</Badge>
}
+69
View File
@@ -0,0 +1,69 @@
import { apiRequest, apiUpload, getAccessToken } from '@/shared/api/client'
import type {
PagedList,
RoleDto,
TicketCommentDto,
TicketDetailDto,
TicketStatus,
TicketSummaryDto,
TicketType,
} from '@/shared/api/types'
function ticketsQuery(type: TicketType | undefined, status: TicketStatus | undefined, page: number, pageSize: number) {
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
if (type) params.set('type', type)
if (status) params.set('status', status)
return params.toString()
}
export function listMyTickets(type: TicketType | undefined, status: TicketStatus | undefined, page: number, pageSize: number) {
return apiRequest<PagedList<TicketSummaryDto>>(`/support/tickets?${ticketsQuery(type, status, page, pageSize)}`)
}
export function getTicket(id: string) {
return apiRequest<TicketDetailDto>(`/support/tickets/${id}`)
}
export function listSelectableRoles() {
return apiRequest<RoleDto[]>('/support/roles')
}
export function createBugReportTicket(message: string, files: File[]) {
const formData = new FormData()
formData.set('message', message)
files.forEach((file) => formData.append('files', file))
return apiUpload<TicketDetailDto>('/support/tickets/bug-reports', formData)
}
export function createRoleRequestTicket(payload: {
existingRoleId?: string
newRoleName?: string
newRoleMaxConfigs?: number
newRoleMaxIpLimit?: number
justification: string
}) {
return apiRequest<TicketDetailDto>('/support/tickets/role-requests', { method: 'POST', body: payload })
}
export function addTicketComment(ticketId: string, body: string, files: File[]) {
const formData = new FormData()
formData.set('body', body)
files.forEach((file) => formData.append('files', file))
return apiUpload<TicketCommentDto>(`/support/tickets/${ticketId}/comments`, formData)
}
export function reopenTicket(ticketId: string) {
return apiRequest<void>(`/support/tickets/${ticketId}/reopen`, { method: 'POST' })
}
/** Вложения отдаются авторизованным эндпоинтом (не статикой) — обычный <img src> не может передать
* Authorization-заголовок, поэтому качаем как Blob и рендерим через Object URL (см. TicketAttachmentImage). */
export async function fetchAttachmentBlob(id: string): Promise<Blob> {
const headers: Record<string, string> = {}
const token = getAccessToken()
if (token) headers.Authorization = `Bearer ${token}`
const response = await fetch(`/api/support/attachments/${id}`, { headers, credentials: 'include' })
if (!response.ok) throw new Error('Не удалось загрузить вложение')
return response.blob()
}