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/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/admin_vocab.rs b/crates/api/src/admin_vocab.rs index 0b714e4..a0c3551 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,14 +222,262 @@ 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 { + let _ = tx.rollback().await; + + 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(), + } +} + +#[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 { + let _ = tx.rollback().await; + + 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), ) + .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..1827dec 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -26,14 +26,22 @@ 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, admin_vocab::list_terms, 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 + admin_authorities::create_authority, + admin_authorities::update_authority, + admin_authorities::delete_authority ), components(schemas( config::ConfigView, @@ -52,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, @@ -60,10 +69,14 @@ use crate::{ admin_vocab::LabelInput, admin_vocab::TermView, admin_vocab::CreatedId, + admin_vocab::UpdateTermRequest, + admin_vocab::InUseView, + admin_vocab::RenameVocabularyRequest, admin_search::SearchHitView, 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 04aec01..a8d957c 100644 --- a/crates/api/tests/admin_catalog.rs +++ b/crates/api/tests/admin_catalog.rs @@ -333,3 +333,548 @@ 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); +} + +#[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); +} + +#[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); +} + +#[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/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/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/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..4c8f9a9 100644 --- a/crates/db/src/vocab.rs +++ b/crates/db/src/vocab.rs @@ -177,6 +177,204 @@ 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) +} + +/// 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/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 + ); +} diff --git a/crates/db/tests/fields.rs b/crates/db/tests/fields.rs index faf8ecf..35ef8b1 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, audit, catalog, fields, vocab}; +use domain::{ + AuditAction, 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,122 @@ 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 field_def_id = fields::field_definition_by_key(&mut *tx, "weight") + .await + .unwrap() + .unwrap() + .id + .to_uuid(); + + 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 + ); + + let history = audit::history_for(&mut *tx, "field_definition", field_def_id) + .await + .unwrap(); + assert!( + history.iter().any(|e| e.action == AuditAction::Deleted), + "expected a Deleted audit entry for the field_definition" + ); + + assert_eq!( + fields::delete_field_definition(&mut tx, AuditActor::System, "weight") + .await + .unwrap(), + DeleteOutcome::NotFound + ); +} diff --git a/crates/db/tests/vocab.rs b/crates/db/tests/vocab.rs index d3edabb..3b64c6d 100644 --- a/crates/db/tests/vocab.rs +++ b/crates/db/tests/vocab.rs @@ -1,5 +1,8 @@ -use db::{Db, vocab}; -use domain::{AuditActor, LocalizedLabel, NewTerm}; +use db::{Db, audit, catalog, fields, vocab}; +use domain::{ + AuditAction, AuditActor, FieldType, LocalizedLabel, NewFieldDefinition, NewTerm, ObjectInput, + Visibility, +}; use sqlx::PgPool; #[sqlx::test] @@ -169,3 +172,234 @@ 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); + + let history = audit::history_for(&mut *tx, "term", term_id.to_uuid()) + .await + .unwrap(); + assert!( + history.iter().any(|e| e.action == AuditAction::Updated), + "expected an Updated audit entry for the term" + ); + + 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 history = audit::history_for(&mut *tx, "term", term_id.to_uuid()) + .await + .unwrap(); + assert!( + history.iter().any(|e| e.action == AuditAction::Deleted), + "expected a Deleted audit entry for the term" + ); + + let gone = vocab::delete_term(&mut tx, AuditActor::System, vocab.id, term_id) + .await + .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); +} 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/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; diff --git a/web/src/authorities/authorities-page.tsx b/web/src/authorities/authorities-page.tsx index 6b97aa4..676f92c 100644 --- a/web/src/authorities/authorities-page.tsx +++ b/web/src/authorities/authorities-page.tsx @@ -6,7 +6,7 @@ import type { components } from "../api/schema"; import { useAuthorities, useCreateAuthority } from "../api/queries"; import { LabelEditor } from "../components/label-editor"; import { Button } from "@/components/ui/button"; -import { labelText } from "../lib/labels"; +import { AuthorityRow } from "./authority-row"; type LabelInput = components["schemas"]["LabelInput"]; @@ -72,9 +72,7 @@ export function AuthoritiesPage() {
  • {t("authorities.empty")}
  • )} {authorities?.map((a) => ( -
  • - {labelText(a.labels, lang)} -
  • + ))} diff --git a/web/src/authorities/authority-row.stories.tsx b/web/src/authorities/authority-row.stories.tsx new file mode 100644 index 0000000..ac5ec5b --- /dev/null +++ b/web/src/authorities/authority-row.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, userEvent } from 'storybook/test' + +import { AuthorityRow } from './authority-row' + +const meta = { + component: AuthorityRow, + tags: ['ai-generated'], + args: { + kind: 'person', + lang: 'en', + authority: { id: 'a1', kind: 'person', external_uri: null, labels: [{ lang: 'en', label: 'Astrid Lindgren' }] }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Display: Story = { + play: async ({ canvas }) => { + await expect(canvas.getByText('Astrid Lindgren')).toBeVisible() + }, +} + +export const TogglesEdit: Story = { + play: async ({ canvas }) => { + await userEvent.click(canvas.getByRole('button', { name: 'Edit' })) + await expect(canvas.getByRole('button', { name: 'Save' })).toBeVisible() + }, +} diff --git a/web/src/authorities/authority-row.tsx b/web/src/authorities/authority-row.tsx new file mode 100644 index 0000000..fe06c1b --- /dev/null +++ b/web/src/authorities/authority-row.tsx @@ -0,0 +1,77 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import type { components } from "../api/schema"; +import { useUpdateAuthority, useDeleteAuthority } from "../api/queries"; +import { LabelEditor } from "../components/label-editor"; +import { DeleteConfirmDialog } from "../components/delete-confirm-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { labelText } from "../lib/labels"; + +type AuthorityView = components["schemas"]["AuthorityView"]; +type LabelInput = components["schemas"]["LabelInput"]; + +export function AuthorityRow({ authority, kind, lang }: { authority: AuthorityView; kind: string; lang: string }) { + const { t } = useTranslation(); + + const updateAuthority = useUpdateAuthority(); + const deleteAuthority = useDeleteAuthority(); + + const [editing, setEditing] = useState(false); + const [labels, setLabels] = useState(authority.labels as LabelInput[]); + const [uri, setUri] = useState(authority.external_uri ?? ""); + + if (editing) { + return ( +
  • + +
    + + setUri(e.target.value)} /> +
    +
    + + +
    +
  • + ); + } + + return ( +
  • + {labelText(authority.labels, lang)} + + deleteAuthority.mutateAsync({ id: authority.id, kind })} + /> +
  • + ); +} 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")} + +
    +
    + ); +} 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 (
    -
    {t("fields.newField")}
    +
    +
    + {isEdit ? labelTextOrKey(editing) : t("fields.newField")} +
    + {isEdit && ( + + )} +
    - setKey(e.target.value)} /> + setKey(e.target.value)} + />
    @@ -81,8 +120,9 @@ export function FieldForm() { setVocabularyId(e.target.value)} - className="w-full rounded border px-2 py-1 text-sm" + className="w-full rounded border px-2 py-1 text-sm disabled:opacity-60" > {vocabularies?.map((vocab) => ( @@ -117,8 +158,9 @@ export function FieldForm() { setUri(e.target.value)} /> + +
    + + +
    + + ); + } + + return ( +
  • + {labelText(term.labels, lang)} + + deleteTerm.mutateAsync({ vocabularyId, termId: term.id })} + /> +
  • + ); +} diff --git a/web/src/vocab/vocabulary-list.tsx b/web/src/vocab/vocabulary-list.tsx index 41e07d5..ffc582f 100644 --- a/web/src/vocab/vocabulary-list.tsx +++ b/web/src/vocab/vocabulary-list.tsx @@ -2,7 +2,8 @@ import { useState, type FormEvent } from "react"; import { NavLink } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { useVocabularies, useCreateVocabulary } from "../api/queries"; +import { useVocabularies, useCreateVocabulary, useRenameVocabulary, useDeleteVocabulary } from "../api/queries"; +import { DeleteConfirmDialog } from "../components/delete-confirm-dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -13,8 +14,12 @@ export function VocabularyList() { const { data, isLoading, isError } = useVocabularies(); const create = useCreateVocabulary(); + const renameVocabulary = useRenameVocabulary(); + const deleteVocabulary = useDeleteVocabulary(); const [key, setKey] = useState(""); + const [editingId, setEditingId] = useState(null); + const [draftKey, setDraftKey] = useState(""); const onCreate = (event: FormEvent) => { event.preventDefault(); @@ -56,15 +61,59 @@ export function VocabularyList() {
  • {t("vocab.empty")}
  • )} {data?.map((v) => ( -
  • - - `block border-b px-3 py-2 text-sm ${isActive ? "bg-indigo-50" : "hover:bg-neutral-50"}` - } - > - {v.key} - +
  • + {editingId === v.id ? ( + { + e.preventDefault(); + renameVocabulary.mutate( + { id: v.id, key: draftKey.trim() }, + { onSuccess: () => setEditingId(null) }, + ); + }} + > + setDraftKey(e.target.value)} + /> + + + {renameVocabulary.isError && ( +

    + {t("form.rejected")} +

    + )} +
  • + ) : ( + <> + + `block flex-1 px-3 py-2 text-sm ${isActive ? "bg-indigo-50" : "hover:bg-neutral-50"}` + } + > + {v.key} + + + deleteVocabulary.mutateAsync(v.id)} + /> + + )} ))} diff --git a/web/src/vocab/vocabulary-terms.tsx b/web/src/vocab/vocabulary-terms.tsx index c4c75e4..992d9a8 100644 --- a/web/src/vocab/vocabulary-terms.tsx +++ b/web/src/vocab/vocabulary-terms.tsx @@ -5,10 +5,10 @@ import { useTranslation } from "react-i18next"; import type { components } from "../api/schema"; import { useTerms, useAddTerm } from "../api/queries"; import { LabelEditor } from "../components/label-editor"; +import { TermRow } from "./term-row"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { labelText } from "../lib/labels"; type LabelInput = components["schemas"]["LabelInput"]; @@ -63,9 +63,7 @@ export function VocabularyTerms() {
  • {t("vocab.noTerms")}
  • )} {terms?.map((term) => ( -
  • - {labelText(term.labels, lang)} -
  • + ))}