Skip to main content

stateset_core/models/
custom_object.rs

1//! Custom object (a.k.a. metaobject / custom state) domain models
2//!
3//! This module provides a typed, schema-driven custom data system similar to:
4//! - Salesforce Custom Objects (object definitions + records)
5//! - Shopify Metaobjects (definitions + entries)
6//!
7//! The intent is to let apps extend the core commerce schema without forking
8//! StateSet tables, while keeping validation deterministic for agent use.
9
10use chrono::{DateTime, Utc};
11use rust_decimal::Decimal;
12use serde::{Deserialize, Serialize};
13use strum::{Display, EnumString};
14use uuid::Uuid;
15
16use crate::{CommerceError, Result, validate_required_text, validate_sku};
17
18/// Custom object field type.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Serialize, Deserialize)]
20#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
21#[serde(rename_all = "snake_case")]
22#[non_exhaustive]
23pub enum CustomFieldType {
24    String,
25    #[strum(serialize = "integer", serialize = "int")]
26    Integer,
27    #[strum(serialize = "decimal", serialize = "number")]
28    Decimal,
29    #[strum(serialize = "boolean", serialize = "bool")]
30    Boolean,
31    #[strum(serialize = "date_time", serialize = "datetime")]
32    DateTime,
33    Uuid,
34    Json,
35}
36
37/// Field definition inside a custom object type.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct CustomFieldDefinition {
40    /// Stable key used in record values (must be unique within the type).
41    pub key: String,
42    /// Field value type.
43    pub field_type: CustomFieldType,
44    /// Whether the field must be provided (non-null) on create/update.
45    pub required: bool,
46    /// Whether the field is a list of values instead of a scalar.
47    pub list: bool,
48    /// Optional human description.
49    pub description: Option<String>,
50}
51
52impl CustomFieldDefinition {
53    /// Validate the definition itself (key format, etc.).
54    pub fn validate(&self) -> Result<()> {
55        // Reuse SKU validation rules for keys: stable, ASCII, and safe for many environments.
56        validate_sku(&self.key)?;
57        Ok(())
58    }
59
60    /// Validate a JSON value against this field definition.
61    pub fn validate_value(&self, value: &serde_json::Value) -> Result<()> {
62        if value.is_null() {
63            if self.required {
64                return Err(CommerceError::InvalidInput {
65                    field: format!("custom_object.values.{}", self.key),
66                    message: "is required".into(),
67                });
68            }
69            return Ok(());
70        }
71
72        if self.list {
73            let arr = value.as_array().ok_or_else(|| CommerceError::InvalidInput {
74                field: format!("custom_object.values.{}", self.key),
75                message: "must be an array".into(),
76            })?;
77            for (idx, item) in arr.iter().enumerate() {
78                if item.is_null() {
79                    return Err(CommerceError::InvalidInput {
80                        field: format!("custom_object.values.{}[{}]", self.key, idx),
81                        message: "cannot be null".into(),
82                    });
83                }
84                validate_scalar(self.field_type, item).map_err(|mut e| {
85                    // Add precise location context.
86                    if let CommerceError::InvalidInput { ref mut field, .. } = e {
87                        *field = format!("custom_object.values.{}[{}]", self.key, idx);
88                    }
89                    e
90                })?;
91            }
92            return Ok(());
93        }
94
95        // Scalar
96        if value.is_array() {
97            return Err(CommerceError::InvalidInput {
98                field: format!("custom_object.values.{}", self.key),
99                message: "must be a scalar value".into(),
100            });
101        }
102        validate_scalar(self.field_type, value).map_err(|mut e| {
103            if let CommerceError::InvalidInput { ref mut field, .. } = e {
104                *field = format!("custom_object.values.{}", self.key);
105            }
106            e
107        })
108    }
109}
110
111fn validate_scalar(field_type: CustomFieldType, value: &serde_json::Value) -> Result<()> {
112    match field_type {
113        CustomFieldType::String => match value {
114            serde_json::Value::String(s) => {
115                if s.trim().is_empty() {
116                    return Err(CommerceError::InvalidInput {
117                        field: "custom_object.value".into(),
118                        message: "cannot be empty".into(),
119                    });
120                }
121                Ok(())
122            }
123            _ => Err(CommerceError::InvalidInput {
124                field: "custom_object.value".into(),
125                message: "must be a string".into(),
126            }),
127        },
128        CustomFieldType::Integer => match value {
129            serde_json::Value::Number(n) => {
130                if n.is_i64() || n.is_u64() {
131                    Ok(())
132                } else {
133                    Err(CommerceError::InvalidInput {
134                        field: "custom_object.value".into(),
135                        message: "must be an integer".into(),
136                    })
137                }
138            }
139            serde_json::Value::String(s) => {
140                s.parse::<i64>().map_err(|_| CommerceError::InvalidInput {
141                    field: "custom_object.value".into(),
142                    message: "must be an integer".into(),
143                })?;
144                Ok(())
145            }
146            _ => Err(CommerceError::InvalidInput {
147                field: "custom_object.value".into(),
148                message: "must be an integer".into(),
149            }),
150        },
151        CustomFieldType::Decimal => match value {
152            serde_json::Value::Number(n) => {
153                let s = n.to_string();
154                s.parse::<Decimal>().map_err(|_| CommerceError::InvalidInput {
155                    field: "custom_object.value".into(),
156                    message: "must be a decimal".into(),
157                })?;
158                Ok(())
159            }
160            serde_json::Value::String(s) => {
161                s.parse::<Decimal>().map_err(|_| CommerceError::InvalidInput {
162                    field: "custom_object.value".into(),
163                    message: "must be a decimal".into(),
164                })?;
165                Ok(())
166            }
167            _ => Err(CommerceError::InvalidInput {
168                field: "custom_object.value".into(),
169                message: "must be a decimal".into(),
170            }),
171        },
172        CustomFieldType::Boolean => match value {
173            serde_json::Value::Bool(_) => Ok(()),
174            _ => Err(CommerceError::InvalidInput {
175                field: "custom_object.value".into(),
176                message: "must be a boolean".into(),
177            }),
178        },
179        CustomFieldType::DateTime => match value {
180            serde_json::Value::String(s) => {
181                chrono::DateTime::parse_from_rfc3339(s).map_err(|_| {
182                    CommerceError::InvalidInput {
183                        field: "custom_object.value".into(),
184                        message: "must be an RFC3339 datetime string".into(),
185                    }
186                })?;
187                Ok(())
188            }
189            _ => Err(CommerceError::InvalidInput {
190                field: "custom_object.value".into(),
191                message: "must be an RFC3339 datetime string".into(),
192            }),
193        },
194        CustomFieldType::Uuid => match value {
195            serde_json::Value::String(s) => {
196                Uuid::parse_str(s).map_err(|_| CommerceError::InvalidInput {
197                    field: "custom_object.value".into(),
198                    message: "must be a UUID string".into(),
199                })?;
200                Ok(())
201            }
202            _ => Err(CommerceError::InvalidInput {
203                field: "custom_object.value".into(),
204                message: "must be a UUID string".into(),
205            }),
206        },
207        CustomFieldType::Json => Ok(()),
208    }
209}
210
211/// Custom object type (schema / definition).
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213pub struct CustomObjectType {
214    pub id: Uuid,
215    /// Stable identifier ("api name"). Unique across all custom object types.
216    pub handle: String,
217    /// Human-friendly name (e.g., "Warranty Registration").
218    pub display_name: String,
219    pub description: String,
220    pub fields: Vec<CustomFieldDefinition>,
221    pub created_at: DateTime<Utc>,
222    pub updated_at: DateTime<Utc>,
223    /// Version for optimistic locking.
224    pub version: i32,
225}
226
227impl CustomObjectType {
228    /// Validate a record values JSON object against this type schema.
229    pub fn validate_values(&self, values: &serde_json::Value) -> Result<()> {
230        let obj = values.as_object().ok_or_else(|| CommerceError::InvalidInput {
231            field: "custom_object.values".into(),
232            message: "must be a JSON object".into(),
233        })?;
234
235        // Enforce "no unknown fields" for determinism.
236        for key in obj.keys() {
237            if !self.fields.iter().any(|f| f.key == *key) {
238                return Err(CommerceError::InvalidInput {
239                    field: format!("custom_object.values.{key}"),
240                    message: "unknown field".into(),
241                });
242            }
243        }
244
245        // Validate provided values and required-ness.
246        for field in &self.fields {
247            field.validate()?;
248            match obj.get(&field.key) {
249                Some(v) => field.validate_value(v)?,
250                None if field.required => {
251                    return Err(CommerceError::InvalidInput {
252                        field: format!("custom_object.values.{}", field.key),
253                        message: "is required".into(),
254                    });
255                }
256                None => {}
257            }
258        }
259
260        Ok(())
261    }
262}
263
264/// Input for creating a custom object type.
265#[derive(Debug, Clone, Serialize, Deserialize, Default)]
266pub struct CreateCustomObjectType {
267    pub handle: String,
268    pub display_name: String,
269    pub description: Option<String>,
270    pub fields: Vec<CustomFieldDefinition>,
271}
272
273/// Input for updating a custom object type.
274#[derive(Debug, Clone, Serialize, Deserialize, Default)]
275pub struct UpdateCustomObjectType {
276    pub display_name: Option<String>,
277    pub description: Option<String>,
278    /// Full replacement of the field definitions.
279    pub fields: Option<Vec<CustomFieldDefinition>>,
280}
281
282/// Filter for listing custom object types.
283#[derive(Debug, Clone, Serialize, Deserialize, Default)]
284pub struct CustomObjectTypeFilter {
285    pub search: Option<String>,
286    pub limit: Option<u32>,
287    pub offset: Option<u32>,
288}
289
290/// Custom object record (instance of a type definition).
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
292pub struct CustomObject {
293    pub id: Uuid,
294    pub type_id: Uuid,
295    pub type_handle: String,
296    /// Optional stable handle (unique within type) for human-friendly addressing.
297    pub handle: Option<String>,
298    /// Optional owner link. When set, both `owner_type` and `owner_id` must be set.
299    pub owner_type: Option<String>,
300    pub owner_id: Option<String>,
301    /// Record values (JSON object).
302    pub values: serde_json::Value,
303    pub created_at: DateTime<Utc>,
304    pub updated_at: DateTime<Utc>,
305    /// Version for optimistic locking.
306    pub version: i32,
307}
308
309/// Input for creating a custom object record.
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct CreateCustomObject {
312    pub type_handle: String,
313    pub handle: Option<String>,
314    pub owner_type: Option<String>,
315    pub owner_id: Option<String>,
316    pub values: serde_json::Value,
317}
318
319/// Input for updating a custom object record.
320#[derive(Debug, Clone, Serialize, Deserialize, Default)]
321pub struct UpdateCustomObject {
322    pub handle: Option<String>,
323    pub owner_type: Option<String>,
324    pub owner_id: Option<String>,
325    pub values: Option<serde_json::Value>,
326}
327
328/// Filter for querying custom object records.
329#[derive(Debug, Clone, Serialize, Deserialize, Default)]
330pub struct CustomObjectFilter {
331    pub type_handle: Option<String>,
332    pub owner_type: Option<String>,
333    pub owner_id: Option<String>,
334    pub handle: Option<String>,
335    pub limit: Option<u32>,
336    pub offset: Option<u32>,
337}
338
339/// Validate a type definition input.
340pub fn validate_custom_object_type_input(input: &CreateCustomObjectType) -> Result<()> {
341    validate_sku(&input.handle)?;
342    validate_required_text("custom_object_type.display_name", &input.display_name, 128)?;
343
344    // Field keys must be unique and valid.
345    let mut keys = std::collections::HashSet::new();
346    for field in &input.fields {
347        field.validate()?;
348        if !keys.insert(field.key.clone()) {
349            return Err(CommerceError::InvalidInput {
350                field: "custom_object_type.fields".into(),
351                message: format!("duplicate field key: {}", field.key),
352            });
353        }
354    }
355
356    Ok(())
357}