1use mkt_core::error::{MktError, Result};
4use mkt_core::models::{
5 Campaign, CampaignFilters, CampaignId, CreateCampaignInput, InsightsQuery, InsightsReport,
6 Paginated, ProviderHealth, UpdateCampaignInput,
7};
8use mkt_core::provider::{MarketingProvider, ProviderCapabilities};
9use tracing::instrument;
10
11use crate::client::GoogleClient;
12use crate::mapping;
13
14#[derive(Debug)]
19pub struct GoogleProvider {
20 client: GoogleClient,
21}
22
23impl GoogleProvider {
24 pub const fn new(client: GoogleClient) -> Self {
26 Self { client }
27 }
28
29 async fn fetch_campaign(&self, id: &str) -> Result<Campaign> {
31 let query = format!(
32 "SELECT {} FROM campaign WHERE campaign.id = {id}",
33 mapping::CAMPAIGN_GAQL_FIELDS
34 );
35 let resp = self.client.search(&query).await?;
36 let row = resp["results"]
37 .as_array()
38 .and_then(|rows| rows.first())
39 .ok_or_else(|| MktError::ApiError {
40 provider: "google".into(),
41 status: 404,
42 message: format!("campaign {id} not found"),
43 retry_after: None,
44 })?;
45 mapping::google_row_to_campaign(row)
46 }
47}
48
49impl MarketingProvider for GoogleProvider {
50 fn name(&self) -> &'static str {
51 "google"
52 }
53
54 fn display_name(&self) -> &'static str {
55 "Google Ads"
56 }
57
58 fn capabilities(&self) -> ProviderCapabilities {
59 ProviderCapabilities {
60 campaigns: true,
61 adsets: false,
62 ads: false,
63 creatives: false,
64 audiences: false,
65 insights: true,
66 organic_posts: false,
67 dark_posts: false,
68 video_upload: false,
69 image_upload: false,
70 workflow_templates: false,
71 }
72 }
73
74 #[instrument(skip(self))]
77 fn list_campaigns(
78 &self,
79 filters: &CampaignFilters,
80 ) -> impl std::future::Future<Output = Result<Paginated<Campaign>>> + Send {
81 async move {
82 let mut query = format!("SELECT {} FROM campaign", mapping::CAMPAIGN_GAQL_FIELDS);
83
84 let mut conditions: Vec<String> = Vec::new();
85 if let Some(status) = &filters.status {
86 conditions.push(format!(
87 "campaign.status = '{}'",
88 mapping::domain_status_to_google(status)
89 ));
90 }
91 if let Some(name) = &filters.name_contains {
92 let escaped = name.replace('\'', "\\'");
94 conditions.push(format!("campaign.name LIKE '%{escaped}%'"));
95 }
96 if !conditions.is_empty() {
97 query.push_str(" WHERE ");
98 query.push_str(&conditions.join(" AND "));
99 }
100 if let Some(limit) = filters.limit {
101 query = format!("{query} LIMIT {limit}");
102 }
103
104 let resp = self.client.search(&query).await?;
105
106 let items = resp["results"]
107 .as_array()
108 .unwrap_or(&Vec::new())
109 .iter()
110 .map(mapping::google_row_to_campaign)
111 .collect::<Result<Vec<_>>>()?;
112
113 let next_cursor = resp["nextPageToken"].as_str().map(String::from);
114
115 Ok(Paginated {
116 data: items,
117 next_cursor,
118 total: None,
119 })
120 }
121 }
122
123 #[instrument(skip(self))]
124 fn get_campaign(
125 &self,
126 id: &CampaignId,
127 ) -> impl std::future::Future<Output = Result<Campaign>> + Send {
128 async move { self.fetch_campaign(&id.0).await }
129 }
130
131 #[instrument(skip(self, input))]
132 fn create_campaign(
133 &self,
134 input: &CreateCampaignInput,
135 ) -> impl std::future::Future<Output = Result<Campaign>> + Send {
136 async move {
137 let budget_ops = mapping::budget_create_operation(input).ok_or_else(|| {
139 MktError::ValidationError {
140 field: "budget".into(),
141 message: "a budget is required to create a Google Ads campaign".into(),
142 }
143 })?;
144 let budget_resp = self.client.mutate("campaignBudgets", &budget_ops).await?;
145 let budget_resource = budget_resp["results"][0]["resourceName"]
146 .as_str()
147 .ok_or_else(|| MktError::ApiError {
148 provider: "google".into(),
149 status: 0,
150 message: "budget mutate response missing resourceName".into(),
151 retry_after: None,
152 })?;
153
154 let campaign_ops = mapping::campaign_create_operation(input, budget_resource);
155 let campaign_resp = self.client.mutate("campaigns", &campaign_ops).await?;
156 let new_id = mapping::campaign_id_from_mutate(&campaign_resp)?;
157
158 self.fetch_campaign(&new_id).await
159 }
160 }
161
162 #[instrument(skip(self, input))]
163 fn update_campaign(
164 &self,
165 id: &CampaignId,
166 input: &UpdateCampaignInput,
167 ) -> impl std::future::Future<Output = Result<Campaign>> + Send {
168 async move {
169 let ops = mapping::campaign_update_operation(self.client.customer_id(), &id.0, input);
170 let _resp = self.client.mutate("campaigns", &ops).await?;
171 self.fetch_campaign(&id.0).await
172 }
173 }
174
175 #[instrument(skip(self))]
176 fn delete_campaign(
177 &self,
178 id: &CampaignId,
179 ) -> impl std::future::Future<Output = Result<()>> + Send {
180 async move {
181 let resource = mapping::campaign_resource_name(self.client.customer_id(), &id.0);
182 let ops = serde_json::json!([{ "remove": resource }]);
183 let _resp = self.client.mutate("campaigns", &ops).await?;
184 Ok(())
185 }
186 }
187
188 #[instrument(skip(self, query))]
191 fn get_insights(
192 &self,
193 query: &InsightsQuery,
194 ) -> impl std::future::Future<Output = Result<InsightsReport>> + Send {
195 async move {
196 let metrics = if query.metrics.is_empty() {
197 "metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.ctr".to_string()
198 } else {
199 query
200 .metrics
201 .iter()
202 .map(|m| format!("metrics.{m}"))
203 .collect::<Vec<_>>()
204 .join(", ")
205 };
206
207 let gaql = format!(
208 "SELECT campaign.id, campaign.name, segments.date, {metrics} FROM campaign"
209 );
210
211 let gaql = query.date_range.as_ref().map_or_else(
214 || format!("{gaql} WHERE segments.date DURING LAST_30_DAYS"),
215 |range| {
216 format!(
217 "{gaql} WHERE segments.date BETWEEN '{}' AND '{}'",
218 range.start.format("%Y-%m-%d"),
219 range.end.format("%Y-%m-%d")
220 )
221 },
222 );
223
224 let resp = self.client.search(&gaql).await?;
225 mapping::google_insights_to_domain(&resp)
226 }
227 }
228
229 async fn health_check(&self) -> Result<ProviderHealth> {
232 let start = std::time::Instant::now();
233 let result = self
234 .client
235 .search("SELECT customer.id FROM customer LIMIT 1")
236 .await;
237 #[allow(clippy::cast_possible_truncation)] let latency = start.elapsed().as_millis() as u64;
239
240 match result {
241 Ok(_) => Ok(ProviderHealth {
242 provider: "google".into(),
243 healthy: true,
244 latency_ms: latency,
245 message: None,
246 }),
247 Err(e) => Ok(ProviderHealth {
248 provider: "google".into(),
249 healthy: false,
250 latency_ms: latency,
251 message: Some(e.to_string()),
252 }),
253 }
254 }
255}