feat(dict): 7-day retention auto-purge + inline restore UI

This commit is contained in:
Александр Зимин
2026-06-09 13:40:23 +00:00
parent 05d2093273
commit 8621687a60
5 changed files with 286 additions and 3 deletions
+34
View File
@@ -158,6 +158,40 @@ export const useDeleteDictionary = () => {
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['dictionaries'] })
qc.invalidateQueries({ queryKey: ['dictionaries', 'deleted'] })
},
})
}
/**
* Восстановление soft-deleted справочника. Admin-only. Сбрасывает
* deleted_at/deleted_by в NULL → dict снова visible в списке.
*
* <p>7-day retention window: admin может вызвать restore в течение 7 дней
* после soft-delete. После — physical purge через DictionaryPurgeJob
* (backend cron). Восстановление невозможно после purge — только из DB
* backup.
*
* <p>Backend errors:
* <ul>
* <li>403 forbidden_role — не admin</li>
* <li>404 dictionary_not_found — name never existed</li>
* <li>409 not_deleted — dict не помечен как удалённый</li>
* </ul>
*/
export const useRestoreDictionary = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (name: string): Promise<DictionaryDetail> => {
const { data } = await apiClient.post<DictionaryDetail>(
`/dictionaries/${name}/restore`,
)
return data
},
onSuccess: (_, name) => {
qc.invalidateQueries({ queryKey: ['dictionaries'] })
qc.invalidateQueries({ queryKey: ['dictionary', name] })
qc.invalidateQueries({ queryKey: ['dictionaries', 'deleted'] })
},
})
}
+25
View File
@@ -783,6 +783,31 @@ export const useDictionaries = () => useQuery(dictionariesQuery)
export const useDictionaryDetail = (name: string | undefined) =>
useQuery({ ...dictionaryDetailQuery(name ?? ''), enabled: Boolean(name) })
/**
* Soft-deleted справочники (Корзина admin page). Admin-only — non-admin
* получит 403, fallback на пустой массив. 7-day retention window: dict
* физически purgе'ится через DictionaryPurgeJob cron после deleted_at +
* 7 дней. Restore возможен ТОЛЬКО до purge.
*/
export const useDictionariesDeleted = (enabled = true) =>
useQuery({
queryKey: ['dictionaries', 'deleted'],
queryFn: async (): Promise<DictionaryDetail[]> => {
try {
const { data } = await apiClient.get<DictionaryDetail[]>('/dictionaries/deleted')
return data
} catch (err) {
if ((err as { response?: { status?: number } })?.response?.status === 403) {
return []
}
throw err
}
},
enabled,
staleTime: 30_000,
retry: false,
})
/**
* Список всех закэшированных юзеров — для пикеров «Пользователь» в
* фильтрах audit/reviews. INTERNAL+ scope; non-INTERNAL получит 403,
@@ -31,6 +31,7 @@ import {
useAudit,
useChangelog,
useDictActiveSchemaDraft,
useDictionariesDeleted,
useDictionaryDetail,
useDictPendingDrafts,
useRecordRaw,
@@ -38,7 +39,7 @@ import {
useScheduledSummary,
type RecordsFilter,
} from '@/api/queries'
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useDeleteDictionary, useFormIdempotencyKey, useSubmitDraft } from '@/api/mutations'
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useDeleteDictionary, useRestoreDictionary, useFormIdempotencyKey, useSubmitDraft } from '@/api/mutations'
import { useCanMutate } from '@/auth/useCanMutate'
import { useIsAdmin } from '@/auth/usePermissions'
import { cn } from '@/lib/utils'
@@ -863,7 +864,14 @@ function DictionaryDetail() {
)
}
if (detailQuery.error) {
return <QueryErrorState error={detailQuery.error} onRetry={() => detailQuery.refetch()} />
return (
<DictionaryErrorOrSoftDeletedCard
name={name}
error={detailQuery.error}
isAdmin={isAdmin}
onRetry={() => detailQuery.refetch()}
/>
)
}
return (
@@ -2452,6 +2460,114 @@ function JsonTabContent({ detail }: { detail: { schemaJson: import('@/api/client
* <p>Каждый escape перед взаимодействием: source already JSON.stringify'd
* (safe), но regex применяется к HTML так что & < > > сначала escape'аются.
*/
/**
* Error state для route когда `useDictionaryDetail` returns 404 либо другой
* error. Special-case: если dict в /dictionaries/deleted (Корзина) show
* inline restore card вместо generic error. 7-day retention window:
* admin может restore в этот период, дальше DictionaryPurgeJob physically
* removes (recovery только из DB backup).
*
* <p>Non-admin / dict не в Корзине стандартный QueryErrorState.
*/
function DictionaryErrorOrSoftDeletedCard({
name,
error,
isAdmin,
onRetry,
}: {
name: string
error: unknown
isAdmin: boolean
onRetry: () => void
}) {
const { t } = useTranslation()
const navigate = useNavigate({ from: Route.fullPath })
const restoreMut = useRestoreDictionary()
// Дёргаем Корзину только если admin (non-admin получает 403 от backend).
const deletedQ = useDictionariesDeleted(isAdmin)
const status = (error as { response?: { status?: number } })?.response?.status
const isNotFound = status === 404
const softDeleted = deletedQ.data?.find((d) => d.name === name)
if (!isNotFound || !softDeleted) {
return <QueryErrorState error={error} onRetry={onRetry} />
}
// Сколько осталось до auto-purge (7-day retention).
const deletedAt =
(softDeleted as unknown as { deletedAt?: string; updatedAt?: string }).deletedAt
?? softDeleted.updatedAt
const deletedDate = deletedAt ? new Date(deletedAt) : null
const purgeDate = deletedDate ? new Date(deletedDate.getTime() + 7 * 24 * 60 * 60 * 1000) : null
const daysLeft = purgeDate
? Math.ceil((purgeDate.getTime() - Date.now()) / (24 * 60 * 60 * 1000))
: null
return (
<div className="max-w-2xl mx-auto mt-12">
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-5 py-4 space-y-4">
<div className="flex items-start gap-3">
<TrashIcon size={24} weight="regular" className="text-destructive shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<h2 className="text-title-md text-ink mb-1">
{t('dict.deleted.title', { defaultValue: 'Справочник удалён' })}
</h2>
<p className="text-cell text-ink-2 mb-1">
<strong>{softDeleted.name}</strong>
{softDeleted.displayName ? `${softDeleted.displayName}` : null}
</p>
<p className="text-cell text-ink-2">
{daysLeft !== null && daysLeft > 0
? t('dict.deleted.retentionLeft', {
defaultValue:
'Восстановить можно в течение {{days}} дн. — после этого записи будут безвозвратно удалены.',
days: daysLeft,
})
: t('dict.deleted.retentionExpiringSoon', {
defaultValue: 'Срок восстановления истекает — purge cron сработает в ближайшие часы.',
})}
</p>
</div>
</div>
{isAdmin ? (
<div className="flex justify-end gap-2 pt-2 border-t border-destructive/20">
<button
type="button"
className="h-9 px-4 rounded-md border border-line bg-surface text-ink-2 text-cell hover:bg-surface-2"
onClick={() => navigate({ to: '/dictionaries' })}
>
{t('dict.deleted.backToList', { defaultValue: 'К списку справочников' })}
</button>
<button
type="button"
className="h-9 px-4 rounded-md bg-accent text-on-accent text-cell font-medium hover:bg-accent/85 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => {
restoreMut.mutate(name, {
onSuccess: () => {
// После restore — refetch detail + navigate back to dict.
onRetry()
},
})
}}
disabled={restoreMut.isPending}
>
{restoreMut.isPending
? t('dict.deleted.restorePending', { defaultValue: 'Восстанавливаю…' })
: t('dict.deleted.restore', { defaultValue: 'Восстановить справочник' })}
</button>
</div>
) : (
<p className="text-cell text-mute pt-2 border-t border-destructive/20">
{t('dict.deleted.adminOnly', {
defaultValue: 'Восстановление справочника доступно только администратору.',
})}
</p>
)}
</div>
</div>
)
}
function highlightJson(json: string): string {
const escape = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')