From d88928912acaaf9eeee52235ee4e2aace5e245e7 Mon Sep 17 00:00:00 2001 From: airp0wer Date: Mon, 10 Aug 2026 17:38:47 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(support):=20=D0=BF=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BF=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8E=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=20=D0=B1=D1=8D=D0=BA=D0=B0=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B8=20?= =?UTF-8?q?=D1=82=D0=B8=D0=BA=D0=B5=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /cabinet/tickets` отвечает 409 «You already have an open ticket», если у пользователя уже есть незакрытый тикет, но `createMutation` не имела `onError` — форма молча оставалась на месте. Со стороны это выглядело как «кнопка Отправить не работает»: пользователь жал её повторно и уходил в поддержку с вопросом, почему обращение не создаётся. Тем же молчанием отвечал и ответ в тикет (403 при блокировке в поддержке, 400 на закрытом тикете). Обе мутации теперь пишут текст ошибки в состояние формы — тот же блок, в котором уже показывался клиентский rate-limit (`rateLimitError` переименован в `formError`, состояние по смыслу общее). Для 409 показываем локализованную подсказку и обновляем список тикетов: открытый тикет мог появиться в другой сессии (бот, второе устройство), и после инвалидации пользователю есть куда перейти. Остальные коды отдают `detail` бэка через `getApiErrorMessage` с локализованным фолбэком. 409 ловим по статусу, а не по тексту `detail`: он приходит только по-английски, а кабинет и бот версионируются раздельно. --- src/locales/en.json | 7 ++++- src/locales/fa.json | 7 ++++- src/locales/ru.json | 7 ++++- src/locales/zh.json | 7 ++++- src/pages/Support.tsx | 48 ++++++++++++++++++++++++++------ src/utils/ticketErrors.test.ts | 50 ++++++++++++++++++++++++++++++++++ src/utils/ticketErrors.ts | 14 ++++++++++ 7 files changed, 127 insertions(+), 13 deletions(-) create mode 100644 src/utils/ticketErrors.test.ts create mode 100644 src/utils/ticketErrors.ts diff --git a/src/locales/en.json b/src/locales/en.json index b07b36d..141f761 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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", diff --git a/src/locales/fa.json b/src/locales/fa.json index cbbb785..0c91ce2 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1004,7 +1004,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 080e88f..21f7991 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1150,7 +1150,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 49b202d..751b8a8 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1004,7 +1004,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..67dbb11 --- /dev/null +++ b/src/utils/ticketErrors.test.ts @@ -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); + }); +}); 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; +} From 533fc3073f24c3a6dc8c280c99ff4e1cf0c27a7d Mon Sep 17 00:00:00 2001 From: Fringg Date: Tue, 11 Aug 2026 13:44:00 +0300 Subject: [PATCH 2/2] =?UTF-8?q?test(support):=20=D0=B7=D0=B0=D0=BA=D1=80?= =?UTF-8?q?=D0=B5=D0=BF=D0=B8=D1=82=D1=8C=20=D0=BF=D1=80=D0=B8=D0=B2=D1=8F?= =?UTF-8?q?=D0=B7=D0=BA=D1=83=20onError=20=D0=BA=20=D0=BC=D1=83=D1=82?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=D0=BC=20=D1=82=D0=B8=D0=BA=D0=B5=D1=82?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дополнение к PR #548 при мерже. Код менять не пришлось — добавлены три теста. Решение «это 409» автор вынес в утилиту и покрыл хорошо, а вот сама привязка onError к мутациям осталась непокрытой — при том, что её отсутствие и есть исходный баг: форма молчала, человек жал «Отправить» повторно. Проверил мутациями: снятие onError у createMutation, снятие onError у replyMutation и удаление обновления списка тикетов в ветке 409 набор не роняли. Окружения для рендера компонентов в репозитории нет (vitest настроен на node, без jsdom), поэтому закрепил структурно, по исходнику — приём тот же, что уже используется в бот-репозитории. Крудовато, но ловит ровно ту регрессию, ради которой PR и написан. Остальное проверено, правок не потребовалось. Несущее допущение верно: 409 в app/cabinet/routes/tickets.py встречается ровно один раз и именно про открытый тикет, так что матч по статусу вместо текста detail — правильный выбор (текст приходит только по-английски, а кабинет и бот версионируются раздельно). Переименование rateLimitError → formError сквозное, хвостов не осталось; формы create и reply взаимоисключающие, состояние сбрасывается на всех переходах. Ключи support.errors.* есть во всех четырёх локалях. Шесть предупреждений biome в Support.tsx — предсуществующие, столько же на чистом dev. --- src/utils/ticketErrors.test.ts | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/utils/ticketErrors.test.ts b/src/utils/ticketErrors.test.ts index 67dbb11..1cd389f 100644 --- a/src/utils/ticketErrors.test.ts +++ b/src/utils/ticketErrors.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import { AxiosError, AxiosHeaders } from 'axios'; import { describe, expect, it } from 'vitest'; import { isOpenTicketConflict } from './ticketErrors'; @@ -48,3 +49,37 @@ describe('isOpenTicketConflict', () => { 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'); + }); +});