feat: edit/delete field definitions — audited, blocked when in use (#36)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 19:58:38 +02:00
parent 47240dafcc
commit 3e7c6ad712
5 changed files with 540 additions and 5 deletions
+118 -2
View File
@@ -1,11 +1,15 @@
//! Registry of flexible field definitions.
use domain::{
AuthorityKind, FieldDefinition, FieldDefinitionId, FieldType, LocalizedLabel,
NewFieldDefinition, VocabularyId,
AuditAction, AuditActor, AuthorityKind, FieldDefinition, FieldDefinitionId, FieldType,
LocalizedLabel, NewAuditEvent, NewFieldDefinition, VocabularyId,
};
use sqlx::Row;
use crate::audit;
const FIELD_DEFINITION_ENTITY_TYPE: &str = "field_definition";
/// Labels aggregated per row as JSON, to read a definition and its labels in one query.
const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', fdl.lang, 'label', fdl.label) \
ORDER BY fdl.lang) FILTER (WHERE fdl.field_definition_id IS NOT NULL), '[]'::json)";
@@ -121,3 +125,115 @@ fn map_field_definition(row: sqlx::postgres::PgRow) -> Result<FieldDefinition, s
labels: labels.0,
})
}
/// Update a field definition's mutable attributes (`required`, `group_key`, labels);
/// `key`, `data_type`, and binding are immutable and untouched. Records an `updated`
/// audit entry. Returns `false` if no such key. Pass a transaction connection.
pub async fn update_field_definition(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
key: &str,
required: bool,
group_key: Option<&str>,
labels: &[LocalizedLabel],
) -> Result<bool, sqlx::Error> {
let id: Option<uuid::Uuid> =
sqlx::query_scalar("SELECT id FROM field_definition WHERE key = $1")
.bind(key)
.fetch_optional(&mut *conn)
.await?;
let Some(id) = id else { return Ok(false) };
sqlx::query("UPDATE field_definition SET required = $2, group_key = $3 WHERE id = $1")
.bind(id)
.bind(required)
.bind(group_key)
.execute(&mut *conn)
.await?;
sqlx::query("DELETE FROM field_definition_label WHERE field_definition_id = $1")
.bind(id)
.execute(&mut *conn)
.await?;
for label in labels {
sqlx::query(
"INSERT INTO field_definition_label (field_definition_id, lang, label) \
VALUES ($1, $2, $3)",
)
.bind(id)
.bind(&label.lang)
.bind(&label.label)
.execute(&mut *conn)
.await?;
}
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Updated,
entity_type: FIELD_DEFINITION_ENTITY_TYPE.to_owned(),
entity_id: id,
changes: Vec::new(),
},
)
.await?;
Ok(true)
}
/// Count catalogue objects that store a value under field `key`.
pub async fn count_objects_using_field<'e, E>(executor: E, key: &str) -> Result<i64, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
sqlx::query_scalar("SELECT count(*) FROM object WHERE jsonb_exists(fields, $1)")
.bind(key)
.fetch_one(executor)
.await
}
/// Delete a field definition (labels cascade) unless catalogue objects use its key,
/// recording a `deleted` audit entry. Pass a transaction connection.
pub async fn delete_field_definition(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
key: &str,
) -> Result<crate::DeleteOutcome, sqlx::Error> {
let id: Option<uuid::Uuid> =
sqlx::query_scalar("SELECT id FROM field_definition WHERE key = $1")
.bind(key)
.fetch_optional(&mut *conn)
.await?;
let Some(id) = id else {
return Ok(crate::DeleteOutcome::NotFound);
};
let count = count_objects_using_field(&mut *conn, key).await?;
if count > 0 {
return Ok(crate::DeleteOutcome::InUse { count });
}
sqlx::query("DELETE FROM field_definition WHERE id = $1")
.bind(id)
.execute(&mut *conn)
.await?;
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Deleted,
entity_type: FIELD_DEFINITION_ENTITY_TYPE.to_owned(),
entity_id: id,
changes: Vec::new(),
},
)
.await?;
Ok(crate::DeleteOutcome::Deleted)
}