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",
|
"contactSupport": "Please contact {{username}} for support",
|
||||||
"contactUs": "Contact Support",
|
"contactUs": "Contact Support",
|
||||||
"create_ticket": "Create ticket",
|
"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": {
|
"wheel": {
|
||||||
"title": "Fortune Wheel",
|
"title": "Fortune Wheel",
|
||||||
|
|||||||
@@ -1004,7 +1004,12 @@
|
|||||||
"ticketsDisabled": "تیکتها غیرفعال است",
|
"ticketsDisabled": "تیکتها غیرفعال است",
|
||||||
"useExternalLink": "لطفاً از لینک خارجی برای دریافت پشتیبانی استفاده کنید",
|
"useExternalLink": "لطفاً از لینک خارجی برای دریافت پشتیبانی استفاده کنید",
|
||||||
"create_ticket": "ایجاد تیکت",
|
"create_ticket": "ایجاد تیکت",
|
||||||
"writeButton": "پیام"
|
"writeButton": "پیام",
|
||||||
|
"errors": {
|
||||||
|
"alreadyOpenTicket": "شما در حال حاضر یک تیکت باز دارید. گفتگو را در همان تیکت ادامه دهید؛ پس از بسته شدن آن میتوانید تیکت جدید ایجاد کنید.",
|
||||||
|
"createFailed": "ایجاد تیکت ناموفق بود. لطفاً دوباره تلاش کنید.",
|
||||||
|
"replyFailed": "ارسال پاسخ ناموفق بود. لطفاً دوباره تلاش کنید."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"wheel": {
|
"wheel": {
|
||||||
"title": "چرخ شانس",
|
"title": "چرخ شانس",
|
||||||
|
|||||||
@@ -1150,7 +1150,12 @@
|
|||||||
"contactSupport": "Для получения поддержки обратитесь к {{username}}",
|
"contactSupport": "Для получения поддержки обратитесь к {{username}}",
|
||||||
"contactUs": "Связаться с поддержкой",
|
"contactUs": "Связаться с поддержкой",
|
||||||
"create_ticket": "Создать тикет",
|
"create_ticket": "Создать тикет",
|
||||||
"writeButton": "Написать"
|
"writeButton": "Написать",
|
||||||
|
"errors": {
|
||||||
|
"alreadyOpenTicket": "У вас уже есть открытое обращение. Продолжите переписку в нём — новое можно создать после закрытия текущего.",
|
||||||
|
"createFailed": "Не удалось создать обращение. Попробуйте ещё раз.",
|
||||||
|
"replyFailed": "Не удалось отправить ответ. Попробуйте ещё раз."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"wheel": {
|
"wheel": {
|
||||||
"title": "Колесо удачи",
|
"title": "Колесо удачи",
|
||||||
|
|||||||
@@ -1004,7 +1004,12 @@
|
|||||||
"ticketsDisabled": "工单已禁用",
|
"ticketsDisabled": "工单已禁用",
|
||||||
"useExternalLink": "请使用外部链接获取支持",
|
"useExternalLink": "请使用外部链接获取支持",
|
||||||
"create_ticket": "创建工单",
|
"create_ticket": "创建工单",
|
||||||
"writeButton": "发消息"
|
"writeButton": "发消息",
|
||||||
|
"errors": {
|
||||||
|
"alreadyOpenTicket": "您已有一个未关闭的工单。请在该工单中继续沟通,当前工单关闭后即可创建新工单。",
|
||||||
|
"createFailed": "创建工单失败,请重试。",
|
||||||
|
"replyFailed": "发送回复失败,请重试。"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"wheel": {
|
"wheel": {
|
||||||
"title": "幸运转盘",
|
"title": "幸运转盘",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { infoApi } from '../api/info';
|
|||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit';
|
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 type { SupportConfig, TicketDetail } from '../types';
|
||||||
import { Card } from '@/components/data-display/Card';
|
import { Card } from '@/components/data-display/Card';
|
||||||
import { Button } from '@/components/primitives/Button';
|
import { Button } from '@/components/primitives/Button';
|
||||||
@@ -55,7 +57,10 @@ export default function Support() {
|
|||||||
const [newTitle, setNewTitle] = useState('');
|
const [newTitle, setNewTitle] = useState('');
|
||||||
const [newMessage, setNewMessage] = useState('');
|
const [newMessage, setNewMessage] = useState('');
|
||||||
const [replyMessage, setReplyMessage] = 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)
|
// Media attachment states (multi-upload, up to 10)
|
||||||
const [createAttachments, setCreateAttachments] = useState<MediaAttachment[]>([]);
|
const [createAttachments, setCreateAttachments] = useState<MediaAttachment[]>([]);
|
||||||
@@ -154,11 +159,26 @@ export default function Support() {
|
|||||||
onSuccess: (ticket) => {
|
onSuccess: (ticket) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
||||||
setShowCreateForm(false);
|
setShowCreateForm(false);
|
||||||
|
setFormError(null);
|
||||||
setNewTitle('');
|
setNewTitle('');
|
||||||
setNewMessage('');
|
setNewMessage('');
|
||||||
clearCreateAttachments();
|
clearCreateAttachments();
|
||||||
setSelectedTicket(ticket);
|
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({
|
const replyMutation = useMutation({
|
||||||
@@ -176,9 +196,16 @@ export default function Support() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] });
|
queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] });
|
||||||
|
setFormError(null);
|
||||||
setReplyMessage('');
|
setReplyMessage('');
|
||||||
clearReplyAttachments();
|
clearReplyAttachments();
|
||||||
},
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
// Ответ в тикет молчал ровно так же: 403 (блокировка в поддержке) и 400
|
||||||
|
// (тикет уже закрыт) выглядели как «кнопка не работает».
|
||||||
|
log.error('Ticket reply failed', error);
|
||||||
|
setFormError(getApiErrorMessage(error, t('support.errors.replyFailed')));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
@@ -321,6 +348,7 @@ export default function Support() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowCreateForm(true);
|
setShowCreateForm(true);
|
||||||
setSelectedTicket(null);
|
setSelectedTicket(null);
|
||||||
|
setFormError(null);
|
||||||
clearCreateAttachments();
|
clearCreateAttachments();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -374,6 +402,7 @@ export default function Support() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedTicket(ticket as unknown as TicketDetail);
|
setSelectedTicket(ticket as unknown as TicketDetail);
|
||||||
setShowCreateForm(false);
|
setShowCreateForm(false);
|
||||||
|
setFormError(null);
|
||||||
clearReplyAttachments();
|
clearReplyAttachments();
|
||||||
}}
|
}}
|
||||||
className={`w-full rounded-bento border p-4 text-left transition-all ${
|
className={`w-full rounded-bento border p-4 text-left transition-all ${
|
||||||
@@ -414,11 +443,11 @@ export default function Support() {
|
|||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setRateLimitError(null);
|
setFormError(null);
|
||||||
// Rate limit: max 3 tickets per 60 seconds
|
// Rate limit: max 3 tickets per 60 seconds
|
||||||
if (!checkRateLimit(RATE_LIMIT_KEYS.TICKET_CREATE, 3, 60000)) {
|
if (!checkRateLimit(RATE_LIMIT_KEYS.TICKET_CREATE, 3, 60000)) {
|
||||||
const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.TICKET_CREATE);
|
const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.TICKET_CREATE);
|
||||||
setRateLimitError(t('support.tooManyRequests', { seconds: resetTime }));
|
setFormError(t('support.tooManyRequests', { seconds: resetTime }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
createMutation.mutate();
|
createMutation.mutate();
|
||||||
@@ -495,9 +524,9 @@ export default function Support() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{rateLimitError && (
|
{formError && (
|
||||||
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
|
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
|
||||||
{rateLimitError}
|
{formError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -515,6 +544,7 @@ export default function Support() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowCreateForm(false);
|
setShowCreateForm(false);
|
||||||
|
setFormError(null);
|
||||||
clearCreateAttachments();
|
clearCreateAttachments();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -589,11 +619,11 @@ export default function Support() {
|
|||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setRateLimitError(null);
|
setFormError(null);
|
||||||
// Rate limit: max 5 replies per 30 seconds
|
// Rate limit: max 5 replies per 30 seconds
|
||||||
if (!checkRateLimit(RATE_LIMIT_KEYS.TICKET_REPLY, 5, 30000)) {
|
if (!checkRateLimit(RATE_LIMIT_KEYS.TICKET_REPLY, 5, 30000)) {
|
||||||
const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.TICKET_REPLY);
|
const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.TICKET_REPLY);
|
||||||
setRateLimitError(t('support.tooManyRequests', { seconds: resetTime }));
|
setFormError(t('support.tooManyRequests', { seconds: resetTime }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
replyMutation.mutate();
|
replyMutation.mutate();
|
||||||
@@ -662,9 +692,9 @@ export default function Support() {
|
|||||||
<SendIcon className="h-4 w-4" />
|
<SendIcon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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">
|
<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>
|
||||||
)}
|
)}
|
||||||
</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