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); +}