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,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<File[]>([])
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<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}`)}
{data && <span className="text-sm font-normal text-muted-foreground"> {data.userName}</span>}
</DialogTitle>
</DialogHeader>
{isLoading && <p className="text-sm text-muted-foreground"></p>}
{data && (
<div className="flex max-h-[70vh] 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 !== 'Closed' && (
<div className="flex flex-wrap gap-2 border-t border-border pt-3">
{data.type === 'RoleRequest' && data.status === 'Open' ? (
<>
<Button size="sm" disabled={approveMutation.isPending} onClick={() => approveMutation.mutate()}>
{t('admin.support.approve')}
</Button>
<Button size="sm" variant="outline" disabled={rejectMutation.isPending} onClick={() => rejectMutation.mutate()}>
{t('admin.support.reject')}
</Button>
</>
) : (
<>
{data.status === 'Open' && (
<Button size="sm" disabled={resolveMutation.isPending} onClick={() => resolveMutation.mutate()}>
{t('admin.support.resolve')}
</Button>
)}
<Button size="sm" variant="outline" disabled={closeMutation.isPending} onClick={() => closeMutation.mutate()}>
{t('admin.support.close')}
</Button>
</>
)}
</div>
)}
{data.status !== 'Closed' && (
<form
className="flex flex-col gap-2"
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,43 @@
import { apiRequest, apiUpload } from '@/shared/api/client'
import type {
PagedList,
TicketCommentDto,
TicketDetailDto,
TicketStatus,
TicketSummaryDto,
TicketType,
} from '@/shared/api/types'
export function listAllTickets(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 apiRequest<PagedList<TicketSummaryDto>>(`/admin/support/tickets?${params}`)
}
export function getAdminTicket(id: string) {
return apiRequest<TicketDetailDto>(`/admin/support/tickets/${id}`)
}
export function addAdminComment(ticketId: string, body: string, files: File[]) {
const formData = new FormData()
formData.set('body', body)
files.forEach((file) => formData.append('files', file))
return apiUpload<TicketCommentDto>(`/admin/support/tickets/${ticketId}/comments`, formData)
}
export function resolveTicket(ticketId: string) {
return apiRequest<void>(`/admin/support/tickets/${ticketId}/resolve`, { method: 'POST' })
}
export function closeTicket(ticketId: string) {
return apiRequest<void>(`/admin/support/tickets/${ticketId}/close`, { method: 'POST' })
}
export function approveRoleRequest(ticketId: string) {
return apiRequest<void>(`/admin/support/tickets/${ticketId}/approve`, { method: 'POST' })
}
export function rejectRoleRequest(ticketId: string, reason?: string) {
return apiRequest<void>(`/admin/support/tickets/${ticketId}/reject`, { method: 'POST', body: { reason } })
}