feat(dict): delete dictionary endpoint + UI button с cascade safety

This commit is contained in:
Александр Зимин
2026-06-05 09:47:05 +00:00
parent cdffa4261c
commit 5c6801f993
10 changed files with 437 additions and 4 deletions
+26
View File
@@ -136,6 +136,32 @@ export const useUpdateDictionary = () => {
})
}
/**
* Удаление справочника. Cascade на records + drafts + schema_drafts через
* FK ON DELETE CASCADE (migration 0030). Только admin role.
*
* <p>Backend errors:
* <ul>
* <li>403 {@code forbidden_role} — не admin.</li>
* <li>404 {@code dictionary_not_found} — нет либо вне scope.</li>
* <li>409 {@code has_dependents} — другой dict ссылается через
* x-references. message содержит список dependents. UI показывает
* их юзеру и предлагает либо убрать FK у зависимых, либо force.</li>
* </ul>
*/
export const useDeleteDictionary = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (params: { name: string; force?: boolean }): Promise<void> => {
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}). Это защищает от
@@ -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 фильтр…' })}
</button>
)}
{actions.onDelete && (
<button
type="button"
onClick={actions.onDelete}
disabled={actions.deletePending}
className="w-full h-8 rounded-md border border-mars/40 bg-surface text-mars text-cell flex items-center justify-center gap-1.5 transition-colors hover:border-mars hover:bg-mars/8 disabled:opacity-50 disabled:cursor-not-allowed"
>
{actions.deletePending
? t('dict.delete.pending', { defaultValue: 'Удаляю…' })
: t('dict.delete.button', { defaultValue: 'Удалить справочник…' })}
</button>
)}
</div>
)}
</aside>
@@ -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<string | null>(null)
const [edit, setEdit] = useState<EditState>({ 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}
/>
</Modal>
{/* 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. */}
<Modal
isOpen={deleteConfirmOpen}
onClose={() => {
if (deleteMut.isPending) return
setDeleteConfirmOpen(false)
setDeleteError(null)
}}
title={t('dict.delete.title', { defaultValue: 'Удалить справочник?' })}
maxWidth="max-w-lg"
>
<div className="space-y-4">
<p className="text-cell text-ink-2">
{t('dict.delete.warning', {
defaultValue:
'Будут удалены: ВСЕ записи справочника, drafts, schema-drafts, история версий. Эта операция необратима.',
})}
</p>
<div className="rounded-md border border-mars/30 bg-mars/8 px-3 py-2 text-cell">
<strong>{name}</strong>
{detailQuery.data?.displayName ? `${detailQuery.data.displayName}` : null}
</div>
{deleteError && (
<div className="rounded-md border border-mars/40 bg-mars/8 px-3 py-2 text-cell text-mars whitespace-pre-line">
{deleteError}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
className="h-9 px-4 rounded-md border border-line bg-surface text-ink-2 text-cell hover:bg-surface-2"
onClick={() => {
setDeleteConfirmOpen(false)
setDeleteError(null)
}}
disabled={deleteMut.isPending}
>
{t('common.cancel', { defaultValue: 'Отмена' })}
</button>
<button
type="button"
className="h-9 px-4 rounded-md bg-mars text-surface text-cell font-medium hover:bg-mars/85 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => {
setDeleteError(null)
deleteMut.mutate(
{ name },
{
onSuccess: () => {
setDeleteConfirmOpen(false)
navigate({ to: '/dictionaries' })
},
onError: (err) => {
const code = (err as { response?: { data?: { code?: string } } })?.response
?.data?.code
const msg = (err as { response?: { data?: { message?: string } } })?.response
?.data?.message
if (code === 'has_dependents') {
setDeleteError(
(msg ?? 'Есть зависимые справочники.') +
'\n\n' +
t('dict.delete.forceHint', {
defaultValue:
'Можно повторить с force=true — но зависимые справочники будут ссылаться на удалённое (FK validation сломается).',
}),
)
} else {
setDeleteError(msg ?? String(err))
}
},
},
)
}}
disabled={deleteMut.isPending}
>
{deleteMut.isPending
? t('dict.delete.pending', { defaultValue: 'Удаляю…' })
: t('dict.delete.confirm', { defaultValue: 'Удалить безвозвратно' })}
</button>
{deleteError?.includes('has_dependents') ||
(deleteError &&
deleteError.toLowerCase().includes('зависимы')) ? (
<button
type="button"
className="h-9 px-4 rounded-md border border-mars/40 bg-surface text-mars text-cell hover:bg-mars/8 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => {
setDeleteError(null)
deleteMut.mutate(
{ name, force: true },
{
onSuccess: () => {
setDeleteConfirmOpen(false)
navigate({ to: '/dictionaries' })
},
onError: (err) => {
const msg = (err as { response?: { data?: { message?: string } } })
?.response?.data?.message
setDeleteError(msg ?? String(err))
},
},
)
}}
disabled={deleteMut.isPending}
>
{t('dict.delete.force', { defaultValue: 'Удалить force (всё равно)' })}
</button>
) : null}
</div>
</div>
</Modal>
</div>
)
}
@@ -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.
*
* <p>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"; }
}
@@ -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();
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<!--
ON DELETE CASCADE для FK на dictionary_definitions(id).
Контекст: feature `DELETE /api/v1/dictionaries/{name}`. Service.delete
выполняет SINGLE `definitionRepository.deleteById` — без ON CASCADE
Postgres reject'нул бы с violation (dictionary_records → 23503). Тогда
Java logic должен был бы вручную в правильном порядке deleteByDictionaryId
на N+ child tables, что error-prone и не атомарно.
CASCADE на FK — Postgres делает delete детей в одной транзакции,
атомарно. Audit log + outbox events не имеют FK constraints (они
audit-style append-only), delete их выполняется в Java service
явным DELETE WHERE aggregate_id=<defId>.
Child tables (FK references dictionary_definitions(id)):
- dictionary_records (0003)
- record_drafts (0019)
- dictionary_schema_drafts (0022)
-->
<changeSet id="0030-1-dictionary-records-cascade" author="ordinis">
<comment>FK fk_record_dictionary → ON DELETE CASCADE</comment>
<sql>
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;
</sql>
<rollback>
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);
</rollback>
</changeSet>
<changeSet id="0030-2-record-drafts-cascade" author="ordinis">
<comment>FK fk_draft_dictionary → ON DELETE CASCADE</comment>
<sql>
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;
</sql>
<rollback>
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);
</rollback>
</changeSet>
<changeSet id="0030-3-schema-drafts-cascade" author="ordinis">
<comment>FK fk_schema_draft_dictionary → ON DELETE CASCADE</comment>
<sql>
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;
</sql>
<rollback>
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);
</rollback>
</changeSet>
</databaseChangeLog>
@@ -39,5 +39,6 @@
<include file="changes/0027-spacecraft-mission-fk.xml" relativeToChangelogFile="true"/>
<include file="changes/0028-phase3-prep-org-id.xml" relativeToChangelogFile="true"/>
<include file="changes/0029-drop-self-approve-constraints.xml" relativeToChangelogFile="true"/>
<include file="changes/0030-dictionary-delete-cascade.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -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());
@@ -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
* атомарно в одной транзакции.
*
* <p>Caller (controller) ответственен за:
* <ul>
* <li>Admin role check.</li>
* <li>Scope check (юзер должен иметь access к scope удаляемого dict).</li>
* <li>Incoming FK dependents check если другой dict имеет
* x-references на этот, удаление сломает их integrity. Controller
* вызывает {@code LineageIndexService.findSchemaDependents}
* и throw'ит 409 если non-empty.</li>
* </ul>
*
* <p>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());
@@ -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.
*
* <p>Защита:
* <ul>
* <li>403 {@code forbidden_role} только actor с admin role.</li>
* <li>404 {@code dictionary_not_found} либо нет, либо вне scope actor'а.</li>
* <li>409 {@code has_dependents} другой dict имеет {@code x-references}
* на этот. Список dependents возвращается в response для UI.
* Override: {@code ?force=true} (но это сломает FK integrity
* зависимых справочников backend всё равно rejектит record-saves
* до тех пор пока зависимый dict не уберёт x-references).</li>
* </ul>
*
* <p>Возврат: 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);
}
}