//! Authority records (person / organisation / place). use domain::{ AuditAction, AuditActor, Authority, AuthorityId, AuthorityKind, AuthorityRef, LocalizedLabel, NewAuditEvent, NewAuthority, }; use sqlx::Row; use crate::audit; const AUTHORITY_ENTITY_TYPE: &str = "authority"; /// Labels aggregated per row as JSON, to read an authority and its labels in one query. const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', al.lang, 'label', al.label) \ ORDER BY al.lang) FILTER (WHERE al.authority_id IS NOT NULL), '[]'::json)"; /// Insert an authority and its labels, then record a `created` audit entry. Multiple /// statements — pass a transaction connection (`&mut *tx`) so everything commits /// atomically. pub async fn create_authority( conn: &mut sqlx::PgConnection, actor: AuditActor, new: &NewAuthority, ) -> Result { let id = AuthorityId::new(); sqlx::query("INSERT INTO authority (id, kind, external_uri) VALUES ($1, $2, $3)") .bind(id.to_uuid()) .bind(new.kind.as_str()) .bind(new.external_uri.as_deref()) .execute(&mut *conn) .await?; for label in &new.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::Created, entity_type: AUTHORITY_ENTITY_TYPE.to_owned(), entity_id: id.to_uuid(), changes: Vec::new(), }, ) .await?; Ok(id) } /// Fetch one authority (with its labels). pub async fn authority_by_id<'e, E>( executor: E, id: AuthorityId, ) -> Result, sqlx::Error> where E: sqlx::PgExecutor<'e>, { let sql = format!( "SELECT a.id, a.kind, a.external_uri, {LABELS_JSON} AS labels \ FROM authority a LEFT JOIN authority_label al ON al.authority_id = a.id \ WHERE a.id = $1 GROUP BY a.id" ); let row = sqlx::query(&sql) .bind(id.to_uuid()) .fetch_optional(executor) .await?; row.map(map_authority).transpose() } /// List authorities of a given kind (with labels), ordered by id. pub async fn list_by_kind<'e, E>( executor: E, kind: AuthorityKind, ) -> Result, sqlx::Error> where E: sqlx::PgExecutor<'e>, { let sql = format!( "SELECT a.id, a.kind, a.external_uri, {LABELS_JSON} AS labels \ FROM authority a LEFT JOIN authority_label al ON al.authority_id = a.id \ WHERE a.kind = $1 GROUP BY a.id ORDER BY a.id" ); let rows = sqlx::query(&sql) .bind(kind.as_str()) .fetch_all(executor) .await?; rows.into_iter().map(map_authority).collect() } /// Resolve an authority to an [`AuthorityRef`] (carrying its kind). pub async fn resolve_authority<'e, E>( executor: E, id: AuthorityId, ) -> Result, sqlx::Error> where E: sqlx::PgExecutor<'e>, { let kind: Option = sqlx::query_scalar("SELECT kind FROM authority WHERE id = $1") .bind(id.to_uuid()) .fetch_optional(executor) .await?; match kind { Some(k) => { let kind = AuthorityKind::from_db(&k).ok_or_else(|| { sqlx::Error::Decode(format!("unknown authority kind: {k}").into()) })?; Ok(Some(AuthorityRef::new(id, kind))) } None => Ok(None), } } /// 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) .ok_or_else(|| sqlx::Error::Decode(format!("unknown authority kind: {kind_str}").into()))?; let labels: sqlx::types::Json> = row.try_get("labels")?; Ok(Authority { id: AuthorityId::from_uuid(row.try_get("id")?), kind, external_uri: row.try_get("external_uri")?, labels: labels.0, }) }