//! Snapchat Marketing API Integration //! //! Provides Snap Ads management, audience targeting, and content publishing capabilities. //! Supports OAuth 2.0 authentication flow for Snapchat Marketing API. use crate::channels::{ ChannelAccount, ChannelCredentials, ChannelError, ChannelProvider, ChannelType, PostContent, PostResult, }; use serde::{Deserialize, Serialize}; /// Snapchat Marketing API provider pub struct SnapchatProvider { client: reqwest::Client, api_base_url: String, oauth_base_url: String, } impl SnapchatProvider { pub fn new() -> Self { Self { client: reqwest::Client::new(), api_base_url: "https://adsapi.snapchat.com/v1".to_string(), oauth_base_url: "https://accounts.snapchat.com".to_string(), } } /// Get authenticated user info pub async fn get_me(&self, access_token: &str) -> Result { let url = format!("{}/me", self.api_base_url); let response = self .client .get(&url) .header("Authorization", format!("Bearer {}", access_token)) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } let result: MeResponse = response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), })?; result.me.ok_or_else(|| ChannelError::ApiError { code: None, message: "No user data in response".to_string(), }) } /// List organizations for the authenticated user pub async fn list_organizations( &self, access_token: &str, ) -> Result, ChannelError> { let url = format!("{}/me/organizations", self.api_base_url); let response = self .client .get(&url) .header("Authorization", format!("Bearer {}", access_token)) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } let result: OrganizationsResponse = response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), })?; Ok(result .organizations .into_iter() .filter_map(|wrapper| wrapper.organization) .collect()) } /// List ad accounts for an organization pub async fn list_ad_accounts( &self, access_token: &str, organization_id: &str, ) -> Result, ChannelError> { let url = format!( "{}/organizations/{}/adaccounts", self.api_base_url, organization_id ); let response = self .client .get(&url) .header("Authorization", format!("Bearer {}", access_token)) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } let result: AdAccountsResponse = response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), })?; Ok(result .adaccounts .into_iter() .filter_map(|wrapper| wrapper.adaccount) .collect()) } /// Create a campaign pub async fn create_campaign( &self, access_token: &str, ad_account_id: &str, campaign: &CampaignCreateRequest, ) -> Result { let url = format!( "{}/adaccounts/{}/campaigns", self.api_base_url, ad_account_id ); let request_body = serde_json::json!({ "campaigns": [{ "name": campaign.name, "status": campaign.status, "objective": campaign.objective, "start_time": campaign.start_time, "end_time": campaign.end_time, "daily_budget_micro": campaign.daily_budget_micro, "lifetime_spend_cap_micro": campaign.lifetime_spend_cap_micro }] }); let response = self .client .post(&url) .header("Authorization", format!("Bearer {}", access_token)) .header("Content-Type", "application/json") .json(&request_body) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } let result: CampaignsResponse = response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), })?; result .campaigns .into_iter() .next() .and_then(|wrapper| wrapper.campaign) .ok_or_else(|| ChannelError::ApiError { code: None, message: "No campaign in response".to_string(), }) } /// List campaigns for an ad account pub async fn list_campaigns( &self, access_token: &str, ad_account_id: &str, ) -> Result, ChannelError> { let url = format!( "{}/adaccounts/{}/campaigns", self.api_base_url, ad_account_id ); let response = self .client .get(&url) .header("Authorization", format!("Bearer {}", access_token)) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } let result: CampaignsResponse = response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), })?; Ok(result .campaigns .into_iter() .filter_map(|wrapper| wrapper.campaign) .collect()) } /// Create an ad squad (ad set) pub async fn create_ad_squad( &self, access_token: &str, campaign_id: &str, ad_squad: &AdSquadCreateRequest, ) -> Result { let url = format!("{}/campaigns/{}/adsquads", self.api_base_url, campaign_id); let request_body = serde_json::json!({ "adsquads": [{ "name": ad_squad.name, "status": ad_squad.status, "type": ad_squad.squad_type, "placement_v2": ad_squad.placement, "billing_event": ad_squad.billing_event, "bid_micro": ad_squad.bid_micro, "daily_budget_micro": ad_squad.daily_budget_micro, "start_time": ad_squad.start_time, "end_time": ad_squad.end_time, "optimization_goal": ad_squad.optimization_goal, "targeting": ad_squad.targeting }] }); let response = self .client .post(&url) .header("Authorization", format!("Bearer {}", access_token)) .header("Content-Type", "application/json") .json(&request_body) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } let result: AdSquadsResponse = response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), })?; result .adsquads .into_iter() .next() .and_then(|wrapper| wrapper.adsquad) .ok_or_else(|| ChannelError::ApiError { code: None, message: "No ad squad in response".to_string(), }) } /// Create a creative pub async fn create_creative( &self, access_token: &str, ad_account_id: &str, creative: &CreativeCreateRequest, ) -> Result { let url = format!( "{}/adaccounts/{}/creatives", self.api_base_url, ad_account_id ); let request_body = serde_json::json!({ "creatives": [{ "name": creative.name, "type": creative.creative_type, "headline": creative.headline, "brand_name": creative.brand_name, "shareable": creative.shareable, "call_to_action": creative.call_to_action, "top_snap_media_id": creative.top_snap_media_id, "top_snap_crop_position": creative.top_snap_crop_position, "longform_video_properties": creative.longform_video_properties, "web_view_properties": creative.web_view_properties }] }); let response = self .client .post(&url) .header("Authorization", format!("Bearer {}", access_token)) .header("Content-Type", "application/json") .json(&request_body) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } let result: CreativesResponse = response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), })?; result .creatives .into_iter() .next() .and_then(|wrapper| wrapper.creative) .ok_or_else(|| ChannelError::ApiError { code: None, message: "No creative in response".to_string(), }) } /// Create an ad pub async fn create_ad( &self, access_token: &str, ad_squad_id: &str, ad: &AdCreateRequest, ) -> Result { let url = format!("{}/adsquads/{}/ads", self.api_base_url, ad_squad_id); let request_body = serde_json::json!({ "ads": [{ "name": ad.name, "status": ad.status, "creative_id": ad.creative_id, "type": ad.ad_type }] }); let response = self .client .post(&url) .header("Authorization", format!("Bearer {}", access_token)) .header("Content-Type", "application/json") .json(&request_body) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } let result: AdsResponse = response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), })?; result .ads .into_iter() .next() .and_then(|wrapper| wrapper.ad) .ok_or_else(|| ChannelError::ApiError { code: None, message: "No ad in response".to_string(), }) } /// Upload media (initialize upload) pub async fn init_media_upload( &self, access_token: &str, ad_account_id: &str, media: &MediaUploadRequest, ) -> Result { let url = format!("{}/adaccounts/{}/media", self.api_base_url, ad_account_id); let request_body = serde_json::json!({ "media": [{ "name": media.name, "type": media.media_type, "ad_account_id": ad_account_id }] }); let response = self .client .post(&url) .header("Authorization", format!("Bearer {}", access_token)) .header("Content-Type", "application/json") .json(&request_body) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } let result: MediaResponse = response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), })?; result .media .into_iter() .next() .and_then(|wrapper| wrapper.media) .ok_or_else(|| ChannelError::ApiError { code: None, message: "No media in response".to_string(), }) } /// Upload media chunk pub async fn upload_media_chunk( &self, access_token: &str, ad_account_id: &str, media_id: &str, chunk_data: &[u8], chunk_number: u32, ) -> Result<(), ChannelError> { let url = format!( "{}/adaccounts/{}/media/{}/upload", self.api_base_url, ad_account_id, media_id ); let part = reqwest::multipart::Part::bytes(chunk_data.to_vec()) .file_name("chunk") .mime_str("application/octet-stream") .map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), })?; let form = reqwest::multipart::Form::new() .part("file", part) .text("chunk_number", chunk_number.to_string()); let response = self .client .post(&url) .header("Authorization", format!("Bearer {}", access_token)) .multipart(form) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } Ok(()) } /// Complete media upload pub async fn complete_media_upload( &self, access_token: &str, ad_account_id: &str, media_id: &str, total_chunks: u32, ) -> Result { let url = format!( "{}/adaccounts/{}/media/{}/complete", self.api_base_url, ad_account_id, media_id ); let request_body = serde_json::json!({ "total_chunks": total_chunks }); let response = self .client .post(&url) .header("Authorization", format!("Bearer {}", access_token)) .header("Content-Type", "application/json") .json(&request_body) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } let result: SingleMediaResponse = response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), })?; result.media.ok_or_else(|| ChannelError::ApiError { code: None, message: "No media in response".to_string(), }) } /// Get media status pub async fn get_media( &self, access_token: &str, ad_account_id: &str, media_id: &str, ) -> Result { let url = format!( "{}/adaccounts/{}/media/{}", self.api_base_url, ad_account_id, media_id ); let response = self .client .get(&url) .header("Authorization", format!("Bearer {}", access_token)) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } let result: SingleMediaResponse = response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), })?; result.media.ok_or_else(|| ChannelError::ApiError { code: None, message: "No media in response".to_string(), }) } /// Create a custom audience pub async fn create_audience( &self, access_token: &str, ad_account_id: &str, audience: &AudienceCreateRequest, ) -> Result { let url = format!( "{}/adaccounts/{}/segments", self.api_base_url, ad_account_id ); let request_body = serde_json::json!({ "segments": [{ "name": audience.name, "description": audience.description, "source_type": audience.source_type, "retention_in_days": audience.retention_in_days, "ad_account_id": ad_account_id }] }); let response = self .client .post(&url) .header("Authorization", format!("Bearer {}", access_token)) .header("Content-Type", "application/json") .json(&request_body) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } let result: AudiencesResponse = response.json().await.map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), })?; result .segments .into_iter() .next() .and_then(|wrapper| wrapper.segment) .ok_or_else(|| ChannelError::ApiError { code: None, message: "No audience in response".to_string(), }) } /// Get campaign stats pub async fn get_campaign_stats( &self, access_token: &str, campaign_id: &str, granularity: &str, start_time: &str, end_time: &str, ) -> Result { let url = format!("{}/campaigns/{}/stats", self.api_base_url, campaign_id); let response = self .client .get(&url) .header("Authorization", format!("Bearer {}", access_token)) .query(&[ ("granularity", granularity), ("start_time", start_time), ("end_time", end_time), ]) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } response .json::() .await .map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), }) } /// Refresh OAuth token pub async fn refresh_oauth_token( &self, client_id: &str, client_secret: &str, refresh_token: &str, ) -> Result { let url = format!("{}/login/oauth2/access_token", self.oauth_base_url); let response = self .client .post(&url) .form(&[ ("client_id", client_id), ("client_secret", client_secret), ("refresh_token", refresh_token), ("grant_type", "refresh_token"), ]) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } response .json::() .await .map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), }) } /// Generate OAuth authorization URL pub fn get_authorization_url( &self, client_id: &str, redirect_uri: &str, scope: &str, state: &str, ) -> String { format!( "{}/login/oauth2/authorize?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}", self.oauth_base_url, client_id, urlencoding::encode(redirect_uri), urlencoding::encode(scope), urlencoding::encode(state) ) } /// Exchange authorization code for access token pub async fn exchange_code_for_token( &self, client_id: &str, client_secret: &str, code: &str, redirect_uri: &str, ) -> Result { let url = format!("{}/login/oauth2/access_token", self.oauth_base_url); let response = self .client .post(&url) .form(&[ ("client_id", client_id), ("client_secret", client_secret), ("code", code), ("grant_type", "authorization_code"), ("redirect_uri", redirect_uri), ]) .send() .await .map_err(|e| ChannelError::NetworkError(e.to_string()))?; if !response.status().is_success() { return Err(self.parse_error_response(response).await); } response .json::() .await .map_err(|e| ChannelError::ApiError { code: None, message: e.to_string(), }) } async fn parse_error_response(&self, response: reqwest::Response) -> ChannelError { let status = response.status(); if status.as_u16() == 401 { return ChannelError::AuthenticationFailed("Invalid or expired token".to_string()); } if status.as_u16() == 429 { let retry_after = response .headers() .get("retry-after") .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse().ok()); return ChannelError::RateLimited { retry_after }; } let error_text = response.text().await.unwrap_or_default(); if let Ok(error_response) = serde_json::from_str::(&error_text) { return ChannelError::ApiError { code: error_response.request_status.clone(), message: error_response .debug_message .or(error_response.display_message) .unwrap_or_else(|| error_response.request_status.unwrap_or_default()), }; } ChannelError::ApiError { code: Some(status.to_string()), message: error_text, } } } impl Default for SnapchatProvider { fn default() -> Self { Self::new() } } #[async_trait::async_trait] impl ChannelProvider for SnapchatProvider { fn channel_type(&self) -> ChannelType { ChannelType::Snapchat } fn max_text_length(&self) -> usize { 34 // Headline character limit for ads } fn supports_images(&self) -> bool { true } fn supports_video(&self) -> bool { true } fn supports_links(&self) -> bool { true } async fn post( &self, account: &ChannelAccount, content: &PostContent, ) -> Result { let access_token = match &account.credentials { ChannelCredentials::OAuth { access_token, .. } => access_token.clone(), _ => { return Err(ChannelError::AuthenticationFailed( "OAuth credentials required for Snapchat".to_string(), )) } }; // Get required IDs from settings let ad_account_id = account .settings .custom .get("ad_account_id") .and_then(|v| v.as_str()) .ok_or_else(|| ChannelError::ApiError { code: None, message: "ad_account_id required in settings".to_string(), })?; account .settings .custom .get("campaign_id") .and_then(|v| v.as_str()) .ok_or_else(|| ChannelError::ApiError { code: None, message: "campaign_id required in settings".to_string(), })?; let ad_squad_id = account .settings .custom .get("ad_squad_id") .and_then(|v| v.as_str()) .ok_or_else(|| ChannelError::ApiError { code: None, message: "ad_squad_id required in settings".to_string(), })?; let text = content.text.as_deref().unwrap_or(""); // For Snapchat Ads, we need a media file first // This is a simplified flow - real implementation would handle media upload let media_id = content .metadata .get("media_id") .and_then(|v| v.as_str()) .ok_or_else(|| ChannelError::ApiError { code: None, message: "media_id required for Snapchat ads".to_string(), })?; // Create creative let creative_request = CreativeCreateRequest { name: format!("Creative - {}", chrono::Utc::now().format("%Y%m%d%H%M%S")), creative_type: "SNAP_AD".to_string(), headline: Some(text.chars().take(34).collect()), brand_name: content .metadata .get("brand_name") .and_then(|v| v.as_str()) .map(String::from), shareable: Some(true), call_to_action: content .metadata .get("cta") .and_then(|v| v.as_str()) .map(String::from), top_snap_media_id: media_id.to_string(), top_snap_crop_position: Some("MIDDLE".to_string()), longform_video_properties: None, web_view_properties: content.link.as_ref().map(|url| WebViewProperties { url: url.clone(), allow_snap_javascript_sdk: Some(false), use_immersive_mode: Some(false), deep_link_urls: None, }), }; let creative = self .create_creative(&access_token, ad_account_id, &creative_request) .await?; // Create ad let ad_request = AdCreateRequest { name: format!("Ad - {}", chrono::Utc::now().format("%Y%m%d%H%M%S")), status: "ACTIVE".to_string(), creative_id: creative.id.clone(), ad_type: "SNAP_AD".to_string(), }; let ad = self .create_ad(&access_token, ad_squad_id, &ad_request) .await?; Ok(PostResult::success(ChannelType::Snapchat, ad.id, None)) } async fn validate_credentials( &self, credentials: &ChannelCredentials, ) -> Result { match credentials { ChannelCredentials::OAuth { access_token, .. } => { match self.get_me(access_token).await { Ok(_) => Ok(true), Err(ChannelError::AuthenticationFailed(_)) => Ok(false), Err(e) => Err(e), } } _ => Ok(false), } } async fn refresh_token(&self, account: &mut ChannelAccount) -> Result<(), ChannelError> { let (refresh_token, client_id, client_secret) = match &account.credentials { ChannelCredentials::OAuth { refresh_token, .. } => { let refresh = refresh_token.as_ref().ok_or_else(|| { ChannelError::AuthenticationFailed("No refresh token available".to_string()) })?; let client_id = account .settings .custom .get("client_id") .and_then(|v| v.as_str()) .ok_or_else(|| { ChannelError::AuthenticationFailed("Missing client_id".to_string()) })?; let client_secret = account .settings .custom .get("client_secret") .and_then(|v| v.as_str()) .ok_or_else(|| { ChannelError::AuthenticationFailed("Missing client_secret".to_string()) })?; ( refresh.clone(), client_id.to_string(), client_secret.to_string(), ) } _ => { return Err(ChannelError::AuthenticationFailed( "OAuth credentials required".to_string(), )) } }; let token_response = self .refresh_oauth_token(&client_id, &client_secret, &refresh_token) .await?; let expires_at = chrono::Utc::now() + chrono::Duration::seconds(token_response.expires_in as i64); account.credentials = ChannelCredentials::OAuth { access_token: token_response.access_token, refresh_token: Some(token_response.refresh_token), expires_at: Some(expires_at), scope: Some(token_response.scope), }; Ok(()) } } // ============================================================================ // Request Types // ============================================================================ #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CampaignCreateRequest { pub name: String, pub status: String, pub objective: String, pub start_time: Option, pub end_time: Option, pub daily_budget_micro: Option, pub lifetime_spend_cap_micro: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AdSquadCreateRequest { pub name: String, pub status: String, pub squad_type: String, pub placement: Option, pub billing_event: String, pub bid_micro: i64, pub daily_budget_micro: Option, pub start_time: Option, pub end_time: Option, pub optimization_goal: String, pub targeting: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlacementV2 { pub config: String, // "AUTOMATIC" or "CUSTOM" pub platforms: Option>, pub snap_positions: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Targeting { pub geos: Option>, pub demographics: Option>, pub interests: Option>, pub devices: Option>, pub segments: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GeoTarget { pub country_code: String, pub region_id: Option, pub metro_id: Option, pub postal_code: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Demographic { pub age_groups: Option>, pub genders: Option>, pub languages: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Interest { pub category_id: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Device { pub os_type: Option, pub os_version_min: Option, pub os_version_max: Option, pub make: Option>, pub connection_type: Option>, pub carrier: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SegmentTarget { pub segment_id: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreativeCreateRequest { pub name: String, pub creative_type: String, pub headline: Option, pub brand_name: Option, pub shareable: Option, pub call_to_action: Option, pub top_snap_media_id: String, pub top_snap_crop_position: Option, pub longform_video_properties: Option, pub web_view_properties: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LongformVideoProperties { pub video_media_id: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebViewProperties { pub url: String, pub allow_snap_javascript_sdk: Option, pub use_immersive_mode: Option, pub deep_link_urls: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AdCreateRequest { pub name: String, pub status: String, pub creative_id: String, pub ad_type: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MediaUploadRequest { pub name: String, pub media_type: String, // "VIDEO" or "IMAGE" } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AudienceCreateRequest { pub name: String, pub description: Option, pub source_type: String, pub retention_in_days: Option, } // ============================================================================ // Response Types // ============================================================================ #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SnapchatErrorResponse { pub request_status: Option, pub request_id: Option, pub debug_message: Option, pub display_message: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MeResponse { pub request_status: Option, pub request_id: Option, pub me: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SnapchatUser { pub id: String, pub updated_at: Option, pub created_at: Option, pub email: Option, pub organization_id: Option, pub display_name: Option, pub member_status: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrganizationsResponse { pub request_status: Option, pub request_id: Option, pub organizations: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrganizationWrapper { pub organization: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Organization { pub id: String, pub updated_at: Option, pub created_at: Option, pub name: String, pub address_line_1: Option, pub locality: Option, pub administrative_district_level_1: Option, pub country: Option, pub postal_code: Option, pub organization_type: Option, pub state: Option, pub roles: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AdAccountsResponse { pub request_status: Option, pub request_id: Option, pub adaccounts: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AdAccountWrapper { pub adaccount: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AdAccount { pub id: String, pub updated_at: Option, pub created_at: Option, pub name: String, pub organization_id: String, pub currency: String, pub timezone: String, pub advertiser: Option, pub status: Option, pub funding_source_ids: Option>, pub lifetime_spend_cap_micro: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CampaignsResponse { pub request_status: Option, pub request_id: Option, pub campaigns: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CampaignWrapper { pub campaign: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Campaign { pub id: String, pub updated_at: Option, pub created_at: Option, pub name: String, pub ad_account_id: String, pub status: String, pub objective: String, pub start_time: Option, pub end_time: Option, pub daily_budget_micro: Option, pub lifetime_spend_cap_micro: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AdSquadsResponse { pub request_status: Option, pub request_id: Option, pub adsquads: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AdSquadWrapper { pub adsquad: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AdSquad { pub id: String, pub updated_at: Option, pub created_at: Option, pub name: String, pub campaign_id: String, pub status: String, pub squad_type: Option, pub billing_event: Option, pub bid_micro: Option, pub daily_budget_micro: Option, pub start_time: Option, pub end_time: Option, pub optimization_goal: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreativesResponse { pub request_status: Option, pub request_id: Option, pub creatives: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreativeWrapper { pub creative: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Creative { pub id: String, pub updated_at: Option, pub created_at: Option, pub name: String, pub ad_account_id: String, pub creative_type: Option, pub headline: Option, pub brand_name: Option, pub shareable: Option, pub call_to_action: Option, pub top_snap_media_id: Option, pub top_snap_crop_position: Option, pub review_status: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AdsResponse { pub request_status: Option, pub request_id: Option, pub ads: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AdWrapper { pub ad: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Ad { pub id: String, pub updated_at: Option, pub created_at: Option, pub name: String, pub ad_squad_id: String, pub creative_id: String, pub status: String, pub ad_type: Option, pub review_status: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MediaResponse { pub request_status: Option, pub request_id: Option, pub media: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MediaWrapper { pub media: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SingleMediaResponse { pub request_status: Option, pub request_id: Option, pub media: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Media { pub id: String, pub updated_at: Option, pub created_at: Option, pub name: String, pub ad_account_id: String, pub media_type: String, pub media_status: Option, pub file_name: Option, pub download_link: Option, pub duration_secs: Option, pub upload_link: Option, } impl Media { pub fn is_ready(&self) -> bool { self.media_status.as_deref() == Some("READY") } pub fn is_pending(&self) -> bool { self.media_status.as_deref() == Some("PENDING_UPLOAD") } pub fn is_processing(&self) -> bool { self.media_status.as_deref() == Some("PROCESSING") } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AudiencesResponse { pub request_status: Option, pub request_id: Option, pub segments: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AudienceWrapper { pub segment: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Audience { pub id: String, pub updated_at: Option, pub created_at: Option, pub name: String, pub ad_account_id: String, pub description: Option, pub source_type: String, pub retention_in_days: Option, pub status: Option, pub approximate_number_users: Option, pub upload_status: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CampaignStats { pub request_status: Option, pub request_id: Option, pub total_stats: Option>, pub timeseries_stats: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StatsEntry { pub id: Option, pub stats: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Stats { pub impressions: Option, pub swipes: Option, pub spend: Option, pub quartile_1: Option, pub quartile_2: Option, pub quartile_3: Option, pub view_completion: Option, pub screen_time_millis: Option, pub video_views: Option, pub video_views_time_based: Option, pub video_views_15s: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TimeseriesStats { pub id: Option, pub timeseries: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TimeseriesEntry { pub start_time: Option, pub end_time: Option, pub stats: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OAuthTokenResponse { pub access_token: String, pub refresh_token: String, pub expires_in: u64, pub token_type: String, pub scope: String, } // ============================================================================ // Constants // ============================================================================ /// Campaign objectives pub struct CampaignObjectives; impl CampaignObjectives { pub const AWARENESS: &'static str = "AWARENESS"; pub const APP_INSTALLS: &'static str = "APP_INSTALLS"; pub const ENGAGEMENT: &'static str = "ENGAGEMENT"; pub const VIDEO_VIEWS: &'static str = "VIDEO_VIEWS"; pub const WEB_CONVERSIONS: &'static str = "WEB_CONVERSIONS"; pub const LEAD_GENERATION: &'static str = "LEAD_GENERATION"; pub const CATALOG_SALES: &'static str = "CATALOG_SALES"; } /// Ad status values pub struct AdStatus; impl AdStatus { pub const ACTIVE: &'static str = "ACTIVE"; pub const PAUSED: &'static str = "PAUSED"; } /// Billing events pub struct BillingEvents; impl BillingEvents { pub const IMPRESSION: &'static str = "IMPRESSION"; pub const SWIPE: &'static str = "SWIPE"; pub const VIDEO_VIEW: &'static str = "VIDEO_VIEW"; } /// Optimization goals pub struct OptimizationGoals; impl OptimizationGoals { pub const IMPRESSIONS: &'static str = "IMPRESSIONS"; pub const SWIPES: &'static str = "SWIPES"; pub const APP_INSTALLS: &'static str = "APP_INSTALLS"; pub const VIDEO_VIEWS: &'static str = "VIDEO_VIEWS"; pub const VIDEO_VIEWS_15_SEC: &'static str = "VIDEO_VIEWS_15_SEC"; pub const USES: &'static str = "USES"; pub const STORY_OPENS: &'static str = "STORY_OPENS"; pub const PIXEL_PAGE_VIEW: &'static str = "PIXEL_PAGE_VIEW"; pub const PIXEL_ADD_TO_CART: &'static str = "PIXEL_ADD_TO_CART"; pub const PIXEL_PURCHASE: &'static str = "PIXEL_PURCHASE"; pub const PIXEL_SIGNUP: &'static str = "PIXEL_SIGNUP"; } /// Call to action types pub struct CallToAction; impl CallToAction { pub const APPLY_NOW: &'static str = "APPLY_NOW"; pub const BOOK_NOW: &'static str = "BOOK_NOW"; pub const BUY_TICKETS: &'static str = "BUY_TICKETS"; pub const CONTACT_US: &'static str = "CONTACT_US"; pub const DONATE: &'static str = "DONATE"; pub const DOWNLOAD: &'static str = "DOWNLOAD"; pub const GET_NOW: &'static str = "GET_NOW"; pub const INSTALL_NOW: &'static str = "INSTALL_NOW"; pub const LEARN_MORE: &'static str = "LEARN_MORE"; pub const LISTEN: &'static str = "LISTEN"; pub const MORE: &'static str = "MORE"; pub const ORDER_NOW: &'static str = "ORDER_NOW"; pub const PLAY: &'static str = "PLAY"; pub const READ: &'static str = "READ"; pub const SHOP_NOW: &'static str = "SHOP_NOW"; pub const SHOW_TIMES: &'static str = "SHOW_TIMES"; pub const SIGN_UP: &'static str = "SIGN_UP"; pub const SUBSCRIBE: &'static str = "SUBSCRIBE"; pub const USE_APP: &'static str = "USE_APP"; pub const VIEW: &'static str = "VIEW"; pub const VIEW_MORE: &'static str = "VIEW_MORE"; pub const VOTE_NOW: &'static str = "VOTE_NOW"; pub const WATCH: &'static str = "WATCH"; } /// Audience source types pub struct AudienceSourceTypes; impl AudienceSourceTypes { pub const FIRST_PARTY: &'static str = "FIRST_PARTY"; pub const ENGAGEMENT_SNAPCHAT: &'static str = "ENGAGEMENT_SNAPCHAT"; pub const PIXEL: &'static str = "PIXEL"; pub const MOBILE_APP: &'static str = "MOBILE_APP"; pub const LOOKALIKE: &'static str = "LOOKALIKE"; }