fix(reviews): friendly error в ReviewDrawer вместо AxiosError

This commit is contained in:
Александр Зимин
2026-05-13 16:41:05 +00:00
parent 0e165f69ca
commit 94360681b6
+72 -4
View File
@@ -23,6 +23,7 @@ import {
TextInput,
QueryErrorState,
} from '@/ui'
import axios from 'axios'
import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react'
import { useDraft, useLiveRecord, useReviewQueue, useSchemaReviewQueue } from '@/api/queries'
import { useApproveDraft, useRejectDraft } from '@/api/mutations'
@@ -402,6 +403,69 @@ type DrawerProps = {
onClose: () => void
}
/**
* Превращает Axios error в human-readable title + body. Mapping для
* известных backend кодов (см. {@code OrdinisException.conflict}):
* - self_approve_forbidden — maker = reviewer (D3 maker-checker invariant)
* - draft_not_pending — кто-то уже approve/reject'нул этот draft
* - forbidden_role — у actor'а нет нужной workflow роли (Phase 1d enforcement)
*
* Default fallback — backend message + статус код. Сырой "AxiosError ... 409"
* больше не показывается.
*/
function extractReviewError(err: unknown): { title: string; body: string } {
if (!err) return { title: 'Ошибка', body: '' }
if (axios.isAxiosError(err)) {
const status = err.response?.status
const data = err.response?.data as
| { code?: string; message?: string; error?: string }
| undefined
const code = data?.code
const msg = data?.message ?? data?.error ?? err.message
if (code === 'self_approve_forbidden') {
return {
title: 'Нельзя одобрить свой собственный черновик',
body:
'Maker-checker invariant: тот, кто создал draft, не может его approve. Попроси другого reviewer\'а из пула.',
}
}
if (code === 'self_reject_forbidden') {
return {
title: 'Нельзя отклонить свой собственный черновик',
body:
'Тот, кто создал draft, не может его reject. Если передумал — используй Отозвать (withdraw).',
}
}
if (code === 'draft_not_pending') {
return {
title: 'Draft уже не pending',
body: 'Кто-то уже принял решение по этому draft\'у. Обнови очередь.',
}
}
if (code === 'forbidden_role') {
return {
title: 'Недостаточно прав',
body: msg || 'У вас нет нужной workflow role для этого действия.',
}
}
if (code === 'reject_reason_required') {
return {
title: 'Укажите причину отклонения',
body: 'Поле «Комментарий ревьюера» обязательно для reject (для compliance).',
}
}
return {
title: `Ошибка ${status ?? ''}`.trim(),
body: msg,
}
}
return {
title: 'Ошибка',
body: err instanceof Error ? err.message : String(err),
}
}
function ReviewDrawer({ draftId, onClose }: DrawerProps) {
const { t } = useTranslation()
const draftQ = useDraft(draftId)
@@ -513,11 +577,15 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
</div>
</div>
{(approveMut.error || rejectMut.error) && (
<Alert variant="error" title={t('error.failed')}>
{String(approveMut.error || rejectMut.error)}
{(approveMut.error || rejectMut.error) && (() => {
const err = approveMut.error || rejectMut.error
const info = extractReviewError(err)
return (
<Alert variant="error" title={info.title}>
{info.body}
</Alert>
)}
)
})()}
<div className="border-t border-line pt-3 space-y-2">
<label