diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinition.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinition.java index 7974cc7..9749598 100644 --- a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinition.java +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinition.java @@ -112,6 +112,22 @@ public class DictionaryDefinition { @Column(name = "version") private Long version; + /** + * Soft-delete timestamp. NULL = active dictionary. Non-null = deleted (set + * by SchemaDeletionApproval after 2nd admin approve). Records/drafts + * остаются физически в БД (recovery возможен через restore()). + * + *
Permanent purge (drop row + cascade на child tables) — отдельный
+ * admin-only endpoint после approval+30d retention. По умолчанию
+ * list/findByName excludeает deleted, restore сбрасывает в NULL.
+ */
+ @Column(name = "deleted_at")
+ private OffsetDateTime deletedAt;
+
+ /** Admin sub claim that approved the deletion (audit trail). */
+ @Column(name = "deleted_by", length = 64)
+ private String deletedBy;
+
protected DictionaryDefinition() {}
public DictionaryDefinition(UUID id, String name, DataScope scope, JsonNode schemaJson) {
@@ -139,6 +155,19 @@ public class DictionaryDefinition {
public String getCreatedBy() { return createdBy; }
public String getUpdatedBy() { return updatedBy; }
public Long getVersion() { return version; }
+ public OffsetDateTime getDeletedAt() { return deletedAt; }
+ public String getDeletedBy() { return deletedBy; }
+ public boolean isDeleted() { return deletedAt != null; }
+
+ public void markDeleted(String approverSub) {
+ this.deletedAt = OffsetDateTime.now();
+ this.deletedBy = approverSub;
+ }
+
+ public void restore() {
+ this.deletedAt = null;
+ this.deletedBy = null;
+ }
public void setDisplayName(String v) { this.displayName = v; }
public void setDescription(String v) { this.description = v; }
diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinitionRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinitionRepository.java
index 10343dc..e0cae5b 100644
--- a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinitionRepository.java
+++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinitionRepository.java
@@ -9,7 +9,33 @@ import java.util.UUID;
public interface DictionaryDefinitionRepository extends JpaRepository Caller (controller) ответственен за:
- * Phase 1 (this method): `deleted_at = now()`. Records/drafts остаются
+ * физически в БД. list/findByName excludes deleted by default — выглядит
+ * как 404 для regular consumers. Admin Корзина page + {@link #restore}
+ * восстанавливают.
*
- * Audit + outbox event пишутся ПЕРЕД delete — после cascade rows
- * исчезают. Consumers через outbox узнают что dict удалён и могут
- * invalidate cache (geoportal, Альтум).
+ * Phase 2 (follow-up MR): 2nd-admin approval workflow (создать
+ * dictionary_deletion_requests entity с maker/reviewer/status).
+ *
+ * Permanent purge (физический delete row + cascade) — отдельный admin
+ * flow `purge(name)` после retention period 30+ дней.
*
* @param name dict name
- * @throws OrdinisException 404 если dict не существует
+ * @param actorSub admin sub claim из JWT — для audit `deleted_by`
+ * @throws OrdinisException 404 если dict не существует или уже deleted
*/
@Transactional
- public void delete(String name) {
+ public void softDelete(String name, String actorSub) {
DictionaryDefinition def = findByName(name);
- // Audit ДО delete: после definition row gone, attached schema lost.
+ // markDeleted мутирует deletedAt + deletedBy. @Transactional flush
+ // запишет UPDATE на definition row перед commit.
+ def.markDeleted(actorSub);
+ DictionaryDefinition saved = repository.save(def);
+
if (auditLogger != null) {
- auditLogger.definitionDeleted(def);
+ auditLogger.definitionDeleted(saved);
}
- // Outbox event ДО delete — consumers получают snapshot final state.
+ // Outbox event — consumers (геопортал, Альтум) узнают что dict soft-deleted
+ // и могут invalidate cache. На restore — отдельный event (TODO follow-up).
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());
+ saved.getId(),
+ saved.getName(),
+ saved.getDisplayName(),
+ toEventScope(saved.getScope()),
+ saved.getBundle(),
+ -1L, -1L, -1L,
+ saved.getDeletedAt(),
+ saved.getDeletedBy());
var envelope = EventEnvelope.of(
- def.getName(), toEventScope(def.getScope()), def.getBundle(), event);
+ saved.getName(), toEventScope(saved.getScope()), saved.getBundle(), event);
outboxRecorder.record(
"DefinitionDeleted",
"DictionaryDefinition",
- def.getId().toString(),
- def.getName(),
- def.getScope(),
- def.getBundle(),
- "definition:" + def.getName(),
+ saved.getId().toString(),
+ saved.getName(),
+ saved.getScope(),
+ saved.getBundle(),
+ "definition:" + saved.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());
+ /**
+ * Восстановление soft-deleted справочника. Admin-only. Resets
+ * `deleted_at` / `deleted_by` в NULL → dict снова visible в list().
+ *
+ * @throws OrdinisException 404 если dict не существует или active (not deleted)
+ */
+ @Transactional
+ public DictionaryDefinition restore(String name) {
+ var existing = repository.findByNameIncludingDeleted(name)
+ .orElseThrow(() -> OrdinisException.notFound(
+ "dictionary_not_found", "Dictionary not found: " + name));
+ if (!existing.isDeleted()) {
+ throw OrdinisException.conflict(
+ "not_deleted", "Dictionary " + name + " не помечен как удалённый.");
+ }
+ existing.restore();
+ var saved = repository.save(existing);
+ if (auditLogger != null) {
+ // Reuse definitionUpdated audit — payload_before = schema as-deleted,
+ // after = same schema (restore не меняет content, только deleted_at).
+ // Создавать отдельный DEFINITION_RESTORED event можно в follow-up.
+ auditLogger.definitionUpdated(saved, saved.getSchemaJson());
+ }
+ return saved;
+ }
+
+ /** Все soft-deleted справочники для admin Корзина page. */
+ @Transactional(readOnly = true)
+ public List Admin-only. Сбрасывает {@code deleted_at}/{@code deleted_by} в NULL —
+ * dict снова visible в /dictionaries list. Records/drafts физически не
+ * трогались soft-delete'ом, восстанавливаются автоматически.
+ *
+ *
- *
+ *
+ *
+ */
+ @PostMapping("/{name}/restore")
+ public DictionaryResponse restore(@PathVariable String name) {
+ if (!workflowRoles.isAdmin()) {
+ throw OrdinisException.forbidden(
+ "forbidden_role",
+ "Восстановление справочника требует роли admin.");
+ }
+ return DictionaryResponse.from(service.restore(name));
+ }
+
+ /**
+ * Список soft-deleted справочников (Корзина admin page). Admin-only.
+ * Каждый item можно восстановить через {@link #restore} или physically
+ * purge через отдельный admin endpoint (TODO follow-up).
+ */
+ @GetMapping("/deleted")
+ public List