diff --git a/crates/api/src/admin.rs b/crates/api/src/admin.rs index fba8127..07587c7 100644 --- a/crates/api/src/admin.rs +++ b/crates/api/src/admin.rs @@ -166,6 +166,9 @@ pub(crate) async fn set_visibility( } Err(db::catalog::VisibilityError::ObjectNotFound) => Err(StatusCode::NOT_FOUND), Err(db::catalog::VisibilityError::Illegal(_)) => Err(StatusCode::CONFLICT), + Err(db::catalog::VisibilityError::MissingRequiredFields(_)) => { + Err(StatusCode::UNPROCESSABLE_ENTITY) + } Err(db::catalog::VisibilityError::Db(_)) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } diff --git a/crates/api/tests/admin.rs b/crates/api/tests/admin.rs index 9aeb7e5..631a3d8 100644 --- a/crates/api/tests/admin.rs +++ b/crates/api/tests/admin.rs @@ -335,3 +335,80 @@ async fn illegal_visibility_transition_is_409(pool: PgPool) { .unwrap(); assert_eq!(publish.status(), StatusCode::CONFLICT); } + +#[sqlx::test(migrations = "../db/migrations")] +async fn publishing_without_required_field_is_422(pool: PgPool) { + use domain::{FieldType, LocalizedLabel, NewFieldDefinition}; + + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + + seed_user(&pool, "editor@example.com", "pw-editor-123", Role::Editor).await; + + let db = db::Db::from_pool(pool.clone()); + let mut tx = db.pool().begin().await.unwrap(); + + db::fields::create_field_definition( + &mut tx, + &NewFieldDefinition { + key: "inscription".into(), + field_type: FieldType::Text, + required: true, + group_key: None, + labels: vec![LocalizedLabel { + lang: "en".into(), + label: "Inscription".into(), + }], + }, + ) + .await + .unwrap(); + + let id = catalog::create_object( + &mut tx, + AuditActor::System, + &ObjectInput { + object_number: "P-2".into(), + object_name: "vase".into(), + number_of_objects: 1, + brief_description: None, + current_location: None, + current_owner: None, + recorder: None, + recording_date: None, + visibility: Visibility::Internal, + }, + ) + .await + .unwrap(); + + tx.commit().await.unwrap(); + + let app = build_app(state(pool.clone())); + + let resp = app + .clone() + .oneshot(login_request("editor@example.com", "pw-editor-123")) + .await + .unwrap(); + let cookie = session_cookie(&resp); + + // publishing while a required field has no value -> 422, visibility unchanged + let publish = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/admin/objects/{id}/visibility")) + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"visibility":"public"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(publish.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let obj = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap(); + assert_eq!(obj.visibility, Visibility::Internal); +} diff --git a/crates/db/src/catalog.rs b/crates/db/src/catalog.rs index e3d67fb..c9dc3ce 100644 --- a/crates/db/src/catalog.rs +++ b/crates/db/src/catalog.rs @@ -348,6 +348,8 @@ pub enum VisibilityError { ObjectNotFound, #[error(transparent)] Illegal(#[from] IllegalTransition), + #[error("missing required field(s): {}", .0.join(", "))] + MissingRequiredFields(Vec), #[error(transparent)] Db(#[from] sqlx::Error), } @@ -368,6 +370,17 @@ pub async fn set_visibility( 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. + if new_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(); @@ -377,6 +390,22 @@ pub async fn set_visibility( 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( diff --git a/crates/db/tests/visibility.rs b/crates/db/tests/visibility.rs index 37a2d64..f709f28 100644 --- a/crates/db/tests/visibility.rs +++ b/crates/db/tests/visibility.rs @@ -184,3 +184,75 @@ async fn public_reads_return_only_public_records(pool: PgPool) { .is_none() ); } + +#[sqlx::test] +async fn publishing_requires_all_required_fields_present(pool: PgPool) { + use db::fields; + use domain::{FieldType, LocalizedLabel, NewFieldDefinition}; + + let db = Db::from_pool(pool); + + let mut tx = db.pool().begin().await.unwrap(); + + // a required flexible field + fields::create_field_definition( + &mut tx, + &NewFieldDefinition { + key: "inscription".into(), + field_type: FieldType::Text, + required: true, + group_key: None, + labels: vec![LocalizedLabel { + lang: "en".into(), + label: "Inscription".into(), + }], + }, + ) + .await + .unwrap(); + + let id = catalog::create_object( + &mut tx, + AuditActor::System, + &object("LM-1", Visibility::Draft), + ) + .await + .unwrap(); + + catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Internal) + .await + .unwrap(); + + // publishing without the required field present is rejected + let err = catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Public) + .await + .unwrap_err(); + assert!( + matches!(err, catalog::VisibilityError::MissingRequiredFields(ref keys) if keys == &["inscription"]) + ); + + // the object is still not public + let still = catalog::object_by_id(&mut *tx, id).await.unwrap().unwrap(); + assert_eq!(still.visibility, Visibility::Internal); + + // set the required field, then publishing succeeds + catalog::set_object_fields( + &mut tx, + AuditActor::System, + id, + serde_json::json!({ "inscription": "To the gods" }) + .as_object() + .unwrap(), + ) + .await + .unwrap(); + + catalog::set_visibility(&mut tx, AuditActor::System, id, Visibility::Public) + .await + .unwrap(); + + tx.commit().await.unwrap(); + + let published = catalog::object_by_id(db.pool(), id).await.unwrap().unwrap(); + assert_eq!(published.visibility, Visibility::Public); +}