fix(support): показывать пользователю отказ бэка при создании тикета

`POST /cabinet/tickets` отвечает 409 «You already have an open ticket»,
если у пользователя уже есть незакрытый тикет, но `createMutation` не имела
`onError` — форма молча оставалась на месте. Со стороны это выглядело как
«кнопка Отправить не работает»: пользователь жал её повторно и уходил в
поддержку с вопросом, почему обращение не создаётся. Тем же молчанием
отвечал и ответ в тикет (403 при блокировке в поддержке, 400 на закрытом
тикете).

Обе мутации теперь пишут текст ошибки в состояние формы — тот же блок,
в котором уже показывался клиентский rate-limit (`rateLimitError`
переименован в `formError`, состояние по смыслу общее). Для 409 показываем
локализованную подсказку и обновляем список тикетов: открытый тикет мог
появиться в другой сессии (бот, второе устройство), и после инвалидации
пользователю есть куда перейти. Остальные коды отдают `detail` бэка через
`getApiErrorMessage` с локализованным фолбэком.

409 ловим по статусу, а не по тексту `detail`: он приходит только
по-английски, а кабинет и бот версионируются раздельно.
This commit is contained in:
airp0wer
2026-08-10 17:38:47 +03:00
parent b866bebeeb
commit d88928912a
7 changed files with 127 additions and 13 deletions

View File

@@ -1131,7 +1131,12 @@
"contactSupport": "Please contact {{username}} for support",
"contactUs": "Contact Support",
"create_ticket": "Create ticket",
"writeButton": "Message"
"writeButton": "Message",
"errors": {
"alreadyOpenTicket": "You already have an open ticket. Please continue the conversation there — a new ticket can be created once the current one is closed.",
"createFailed": "Could not create the ticket. Please try again.",
"replyFailed": "Could not send the reply. Please try again."
}
},
"wheel": {
"title": "Fortune Wheel",

View File

@@ -1004,7 +1004,12 @@
"ticketsDisabled": "تیکت‌ها غیرفعال است",
"useExternalLink": "لطفاً از لینک خارجی برای دریافت پشتیبانی استفاده کنید",
"create_ticket": "ایجاد تیکت",
"writeButton": "پیام"
"writeButton": "پیام",
"errors": {
"alreadyOpenTicket": "شما در حال حاضر یک تیکت باز دارید. گفتگو را در همان تیکت ادامه دهید؛ پس از بسته شدن آن می‌توانید تیکت جدید ایجاد کنید.",
"createFailed": "ایجاد تیکت ناموفق بود. لطفاً دوباره تلاش کنید.",
"replyFailed": "ارسال پاسخ ناموفق بود. لطفاً دوباره تلاش کنید."
}
},
"wheel": {
"title": "چرخ شانس",

View File

@@ -1150,7 +1150,12 @@
"contactSupport": "Для получения поддержки обратитесь к {{username}}",
"contactUs": "Связаться с поддержкой",
"create_ticket": "Создать тикет",
"writeButton": "Написать"
"writeButton": "Написать",
"errors": {
"alreadyOpenTicket": "У вас уже есть открытое обращение. Продолжите переписку в нём — новое можно создать после закрытия текущего.",
"createFailed": "Не удалось создать обращение. Попробуйте ещё раз.",
"replyFailed": "Не удалось отправить ответ. Попробуйте ещё раз."
}
},
"wheel": {
"title": "Колесо удачи",

View File

@@ -1004,7 +1004,12 @@
"ticketsDisabled": "工单已禁用",
"useExternalLink": "请使用外部链接获取支持",
"create_ticket": "创建工单",
"writeButton": "发消息"
"writeButton": "发消息",
"errors": {
"alreadyOpenTicket": "您已有一个未关闭的工单。请在该工单中继续沟通,当前工单关闭后即可创建新工单。",
"createFailed": "创建工单失败,请重试。",
"replyFailed": "发送回复失败,请重试。"
}
},
"wheel": {
"title": "幸运转盘",

View File

@@ -9,6 +9,8 @@ import { infoApi } from '../api/info';
import { useAuthStore } from '../store/auth';
import { logger } from '../utils/logger';
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit';
import { getApiErrorMessage } from '../utils/api-error';
import { isOpenTicketConflict } from '../utils/ticketErrors';
import type { SupportConfig, TicketDetail } from '../types';
import { Card } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button';
@@ -55,7 +57,10 @@ export default function Support() {
const [newTitle, setNewTitle] = useState('');
const [newMessage, setNewMessage] = useState('');
const [replyMessage, setReplyMessage] = useState('');
const [rateLimitError, setRateLimitError] = useState<string | null>(null);
// Ошибка активной формы: и клиентский rate-limit, и отказ бэка (409 «уже есть
// открытый тикет», 403 «поддержка выключена/пользователь заблокирован» и т.п.).
// Формы create и reply взаимоисключающие, поэтому состояние одно на обе.
const [formError, setFormError] = useState<string | null>(null);
// Media attachment states (multi-upload, up to 10)
const [createAttachments, setCreateAttachments] = useState<MediaAttachment[]>([]);
@@ -154,11 +159,26 @@ export default function Support() {
onSuccess: (ticket) => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
setShowCreateForm(false);
setFormError(null);
setNewTitle('');
setNewMessage('');
clearCreateAttachments();
setSelectedTicket(ticket);
},
onError: (error) => {
// Без этого отказ бэка (чаще всего 409 «уже есть открытый тикет») уходил
// в никуда: форма просто оставалась на месте, и пользователь жал «Отправить»
// снова и снова, не понимая, почему обращение не создаётся.
log.error('Ticket creation failed', error);
if (isOpenTicketConflict(error)) {
setFormError(t('support.errors.alreadyOpenTicket'));
// Открытый тикет мог появиться в другой сессии (бот, второе устройство) —
// подтягиваем список, чтобы пользователю было куда перейти.
queryClient.invalidateQueries({ queryKey: ['tickets'] });
return;
}
setFormError(getApiErrorMessage(error, t('support.errors.createFailed')));
},
});
const replyMutation = useMutation({
@@ -176,9 +196,16 @@ export default function Support() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] });
setFormError(null);
setReplyMessage('');
clearReplyAttachments();
},
onError: (error) => {
// Ответ в тикет молчал ровно так же: 403 (блокировка в поддержке) и 400
// (тикет уже закрыт) выглядели как «кнопка не работает».
log.error('Ticket reply failed', error);
setFormError(getApiErrorMessage(error, t('support.errors.replyFailed')));
},
});
const getStatusBadge = (status: string) => {
@@ -321,6 +348,7 @@ export default function Support() {
onClick={() => {
setShowCreateForm(true);
setSelectedTicket(null);
setFormError(null);
clearCreateAttachments();
}}
>
@@ -374,6 +402,7 @@ export default function Support() {
onClick={() => {
setSelectedTicket(ticket as unknown as TicketDetail);
setShowCreateForm(false);
setFormError(null);
clearReplyAttachments();
}}
className={`w-full rounded-bento border p-4 text-left transition-all ${
@@ -414,11 +443,11 @@ export default function Support() {
<form
onSubmit={(e) => {
e.preventDefault();
setRateLimitError(null);
setFormError(null);
// Rate limit: max 3 tickets per 60 seconds
if (!checkRateLimit(RATE_LIMIT_KEYS.TICKET_CREATE, 3, 60000)) {
const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.TICKET_CREATE);
setRateLimitError(t('support.tooManyRequests', { seconds: resetTime }));
setFormError(t('support.tooManyRequests', { seconds: resetTime }));
return;
}
createMutation.mutate();
@@ -495,9 +524,9 @@ export default function Support() {
)}
</div>
{rateLimitError && (
{formError && (
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
{rateLimitError}
{formError}
</div>
)}
@@ -515,6 +544,7 @@ export default function Support() {
variant="secondary"
onClick={() => {
setShowCreateForm(false);
setFormError(null);
clearCreateAttachments();
}}
>
@@ -589,11 +619,11 @@ export default function Support() {
<form
onSubmit={(e) => {
e.preventDefault();
setRateLimitError(null);
setFormError(null);
// Rate limit: max 5 replies per 30 seconds
if (!checkRateLimit(RATE_LIMIT_KEYS.TICKET_REPLY, 5, 30000)) {
const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.TICKET_REPLY);
setRateLimitError(t('support.tooManyRequests', { seconds: resetTime }));
setFormError(t('support.tooManyRequests', { seconds: resetTime }));
return;
}
replyMutation.mutate();
@@ -662,9 +692,9 @@ export default function Support() {
<SendIcon className="h-4 w-4" />
</Button>
</div>
{rateLimitError && (
{formError && (
<div className="mt-2 rounded-lg border border-error-500/30 bg-error-500/10 p-2 text-sm text-error-400">
{rateLimitError}
{formError}
</div>
)}
</div>

View File

@@ -0,0 +1,50 @@
import { AxiosError, AxiosHeaders } from 'axios';
import { describe, expect, it } from 'vitest';
import { isOpenTicketConflict } from './ticketErrors';
function axiosErrorWithStatus(status: number, detail?: unknown): AxiosError {
const headers = new AxiosHeaders();
const config = { headers };
return new AxiosError(
'Request failed',
'ERR_BAD_REQUEST',
config,
{},
{
status,
statusText: '',
headers,
config,
data: detail === undefined ? {} : { detail },
},
);
}
describe('isOpenTicketConflict', () => {
it('ловит 409 от POST /cabinet/tickets', () => {
expect(isOpenTicketConflict(axiosErrorWithStatus(409, 'You already have an open ticket'))).toBe(
true,
);
});
it('не зависит от текста detail (он приходит с бэка по-английски)', () => {
expect(isOpenTicketConflict(axiosErrorWithStatus(409))).toBe(true);
expect(isOpenTicketConflict(axiosErrorWithStatus(409, { code: 'open_ticket_exists' }))).toBe(
true,
);
});
it('не путает с другими отказами бэка', () => {
expect(isOpenTicketConflict(axiosErrorWithStatus(403, 'Support tickets are disabled'))).toBe(
false,
);
expect(isOpenTicketConflict(axiosErrorWithStatus(400))).toBe(false);
expect(isOpenTicketConflict(axiosErrorWithStatus(500))).toBe(false);
});
it('не падает на не-axios ошибках', () => {
expect(isOpenTicketConflict(new Error('boom'))).toBe(false);
expect(isOpenTicketConflict(undefined)).toBe(false);
expect(isOpenTicketConflict(null)).toBe(false);
});
});

14
src/utils/ticketErrors.ts Normal file
View File

@@ -0,0 +1,14 @@
import axios from 'axios';
/**
* true, если бэк отказал в создании тикета из-за уже открытого обращения.
*
* `POST /cabinet/tickets` отвечает 409 ровно в одном случае — у пользователя
* есть незакрытый тикет (`detail: 'You already have an open ticket'`, паритет
* с бот-путём). Сверяемся со статусом, а не с текстом: detail приходит с бэка
* только по-английски, а кабинет и бот версионируются раздельно — текст может
* поменяться в любой сборке бота, статус останется.
*/
export function isOpenTicketConflict(error: unknown): boolean {
return axios.isAxiosError(error) && error.response?.status === 409;
}