2938649d62
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
262 lines
8.1 KiB
Rust
262 lines
8.1 KiB
Rust
//! Catalogue objects (the inventory-minimum core). Writes record audit entries
|
|
//! on the caller's connection, so the change and its audit entry commit together.
|
|
|
|
use domain::{
|
|
AuditAction, AuditActor, CatalogueObject, FieldChange, NewAuditEvent, ObjectId, ObjectInput,
|
|
Visibility,
|
|
};
|
|
use serde_json::{Value, json};
|
|
use sqlx::Row;
|
|
|
|
use crate::audit;
|
|
|
|
/// The entity_type recorded in the audit log for catalogue objects.
|
|
const ENTITY_TYPE: &str = "object";
|
|
|
|
const OBJECT_COLUMNS: &str = "id, object_number, object_name, number_of_objects, \
|
|
brief_description, current_location, current_owner, recorder, recording_date, \
|
|
visibility, created_at, updated_at";
|
|
|
|
/// Create an object and record a `created` audit entry, both on `conn`
|
|
/// (pass a transaction connection `&mut *tx` so they commit atomically).
|
|
pub async fn create_object(
|
|
conn: &mut sqlx::PgConnection,
|
|
actor: AuditActor,
|
|
input: &ObjectInput,
|
|
) -> Result<ObjectId, sqlx::Error> {
|
|
let id = ObjectId::new();
|
|
|
|
sqlx::query(
|
|
"INSERT INTO object \
|
|
(id, object_number, object_name, number_of_objects, brief_description, \
|
|
current_location, current_owner, recorder, recording_date, visibility) \
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
|
|
)
|
|
.bind(id.to_uuid())
|
|
.bind(&input.object_number)
|
|
.bind(&input.object_name)
|
|
.bind(input.number_of_objects)
|
|
.bind(input.brief_description.as_deref())
|
|
.bind(input.current_location.as_deref())
|
|
.bind(input.current_owner.as_deref())
|
|
.bind(input.recorder.as_deref())
|
|
.bind(input.recording_date)
|
|
.bind(input.visibility.as_str())
|
|
.execute(&mut *conn)
|
|
.await?;
|
|
|
|
let changes = creation_changes(input);
|
|
|
|
audit::record(
|
|
&mut *conn,
|
|
&NewAuditEvent {
|
|
actor,
|
|
action: AuditAction::Created,
|
|
entity_type: ENTITY_TYPE.to_owned(),
|
|
entity_id: id.to_uuid(),
|
|
changes,
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
Ok(id)
|
|
}
|
|
|
|
/// Fetch one object by id.
|
|
pub async fn 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");
|
|
|
|
let row = sqlx::query(&sql)
|
|
.bind(id.to_uuid())
|
|
.fetch_optional(executor)
|
|
.await?;
|
|
|
|
row.map(map_object).transpose()
|
|
}
|
|
|
|
/// List all objects, ordered by object number.
|
|
pub async fn list_objects<'e, E>(executor: E) -> Result<Vec<CatalogueObject>, sqlx::Error>
|
|
where
|
|
E: sqlx::PgExecutor<'e>,
|
|
{
|
|
// TODO: add LIMIT/keyset pagination before exposing this via the API.
|
|
let sql = format!("SELECT {OBJECT_COLUMNS} FROM object ORDER BY object_number");
|
|
|
|
let rows = sqlx::query(&sql).fetch_all(executor).await?;
|
|
|
|
rows.into_iter().map(map_object).collect()
|
|
}
|
|
|
|
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(|| {
|
|
sqlx::Error::Decode(format!("unknown visibility: {visibility_str}").into())
|
|
})?;
|
|
|
|
Ok(CatalogueObject {
|
|
id: ObjectId::from_uuid(row.try_get("id")?),
|
|
object_number: row.try_get("object_number")?,
|
|
object_name: row.try_get("object_name")?,
|
|
number_of_objects: row.try_get("number_of_objects")?,
|
|
brief_description: row.try_get("brief_description")?,
|
|
current_location: row.try_get("current_location")?,
|
|
current_owner: row.try_get("current_owner")?,
|
|
recorder: row.try_get("recorder")?,
|
|
recording_date: row.try_get("recording_date")?,
|
|
visibility,
|
|
created_at: row.try_get("created_at")?,
|
|
updated_at: row.try_get("updated_at")?,
|
|
})
|
|
}
|
|
|
|
/// The mutable fields as `(name, value)` pairs, for building audit diffs.
|
|
/// `None` means the field is unset (NULL).
|
|
fn field_values(input: &ObjectInput) -> Vec<(&'static str, Option<Value>)> {
|
|
vec![
|
|
("object_number", Some(json!(input.object_number))),
|
|
("object_name", Some(json!(input.object_name))),
|
|
("number_of_objects", Some(json!(input.number_of_objects))),
|
|
(
|
|
"brief_description",
|
|
input.brief_description.as_ref().map(|v| json!(v)),
|
|
),
|
|
(
|
|
"current_location",
|
|
input.current_location.as_ref().map(|v| json!(v)),
|
|
),
|
|
(
|
|
"current_owner",
|
|
input.current_owner.as_ref().map(|v| json!(v)),
|
|
),
|
|
("recorder", input.recorder.as_ref().map(|v| json!(v))),
|
|
("recording_date", input.recording_date.map(|d| json!(d))),
|
|
("visibility", Some(json!(input.visibility.as_str()))),
|
|
]
|
|
}
|
|
|
|
/// Audit changes for a newly created object: every set field as an `after` value.
|
|
/// Unset (`None`) optional fields are omitted — absence is conveyed by their not
|
|
/// appearing, consistent with `FieldChange`'s `None`-means-no-value convention.
|
|
fn creation_changes(input: &ObjectInput) -> Vec<FieldChange> {
|
|
field_values(input)
|
|
.into_iter()
|
|
.filter_map(|(field, after)| {
|
|
after.map(|a| FieldChange {
|
|
field: field.to_owned(),
|
|
before: None,
|
|
after: Some(a),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Audit changes between two field sets: only the fields whose value changed.
|
|
fn update_changes(old: &ObjectInput, new: &ObjectInput) -> Vec<FieldChange> {
|
|
field_values(old)
|
|
.into_iter()
|
|
.zip(field_values(new))
|
|
.filter_map(|((field, before), (_, after))| {
|
|
if before != after {
|
|
Some(FieldChange {
|
|
field: field.to_owned(),
|
|
before,
|
|
after,
|
|
})
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Update an object and record an `updated` audit entry with field-level diffs,
|
|
/// both on `conn`. Returns `false` if the object does not exist. A no-op update
|
|
/// (no fields changed) records no audit entry.
|
|
pub async fn update_object(
|
|
conn: &mut sqlx::PgConnection,
|
|
actor: AuditActor,
|
|
id: ObjectId,
|
|
input: &ObjectInput,
|
|
) -> Result<bool, sqlx::Error> {
|
|
let Some(old) = object_by_id(&mut *conn, id).await? else {
|
|
return Ok(false);
|
|
};
|
|
|
|
let changes = update_changes(&old.to_input(), input);
|
|
if changes.is_empty() {
|
|
// No-op: don't touch updated_at or the audit log.
|
|
return Ok(true);
|
|
}
|
|
|
|
sqlx::query(
|
|
"UPDATE object SET \
|
|
object_number = $2, object_name = $3, number_of_objects = $4, \
|
|
brief_description = $5, current_location = $6, current_owner = $7, \
|
|
recorder = $8, recording_date = $9, visibility = $10, updated_at = now() \
|
|
WHERE id = $1",
|
|
)
|
|
.bind(id.to_uuid())
|
|
.bind(&input.object_number)
|
|
.bind(&input.object_name)
|
|
.bind(input.number_of_objects)
|
|
.bind(input.brief_description.as_deref())
|
|
.bind(input.current_location.as_deref())
|
|
.bind(input.current_owner.as_deref())
|
|
.bind(input.recorder.as_deref())
|
|
.bind(input.recording_date)
|
|
.bind(input.visibility.as_str())
|
|
.execute(&mut *conn)
|
|
.await?;
|
|
|
|
audit::record(
|
|
&mut *conn,
|
|
&NewAuditEvent {
|
|
actor,
|
|
action: AuditAction::Updated,
|
|
entity_type: ENTITY_TYPE.to_owned(),
|
|
entity_id: id.to_uuid(),
|
|
changes,
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
Ok(true)
|
|
}
|
|
|
|
/// 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(
|
|
conn: &mut sqlx::PgConnection,
|
|
actor: AuditActor,
|
|
id: ObjectId,
|
|
) -> Result<bool, sqlx::Error> {
|
|
let result = sqlx::query("DELETE FROM object WHERE id = $1")
|
|
.bind(id.to_uuid())
|
|
.execute(&mut *conn)
|
|
.await?;
|
|
|
|
if result.rows_affected() == 0 {
|
|
return Ok(false);
|
|
}
|
|
|
|
audit::record(
|
|
&mut *conn,
|
|
&NewAuditEvent {
|
|
actor,
|
|
action: AuditAction::Deleted,
|
|
entity_type: ENTITY_TYPE.to_owned(),
|
|
entity_id: id.to_uuid(),
|
|
changes: Vec::new(),
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
Ok(true)
|
|
}
|