feat(db): enforce required-field completeness on publish (#16)
set_visibility now gates the transition to Public: every field definition with required=true must have a value on the object (typed inventory-minimum columns are already NOT NULL, so only flexible required fields are checked). Missing values yield VisibilityError::MissingRequiredFields(keys); the admin publish endpoint maps it to 422. The gate runs in db so every caller is protected and the check is atomic with the transition. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -348,6 +348,8 @@ pub enum VisibilityError {
|
||||
ObjectNotFound,
|
||||
#[error(transparent)]
|
||||
Illegal(#[from] IllegalTransition),
|
||||
#[error("missing required field(s): {}", .0.join(", "))]
|
||||
MissingRequiredFields(Vec<String>),
|
||||
#[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<Vec<String>, 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(
|
||||
|
||||
Reference in New Issue
Block a user