Skip to main content

mkt_core/models/
audience.rs

1//! Audience domain model.
2
3use std::fmt;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8/// Opaque audience identifier.
9#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub struct AudienceId(pub String);
11
12impl fmt::Display for AudienceId {
13    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
14        write!(f, "{}", self.0)
15    }
16}
17
18impl<T: Into<String>> From<T> for AudienceId {
19    fn from(s: T) -> Self {
20        Self(s.into())
21    }
22}
23
24/// A unified audience representation across all providers.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Audience {
27    /// Unique identifier.
28    pub id: AudienceId,
29    /// Which provider this audience belongs to.
30    pub provider: String,
31    /// Audience name.
32    pub name: String,
33    /// Optional human-readable description.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub description: Option<String>,
36    /// Estimated number of people in the audience.
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub size: Option<u64>,
39    /// The type of audience.
40    pub audience_type: AudienceType,
41    /// When the audience was created.
42    pub created_at: DateTime<Utc>,
43    /// Original API response for debugging and raw access.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub raw: Option<serde_json::Value>,
46}
47
48/// The classification of an audience.
49#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
50#[serde(rename_all = "snake_case")]
51pub enum AudienceType {
52    /// Custom audience built from first-party data.
53    #[default]
54    Custom,
55    /// Lookalike audience derived from a seed audience.
56    Lookalike,
57    /// Saved audience based on demographic/interest criteria.
58    SavedAudience,
59    /// Platform-specific type not mapped to a known variant.
60    Other(String),
61}
62
63impl fmt::Display for AudienceType {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        match self {
66            Self::Custom => write!(f, "custom"),
67            Self::Lookalike => write!(f, "lookalike"),
68            Self::SavedAudience => write!(f, "saved_audience"),
69            Self::Other(s) => write!(f, "{s}"),
70        }
71    }
72}
73
74impl<'de> Deserialize<'de> for AudienceType {
75    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
76        let s = String::deserialize(deserializer)?;
77        Ok(match s.as_str() {
78            "custom" => Self::Custom,
79            "lookalike" => Self::Lookalike,
80            "saved_audience" => Self::SavedAudience,
81            _ => Self::Other(s),
82        })
83    }
84}
85
86/// Input for creating a new audience.
87#[derive(Debug, Clone, Default, Serialize, Deserialize)]
88pub struct CreateAudienceInput {
89    /// Audience name.
90    pub name: String,
91    /// Optional description.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub description: Option<String>,
94    /// Type of audience to create.
95    pub audience_type: AudienceType,
96    /// Provider-specific extra fields.
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub extra: Option<serde_json::Value>,
99}
100
101/// A single user record for audience membership operations.
102#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103pub struct AudienceUser {
104    /// Hashed or plain email address.
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub email: Option<String>,
107    /// Hashed or plain phone number.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub phone: Option<String>,
110    /// First-party external identifier.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub external_id: Option<String>,
113}
114
115/// Result returned after adding or removing users from an audience.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct AudienceUpdateResult {
118    /// The audience that was updated.
119    pub audience_id: AudienceId,
120    /// Number of user records received.
121    pub num_received: u64,
122    /// Number of user records that were invalid.
123    pub num_invalid: u64,
124}
125
126impl crate::output::Formattable for Audience {
127    fn headers() -> Vec<String> {
128        vec![
129            "ID".into(),
130            "Name".into(),
131            "Type".into(),
132            "Size".into(),
133            "Provider".into(),
134        ]
135    }
136
137    fn row(&self) -> Vec<String> {
138        vec![
139            self.id.to_string(),
140            self.name.clone(),
141            self.audience_type.to_string(),
142            self.size.map_or_else(|| "-".to_string(), |s| s.to_string()),
143            self.provider.clone(),
144        ]
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use test_case::test_case;
152
153    #[test]
154    fn audience_id_display() {
155        let id = AudienceId("aud_123".into());
156        assert_eq!(id.to_string(), "aud_123");
157    }
158
159    #[test]
160    fn audience_id_from_str() {
161        let id = AudienceId::from("aud_456");
162        assert_eq!(id.0, "aud_456");
163    }
164
165    #[test]
166    #[allow(clippy::expect_used)]
167    fn audience_id_serde_roundtrip() {
168        let id = AudienceId("aud_789".into());
169        let json = serde_json::to_string(&id).expect("serialize AudienceId");
170        let back: AudienceId = serde_json::from_str(&json).expect("deserialize AudienceId");
171        assert_eq!(id, back);
172    }
173
174    #[test_case(AudienceType::Custom, "custom" ; "custom")]
175    #[test_case(AudienceType::Lookalike, "lookalike" ; "lookalike")]
176    #[test_case(AudienceType::SavedAudience, "saved_audience" ; "saved_audience")]
177    #[allow(clippy::needless_pass_by_value)]
178    fn audience_type_display(audience_type: AudienceType, expected: &str) {
179        assert_eq!(audience_type.to_string(), expected);
180    }
181
182    #[test]
183    fn audience_type_other_display() {
184        let t = AudienceType::Other("combined".into());
185        assert_eq!(t.to_string(), "combined");
186    }
187
188    #[test]
189    #[allow(clippy::expect_used)]
190    fn audience_type_serde_roundtrip() {
191        let json = serde_json::to_string(&AudienceType::Lookalike).expect("serialize");
192        assert_eq!(json, r#""lookalike""#);
193        let back: AudienceType = serde_json::from_str(&json).expect("deserialize");
194        assert_eq!(back, AudienceType::Lookalike);
195    }
196
197    #[test]
198    #[allow(clippy::expect_used)]
199    fn unknown_audience_type_deserializes_as_other() {
200        let t: AudienceType =
201            serde_json::from_str(r#""website_retargeting""#).expect("deserialize");
202        assert_eq!(t, AudienceType::Other("website_retargeting".into()));
203    }
204
205    #[test]
206    fn create_audience_input_default() {
207        let input = CreateAudienceInput::default();
208        assert!(input.name.is_empty());
209        assert!(input.description.is_none());
210        assert_eq!(input.audience_type, AudienceType::Custom);
211        assert!(input.extra.is_none());
212    }
213
214    #[test]
215    fn audience_user_default_all_none() {
216        let user = AudienceUser::default();
217        assert!(user.email.is_none());
218        assert!(user.phone.is_none());
219        assert!(user.external_id.is_none());
220    }
221
222    #[test]
223    #[allow(clippy::expect_used)]
224    fn audience_update_result_serde_roundtrip() {
225        let result = AudienceUpdateResult {
226            audience_id: AudienceId("aud_1".into()),
227            num_received: 100,
228            num_invalid: 5,
229        };
230        let json = serde_json::to_string(&result).expect("serialize AudienceUpdateResult");
231        let back: AudienceUpdateResult =
232            serde_json::from_str(&json).expect("deserialize AudienceUpdateResult");
233        assert_eq!(back.audience_id, result.audience_id);
234        assert_eq!(back.num_received, 100);
235        assert_eq!(back.num_invalid, 5);
236    }
237
238    #[test]
239    #[allow(clippy::expect_used)]
240    fn audience_optional_fields_skip_serializing_if_none() {
241        let now = chrono::Utc::now();
242        let audience = Audience {
243            id: AudienceId("aud_1".into()),
244            provider: "meta".into(),
245            name: "Test Audience".into(),
246            description: None,
247            size: None,
248            audience_type: AudienceType::Custom,
249            created_at: now,
250            raw: None,
251        };
252        let json = serde_json::to_string(&audience).expect("serialize Audience");
253        assert!(!json.contains("description"));
254        assert!(!json.contains("size"));
255        assert!(!json.contains("raw"));
256    }
257}