//! Admin authority-record management. Reads require `ViewInternal`; writes `EditCatalogue`. use auth::{Authorized, EditCatalogue, ViewInternal}; use axum::{ Json, Router, extract::{Path, Query, State}, http::StatusCode, response::{IntoResponse, Response}, routing::get, }; use domain::{AuditActor, AuthorityId, AuthorityKind, LocalizedLabel, NewAuthority}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use crate::{ AppState, admin_objects::LabelView, admin_vocab::{CreatedId, InUseView, LabelInput}, }; #[derive(Serialize, ToSchema)] pub(crate) struct AuthorityView { pub id: String, #[schema(value_type = domain::AuthorityKind)] pub kind: String, pub external_uri: Option, pub labels: Vec, } #[derive(Deserialize, ToSchema)] pub(crate) struct NewAuthorityRequest { /// "person" | "organisation" | "place". pub kind: String, pub external_uri: Option, pub labels: Vec, } #[derive(Deserialize)] pub(crate) struct KindQuery { kind: String, } #[utoipa::path( get, path = "/api/admin/authorities", params(("kind" = String, Query, description = "person | organisation | place")), responses( (status = 200, body = [AuthorityView]), (status = 401), (status = 403), (status = 422) ) )] pub(crate) async fn list_authorities( _auth: Authorized, State(state): State, Query(q): Query, ) -> Result>, StatusCode> { let kind = AuthorityKind::from_db(&q.kind).ok_or(StatusCode::UNPROCESSABLE_ENTITY)?; let authorities = db::authority::list_by_kind(state.db.pool(), kind) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json( authorities .into_iter() .map(|authority| AuthorityView { id: authority.id.to_string(), kind: authority.kind.as_str().to_owned(), external_uri: authority.external_uri, labels: authority .labels .into_iter() .map(|label| LabelView { lang: label.lang, label: label.label, }) .collect(), }) .collect(), )) } #[utoipa::path( post, path = "/api/admin/authorities", request_body = NewAuthorityRequest, responses( (status = 201, body = CreatedId), (status = 401), (status = 403), (status = 422) ) )] pub(crate) async fn create_authority( auth: Authorized, State(state): State, Json(req): Json, ) -> Result<(StatusCode, Json), StatusCode> { let kind = AuthorityKind::from_db(&req.kind).ok_or(StatusCode::UNPROCESSABLE_ENTITY)?; let new = NewAuthority { kind, external_uri: req.external_uri, labels: req .labels .into_iter() .map(|label| LocalizedLabel { lang: label.lang, label: label.label, }) .collect(), }; let mut tx = state .db .pool() .begin() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let id = db::authority::create_authority(&mut tx, AuditActor::User(auth.user.id.to_uuid()), &new) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; tx.commit() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok((StatusCode::CREATED, Json(CreatedId { id: id.to_string() }))) } #[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), ) }