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: + *
+ {t('dict.delete.warning', { + defaultValue: + 'Будут удалены: ВСЕ записи справочника, drafts, schema-drafts, история версий. Эта операция необратима.', + })} +
+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 @@
+
+
Caller (controller) ответственен за: + *
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. + * + *
Защита: + *
Возврат: 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); + } }