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: + *
Каждый 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
+ {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 сработает в ближайшие часы.', + })} +
++ {t('dict.deleted.adminOnly', { + defaultValue: 'Восстановление справочника доступно только администратору.', + })} +
+ )} +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