diff --git a/src/locales/en.json b/src/locales/en.json index e5bbd95..2cb01d1 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1134,7 +1134,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", diff --git a/src/locales/fa.json b/src/locales/fa.json index 9ba6a00..9888553 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1007,7 +1007,12 @@ "ticketsDisabled": "تیکت‌ها غیرفعال است", "useExternalLink": "لطفاً از لینک خارجی برای دریافت پشتیبانی استفاده کنید", "create_ticket": "ایجاد تیکت", - "writeButton": "پیام" + "writeButton": "پیام", + "errors": { + "alreadyOpenTicket": "شما در حال حاضر یک تیکت باز دارید. گفتگو را در همان تیکت ادامه دهید؛ پس از بسته شدن آن می‌توانید تیکت جدید ایجاد کنید.", + "createFailed": "ایجاد تیکت ناموفق بود. لطفاً دوباره تلاش کنید.", + "replyFailed": "ارسال پاسخ ناموفق بود. لطفاً دوباره تلاش کنید." + } }, "wheel": { "title": "چرخ شانس", diff --git a/src/locales/ru.json b/src/locales/ru.json index b1e0c61..e9d4655 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1153,7 +1153,12 @@ "contactSupport": "Для получения поддержки обратитесь к {{username}}", "contactUs": "Связаться с поддержкой", "create_ticket": "Создать тикет", - "writeButton": "Написать" + "writeButton": "Написать", + "errors": { + "alreadyOpenTicket": "У вас уже есть открытое обращение. Продолжите переписку в нём — новое можно создать после закрытия текущего.", + "createFailed": "Не удалось создать обращение. Попробуйте ещё раз.", + "replyFailed": "Не удалось отправить ответ. Попробуйте ещё раз." + } }, "wheel": { "title": "Колесо удачи", diff --git a/src/locales/zh.json b/src/locales/zh.json index f39b815..27f132d 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1007,7 +1007,12 @@ "ticketsDisabled": "工单已禁用", "useExternalLink": "请使用外部链接获取支持", "create_ticket": "创建工单", - "writeButton": "发消息" + "writeButton": "发消息", + "errors": { + "alreadyOpenTicket": "您已有一个未关闭的工单。请在该工单中继续沟通,当前工单关闭后即可创建新工单。", + "createFailed": "创建工单失败,请重试。", + "replyFailed": "发送回复失败,请重试。" + } }, "wheel": { "title": "幸运转盘", diff --git a/src/pages/Support.tsx b/src/pages/Support.tsx index 90f0306..40ebf7a 100644 --- a/src/pages/Support.tsx +++ b/src/pages/Support.tsx @@ -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(null); + // Ошибка активной формы: и клиентский rate-limit, и отказ бэка (409 «уже есть + // открытый тикет», 403 «поддержка выключена/пользователь заблокирован» и т.п.). + // Формы create и reply взаимоисключающие, поэтому состояние одно на обе. + const [formError, setFormError] = useState(null); // Media attachment states (multi-upload, up to 10) const [createAttachments, setCreateAttachments] = useState([]); @@ -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() {
{ 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() { )} - {rateLimitError && ( + {formError && (
- {rateLimitError} + {formError}
)} @@ -515,6 +544,7 @@ export default function Support() { variant="secondary" onClick={() => { setShowCreateForm(false); + setFormError(null); clearCreateAttachments(); }} > @@ -589,11 +619,11 @@ export default function Support() { { 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() { - {rateLimitError && ( + {formError && (
- {rateLimitError} + {formError}
)} diff --git a/src/utils/ticketErrors.test.ts b/src/utils/ticketErrors.test.ts new file mode 100644 index 0000000..1cd389f --- /dev/null +++ b/src/utils/ticketErrors.test.ts @@ -0,0 +1,85 @@ +import { readFileSync } from 'node:fs'; +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); + }); +}); + +describe('обработчики ошибок на форме поддержки', () => { + // Решение «это 409» вынесено в утилиту и покрыто выше, а вот сама привязка + // onError к мутациям — нет: в репозитории нет окружения для рендера + // компонентов (vitest настроен на node, без jsdom). Поэтому закрепляем + // структурно: пропажа onError — это ровно тот баг, который PR и чинит + // (форма молчала, человек жал «Отправить» повторно). + const source = readFileSync(new URL('../pages/Support.tsx', import.meta.url), 'utf8'); + + it('createMutation обрабатывает отказ бэка', () => { + const block = source.slice( + source.indexOf('const createMutation'), + source.indexOf('const replyMutation'), + ); + expect(block).toContain('onError'); + expect(block).toContain('isOpenTicketConflict'); + }); + + it('replyMutation обрабатывает отказ бэка', () => { + const block = source.slice( + source.indexOf('const replyMutation'), + source.indexOf('const getStatusBadge'), + ); + expect(block).toContain('onError'); + }); + + it('409 подтягивает список тикетов — открытый мог появиться в другой сессии', () => { + const block = source.slice( + source.indexOf('isOpenTicketConflict(error)'), + source.indexOf('const replyMutation'), + ); + expect(block).toContain('invalidateQueries'); + }); +}); diff --git a/src/utils/ticketErrors.ts b/src/utils/ticketErrors.ts new file mode 100644 index 0000000..0bd681a --- /dev/null +++ b/src/utils/ticketErrors.ts @@ -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; +}