From e0c0187f29331963f9cbd6b1478f698f678d7850 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 2 Jun 2026 09:24:03 +0200 Subject: [PATCH] feat(db): add catalogue object create/read/list with audit on create Co-Authored-By: Claude Sonnet 4.6 --- crates/db/Cargo.toml | 2 +- crates/db/src/catalog.rs | 183 +++++++++++++++++++++++++++++++++++++ crates/db/src/lib.rs | 1 + crates/db/tests/catalog.rs | 77 ++++++++++++++++ 4 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 crates/db/src/catalog.rs create mode 100644 crates/db/tests/catalog.rs diff --git a/crates/db/Cargo.toml b/crates/db/Cargo.toml index f687e18..d6ca912 100644 --- a/crates/db/Cargo.toml +++ b/crates/db/Cargo.toml @@ -10,7 +10,7 @@ thiserror.workspace = true domain = { path = "../domain" } uuid.workspace = true time.workspace = true +serde_json.workspace = true [dev-dependencies] tokio.workspace = true -serde_json.workspace = true diff --git a/crates/db/src/catalog.rs b/crates/db/src/catalog.rs new file mode 100644 index 0000000..910b707 --- /dev/null +++ b/crates/db/src/catalog.rs @@ -0,0 +1,183 @@ +//! 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 SELECT_OBJECT_BY_ID: &str = "SELECT id, object_number, object_name, number_of_objects, brief_description, \ + current_location, current_owner, recorder, recording_date, visibility, \ + created_at, updated_at FROM object WHERE id = $1"; + +const SELECT_OBJECTS_ORDERED: &str = "SELECT id, object_number, object_name, number_of_objects, brief_description, \ + current_location, current_owner, recorder, recording_date, visibility, \ + created_at, updated_at FROM object ORDER BY object_number"; + +/// 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 row = sqlx::query(SELECT_OBJECT_BY_ID) + .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 rows = sqlx::query(SELECT_OBJECTS_ORDERED) + .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 + .and_then(|d| serde_json::to_value(d).ok()), + ), + ("visibility", Some(json!(input.visibility.as_str()))), + ] +} + +/// Audit changes for a newly created object: every set field as an `after` value. +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. +/// (Used by `update_object` in the next task.) +#[allow(dead_code)] +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() +} diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index 1eea258..4623d8e 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -2,6 +2,7 @@ pub mod audit; pub mod authority; +pub mod catalog; pub mod vocab; use sqlx::postgres::{PgPool, PgPoolOptions}; diff --git a/crates/db/tests/catalog.rs b/crates/db/tests/catalog.rs new file mode 100644 index 0000000..9406e19 --- /dev/null +++ b/crates/db/tests/catalog.rs @@ -0,0 +1,77 @@ +use db::{Db, audit, catalog}; +use domain::{AuditAction, AuditActor, ObjectInput, Visibility}; +use sqlx::PgPool; + +fn sample_input(number: &str) -> ObjectInput { + ObjectInput { + object_number: number.into(), + object_name: "vase".into(), + number_of_objects: 1, + brief_description: Some("a small vase".into()), + current_location: Some("shelf A1".into()), + current_owner: None, + recorder: Some("anna".into()), + recording_date: None, + visibility: Visibility::Draft, + } +} + +#[sqlx::test] +async fn create_reads_back_and_audits(pool: PgPool) { + let db = Db::from_pool(pool); + + let mut tx = db.pool().begin().await.unwrap(); + let id = catalog::create_object(&mut tx, AuditActor::System, &sample_input("LM-1")) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap(); + assert_eq!(obj.object_number, "LM-1"); + assert_eq!(obj.object_name, "vase"); + assert_eq!(obj.number_of_objects, 1); + assert_eq!(obj.brief_description.as_deref(), Some("a small vase")); + assert_eq!(obj.visibility, Visibility::Draft); + + let history = audit::history_for(db.pool(), "object", id.to_uuid()) + .await + .unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].action, AuditAction::Created); + assert_eq!(history[0].actor, AuditActor::System); + assert!( + history[0] + .changes + .iter() + .any(|c| c.field == "object_number") + ); +} + +#[sqlx::test] +async fn list_returns_created_objects(pool: PgPool) { + let db = Db::from_pool(pool); + let mut tx = db.pool().begin().await.unwrap(); + catalog::create_object(&mut tx, AuditActor::System, &sample_input("LM-1")) + .await + .unwrap(); + catalog::create_object(&mut tx, AuditActor::System, &sample_input("LM-2")) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let all = catalog::list_objects(db.pool()).await.unwrap(); + assert_eq!(all.len(), 2); + assert_eq!(all[0].object_number, "LM-1"); + assert_eq!(all[1].object_number, "LM-2"); +} + +#[sqlx::test] +async fn object_by_id_missing_is_none(pool: PgPool) { + let db = Db::from_pool(pool); + assert!( + catalog::object_by_id(db.pool(), domain::ObjectId::new()) + .await + .unwrap() + .is_none() + ); +}