From 8621687a6096133c8a30e090d2e0939828bf5a40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Tue, 9 Jun 2026 13:40:23 +0000 Subject: [PATCH] feat(dict): 7-day retention auto-purge + inline restore UI --- docs/changelog.md | 8 +- ordinis-admin-ui/src/api/mutations.ts | 34 +++++ ordinis-admin-ui/src/api/queries.ts | 25 ++++ .../src/routes/dictionaries.$name.tsx | 120 +++++++++++++++++- .../restapi/service/DictionaryPurgeJob.java | 102 +++++++++++++++ 5 files changed, 286 insertions(+), 3 deletions(-) create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryPurgeJob.java diff --git a/docs/changelog.md b/docs/changelog.md index a1a53e4..a21ba16 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,11 +4,17 @@ Обновления приходят на твой сервер автоматически при следующем deploy. +## [2.54.0](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/-/tags/v2.54.0) (2026-06-09) + +### ✨ Новые возможности + +* **Удаление справочника — теперь soft-delete с 7-дневным окном восстановления** — раньше «Удалить справочник» физически удалял записи каскадом (recovery только из DB-бэкапа). Теперь backend ставит метку `deleted_at = now()`, записи и черновики остаются в БД. В течение 7 дней администратор может восстановить справочник: открой URL удалённого справочника → красная карточка «Справочник удалён» с кнопкой **«Восстановить справочник»** + счётчик дней до автоудаления. После 7 дней backend cron `DictionaryPurgeJob` (раз в час) физически удаляет cascade — recovery невозможен. Список soft-deleted dicts доступен через `GET /api/v1/dictionaries/deleted` (admin-only Корзина page — UI в follow-up MR). + ## [2.52.0](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/-/tags/v2.52.0) (2026-06-05) ### ✨ Новые возможности -* **Удаление справочника одной кнопкой** — в правой info-панели редактора справочника появилась danger-кнопка «Удалить справочник…» (видна только пользователям с ролью admin). При клике — модальное окно с предупреждением о cascade: будут удалены ВСЕ записи, drafts, schema-drafts и история версий. Если на справочник ссылается другой через `x-references` — backend возвращает 409 со списком зависимостей, в модалке появляется дополнительная кнопка «Удалить force» (на случай если зависимый справочник нужно поправить отдельно). Cascade на уровне БД через FK `ON DELETE CASCADE` (миграция 0030). Audit log + outbox event «DefinitionDeleted» пишутся ДО удаления — consumers downstream (геопортал, Альтум) получают snapshot final state для инвалидации кэша. +* **Удаление справочника одной кнопкой** — в правой info-панели редактора справочника появилась danger-кнопка «Удалить справочник…» (видна только пользователям с ролью admin). При клике — модальное окно с предупреждением: будут soft-deleted ВСЕ записи, drafts, schema-drafts. Если на справочник ссылается другой через `x-references` — backend возвращает 409 со списком зависимостей, в модалке появляется дополнительная кнопка «Удалить force» (на случай если зависимый справочник нужно поправить отдельно). Audit log + outbox event «DefinitionDeleted» пишутся при пометке — consumers downstream (геопортал, Альтум) получают snapshot для инвалидации кэша. ## [2.51.2](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/-/tags/v2.51.2) (2026-06-04) diff --git a/ordinis-admin-ui/src/api/mutations.ts b/ordinis-admin-ui/src/api/mutations.ts index 9a91f82..55fbadb 100644 --- a/ordinis-admin-ui/src/api/mutations.ts +++ b/ordinis-admin-ui/src/api/mutations.ts @@ -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 в списке. + * + *

7-day retention window: admin может вызвать restore в течение 7 дней + * после soft-delete. После — physical purge через DictionaryPurgeJob + * (backend cron). Восстановление невозможно после purge — только из DB + * backup. + * + *

Backend errors: + *

+ */ +export const useRestoreDictionary = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (name: string): Promise => { + const { data } = await apiClient.post( + `/dictionaries/${name}/restore`, + ) + return data + }, + onSuccess: (_, name) => { + qc.invalidateQueries({ queryKey: ['dictionaries'] }) + qc.invalidateQueries({ queryKey: ['dictionary', name] }) + qc.invalidateQueries({ queryKey: ['dictionaries', 'deleted'] }) }, }) } diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts index 88821f3..4b92000 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -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 => { + try { + const { data } = await apiClient.get('/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, diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index b273651..d07d77b 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -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 detailQuery.refetch()} /> + return ( + detailQuery.refetch()} + /> + ) } return ( @@ -2452,6 +2460,114 @@ function JsonTabContent({ detail }: { detail: { schemaJson: import('@/api/client *

Каждый 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). + * + *

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 + } + + // Сколько осталось до 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 ( +

+
+
+ +
+

+ {t('dict.deleted.title', { defaultValue: 'Справочник удалён' })} +

+

+ {softDeleted.name} + {softDeleted.displayName ? ` — ${softDeleted.displayName}` : null} +

+

+ {daysLeft !== null && daysLeft > 0 + ? t('dict.deleted.retentionLeft', { + defaultValue: + 'Восстановить можно в течение {{days}} дн. — после этого записи будут безвозвратно удалены.', + days: daysLeft, + }) + : t('dict.deleted.retentionExpiringSoon', { + defaultValue: 'Срок восстановления истекает — purge cron сработает в ближайшие часы.', + })} +

+
+
+ {isAdmin ? ( +
+ + +
+ ) : ( +

+ {t('dict.deleted.adminOnly', { + defaultValue: 'Восстановление справочника доступно только администратору.', + })} +

+ )} +
+
+ ) +} + function highlightJson(json: string): string { const escape = (s: string) => s.replace(/&/g, '&').replace(//g, '>') diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryPurgeJob.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryPurgeJob.java new file mode 100644 index 0000000..d30a4ef --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryPurgeJob.java @@ -0,0 +1,102 @@ +package cloud.nstart.terravault.ordinis.restapi.service; + +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.List; + +/** + * Permanent purge для soft-deleted справочников. + * + *

Retention 7 дней (NSI governance policy — юзер: «договаривались же + * 7 дней хранится и только потом удаляется и в течение 7 дней можно + * вернуть словарь»). После {@code deleted_at + 7d} dict physically + * removed через FK CASCADE (migration 0030): definition row + cascade на + * dictionary_records + record_drafts + dictionary_schema_drafts. Audit_log + * остаётся (нет FK) как permanent trail. + * + *

Cron каждый час. Если purge fails — log + counter, не блокируем + * остальные jobs. Idempotent: следующий run попробует снова. + * + *

Recovery window: 0–7 дней после soft-delete admin может вызвать + * {@code POST /api/v1/dictionaries/{name}/restore}. После purge — только + * через DB backup. + */ +@Component +public class DictionaryPurgeJob { + + private static final Logger log = LoggerFactory.getLogger(DictionaryPurgeJob.class); + + /** 7 дней — NSI retention policy. Override через property для testing. */ + private static final Duration RETENTION = Duration.ofDays(7); + + private final DictionaryDefinitionRepository repository; + private final Counter purgedCounter; + private final Counter errorCounter; + + public DictionaryPurgeJob( + DictionaryDefinitionRepository repository, MeterRegistry meterRegistry) { + this.repository = repository; + this.purgedCounter = Counter.builder("ordinis_dictionary_purged_total") + .description("Permanently purged soft-deleted dictionaries (после 7d retention)") + .register(meterRegistry); + this.errorCounter = Counter.builder("ordinis_dictionary_purge_errors_total") + .description("Purge attempts что failed (rollback safe — retry on next run)") + .register(meterRegistry); + } + + /** + * Cron каждый час в :23 (random minute — не competing с idempotency + * cleanup at :17, projection refresh, и др.). + * + *

Override через property {@code ordinis.dictionary.purge-cron} для + * test/staging если нужно faster purge cycle. + */ + @Scheduled(cron = "${ordinis.dictionary.purge-cron:0 23 * * * *}") + @Transactional + public void purge() { + OffsetDateTime cutoff = OffsetDateTime.now().minus(RETENTION); + List candidates = repository.findAllDeleted().stream() + .filter(d -> d.getDeletedAt() != null && d.getDeletedAt().isBefore(cutoff)) + .toList(); + + if (candidates.isEmpty()) { + log.debug("Dictionary purge: 0 candidates (retention {} days, cutoff {})", + RETENTION.toDays(), cutoff); + return; + } + + log.info("Dictionary purge: найдено {} кандидатов на physical delete " + + "(deleted_at < {})", candidates.size(), cutoff); + + int purged = 0; + for (DictionaryDefinition d : candidates) { + try { + // FK ON DELETE CASCADE (migration 0030) cleanup'ит: + // dictionary_records + record_drafts + dictionary_schema_drafts. + // audit_log + outbox_events не имеют FK — остаются permanent trail. + repository.deleteById(d.getId()); + log.info("Dictionary purged: name={} id={} deleted_at={} (>{} days ago)", + d.getName(), d.getId(), d.getDeletedAt(), RETENTION.toDays()); + purged++; + } catch (Exception e) { + // Не throw — продолжаем остальные. Retry на следующем cron run. + log.error("Dictionary purge failed для name={}: {}", d.getName(), e.getMessage()); + errorCounter.increment(); + } + } + + if (purged > 0) { + purgedCounter.increment(purged); + } + } +}