From 5c6801f993a34cfa43e71419807250973ce376ac 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: Fri, 5 Jun 2026 09:47:05 +0000 Subject: [PATCH] =?UTF-8?q?feat(dict):=20delete=20dictionary=20endpoint=20?= =?UTF-8?q?+=20UI=20button=20=D1=81=20cascade=20safety?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ordinis-admin-ui/src/api/mutations.ts | 26 ++++ .../src/components/editor/EditorInfoPanel.tsx | 17 +++ .../src/routes/dictionaries.$name.tsx | 128 +++++++++++++++++- .../DictionaryDefinitionDeleted.java | 34 +++++ .../definition/DictionaryDefinitionEvent.java | 5 +- .../0030-dictionary-delete-cascade.xml | 84 ++++++++++++ .../main/resources/db/changelog/master.xml | 1 + .../ordinis/restapi/audit/AuditLogger.java | 10 ++ .../service/DictionaryDefinitionService.java | 62 +++++++++ .../web/DictionaryDefinitionController.java | 74 +++++++++- 10 files changed, 437 insertions(+), 4 deletions(-) create mode 100644 ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionDeleted.java create mode 100644 ordinis-migrations/src/main/resources/db/changelog/changes/0030-dictionary-delete-cascade.xml diff --git a/ordinis-admin-ui/src/api/mutations.ts b/ordinis-admin-ui/src/api/mutations.ts index ff95cbc..9a91f82 100644 --- a/ordinis-admin-ui/src/api/mutations.ts +++ b/ordinis-admin-ui/src/api/mutations.ts @@ -136,6 +136,32 @@ export const useUpdateDictionary = () => { }) } +/** + * Удаление справочника. Cascade на records + drafts + schema_drafts через + * FK ON DELETE CASCADE (migration 0030). Только admin role. + * + *

Backend errors: + *

+ */ +export const useDeleteDictionary = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (params: { name: string; force?: boolean }): Promise => { + const qs = params.force ? '?force=true' : '' + await apiClient.delete(`/dictionaries/${params.name}${qs}`) + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['dictionaries'] }) + }, + }) +} + /** * Create-record mutation. {@code idempotencyKey} — required, передаётся caller'ом * (один на жизнь формы; см. {@link useFormIdempotencyKey}). Это защищает от diff --git a/ordinis-admin-ui/src/components/editor/EditorInfoPanel.tsx b/ordinis-admin-ui/src/components/editor/EditorInfoPanel.tsx index 671017a..a3b96fb 100644 --- a/ordinis-admin-ui/src/components/editor/EditorInfoPanel.tsx +++ b/ordinis-admin-ui/src/components/editor/EditorInfoPanel.tsx @@ -40,6 +40,11 @@ export type EditorInfoPanelProps = { aoiActive?: boolean onExport?: () => void exportPending?: boolean + /** Удалить справочник — admin-only action. Caller гейтит видимость + * через `useIsAdmin()`. Cascade delete на records/drafts/audit + * (backend migration 0030 + DictionaryDefinitionService.delete). */ + onDelete?: () => void + deletePending?: boolean } /** Optional banner slot — WorkflowBanner / status notices живут в * left panel per user feedback ("плашки пусть в левой панели будут"). @@ -231,6 +236,18 @@ export function EditorInfoPanel({ detail, recordCount, actions, banner }: Editor {t('aoi.button', { defaultValue: 'AOI фильтр…' })} )} + {actions.onDelete && ( + + )} )} diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index ff47d1f..7ecaf04 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -38,8 +38,9 @@ import { useScheduledSummary, type RecordsFilter, } from '@/api/queries' -import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useFormIdempotencyKey, useSubmitDraft } from '@/api/mutations' +import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useDeleteDictionary, useFormIdempotencyKey, useSubmitDraft } from '@/api/mutations' import { useCanMutate } from '@/auth/useCanMutate' +import { useIsAdmin } from '@/auth/usePermissions' import { cn } from '@/lib/utils' import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client' import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm' @@ -223,6 +224,11 @@ function DictionaryDetail() { const closeMut = useCloseRecord(name) const bulkCloseMut = useBulkCloseRecords(name) const bulkExportMut = useBulkExportRecords(name) + // Delete dict mutation — admin-only через UI button в InfoPanel. + const deleteMut = useDeleteDictionary() + const isAdmin = useIsAdmin() + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) + const [deleteError, setDeleteError] = useState(null) const [edit, setEdit] = useState({ kind: 'closed' }) const [closeReason, setCloseReason] = useState('') @@ -908,6 +914,12 @@ function DictionaryDetail() { // выбирает формат/scope/columns/encoding/delimiter. onExport: () => setExportOpen(true), exportPending: bulkExportMut.isPending, + // Удалить справочник — только админ. Cascade delete на + // records/drafts/audit (FK CASCADE migration 0030). + // Confirmation modal проверяет dependents через backend + // и предлагает force option если нужно. + onDelete: isAdmin ? () => setDeleteConfirmOpen(true) : undefined, + deletePending: deleteMut.isPending, }} /> )} @@ -1813,6 +1825,120 @@ function DictionaryDetail() { approvalMode={approvalRequired} /> + + {/* Delete dict confirmation modal — admin-only. Cascade на + * records + drafts + schema_drafts через FK CASCADE (migration 0030). + * Backend проверяет incoming dependents через lineage и rejектит с + * 409 has_dependents если другой dict ссылается через x-references. + * UI показывает error text + предлагает force option. */} + { + if (deleteMut.isPending) return + setDeleteConfirmOpen(false) + setDeleteError(null) + }} + title={t('dict.delete.title', { defaultValue: 'Удалить справочник?' })} + maxWidth="max-w-lg" + > +
+

+ {t('dict.delete.warning', { + defaultValue: + 'Будут удалены: ВСЕ записи справочника, drafts, schema-drafts, история версий. Эта операция необратима.', + })} +

+
+ {name} + {detailQuery.data?.displayName ? ` — ${detailQuery.data.displayName}` : null} +
+ {deleteError && ( +
+ {deleteError} +
+ )} +
+ + + {deleteError?.includes('has_dependents') || + (deleteError && + deleteError.toLowerCase().includes('зависимы')) ? ( + + ) : null} +
+
+
) } diff --git a/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionDeleted.java b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionDeleted.java new file mode 100644 index 0000000..ab19dbb --- /dev/null +++ b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionDeleted.java @@ -0,0 +1,34 @@ +package cloud.nstart.terravault.ordinis.events.definition; + +import cloud.nstart.terravault.ordinis.events.DataScope; +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Удаление справочника (definition). Записывается в outbox + audit_log + * ПЕРЕД cascade delete child rows — у consumers'ов остаётся пэйлоад, + * чтобы инвалидировать cache / уведомить downstream geoportal / etc. + * + *

Trigger: {@code DELETE /api/v1/dictionaries/{name}} админом. Backend + * валидирует отсутствие incoming FK dependents (lineage), затем + * cascade-delete'ит records / drafts / schema_drafts через FK CASCADE + * (migration 0030). + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record DictionaryDefinitionDeleted( + UUID definitionId, + String dictionaryName, + String displayName, + DataScope scope, + String bundle, + long recordsDeleted, + long draftsDeleted, + long schemaDraftsDeleted, + OffsetDateTime deletedAt, + String deletedBy) implements DictionaryDefinitionEvent { + + @com.fasterxml.jackson.annotation.JsonProperty("type") + public String type() { return "DefinitionDeleted"; } +} diff --git a/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionEvent.java b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionEvent.java index b322c4a..aaac3aa 100644 --- a/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionEvent.java +++ b/ordinis-events-api/src/main/java/cloud/nstart/terravault/ordinis/events/definition/DictionaryDefinitionEvent.java @@ -6,10 +6,11 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ @JsonSubTypes.Type(value = DictionaryDefinitionCreated.class, name = "DefinitionCreated"), - @JsonSubTypes.Type(value = DictionaryDefinitionUpdated.class, name = "DefinitionUpdated") + @JsonSubTypes.Type(value = DictionaryDefinitionUpdated.class, name = "DefinitionUpdated"), + @JsonSubTypes.Type(value = DictionaryDefinitionDeleted.class, name = "DefinitionDeleted") }) public sealed interface DictionaryDefinitionEvent - permits DictionaryDefinitionCreated, DictionaryDefinitionUpdated { + permits DictionaryDefinitionCreated, DictionaryDefinitionUpdated, DictionaryDefinitionDeleted { java.util.UUID definitionId(); diff --git a/ordinis-migrations/src/main/resources/db/changelog/changes/0030-dictionary-delete-cascade.xml b/ordinis-migrations/src/main/resources/db/changelog/changes/0030-dictionary-delete-cascade.xml new file mode 100644 index 0000000..36d9092 --- /dev/null +++ b/ordinis-migrations/src/main/resources/db/changelog/changes/0030-dictionary-delete-cascade.xml @@ -0,0 +1,84 @@ + + + + + + + FK fk_record_dictionary → ON DELETE CASCADE + + ALTER TABLE dictionary_records + DROP CONSTRAINT IF EXISTS fk_record_dictionary; + ALTER TABLE dictionary_records + ADD CONSTRAINT fk_record_dictionary + FOREIGN KEY (dictionary_id) REFERENCES dictionary_definitions(id) + ON DELETE CASCADE; + + + ALTER TABLE dictionary_records + DROP CONSTRAINT IF EXISTS fk_record_dictionary; + ALTER TABLE dictionary_records + ADD CONSTRAINT fk_record_dictionary + FOREIGN KEY (dictionary_id) REFERENCES dictionary_definitions(id); + + + + + FK fk_draft_dictionary → ON DELETE CASCADE + + ALTER TABLE record_drafts + DROP CONSTRAINT IF EXISTS fk_draft_dictionary; + ALTER TABLE record_drafts + ADD CONSTRAINT fk_draft_dictionary + FOREIGN KEY (dictionary_id) REFERENCES dictionary_definitions(id) + ON DELETE CASCADE; + + + ALTER TABLE record_drafts + DROP CONSTRAINT IF EXISTS fk_draft_dictionary; + ALTER TABLE record_drafts + ADD CONSTRAINT fk_draft_dictionary + FOREIGN KEY (dictionary_id) REFERENCES dictionary_definitions(id); + + + + + FK fk_schema_draft_dictionary → ON DELETE CASCADE + + ALTER TABLE dictionary_schema_drafts + DROP CONSTRAINT IF EXISTS fk_schema_draft_dictionary; + ALTER TABLE dictionary_schema_drafts + ADD CONSTRAINT fk_schema_draft_dictionary + FOREIGN KEY (dictionary_id) REFERENCES dictionary_definitions(id) + ON DELETE CASCADE; + + + ALTER TABLE dictionary_schema_drafts + DROP CONSTRAINT IF EXISTS fk_schema_draft_dictionary; + ALTER TABLE dictionary_schema_drafts + ADD CONSTRAINT fk_schema_draft_dictionary + FOREIGN KEY (dictionary_id) REFERENCES dictionary_definitions(id); + + + diff --git a/ordinis-migrations/src/main/resources/db/changelog/master.xml b/ordinis-migrations/src/main/resources/db/changelog/master.xml index e97ab8f..e71a126 100644 --- a/ordinis-migrations/src/main/resources/db/changelog/master.xml +++ b/ordinis-migrations/src/main/resources/db/changelog/master.xml @@ -39,5 +39,6 @@ + diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java index 9a8b8dc..0b57ef1 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java @@ -67,6 +67,16 @@ public class AuditLogger { write("DEFINITION_UPDATED", "DEFINITION_UPDATE", d, null, prevSchema, d.getSchemaJson()); } + /** + * Audit для удаления справочника. payload_before = last schema, after = null. + * Пишется ДО cascade delete — после delete record уже не имеет dictionary_id + * (FK CASCADE на dictionary_definitions). Audit_log rows pre-delete остаются + * как append-only trail (нет FK к definition). + */ + public void definitionDeleted(DictionaryDefinition d) { + write("DEFINITION_DELETED", "DEFINITION_DELETE", d, null, d.getSchemaJson(), null); + } + public void recordUpdate( DictionaryDefinition d, DictionaryRecord r, JsonNode payloadBefore) { write("RECORD_UPDATED", "UPDATE", d, r, payloadBefore, r.getData()); diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java index da6df63..c2429a0 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryDefinitionService.java @@ -4,6 +4,7 @@ import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition; import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository; import cloud.nstart.terravault.ordinis.events.EventEnvelope; import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionCreated; +import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionDeleted; import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionUpdated; import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder; import cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger; @@ -224,6 +225,67 @@ public class DictionaryDefinitionService { return saved; } + /** + * Удаление справочника. Cascade на FK (migration 0030 → ON DELETE CASCADE) + * cleanup'ит dictionary_records, record_drafts, dictionary_schema_drafts + * атомарно в одной транзакции. + * + *

Caller (controller) ответственен за: + *

    + *
  • Admin role check.
  • + *
  • Scope check (юзер должен иметь access к scope удаляемого dict).
  • + *
  • Incoming FK dependents check — если другой dict имеет + * x-references на этот, удаление сломает их integrity. Controller + * вызывает {@code LineageIndexService.findSchemaDependents} + * и throw'ит 409 если non-empty.
  • + *
+ * + *

Audit + outbox event пишутся ПЕРЕД delete — после cascade rows + * исчезают. Consumers через outbox узнают что dict удалён и могут + * invalidate cache (geoportal, Альтум). + * + * @param name dict name + * @throws OrdinisException 404 если dict не существует + */ + @Transactional + public void delete(String name) { + DictionaryDefinition def = findByName(name); + + // Audit ДО delete: после definition row gone, attached schema lost. + if (auditLogger != null) { + auditLogger.definitionDeleted(def); + } + + // Outbox event ДО delete — consumers получают snapshot final state. + var event = new DictionaryDefinitionDeleted( + def.getId(), + def.getName(), + def.getDisplayName(), + toEventScope(def.getScope()), + def.getBundle(), + -1L, // counts skipped в этой версии; см. TODO ниже + -1L, + -1L, + java.time.OffsetDateTime.now(), + def.getUpdatedBy()); + var envelope = EventEnvelope.of( + def.getName(), toEventScope(def.getScope()), def.getBundle(), event); + outboxRecorder.record( + "DefinitionDeleted", + "DictionaryDefinition", + def.getId().toString(), + def.getName(), + def.getScope(), + def.getBundle(), + "definition:" + def.getName(), + envelope); + + // Single delete — FK CASCADE (migration 0030) подхватит + // dictionary_records / record_drafts / dictionary_schema_drafts. + // audit_log + outbox_events не имеют FK, остаются как append-only trail. + repository.deleteById(def.getId()); + } + private static cloud.nstart.terravault.ordinis.events.DataScope toEventScope( cloud.nstart.terravault.ordinis.domain.DataScope d) { return cloud.nstart.terravault.ordinis.events.DataScope.valueOf(d.name()); diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java index 12182a1..c305291 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java @@ -7,10 +7,13 @@ import cloud.nstart.terravault.ordinis.restapi.dto.CreateDictionaryRequest; import cloud.nstart.terravault.ordinis.restapi.dto.DictionaryResponse; import cloud.nstart.terravault.ordinis.restapi.dto.PatchSchemaRequest; import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; +import cloud.nstart.terravault.ordinis.restapi.auth.WorkflowRoles; import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService; import cloud.nstart.terravault.ordinis.restapi.service.SchemaPatchService; +import cloud.nstart.terravault.ordinis.restapi.service.reference.LineageIndexService; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -18,6 +21,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -36,16 +40,22 @@ public class DictionaryDefinitionController { private final ScopeContext scopeContext; private final DictionaryRecordRepository recordRepository; private final SchemaPatchService schemaPatchService; + private final LineageIndexService lineageService; + private final WorkflowRoles workflowRoles; public DictionaryDefinitionController( DictionaryDefinitionService service, ScopeContext scopeContext, DictionaryRecordRepository recordRepository, - SchemaPatchService schemaPatchService) { + SchemaPatchService schemaPatchService, + LineageIndexService lineageService, + WorkflowRoles workflowRoles) { this.service = service; this.scopeContext = scopeContext; this.recordRepository = recordRepository; this.schemaPatchService = schemaPatchService; + this.lineageService = lineageService; + this.workflowRoles = workflowRoles; } @GetMapping @@ -129,4 +139,66 @@ public class DictionaryDefinitionController { @jakarta.validation.Valid PatchSchemaRequest req) { return schemaPatchService.patchSchema(name, req); } + + /** + * Удалить справочник. Cascade на records + drafts + schema_drafts через + * FK ON DELETE CASCADE (migration 0030). Outbox event + audit log + * пишутся ДО delete. + * + *

Защита: + *

    + *
  • 403 {@code forbidden_role} — только actor с admin role.
  • + *
  • 404 {@code dictionary_not_found} — либо нет, либо вне scope actor'а.
  • + *
  • 409 {@code has_dependents} — другой dict имеет {@code x-references} + * на этот. Список dependents возвращается в response для UI. + * Override: {@code ?force=true} (но это сломает FK integrity + * зависимых справочников — backend всё равно rejектит record-saves + * до тех пор пока зависимый dict не уберёт x-references).
  • + *
+ * + *

Возврат: 204 No Content на success. + */ + @DeleteMapping("/{name}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete( + @PathVariable String name, + @RequestParam(defaultValue = "false") boolean force) { + // Admin-only — удаление справочника deleteит N+ records/drafts/audit. + if (!workflowRoles.isAdmin()) { + throw OrdinisException.forbidden( + "forbidden_role", + "Удаление справочника требует роли admin."); + } + + var existing = service.findByName(name); + + // Scope check: actor не может удалить dict выше своего scope (PUBLIC + // юзер не удалит INTERNAL/RESTRICTED dict даже с admin role на нём — + // canAccess валидирует scope hierarchy). + if (!scopeContext.canAccess(existing.getScope())) { + throw OrdinisException.notFound( + "dictionary_not_found", "Dictionary not found: " + name); + } + + // Incoming dependents: другой dict ссылается через x-references на + // этот → удаление сломает FK integrity. Reject с list dependents. + // ?force=true пропускает (только для cleanup orphaned test dicts). + if (!force) { + var dependents = lineageService.findSchemaDependents( + name, scopeContext.currentScopes()); + if (!dependents.isEmpty()) { + var depNames = dependents.stream() + .map(d -> d.sourceDict() + "." + d.sourceField()) + .toList(); + throw OrdinisException.conflict( + "has_dependents", + "Нельзя удалить «" + name + "» — на него ссылаются: " + + String.join(", ", depNames) + + ". Сначала уберите x-references из зависимых справочников " + + "или используйте ?force=true (сломает их FK validation)."); + } + } + + service.delete(name); + } }