Skip to main content

stateset_core/models/
integration_field_mapping.rs

1//! Integration field-mapping domain models
2//!
3//! A field mapping describes how a source field (a dotted path in an external
4//! payload, e.g. `order.customer.email`) maps onto a destination field, with an
5//! optional template, value transform, and fallback. This is distinct from an
6//! [`IntegrationMapping`](crate::IntegrationMapping), which maps discrete
7//! *values* (e.g. carrier names) rather than *field paths*.
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use stateset_primitives::IntegrationFieldMappingId;
12use strum::{Display, EnumString};
13
14/// A value transform applied during field mapping.
15#[derive(
16    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
17)]
18#[serde(rename_all = "snake_case")]
19#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
20#[non_exhaustive]
21pub enum FieldTransform {
22    /// Pass the value through unchanged.
23    #[default]
24    None,
25    /// Uppercase the value.
26    Uppercase,
27    /// Lowercase the value.
28    Lowercase,
29    /// Trim surrounding whitespace.
30    Trim,
31}
32
33impl FieldTransform {
34    /// Apply the transform to a value.
35    #[must_use]
36    pub fn apply(&self, value: &str) -> String {
37        match self {
38            Self::None => value.to_string(),
39            Self::Uppercase => value.to_uppercase(),
40            Self::Lowercase => value.to_lowercase(),
41            Self::Trim => value.trim().to_string(),
42        }
43    }
44}
45
46/// A field-path mapping for an integration account.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct IntegrationFieldMapping {
49    /// Unique mapping ID.
50    pub id: IntegrationFieldMappingId,
51    /// Integration account this mapping belongs to.
52    pub integration_account: String,
53    /// Logical mapping group (e.g. `order`, `shipment`).
54    pub mapping_group: String,
55    /// Source field path (e.g. `order.customer.email`).
56    pub source_field: String,
57    /// Destination field name.
58    pub destination_field: String,
59    /// Optional template (e.g. `"{first} {last}"`).
60    pub template: Option<String>,
61    /// Value transform.
62    pub transform: FieldTransform,
63    /// Fallback value when the source is missing/empty.
64    pub fallback: Option<String>,
65    /// Whether the mapping is active.
66    pub is_active: bool,
67    /// When the mapping was created.
68    pub created_at: DateTime<Utc>,
69    /// When the mapping was last updated.
70    pub updated_at: DateTime<Utc>,
71}
72
73impl IntegrationFieldMapping {
74    /// Resolve the destination value given a source value (or absence),
75    /// applying transform then falling back when empty.
76    #[must_use]
77    pub fn resolve_value(&self, source: Option<&str>) -> Option<String> {
78        let transformed = source.map(|v| self.transform.apply(v)).filter(|v| !v.is_empty());
79        transformed.or_else(|| self.fallback.clone())
80    }
81}
82
83/// Input for creating a field mapping.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct CreateIntegrationFieldMapping {
86    /// Integration account.
87    pub integration_account: String,
88    /// Mapping group.
89    pub mapping_group: String,
90    /// Source field path.
91    pub source_field: String,
92    /// Destination field.
93    pub destination_field: String,
94    /// Optional template.
95    pub template: Option<String>,
96    /// Value transform (defaults to `None`).
97    #[serde(default)]
98    pub transform: FieldTransform,
99    /// Fallback value.
100    pub fallback: Option<String>,
101}
102
103/// Input for updating a field mapping (partial).
104#[derive(Debug, Clone, Serialize, Deserialize, Default)]
105pub struct UpdateIntegrationFieldMapping {
106    /// Updated destination field.
107    pub destination_field: Option<String>,
108    /// Updated template.
109    pub template: Option<String>,
110    /// Updated transform.
111    pub transform: Option<FieldTransform>,
112    /// Updated fallback.
113    pub fallback: Option<String>,
114    /// Updated active state.
115    pub is_active: Option<bool>,
116}
117
118/// Filter for listing field mappings.
119#[derive(Debug, Clone, Serialize, Deserialize, Default)]
120pub struct IntegrationFieldMappingFilter {
121    /// Filter by integration account.
122    pub integration_account: Option<String>,
123    /// Filter by mapping group.
124    pub mapping_group: Option<String>,
125    /// Filter by source field.
126    pub source_field: Option<String>,
127    /// Filter by active state.
128    pub is_active: Option<bool>,
129    /// Maximum results.
130    pub limit: Option<u32>,
131    /// Offset for pagination.
132    pub offset: Option<u32>,
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    fn make(transform: FieldTransform, fallback: Option<&str>) -> IntegrationFieldMapping {
140        IntegrationFieldMapping {
141            id: IntegrationFieldMappingId::new(),
142            integration_account: "acct-1".into(),
143            mapping_group: "order".into(),
144            source_field: "order.customer.email".into(),
145            destination_field: "email".into(),
146            template: None,
147            transform,
148            fallback: fallback.map(String::from),
149            is_active: true,
150            created_at: Utc::now(),
151            updated_at: Utc::now(),
152        }
153    }
154
155    #[test]
156    fn transform_apply() {
157        assert_eq!(FieldTransform::Uppercase.apply("aBc"), "ABC");
158        assert_eq!(FieldTransform::Lowercase.apply("aBc"), "abc");
159        assert_eq!(FieldTransform::Trim.apply("  x  "), "x");
160        assert_eq!(FieldTransform::None.apply(" x "), " x ");
161    }
162
163    #[test]
164    fn resolve_applies_transform() {
165        let m = make(FieldTransform::Uppercase, None);
166        assert_eq!(m.resolve_value(Some("hi")), Some("HI".to_string()));
167    }
168
169    #[test]
170    fn resolve_uses_fallback_when_empty_or_absent() {
171        let m = make(FieldTransform::None, Some("default@x.test"));
172        assert_eq!(m.resolve_value(None), Some("default@x.test".to_string()));
173        assert_eq!(m.resolve_value(Some("")), Some("default@x.test".to_string()));
174        assert_eq!(m.resolve_value(Some("real@x.test")), Some("real@x.test".to_string()));
175    }
176
177    #[test]
178    fn resolve_none_without_fallback() {
179        let m = make(FieldTransform::None, None);
180        assert_eq!(m.resolve_value(None), None);
181    }
182
183    #[test]
184    fn transform_roundtrip() {
185        for t in [
186            FieldTransform::None,
187            FieldTransform::Uppercase,
188            FieldTransform::Lowercase,
189            FieldTransform::Trim,
190        ] {
191            assert_eq!(t.to_string().parse::<FieldTransform>().unwrap(), t);
192        }
193    }
194}