From 09baf2949f02b02cc55e1dfa2cda3081592b4740 Mon Sep 17 00:00:00 2001
From: Anders Olsson
Date: Fri, 5 Jun 2026 18:43:02 +0200
Subject: [PATCH 01/11] =?UTF-8?q?feat:=20edit/delete=20terms=20=E2=80=94?=
=?UTF-8?q?=20audited,=20blocked=20when=20referenced=20(#30)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Sonnet 4.6
---
crates/api/src/admin_vocab.rs | 140 +++++++++++++++++++++++++++-
crates/api/src/openapi.rs | 4 +
crates/api/tests/admin_catalog.rs | 126 +++++++++++++++++++++++++
crates/db/src/lib.rs | 11 +++
crates/db/src/vocab.rs | 116 +++++++++++++++++++++++
crates/db/tests/vocab.rs | 148 +++++++++++++++++++++++++++++-
6 files changed, 542 insertions(+), 3 deletions(-)
diff --git a/crates/api/src/admin_vocab.rs b/crates/api/src/admin_vocab.rs
index 0b714e4..b8d60de 100644
--- a/crates/api/src/admin_vocab.rs
+++ b/crates/api/src/admin_vocab.rs
@@ -5,9 +5,10 @@ use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
+ response::{IntoResponse, Response},
routing::get,
};
-use domain::{AuditActor, LocalizedLabel, NewTerm, VocabularyId};
+use domain::{AuditActor, LocalizedLabel, NewTerm, TermId, VocabularyId};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
@@ -221,6 +222,139 @@ pub(crate) async fn add_term(
))
}
+/// 409 body: how many catalogue objects still reference the entity.
+#[derive(Serialize, ToSchema)]
+pub(crate) struct InUseView {
+ pub count: i64,
+}
+
+#[derive(Deserialize, ToSchema)]
+pub(crate) struct UpdateTermRequest {
+ pub external_uri: Option,
+ pub labels: Vec,
+}
+
+#[utoipa::path(
+ patch, path = "/api/admin/vocabularies/{id}/terms/{term_id}",
+ request_body = UpdateTermRequest,
+ params(
+ ("id" = String, Path, description = "Vocabulary id (UUID)"),
+ ("term_id" = String, Path, description = "Term id (UUID)")
+ ),
+ responses(
+ (status = 204),
+ (status = 401),
+ (status = 403),
+ (status = 404)
+ )
+)]
+pub(crate) async fn update_term(
+ auth: Authorized,
+ State(state): State,
+ Path((id, term_id)): Path<(String, String)>,
+ Json(req): Json,
+) -> Result {
+ let vocabulary_id = id
+ .parse::()
+ .map_err(|_| StatusCode::NOT_FOUND)?;
+
+ let term_id = term_id
+ .parse::()
+ .map_err(|_| StatusCode::NOT_FOUND)?;
+
+ let labels: Vec = req
+ .labels
+ .into_iter()
+ .map(|l| LocalizedLabel {
+ lang: l.lang,
+ label: l.label,
+ })
+ .collect();
+
+ let mut tx = state
+ .db
+ .pool()
+ .begin()
+ .await
+ .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+
+ let existed = db::vocab::update_term(
+ &mut tx,
+ AuditActor::User(auth.user.id.to_uuid()),
+ vocabulary_id,
+ term_id,
+ req.external_uri.as_deref(),
+ &labels,
+ )
+ .await
+ .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+
+ if existed {
+ tx.commit()
+ .await
+ .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+
+ Ok(StatusCode::NO_CONTENT)
+ } else {
+ Err(StatusCode::NOT_FOUND)
+ }
+}
+
+#[utoipa::path(
+ delete, path = "/api/admin/vocabularies/{id}/terms/{term_id}",
+ params(
+ ("id" = String, Path, description = "Vocabulary id (UUID)"),
+ ("term_id" = String, Path, description = "Term id (UUID)")
+ ),
+ responses(
+ (status = 204),
+ (status = 401),
+ (status = 403),
+ (status = 404),
+ (status = 409, body = InUseView, description = "Referenced by catalogue objects")
+ )
+)]
+pub(crate) async fn delete_term(
+ auth: Authorized,
+ State(state): State,
+ Path((id, term_id)): Path<(String, String)>,
+) -> Response {
+ let (Ok(vocab_id), Ok(term_id)) = (id.parse::(), term_id.parse::())
+ else {
+ return StatusCode::NOT_FOUND.into_response();
+ };
+
+ let Ok(mut tx) = state.db.pool().begin().await else {
+ return StatusCode::INTERNAL_SERVER_ERROR.into_response();
+ };
+
+ let outcome = db::vocab::delete_term(
+ &mut tx,
+ AuditActor::User(auth.user.id.to_uuid()),
+ vocab_id,
+ term_id,
+ )
+ .await;
+
+ match outcome {
+ Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
+ Ok(()) => StatusCode::NO_CONTENT.into_response(),
+ Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
+ },
+ Ok(db::DeleteOutcome::InUse { count }) => {
+ let _ = tx.rollback().await;
+
+ (StatusCode::CONFLICT, Json(InUseView { count })).into_response()
+ }
+ Ok(db::DeleteOutcome::NotFound) => {
+ let _ = tx.rollback().await;
+
+ StatusCode::NOT_FOUND.into_response()
+ }
+ Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
+ }
+}
+
pub(crate) fn routes() -> Router {
Router::new()
.route(
@@ -231,4 +365,8 @@ pub(crate) fn routes() -> Router {
"/api/admin/vocabularies/{id}/terms",
get(list_terms).post(add_term),
)
+ .route(
+ "/api/admin/vocabularies/{id}/terms/{term_id}",
+ axum::routing::patch(update_term).delete(delete_term),
+ )
}
diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs
index b625cba..dcf2e99 100644
--- a/crates/api/src/openapi.rs
+++ b/crates/api/src/openapi.rs
@@ -31,6 +31,8 @@ use crate::{
admin_vocab::create_vocabulary,
admin_vocab::list_terms,
admin_vocab::add_term,
+ admin_vocab::update_term,
+ admin_vocab::delete_term,
admin_search::search_objects,
admin_authorities::list_authorities,
admin_authorities::create_authority
@@ -60,6 +62,8 @@ use crate::{
admin_vocab::LabelInput,
admin_vocab::TermView,
admin_vocab::CreatedId,
+ admin_vocab::UpdateTermRequest,
+ admin_vocab::InUseView,
admin_search::SearchHitView,
admin_search::SearchResultsView,
admin_authorities::AuthorityView,
diff --git a/crates/api/tests/admin_catalog.rs b/crates/api/tests/admin_catalog.rs
index 04aec01..336455f 100644
--- a/crates/api/tests/admin_catalog.rs
+++ b/crates/api/tests/admin_catalog.rs
@@ -333,3 +333,129 @@ async fn creating_a_vocabulary_writes_an_audit_entry(pool: PgPool) {
"expected actor to be a user"
);
}
+
+async fn send(
+ app: &axum::Router,
+ cookie: &str,
+ method: &str,
+ uri: &str,
+ body: Option<&str>,
+) -> axum::http::Response {
+ let mut req = Request::builder()
+ .method(method)
+ .uri(uri)
+ .header(header::COOKIE, cookie);
+
+ if body.is_some() {
+ req = req.header(header::CONTENT_TYPE, "application/json");
+ }
+
+ let body = body
+ .map(|b| Body::from(b.to_owned()))
+ .unwrap_or_else(Body::empty);
+
+ app.clone().oneshot(req.body(body).unwrap()).await.unwrap()
+}
+
+#[sqlx::test(migrations = "../db/migrations")]
+async fn edit_and_delete_term(pool: PgPool) {
+ migrate_sessions(&db::Db::from_pool(pool.clone()))
+ .await
+ .unwrap();
+
+ seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
+
+ let app = build_app(state(pool));
+ let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
+
+ let v = send(
+ &app,
+ &cookie,
+ "POST",
+ "/api/admin/vocabularies",
+ Some(r#"{"key":"material"}"#),
+ )
+ .await;
+ let vid: serde_json::Value =
+ serde_json::from_slice(&v.into_body().collect().await.unwrap().to_bytes()).unwrap();
+ let vid = vid["id"].as_str().unwrap().to_owned();
+
+ let t = send(
+ &app,
+ &cookie,
+ "POST",
+ &format!("/api/admin/vocabularies/{vid}/terms"),
+ Some(r#"{"external_uri":null,"labels":[{"lang":"sv","label":"Trä"}]}"#),
+ )
+ .await;
+ let tid: serde_json::Value =
+ serde_json::from_slice(&t.into_body().collect().await.unwrap().to_bytes()).unwrap();
+ let tid = tid["id"].as_str().unwrap().to_owned();
+
+ let patched = send(
+ &app,
+ &cookie,
+ "PATCH",
+ &format!("/api/admin/vocabularies/{vid}/terms/{tid}"),
+ Some(r#"{"external_uri":"https://x","labels":[{"lang":"sv","label":"Träslag"}]}"#),
+ )
+ .await;
+ assert_eq!(patched.status(), StatusCode::NO_CONTENT);
+
+ let deleted = send(
+ &app,
+ &cookie,
+ "DELETE",
+ &format!("/api/admin/vocabularies/{vid}/terms/{tid}"),
+ None,
+ )
+ .await;
+ assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
+
+ let again = send(
+ &app,
+ &cookie,
+ "DELETE",
+ &format!("/api/admin/vocabularies/{vid}/terms/{tid}"),
+ None,
+ )
+ .await;
+ assert_eq!(again.status(), StatusCode::NOT_FOUND);
+}
+
+#[sqlx::test(migrations = "../db/migrations")]
+async fn term_edit_delete_requires_auth(pool: PgPool) {
+ migrate_sessions(&db::Db::from_pool(pool.clone()))
+ .await
+ .unwrap();
+
+ let app = build_app(state(pool));
+ let term_uri = "/api/admin/vocabularies/00000000-0000-0000-0000-000000000000/terms/00000000-0000-0000-0000-000000000000";
+
+ let patch_resp = app
+ .clone()
+ .oneshot(
+ Request::builder()
+ .method("PATCH")
+ .uri(term_uri)
+ .header(header::CONTENT_TYPE, "application/json")
+ .body(Body::from(r#"{"labels":[]}"#))
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ assert_eq!(patch_resp.status(), StatusCode::UNAUTHORIZED);
+
+ let delete_resp = app
+ .clone()
+ .oneshot(
+ Request::builder()
+ .method("DELETE")
+ .uri(term_uri)
+ .body(Body::empty())
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ assert_eq!(delete_resp.status(), StatusCode::UNAUTHORIZED);
+}
diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs
index 0f1c2fa..6db903f 100644
--- a/crates/db/src/lib.rs
+++ b/crates/db/src/lib.rs
@@ -10,6 +10,17 @@ pub mod vocab;
use sqlx::postgres::{PgPool, PgPoolOptions};
+/// Result of a delete that catalogue-object references may block.
+#[derive(Debug, PartialEq, Eq)]
+pub enum DeleteOutcome {
+ /// The row was deleted.
+ Deleted,
+ /// Refused: `count` catalogue objects still reference it.
+ InUse { count: i64 },
+ /// The row did not exist.
+ NotFound,
+}
+
/// A handle to the organization's PostgreSQL database.
#[derive(Clone)]
pub struct Db {
diff --git a/crates/db/src/vocab.rs b/crates/db/src/vocab.rs
index 9bc558a..681ef14 100644
--- a/crates/db/src/vocab.rs
+++ b/crates/db/src/vocab.rs
@@ -177,6 +177,122 @@ where
Ok(found.map(|_| TermRef::new(term_id, vocabulary_id)))
}
+/// Update a term's `external_uri` and labels (full replace), recording an `updated`
+/// audit entry. Returns `false` if no such term or the term does not belong to
+/// `vocabulary_id`. Pass a transaction connection.
+pub async fn update_term(
+ conn: &mut sqlx::PgConnection,
+ actor: AuditActor,
+ vocabulary_id: VocabularyId,
+ term_id: TermId,
+ external_uri: Option<&str>,
+ labels: &[LocalizedLabel],
+) -> Result {
+ let updated =
+ sqlx::query("UPDATE term SET external_uri = $2 WHERE id = $1 AND vocabulary_id = $3")
+ .bind(term_id.to_uuid())
+ .bind(external_uri)
+ .bind(vocabulary_id.to_uuid())
+ .execute(&mut *conn)
+ .await?
+ .rows_affected();
+
+ if updated == 0 {
+ return Ok(false);
+ }
+
+ sqlx::query("DELETE FROM term_label WHERE term_id = $1")
+ .bind(term_id.to_uuid())
+ .execute(&mut *conn)
+ .await?;
+
+ for label in labels {
+ sqlx::query("INSERT INTO term_label (term_id, lang, label) VALUES ($1, $2, $3)")
+ .bind(term_id.to_uuid())
+ .bind(&label.lang)
+ .bind(&label.label)
+ .execute(&mut *conn)
+ .await?;
+ }
+
+ audit::record(
+ &mut *conn,
+ &NewAuditEvent {
+ actor,
+ action: AuditAction::Updated,
+ entity_type: TERM_ENTITY_TYPE.to_owned(),
+ entity_id: term_id.to_uuid(),
+ changes: Vec::new(),
+ },
+ )
+ .await?;
+
+ Ok(true)
+}
+
+/// Count catalogue objects that reference `term_id` through a `term`-typed field.
+pub async fn count_objects_referencing_term<'e, E>(
+ executor: E,
+ term_id: TermId,
+) -> Result
+where
+ E: sqlx::PgExecutor<'e>,
+{
+ sqlx::query_scalar(
+ "SELECT count(*) FROM object o WHERE EXISTS ( \
+ SELECT 1 FROM field_definition fd \
+ WHERE fd.data_type = 'term' AND o.fields ->> fd.key = $1 )",
+ )
+ .bind(term_id.to_string())
+ .fetch_one(executor)
+ .await
+}
+
+/// Delete a term (its labels cascade) unless catalogue objects reference it, recording a
+/// `deleted` audit entry. Pass a transaction connection.
+pub async fn delete_term(
+ conn: &mut sqlx::PgConnection,
+ actor: AuditActor,
+ vocabulary_id: VocabularyId,
+ term_id: TermId,
+) -> Result {
+ let exists =
+ sqlx::query_scalar::<_, i32>("SELECT 1 FROM term WHERE id = $1 AND vocabulary_id = $2")
+ .bind(term_id.to_uuid())
+ .bind(vocabulary_id.to_uuid())
+ .fetch_optional(&mut *conn)
+ .await?;
+
+ if exists.is_none() {
+ return Ok(crate::DeleteOutcome::NotFound);
+ }
+
+ let count = count_objects_referencing_term(&mut *conn, term_id).await?;
+
+ if count > 0 {
+ return Ok(crate::DeleteOutcome::InUse { count });
+ }
+
+ sqlx::query("DELETE FROM term WHERE id = $1")
+ .bind(term_id.to_uuid())
+ .execute(&mut *conn)
+ .await?;
+
+ audit::record(
+ &mut *conn,
+ &NewAuditEvent {
+ actor,
+ action: AuditAction::Deleted,
+ entity_type: TERM_ENTITY_TYPE.to_owned(),
+ entity_id: term_id.to_uuid(),
+ changes: Vec::new(),
+ },
+ )
+ .await?;
+
+ Ok(crate::DeleteOutcome::Deleted)
+}
+
fn map_vocabulary(row: sqlx::postgres::PgRow) -> Result {
Ok(Vocabulary {
id: VocabularyId::from_uuid(row.try_get("id")?),
diff --git a/crates/db/tests/vocab.rs b/crates/db/tests/vocab.rs
index d3edabb..79564d3 100644
--- a/crates/db/tests/vocab.rs
+++ b/crates/db/tests/vocab.rs
@@ -1,5 +1,7 @@
-use db::{Db, vocab};
-use domain::{AuditActor, LocalizedLabel, NewTerm};
+use db::{Db, catalog, fields, vocab};
+use domain::{
+ AuditActor, FieldType, LocalizedLabel, NewFieldDefinition, NewTerm, ObjectInput, Visibility,
+};
use sqlx::PgPool;
#[sqlx::test]
@@ -169,3 +171,145 @@ async fn resolve_term_checks_vocabulary_membership(pool: PgPool) {
.is_none()
);
}
+
+fn sample_object_input() -> ObjectInput {
+ ObjectInput {
+ object_number: "X.1".into(),
+ object_name: "Test".into(),
+ number_of_objects: 1,
+ brief_description: None,
+ current_location: None,
+ current_owner: None,
+ recorder: None,
+ recording_date: None,
+ visibility: Visibility::Draft,
+ }
+}
+
+#[sqlx::test(migrations = "../db/migrations")]
+async fn update_term_changes_labels_and_uri(pool: PgPool) {
+ let db = Db::from_pool(pool);
+ let mut tx = db.pool().begin().await.unwrap();
+ let vocab = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
+ .await
+ .unwrap();
+ let term_id = vocab::add_term(
+ &mut tx,
+ AuditActor::System,
+ &NewTerm {
+ vocabulary_id: vocab.id,
+ external_uri: None,
+ labels: vec![LocalizedLabel {
+ lang: "sv".into(),
+ label: "Trä".into(),
+ }],
+ },
+ )
+ .await
+ .unwrap();
+
+ let existed = vocab::update_term(
+ &mut tx,
+ AuditActor::System,
+ vocab.id,
+ term_id,
+ Some("https://example.org/wood"),
+ &[LocalizedLabel {
+ lang: "sv".into(),
+ label: "Träslag".into(),
+ }],
+ )
+ .await
+ .unwrap();
+ assert!(existed);
+ tx.commit().await.unwrap();
+
+ let term = vocab::term_by_id(db.pool(), term_id)
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(
+ term.external_uri.as_deref(),
+ Some("https://example.org/wood")
+ );
+ assert_eq!(term.labels.len(), 1);
+ assert_eq!(term.labels[0].label, "Träslag");
+}
+
+#[sqlx::test(migrations = "../db/migrations")]
+async fn delete_term_blocks_when_referenced_then_succeeds(pool: PgPool) {
+ use db::DeleteOutcome;
+
+ let db = Db::from_pool(pool);
+ let mut tx = db.pool().begin().await.unwrap();
+ let vocab = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
+ .await
+ .unwrap();
+ let term_id = vocab::add_term(
+ &mut tx,
+ AuditActor::System,
+ &NewTerm {
+ vocabulary_id: vocab.id,
+ external_uri: None,
+ labels: vec![LocalizedLabel {
+ lang: "sv".into(),
+ label: "Trä".into(),
+ }],
+ },
+ )
+ .await
+ .unwrap();
+
+ fields::create_field_definition(
+ &mut tx,
+ &NewFieldDefinition {
+ key: "material".into(),
+ field_type: FieldType::Term {
+ vocabulary_id: vocab.id,
+ },
+ required: false,
+ group_key: None,
+ labels: vec![LocalizedLabel {
+ lang: "sv".into(),
+ label: "Material".into(),
+ }],
+ },
+ )
+ .await
+ .unwrap();
+ let obj = catalog::create_object(&mut tx, AuditActor::System, &sample_object_input())
+ .await
+ .unwrap();
+ let mut map = serde_json::Map::new();
+ map.insert(
+ "material".into(),
+ serde_json::Value::String(term_id.to_string()),
+ );
+ catalog::set_object_fields(&mut tx, AuditActor::System, obj, &map)
+ .await
+ .unwrap();
+
+ let blocked = vocab::delete_term(&mut tx, AuditActor::System, vocab.id, term_id)
+ .await
+ .unwrap();
+ assert_eq!(blocked, DeleteOutcome::InUse { count: 1 });
+
+ catalog::set_object_fields(&mut tx, AuditActor::System, obj, &serde_json::Map::new())
+ .await
+ .unwrap();
+ let ok = vocab::delete_term(&mut tx, AuditActor::System, vocab.id, term_id)
+ .await
+ .unwrap();
+ assert_eq!(ok, DeleteOutcome::Deleted);
+ assert!(
+ vocab::term_by_id(&mut *tx, term_id)
+ .await
+ .unwrap()
+ .is_none()
+ );
+
+ let gone = vocab::delete_term(&mut tx, AuditActor::System, vocab.id, term_id)
+ .await
+ .unwrap();
+ assert_eq!(gone, DeleteOutcome::NotFound);
+}
From 83a72028610d6f73debdf284867f39a56099b8d6 Mon Sep 17 00:00:00 2001
From: Anders Olsson
Date: Fri, 5 Jun 2026 19:35:33 +0200
Subject: [PATCH 02/11] feat: rename + delete vocabularies, blocked when in use
(#30)
---
crates/api/src/admin_vocab.rs | 107 +++++++++++++++++++++++++
crates/api/src/openapi.rs | 3 +
crates/api/tests/admin_catalog.rs | 128 ++++++++++++++++++++++++++++++
crates/db/src/vocab.rs | 82 +++++++++++++++++++
crates/db/tests/vocab.rs | 72 +++++++++++++++++
5 files changed, 392 insertions(+)
diff --git a/crates/api/src/admin_vocab.rs b/crates/api/src/admin_vocab.rs
index b8d60de..d9b5e26 100644
--- a/crates/api/src/admin_vocab.rs
+++ b/crates/api/src/admin_vocab.rs
@@ -355,12 +355,119 @@ pub(crate) async fn delete_term(
}
}
+#[derive(Deserialize, ToSchema)]
+pub(crate) struct RenameVocabularyRequest {
+ pub key: String,
+}
+
+#[utoipa::path(
+ patch, path = "/api/admin/vocabularies/{id}",
+ request_body = RenameVocabularyRequest,
+ params(("id" = String, Path, description = "Vocabulary id (UUID)")),
+ responses(
+ (status = 204),
+ (status = 401),
+ (status = 403),
+ (status = 404),
+ (status = 409, description = "Key already in use")
+ )
+)]
+pub(crate) async fn rename_vocabulary(
+ auth: Authorized,
+ State(state): State,
+ Path(id): Path,
+ Json(req): Json,
+) -> Result {
+ let id = id
+ .parse::()
+ .map_err(|_| StatusCode::NOT_FOUND)?;
+
+ let mut tx = state
+ .db
+ .pool()
+ .begin()
+ .await
+ .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+
+ let existed = db::vocab::rename_vocabulary(
+ &mut tx,
+ AuditActor::User(auth.user.id.to_uuid()),
+ id,
+ &req.key,
+ )
+ .await
+ .map_err(|err| {
+ if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23505") {
+ StatusCode::CONFLICT
+ } else {
+ StatusCode::INTERNAL_SERVER_ERROR
+ }
+ })?;
+
+ if existed {
+ tx.commit()
+ .await
+ .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+ Ok(StatusCode::NO_CONTENT)
+ } else {
+ Err(StatusCode::NOT_FOUND)
+ }
+}
+
+#[utoipa::path(
+ delete, path = "/api/admin/vocabularies/{id}",
+ params(("id" = String, Path, description = "Vocabulary id (UUID)")),
+ responses(
+ (status = 204),
+ (status = 401),
+ (status = 403),
+ (status = 404),
+ (status = 409, body = InUseView, description = "Has terms or is bound by a field")
+ )
+)]
+pub(crate) async fn delete_vocabulary(
+ auth: Authorized,
+ State(state): State,
+ Path(id): Path,
+) -> Response {
+ let Ok(id) = id.parse::() else {
+ return StatusCode::NOT_FOUND.into_response();
+ };
+
+ let Ok(mut tx) = state.db.pool().begin().await else {
+ return StatusCode::INTERNAL_SERVER_ERROR.into_response();
+ };
+
+ match db::vocab::delete_vocabulary(&mut tx, AuditActor::User(auth.user.id.to_uuid()), id).await
+ {
+ Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
+ Ok(()) => StatusCode::NO_CONTENT.into_response(),
+ Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
+ },
+ Ok(db::DeleteOutcome::InUse { count }) => {
+ let _ = tx.rollback().await;
+
+ (StatusCode::CONFLICT, Json(InUseView { count })).into_response()
+ }
+ Ok(db::DeleteOutcome::NotFound) => {
+ let _ = tx.rollback().await;
+
+ StatusCode::NOT_FOUND.into_response()
+ }
+ Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
+ }
+}
+
pub(crate) fn routes() -> Router {
Router::new()
.route(
"/api/admin/vocabularies",
get(list_vocabularies).post(create_vocabulary),
)
+ .route(
+ "/api/admin/vocabularies/{id}",
+ axum::routing::patch(rename_vocabulary).delete(delete_vocabulary),
+ )
.route(
"/api/admin/vocabularies/{id}/terms",
get(list_terms).post(add_term),
diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs
index dcf2e99..1484363 100644
--- a/crates/api/src/openapi.rs
+++ b/crates/api/src/openapi.rs
@@ -33,6 +33,8 @@ use crate::{
admin_vocab::add_term,
admin_vocab::update_term,
admin_vocab::delete_term,
+ admin_vocab::rename_vocabulary,
+ admin_vocab::delete_vocabulary,
admin_search::search_objects,
admin_authorities::list_authorities,
admin_authorities::create_authority
@@ -64,6 +66,7 @@ use crate::{
admin_vocab::CreatedId,
admin_vocab::UpdateTermRequest,
admin_vocab::InUseView,
+ admin_vocab::RenameVocabularyRequest,
admin_search::SearchHitView,
admin_search::SearchResultsView,
admin_authorities::AuthorityView,
diff --git a/crates/api/tests/admin_catalog.rs b/crates/api/tests/admin_catalog.rs
index 336455f..4f9d05a 100644
--- a/crates/api/tests/admin_catalog.rs
+++ b/crates/api/tests/admin_catalog.rs
@@ -459,3 +459,131 @@ async fn term_edit_delete_requires_auth(pool: PgPool) {
.unwrap();
assert_eq!(delete_resp.status(), StatusCode::UNAUTHORIZED);
}
+
+#[sqlx::test(migrations = "../db/migrations")]
+async fn vocabulary_edit_delete_requires_auth(pool: PgPool) {
+ migrate_sessions(&db::Db::from_pool(pool.clone()))
+ .await
+ .unwrap();
+
+ let app = build_app(state(pool));
+ let vocab_uri = "/api/admin/vocabularies/00000000-0000-0000-0000-000000000000";
+
+ let patch_resp = app
+ .clone()
+ .oneshot(
+ Request::builder()
+ .method("PATCH")
+ .uri(vocab_uri)
+ .header(header::CONTENT_TYPE, "application/json")
+ .body(Body::from(r#"{"key":"x"}"#))
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ assert_eq!(patch_resp.status(), StatusCode::UNAUTHORIZED);
+
+ let delete_resp = app
+ .clone()
+ .oneshot(
+ Request::builder()
+ .method("DELETE")
+ .uri(vocab_uri)
+ .body(Body::empty())
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ assert_eq!(delete_resp.status(), StatusCode::UNAUTHORIZED);
+}
+
+#[sqlx::test(migrations = "../db/migrations")]
+async fn rename_and_delete_vocabulary(pool: PgPool) {
+ migrate_sessions(&db::Db::from_pool(pool.clone()))
+ .await
+ .unwrap();
+
+ seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
+
+ let app = build_app(state(pool));
+ let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
+
+ let v = send(
+ &app,
+ &cookie,
+ "POST",
+ "/api/admin/vocabularies",
+ Some(r#"{"key":"old"}"#),
+ )
+ .await;
+ let vid: serde_json::Value =
+ serde_json::from_slice(&v.into_body().collect().await.unwrap().to_bytes()).unwrap();
+ let vid = vid["id"].as_str().unwrap().to_owned();
+
+ let renamed = send(
+ &app,
+ &cookie,
+ "PATCH",
+ &format!("/api/admin/vocabularies/{vid}"),
+ Some(r#"{"key":"new"}"#),
+ )
+ .await;
+ assert_eq!(renamed.status(), StatusCode::NO_CONTENT);
+
+ let deleted = send(
+ &app,
+ &cookie,
+ "DELETE",
+ &format!("/api/admin/vocabularies/{vid}"),
+ None,
+ )
+ .await;
+ assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
+}
+
+#[sqlx::test(migrations = "../db/migrations")]
+async fn delete_vocabulary_with_terms_is_409(pool: PgPool) {
+ migrate_sessions(&db::Db::from_pool(pool.clone()))
+ .await
+ .unwrap();
+
+ seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
+
+ let app = build_app(state(pool));
+ let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
+
+ let v = send(
+ &app,
+ &cookie,
+ "POST",
+ "/api/admin/vocabularies",
+ Some(r#"{"key":"material"}"#),
+ )
+ .await;
+ let vid: serde_json::Value =
+ serde_json::from_slice(&v.into_body().collect().await.unwrap().to_bytes()).unwrap();
+ let vid = vid["id"].as_str().unwrap().to_owned();
+
+ send(
+ &app,
+ &cookie,
+ "POST",
+ &format!("/api/admin/vocabularies/{vid}/terms"),
+ Some(r#"{"external_uri":null,"labels":[{"lang":"sv","label":"Trä"}]}"#),
+ )
+ .await;
+
+ let blocked = send(
+ &app,
+ &cookie,
+ "DELETE",
+ &format!("/api/admin/vocabularies/{vid}"),
+ None,
+ )
+ .await;
+ assert_eq!(blocked.status(), StatusCode::CONFLICT);
+
+ let body: serde_json::Value =
+ serde_json::from_slice(&blocked.into_body().collect().await.unwrap().to_bytes()).unwrap();
+ assert_eq!(body["count"], 1);
+}
diff --git a/crates/db/src/vocab.rs b/crates/db/src/vocab.rs
index 681ef14..4c8f9a9 100644
--- a/crates/db/src/vocab.rs
+++ b/crates/db/src/vocab.rs
@@ -293,6 +293,88 @@ pub async fn delete_term(
Ok(crate::DeleteOutcome::Deleted)
}
+/// Rename a vocabulary's key, recording an `updated` audit entry. Returns `false` if no
+/// such vocabulary. A unique-key collision surfaces as the underlying sqlx error (23505).
+pub async fn rename_vocabulary(
+ conn: &mut sqlx::PgConnection,
+ actor: AuditActor,
+ id: VocabularyId,
+ key: &str,
+) -> Result {
+ let updated = sqlx::query("UPDATE vocabulary SET key = $2 WHERE id = $1")
+ .bind(id.to_uuid())
+ .bind(key)
+ .execute(&mut *conn)
+ .await?
+ .rows_affected();
+
+ if updated == 0 {
+ return Ok(false);
+ }
+
+ audit::record(
+ &mut *conn,
+ &NewAuditEvent {
+ actor,
+ action: AuditAction::Updated,
+ entity_type: VOCABULARY_ENTITY_TYPE.to_owned(),
+ entity_id: id.to_uuid(),
+ changes: Vec::new(),
+ },
+ )
+ .await?;
+
+ Ok(true)
+}
+
+/// Delete a vocabulary unless it still has terms or is bound by a field definition
+/// (both would otherwise hit the FK `RESTRICT`). Records a `deleted` audit entry.
+pub async fn delete_vocabulary(
+ conn: &mut sqlx::PgConnection,
+ actor: AuditActor,
+ id: VocabularyId,
+) -> Result {
+ let exists = sqlx::query_scalar::<_, i32>("SELECT 1 FROM vocabulary WHERE id = $1")
+ .bind(id.to_uuid())
+ .fetch_optional(&mut *conn)
+ .await?;
+
+ if exists.is_none() {
+ return Ok(crate::DeleteOutcome::NotFound);
+ }
+
+ let count: i64 = sqlx::query_scalar(
+ "SELECT (SELECT count(*) FROM term WHERE vocabulary_id = $1) \
+ + (SELECT count(*) FROM field_definition WHERE vocabulary_id = $1)",
+ )
+ .bind(id.to_uuid())
+ .fetch_one(&mut *conn)
+ .await?;
+
+ if count > 0 {
+ return Ok(crate::DeleteOutcome::InUse { count });
+ }
+
+ sqlx::query("DELETE FROM vocabulary WHERE id = $1")
+ .bind(id.to_uuid())
+ .execute(&mut *conn)
+ .await?;
+
+ audit::record(
+ &mut *conn,
+ &NewAuditEvent {
+ actor,
+ action: AuditAction::Deleted,
+ entity_type: VOCABULARY_ENTITY_TYPE.to_owned(),
+ entity_id: id.to_uuid(),
+ changes: Vec::new(),
+ },
+ )
+ .await?;
+
+ Ok(crate::DeleteOutcome::Deleted)
+}
+
fn map_vocabulary(row: sqlx::postgres::PgRow) -> Result {
Ok(Vocabulary {
id: VocabularyId::from_uuid(row.try_get("id")?),
diff --git a/crates/db/tests/vocab.rs b/crates/db/tests/vocab.rs
index 79564d3..2b5f1cb 100644
--- a/crates/db/tests/vocab.rs
+++ b/crates/db/tests/vocab.rs
@@ -313,3 +313,75 @@ async fn delete_term_blocks_when_referenced_then_succeeds(pool: PgPool) {
.unwrap();
assert_eq!(gone, DeleteOutcome::NotFound);
}
+
+#[sqlx::test(migrations = "../db/migrations")]
+async fn rename_vocabulary_changes_key(pool: PgPool) {
+ let db = Db::from_pool(pool);
+ let mut tx = db.pool().begin().await.unwrap();
+ let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "old")
+ .await
+ .unwrap();
+ let existed = vocab::rename_vocabulary(&mut tx, AuditActor::System, v.id, "new")
+ .await
+ .unwrap();
+ assert!(existed);
+ tx.commit().await.unwrap();
+
+ assert!(
+ vocab::vocabulary_by_key(db.pool(), "new")
+ .await
+ .unwrap()
+ .is_some()
+ );
+ assert!(
+ vocab::vocabulary_by_key(db.pool(), "old")
+ .await
+ .unwrap()
+ .is_none()
+ );
+}
+
+#[sqlx::test(migrations = "../db/migrations")]
+async fn delete_vocabulary_blocks_when_it_has_terms(pool: PgPool) {
+ use db::DeleteOutcome;
+
+ let db = Db::from_pool(pool);
+ let mut tx = db.pool().begin().await.unwrap();
+ let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
+ .await
+ .unwrap();
+ vocab::add_term(
+ &mut tx,
+ AuditActor::System,
+ &NewTerm {
+ vocabulary_id: v.id,
+ external_uri: None,
+ labels: vec![LocalizedLabel {
+ lang: "sv".into(),
+ label: "Trä".into(),
+ }],
+ },
+ )
+ .await
+ .unwrap();
+
+ let blocked = vocab::delete_vocabulary(&mut tx, AuditActor::System, v.id)
+ .await
+ .unwrap();
+ assert_eq!(blocked, DeleteOutcome::InUse { count: 1 });
+
+ let empty = vocab::create_vocabulary(&mut tx, AuditActor::System, "empty")
+ .await
+ .unwrap();
+ assert_eq!(
+ vocab::delete_vocabulary(&mut tx, AuditActor::System, empty.id)
+ .await
+ .unwrap(),
+ DeleteOutcome::Deleted
+ );
+
+ let gone = vocab::delete_vocabulary(&mut tx, AuditActor::System, empty.id)
+ .await
+ .unwrap();
+ assert_eq!(gone, DeleteOutcome::NotFound);
+}
From 47240dafcc705a72b96125210b1986f55b383cd5 Mon Sep 17 00:00:00 2001
From: Anders Olsson
Date: Fri, 5 Jun 2026 19:46:27 +0200
Subject: [PATCH 03/11] feat: edit/delete authorities, blocked when referenced
(#30)
---
crates/api/src/admin_authorities.rs | 133 +++++++++++++++++++++++++--
crates/api/src/openapi.rs | 5 +-
crates/api/tests/admin_catalog.rs | 127 ++++++++++++++++++++++++++
crates/db/src/authority.rs | 109 ++++++++++++++++++++++
crates/db/tests/authority.rs | 134 +++++++++++++++++++++++++++-
5 files changed, 497 insertions(+), 11 deletions(-)
diff --git a/crates/api/src/admin_authorities.rs b/crates/api/src/admin_authorities.rs
index 22e1e45..deba5f1 100644
--- a/crates/api/src/admin_authorities.rs
+++ b/crates/api/src/admin_authorities.rs
@@ -3,18 +3,19 @@
use auth::{Authorized, EditCatalogue, ViewInternal};
use axum::{
Json, Router,
- extract::{Query, State},
+ extract::{Path, Query, State},
http::StatusCode,
+ response::{IntoResponse, Response},
routing::get,
};
-use domain::{AuditActor, AuthorityKind, LocalizedLabel, NewAuthority};
+use domain::{AuditActor, AuthorityId, AuthorityKind, LocalizedLabel, NewAuthority};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::{
AppState,
admin_objects::LabelView,
- admin_vocab::{CreatedId, LabelInput},
+ admin_vocab::{CreatedId, InUseView, LabelInput},
};
#[derive(Serialize, ToSchema)]
@@ -129,9 +130,125 @@ pub(crate) async fn create_authority(
Ok((StatusCode::CREATED, Json(CreatedId { id: id.to_string() })))
}
-pub(crate) fn routes() -> Router {
- Router::new().route(
- "/api/admin/authorities",
- get(list_authorities).post(create_authority),
- )
+#[derive(Deserialize, ToSchema)]
+pub(crate) struct UpdateAuthorityRequest {
+ pub external_uri: Option,
+ pub labels: Vec,
+}
+
+#[utoipa::path(
+ patch, path = "/api/admin/authorities/{id}",
+ request_body = UpdateAuthorityRequest,
+ params(("id" = String, Path, description = "Authority id (UUID)")),
+ responses(
+ (status = 204),
+ (status = 401),
+ (status = 403),
+ (status = 404)
+ )
+)]
+pub(crate) async fn update_authority(
+ auth: Authorized,
+ State(state): State,
+ Path(id): Path,
+ Json(req): Json,
+) -> Result {
+ let id = id
+ .parse::()
+ .map_err(|_| StatusCode::NOT_FOUND)?;
+
+ let labels: Vec = req
+ .labels
+ .into_iter()
+ .map(|l| LocalizedLabel {
+ lang: l.lang,
+ label: l.label,
+ })
+ .collect();
+
+ let mut tx = state
+ .db
+ .pool()
+ .begin()
+ .await
+ .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+
+ let existed = db::authority::update_authority(
+ &mut tx,
+ AuditActor::User(auth.user.id.to_uuid()),
+ id,
+ req.external_uri.as_deref(),
+ &labels,
+ )
+ .await
+ .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+
+ if existed {
+ tx.commit()
+ .await
+ .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+
+ Ok(StatusCode::NO_CONTENT)
+ } else {
+ let _ = tx.rollback().await;
+
+ Err(StatusCode::NOT_FOUND)
+ }
+}
+
+#[utoipa::path(
+ delete, path = "/api/admin/authorities/{id}",
+ params(("id" = String, Path, description = "Authority id (UUID)")),
+ responses(
+ (status = 204),
+ (status = 401),
+ (status = 403),
+ (status = 404),
+ (status = 409, body = InUseView, description = "Referenced by catalogue objects")
+ )
+)]
+pub(crate) async fn delete_authority(
+ auth: Authorized,
+ State(state): State,
+ Path(id): Path,
+) -> Response {
+ let Ok(id) = id.parse::() else {
+ return StatusCode::NOT_FOUND.into_response();
+ };
+
+ let Ok(mut tx) = state.db.pool().begin().await else {
+ return StatusCode::INTERNAL_SERVER_ERROR.into_response();
+ };
+
+ match db::authority::delete_authority(&mut tx, AuditActor::User(auth.user.id.to_uuid()), id)
+ .await
+ {
+ Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
+ Ok(()) => StatusCode::NO_CONTENT.into_response(),
+ Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
+ },
+ Ok(db::DeleteOutcome::InUse { count }) => {
+ let _ = tx.rollback().await;
+
+ (StatusCode::CONFLICT, Json(InUseView { count })).into_response()
+ }
+ Ok(db::DeleteOutcome::NotFound) => {
+ let _ = tx.rollback().await;
+
+ StatusCode::NOT_FOUND.into_response()
+ }
+ Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
+ }
+}
+
+pub(crate) fn routes() -> Router {
+ Router::new()
+ .route(
+ "/api/admin/authorities",
+ get(list_authorities).post(create_authority),
+ )
+ .route(
+ "/api/admin/authorities/{id}",
+ axum::routing::patch(update_authority).delete(delete_authority),
+ )
}
diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs
index 1484363..7c7383a 100644
--- a/crates/api/src/openapi.rs
+++ b/crates/api/src/openapi.rs
@@ -37,7 +37,9 @@ use crate::{
admin_vocab::delete_vocabulary,
admin_search::search_objects,
admin_authorities::list_authorities,
- admin_authorities::create_authority
+ admin_authorities::create_authority,
+ admin_authorities::update_authority,
+ admin_authorities::delete_authority
),
components(schemas(
config::ConfigView,
@@ -71,6 +73,7 @@ use crate::{
admin_search::SearchResultsView,
admin_authorities::AuthorityView,
admin_authorities::NewAuthorityRequest,
+ admin_authorities::UpdateAuthorityRequest,
domain::Visibility,
domain::AuthorityKind,
domain::DataType
diff --git a/crates/api/tests/admin_catalog.rs b/crates/api/tests/admin_catalog.rs
index 4f9d05a..5157c31 100644
--- a/crates/api/tests/admin_catalog.rs
+++ b/crates/api/tests/admin_catalog.rs
@@ -587,3 +587,130 @@ async fn delete_vocabulary_with_terms_is_409(pool: PgPool) {
serde_json::from_slice(&blocked.into_body().collect().await.unwrap().to_bytes()).unwrap();
assert_eq!(body["count"], 1);
}
+
+#[sqlx::test(migrations = "../db/migrations")]
+async fn delete_authority_referenced_is_409(pool: PgPool) {
+ migrate_sessions(&db::Db::from_pool(pool.clone()))
+ .await
+ .unwrap();
+
+ seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
+
+ let app = build_app(state(pool));
+ let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
+
+ // create an authority
+ let a = send(
+ &app,
+ &cookie,
+ "POST",
+ "/api/admin/authorities",
+ Some(r#"{"kind":"person","external_uri":null,"labels":[{"lang":"sv","label":"Astrid"}]}"#),
+ )
+ .await;
+ assert_eq!(a.status(), StatusCode::CREATED);
+
+ let aid: serde_json::Value =
+ serde_json::from_slice(&a.into_body().collect().await.unwrap().to_bytes()).unwrap();
+ let aid = aid["id"].as_str().unwrap().to_owned();
+
+ // create an authority-typed field definition
+ send(
+ &app,
+ &cookie,
+ "POST",
+ "/api/admin/field-definitions",
+ Some(
+ r#"{"key":"maker","data_type":"authority","vocabulary_id":null,"authority_kind":"person","required":false,"group":null,"labels":[{"lang":"sv","label":"Tillverkare"}]}"#,
+ ),
+ )
+ .await;
+
+ // create an object
+ let obj = send(
+ &app,
+ &cookie,
+ "POST",
+ "/api/admin/objects",
+ Some(
+ r#"{"object_number":"T-1","object_name":"test object","number_of_objects":1,"visibility":"draft"}"#,
+ ),
+ )
+ .await;
+ assert_eq!(obj.status(), StatusCode::CREATED);
+
+ let obj_json: serde_json::Value =
+ serde_json::from_slice(&obj.into_body().collect().await.unwrap().to_bytes()).unwrap();
+ let obj_id = obj_json["id"].as_str().unwrap().to_owned();
+
+ // set the object's maker field to the authority id
+ let fields_body = format!(r#"{{"maker":"{aid}"}}"#);
+ let set = send(
+ &app,
+ &cookie,
+ "PUT",
+ &format!("/api/admin/objects/{obj_id}/fields"),
+ Some(&fields_body),
+ )
+ .await;
+ assert_eq!(set.status(), StatusCode::NO_CONTENT);
+
+ // delete the authority — must be blocked
+ let blocked = send(
+ &app,
+ &cookie,
+ "DELETE",
+ &format!("/api/admin/authorities/{aid}"),
+ None,
+ )
+ .await;
+ assert_eq!(blocked.status(), StatusCode::CONFLICT);
+
+ let body: serde_json::Value =
+ serde_json::from_slice(&blocked.into_body().collect().await.unwrap().to_bytes()).unwrap();
+ assert_eq!(body["count"], 1);
+}
+
+#[sqlx::test(migrations = "../db/migrations")]
+async fn edit_and_delete_authority(pool: PgPool) {
+ migrate_sessions(&db::Db::from_pool(pool.clone()))
+ .await
+ .unwrap();
+
+ seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
+
+ let app = build_app(state(pool));
+ let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
+
+ let a = send(
+ &app,
+ &cookie,
+ "POST",
+ "/api/admin/authorities",
+ Some(r#"{"kind":"person","external_uri":null,"labels":[{"lang":"sv","label":"Anon"}]}"#),
+ )
+ .await;
+ let aid: serde_json::Value =
+ serde_json::from_slice(&a.into_body().collect().await.unwrap().to_bytes()).unwrap();
+ let aid = aid["id"].as_str().unwrap().to_owned();
+
+ let patched = send(
+ &app,
+ &cookie,
+ "PATCH",
+ &format!("/api/admin/authorities/{aid}"),
+ Some(r#"{"external_uri":"https://viaf.org/1","labels":[{"lang":"sv","label":"Astrid"}]}"#),
+ )
+ .await;
+ assert_eq!(patched.status(), StatusCode::NO_CONTENT);
+
+ let deleted = send(
+ &app,
+ &cookie,
+ "DELETE",
+ &format!("/api/admin/authorities/{aid}"),
+ None,
+ )
+ .await;
+ assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
+}
diff --git a/crates/db/src/authority.rs b/crates/db/src/authority.rs
index dc2468f..617f406 100644
--- a/crates/db/src/authority.rs
+++ b/crates/db/src/authority.rs
@@ -124,6 +124,115 @@ where
}
}
+/// Update an authority's `external_uri` and labels (full replace), recording an
+/// `updated` audit entry. Returns `false` if no such authority. `kind` is immutable.
+pub async fn update_authority(
+ conn: &mut sqlx::PgConnection,
+ actor: AuditActor,
+ id: AuthorityId,
+ external_uri: Option<&str>,
+ labels: &[LocalizedLabel],
+) -> Result {
+ let updated = sqlx::query("UPDATE authority SET external_uri = $2 WHERE id = $1")
+ .bind(id.to_uuid())
+ .bind(external_uri)
+ .execute(&mut *conn)
+ .await?
+ .rows_affected();
+
+ if updated == 0 {
+ return Ok(false);
+ }
+
+ sqlx::query("DELETE FROM authority_label WHERE authority_id = $1")
+ .bind(id.to_uuid())
+ .execute(&mut *conn)
+ .await?;
+
+ for label in labels {
+ sqlx::query("INSERT INTO authority_label (authority_id, lang, label) VALUES ($1, $2, $3)")
+ .bind(id.to_uuid())
+ .bind(&label.lang)
+ .bind(&label.label)
+ .execute(&mut *conn)
+ .await?;
+ }
+
+ audit::record(
+ &mut *conn,
+ &NewAuditEvent {
+ actor,
+ action: AuditAction::Updated,
+ entity_type: AUTHORITY_ENTITY_TYPE.to_owned(),
+ entity_id: id.to_uuid(),
+ changes: Vec::new(),
+ },
+ )
+ .await?;
+
+ Ok(true)
+}
+
+/// Count catalogue objects referencing `id` through an `authority`-typed field.
+pub async fn count_objects_referencing_authority<'e, E>(
+ executor: E,
+ id: AuthorityId,
+) -> Result
+where
+ E: sqlx::PgExecutor<'e>,
+{
+ sqlx::query_scalar(
+ "SELECT count(*) FROM object o WHERE EXISTS ( \
+ SELECT 1 FROM field_definition fd \
+ WHERE fd.data_type = 'authority' AND o.fields ->> fd.key = $1 )",
+ )
+ .bind(id.to_string())
+ .fetch_one(executor)
+ .await
+}
+
+/// Delete an authority (labels cascade) unless catalogue objects reference it,
+/// recording a `deleted` audit entry.
+pub async fn delete_authority(
+ conn: &mut sqlx::PgConnection,
+ actor: AuditActor,
+ id: AuthorityId,
+) -> Result {
+ let exists = sqlx::query_scalar::<_, i32>("SELECT 1 FROM authority WHERE id = $1")
+ .bind(id.to_uuid())
+ .fetch_optional(&mut *conn)
+ .await?;
+
+ if exists.is_none() {
+ return Ok(crate::DeleteOutcome::NotFound);
+ }
+
+ let count = count_objects_referencing_authority(&mut *conn, id).await?;
+
+ if count > 0 {
+ return Ok(crate::DeleteOutcome::InUse { count });
+ }
+
+ sqlx::query("DELETE FROM authority WHERE id = $1")
+ .bind(id.to_uuid())
+ .execute(&mut *conn)
+ .await?;
+
+ audit::record(
+ &mut *conn,
+ &NewAuditEvent {
+ actor,
+ action: AuditAction::Deleted,
+ entity_type: AUTHORITY_ENTITY_TYPE.to_owned(),
+ entity_id: id.to_uuid(),
+ changes: Vec::new(),
+ },
+ )
+ .await?;
+
+ Ok(crate::DeleteOutcome::Deleted)
+}
+
fn map_authority(row: sqlx::postgres::PgRow) -> Result {
let kind_str: String = row.try_get("kind")?;
let kind = AuthorityKind::from_db(&kind_str)
diff --git a/crates/db/tests/authority.rs b/crates/db/tests/authority.rs
index c7ebc51..36bff06 100644
--- a/crates/db/tests/authority.rs
+++ b/crates/db/tests/authority.rs
@@ -1,7 +1,23 @@
-use db::{Db, authority};
-use domain::{AuditActor, AuthorityKind, LocalizedLabel, NewAuthority};
+use db::{Db, authority, catalog, fields};
+use domain::{
+ AuditActor, AuthorityKind, LocalizedLabel, NewAuthority, NewFieldDefinition, Visibility,
+};
use sqlx::PgPool;
+fn sample_object_input() -> domain::ObjectInput {
+ domain::ObjectInput {
+ object_number: "X.1".into(),
+ object_name: "Test".into(),
+ number_of_objects: 1,
+ brief_description: None,
+ current_location: None,
+ current_owner: None,
+ recorder: None,
+ recording_date: None,
+ visibility: Visibility::Draft,
+ }
+}
+
fn new_person(name_sv: &str, name_en: &str) -> NewAuthority {
NewAuthority {
kind: AuthorityKind::Person,
@@ -131,3 +147,117 @@ async fn authority_with_no_labels_round_trips_empty(pool: PgPool) {
assert_eq!(got.kind, AuthorityKind::Organisation);
assert!(got.labels.is_empty());
}
+
+#[sqlx::test(migrations = "../db/migrations")]
+async fn update_authority_changes_labels(pool: PgPool) {
+ let db = Db::from_pool(pool);
+ let mut tx = db.pool().begin().await.unwrap();
+ let id = authority::create_authority(
+ &mut tx,
+ AuditActor::System,
+ &NewAuthority {
+ kind: AuthorityKind::Person,
+ external_uri: None,
+ labels: vec![LocalizedLabel {
+ lang: "sv".into(),
+ label: "Anon".into(),
+ }],
+ },
+ )
+ .await
+ .unwrap();
+
+ let existed = authority::update_authority(
+ &mut tx,
+ AuditActor::System,
+ id,
+ Some("https://viaf.org/1"),
+ &[LocalizedLabel {
+ lang: "sv".into(),
+ label: "Astrid".into(),
+ }],
+ )
+ .await
+ .unwrap();
+ assert!(existed);
+ tx.commit().await.unwrap();
+
+ let a = authority::authority_by_id(db.pool(), id)
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(a.external_uri.as_deref(), Some("https://viaf.org/1"));
+ assert_eq!(a.labels[0].label, "Astrid");
+}
+
+#[sqlx::test(migrations = "../db/migrations")]
+async fn delete_authority_blocks_when_referenced(pool: PgPool) {
+ use db::DeleteOutcome;
+
+ let db = Db::from_pool(pool);
+ let mut tx = db.pool().begin().await.unwrap();
+ let id = authority::create_authority(
+ &mut tx,
+ AuditActor::System,
+ &NewAuthority {
+ kind: AuthorityKind::Person,
+ external_uri: None,
+ labels: vec![LocalizedLabel {
+ lang: "sv".into(),
+ label: "Astrid".into(),
+ }],
+ },
+ )
+ .await
+ .unwrap();
+
+ fields::create_field_definition(
+ &mut tx,
+ &NewFieldDefinition {
+ key: "maker".into(),
+ field_type: domain::FieldType::Authority {
+ kind: Some(AuthorityKind::Person),
+ },
+ required: false,
+ group_key: None,
+ labels: vec![LocalizedLabel {
+ lang: "sv".into(),
+ label: "Tillverkare".into(),
+ }],
+ },
+ )
+ .await
+ .unwrap();
+
+ let obj = catalog::create_object(&mut tx, AuditActor::System, &sample_object_input())
+ .await
+ .unwrap();
+ let mut map = serde_json::Map::new();
+ map.insert("maker".into(), serde_json::Value::String(id.to_string()));
+ catalog::set_object_fields(&mut tx, AuditActor::System, obj, &map)
+ .await
+ .unwrap();
+
+ assert_eq!(
+ authority::delete_authority(&mut tx, AuditActor::System, id)
+ .await
+ .unwrap(),
+ DeleteOutcome::InUse { count: 1 }
+ );
+
+ catalog::set_object_fields(&mut tx, AuditActor::System, obj, &serde_json::Map::new())
+ .await
+ .unwrap();
+ assert_eq!(
+ authority::delete_authority(&mut tx, AuditActor::System, id)
+ .await
+ .unwrap(),
+ DeleteOutcome::Deleted
+ );
+ assert_eq!(
+ authority::delete_authority(&mut tx, AuditActor::System, id)
+ .await
+ .unwrap(),
+ DeleteOutcome::NotFound
+ );
+}
From 3e7c6ad712979120ff93fd518f0a4459608f8ad0 Mon Sep 17 00:00:00 2001
From: Anders Olsson
Date: Fri, 5 Jun 2026 19:58:38 +0200
Subject: [PATCH 04/11] =?UTF-8?q?feat:=20edit/delete=20field=20definitions?=
=?UTF-8?q?=20=E2=80=94=20audited,=20blocked=20when=20in=20use=20(#36)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Sonnet 4.6
---
crates/api/src/admin_objects.rs | 133 +++++++++++++++++++++++-
crates/api/src/openapi.rs | 3 +
crates/api/tests/admin_catalog.rs | 164 ++++++++++++++++++++++++++++++
crates/db/src/fields.rs | 120 +++++++++++++++++++++-
crates/db/tests/fields.rs | 125 ++++++++++++++++++++++-
5 files changed, 540 insertions(+), 5 deletions(-)
diff --git a/crates/api/src/admin_objects.rs b/crates/api/src/admin_objects.rs
index c053d85..ef32f2d 100644
--- a/crates/api/src/admin_objects.rs
+++ b/crates/api/src/admin_objects.rs
@@ -6,7 +6,7 @@ use axum::{
Json, Router,
extract::{Path, Query, State},
http::StatusCode,
- response::IntoResponse,
+ response::{IntoResponse, Response},
routing::{get, put},
};
use domain::{
@@ -510,6 +510,133 @@ pub(crate) async fn create_field_definition(
}
}
+/// Fields that may be changed on an existing field definition. `key`, `data_type`, and
+/// binding are immutable and intentionally absent from this request.
+#[derive(Deserialize, ToSchema)]
+pub(crate) struct UpdateFieldDefinitionRequest {
+ pub required: bool,
+ pub group: Option,
+ pub labels: Vec,
+}
+
+/// Update a field definition's mutable attributes (labels, group, required).
+/// `key`, `data_type`, and binding are immutable. Requires `EditCatalogue`.
+#[utoipa::path(
+ patch, path = "/api/admin/field-definitions/{key}",
+ request_body = UpdateFieldDefinitionRequest,
+ params(("key" = String, Path, description = "Field definition key")),
+ responses(
+ (status = 204),
+ (status = 401),
+ (status = 403),
+ (status = 404),
+ (status = 422, description = "CHECK constraint violated (e.g. empty label)")
+ )
+)]
+pub(crate) async fn update_field_definition(
+ auth: Authorized,
+ State(state): State,
+ Path(key): Path,
+ Json(req): Json,
+) -> Result {
+ let labels: Vec = req
+ .labels
+ .into_iter()
+ .map(|l| LocalizedLabel {
+ lang: l.lang,
+ label: l.label,
+ })
+ .collect();
+
+ let mut tx = state
+ .db
+ .pool()
+ .begin()
+ .await
+ .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+
+ let result = db::fields::update_field_definition(
+ &mut tx,
+ actor(&auth.user),
+ &key,
+ req.required,
+ req.group.as_deref(),
+ &labels,
+ )
+ .await;
+
+ match result {
+ Ok(true) => {
+ tx.commit()
+ .await
+ .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
+
+ Ok(StatusCode::NO_CONTENT)
+ }
+ Ok(false) => {
+ let _ = tx.rollback().await;
+
+ Err(StatusCode::NOT_FOUND)
+ }
+ Err(err) => {
+ let _ = tx.rollback().await;
+
+ match err.as_database_error().and_then(|e| e.code()).as_deref() {
+ Some("23514") => Err(StatusCode::UNPROCESSABLE_ENTITY),
+ _ => Err(StatusCode::INTERNAL_SERVER_ERROR),
+ }
+ }
+ }
+}
+
+/// Delete a field definition. Blocked (409) when catalogue objects store a value under
+/// this key. Requires `EditCatalogue`.
+#[utoipa::path(
+ delete, path = "/api/admin/field-definitions/{key}",
+ params(("key" = String, Path, description = "Field definition key")),
+ responses(
+ (status = 204),
+ (status = 401),
+ (status = 403),
+ (status = 404),
+ (status = 409, body = crate::admin_vocab::InUseView,
+ description = "Field is used by catalogue objects")
+ )
+)]
+pub(crate) async fn delete_field_definition(
+ auth: Authorized,
+ State(state): State,
+ Path(key): Path,
+) -> Response {
+ use crate::admin_vocab::InUseView;
+
+ let Ok(mut tx) = state.db.pool().begin().await else {
+ return StatusCode::INTERNAL_SERVER_ERROR.into_response();
+ };
+
+ match db::fields::delete_field_definition(&mut tx, actor(&auth.user), &key).await {
+ Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
+ Ok(()) => StatusCode::NO_CONTENT.into_response(),
+ Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
+ },
+ Ok(db::DeleteOutcome::InUse { count }) => {
+ let _ = tx.rollback().await;
+
+ (StatusCode::CONFLICT, Json(InUseView { count })).into_response()
+ }
+ Ok(db::DeleteOutcome::NotFound) => {
+ let _ = tx.rollback().await;
+
+ StatusCode::NOT_FOUND.into_response()
+ }
+ Err(_) => {
+ let _ = tx.rollback().await;
+
+ StatusCode::INTERNAL_SERVER_ERROR.into_response()
+ }
+ }
+}
+
/// Field-level rejection detail for `set_fields`, so the UI can highlight the field.
#[derive(Serialize, ToSchema)]
pub(crate) struct FieldErrorView {
@@ -609,4 +736,8 @@ pub(crate) fn routes() -> Router {
"/api/admin/field-definitions",
get(list_field_definitions).post(create_field_definition),
)
+ .route(
+ "/api/admin/field-definitions/{key}",
+ axum::routing::patch(update_field_definition).delete(delete_field_definition),
+ )
}
diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs
index 7c7383a..1827dec 100644
--- a/crates/api/src/openapi.rs
+++ b/crates/api/src/openapi.rs
@@ -26,6 +26,8 @@ use crate::{
admin_objects::delete_object,
admin_objects::list_field_definitions,
admin_objects::create_field_definition,
+ admin_objects::update_field_definition,
+ admin_objects::delete_field_definition,
admin_objects::set_fields,
admin_vocab::list_vocabularies,
admin_vocab::create_vocabulary,
@@ -58,6 +60,7 @@ use crate::{
admin_objects::CreatedObject,
admin_objects::FieldDefinitionView,
admin_objects::NewFieldDefinitionRequest,
+ admin_objects::UpdateFieldDefinitionRequest,
admin_objects::CreatedField,
admin_objects::FieldErrorView,
admin_vocab::VocabularyView,
diff --git a/crates/api/tests/admin_catalog.rs b/crates/api/tests/admin_catalog.rs
index 5157c31..a8d957c 100644
--- a/crates/api/tests/admin_catalog.rs
+++ b/crates/api/tests/admin_catalog.rs
@@ -714,3 +714,167 @@ async fn edit_and_delete_authority(pool: PgPool) {
.await;
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
}
+
+#[sqlx::test(migrations = "../db/migrations")]
+async fn edit_and_delete_field_definition(pool: PgPool) {
+ migrate_sessions(&db::Db::from_pool(pool.clone()))
+ .await
+ .unwrap();
+
+ seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
+
+ let app = build_app(state(pool));
+ let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
+
+ // create a field definition
+ send(
+ &app,
+ &cookie,
+ "POST",
+ "/api/admin/field-definitions",
+ Some(r#"{"key":"weight","data_type":"integer","vocabulary_id":null,"authority_kind":null,"required":false,"group":null,"labels":[{"lang":"sv","label":"Vikt"}]}"#),
+ )
+ .await;
+
+ // PATCH: update required + group + labels
+ let patched = send(
+ &app,
+ &cookie,
+ "PATCH",
+ "/api/admin/field-definitions/weight",
+ Some(r#"{"required":true,"group":"Mått","labels":[{"lang":"sv","label":"Vikt (g)"}]}"#),
+ )
+ .await;
+ assert_eq!(patched.status(), StatusCode::NO_CONTENT);
+
+ // PATCH unknown key → 404
+ let missing = send(
+ &app,
+ &cookie,
+ "PATCH",
+ "/api/admin/field-definitions/nope",
+ Some(r#"{"required":false,"group":null,"labels":[]}"#),
+ )
+ .await;
+ assert_eq!(missing.status(), StatusCode::NOT_FOUND);
+
+ // DELETE the (unreferenced) field definition
+ let deleted = send(
+ &app,
+ &cookie,
+ "DELETE",
+ "/api/admin/field-definitions/weight",
+ None,
+ )
+ .await;
+ assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
+
+ // DELETE again → 404
+ let again = send(
+ &app,
+ &cookie,
+ "DELETE",
+ "/api/admin/field-definitions/weight",
+ None,
+ )
+ .await;
+ assert_eq!(again.status(), StatusCode::NOT_FOUND);
+}
+
+#[sqlx::test(migrations = "../db/migrations")]
+async fn delete_field_definition_referenced_is_409(pool: PgPool) {
+ migrate_sessions(&db::Db::from_pool(pool.clone()))
+ .await
+ .unwrap();
+
+ seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
+
+ let app = build_app(state(pool));
+ let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
+
+ // create a field definition
+ send(
+ &app,
+ &cookie,
+ "POST",
+ "/api/admin/field-definitions",
+ Some(r#"{"key":"weight","data_type":"integer","vocabulary_id":null,"authority_kind":null,"required":false,"group":null,"labels":[{"lang":"sv","label":"Vikt"}]}"#),
+ )
+ .await;
+
+ // create an object and set the field
+ let obj = send(
+ &app,
+ &cookie,
+ "POST",
+ "/api/admin/objects",
+ Some(r#"{"object_number":"T-2","object_name":"test","number_of_objects":1,"visibility":"draft"}"#),
+ )
+ .await;
+ assert_eq!(obj.status(), StatusCode::CREATED);
+
+ let obj_json: serde_json::Value =
+ serde_json::from_slice(&obj.into_body().collect().await.unwrap().to_bytes()).unwrap();
+ let obj_id = obj_json["id"].as_str().unwrap().to_owned();
+
+ let set = send(
+ &app,
+ &cookie,
+ "PUT",
+ &format!("/api/admin/objects/{obj_id}/fields"),
+ Some(r#"{"weight":42}"#),
+ )
+ .await;
+ assert_eq!(set.status(), StatusCode::NO_CONTENT);
+
+ // delete the field definition — must be blocked
+ let blocked = send(
+ &app,
+ &cookie,
+ "DELETE",
+ "/api/admin/field-definitions/weight",
+ None,
+ )
+ .await;
+ assert_eq!(blocked.status(), StatusCode::CONFLICT);
+
+ let body: serde_json::Value =
+ serde_json::from_slice(&blocked.into_body().collect().await.unwrap().to_bytes()).unwrap();
+ assert_eq!(body["count"], 1);
+}
+
+#[sqlx::test(migrations = "../db/migrations")]
+async fn field_definition_edit_delete_requires_auth(pool: PgPool) {
+ migrate_sessions(&db::Db::from_pool(pool.clone()))
+ .await
+ .unwrap();
+
+ let app = build_app(state(pool));
+
+ let patch_resp = app
+ .clone()
+ .oneshot(
+ Request::builder()
+ .method("PATCH")
+ .uri("/api/admin/field-definitions/weight")
+ .header(header::CONTENT_TYPE, "application/json")
+ .body(Body::from(r#"{"required":false,"group":null,"labels":[]}"#))
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ assert_eq!(patch_resp.status(), StatusCode::UNAUTHORIZED);
+
+ let delete_resp = app
+ .clone()
+ .oneshot(
+ Request::builder()
+ .method("DELETE")
+ .uri("/api/admin/field-definitions/weight")
+ .body(Body::empty())
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ assert_eq!(delete_resp.status(), StatusCode::UNAUTHORIZED);
+}
diff --git a/crates/db/src/fields.rs b/crates/db/src/fields.rs
index d07ba6e..83dd6a2 100644
--- a/crates/db/src/fields.rs
+++ b/crates/db/src/fields.rs
@@ -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,
+ labels: &[LocalizedLabel],
+) -> Result {
+ let id: Option =
+ 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
+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 {
+ let id: Option =
+ 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)
+}
diff --git a/crates/db/tests/fields.rs b/crates/db/tests/fields.rs
index faf8ecf..c0efef6 100644
--- a/crates/db/tests/fields.rs
+++ b/crates/db/tests/fields.rs
@@ -1,7 +1,24 @@
-use db::{Db, fields, vocab};
-use domain::{AuditActor, AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition};
+use db::{Db, DeleteOutcome, catalog, fields, vocab};
+use domain::{
+ AuditActor, AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition, ObjectInput,
+ Visibility,
+};
use sqlx::PgPool;
+fn sample_object_input() -> ObjectInput {
+ ObjectInput {
+ object_number: "X.1".into(),
+ object_name: "Test".into(),
+ number_of_objects: 1,
+ brief_description: None,
+ current_location: None,
+ current_owner: None,
+ recorder: None,
+ recording_date: None,
+ visibility: Visibility::Draft,
+ }
+}
+
fn labels() -> Vec {
vec![
LocalizedLabel {
@@ -171,3 +188,107 @@ async fn any_authority_scalar_and_zero_labels_round_trip(pool: PgPool) {
let keys: Vec<&str> = all.iter().map(|d| d.key.as_str()).collect();
assert_eq!(keys, vec!["donor", "on_display"]);
}
+
+#[sqlx::test(migrations = "../db/migrations")]
+async fn update_field_definition_edits_labels_group_required(pool: PgPool) {
+ let db = Db::from_pool(pool);
+ let mut tx = db.pool().begin().await.unwrap();
+
+ fields::create_field_definition(
+ &mut tx,
+ &NewFieldDefinition {
+ key: "weight".into(),
+ field_type: FieldType::Integer,
+ required: false,
+ group_key: None,
+ labels: vec![LocalizedLabel {
+ lang: "sv".into(),
+ label: "Vikt".into(),
+ }],
+ },
+ )
+ .await
+ .unwrap();
+
+ let existed = fields::update_field_definition(
+ &mut tx,
+ AuditActor::System,
+ "weight",
+ true,
+ Some("Mått"),
+ &[LocalizedLabel {
+ lang: "sv".into(),
+ label: "Vikt (g)".into(),
+ }],
+ )
+ .await
+ .unwrap();
+ assert!(existed);
+
+ tx.commit().await.unwrap();
+
+ let def = fields::field_definition_by_key(db.pool(), "weight")
+ .await
+ .unwrap()
+ .unwrap();
+ assert!(def.required);
+ assert_eq!(def.group_key.as_deref(), Some("Mått"));
+ assert_eq!(def.labels[0].label, "Vikt (g)");
+}
+
+#[sqlx::test(migrations = "../db/migrations")]
+async fn delete_field_definition_blocks_when_objects_use_it(pool: PgPool) {
+ let db = Db::from_pool(pool);
+ let mut tx = db.pool().begin().await.unwrap();
+
+ fields::create_field_definition(
+ &mut tx,
+ &NewFieldDefinition {
+ key: "weight".into(),
+ field_type: FieldType::Integer,
+ required: false,
+ group_key: None,
+ labels: vec![LocalizedLabel {
+ lang: "sv".into(),
+ label: "Vikt".into(),
+ }],
+ },
+ )
+ .await
+ .unwrap();
+
+ let obj = catalog::create_object(&mut tx, AuditActor::System, &sample_object_input())
+ .await
+ .unwrap();
+
+ let mut map = serde_json::Map::new();
+ map.insert("weight".into(), serde_json::Value::from(42));
+ catalog::set_object_fields(&mut tx, AuditActor::System, obj, &map)
+ .await
+ .unwrap();
+
+ assert_eq!(
+ fields::delete_field_definition(&mut tx, AuditActor::System, "weight")
+ .await
+ .unwrap(),
+ DeleteOutcome::InUse { count: 1 }
+ );
+
+ catalog::set_object_fields(&mut tx, AuditActor::System, obj, &serde_json::Map::new())
+ .await
+ .unwrap();
+
+ assert_eq!(
+ fields::delete_field_definition(&mut tx, AuditActor::System, "weight")
+ .await
+ .unwrap(),
+ DeleteOutcome::Deleted
+ );
+
+ assert_eq!(
+ fields::delete_field_definition(&mut tx, AuditActor::System, "weight")
+ .await
+ .unwrap(),
+ DeleteOutcome::NotFound
+ );
+}
From 78c950d2eee5bc7f1b14927d1942ed001267b258 Mon Sep 17 00:00:00 2001
From: Anders Olsson
Date: Fri, 5 Jun 2026 20:04:03 +0200
Subject: [PATCH 05/11] chore(web): regenerate API types for reference-data
edit/delete
Co-Authored-By: Claude Opus 4.8 (1M context)
---
web/src/api/schema.d.ts | 471 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 471 insertions(+)
diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts
index 4e49a0d..dedd2bf 100644
--- a/web/src/api/schema.d.ts
+++ b/web/src/api/schema.d.ts
@@ -20,6 +20,22 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/admin/authorities/{id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ delete: operations["delete_authority"];
+ options?: never;
+ head?: never;
+ patch: operations["update_authority"];
+ trace?: never;
+ };
"/api/admin/field-definitions": {
parameters: {
query?: never;
@@ -42,6 +58,30 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/admin/field-definitions/{key}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ /**
+ * Delete a field definition. Blocked (409) when catalogue objects store a value under
+ * this key. Requires `EditCatalogue`.
+ */
+ delete: operations["delete_field_definition"];
+ options?: never;
+ head?: never;
+ /**
+ * Update a field definition's mutable attributes (labels, group, required).
+ * `key`, `data_type`, and binding are immutable. Requires `EditCatalogue`.
+ */
+ patch: operations["update_field_definition"];
+ trace?: never;
+ };
"/api/admin/login": {
parameters: {
query?: never;
@@ -222,6 +262,22 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/admin/vocabularies/{id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ delete: operations["delete_vocabulary"];
+ options?: never;
+ head?: never;
+ patch: operations["rename_vocabulary"];
+ trace?: never;
+ };
"/api/admin/vocabularies/{id}/terms": {
parameters: {
query?: never;
@@ -238,6 +294,22 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/admin/vocabularies/{id}/terms/{term_id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ delete: operations["delete_term"];
+ options?: never;
+ head?: never;
+ patch: operations["update_term"];
+ trace?: never;
+ };
"/api/config": {
parameters: {
query?: never;
@@ -415,6 +487,11 @@ export interface components {
/** @description The flexible-field key that was rejected. */
field: string;
};
+ /** @description 409 body: how many catalogue objects still reference the entity. */
+ InUseView: {
+ /** Format: int64 */
+ count: number;
+ };
LabelInput: {
label: string;
lang: string;
@@ -514,6 +591,9 @@ export interface components {
/** @description `"ok"` when ready, `"degraded"` otherwise. */
status: string;
};
+ RenameVocabularyRequest: {
+ key: string;
+ };
SearchHitView: {
brief_description?: string | null;
id: string;
@@ -532,6 +612,23 @@ export interface components {
id: string;
labels: components["schemas"]["LabelView"][];
};
+ UpdateAuthorityRequest: {
+ external_uri?: string | null;
+ labels: components["schemas"]["LabelInput"][];
+ };
+ /**
+ * @description Fields that may be changed on an existing field definition. `key`, `data_type`, and
+ * binding are immutable and intentionally absent from this request.
+ */
+ UpdateFieldDefinitionRequest: {
+ group?: string | null;
+ labels: components["schemas"]["LabelInput"][];
+ required: boolean;
+ };
+ UpdateTermRequest: {
+ external_uri?: string | null;
+ labels: components["schemas"]["LabelInput"][];
+ };
/** @description A user as exposed on the admin surface (no password material). */
UserView: {
email: string;
@@ -641,6 +738,95 @@ export interface operations {
};
};
};
+ delete_authority: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Authority id (UUID) */
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Referenced by catalogue objects */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["InUseView"];
+ };
+ };
+ };
+ };
+ update_authority: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Authority id (UUID) */
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["UpdateAuthorityRequest"];
+ };
+ };
+ responses: {
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
list_field_definitions: {
parameters: {
query?: never;
@@ -728,6 +914,102 @@ export interface operations {
};
};
};
+ delete_field_definition: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Field definition key */
+ key: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Field is used by catalogue objects */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["InUseView"];
+ };
+ };
+ };
+ };
+ update_field_definition: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Field definition key */
+ key: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["UpdateFieldDefinitionRequest"];
+ };
+ };
+ responses: {
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description CHECK constraint violated (e.g. empty label) */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
login: {
parameters: {
query?: never;
@@ -1258,6 +1540,102 @@ export interface operations {
};
};
};
+ delete_vocabulary: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Vocabulary id (UUID) */
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Has terms or is bound by a field */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["InUseView"];
+ };
+ };
+ };
+ };
+ rename_vocabulary: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Vocabulary id (UUID) */
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["RenameVocabularyRequest"];
+ };
+ };
+ responses: {
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Key already in use */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
list_terms: {
parameters: {
query?: never;
@@ -1342,6 +1720,99 @@ export interface operations {
};
};
};
+ delete_term: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Vocabulary id (UUID) */
+ id: string;
+ /** @description Term id (UUID) */
+ term_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Referenced by catalogue objects */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["InUseView"];
+ };
+ };
+ };
+ };
+ update_term: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Vocabulary id (UUID) */
+ id: string;
+ /** @description Term id (UUID) */
+ term_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["UpdateTermRequest"];
+ };
+ };
+ responses: {
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
get_config: {
parameters: {
query?: never;
From 282e6430d4abbb98c0654257f617a0951fda88cb Mon Sep 17 00:00:00 2001
From: Anders Olsson
Date: Fri, 5 Jun 2026 20:06:23 +0200
Subject: [PATCH 06/11] feat(web): mutation hooks + InUseError + i18n for
reference-data edit/delete
---
web/src/api/queries.test.ts | 11 +++
web/src/api/queries.ts | 164 ++++++++++++++++++++++++++++++++++++
web/src/i18n/en.json | 2 +-
web/src/i18n/sv.json | 2 +-
4 files changed, 177 insertions(+), 2 deletions(-)
create mode 100644 web/src/api/queries.test.ts
diff --git a/web/src/api/queries.test.ts b/web/src/api/queries.test.ts
new file mode 100644
index 0000000..bd45710
--- /dev/null
+++ b/web/src/api/queries.test.ts
@@ -0,0 +1,11 @@
+import { describe, it, expect } from "vitest";
+
+import { InUseError } from "./queries";
+
+describe("InUseError", () => {
+ it("carries the count", () => {
+ const e = new InUseError(7);
+ expect(e.count).toBe(7);
+ expect(e).toBeInstanceOf(Error);
+ });
+});
diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts
index 5741123..c4c82fe 100644
--- a/web/src/api/queries.ts
+++ b/web/src/api/queries.ts
@@ -17,6 +17,13 @@ export class FieldRejection extends Error {
}
}
+export class InUseError extends Error {
+ constructor(public readonly count: number) {
+ super(`in use: ${count}`);
+ this.name = "InUseError";
+ }
+}
+
type UserView = components["schemas"]["UserView"];
type LoginRequest = components["schemas"]["LoginRequest"];
@@ -381,3 +388,160 @@ export function useSetVisibility() {
},
});
}
+
+export function useUpdateTerm() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ vocabularyId,
+ termId,
+ external_uri,
+ labels,
+ }: {
+ vocabularyId: string;
+ termId: string;
+ external_uri: string | null;
+ labels: LabelInput[];
+ }) => {
+ const { response } = await api.PATCH("/api/admin/vocabularies/{id}/terms/{term_id}", {
+ params: { path: { id: vocabularyId, term_id: termId } },
+ body: { external_uri, labels },
+ });
+
+ if (response.status !== 204) throw new Error("update term failed");
+ },
+ onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }),
+ });
+}
+
+export function useDeleteTerm() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ vocabularyId, termId }: { vocabularyId: string; termId: string }) => {
+ const { error, response } = await api.DELETE("/api/admin/vocabularies/{id}/terms/{term_id}", {
+ params: { path: { id: vocabularyId, term_id: termId } },
+ });
+
+ if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
+ if (response.status !== 204) throw new Error("delete term failed");
+ },
+ onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }),
+ });
+}
+
+export function useRenameVocabulary() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ id, key }: { id: string; key: string }) => {
+ const { response } = await api.PATCH("/api/admin/vocabularies/{id}", {
+ params: { path: { id } },
+ body: { key },
+ });
+
+ if (response.status !== 204) throw new Error("rename failed");
+ },
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }),
+ });
+}
+
+export function useDeleteVocabulary() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (id: string) => {
+ const { error, response } = await api.DELETE("/api/admin/vocabularies/{id}", {
+ params: { path: { id } },
+ });
+
+ if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
+ if (response.status !== 204) throw new Error("delete vocabulary failed");
+ },
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }),
+ });
+}
+
+export function useUpdateAuthority() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ id,
+ external_uri,
+ labels,
+ }: {
+ id: string;
+ kind: string;
+ external_uri: string | null;
+ labels: LabelInput[];
+ }) => {
+ const { response } = await api.PATCH("/api/admin/authorities/{id}", {
+ params: { path: { id } },
+ body: { external_uri, labels },
+ });
+
+ if (response.status !== 204) throw new Error("update authority failed");
+ },
+ onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: ["authorities", kind] }),
+ });
+}
+
+export function useDeleteAuthority() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ id }: { id: string; kind: string }) => {
+ const { error, response } = await api.DELETE("/api/admin/authorities/{id}", {
+ params: { path: { id } },
+ });
+
+ if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
+ if (response.status !== 204) throw new Error("delete authority failed");
+ },
+ onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: ["authorities", kind] }),
+ });
+}
+
+export function useUpdateFieldDefinition() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ key,
+ required,
+ group,
+ labels,
+ }: {
+ key: string;
+ required: boolean;
+ group: string | null;
+ labels: LabelInput[];
+ }) => {
+ const { response } = await api.PATCH("/api/admin/field-definitions/{key}", {
+ params: { path: { key } },
+ body: { required, group, labels },
+ });
+
+ if (response.status !== 204) throw new Error("update field failed");
+ },
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }),
+ });
+}
+
+export function useDeleteFieldDefinition() {
+ const qc = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (key: string) => {
+ const { error, response } = await api.DELETE("/api/admin/field-definitions/{key}", {
+ params: { path: { key } },
+ });
+
+ if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
+ if (response.status !== 204) throw new Error("delete field failed");
+ },
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }),
+ });
+}
diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json
index 8f798b5..2ed788b 100644
--- a/web/src/i18n/en.json
+++ b/web/src/i18n/en.json
@@ -6,7 +6,7 @@
"fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility", "flexible": "Catalogue fields" },
"visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" },
"form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "fieldRejected": "The field \"{{field}}\" was rejected — check its value", "flexibleHeading": "Catalogue fields" },
- "actions": { "edit": "Edit", "delete": "Delete", "confirmDelete": "Delete this object? This cannot be undone." },
+ "actions": { "edit": "Edit", "delete": "Delete", "rename": "Rename", "save": "Save", "confirmDelete": "Delete this object? This cannot be undone.", "confirmDeleteTerm": "Delete this term? This cannot be undone.", "confirmDeleteAuthority": "Delete this authority? This cannot be undone.", "confirmDeleteField": "Delete this field definition? This cannot be undone.", "confirmDeleteVocabulary": "Delete this vocabulary? This cannot be undone.", "inUse": "Can't delete — used by {{count}} object(s). Clear those fields first." },
"labels": { "label": "Label", "externalUri": "External URI (optional)" },
"vocab": {
"newVocabulary": "New vocabulary", "key": "Key",
diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json
index 725ab38..456d269 100644
--- a/web/src/i18n/sv.json
+++ b/web/src/i18n/sv.json
@@ -6,7 +6,7 @@
"fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet", "flexible": "Katalogfält" },
"visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" },
"form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "fieldRejected": "Fältet \"{{field}}\" avvisades — kontrollera värdet", "flexibleHeading": "Katalogfält" },
- "actions": { "edit": "Redigera", "delete": "Ta bort", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras." },
+ "actions": { "edit": "Redigera", "delete": "Ta bort", "rename": "Byt namn", "save": "Spara", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras.", "confirmDeleteTerm": "Ta bort denna term? Detta kan inte ångras.", "confirmDeleteAuthority": "Ta bort denna auktoritet? Detta kan inte ångras.", "confirmDeleteField": "Ta bort denna fältdefinition? Detta kan inte ångras.", "confirmDeleteVocabulary": "Ta bort denna vokabulär? Detta kan inte ångras.", "inUse": "Kan inte ta bort — används av {{count}} föremål. Rensa de fälten först." },
"labels": { "label": "Etikett", "externalUri": "Extern URI (valfritt)" },
"vocab": {
"newVocabulary": "Ny vokabulär", "key": "Nyckel",
From 194f18c8ede37c810cd793cdf8b975dec872180e Mon Sep 17 00:00:00 2001
From: Anders Olsson
Date: Fri, 5 Jun 2026 20:12:23 +0200
Subject: [PATCH 07/11] feat(web): reusable DeleteConfirmDialog with in-use
handling + stories
---
.../delete-confirm-dialog.stories.tsx | 36 ++++++++++
web/src/components/delete-confirm-dialog.tsx | 70 +++++++++++++++++++
2 files changed, 106 insertions(+)
create mode 100644 web/src/components/delete-confirm-dialog.stories.tsx
create mode 100644 web/src/components/delete-confirm-dialog.tsx
diff --git a/web/src/components/delete-confirm-dialog.stories.tsx b/web/src/components/delete-confirm-dialog.stories.tsx
new file mode 100644
index 0000000..6665f80
--- /dev/null
+++ b/web/src/components/delete-confirm-dialog.stories.tsx
@@ -0,0 +1,36 @@
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import { expect, userEvent, fn, within } from 'storybook/test'
+
+import { DeleteConfirmDialog } from './delete-confirm-dialog'
+import { InUseError } from '../api/queries'
+
+const meta = {
+ component: DeleteConfirmDialog,
+ tags: ['ai-generated'],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Confirms: Story = {
+ args: { description: 'Delete this term? This cannot be undone.', onConfirm: fn() },
+ play: async ({ canvas, args }) => {
+ await userEvent.click(canvas.getByRole('button', { name: 'Delete' }))
+ const confirm = await within(document.body).findByRole('button', { name: 'Delete' })
+ await userEvent.click(confirm)
+ await expect(args.onConfirm).toHaveBeenCalled()
+ },
+}
+
+export const ShowsInUse: Story = {
+ args: {
+ description: 'Delete this term? This cannot be undone.',
+ onConfirm: async () => { throw new InUseError(7) },
+ },
+ play: async ({ canvas }) => {
+ await userEvent.click(canvas.getByRole('button', { name: 'Delete' }))
+ const confirm = await within(document.body).findByRole('button', { name: 'Delete' })
+ await userEvent.click(confirm)
+ await expect(await within(document.body).findByRole('alert')).toHaveTextContent(/used by 7/i)
+ },
+}
diff --git a/web/src/components/delete-confirm-dialog.tsx b/web/src/components/delete-confirm-dialog.tsx
new file mode 100644
index 0000000..190d2ff
--- /dev/null
+++ b/web/src/components/delete-confirm-dialog.tsx
@@ -0,0 +1,70 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import { InUseError } from "../api/queries";
+import {
+ AlertDialog,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogCancel,
+ AlertDialogAction,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+
+export function DeleteConfirmDialog({
+ description,
+ onConfirm,
+ triggerLabel,
+}: {
+ /** Confirmation prompt, e.g. t("actions.confirmDeleteTerm"). */
+ description: string;
+ /** Performs the delete; may throw InUseError to surface the in-use count. */
+ onConfirm: () => Promise;
+ /** Optional override for the trigger button text (defaults to actions.delete). */
+ triggerLabel?: string;
+}) {
+ const { t } = useTranslation();
+ const [open, setOpen] = useState(false);
+ const [message, setMessage] = useState(null);
+
+ const confirm = async () => {
+ setMessage(null);
+ try {
+ await onConfirm();
+ } catch (err) {
+ // Keep the dialog open; show the blocking reason. Never let the rejected
+ // mutation escape as an unhandled rejection.
+ setMessage(err instanceof InUseError ? t("actions.inUse", { count: err.count }) : t("form.rejected"));
+ return;
+ }
+ setOpen(false);
+ };
+
+ return (
+
+
+ {triggerLabel ?? t("actions.delete")}
+
+ }
+ />
+
+ {t("actions.delete")}
+ {description}
+ {message && (
+
+ {message}
+
+ )}
+
+ {t("form.cancel")}
+ {t("actions.delete")}
+
+
+
+ );
+}
From 65ca79f2bd98f3cc9a8129c8340d09ce964b9820 Mon Sep 17 00:00:00 2001
From: Anders Olsson
Date: Fri, 5 Jun 2026 20:19:13 +0200
Subject: [PATCH 08/11] feat(web): edit/delete field definitions on /fields
(in-place edit pane) (#36)
---
web/src/fields/field-form.stories.tsx | 38 ++++++++
web/src/fields/field-form.tsx | 130 +++++++++++++++++---------
web/src/fields/field-list.tsx | 56 +++++++----
web/src/fields/fields-page.tsx | 15 ++-
4 files changed, 179 insertions(+), 60 deletions(-)
create mode 100644 web/src/fields/field-form.stories.tsx
diff --git a/web/src/fields/field-form.stories.tsx b/web/src/fields/field-form.stories.tsx
new file mode 100644
index 0000000..662983d
--- /dev/null
+++ b/web/src/fields/field-form.stories.tsx
@@ -0,0 +1,38 @@
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import { expect, fn } from 'storybook/test'
+
+import { FieldForm } from './field-form'
+
+const meta = {
+ component: FieldForm,
+ tags: ['ai-generated'],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Create: Story = {
+ args: { editing: null, onDone: fn() },
+ play: async ({ canvas }) => {
+ await expect(canvas.getByLabelText('Key')).toBeEnabled()
+ },
+}
+
+export const Edit: Story = {
+ args: {
+ editing: {
+ key: 'material',
+ data_type: 'text',
+ vocabulary_id: null,
+ authority_kind: null,
+ required: true,
+ group: 'Identification',
+ labels: [{ lang: 'en', label: 'Material' }],
+ },
+ onDone: fn(),
+ },
+ play: async ({ canvas }) => {
+ await expect(canvas.getByLabelText('Key')).toBeDisabled()
+ await expect(canvas.getByRole('button', { name: 'Save' })).toBeVisible()
+ },
+}
diff --git a/web/src/fields/field-form.tsx b/web/src/fields/field-form.tsx
index 8177fd7..c153128 100644
--- a/web/src/fields/field-form.tsx
+++ b/web/src/fields/field-form.tsx
@@ -2,7 +2,11 @@ import { useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
-import { useCreateFieldDefinition, useVocabularies } from "../api/queries";
+import {
+ useCreateFieldDefinition,
+ useUpdateFieldDefinition,
+ useVocabularies,
+} from "../api/queries";
import { LabelEditor } from "../components/label-editor";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -10,68 +14,103 @@ import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
type LabelInput = components["schemas"]["LabelInput"];
+type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
const TYPES = ["text", "localized_text", "integer", "date", "boolean", "term", "authority"] as const;
const KINDS = ["person", "organisation", "place"] as const;
-export function FieldForm() {
+export function FieldForm({
+ editing,
+ onDone,
+}: {
+ editing: FieldDefinitionView | null;
+ onDone: () => void;
+}) {
const { t } = useTranslation();
const create = useCreateFieldDefinition();
+ const update = useUpdateFieldDefinition();
const { data: vocabularies } = useVocabularies();
- const [key, setKey] = useState("");
- const [labels, setLabels] = useState([]);
- const [dataType, setDataType] = useState("text");
- const [vocabularyId, setVocabularyId] = useState("");
- const [authorityKind, setAuthorityKind] = useState("");
- const [group, setGroup] = useState("");
- const [required, setRequired] = useState(false);
- const [error, setError] = useState(false);
+ const isEdit = editing !== null;
- const reset = () => {
- setKey("");
- setLabels([]);
- setDataType("text");
- setVocabularyId("");
- setAuthorityKind("");
- setGroup("");
- setRequired(false);
- setError(false);
- };
+ const [key, setKey] = useState(editing?.key ?? "");
+ const [labels, setLabels] = useState((editing?.labels as LabelInput[]) ?? []);
+ const [dataType, setDataType] = useState(editing?.data_type ?? "text");
+ const [vocabularyId, setVocabularyId] = useState(editing?.vocabulary_id ?? "");
+ const [authorityKind, setAuthorityKind] = useState(editing?.authority_kind ?? "");
+ const [group, setGroup] = useState(editing?.group ?? "");
+ const [required, setRequired] = useState(editing?.required ?? false);
+ const [error, setError] = useState(false);
const onSubmit = (event: FormEvent) => {
event.preventDefault();
const hasLabel = labels.some((l) => l.label);
- const termNeedsVocab = dataType === "term" && !vocabularyId;
- if (!key.trim() || !hasLabel || termNeedsVocab) {
+ if (!hasLabel || (!isEdit && !key.trim()) || (!isEdit && dataType === "term" && !vocabularyId)) {
setError(true);
return;
}
setError(false);
- create.mutate(
- {
- key: key.trim(),
- data_type: dataType,
- vocabulary_id: dataType === "term" ? vocabularyId : null,
- authority_kind: dataType === "authority" ? authorityKind || null : null,
- required,
- group: group.trim() || null,
- labels,
- },
- { onSuccess: reset },
- );
+
+ if (isEdit) {
+ update.mutate(
+ { key: editing.key, required, group: group.trim() || null, labels },
+ { onSuccess: onDone },
+ );
+ } else {
+ create.mutate(
+ {
+ key: key.trim(),
+ data_type: dataType,
+ vocabulary_id: dataType === "term" ? vocabularyId : null,
+ authority_kind: dataType === "authority" ? authorityKind || null : null,
+ required,
+ group: group.trim() || null,
+ labels,
+ },
+ {
+ onSuccess: () => {
+ setKey("");
+ setLabels([]);
+ setDataType("text");
+ setVocabularyId("");
+ setAuthorityKind("");
+ setGroup("");
+ setRequired(false);
+ setError(false);
+ onDone();
+ },
+ },
+ );
+ }
};
+ const pending = isEdit ? update.isPending : create.isPending;
+ const failed = isEdit ? update.isError : create.isError;
+
return (
)}
- {create.isError && (
+ {failed && (
{t("form.rejected")}
)}
-