//! 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 { 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, 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, 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 { 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)> { 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 { 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 { 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 { let Some(old) = object_by_id(&mut *conn, id).await? else { return Ok(false); }; 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?; let changes = update_changes(&old.to_input(), input); if !changes.is_empty() { 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 { 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) }