feat(dict): delete dictionary endpoint + UI button с cascade safety
This commit is contained in:
@@ -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'ом
|
* Create-record mutation. {@code idempotencyKey} — required, передаётся caller'ом
|
||||||
* (один на жизнь формы; см. {@link useFormIdempotencyKey}). Это защищает от
|
* (один на жизнь формы; см. {@link useFormIdempotencyKey}). Это защищает от
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ export type EditorInfoPanelProps = {
|
|||||||
aoiActive?: boolean
|
aoiActive?: boolean
|
||||||
onExport?: () => void
|
onExport?: () => void
|
||||||
exportPending?: boolean
|
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 живут в
|
/** Optional banner slot — WorkflowBanner / status notices живут в
|
||||||
* left panel per user feedback ("плашки пусть в левой панели будут").
|
* left panel per user feedback ("плашки пусть в левой панели будут").
|
||||||
@@ -231,6 +236,18 @@ export function EditorInfoPanel({ detail, recordCount, actions, banner }: Editor
|
|||||||
{t('aoi.button', { defaultValue: 'AOI фильтр…' })}
|
{t('aoi.button', { defaultValue: 'AOI фильтр…' })}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -38,8 +38,9 @@ import {
|
|||||||
useScheduledSummary,
|
useScheduledSummary,
|
||||||
type RecordsFilter,
|
type RecordsFilter,
|
||||||
} from '@/api/queries'
|
} 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 { useCanMutate } from '@/auth/useCanMutate'
|
||||||
|
import { useIsAdmin } from '@/auth/usePermissions'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
|
import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
|
||||||
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
|
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
|
||||||
@@ -223,6 +224,11 @@ function DictionaryDetail() {
|
|||||||
const closeMut = useCloseRecord(name)
|
const closeMut = useCloseRecord(name)
|
||||||
const bulkCloseMut = useBulkCloseRecords(name)
|
const bulkCloseMut = useBulkCloseRecords(name)
|
||||||
const bulkExportMut = useBulkExportRecords(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 [edit, setEdit] = useState<EditState>({ kind: 'closed' })
|
||||||
const [closeReason, setCloseReason] = useState('')
|
const [closeReason, setCloseReason] = useState('')
|
||||||
@@ -908,6 +914,12 @@ function DictionaryDetail() {
|
|||||||
// выбирает формат/scope/columns/encoding/delimiter.
|
// выбирает формат/scope/columns/encoding/delimiter.
|
||||||
onExport: () => setExportOpen(true),
|
onExport: () => setExportOpen(true),
|
||||||
exportPending: bulkExportMut.isPending,
|
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}
|
approvalMode={approvalRequired}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+34
@@ -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"; }
|
||||||
|
}
|
||||||
+3
-2
@@ -6,10 +6,11 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
|||||||
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
|
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
|
||||||
@JsonSubTypes({
|
@JsonSubTypes({
|
||||||
@JsonSubTypes.Type(value = DictionaryDefinitionCreated.class, name = "DefinitionCreated"),
|
@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
|
public sealed interface DictionaryDefinitionEvent
|
||||||
permits DictionaryDefinitionCreated, DictionaryDefinitionUpdated {
|
permits DictionaryDefinitionCreated, DictionaryDefinitionUpdated, DictionaryDefinitionDeleted {
|
||||||
|
|
||||||
java.util.UUID definitionId();
|
java.util.UUID definitionId();
|
||||||
|
|
||||||
|
|||||||
+84
@@ -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/0027-spacecraft-mission-fk.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="changes/0028-phase3-prep-org-id.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/0029-drop-self-approve-constraints.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changes/0030-dictionary-delete-cascade.xml" relativeToChangelogFile="true"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
+10
@@ -67,6 +67,16 @@ public class AuditLogger {
|
|||||||
write("DEFINITION_UPDATED", "DEFINITION_UPDATE", d, null, prevSchema, d.getSchemaJson());
|
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(
|
public void recordUpdate(
|
||||||
DictionaryDefinition d, DictionaryRecord r, JsonNode payloadBefore) {
|
DictionaryDefinition d, DictionaryRecord r, JsonNode payloadBefore) {
|
||||||
write("RECORD_UPDATED", "UPDATE", d, r, payloadBefore, r.getData());
|
write("RECORD_UPDATED", "UPDATE", d, r, payloadBefore, r.getData());
|
||||||
|
|||||||
+62
@@ -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.domain.definition.DictionaryDefinitionRepository;
|
||||||
import cloud.nstart.terravault.ordinis.events.EventEnvelope;
|
import cloud.nstart.terravault.ordinis.events.EventEnvelope;
|
||||||
import cloud.nstart.terravault.ordinis.events.definition.DictionaryDefinitionCreated;
|
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.events.definition.DictionaryDefinitionUpdated;
|
||||||
import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
|
import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger;
|
import cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger;
|
||||||
@@ -224,6 +225,67 @@ public class DictionaryDefinitionService {
|
|||||||
return saved;
|
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(
|
private static cloud.nstart.terravault.ordinis.events.DataScope toEventScope(
|
||||||
cloud.nstart.terravault.ordinis.domain.DataScope d) {
|
cloud.nstart.terravault.ordinis.domain.DataScope d) {
|
||||||
return cloud.nstart.terravault.ordinis.events.DataScope.valueOf(d.name());
|
return cloud.nstart.terravault.ordinis.events.DataScope.valueOf(d.name());
|
||||||
|
|||||||
+73
-1
@@ -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.DictionaryResponse;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.dto.PatchSchemaRequest;
|
import cloud.nstart.terravault.ordinis.restapi.dto.PatchSchemaRequest;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
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.DictionaryDefinitionService;
|
||||||
import cloud.nstart.terravault.ordinis.restapi.service.SchemaPatchService;
|
import cloud.nstart.terravault.ordinis.restapi.service.SchemaPatchService;
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.service.reference.LineageIndexService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.http.HttpStatus;
|
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.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PatchMapping;
|
import org.springframework.web.bind.annotation.PatchMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
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.PutMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
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.ResponseStatus;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
@@ -36,16 +40,22 @@ public class DictionaryDefinitionController {
|
|||||||
private final ScopeContext scopeContext;
|
private final ScopeContext scopeContext;
|
||||||
private final DictionaryRecordRepository recordRepository;
|
private final DictionaryRecordRepository recordRepository;
|
||||||
private final SchemaPatchService schemaPatchService;
|
private final SchemaPatchService schemaPatchService;
|
||||||
|
private final LineageIndexService lineageService;
|
||||||
|
private final WorkflowRoles workflowRoles;
|
||||||
|
|
||||||
public DictionaryDefinitionController(
|
public DictionaryDefinitionController(
|
||||||
DictionaryDefinitionService service,
|
DictionaryDefinitionService service,
|
||||||
ScopeContext scopeContext,
|
ScopeContext scopeContext,
|
||||||
DictionaryRecordRepository recordRepository,
|
DictionaryRecordRepository recordRepository,
|
||||||
SchemaPatchService schemaPatchService) {
|
SchemaPatchService schemaPatchService,
|
||||||
|
LineageIndexService lineageService,
|
||||||
|
WorkflowRoles workflowRoles) {
|
||||||
this.service = service;
|
this.service = service;
|
||||||
this.scopeContext = scopeContext;
|
this.scopeContext = scopeContext;
|
||||||
this.recordRepository = recordRepository;
|
this.recordRepository = recordRepository;
|
||||||
this.schemaPatchService = schemaPatchService;
|
this.schemaPatchService = schemaPatchService;
|
||||||
|
this.lineageService = lineageService;
|
||||||
|
this.workflowRoles = workflowRoles;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -129,4 +139,66 @@ public class DictionaryDefinitionController {
|
|||||||
@jakarta.validation.Valid PatchSchemaRequest req) {
|
@jakarta.validation.Valid PatchSchemaRequest req) {
|
||||||
return schemaPatchService.patchSchema(name, 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user