feat(db): add catalogue object create/read/list with audit on create
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<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 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<Vec<CatalogueObject>, 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<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
|
||||
.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<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.
|
||||
/// (Used by `update_object` in the next task.)
|
||||
#[allow(dead_code)]
|
||||
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()
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod audit;
|
||||
pub mod authority;
|
||||
pub mod catalog;
|
||||
pub mod vocab;
|
||||
|
||||
use sqlx::postgres::{PgPool, PgPoolOptions};
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user