//! 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, FieldType, IllegalTransition, NewAuditEvent, ObjectId, ObjectInput, Visibility, }; use serde_json::{Value, json}; use sqlx::Row; use crate::{audit, authority, fields, vocab}; /// The entity_type recorded in the audit log for catalogue objects. const ENTITY_TYPE: &str = "object"; /// The visibility value eligible for the public surface. const PUBLIC_VISIBILITY: &str = Visibility::Public.as_str(); const OBJECT_COLUMNS: &str = "id, object_number, object_name, number_of_objects, \ brief_description, current_location, current_owner, recorder, recording_date, \ visibility, fields, 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() } /// Whitelisted, injection-safe sort columns for the object list. The client never /// supplies a column name directly — the API layer maps an opaque token onto a variant, /// and only [`ObjectSort::column`] (returning a `'static str`) reaches the SQL string. #[derive(Debug, Clone, Copy)] pub enum ObjectSort { ObjectNumber, ObjectName, UpdatedAt, CreatedAt, Visibility, } impl ObjectSort { fn column(self) -> &'static str { match self { ObjectSort::ObjectNumber => "object_number", ObjectSort::ObjectName => "object_name", ObjectSort::UpdatedAt => "updated_at", ObjectSort::CreatedAt => "created_at", ObjectSort::Visibility => "visibility", } } } /// Filters + ordering for a paged object query. `visibility`/`q` are optional; /// both are bound as parameters, never interpolated into the SQL string. pub struct ObjectQuery<'a> { pub sort: ObjectSort, pub descending: bool, pub visibility: Option<&'a str>, pub q: Option<&'a str>, } /// Build the optional `WHERE` clause and its ordered bind values from the filters. /// Each clause references a positional placeholder (`$1`, `$2`, …) matching the order /// the returned `binds` are applied; the client's strings only ever arrive as binds. fn where_clause(visibility: Option<&str>, q: Option<&str>) -> (String, Vec) { let mut clauses = Vec::new(); let mut binds = Vec::new(); if let Some(v) = visibility { binds.push(v.to_owned()); clauses.push(format!("visibility = ${}", binds.len())); } if let Some(term) = q { binds.push(format!("%{term}%")); let p = binds.len(); clauses.push(format!( "(object_number ILIKE ${p} OR object_name ILIKE ${p})" )); } let sql = if clauses.is_empty() { String::new() } else { format!(" WHERE {}", clauses.join(" AND ")) }; (sql, binds) } /// List objects (all visibility levels) with whitelisted sort, optional visibility/quick /// filters, and paging. Ordering uses [`ObjectSort::column`] (a `'static str`) plus a /// stable secondary key, so no client-controlled string ever reaches the SQL text. pub async fn list_objects_query( pool: &sqlx::PgPool, query: &ObjectQuery<'_>, limit: i64, offset: i64, ) -> Result, sqlx::Error> { let (where_sql, binds) = where_clause(query.visibility, query.q); let dir = if query.descending { "DESC" } else { "ASC" }; // Secondary key keeps ordering stable when the primary sort has ties. let sql = format!( "SELECT {OBJECT_COLUMNS} FROM object{where_sql} \ ORDER BY {} {dir}, object_number ASC LIMIT ${} OFFSET ${}", query.sort.column(), binds.len() + 1, binds.len() + 2, ); let mut sql_query = sqlx::query(&sql); for bind in &binds { sql_query = sql_query.bind(bind); } let rows = sql_query.bind(limit).bind(offset).fetch_all(pool).await?; rows.into_iter().map(map_object).collect() } /// Count objects matching the optional visibility/quick filters (for pagination totals). pub async fn count_objects_query( pool: &sqlx::PgPool, visibility: Option<&str>, q: Option<&str>, ) -> Result { let (where_sql, binds) = where_clause(visibility, q); let sql = format!("SELECT count(*) AS n FROM object{where_sql}"); let mut sql_query = sqlx::query(&sql); for bind in &binds { sql_query = sql_query.bind(bind); } sql_query.fetch_one(pool).await?.try_get("n") } /// Fetch one **public** object by id. Returns `None` if the object is missing **or** /// not public — callers map both to 404 so non-public existence isn't revealed. pub async fn public_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 AND visibility = $2"); let row = sqlx::query(&sql) .bind(id.to_uuid()) .bind(PUBLIC_VISIBILITY) .fetch_optional(executor) .await?; row.map(map_object).transpose() } /// List **public** objects ordered by object number, with `limit`/`offset` paging. /// /// `limit` and `offset` must be non-negative (Postgres rejects a negative `LIMIT`); /// the public API layer clamps them before calling. pub async fn list_public_objects<'e, E>( executor: E, limit: i64, offset: i64, ) -> Result, sqlx::Error> where E: sqlx::PgExecutor<'e>, { let sql = format!( "SELECT {OBJECT_COLUMNS} FROM object WHERE visibility = $1 \ ORDER BY object_number LIMIT $2 OFFSET $3" ); let rows = sqlx::query(&sql) .bind(PUBLIC_VISIBILITY) .bind(limit) .bind(offset) .fetch_all(executor) .await?; rows.into_iter().map(map_object).collect() } /// Count all public objects (for pagination totals). pub async fn count_public_objects<'e, E>(executor: E) -> Result where E: sqlx::PgExecutor<'e>, { let row = sqlx::query("SELECT count(*) AS n FROM object WHERE visibility = $1") .bind(PUBLIC_VISIBILITY) .fetch_one(executor) .await?; row.try_get("n") } 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, fields: row.try_get("fields")?, 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); }; apply_object_update(&mut *conn, actor, id, &old.to_input(), input).await?; Ok(true) } /// Diff `old`→`new`, write the changed columns + an `updated` audit entry, both on /// `conn`. A no-op (no field changed) touches neither the row's `updated_at` nor the /// audit log. async fn apply_object_update( conn: &mut sqlx::PgConnection, actor: AuditActor, id: ObjectId, old: &ObjectInput, new: &ObjectInput, ) -> Result<(), sqlx::Error> { let changes = update_changes(old, new); if changes.is_empty() { return Ok(()); } 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(&new.object_number) .bind(&new.object_name) .bind(new.number_of_objects) .bind(new.brief_description.as_deref()) .bind(new.current_location.as_deref()) .bind(new.current_owner.as_deref()) .bind(new.recorder.as_deref()) .bind(new.recording_date) .bind(new.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(()) } /// Why changing an object's visibility failed. #[derive(Debug, thiserror::Error)] pub enum VisibilityError { #[error("object not found")] ObjectNotFound, #[error(transparent)] Illegal(#[from] IllegalTransition), #[error("missing required field(s): {}", .0.join(", "))] MissingRequiredFields(Vec), #[error(transparent)] Db(#[from] sqlx::Error), } /// Move an object to `target` visibility, enforcing the stepwise state machine, and /// audit the change. Uses the same diff/audit path as `update_object`, so only /// `visibility` appears in the audit entry — and setting to the current value is an /// idempotent no-op (no row touch, no audit). Pass a transaction connection. pub async fn set_visibility( conn: &mut sqlx::PgConnection, actor: AuditActor, id: ObjectId, target: Visibility, ) -> Result<(), VisibilityError> { let Some(object) = object_by_id(&mut *conn, id).await? else { return Err(VisibilityError::ObjectNotFound); }; let new_visibility = object.visibility.transition_to(target)?; // The publish gate: a record may only *become* public once every required field // has a value. The typed inventory-minimum columns are already NOT NULL, so only // the flexible required fields need checking here. Gated on an actual transition // into public so a set-to-current no-op stays a no-op (never a late rejection). if new_visibility == Visibility::Public && object.visibility != Visibility::Public { let missing = missing_required_fields(&mut *conn, &object.fields).await?; if !missing.is_empty() { return Err(VisibilityError::MissingRequiredFields(missing)); } } let old_input = object.to_input(); let mut new_input = old_input.clone(); new_input.visibility = new_visibility; apply_object_update(&mut *conn, actor, id, &old_input, &new_input).await?; Ok(()) } /// The keys of `required` field definitions that have no value on `fields` (absent or /// null). Empty when every required field is present. async fn missing_required_fields( conn: &mut sqlx::PgConnection, fields: &Value, ) -> Result, sqlx::Error> { let definitions = fields::list_field_definitions(&mut *conn).await?; Ok(definitions .into_iter() .filter(|definition| definition.required) .filter(|definition| fields.get(&definition.key).is_none_or(Value::is_null)) .map(|definition| definition.key) .collect()) } /// 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) } /// Why setting flexible field values failed. #[derive(Debug, thiserror::Error)] pub enum FieldError { #[error("object not found")] ObjectNotFound, #[error("unknown field: {0}")] UnknownField(String), #[error("field `{field}` expects a {expected} value")] TypeMismatch { field: String, expected: &'static str, }, #[error("field `{field}`: value does not resolve to an existing {kind}")] Unresolved { field: String, kind: &'static str }, #[error(transparent)] Db(#[from] sqlx::Error), } /// Replace an object's flexible field values, validating each against the registry /// (type + term/authority resolution), and audit the per-field diff — all on `conn`. /// A no-op (identical to the current values) writes nothing and records no audit. /// /// **Replace semantics:** `values` is the *complete* desired set. Omitting a key that /// was previously set REMOVES it (recorded in the audit as a removal); send every key /// the caller wants to retain. /// /// Required-field *completeness* is intentionally NOT enforced here — a caller may set /// any subset. That check belongs to the publish gate (when moving to /// `Visibility::Public`, Plan 7). pub async fn set_object_fields( conn: &mut sqlx::PgConnection, actor: AuditActor, object_id: ObjectId, values: &serde_json::Map, ) -> Result<(), FieldError> { let Some(old) = object_by_id(&mut *conn, object_id).await? else { return Err(FieldError::ObjectNotFound); }; for (key, value) in values { validate_field(&mut *conn, key, value).await?; } let new_fields = Value::Object(values.clone()); let changes = field_map_changes(&old.fields, &new_fields); if changes.is_empty() { return Ok(()); } sqlx::query("UPDATE object SET fields = $2, updated_at = now() WHERE id = $1") .bind(object_id.to_uuid()) .bind(&new_fields) .execute(&mut *conn) .await?; audit::record( &mut *conn, &NewAuditEvent { actor, action: AuditAction::Updated, entity_type: ENTITY_TYPE.to_owned(), entity_id: object_id.to_uuid(), changes, }, ) .await?; Ok(()) } async fn validate_field( conn: &mut sqlx::PgConnection, key: &str, value: &Value, ) -> Result<(), FieldError> { let def = fields::field_definition_by_key(&mut *conn, key) .await? .ok_or_else(|| FieldError::UnknownField(key.to_owned()))?; match def.field_type { FieldType::Text => require(value.is_string(), key, "text")?, FieldType::LocalizedText => require( value .as_object() .is_some_and(|o| o.values().all(Value::is_string)), key, "localized-text object {lang: string}", )?, FieldType::Integer => require(value.is_i64(), key, "integer")?, // Format/range validation (real date parsing) is deferred to issue #11; // here a date field only requires a string value. FieldType::Date => require(value.is_string(), key, "date string")?, FieldType::Boolean => require(value.is_boolean(), key, "boolean")?, FieldType::Term { vocabulary_id } => { let term_id = parse_uuid(value, key, "term id (uuid string)")?; if vocab::resolve_term( &mut *conn, vocabulary_id, domain::TermId::from_uuid(term_id), ) .await? .is_none() { return Err(FieldError::Unresolved { field: key.to_owned(), kind: "term", }); } } FieldType::Authority { kind } => { let authority_id = parse_uuid(value, key, "authority id (uuid string)")?; match authority::resolve_authority( &mut *conn, domain::AuthorityId::from_uuid(authority_id), ) .await? { Some(ref_) if kind.is_none_or(|k| ref_.kind() == k) => {} _ => { return Err(FieldError::Unresolved { field: key.to_owned(), kind: "authority", }); } } } } Ok(()) } fn require(ok: bool, field: &str, expected: &'static str) -> Result<(), FieldError> { if ok { Ok(()) } else { Err(FieldError::TypeMismatch { field: field.to_owned(), expected, }) } } fn parse_uuid( value: &Value, field: &str, expected: &'static str, ) -> Result { value .as_str() .and_then(|s| s.parse::().ok()) .ok_or_else(|| FieldError::TypeMismatch { field: field.to_owned(), expected, }) } /// Per-key diff between two flexible-field maps. `before`/`after` are `None` when /// the key is absent on that side (so adds and removes are captured). fn field_map_changes(old: &Value, new: &Value) -> Vec { let empty = serde_json::Map::new(); let old_map = old.as_object().unwrap_or(&empty); let new_map = new.as_object().unwrap_or(&empty); let keys: std::collections::BTreeSet<&String> = old_map.keys().chain(new_map.keys()).collect(); keys.into_iter() .filter_map(|key| { let before = old_map.get(key).cloned(); let after = new_map.get(key).cloned(); if before != after { Some(FieldChange { field: key.clone(), before, after, }) } else { None } }) .collect() }