mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-09-13 16:23:08 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -1004,7 +1004,12 @@
|
||||
"ticketsDisabled": "تیکتها غیرفعال است",
|
||||
"useExternalLink": "لطفاً از لینک خارجی برای دریافت پشتیبانی استفاده کنید",
|
||||
"create_ticket": "ایجاد تیکت",
|
||||
"writeButton": "پیام"
|
||||
"writeButton": "پیام",
|
||||
"errors": {
|
||||
"alreadyOpenTicket": "شما در حال حاضر یک تیکت باز دارید. گفتگو را در همان تیکت ادامه دهید؛ پس از بسته شدن آن میتوانید تیکت جدید ایجاد کنید.",
|
||||
"createFailed": "ایجاد تیکت ناموفق بود. لطفاً دوباره تلاش کنید.",
|
||||
"replyFailed": "ارسال پاسخ ناموفق بود. لطفاً دوباره تلاش کنید."
|
||||
}
|
||||
},
|
||||
"wheel": {
|
||||
"title": "چرخ شانس",
|
||||
|
||||
@@ -1150,7 +1150,12 @@
|
||||
"contactSupport": "Для получения поддержки обратитесь к {{username}}",
|
||||
"contactUs": "Связаться с поддержкой",
|
||||
"create_ticket": "Создать тикет",
|
||||
"writeButton": "Написать"
|
||||
"writeButton": "Написать",
|
||||
"errors": {
|
||||
"alreadyOpenTicket": "У вас уже есть открытое обращение. Продолжите переписку в нём — новое можно создать после закрытия текущего.",
|
||||
"createFailed": "Не удалось создать обращение. Попробуйте ещё раз.",
|
||||
"replyFailed": "Не удалось отправить ответ. Попробуйте ещё раз."
|
||||
}
|
||||
},
|
||||
"wheel": {
|
||||
"title": "Колесо удачи",
|
||||
|
||||
@@ -1004,7 +1004,12 @@
|
||||
"ticketsDisabled": "工单已禁用",
|
||||
"useExternalLink": "请使用外部链接获取支持",
|
||||
"create_ticket": "创建工单",
|
||||
"writeButton": "发消息"
|
||||
"writeButton": "发消息",
|
||||
"errors": {
|
||||
"alreadyOpenTicket": "您已有一个未关闭的工单。请在该工单中继续沟通,当前工单关闭后即可创建新工单。",
|
||||
"createFailed": "创建工单失败,请重试。",
|
||||
"replyFailed": "发送回复失败,请重试。"
|
||||
}
|
||||
},
|
||||
"wheel": {
|
||||
"title": "幸运转盘",
|
||||
|
||||
@@ -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>
|
||||
|
||||
50
src/utils/ticketErrors.test.ts
Normal file
50
src/utils/ticketErrors.test.ts
Normal 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
14
src/utils/ticketErrors.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user