Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2938649d62 | |||
| a690c60ec6 | |||
| 9e1c88b294 | |||
| 616a6f05c6 | |||
| e0c0187f29 | |||
| 95357f01dd | |||
| c1dda280e2 | |||
| bf332ac0ae | |||
| 266f914b88 | |||
| ed608c6e37 |
@@ -10,7 +10,8 @@ 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
|
||||
time.workspace = true
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Catalogue objects (the inventory-minimum core). One row = one object or a group.
|
||||
CREATE TABLE object (
|
||||
id UUID PRIMARY KEY,
|
||||
object_number TEXT NOT NULL UNIQUE CHECK (object_number <> ''),
|
||||
object_name TEXT NOT NULL CHECK (object_name <> ''),
|
||||
number_of_objects INTEGER NOT NULL DEFAULT 1 CHECK (number_of_objects >= 1),
|
||||
brief_description TEXT CHECK (brief_description <> ''),
|
||||
current_location TEXT CHECK (current_location <> ''),
|
||||
current_owner TEXT CHECK (current_owner <> ''),
|
||||
recorder TEXT CHECK (recorder <> ''),
|
||||
recording_date DATE,
|
||||
visibility TEXT NOT NULL DEFAULT 'draft'
|
||||
CHECK (visibility IN ('draft', 'internal', 'public')),
|
||||
-- updated_at is maintained by the repository (set to now() on update).
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX object_visibility_idx ON object (visibility);
|
||||
@@ -0,0 +1,261 @@
|
||||
//! 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)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod audit;
|
||||
pub mod authority;
|
||||
pub mod catalog;
|
||||
pub mod vocab;
|
||||
|
||||
use sqlx::postgres::{PgPool, PgPoolOptions};
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
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()
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn object_with_date_and_all_none_optionals_round_trips(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let date = time::Date::from_calendar_date(2020, time::Month::January, 28).unwrap();
|
||||
let input = ObjectInput {
|
||||
object_number: "LM-3".into(),
|
||||
object_name: "drawing".into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: None,
|
||||
current_location: None,
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: Some(date),
|
||||
visibility: Visibility::Internal,
|
||||
};
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let id = catalog::create_object(&mut tx, AuditActor::System, &input)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(obj.recording_date, Some(date));
|
||||
assert_eq!(obj.brief_description, None);
|
||||
assert_eq!(obj.current_location, None);
|
||||
assert_eq!(obj.current_owner, None);
|
||||
assert_eq!(obj.recorder, None);
|
||||
assert_eq!(obj.visibility, Visibility::Internal);
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
history[0]
|
||||
.changes
|
||||
.iter()
|
||||
.any(|c| c.field == "recording_date")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
use db::{Db, audit, catalog};
|
||||
use domain::{AuditAction, AuditActor, ObjectInput, Visibility};
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn base() -> ObjectInput {
|
||||
ObjectInput {
|
||||
object_number: "LM-1".into(),
|
||||
object_name: "vase".into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: None,
|
||||
current_location: Some("shelf A1".into()),
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility: Visibility::Draft,
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn update_changes_are_audited_as_diffs(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, &base())
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let mut changed = base();
|
||||
changed.object_name = "roman vase".into();
|
||||
changed.visibility = Visibility::Public;
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let updated = catalog::update_object(&mut tx, AuditActor::System, id, &changed)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
assert!(updated);
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(obj.object_name, "roman vase");
|
||||
assert_eq!(obj.visibility, Visibility::Public);
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(history.len(), 2); // created + updated
|
||||
let update = &history[1];
|
||||
assert_eq!(update.action, AuditAction::Updated);
|
||||
let mut fields: Vec<&str> = update.changes.iter().map(|c| c.field.as_str()).collect();
|
||||
fields.sort_unstable();
|
||||
assert_eq!(fields, vec!["object_name", "visibility"]);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn no_op_update_records_no_audit(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, &base())
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let updated = catalog::update_object(&mut tx, AuditActor::System, id, &base())
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
assert!(updated);
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
history.len(),
|
||||
1,
|
||||
"a no-op update must not add an audit entry"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn update_missing_returns_false(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let missing = domain::ObjectId::new();
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let updated = catalog::update_object(&mut tx, AuditActor::System, missing, &base())
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
assert!(!updated);
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", missing.to_uuid())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
history.is_empty(),
|
||||
"updating a missing object records no audit"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn delete_missing_returns_false(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let deleted = catalog::delete_object(&mut tx, AuditActor::System, domain::ObjectId::new())
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
assert!(!deleted);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn clearing_a_field_is_audited(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, &base())
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let mut cleared = base();
|
||||
cleared.current_location = None;
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
catalog::update_object(&mut tx, AuditActor::System, id, &cleared)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid())
|
||||
.await
|
||||
.unwrap();
|
||||
let update = history.last().unwrap();
|
||||
let loc = update
|
||||
.changes
|
||||
.iter()
|
||||
.find(|c| c.field == "current_location")
|
||||
.expect("location change recorded");
|
||||
assert!(
|
||||
loc.before.is_some(),
|
||||
"cleared field should have before = Some"
|
||||
);
|
||||
assert!(
|
||||
loc.after.is_none(),
|
||||
"cleared field should have after = None"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn delete_removes_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, &base())
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let deleted = catalog::delete_object(&mut tx, AuditActor::System, id)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
assert!(deleted);
|
||||
|
||||
assert!(
|
||||
catalog::object_by_id(db.pool(), id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(history.last().unwrap().action, AuditAction::Deleted);
|
||||
}
|
||||
@@ -19,6 +19,18 @@ async fn migrate_is_idempotent_and_creates_audit_log(pool: PgPool) {
|
||||
assert_eq!(regclass.as_deref(), Some("audit_log"));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn migrate_creates_object_table(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
let regclass: Option<String> = sqlx::query_scalar("SELECT to_regclass('public.object')::text")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(regclass.as_deref(), Some("object"));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn migrate_creates_vocabulary_and_authority_tables(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
|
||||
@@ -64,6 +64,10 @@ id_newtype!(
|
||||
/// Identifier for an authority record (person, organisation, or place).
|
||||
AuthorityId
|
||||
);
|
||||
id_newtype!(
|
||||
/// Identifier for a catalogue object (or group of objects).
|
||||
ObjectId
|
||||
);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -4,10 +4,12 @@ mod audit;
|
||||
mod authority;
|
||||
mod id;
|
||||
mod label;
|
||||
mod object;
|
||||
mod vocabulary;
|
||||
|
||||
pub use audit::{AuditAction, AuditActor, AuditEntry, FieldChange, NewAuditEvent};
|
||||
pub use authority::{Authority, AuthorityKind, AuthorityRef, NewAuthority};
|
||||
pub use id::{AuthorityId, OrgId, TermId, VocabularyId};
|
||||
pub use id::{AuthorityId, ObjectId, OrgId, TermId, VocabularyId};
|
||||
pub use label::{LocalizedLabel, pick_label};
|
||||
pub use object::{CatalogueObject, ObjectInput, Visibility};
|
||||
pub use vocabulary::{NewTerm, Term, TermRef, Vocabulary};
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::{Date, OffsetDateTime};
|
||||
|
||||
use crate::ObjectId;
|
||||
|
||||
/// Publication state of a catalogue record.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Visibility {
|
||||
/// Work in progress; not shown anywhere public.
|
||||
#[default]
|
||||
Draft,
|
||||
/// Complete but internal-only.
|
||||
Internal,
|
||||
/// Published; eligible for the public API.
|
||||
Public,
|
||||
}
|
||||
|
||||
impl Visibility {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Visibility::Draft => "draft",
|
||||
Visibility::Internal => "internal",
|
||||
Visibility::Public => "public",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_db(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"draft" => Some(Visibility::Draft),
|
||||
"internal" => Some(Visibility::Internal),
|
||||
"public" => Some(Visibility::Public),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mutable inventory-minimum fields of a catalogue object.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ObjectInput {
|
||||
pub object_number: String,
|
||||
pub object_name: String,
|
||||
pub number_of_objects: i32,
|
||||
pub brief_description: Option<String>,
|
||||
pub current_location: Option<String>,
|
||||
pub current_owner: Option<String>,
|
||||
pub recorder: Option<String>,
|
||||
pub recording_date: Option<Date>,
|
||||
pub visibility: Visibility,
|
||||
}
|
||||
|
||||
/// A catalogue object (or group of objects), read back from storage.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CatalogueObject {
|
||||
pub id: ObjectId,
|
||||
pub object_number: String,
|
||||
pub object_name: String,
|
||||
pub number_of_objects: i32,
|
||||
pub brief_description: Option<String>,
|
||||
pub current_location: Option<String>,
|
||||
pub current_owner: Option<String>,
|
||||
pub recorder: Option<String>,
|
||||
pub recording_date: Option<Date>,
|
||||
pub visibility: Visibility,
|
||||
pub created_at: OffsetDateTime,
|
||||
pub updated_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
impl CatalogueObject {
|
||||
/// The mutable fields as an [`ObjectInput`] (used to diff against an update).
|
||||
pub fn to_input(&self) -> ObjectInput {
|
||||
ObjectInput {
|
||||
object_number: self.object_number.clone(),
|
||||
object_name: self.object_name.clone(),
|
||||
number_of_objects: self.number_of_objects,
|
||||
brief_description: self.brief_description.clone(),
|
||||
current_location: self.current_location.clone(),
|
||||
current_owner: self.current_owner.clone(),
|
||||
recorder: self.recorder.clone(),
|
||||
recording_date: self.recording_date,
|
||||
visibility: self.visibility,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn visibility_round_trips_and_defaults_to_draft() {
|
||||
for v in [Visibility::Draft, Visibility::Internal, Visibility::Public] {
|
||||
assert_eq!(Visibility::from_db(v.as_str()), Some(v));
|
||||
}
|
||||
assert_eq!(Visibility::from_db("secret"), None);
|
||||
assert_eq!(Visibility::default(), Visibility::Draft);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_serde_matches_as_str() {
|
||||
for v in [Visibility::Draft, Visibility::Internal, Visibility::Public] {
|
||||
assert_eq!(
|
||||
serde_json::to_value(v).unwrap(),
|
||||
serde_json::Value::String(v.as_str().to_owned())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
# Catalogue Core Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** The typed inventory-minimum catalogue object (Approach C's strongly-typed core, §6.1) with CRUD, and — crucially — the **first real consumer of the audit spine**: every create/update/delete records an audit entry (field-level diffs on update) inside the write transaction.
|
||||
|
||||
**Architecture:** `domain` holds `ObjectId`, `Visibility`, `ObjectInput` (mutable fields) and `CatalogueObject` (read model). `db::catalog` owns the `object` table (migration 0003) and the repository. Writes take `&mut PgConnection` and record audit via `db::audit::record` on the same connection, so the change and its audit entry commit atomically. Vocabulary/authority *binding* of fields is deferred to the flexible layer (Plan 4); fields are simple types here. No HTTP, no flexible fields, no search.
|
||||
|
||||
**Tech Stack:** Rust 2024, sqlx 0.8 (`time`+`json`), `time::Date`/`OffsetDateTime`, `serde_json` (now a normal dep of `db`, to build audit `FieldChange` values). Tests use `#[sqlx::test]`.
|
||||
|
||||
## Design decisions (approved)
|
||||
- One `object` table = object **or group** (`number_of_objects ≥ 1`).
|
||||
- Inventory-minimum fields as **simple types** (free text); vocab/authority binding deferred to Plan 4.
|
||||
- **Audit on every write**, in the write transaction (this plan is the audit spine's first consumer).
|
||||
- `Visibility` stored now; publish/unpublish transitions + `PublicView` + public API in Plan 7.
|
||||
- Scope: object CRUD + list in `domain` + `db`.
|
||||
|
||||
## Prerequisites
|
||||
- Postgres for tests with CREATE DATABASE rights; pass `DATABASE_URL` inline (e.g. `postgres://postgres:postgres@localhost:5433/cms_dev`). Shell env does not persist between commands.
|
||||
|
||||
## File Structure
|
||||
```
|
||||
crates/domain/
|
||||
src/id.rs + ObjectId via id_newtype!
|
||||
src/object.rs Visibility, ObjectInput, CatalogueObject (+ to_input)
|
||||
src/lib.rs re-exports
|
||||
crates/db/
|
||||
Cargo.toml serde_json -> normal dependency
|
||||
migrations/0003_object.sql
|
||||
src/catalog.rs create/get/list/update/delete + audit integration
|
||||
src/lib.rs pub mod catalog;
|
||||
tests/catalog.rs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `domain` — object types
|
||||
|
||||
**Files:** modify `crates/domain/src/id.rs`, `crates/domain/src/lib.rs`; create `crates/domain/src/object.rs`.
|
||||
|
||||
- [ ] **Step 1: Add `ObjectId`** to `crates/domain/src/id.rs` — add another `id_newtype!` invocation after the existing ones:
|
||||
```rust
|
||||
id_newtype!(
|
||||
/// Identifier for a catalogue object (or group of objects).
|
||||
ObjectId
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `crates/domain/src/object.rs`:**
|
||||
```rust
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::{Date, OffsetDateTime};
|
||||
|
||||
use crate::ObjectId;
|
||||
|
||||
/// Publication state of a catalogue record.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Visibility {
|
||||
/// Work in progress; not shown anywhere public.
|
||||
#[default]
|
||||
Draft,
|
||||
/// Complete but internal-only.
|
||||
Internal,
|
||||
/// Published; eligible for the public API.
|
||||
Public,
|
||||
}
|
||||
|
||||
impl Visibility {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Visibility::Draft => "draft",
|
||||
Visibility::Internal => "internal",
|
||||
Visibility::Public => "public",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_db(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"draft" => Some(Visibility::Draft),
|
||||
"internal" => Some(Visibility::Internal),
|
||||
"public" => Some(Visibility::Public),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mutable inventory-minimum fields of a catalogue object.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ObjectInput {
|
||||
pub object_number: String,
|
||||
pub object_name: String,
|
||||
pub number_of_objects: i32,
|
||||
pub brief_description: Option<String>,
|
||||
pub current_location: Option<String>,
|
||||
pub current_owner: Option<String>,
|
||||
pub recorder: Option<String>,
|
||||
pub recording_date: Option<Date>,
|
||||
pub visibility: Visibility,
|
||||
}
|
||||
|
||||
/// A catalogue object (or group of objects), read back from storage.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CatalogueObject {
|
||||
pub id: ObjectId,
|
||||
pub object_number: String,
|
||||
pub object_name: String,
|
||||
pub number_of_objects: i32,
|
||||
pub brief_description: Option<String>,
|
||||
pub current_location: Option<String>,
|
||||
pub current_owner: Option<String>,
|
||||
pub recorder: Option<String>,
|
||||
pub recording_date: Option<Date>,
|
||||
pub visibility: Visibility,
|
||||
pub created_at: OffsetDateTime,
|
||||
pub updated_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
impl CatalogueObject {
|
||||
/// The mutable fields as an [`ObjectInput`] (used to diff against an update).
|
||||
pub fn to_input(&self) -> ObjectInput {
|
||||
ObjectInput {
|
||||
object_number: self.object_number.clone(),
|
||||
object_name: self.object_name.clone(),
|
||||
number_of_objects: self.number_of_objects,
|
||||
brief_description: self.brief_description.clone(),
|
||||
current_location: self.current_location.clone(),
|
||||
current_owner: self.current_owner.clone(),
|
||||
recorder: self.recorder.clone(),
|
||||
recording_date: self.recording_date,
|
||||
visibility: self.visibility,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn visibility_round_trips_and_defaults_to_draft() {
|
||||
for v in [Visibility::Draft, Visibility::Internal, Visibility::Public] {
|
||||
assert_eq!(Visibility::from_db(v.as_str()), Some(v));
|
||||
}
|
||||
assert_eq!(Visibility::from_db("secret"), None);
|
||||
assert_eq!(Visibility::default(), Visibility::Draft);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `crates/domain/src/lib.rs`** — add `mod object;` (alphabetical, after `mod label;` / before `mod vocabulary;` is fine) and add `ObjectId` to the id re-export and the object types. The id re-export becomes:
|
||||
```rust
|
||||
pub use id::{AuthorityId, ObjectId, OrgId, TermId, VocabularyId};
|
||||
```
|
||||
and add:
|
||||
```rust
|
||||
pub use object::{CatalogueObject, ObjectInput, Visibility};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Test + lint.** `cargo test -p domain` → all pass (incl. the new visibility test). `cargo +nightly fmt`; `cargo clippy -p domain --all-targets -- -D warnings` → clean.
|
||||
|
||||
- [ ] **Step 5: Commit.**
|
||||
```bash
|
||||
git add crates/domain
|
||||
git commit -m "feat(domain): add catalogue object types (Visibility, ObjectInput, CatalogueObject)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `db` migration — object table
|
||||
|
||||
**Files:** create `crates/db/migrations/0003_object.sql`; modify `crates/db/tests/migrate.rs`.
|
||||
|
||||
- [ ] **Step 1: Create `crates/db/migrations/0003_object.sql`:**
|
||||
```sql
|
||||
-- Catalogue objects (the inventory-minimum core). One row = one object or a group.
|
||||
CREATE TABLE object (
|
||||
id UUID PRIMARY KEY,
|
||||
object_number TEXT NOT NULL UNIQUE,
|
||||
object_name TEXT NOT NULL,
|
||||
number_of_objects INTEGER NOT NULL DEFAULT 1 CHECK (number_of_objects >= 1),
|
||||
brief_description TEXT,
|
||||
current_location TEXT,
|
||||
current_owner TEXT,
|
||||
recorder TEXT,
|
||||
recording_date DATE,
|
||||
visibility TEXT NOT NULL DEFAULT 'draft'
|
||||
CHECK (visibility IN ('draft', 'internal', 'public')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX object_visibility_idx ON object (visibility);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extend `crates/db/tests/migrate.rs`** — append:
|
||||
```rust
|
||||
#[sqlx::test]
|
||||
async fn migrate_creates_object_table(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let regclass: Option<String> =
|
||||
sqlx::query_scalar("SELECT to_regclass('public.object')::text")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(regclass.as_deref(), Some("object"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run + lint.** `DATABASE_URL=<url> cargo test -p db --test migrate` → 3 tests pass. `cargo +nightly fmt`; clippy clean.
|
||||
|
||||
- [ ] **Step 4: Commit.**
|
||||
```bash
|
||||
git add crates/db/migrations crates/db/tests/migrate.rs
|
||||
git commit -m "feat(db): add object table"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `db::catalog` — create, read, list (with audit on create)
|
||||
|
||||
**Files:** modify `crates/db/Cargo.toml`, `crates/db/src/lib.rs`; create `crates/db/src/catalog.rs`, `crates/db/tests/catalog.rs`.
|
||||
|
||||
- [ ] **Step 1: Make `serde_json` a normal dependency of `db`.** In `crates/db/Cargo.toml`, move `serde_json` from `[dev-dependencies]` to `[dependencies]` (it is needed in `catalog.rs` to build audit `FieldChange` values). Result:
|
||||
```toml
|
||||
[dependencies]
|
||||
sqlx.workspace = true
|
||||
thiserror.workspace = true
|
||||
domain = { path = "../domain" }
|
||||
uuid.workspace = true
|
||||
time.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing test** `crates/db/tests/catalog.rs`:
|
||||
```rust
|
||||
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);
|
||||
|
||||
// The create was audited within the same transaction.
|
||||
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());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run to verify it fails.** `DATABASE_URL=<url> cargo test -p db --test catalog` → FAIL.
|
||||
|
||||
- [ ] **Step 4: Implement** `crates/db/src/catalog.rs`:
|
||||
```rust
|
||||
//! Catalogue objects (the inventory-minimum core). Writes record audit entries
|
||||
//! in the caller's transaction.
|
||||
|
||||
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";
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
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";
|
||||
|
||||
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.
|
||||
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()
|
||||
}
|
||||
```
|
||||
Note: `OBJECT_COLUMNS` is intentionally unused for now (kept adjacent for documentation of the column set); if clippy flags it as dead code, DELETE the `OBJECT_COLUMNS` const (the two SELECT consts are the live ones). `update_changes` is used in Task 4 — if clippy flags it unused in this task, add `#[allow(dead_code)]` to `update_changes` with a `// used in Task 4 (update_object)` comment, OR implement Task 4 immediately after so it's used. (Recommended: proceed to Task 4 before the final clippy gate.)
|
||||
|
||||
Add to `crates/db/src/lib.rs` (top-level): `pub mod catalog;`
|
||||
|
||||
- [ ] **Step 5: Run to verify it passes.** `DATABASE_URL=<url> cargo test -p db --test catalog` → PASS (3 tests).
|
||||
|
||||
- [ ] **Step 6: Lint.** `cargo +nightly fmt`; `DATABASE_URL=<url> cargo clippy -p db --all-targets -- -D warnings` → clean (resolve the `OBJECT_COLUMNS`/`update_changes` dead-code note as above).
|
||||
|
||||
- [ ] **Step 7: Commit.**
|
||||
```bash
|
||||
git add crates/db
|
||||
git commit -m "feat(db): add catalogue object create/read/list with audit on create"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `db::catalog` — update & delete (with audit diffs)
|
||||
|
||||
**Files:** modify `crates/db/src/catalog.rs`; test `crates/db/tests/catalog_mutations.rs`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test** `crates/db/tests/catalog_mutations.rs`:
|
||||
```rust
|
||||
use db::{Db, audit, catalog};
|
||||
use domain::{AuditAction, AuditActor, ObjectInput, Visibility};
|
||||
use sqlx::PgPool;
|
||||
|
||||
fn base() -> ObjectInput {
|
||||
ObjectInput {
|
||||
object_number: "LM-1".into(),
|
||||
object_name: "vase".into(),
|
||||
number_of_objects: 1,
|
||||
brief_description: None,
|
||||
current_location: Some("shelf A1".into()),
|
||||
current_owner: None,
|
||||
recorder: None,
|
||||
recording_date: None,
|
||||
visibility: Visibility::Draft,
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn update_changes_are_audited_as_diffs(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, &base()).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let mut changed = base();
|
||||
changed.object_name = "roman vase".into();
|
||||
changed.visibility = Visibility::Public;
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let updated = catalog::update_object(&mut *tx, AuditActor::System, id, &changed).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
assert!(updated);
|
||||
|
||||
let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap();
|
||||
assert_eq!(obj.object_name, "roman vase");
|
||||
assert_eq!(obj.visibility, Visibility::Public);
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap();
|
||||
assert_eq!(history.len(), 2); // created + updated
|
||||
let update = &history[1];
|
||||
assert_eq!(update.action, AuditAction::Updated);
|
||||
// Exactly the two changed fields are recorded.
|
||||
let mut fields: Vec<&str> = update.changes.iter().map(|c| c.field.as_str()).collect();
|
||||
fields.sort_unstable();
|
||||
assert_eq!(fields, vec!["object_name", "visibility"]);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn no_op_update_records_no_audit(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, &base()).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let updated = catalog::update_object(&mut *tx, AuditActor::System, id, &base()).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
assert!(updated);
|
||||
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap();
|
||||
assert_eq!(history.len(), 1, "a no-op update must not add an audit entry");
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn update_missing_returns_false(pool: PgPool) {
|
||||
let db = Db::from_pool(pool);
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let updated = catalog::update_object(&mut *tx, AuditActor::System, domain::ObjectId::new(), &base())
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
assert!(!updated);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn delete_removes_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, &base()).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
let mut tx = db.pool().begin().await.unwrap();
|
||||
let deleted = catalog::delete_object(&mut *tx, AuditActor::System, id).await.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
assert!(deleted);
|
||||
|
||||
assert!(catalog::object_by_id(db.pool(), id).await.unwrap().is_none());
|
||||
let history = audit::history_for(db.pool(), "object", id.to_uuid()).await.unwrap();
|
||||
assert_eq!(history.last().unwrap().action, AuditAction::Deleted);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails.** `DATABASE_URL=<url> cargo test -p db --test catalog_mutations` → FAIL (`update_object`/`delete_object` missing).
|
||||
|
||||
- [ ] **Step 3: Implement** — append to `crates/db/src/catalog.rs`:
|
||||
```rust
|
||||
/// 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);
|
||||
};
|
||||
|
||||
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<bool, sqlx::Error> {
|
||||
if object_by_id(&mut *conn, id).await?.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM object WHERE id = $1")
|
||||
.bind(id.to_uuid())
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
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)
|
||||
}
|
||||
```
|
||||
If you added a temporary `#[allow(dead_code)]` to `update_changes` in Task 3, remove it now (it is used here). If `OBJECT_COLUMNS` is unused, delete that const.
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes.** `DATABASE_URL=<url> cargo test -p db --test catalog_mutations` → PASS (4 tests).
|
||||
|
||||
- [ ] **Step 5: Full workspace check.**
|
||||
```bash
|
||||
cargo +nightly fmt --check
|
||||
DATABASE_URL=<url> cargo clippy --workspace --all-targets -- -D warnings
|
||||
DATABASE_URL=<url> cargo test --workspace
|
||||
```
|
||||
Expected: all green.
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
```bash
|
||||
git add crates/db
|
||||
git commit -m "feat(db): add catalogue object update/delete with audited field diffs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed)
|
||||
|
||||
**Spec coverage (§6.1 typed core + audit integration):**
|
||||
- Inventory-minimum object (object/group via number_of_objects), simple typed fields → Tasks 1–2. ✓
|
||||
- CRUD + list → Tasks 3–4. ✓
|
||||
- Audit on create/update/delete inside the write transaction, field-level diffs on update → Task 3 (create) + Task 4 (update/delete), verified via `audit::history_for` in tests. ✓
|
||||
- Visibility stored; transitions/PublicView/public API deferred to Plan 7. ✓ (intentional)
|
||||
- Vocab/authority binding deferred to Plan 4. ✓ (intentional)
|
||||
- SQL confined to `db`; `domain` I/O-free. ✓
|
||||
|
||||
**Placeholder scan:** none. `<url>` is the documented `DATABASE_URL`. The `OBJECT_COLUMNS`/`update_changes` dead-code notes are explicit resolution instructions, not placeholders.
|
||||
|
||||
**Type consistency:** `ObjectInput`/`CatalogueObject` field names/types identical across `domain` (Task 1), the repo (Tasks 3–4), and tests. `create_object`/`update_object`/`delete_object` take `(&mut PgConnection, AuditActor, …)`; reads take `impl PgExecutor`. `field_values`/`creation_changes`/`update_changes` operate on the same nine mutable fields; `to_input` (domain) bridges `CatalogueObject` → `ObjectInput` for diffing. `ENTITY_TYPE = "object"` matches the `"object"` literal used in tests' `history_for` calls.
|
||||
|
||||
## Notes for follow-on plans
|
||||
- The audit `actor` is threaded as a parameter; the API layer (Plan 7+) will pass the authenticated user (Plan 9 introduces `UserId`; until then `AuditActor::System` or a raw user uuid).
|
||||
- `list_objects` is unpaginated (TODO in code) — add keyset pagination before the API exposes it (same as `audit::history_for`, `vocab::list_terms`, `authority::list_by_kind`).
|
||||
- Flexible fields (Plan 4) attach to this object via the field-definition registry + JSONB; vocab/authority-bound fields (object_name as TermRef, owner as AuthorityRef) live there.
|
||||
- `PublicView` projection + visibility transition methods + public read API: Plan 7.
|
||||
Reference in New Issue
Block a user