feat(db): audited stepwise set_visibility + public-only object readers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 13:24:29 +02:00
parent 5e2ebbc8d9
commit 14cdd2a04a
2 changed files with 268 additions and 2 deletions
+97 -2
View File
@@ -2,8 +2,8 @@
//! on the caller's connection, so the change and its audit entry commit together.
use domain::{
AuditAction, AuditActor, CatalogueObject, FieldChange, FieldType, NewAuditEvent, ObjectId,
ObjectInput, Visibility,
AuditAction, AuditActor, CatalogueObject, FieldChange, FieldType, IllegalTransition,
NewAuditEvent, ObjectId, ObjectInput, Visibility,
};
use serde_json::{Value, json};
use sqlx::Row;
@@ -13,6 +13,9 @@ use crate::{audit, authority, fields, vocab};
/// The entity_type recorded in the audit log for catalogue objects.
const ENTITY_TYPE: &str = "object";
/// The visibility value eligible for the public surface.
const PUBLIC_VISIBILITY: &str = "public";
const OBJECT_COLUMNS: &str = "id, object_number, object_name, number_of_objects, \
brief_description, current_location, current_owner, recorder, recording_date, \
visibility, fields, created_at, updated_at";
@@ -93,6 +96,63 @@ where
rows.into_iter().map(map_object).collect()
}
/// Fetch one **public** object by id. Returns `None` if the object is missing **or**
/// not public — callers map both to 404 so non-public existence isn't revealed.
pub async fn public_object_by_id<'e, E>(
executor: E,
id: ObjectId,
) -> Result<Option<CatalogueObject>, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
let sql = format!("SELECT {OBJECT_COLUMNS} FROM object WHERE id = $1 AND visibility = $2");
let row = sqlx::query(&sql)
.bind(id.to_uuid())
.bind(PUBLIC_VISIBILITY)
.fetch_optional(executor)
.await?;
row.map(map_object).transpose()
}
/// List **public** objects ordered by object number, with `limit`/`offset` paging.
pub async fn list_public_objects<'e, E>(
executor: E,
limit: i64,
offset: i64,
) -> Result<Vec<CatalogueObject>, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
let sql = format!(
"SELECT {OBJECT_COLUMNS} FROM object WHERE visibility = $1 \
ORDER BY object_number LIMIT $2 OFFSET $3"
);
let rows = sqlx::query(&sql)
.bind(PUBLIC_VISIBILITY)
.bind(limit)
.bind(offset)
.fetch_all(executor)
.await?;
rows.into_iter().map(map_object).collect()
}
/// Count all public objects (for pagination totals).
pub async fn count_public_objects<'e, E>(executor: E) -> Result<i64, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
let row = sqlx::query("SELECT count(*) AS n FROM object WHERE visibility = $1")
.bind(PUBLIC_VISIBILITY)
.fetch_one(executor)
.await?;
row.try_get("n")
}
fn map_object(row: sqlx::postgres::PgRow) -> Result<CatalogueObject, sqlx::Error> {
let visibility_str: String = row.try_get("visibility")?;
let visibility = Visibility::from_db(&visibility_str).ok_or_else(|| {
@@ -230,6 +290,41 @@ pub async fn update_object(
Ok(true)
}
/// Why changing an object's visibility failed.
#[derive(Debug, thiserror::Error)]
pub enum VisibilityError {
#[error("object not found")]
ObjectNotFound,
#[error(transparent)]
Illegal(#[from] IllegalTransition),
#[error(transparent)]
Db(#[from] sqlx::Error),
}
/// Move an object to `target` visibility, enforcing the stepwise state machine, and
/// audit the change. Reuses [`update_object`]'s diff/audit path, so only `visibility`
/// appears in the audit entry — and setting to the current value is an idempotent no-op
/// (no row touch, no audit). Pass a transaction connection.
pub async fn set_visibility(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
id: ObjectId,
target: Visibility,
) -> Result<(), VisibilityError> {
let Some(object) = object_by_id(&mut *conn, id).await? else {
return Err(VisibilityError::ObjectNotFound);
};
let new_visibility = object.visibility.transition_to(target)?;
let mut input = object.to_input();
input.visibility = new_visibility;
update_object(&mut *conn, actor, id, &input).await?;
Ok(())
}
/// Delete an object and record a `deleted` audit entry, both on `conn`.
/// Returns `false` if the object did not exist.
pub async fn delete_object(