whale-code 6.5.7 → 6.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +14 -2
  2. package/dist/cli/services/agent-loop.js +26 -2
  3. package/dist/cli/services/agent-loop.js.map +1 -1
  4. package/dist/cli/services/hooks.js +2 -1
  5. package/dist/cli/services/hooks.js.map +1 -1
  6. package/dist/cli/services/telemetry-spans.js +1 -0
  7. package/dist/cli/services/telemetry-spans.js.map +1 -1
  8. package/dist/cli/services/telemetry.d.ts +23 -0
  9. package/dist/cli/services/telemetry.js +45 -1
  10. package/dist/cli/services/telemetry.js.map +1 -1
  11. package/dist/server/handlers/__test-utils__/test-db.d.ts +17 -3
  12. package/dist/server/handlers/__test-utils__/test-db.js +113 -14
  13. package/dist/server/handlers/__test-utils__/test-db.js.map +1 -1
  14. package/dist/server/handlers/affiliates.d.ts +9 -0
  15. package/dist/server/handlers/affiliates.js +197 -0
  16. package/dist/server/handlers/affiliates.js.map +1 -0
  17. package/dist/server/handlers/api-docs.d.ts +4 -2
  18. package/dist/server/handlers/api-docs.js +204 -1681
  19. package/dist/server/handlers/api-docs.js.map +1 -1
  20. package/dist/server/handlers/campaigns.d.ts +9 -0
  21. package/dist/server/handlers/campaigns.js +237 -0
  22. package/dist/server/handlers/campaigns.js.map +1 -0
  23. package/dist/server/handlers/catalog-schemas.js +9 -9
  24. package/dist/server/handlers/catalog-schemas.js.map +1 -1
  25. package/dist/server/handlers/catalog.js +1 -1
  26. package/dist/server/handlers/catalog.js.map +1 -1
  27. package/dist/server/handlers/comms-documents.js +28 -2
  28. package/dist/server/handlers/comms-documents.js.map +1 -1
  29. package/dist/server/handlers/comms-pdf-generation.js +25 -3
  30. package/dist/server/handlers/comms-pdf-generation.js.map +1 -1
  31. package/dist/server/handlers/comms-pdf-helpers.js +4 -4
  32. package/dist/server/handlers/comms-pdf-helpers.js.map +1 -1
  33. package/dist/server/handlers/comms.d.ts +100 -0
  34. package/dist/server/handlers/comms.js +146 -12
  35. package/dist/server/handlers/comms.js.map +1 -1
  36. package/dist/server/handlers/coupons.d.ts +9 -0
  37. package/dist/server/handlers/coupons.js +220 -0
  38. package/dist/server/handlers/coupons.js.map +1 -0
  39. package/dist/server/handlers/embeddings.js +1 -1
  40. package/dist/server/handlers/embeddings.js.map +1 -1
  41. package/dist/server/handlers/enrichment.js +2 -622
  42. package/dist/server/handlers/enrichment.js.map +1 -1
  43. package/dist/server/handlers/fulfillment.d.ts +9 -0
  44. package/dist/server/handlers/fulfillment.js +209 -0
  45. package/dist/server/handlers/fulfillment.js.map +1 -0
  46. package/dist/server/handlers/google-ads.d.ts +24 -0
  47. package/dist/server/handlers/google-ads.js +2199 -0
  48. package/dist/server/handlers/google-ads.js.map +1 -0
  49. package/dist/server/handlers/invoices.d.ts +9 -0
  50. package/dist/server/handlers/invoices.js +252 -0
  51. package/dist/server/handlers/invoices.js.map +1 -0
  52. package/dist/server/handlers/loyalty.d.ts +9 -0
  53. package/dist/server/handlers/loyalty.js +197 -0
  54. package/dist/server/handlers/loyalty.js.map +1 -0
  55. package/dist/server/handlers/meta-ads-graph-api.js +18 -3
  56. package/dist/server/handlers/meta-ads-graph-api.js.map +1 -1
  57. package/dist/server/handlers/phone.d.ts +9 -0
  58. package/dist/server/handlers/phone.js +197 -0
  59. package/dist/server/handlers/phone.js.map +1 -0
  60. package/dist/server/handlers/pipeline.d.ts +9 -0
  61. package/dist/server/handlers/pipeline.js +277 -0
  62. package/dist/server/handlers/pipeline.js.map +1 -0
  63. package/dist/server/handlers/qr-codes.d.ts +9 -0
  64. package/dist/server/handlers/qr-codes.js +198 -0
  65. package/dist/server/handlers/qr-codes.js.map +1 -0
  66. package/dist/server/handlers/reviews.d.ts +9 -0
  67. package/dist/server/handlers/reviews.js +171 -0
  68. package/dist/server/handlers/reviews.js.map +1 -0
  69. package/dist/server/handlers/segments.d.ts +9 -0
  70. package/dist/server/handlers/segments.js +229 -0
  71. package/dist/server/handlers/segments.js.map +1 -0
  72. package/dist/server/handlers/social.d.ts +9 -0
  73. package/dist/server/handlers/social.js +81 -0
  74. package/dist/server/handlers/social.js.map +1 -0
  75. package/dist/server/handlers/tax.d.ts +9 -0
  76. package/dist/server/handlers/tax.js +182 -0
  77. package/dist/server/handlers/tax.js.map +1 -0
  78. package/dist/server/handlers/wallet.d.ts +9 -0
  79. package/dist/server/handlers/wallet.js +203 -0
  80. package/dist/server/handlers/wallet.js.map +1 -0
  81. package/dist/server/handlers/webhooks-mgmt.d.ts +9 -0
  82. package/dist/server/handlers/webhooks-mgmt.js +181 -0
  83. package/dist/server/handlers/webhooks-mgmt.js.map +1 -0
  84. package/dist/server/handlers/wholesale.d.ts +9 -0
  85. package/dist/server/handlers/wholesale.js +219 -0
  86. package/dist/server/handlers/wholesale.js.map +1 -0
  87. package/dist/server/index.js +20 -9
  88. package/dist/server/index.js.map +1 -1
  89. package/dist/server/lib/clickhouse-buffer.js +1 -0
  90. package/dist/server/lib/clickhouse-buffer.js.map +1 -1
  91. package/dist/server/lib/coa-renderer.d.ts +1 -1
  92. package/dist/server/lib/coa-renderer.js +32 -10
  93. package/dist/server/lib/coa-renderer.js.map +1 -1
  94. package/dist/server/server-worker.d.ts +1 -0
  95. package/dist/server/server-worker.js +464 -3
  96. package/dist/server/server-worker.js.map +1 -1
  97. package/dist/server/tool-router.js +118 -4
  98. package/dist/server/tool-router.js.map +1 -1
  99. package/package.json +28 -4
  100. package/vendor/ink/package.json +0 -2
  101. package/whale-logo.png +0 -0
@@ -0,0 +1,2199 @@
1
+ /**
2
+ * Google Ads Handler — campaign/ad group/ad/keyword CRUD + publish to Google Ads API v23.
3
+ *
4
+ * Mirrors the Meta Ads handler pattern:
5
+ * - Draft objects stored in Supabase with local UUIDs
6
+ * - Publish pushes drafts to Google Ads API
7
+ * - Sync pulls metrics from Google Ads into local DB
8
+ * - GAQL queries for custom reporting
9
+ *
10
+ * Actions:
11
+ * create_campaign, list_campaigns, get_campaign, update_campaign, delete_campaign,
12
+ * create_ad_group, list_ad_groups, get_ad_group, update_ad_group,
13
+ * create_ad, list_ads, get_ad, update_ad,
14
+ * create_keyword, list_keywords, update_keyword, delete_keyword,
15
+ * publish, sync, search (GAQL), list_accessible_customers,
16
+ * keyword_ideas, create_conversion_action, list_conversion_actions,
17
+ * create_audience, list_audiences, pause, enable
18
+ */
19
+
20
+ import { createLogger } from "../lib/logger.js";
21
+ const log = createLogger("google-ads");
22
+
23
+ // ============================================================================
24
+ // CONSTANTS
25
+ // ============================================================================
26
+
27
+ const GOOGLE_ADS_API = "v23";
28
+ const GOOGLE_ADS_BASE = `https://googleads.googleapis.com/${GOOGLE_ADS_API}`;
29
+ const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
30
+ const DAILY_MUTATION_LIMIT = 10_000; // Google Basic Access limit
31
+
32
+ // ============================================================================
33
+ // TYPES
34
+ // ============================================================================
35
+
36
+ // ============================================================================
37
+ // CREDENTIALS
38
+ // ============================================================================
39
+
40
+ async function getIntegration(sb, storeId) {
41
+ // Use RPC to read platform secrets + per-store data in one call
42
+ const {
43
+ data: config,
44
+ error: rpcErr
45
+ } = await sb.rpc("get_google_config", {
46
+ p_store_id: storeId
47
+ });
48
+
49
+ // Fallback to direct table read if RPC not yet deployed
50
+ const data = rpcErr ? await getIntegrationDirect(sb, storeId) : config;
51
+ if (!data || data.error) {
52
+ throw new Error(data?.error || "Google Ads not connected. Go to Settings → Google Ads to link your account.");
53
+ }
54
+ const accessToken = data.access_token;
55
+ const refreshToken = data.refresh_token;
56
+ const clientId = data.client_id;
57
+ const clientSecret = data.client_secret;
58
+ const customerId = data.customer_id;
59
+ const developerToken = data.developer_token;
60
+ const status = data.status;
61
+ if (!accessToken || !customerId) {
62
+ throw new Error("Google Ads not connected. Go to Settings → Google Ads to link your account.");
63
+ }
64
+ if (status === "token_expired") {
65
+ // Attempt one auto-refresh before giving up
66
+ if (refreshToken && clientId && clientSecret) {
67
+ const refreshed = await refreshAccessToken(sb, storeId, refreshToken, clientId, clientSecret);
68
+ return {
69
+ accessToken: refreshed,
70
+ refreshToken,
71
+ clientId,
72
+ clientSecret,
73
+ customerId,
74
+ loginCustomerId: data.login_customer_id || null,
75
+ developerToken,
76
+ tokenExpiresAt: null
77
+ };
78
+ }
79
+ throw new Error("Google Ads token expired. Reconnect in Settings → Google Ads.");
80
+ }
81
+ if (status !== "connected") {
82
+ throw new Error(`Google Ads integration status is '${status}'. Reconnect in Settings → Google Ads.`);
83
+ }
84
+
85
+ // Check token expiry and auto-refresh if needed
86
+ let finalToken = accessToken;
87
+ if (data.token_expires_at) {
88
+ const expiresAt = new Date(data.token_expires_at);
89
+ const bufferMs = 5 * 60 * 1000; // refresh 5 min before expiry
90
+ if (expiresAt.getTime() - Date.now() < bufferMs && refreshToken && clientId && clientSecret) {
91
+ finalToken = await refreshAccessToken(sb, storeId, refreshToken, clientId, clientSecret);
92
+ }
93
+ }
94
+ return {
95
+ accessToken: finalToken,
96
+ refreshToken,
97
+ clientId,
98
+ clientSecret,
99
+ customerId,
100
+ loginCustomerId: data.login_customer_id || null,
101
+ developerToken,
102
+ tokenExpiresAt: data.token_expires_at || null
103
+ };
104
+ }
105
+
106
+ /** Fallback: direct table read (used if get_google_config RPC not deployed) */
107
+ async function getIntegrationDirect(sb, storeId) {
108
+ const {
109
+ data,
110
+ error
111
+ } = await sb.from("google_integrations").select("client_id, client_secret_encrypted, developer_token_encrypted, customer_id, login_customer_id, access_token_encrypted, refresh_token_encrypted, token_expires_at, status, measurement_id, ga4_api_secret_encrypted").eq("store_id", storeId).limit(1).single();
112
+ if (error || !data) return null;
113
+ return {
114
+ client_id: data.client_id,
115
+ client_secret: data.client_secret_encrypted,
116
+ developer_token: data.developer_token_encrypted,
117
+ customer_id: data.customer_id,
118
+ login_customer_id: data.login_customer_id,
119
+ access_token: data.access_token_encrypted,
120
+ refresh_token: data.refresh_token_encrypted,
121
+ token_expires_at: data.token_expires_at,
122
+ status: data.status
123
+ };
124
+ }
125
+ async function refreshAccessToken(sb, storeId, refreshToken, clientId, clientSecret) {
126
+ log.info({
127
+ storeId
128
+ }, "refreshing Google Ads access token");
129
+ const res = await fetch(GOOGLE_TOKEN_URL, {
130
+ method: "POST",
131
+ headers: {
132
+ "Content-Type": "application/x-www-form-urlencoded"
133
+ },
134
+ body: new URLSearchParams({
135
+ client_id: clientId,
136
+ client_secret: clientSecret,
137
+ refresh_token: refreshToken,
138
+ grant_type: "refresh_token"
139
+ })
140
+ });
141
+ if (!res.ok) {
142
+ const errText = await res.text();
143
+ log.error({
144
+ storeId,
145
+ status: res.status,
146
+ err: errText
147
+ }, "token refresh failed");
148
+
149
+ // Mark integration as token_expired so UI can prompt reconnect
150
+ await sb.from("google_integrations").update({
151
+ status: "token_expired",
152
+ error: `Token refresh failed (${res.status}): ${errText.slice(0, 200)}`,
153
+ updated_at: new Date().toISOString()
154
+ }).eq("store_id", storeId);
155
+ throw new Error(`Token refresh failed (${res.status}). Reconnect in Settings → Google Ads.`);
156
+ }
157
+ const tokenData = await res.json();
158
+ const newToken = tokenData.access_token;
159
+ const expiresIn = tokenData.expires_in ?? 3600;
160
+ const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();
161
+
162
+ // Persist the new token and ensure status is connected
163
+ await sb.from("google_integrations").update({
164
+ access_token_encrypted: newToken,
165
+ token_expires_at: expiresAt,
166
+ status: "connected",
167
+ error: null,
168
+ updated_at: new Date().toISOString()
169
+ }).eq("store_id", storeId);
170
+ log.info({
171
+ storeId,
172
+ expiresAt
173
+ }, "Google Ads token refreshed");
174
+ return newToken;
175
+ }
176
+
177
+ // ============================================================================
178
+ // RATE LIMITING — 10,000 mutations/day (Google Basic Access)
179
+ // ============================================================================
180
+
181
+ async function checkAndIncrementMutationQuota(sb, storeId, operationCount, operation) {
182
+ const today = new Date().toISOString().slice(0, 10);
183
+
184
+ // Upsert today's counter
185
+ const {
186
+ data: usage
187
+ } = await sb.from("google_ads_daily_usage").select("mutation_count").eq("store_id", storeId).eq("usage_date", today).maybeSingle();
188
+ const currentCount = usage?.mutation_count ?? 0;
189
+ if (currentCount + operationCount > DAILY_MUTATION_LIMIT) {
190
+ throw new Error(`Google Ads daily mutation limit reached (${currentCount}/${DAILY_MUTATION_LIMIT}). Resets at midnight UTC.`);
191
+ }
192
+ await sb.from("google_ads_daily_usage").upsert({
193
+ store_id: storeId,
194
+ usage_date: today,
195
+ mutation_count: currentCount + operationCount,
196
+ last_operation: operation,
197
+ updated_at: new Date().toISOString()
198
+ }, {
199
+ onConflict: "store_id,usage_date"
200
+ });
201
+ }
202
+
203
+ // ============================================================================
204
+ // GOOGLE ADS API HELPERS
205
+ // ============================================================================
206
+
207
+ function authHeaders(int) {
208
+ const h = {
209
+ "Authorization": `Bearer ${int.accessToken}`,
210
+ "developer-token": int.developerToken,
211
+ "Content-Type": "application/json"
212
+ };
213
+ if (int.loginCustomerId) {
214
+ h["login-customer-id"] = int.loginCustomerId;
215
+ }
216
+ return h;
217
+ }
218
+ function parseGoogleError(body, status) {
219
+ try {
220
+ const parsed = JSON.parse(body);
221
+ const err = parsed.error;
222
+ if (err?.details?.length) {
223
+ const detail = err.details[0];
224
+ if (detail.errors?.length) {
225
+ return detail.errors.map(e => `${e.errorCode ? JSON.stringify(e.errorCode) : ""}: ${e.message}`).join("; ");
226
+ }
227
+ }
228
+ return err?.message || body.slice(0, 500);
229
+ } catch {
230
+ return body.slice(0, 500);
231
+ }
232
+ }
233
+
234
+ /** Execute a GAQL search query. */
235
+ async function gaqlSearch(int, query, customerId) {
236
+ const cid = customerId || int.customerId;
237
+ const res = await fetch(`${GOOGLE_ADS_BASE}/customers/${cid}/googleAds:search`, {
238
+ method: "POST",
239
+ headers: authHeaders(int),
240
+ body: JSON.stringify({
241
+ query
242
+ })
243
+ });
244
+ if (!res.ok) {
245
+ const errBody = await res.text();
246
+ throw new Error(`GAQL query failed (${res.status}): ${parseGoogleError(errBody, res.status)}`);
247
+ }
248
+ const json = await res.json();
249
+ return json.results || [];
250
+ }
251
+
252
+ /** Execute a Google Ads API mutate operation with rate limiting and telemetry. */
253
+ async function mutate(int, resourcePath, operations, sb, storeId) {
254
+ // Rate limit check (if sb/storeId available)
255
+ if (sb && storeId) {
256
+ await checkAndIncrementMutationQuota(sb, storeId, operations.length, `mutate:${resourcePath}`);
257
+ }
258
+ const cid = int.customerId;
259
+ const startMs = Date.now();
260
+ const res = await fetch(`${GOOGLE_ADS_BASE}/customers/${cid}/${resourcePath}:mutate`, {
261
+ method: "POST",
262
+ headers: authHeaders(int),
263
+ body: JSON.stringify({
264
+ operations
265
+ })
266
+ });
267
+ const durationMs = Date.now() - startMs;
268
+ if (!res.ok) {
269
+ const errBody = await res.text();
270
+ const errMsg = parseGoogleError(errBody, res.status);
271
+ log.error({
272
+ resourcePath,
273
+ status: res.status,
274
+ durationMs,
275
+ storeId,
276
+ err: errMsg
277
+ }, "Google Ads mutate failed");
278
+ throw new Error(`Mutate ${resourcePath} failed (${res.status}): ${errMsg}`);
279
+ }
280
+ log.info({
281
+ resourcePath,
282
+ ops: operations.length,
283
+ durationMs,
284
+ storeId
285
+ }, "Google Ads mutate succeeded");
286
+ return await res.json();
287
+ }
288
+
289
+ /** Unified batch mutate — creates multiple resource types atomically with temporary IDs. */
290
+ async function batchMutate(int, mutateOperations, sb, storeId) {
291
+ if (sb && storeId) {
292
+ await checkAndIncrementMutationQuota(sb, storeId, mutateOperations.length, "batchMutate");
293
+ }
294
+ const cid = int.customerId;
295
+ const startMs = Date.now();
296
+ const res = await fetch(`${GOOGLE_ADS_BASE}/customers/${cid}/googleAds:mutate`, {
297
+ method: "POST",
298
+ headers: authHeaders(int),
299
+ body: JSON.stringify({
300
+ mutateOperations
301
+ })
302
+ });
303
+ const durationMs = Date.now() - startMs;
304
+ if (!res.ok) {
305
+ const errBody = await res.text();
306
+ const errMsg = parseGoogleError(errBody, res.status);
307
+ log.error({
308
+ status: res.status,
309
+ durationMs,
310
+ storeId,
311
+ err: errMsg
312
+ }, "Google Ads batch mutate failed");
313
+ throw new Error(`Batch mutate failed (${res.status}): ${errMsg}`);
314
+ }
315
+ log.info({
316
+ ops: mutateOperations.length,
317
+ durationMs,
318
+ storeId
319
+ }, "Google Ads batch mutate succeeded");
320
+ return await res.json();
321
+ }
322
+
323
+ /** Download image from URL and return base64 data. Returns null if download fails or exceeds maxBytes. */
324
+ async function downloadAndEncodeImage(url, maxBytes = 5_000_000) {
325
+ try {
326
+ const res = await fetch(url);
327
+ if (!res.ok) return null;
328
+ const buf = Buffer.from(await res.arrayBuffer());
329
+ if (buf.length > maxBytes) {
330
+ log.warn({
331
+ url: url.split("/").pop(),
332
+ size: buf.length
333
+ }, "Image exceeds size limit, skipping");
334
+ return null;
335
+ }
336
+ return buf.toString("base64");
337
+ } catch (err) {
338
+ log.warn({
339
+ url: url.split("/").pop(),
340
+ err
341
+ }, "Failed to download image");
342
+ return null;
343
+ }
344
+ }
345
+
346
+ /** Get a resource by resource name. */
347
+ async function getResource(int, resourceName) {
348
+ const res = await fetch(`${GOOGLE_ADS_BASE}/${resourceName}`, {
349
+ method: "GET",
350
+ headers: authHeaders(int)
351
+ });
352
+ if (!res.ok) {
353
+ const errBody = await res.text();
354
+ throw new Error(`GET ${resourceName} failed (${res.status}): ${parseGoogleError(errBody, res.status)}`);
355
+ }
356
+ return await res.json();
357
+ }
358
+
359
+ // ============================================================================
360
+ // ID HELPERS
361
+ // ============================================================================
362
+
363
+ /** Local draft IDs contain non-numeric chars. Google IDs are purely numeric. */
364
+ function isLocalId(id) {
365
+ if (!id) return true;
366
+ return /[^0-9]/.test(id);
367
+ }
368
+
369
+ /** Convert budget in dollars to micros (Google uses micros). */
370
+ function toMicros(dollars) {
371
+ return Math.round(dollars * 1_000_000);
372
+ }
373
+
374
+ /** Convert micros to dollars. */
375
+ function fromMicros(micros) {
376
+ return micros / 1_000_000;
377
+ }
378
+
379
+ // ============================================================================
380
+ // PUBLISH HELPERS
381
+ // ============================================================================
382
+
383
+ async function publishCampaignToGoogle(sb, int, storeId, campaignId) {
384
+ // Fetch the draft campaign
385
+ const {
386
+ data: campaign,
387
+ error
388
+ } = await sb.from("google_campaigns").select("*").eq("id", campaignId).eq("store_id", storeId).single();
389
+ if (error || !campaign) throw new Error(`Campaign ${campaignId} not found`);
390
+
391
+ // Performance Max uses a dedicated publish path (asset groups, not ad groups)
392
+ if (campaign.campaign_type === "PERFORMANCE_MAX") {
393
+ const pmaxResult = await publishPMaxCampaign(sb, int, storeId, campaignId);
394
+ return {
395
+ googleCampaignId: pmaxResult.googleCampaignId,
396
+ resourceName: pmaxResult.resourceName
397
+ };
398
+ }
399
+
400
+ // Already published?
401
+ if (campaign.google_campaign_id && !isLocalId(campaign.google_campaign_id)) {
402
+ return {
403
+ googleCampaignId: campaign.google_campaign_id,
404
+ resourceName: `customers/${int.customerId}/campaigns/${campaign.google_campaign_id}`
405
+ };
406
+ }
407
+
408
+ // Create budget first
409
+ const budgetOps = [{
410
+ create: {
411
+ name: `${campaign.name} Budget`,
412
+ amountMicros: toMicros(campaign.daily_budget || 10).toString(),
413
+ deliveryMethod: "STANDARD"
414
+ }
415
+ }];
416
+ const budgetResult = await mutate(int, "campaignBudgets", budgetOps, sb, storeId);
417
+ const budgetResourceName = budgetResult.results ? budgetResult.results[0]?.resourceName : null;
418
+ if (!budgetResourceName) throw new Error("Failed to create campaign budget");
419
+
420
+ // Build campaign operation
421
+ const campaignOp = {
422
+ create: {
423
+ name: campaign.name,
424
+ advertisingChannelType: campaign.campaign_type || "SEARCH",
425
+ status: "PAUSED",
426
+ campaignBudget: budgetResourceName
427
+ }
428
+ };
429
+
430
+ // EU political advertising compliance (required in v23+)
431
+ campaignOp.create.containsEuPoliticalAdvertising = "DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING";
432
+
433
+ // Bidding strategy (v23: maximizeClicks → targetSpend)
434
+ const bidding = campaign.bidding_strategy || "MAXIMIZE_CLICKS";
435
+ switch (bidding) {
436
+ case "MAXIMIZE_CLICKS":
437
+ campaignOp.create.targetSpend = {};
438
+ break;
439
+ case "MAXIMIZE_CONVERSIONS":
440
+ campaignOp.create.maximizeConversions = {};
441
+ break;
442
+ case "MAXIMIZE_CONVERSION_VALUE":
443
+ campaignOp.create.maximizeConversionValue = {};
444
+ break;
445
+ case "TARGET_CPA":
446
+ campaignOp.create.targetCpa = {
447
+ targetCpaMicros: toMicros(campaign.bidding_target || 10).toString()
448
+ };
449
+ break;
450
+ case "TARGET_ROAS":
451
+ campaignOp.create.targetRoas = {
452
+ targetRoas: campaign.bidding_target || 1.0
453
+ };
454
+ break;
455
+ case "MANUAL_CPC":
456
+ campaignOp.create.manualCpc = {};
457
+ break;
458
+ default:
459
+ campaignOp.create.targetSpend = {};
460
+ }
461
+
462
+ // Start/end dates
463
+ if (campaign.start_date) {
464
+ campaignOp.create.startDate = campaign.start_date.slice(0, 10).replace(/-/g, "-");
465
+ }
466
+ if (campaign.end_date) {
467
+ campaignOp.create.endDate = campaign.end_date.slice(0, 10).replace(/-/g, "-");
468
+ }
469
+ const result = await mutate(int, "campaigns", [campaignOp], sb, storeId);
470
+ const results = result.results;
471
+ const resourceName = results?.[0]?.resourceName;
472
+ if (!resourceName) throw new Error("Campaign creation returned no resource name");
473
+ const googleCampaignId = resourceName.split("/").pop();
474
+
475
+ // Update local record
476
+ await sb.from("google_campaigns").update({
477
+ google_campaign_id: googleCampaignId,
478
+ google_customer_id: int.customerId,
479
+ google_status: "PAUSED",
480
+ status: "PUBLISHED",
481
+ updated_at: new Date().toISOString()
482
+ }).eq("id", campaignId);
483
+ return {
484
+ googleCampaignId,
485
+ resourceName
486
+ };
487
+ }
488
+ async function publishAdGroupToGoogle(sb, int, storeId, adGroupId) {
489
+ const {
490
+ data: adGroup,
491
+ error
492
+ } = await sb.from("google_ad_groups").select("*").eq("id", adGroupId).eq("store_id", storeId).single();
493
+ if (error || !adGroup) throw new Error(`Ad group ${adGroupId} not found`);
494
+ if (adGroup.google_ad_group_id && !isLocalId(adGroup.google_ad_group_id)) {
495
+ return {
496
+ googleAdGroupId: adGroup.google_ad_group_id,
497
+ resourceName: `customers/${int.customerId}/adGroups/${adGroup.google_ad_group_id}`
498
+ };
499
+ }
500
+
501
+ // Auto-publish parent campaign if needed
502
+ const {
503
+ googleCampaignId,
504
+ resourceName: campaignResource
505
+ } = await publishCampaignToGoogle(sb, int, storeId, adGroup.google_campaign_id);
506
+ const adGroupOp = {
507
+ create: {
508
+ name: adGroup.name,
509
+ campaign: campaignResource,
510
+ type: adGroup.ad_group_type || "SEARCH_STANDARD",
511
+ status: "ENABLED",
512
+ ...(adGroup.cpc_bid ? {
513
+ cpcBidMicros: toMicros(adGroup.cpc_bid).toString()
514
+ } : {})
515
+ }
516
+ };
517
+ const result = await mutate(int, "adGroups", [adGroupOp], sb, storeId);
518
+ const results = result.results;
519
+ const resourceName = results?.[0]?.resourceName;
520
+ if (!resourceName) throw new Error("Ad group creation returned no resource name");
521
+ const googleAdGroupId = resourceName.split("/").pop();
522
+ await sb.from("google_ad_groups").update({
523
+ google_ad_group_id: googleAdGroupId,
524
+ google_status: "ENABLED",
525
+ status: "PUBLISHED",
526
+ updated_at: new Date().toISOString()
527
+ }).eq("id", adGroupId);
528
+ return {
529
+ googleAdGroupId,
530
+ resourceName
531
+ };
532
+ }
533
+ async function publishAdToGoogle(sb, int, storeId, adId) {
534
+ const {
535
+ data: ad,
536
+ error
537
+ } = await sb.from("google_ads").select("*").eq("id", adId).eq("store_id", storeId).single();
538
+ if (error || !ad) throw new Error(`Ad ${adId} not found`);
539
+ if (ad.google_ad_id && !isLocalId(ad.google_ad_id)) {
540
+ return {
541
+ googleAdId: ad.google_ad_id
542
+ };
543
+ }
544
+
545
+ // Auto-publish parent ad group
546
+ const {
547
+ resourceName: adGroupResource
548
+ } = await publishAdGroupToGoogle(sb, int, storeId, ad.ad_group_id);
549
+
550
+ // Build ad group ad operation based on ad type
551
+ const adOp = {
552
+ create: {
553
+ adGroup: adGroupResource,
554
+ status: "PAUSED",
555
+ ad: buildAdPayload(ad)
556
+ }
557
+ };
558
+ const result = await mutate(int, "adGroupAds", [adOp], sb, storeId);
559
+ const results = result.results;
560
+ const resourceName = results?.[0]?.resourceName;
561
+ if (!resourceName) throw new Error("Ad creation returned no resource name");
562
+
563
+ // Resource name format: customers/{cid}/adGroupAds/{agid}~{adId}
564
+ const googleAdId = resourceName.split("~").pop() || resourceName.split("/").pop();
565
+ await sb.from("google_ads").update({
566
+ google_ad_id: googleAdId,
567
+ google_status: "PAUSED",
568
+ status: "PUBLISHED",
569
+ updated_at: new Date().toISOString()
570
+ }).eq("id", adId);
571
+ return {
572
+ googleAdId
573
+ };
574
+ }
575
+ function buildAdPayload(ad) {
576
+ const adType = ad.ad_type;
577
+ if (adType === "RESPONSIVE_SEARCH_AD") {
578
+ const headlines = (ad.headlines || []).map((text, i) => ({
579
+ text,
580
+ pinnedField: i < 3 ? undefined : undefined // no pinning by default
581
+ }));
582
+ const descriptions = (ad.descriptions || []).map(text => ({
583
+ text
584
+ }));
585
+ return {
586
+ responsiveSearchAd: {
587
+ headlines,
588
+ descriptions
589
+ },
590
+ finalUrls: ad.final_urls || [],
591
+ ...(ad.path1 ? {
592
+ path1: ad.path1
593
+ } : {}),
594
+ ...(ad.path2 ? {
595
+ path2: ad.path2
596
+ } : {})
597
+ };
598
+ }
599
+ if (adType === "RESPONSIVE_DISPLAY_AD") {
600
+ return {
601
+ responsiveDisplayAd: {
602
+ headlines: (ad.headlines || []).map(text => ({
603
+ text
604
+ })),
605
+ longHeadline: {
606
+ text: ad.headlines?.[0] || ""
607
+ },
608
+ descriptions: (ad.descriptions || []).map(text => ({
609
+ text
610
+ })),
611
+ businessName: ad.creative?.business_name || ""
612
+ },
613
+ finalUrls: ad.final_urls || []
614
+ };
615
+ }
616
+
617
+ // Default: responsive search ad
618
+ return {
619
+ responsiveSearchAd: {
620
+ headlines: (ad.headlines || []).map(text => ({
621
+ text
622
+ })),
623
+ descriptions: (ad.descriptions || []).map(text => ({
624
+ text
625
+ }))
626
+ },
627
+ finalUrls: ad.final_urls || []
628
+ };
629
+ }
630
+
631
+ // ============================================================================
632
+ // PERFORMANCE MAX PUBLISH
633
+ // ============================================================================
634
+
635
+ async function publishPMaxCampaign(sb, int, storeId, campaignId) {
636
+ const {
637
+ data: campaign
638
+ } = await sb.from("google_campaigns").select("*").eq("id", campaignId).eq("store_id", storeId).single();
639
+ if (!campaign) throw new Error(`Campaign ${campaignId} not found`);
640
+ if (campaign.google_campaign_id && !isLocalId(campaign.google_campaign_id)) {
641
+ return {
642
+ googleCampaignId: campaign.google_campaign_id,
643
+ resourceName: `customers/${int.customerId}/campaigns/${campaign.google_campaign_id}`,
644
+ assetGroupId: "",
645
+ imageAssets: 0
646
+ };
647
+ }
648
+
649
+ // Get the ad row (holds headlines, descriptions, creative with image URLs)
650
+ const {
651
+ data: adGroups
652
+ } = await sb.from("google_ad_groups").select("id").eq("google_campaign_id", campaignId).eq("store_id", storeId);
653
+ let ad = null;
654
+ if (adGroups?.length) {
655
+ const {
656
+ data
657
+ } = await sb.from("google_ads").select("*").eq("ad_group_id", adGroups[0].id).eq("store_id", storeId).limit(1);
658
+ ad = data?.[0] || null;
659
+ }
660
+ const creative = ad?.creative || {};
661
+ const headlines = ad?.headlines || [];
662
+ const descriptions = ad?.descriptions || [];
663
+ const finalUrls = ad?.final_urls || [];
664
+ const longHeadlines = creative.long_headlines || [];
665
+ const businessName = (creative.business_name || campaign.name).slice(0, 25);
666
+ const logoUrls = creative.logo_urls || [];
667
+ const marketingImages = creative.marketing_images || [];
668
+ const squareImages = creative.square_marketing_images || [];
669
+ if (!finalUrls.length) throw new Error("PMax requires at least one final URL");
670
+ if (headlines.length < 3) throw new Error("PMax requires at least 3 headlines");
671
+ if (descriptions.length < 2) throw new Error("PMax requires at least 2 descriptions");
672
+ const cid = int.customerId;
673
+ let nextTempId = -1;
674
+ const getTempId = () => nextTempId--;
675
+
676
+ // ---- Download images first (they must all be in the atomic batch) ----
677
+ // Use Supabase image transforms for correct aspect ratios when URLs are from Supabase Storage
678
+ const toTransformUrl = (url, w, h) => {
679
+ // Convert /storage/v1/object/public/ → /storage/v1/render/image/public/ with resize params
680
+ if (url.includes("supabase.co/storage/v1/object/public/")) {
681
+ return url.replace("/storage/v1/object/public/", `/storage/v1/render/image/public/`) + `?width=${w}&height=${h}&resize=cover`;
682
+ }
683
+ return url; // External URLs used as-is
684
+ };
685
+ log.info({
686
+ logos: logoUrls.length,
687
+ landscape: marketingImages.length,
688
+ square: squareImages.length
689
+ }, "Downloading PMax image assets");
690
+
691
+ // Download logo (1:1, 1200x1200)
692
+ let logoB64 = null;
693
+ for (const url of logoUrls.slice(0, 2)) {
694
+ logoB64 = await downloadAndEncodeImage(toTransformUrl(url, 1200, 1200));
695
+ if (logoB64) break;
696
+ }
697
+
698
+ // Download landscape images (1.91:1, 1200x628)
699
+ const landscapeB64 = [];
700
+ for (const url of marketingImages.slice(0, 5)) {
701
+ const b64 = await downloadAndEncodeImage(toTransformUrl(url, 1200, 628));
702
+ if (b64) landscapeB64.push(b64);
703
+ }
704
+
705
+ // Download square images (1:1, 1200x1200)
706
+ const squareB64 = [];
707
+ for (const url of squareImages.slice(0, 5)) {
708
+ const b64 = await downloadAndEncodeImage(toTransformUrl(url, 1200, 1200));
709
+ if (b64) squareB64.push(b64);
710
+ }
711
+
712
+ // ---- Build ONE atomic batch (unified googleAds:mutate uses snake_case) ----
713
+ const ops = [];
714
+ const agAssetLinks = [];
715
+ const budgetTempId = getTempId();
716
+ const campaignTempId = getTempId();
717
+ const assetGroupTempId = getTempId();
718
+
719
+ // Budget (PMax requires explicitly_shared: false)
720
+ ops.push({
721
+ campaignBudgetOperation: {
722
+ create: {
723
+ resource_name: `customers/${cid}/campaignBudgets/${budgetTempId}`,
724
+ name: `${campaign.name} Budget`,
725
+ amount_micros: toMicros(campaign.daily_budget || 10).toString(),
726
+ delivery_method: "STANDARD",
727
+ explicitly_shared: false
728
+ }
729
+ }
730
+ });
731
+
732
+ // Campaign (note: start_date/end_date not supported on create via unified endpoint)
733
+ const campaignCreate = {
734
+ resource_name: `customers/${cid}/campaigns/${campaignTempId}`,
735
+ name: campaign.name,
736
+ advertising_channel_type: "PERFORMANCE_MAX",
737
+ status: "PAUSED",
738
+ campaign_budget: `customers/${cid}/campaignBudgets/${budgetTempId}`,
739
+ contains_eu_political_advertising: "DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING"
740
+ };
741
+ const bidding = campaign.bidding_strategy || "MAXIMIZE_CONVERSIONS";
742
+ switch (bidding) {
743
+ case "MAXIMIZE_CONVERSIONS":
744
+ campaignCreate.maximize_conversions = {};
745
+ break;
746
+ case "MAXIMIZE_CONVERSION_VALUE":
747
+ campaignCreate.maximize_conversion_value = {};
748
+ break;
749
+ case "TARGET_CPA":
750
+ campaignCreate.target_cpa = {
751
+ target_cpa_micros: toMicros(campaign.bidding_target || 10).toString()
752
+ };
753
+ break;
754
+ case "TARGET_ROAS":
755
+ campaignCreate.target_roas = {
756
+ target_roas: campaign.bidding_target || 1.0
757
+ };
758
+ break;
759
+ default:
760
+ campaignCreate.maximize_conversions = {};
761
+ }
762
+ ops.push({
763
+ campaignOperation: {
764
+ create: campaignCreate
765
+ }
766
+ });
767
+
768
+ // Business Name → CampaignAsset ONLY (Brand Guidelines requirement)
769
+ const bnTempId = getTempId();
770
+ ops.push({
771
+ assetOperation: {
772
+ create: {
773
+ resource_name: `customers/${cid}/assets/${bnTempId}`,
774
+ type: "TEXT",
775
+ text_asset: {
776
+ text: businessName
777
+ },
778
+ name: `Business Name: ${businessName}`
779
+ }
780
+ }
781
+ });
782
+ ops.push({
783
+ campaignAssetOperation: {
784
+ create: {
785
+ campaign: `customers/${cid}/campaigns/${campaignTempId}`,
786
+ asset: `customers/${cid}/assets/${bnTempId}`,
787
+ field_type: "BUSINESS_NAME"
788
+ }
789
+ }
790
+ });
791
+
792
+ // Logo → CampaignAsset ONLY (Brand Guidelines: logo must be campaign-level, NOT asset group)
793
+ if (logoB64) {
794
+ const logoTempId = getTempId();
795
+ ops.push({
796
+ assetOperation: {
797
+ create: {
798
+ resource_name: `customers/${cid}/assets/${logoTempId}`,
799
+ type: "IMAGE",
800
+ image_asset: {
801
+ data: logoB64
802
+ },
803
+ name: "Logo"
804
+ }
805
+ }
806
+ });
807
+ ops.push({
808
+ campaignAssetOperation: {
809
+ create: {
810
+ campaign: `customers/${cid}/campaigns/${campaignTempId}`,
811
+ asset: `customers/${cid}/assets/${logoTempId}`,
812
+ field_type: "LOGO"
813
+ }
814
+ }
815
+ });
816
+ }
817
+
818
+ // Headlines → AssetGroupAsset
819
+ for (const h of headlines.slice(0, 15)) {
820
+ const id = getTempId();
821
+ ops.push({
822
+ assetOperation: {
823
+ create: {
824
+ resource_name: `customers/${cid}/assets/${id}`,
825
+ type: "TEXT",
826
+ text_asset: {
827
+ text: h
828
+ },
829
+ name: `Headline: ${h.slice(0, 50)}`
830
+ }
831
+ }
832
+ });
833
+ agAssetLinks.push({
834
+ tempId: id,
835
+ fieldType: "HEADLINE"
836
+ });
837
+ }
838
+
839
+ // Descriptions → AssetGroupAsset
840
+ for (const d of descriptions.slice(0, 5)) {
841
+ const id = getTempId();
842
+ ops.push({
843
+ assetOperation: {
844
+ create: {
845
+ resource_name: `customers/${cid}/assets/${id}`,
846
+ type: "TEXT",
847
+ text_asset: {
848
+ text: d
849
+ },
850
+ name: `Desc: ${d.slice(0, 50)}`
851
+ }
852
+ }
853
+ });
854
+ agAssetLinks.push({
855
+ tempId: id,
856
+ fieldType: "DESCRIPTION"
857
+ });
858
+ }
859
+
860
+ // Long headlines → AssetGroupAsset
861
+ for (const lh of longHeadlines.slice(0, 5)) {
862
+ const id = getTempId();
863
+ ops.push({
864
+ assetOperation: {
865
+ create: {
866
+ resource_name: `customers/${cid}/assets/${id}`,
867
+ type: "TEXT",
868
+ text_asset: {
869
+ text: lh
870
+ },
871
+ name: `Long: ${lh.slice(0, 50)}`
872
+ }
873
+ }
874
+ });
875
+ agAssetLinks.push({
876
+ tempId: id,
877
+ fieldType: "LONG_HEADLINE"
878
+ });
879
+ }
880
+
881
+ // Landscape images → AssetGroupAsset
882
+ for (const b64 of landscapeB64) {
883
+ const id = getTempId();
884
+ ops.push({
885
+ assetOperation: {
886
+ create: {
887
+ resource_name: `customers/${cid}/assets/${id}`,
888
+ type: "IMAGE",
889
+ image_asset: {
890
+ data: b64
891
+ },
892
+ name: "Landscape"
893
+ }
894
+ }
895
+ });
896
+ agAssetLinks.push({
897
+ tempId: id,
898
+ fieldType: "MARKETING_IMAGE"
899
+ });
900
+ }
901
+
902
+ // Square images → AssetGroupAsset
903
+ for (const b64 of squareB64) {
904
+ const id = getTempId();
905
+ ops.push({
906
+ assetOperation: {
907
+ create: {
908
+ resource_name: `customers/${cid}/assets/${id}`,
909
+ type: "IMAGE",
910
+ image_asset: {
911
+ data: b64
912
+ },
913
+ name: "Square"
914
+ }
915
+ }
916
+ });
917
+ agAssetLinks.push({
918
+ tempId: id,
919
+ fieldType: "SQUARE_MARKETING_IMAGE"
920
+ });
921
+ }
922
+
923
+ // Asset Group
924
+ ops.push({
925
+ assetGroupOperation: {
926
+ create: {
927
+ resource_name: `customers/${cid}/assetGroups/${assetGroupTempId}`,
928
+ campaign: `customers/${cid}/campaigns/${campaignTempId}`,
929
+ name: `${campaign.name} — Asset Group`,
930
+ final_urls: finalUrls,
931
+ status: "ENABLED"
932
+ }
933
+ }
934
+ });
935
+
936
+ // Link all assets to asset group (text + images, NOT business name/logo)
937
+ for (const link of agAssetLinks) {
938
+ ops.push({
939
+ assetGroupAssetOperation: {
940
+ create: {
941
+ asset_group: `customers/${cid}/assetGroups/${assetGroupTempId}`,
942
+ asset: `customers/${cid}/assets/${link.tempId}`,
943
+ field_type: link.fieldType
944
+ }
945
+ }
946
+ });
947
+ }
948
+ const imageAssetsCreated = landscapeB64.length + squareB64.length + (logoB64 ? 1 : 0);
949
+ log.info({
950
+ ops: ops.length,
951
+ images: imageAssetsCreated,
952
+ campaignName: campaign.name
953
+ }, "Publishing PMax (atomic batch)");
954
+ const result = await batchMutate(int, ops, sb, storeId);
955
+
956
+ // Extract real resource names from response (supports both camelCase and snake_case)
957
+ const responses = result.mutateOperationResponses || result.mutate_operation_responses || [];
958
+ let campaignResourceName = "";
959
+ let googleCampaignId = "";
960
+ let assetGroupResourceName = "";
961
+ for (const r of responses) {
962
+ for (const key of ["campaignResult", "campaign_result"]) {
963
+ const cr = r[key];
964
+ const rn = cr?.resourceName || cr?.resource_name;
965
+ if (rn?.includes("/campaigns/")) {
966
+ campaignResourceName = rn;
967
+ googleCampaignId = rn.split("/").pop();
968
+ }
969
+ }
970
+ for (const key of ["assetGroupResult", "asset_group_result"]) {
971
+ const agr = r[key];
972
+ const rn = agr?.resourceName || agr?.resource_name;
973
+ if (rn?.includes("/assetGroups/")) {
974
+ assetGroupResourceName = rn;
975
+ }
976
+ }
977
+ }
978
+ if (!googleCampaignId) throw new Error("PMax batch mutate returned no campaign resource name");
979
+ log.info({
980
+ googleCampaignId,
981
+ imageAssetsCreated,
982
+ assetGroupResourceName
983
+ }, "PMax campaign published");
984
+
985
+ // Update local records
986
+ await sb.from("google_campaigns").update({
987
+ google_campaign_id: googleCampaignId,
988
+ google_customer_id: int.customerId,
989
+ google_status: "PAUSED",
990
+ status: "PUBLISHED",
991
+ updated_at: new Date().toISOString()
992
+ }).eq("id", campaignId);
993
+ if (adGroups?.length) {
994
+ for (const ag of adGroups) {
995
+ await sb.from("google_ad_groups").update({
996
+ google_status: "PUBLISHED",
997
+ status: "PUBLISHED",
998
+ updated_at: new Date().toISOString()
999
+ }).eq("id", ag.id);
1000
+ }
1001
+ }
1002
+ return {
1003
+ googleCampaignId,
1004
+ resourceName: campaignResourceName,
1005
+ assetGroupId: assetGroupResourceName,
1006
+ imageAssets: imageAssetsCreated
1007
+ };
1008
+ }
1009
+ async function publishKeywordsToGoogle(sb, int, storeId, adGroupId) {
1010
+ const {
1011
+ data: keywords
1012
+ } = await sb.from("google_keywords").select("*").eq("ad_group_id", adGroupId).eq("store_id", storeId).is("google_criterion_id", null);
1013
+ if (!keywords?.length) return {
1014
+ published: 0
1015
+ };
1016
+
1017
+ // Get the google ad group resource
1018
+ const {
1019
+ data: adGroup
1020
+ } = await sb.from("google_ad_groups").select("google_ad_group_id").eq("id", adGroupId).single();
1021
+ if (!adGroup?.google_ad_group_id || isLocalId(adGroup.google_ad_group_id)) {
1022
+ throw new Error("Ad group must be published before keywords");
1023
+ }
1024
+ const adGroupResource = `customers/${int.customerId}/adGroups/${adGroup.google_ad_group_id}`;
1025
+ const operations = keywords.map(kw => ({
1026
+ create: {
1027
+ adGroup: adGroupResource,
1028
+ status: "ENABLED",
1029
+ keyword: {
1030
+ text: kw.text,
1031
+ matchType: kw.match_type || "BROAD"
1032
+ },
1033
+ ...(kw.cpc_bid ? {
1034
+ cpcBidMicros: toMicros(kw.cpc_bid).toString()
1035
+ } : {})
1036
+ }
1037
+ }));
1038
+ const result = await mutate(int, "adGroupCriteria", operations, sb, storeId);
1039
+ const results = result.results;
1040
+
1041
+ // Update local records with Google criterion IDs
1042
+ for (let i = 0; i < (results?.length || 0); i++) {
1043
+ const resourceName = results[i]?.resourceName;
1044
+ if (resourceName) {
1045
+ const criterionId = resourceName.split("~").pop();
1046
+ await sb.from("google_keywords").update({
1047
+ google_criterion_id: criterionId,
1048
+ google_status: "ENABLED",
1049
+ updated_at: new Date().toISOString()
1050
+ }).eq("id", keywords[i].id);
1051
+ }
1052
+ }
1053
+ return {
1054
+ published: results?.length || 0
1055
+ };
1056
+ }
1057
+
1058
+ /** Publish a local conversion action to Google Ads API. */
1059
+ async function publishConversionAction(sb, int, storeId, conversionActionId) {
1060
+ const {
1061
+ data: ca
1062
+ } = await sb.from("google_conversion_actions").select("*").eq("id", conversionActionId).eq("store_id", storeId).single();
1063
+ if (!ca) throw new Error("Conversion action not found");
1064
+ if (ca.google_conversion_id) {
1065
+ return {
1066
+ message: "Already published",
1067
+ google_conversion_id: ca.google_conversion_id
1068
+ };
1069
+ }
1070
+ const CATEGORY_MAP = {
1071
+ PURCHASE: "PURCHASE",
1072
+ LEAD: "SUBMIT_LEAD_FORM",
1073
+ SIGNUP: "SIGNUP",
1074
+ ADD_TO_CART: "ADD_TO_CART",
1075
+ PAGE_VIEW: "PAGE_VIEW",
1076
+ OTHER: "DEFAULT"
1077
+ };
1078
+ const result = await mutate(int, "conversionActions", [{
1079
+ create: {
1080
+ name: ca.name,
1081
+ category: CATEGORY_MAP[ca.category] || "DEFAULT",
1082
+ type: "WEBPAGE",
1083
+ status: "ENABLED",
1084
+ countingType: ca.counting_type || "ONE_PER_CLICK",
1085
+ attributionModelSettings: {
1086
+ attributionModel: ca.attribution_model || "GOOGLE_LAST_CLICK"
1087
+ },
1088
+ ...(ca.default_value ? {
1089
+ defaultValue: ca.default_value,
1090
+ alwaysUseDefaultValue: ca.always_use_default_value ?? false
1091
+ } : {})
1092
+ }
1093
+ }], sb, storeId);
1094
+ const results = result.results;
1095
+ const resourceName = results?.[0]?.resourceName;
1096
+ const googleId = resourceName?.split("/").pop();
1097
+ if (googleId) {
1098
+ // Fetch the tag snippet for the newly created conversion action
1099
+ let tagSnippet = null;
1100
+ try {
1101
+ const rows = await gaqlSearch(int, `SELECT conversion_action.tag_snippets FROM conversion_action WHERE conversion_action.resource_name = '${resourceName}'`);
1102
+ const snippets = rows[0]?.conversionAction?.tagSnippets;
1103
+ if (snippets?.length) tagSnippet = snippets[0].eventSnippet || null;
1104
+ } catch {/* non-fatal — snippet fetch can fail */}
1105
+ await sb.from("google_conversion_actions").update({
1106
+ google_conversion_id: googleId,
1107
+ google_tag_snippet: tagSnippet,
1108
+ status: "ENABLED",
1109
+ updated_at: new Date().toISOString()
1110
+ }).eq("id", conversionActionId);
1111
+ }
1112
+ return {
1113
+ google_conversion_id: googleId,
1114
+ resourceName
1115
+ };
1116
+ }
1117
+
1118
+ // ============================================================================
1119
+ // SYNC — pull metrics from Google Ads into local DB
1120
+ // ============================================================================
1121
+
1122
+ async function syncCampaignMetrics(sb, int, storeId, dateRange) {
1123
+ const VALID_DATE_RANGES = ["LAST_7_DAYS", "LAST_14_DAYS", "LAST_30_DAYS", "LAST_90_DAYS", "THIS_MONTH", "LAST_MONTH", "TODAY", "YESTERDAY"];
1124
+ const range = VALID_DATE_RANGES.includes(dateRange || "") ? dateRange : "LAST_30_DAYS";
1125
+ const query = `
1126
+ SELECT
1127
+ campaign.id,
1128
+ campaign.name,
1129
+ campaign.status,
1130
+ campaign.advertising_channel_type,
1131
+ campaign_budget.amount_micros,
1132
+ metrics.impressions,
1133
+ metrics.clicks,
1134
+ metrics.cost_micros,
1135
+ metrics.conversions,
1136
+ metrics.conversions_value,
1137
+ metrics.ctr,
1138
+ metrics.average_cpc
1139
+ FROM campaign
1140
+ WHERE segments.date DURING ${range}
1141
+ `;
1142
+ const rows = await gaqlSearch(int, query);
1143
+ let synced = 0;
1144
+ for (const row of rows) {
1145
+ const c = row.campaign;
1146
+ const m = row.metrics;
1147
+ const b = row.campaignBudget;
1148
+ const googleCampaignId = String(c.id);
1149
+ await sb.from("google_campaigns").upsert({
1150
+ store_id: storeId,
1151
+ google_campaign_id: googleCampaignId,
1152
+ google_customer_id: int.customerId,
1153
+ name: c.name,
1154
+ campaign_type: c.advertisingChannelType,
1155
+ google_status: c.status,
1156
+ daily_budget: b?.amountMicros ? fromMicros(Number(b.amountMicros)) : null,
1157
+ status: "SYNCED",
1158
+ updated_at: new Date().toISOString()
1159
+ }, {
1160
+ onConflict: "store_id,google_campaign_id",
1161
+ ignoreDuplicates: false
1162
+ });
1163
+ synced++;
1164
+ }
1165
+ return {
1166
+ synced
1167
+ };
1168
+ }
1169
+
1170
+ // ============================================================================
1171
+ // MAIN HANDLER
1172
+ // ============================================================================
1173
+
1174
+ export async function handleGoogleAds(sb, args, storeId) {
1175
+ if (!storeId) return {
1176
+ success: false,
1177
+ error: "store_id required"
1178
+ };
1179
+ const action = args.action;
1180
+ try {
1181
+ switch (action) {
1182
+ // ====================================================================
1183
+ // CAMPAIGNS
1184
+ // ====================================================================
1185
+
1186
+ case "create_campaign":
1187
+ {
1188
+ const name = args.name;
1189
+ if (!name) return {
1190
+ success: false,
1191
+ error: "name required"
1192
+ };
1193
+ const row = {
1194
+ store_id: storeId,
1195
+ name,
1196
+ campaign_type: args.campaign_type || "SEARCH",
1197
+ status: "DRAFT",
1198
+ daily_budget: args.daily_budget ? Number(args.daily_budget) : null,
1199
+ bidding_strategy: args.bidding_strategy || "MAXIMIZE_CLICKS",
1200
+ bidding_target: args.bidding_target ? Number(args.bidding_target) : null,
1201
+ start_date: args.start_date || null,
1202
+ end_date: args.end_date || null
1203
+ };
1204
+ if (args.campaign_id) row.campaign_id = args.campaign_id; // link to local campaigns table
1205
+
1206
+ const {
1207
+ data,
1208
+ error
1209
+ } = await sb.from("google_campaigns").insert(row).select().single();
1210
+ if (error) return {
1211
+ success: false,
1212
+ error: error.message
1213
+ };
1214
+ return {
1215
+ success: true,
1216
+ data
1217
+ };
1218
+ }
1219
+ case "list_campaigns":
1220
+ {
1221
+ const query = sb.from("google_campaigns").select("*").eq("store_id", storeId).order("created_at", {
1222
+ ascending: false
1223
+ });
1224
+ if (args.status) query.eq("status", args.status);
1225
+ if (args.limit) query.limit(Number(args.limit));
1226
+ const {
1227
+ data,
1228
+ error
1229
+ } = await query;
1230
+ if (error) return {
1231
+ success: false,
1232
+ error: error.message
1233
+ };
1234
+ return {
1235
+ success: true,
1236
+ data
1237
+ };
1238
+ }
1239
+ case "get_campaign":
1240
+ {
1241
+ const id = args.campaign_id || args.id;
1242
+ if (!id) return {
1243
+ success: false,
1244
+ error: "campaign_id required"
1245
+ };
1246
+ const {
1247
+ data,
1248
+ error
1249
+ } = await sb.from("google_campaigns").select("*").eq("id", id).eq("store_id", storeId).single();
1250
+ if (error) return {
1251
+ success: false,
1252
+ error: error.message
1253
+ };
1254
+ return {
1255
+ success: true,
1256
+ data
1257
+ };
1258
+ }
1259
+ case "update_campaign":
1260
+ {
1261
+ const id = args.campaign_id || args.id;
1262
+ if (!id) return {
1263
+ success: false,
1264
+ error: "campaign_id required"
1265
+ };
1266
+ const updates = {
1267
+ updated_at: new Date().toISOString()
1268
+ };
1269
+ if (args.name) updates.name = args.name;
1270
+ if (args.daily_budget) updates.daily_budget = Number(args.daily_budget);
1271
+ if (args.bidding_strategy) updates.bidding_strategy = args.bidding_strategy;
1272
+ if (args.bidding_target) updates.bidding_target = Number(args.bidding_target);
1273
+ if (args.status) updates.status = args.status;
1274
+ if (args.start_date) updates.start_date = args.start_date;
1275
+ if (args.end_date) updates.end_date = args.end_date;
1276
+ const {
1277
+ data,
1278
+ error
1279
+ } = await sb.from("google_campaigns").update(updates).eq("id", id).eq("store_id", storeId).select().single();
1280
+ if (error) return {
1281
+ success: false,
1282
+ error: error.message
1283
+ };
1284
+
1285
+ // If published campaign, also update on Google
1286
+ if (data.google_campaign_id && !isLocalId(data.google_campaign_id)) {
1287
+ try {
1288
+ const int = await getIntegration(sb, storeId);
1289
+ const googleUpdates = {
1290
+ resourceName: `customers/${int.customerId}/campaigns/${data.google_campaign_id}`
1291
+ };
1292
+ if (args.name) googleUpdates.name = args.name;
1293
+ if (args.daily_budget) {
1294
+ // Update budget separately
1295
+ const budgetQuery = `SELECT campaign_budget.resource_name FROM campaign WHERE campaign.id = ${data.google_campaign_id} LIMIT 1`;
1296
+ const budgetRows = await gaqlSearch(int, budgetQuery);
1297
+ if (budgetRows.length) {
1298
+ const budgetResource = budgetRows[0].campaignBudget?.resourceName;
1299
+ if (budgetResource) {
1300
+ await mutate(int, "campaignBudgets", [{
1301
+ update: {
1302
+ resourceName: budgetResource,
1303
+ amountMicros: toMicros(Number(args.daily_budget)).toString()
1304
+ },
1305
+ updateMask: "amountMicros"
1306
+ }], sb, storeId);
1307
+ }
1308
+ }
1309
+ }
1310
+ } catch (e) {
1311
+ // Non-fatal: local update succeeded, Google update failed
1312
+ return {
1313
+ success: true,
1314
+ data: {
1315
+ ...data,
1316
+ warning: `Local updated, but Google sync failed: ${e instanceof Error ? e.message : String(e)}`
1317
+ }
1318
+ };
1319
+ }
1320
+ }
1321
+ return {
1322
+ success: true,
1323
+ data
1324
+ };
1325
+ }
1326
+ case "delete_campaign":
1327
+ {
1328
+ const id = args.campaign_id || args.id;
1329
+ if (!id) return {
1330
+ success: false,
1331
+ error: "campaign_id required"
1332
+ };
1333
+
1334
+ // If published, remove from Google
1335
+ const {
1336
+ data: campaign
1337
+ } = await sb.from("google_campaigns").select("google_campaign_id").eq("id", id).eq("store_id", storeId).single();
1338
+ if (campaign?.google_campaign_id && !isLocalId(campaign.google_campaign_id)) {
1339
+ try {
1340
+ const int = await getIntegration(sb, storeId);
1341
+ await mutate(int, "campaigns", [{
1342
+ remove: `customers/${int.customerId}/campaigns/${campaign.google_campaign_id}`
1343
+ }], sb, storeId);
1344
+ } catch {/* best-effort deletion from Google */}
1345
+ }
1346
+ const {
1347
+ error
1348
+ } = await sb.from("google_campaigns").delete().eq("id", id).eq("store_id", storeId);
1349
+ if (error) return {
1350
+ success: false,
1351
+ error: error.message
1352
+ };
1353
+ return {
1354
+ success: true,
1355
+ data: {
1356
+ deleted: id
1357
+ }
1358
+ };
1359
+ }
1360
+
1361
+ // ====================================================================
1362
+ // AD GROUPS
1363
+ // ====================================================================
1364
+
1365
+ case "create_ad_group":
1366
+ {
1367
+ const name = args.name;
1368
+ const campaignId = args.campaign_id;
1369
+ if (!name) return {
1370
+ success: false,
1371
+ error: "name required"
1372
+ };
1373
+ if (!campaignId) return {
1374
+ success: false,
1375
+ error: "campaign_id required"
1376
+ };
1377
+ const row = {
1378
+ store_id: storeId,
1379
+ google_campaign_id: campaignId,
1380
+ name,
1381
+ ad_group_type: args.ad_group_type || "SEARCH_STANDARD",
1382
+ status: "DRAFT",
1383
+ cpc_bid: args.cpc_bid ? Number(args.cpc_bid) : null,
1384
+ targeting: args.targeting || null
1385
+ };
1386
+ const {
1387
+ data,
1388
+ error
1389
+ } = await sb.from("google_ad_groups").insert(row).select().single();
1390
+ if (error) return {
1391
+ success: false,
1392
+ error: error.message
1393
+ };
1394
+ return {
1395
+ success: true,
1396
+ data
1397
+ };
1398
+ }
1399
+ case "list_ad_groups":
1400
+ {
1401
+ const query = sb.from("google_ad_groups").select("*").eq("store_id", storeId).order("created_at", {
1402
+ ascending: false
1403
+ });
1404
+ if (args.campaign_id) query.eq("google_campaign_id", args.campaign_id);
1405
+ if (args.limit) query.limit(Number(args.limit));
1406
+ const {
1407
+ data,
1408
+ error
1409
+ } = await query;
1410
+ if (error) return {
1411
+ success: false,
1412
+ error: error.message
1413
+ };
1414
+ return {
1415
+ success: true,
1416
+ data
1417
+ };
1418
+ }
1419
+ case "get_ad_group":
1420
+ {
1421
+ const id = args.ad_group_id || args.id;
1422
+ if (!id) return {
1423
+ success: false,
1424
+ error: "ad_group_id required"
1425
+ };
1426
+ const {
1427
+ data,
1428
+ error
1429
+ } = await sb.from("google_ad_groups").select("*").eq("id", id).eq("store_id", storeId).single();
1430
+ if (error) return {
1431
+ success: false,
1432
+ error: error.message
1433
+ };
1434
+ return {
1435
+ success: true,
1436
+ data
1437
+ };
1438
+ }
1439
+ case "update_ad_group":
1440
+ {
1441
+ const id = args.ad_group_id || args.id;
1442
+ if (!id) return {
1443
+ success: false,
1444
+ error: "ad_group_id required"
1445
+ };
1446
+ const updates = {
1447
+ updated_at: new Date().toISOString()
1448
+ };
1449
+ if (args.name) updates.name = args.name;
1450
+ if (args.cpc_bid) updates.cpc_bid = Number(args.cpc_bid);
1451
+ if (args.status) updates.status = args.status;
1452
+ if (args.targeting) updates.targeting = args.targeting;
1453
+ const {
1454
+ data,
1455
+ error
1456
+ } = await sb.from("google_ad_groups").update(updates).eq("id", id).eq("store_id", storeId).select().single();
1457
+ if (error) return {
1458
+ success: false,
1459
+ error: error.message
1460
+ };
1461
+ return {
1462
+ success: true,
1463
+ data
1464
+ };
1465
+ }
1466
+
1467
+ // ====================================================================
1468
+ // ADS
1469
+ // ====================================================================
1470
+
1471
+ case "create_ad":
1472
+ {
1473
+ const adGroupId = args.ad_group_id;
1474
+ if (!adGroupId) return {
1475
+ success: false,
1476
+ error: "ad_group_id required"
1477
+ };
1478
+ const row = {
1479
+ store_id: storeId,
1480
+ ad_group_id: adGroupId,
1481
+ ad_type: args.ad_type || "RESPONSIVE_SEARCH_AD",
1482
+ headlines: args.headlines || [],
1483
+ descriptions: args.descriptions || [],
1484
+ final_urls: args.final_urls || [],
1485
+ path1: args.path1 || null,
1486
+ path2: args.path2 || null,
1487
+ creative: args.creative || null,
1488
+ status: "DRAFT"
1489
+ };
1490
+ const {
1491
+ data,
1492
+ error
1493
+ } = await sb.from("google_ads").insert(row).select().single();
1494
+ if (error) return {
1495
+ success: false,
1496
+ error: error.message
1497
+ };
1498
+ return {
1499
+ success: true,
1500
+ data
1501
+ };
1502
+ }
1503
+ case "list_ads":
1504
+ {
1505
+ const query = sb.from("google_ads").select("*").eq("store_id", storeId).order("created_at", {
1506
+ ascending: false
1507
+ });
1508
+ if (args.ad_group_id) query.eq("ad_group_id", args.ad_group_id);
1509
+ if (args.limit) query.limit(Number(args.limit));
1510
+ const {
1511
+ data,
1512
+ error
1513
+ } = await query;
1514
+ if (error) return {
1515
+ success: false,
1516
+ error: error.message
1517
+ };
1518
+ return {
1519
+ success: true,
1520
+ data
1521
+ };
1522
+ }
1523
+ case "get_ad":
1524
+ {
1525
+ const id = args.ad_id || args.id;
1526
+ if (!id) return {
1527
+ success: false,
1528
+ error: "ad_id required"
1529
+ };
1530
+ const {
1531
+ data,
1532
+ error
1533
+ } = await sb.from("google_ads").select("*").eq("id", id).eq("store_id", storeId).single();
1534
+ if (error) return {
1535
+ success: false,
1536
+ error: error.message
1537
+ };
1538
+ return {
1539
+ success: true,
1540
+ data
1541
+ };
1542
+ }
1543
+ case "update_ad":
1544
+ {
1545
+ const id = args.ad_id || args.id;
1546
+ if (!id) return {
1547
+ success: false,
1548
+ error: "ad_id required"
1549
+ };
1550
+ const updates = {
1551
+ updated_at: new Date().toISOString()
1552
+ };
1553
+ if (args.headlines) updates.headlines = args.headlines;
1554
+ if (args.descriptions) updates.descriptions = args.descriptions;
1555
+ if (args.final_urls) updates.final_urls = args.final_urls;
1556
+ if (args.path1 !== undefined) updates.path1 = args.path1;
1557
+ if (args.path2 !== undefined) updates.path2 = args.path2;
1558
+ if (args.status) updates.status = args.status;
1559
+ const {
1560
+ data,
1561
+ error
1562
+ } = await sb.from("google_ads").update(updates).eq("id", id).eq("store_id", storeId).select().single();
1563
+ if (error) return {
1564
+ success: false,
1565
+ error: error.message
1566
+ };
1567
+ return {
1568
+ success: true,
1569
+ data
1570
+ };
1571
+ }
1572
+
1573
+ // ====================================================================
1574
+ // KEYWORDS
1575
+ // ====================================================================
1576
+
1577
+ case "create_keyword":
1578
+ {
1579
+ const adGroupId = args.ad_group_id;
1580
+ const text = args.text;
1581
+ if (!adGroupId) return {
1582
+ success: false,
1583
+ error: "ad_group_id required"
1584
+ };
1585
+ if (!text) return {
1586
+ success: false,
1587
+ error: "text required"
1588
+ };
1589
+ const row = {
1590
+ store_id: storeId,
1591
+ ad_group_id: adGroupId,
1592
+ text,
1593
+ match_type: args.match_type || "BROAD",
1594
+ cpc_bid: args.cpc_bid ? Number(args.cpc_bid) : null,
1595
+ status: "DRAFT"
1596
+ };
1597
+ const {
1598
+ data,
1599
+ error
1600
+ } = await sb.from("google_keywords").insert(row).select().single();
1601
+ if (error) return {
1602
+ success: false,
1603
+ error: error.message
1604
+ };
1605
+ return {
1606
+ success: true,
1607
+ data
1608
+ };
1609
+ }
1610
+ case "list_keywords":
1611
+ {
1612
+ const query = sb.from("google_keywords").select("*").eq("store_id", storeId).order("created_at", {
1613
+ ascending: false
1614
+ });
1615
+ if (args.ad_group_id) query.eq("ad_group_id", args.ad_group_id);
1616
+ if (args.limit) query.limit(Number(args.limit));
1617
+ const {
1618
+ data,
1619
+ error
1620
+ } = await query;
1621
+ if (error) return {
1622
+ success: false,
1623
+ error: error.message
1624
+ };
1625
+ return {
1626
+ success: true,
1627
+ data
1628
+ };
1629
+ }
1630
+ case "update_keyword":
1631
+ {
1632
+ const id = args.keyword_id || args.id;
1633
+ if (!id) return {
1634
+ success: false,
1635
+ error: "keyword_id required"
1636
+ };
1637
+ const updates = {
1638
+ updated_at: new Date().toISOString()
1639
+ };
1640
+ if (args.text) updates.text = args.text;
1641
+ if (args.match_type) updates.match_type = args.match_type;
1642
+ if (args.cpc_bid) updates.cpc_bid = Number(args.cpc_bid);
1643
+ if (args.status) updates.status = args.status;
1644
+ const {
1645
+ data,
1646
+ error
1647
+ } = await sb.from("google_keywords").update(updates).eq("id", id).eq("store_id", storeId).select().single();
1648
+ if (error) return {
1649
+ success: false,
1650
+ error: error.message
1651
+ };
1652
+ return {
1653
+ success: true,
1654
+ data
1655
+ };
1656
+ }
1657
+ case "delete_keyword":
1658
+ {
1659
+ const id = args.keyword_id || args.id;
1660
+ if (!id) return {
1661
+ success: false,
1662
+ error: "keyword_id required"
1663
+ };
1664
+ const {
1665
+ data: kw
1666
+ } = await sb.from("google_keywords").select("google_criterion_id, ad_group_id").eq("id", id).eq("store_id", storeId).single();
1667
+ if (kw?.google_criterion_id) {
1668
+ try {
1669
+ const int = await getIntegration(sb, storeId);
1670
+ const {
1671
+ data: ag
1672
+ } = await sb.from("google_ad_groups").select("google_ad_group_id").eq("id", kw.ad_group_id).eq("store_id", storeId).single();
1673
+ if (ag?.google_ad_group_id) {
1674
+ await mutate(int, "adGroupCriteria", [{
1675
+ remove: `customers/${int.customerId}/adGroupCriteria/${ag.google_ad_group_id}~${kw.google_criterion_id}`
1676
+ }], sb, storeId);
1677
+ }
1678
+ } catch {/* best-effort */}
1679
+ }
1680
+ const {
1681
+ error
1682
+ } = await sb.from("google_keywords").delete().eq("id", id).eq("store_id", storeId);
1683
+ if (error) return {
1684
+ success: false,
1685
+ error: error.message
1686
+ };
1687
+ return {
1688
+ success: true,
1689
+ data: {
1690
+ deleted: id
1691
+ }
1692
+ };
1693
+ }
1694
+
1695
+ // ====================================================================
1696
+ // PUBLISH — push drafts to Google Ads
1697
+ // ====================================================================
1698
+
1699
+ case "publish":
1700
+ {
1701
+ const int = await getIntegration(sb, storeId);
1702
+ const type = args.type || "campaign";
1703
+ const id = args.id || args.campaign_id || args.ad_group_id || args.ad_id;
1704
+ if (!id) return {
1705
+ success: false,
1706
+ error: "id required"
1707
+ };
1708
+ switch (type) {
1709
+ case "campaign":
1710
+ {
1711
+ const result = await publishCampaignToGoogle(sb, int, storeId, id);
1712
+ // Also publish keywords for all ad groups in this campaign
1713
+ const {
1714
+ data: adGroups
1715
+ } = await sb.from("google_ad_groups").select("id").eq("google_campaign_id", id).eq("store_id", storeId);
1716
+ let keywordsPublished = 0;
1717
+ for (const ag of adGroups || []) {
1718
+ try {
1719
+ const kResult = await publishKeywordsToGoogle(sb, int, storeId, ag.id);
1720
+ keywordsPublished += kResult.published;
1721
+ } catch {/* non-fatal */}
1722
+ }
1723
+ return {
1724
+ success: true,
1725
+ data: {
1726
+ ...result,
1727
+ keywordsPublished
1728
+ }
1729
+ };
1730
+ }
1731
+ case "ad_group":
1732
+ {
1733
+ const result = await publishAdGroupToGoogle(sb, int, storeId, id);
1734
+ const kResult = await publishKeywordsToGoogle(sb, int, storeId, id);
1735
+ return {
1736
+ success: true,
1737
+ data: {
1738
+ ...result,
1739
+ keywordsPublished: kResult.published
1740
+ }
1741
+ };
1742
+ }
1743
+ case "ad":
1744
+ {
1745
+ const result = await publishAdToGoogle(sb, int, storeId, id);
1746
+ return {
1747
+ success: true,
1748
+ data: result
1749
+ };
1750
+ }
1751
+ case "conversion_action":
1752
+ {
1753
+ const result = await publishConversionAction(sb, int, storeId, id);
1754
+ return {
1755
+ success: true,
1756
+ data: result
1757
+ };
1758
+ }
1759
+ default:
1760
+ return {
1761
+ success: false,
1762
+ error: `Unknown publish type: ${type}. Use campaign, ad_group, ad, or conversion_action.`
1763
+ };
1764
+ }
1765
+ }
1766
+
1767
+ // ====================================================================
1768
+ // PAUSE / ENABLE — change status on Google
1769
+ // ====================================================================
1770
+
1771
+ case "pause":
1772
+ case "enable":
1773
+ {
1774
+ const int = await getIntegration(sb, storeId);
1775
+ const type = args.type || "campaign";
1776
+ const id = args.id || args.campaign_id || args.ad_group_id;
1777
+ if (!id) return {
1778
+ success: false,
1779
+ error: "id required"
1780
+ };
1781
+ const newStatus = action === "pause" ? "PAUSED" : "ENABLED";
1782
+ if (type === "campaign") {
1783
+ const {
1784
+ data: c
1785
+ } = await sb.from("google_campaigns").select("google_campaign_id").eq("id", id).eq("store_id", storeId).single();
1786
+ if (!c?.google_campaign_id || isLocalId(c.google_campaign_id)) {
1787
+ return {
1788
+ success: false,
1789
+ error: "Campaign not published to Google yet"
1790
+ };
1791
+ }
1792
+ await mutate(int, "campaigns", [{
1793
+ update: {
1794
+ resourceName: `customers/${int.customerId}/campaigns/${c.google_campaign_id}`,
1795
+ status: newStatus
1796
+ },
1797
+ updateMask: "status"
1798
+ }], sb, storeId);
1799
+ await sb.from("google_campaigns").update({
1800
+ google_status: newStatus,
1801
+ updated_at: new Date().toISOString()
1802
+ }).eq("id", id).eq("store_id", storeId);
1803
+ return {
1804
+ success: true,
1805
+ data: {
1806
+ status: newStatus
1807
+ }
1808
+ };
1809
+ }
1810
+ if (type === "ad_group") {
1811
+ const {
1812
+ data: ag
1813
+ } = await sb.from("google_ad_groups").select("google_ad_group_id").eq("id", id).eq("store_id", storeId).single();
1814
+ if (!ag?.google_ad_group_id || isLocalId(ag.google_ad_group_id)) {
1815
+ return {
1816
+ success: false,
1817
+ error: "Ad group not published to Google yet"
1818
+ };
1819
+ }
1820
+ await mutate(int, "adGroups", [{
1821
+ update: {
1822
+ resourceName: `customers/${int.customerId}/adGroups/${ag.google_ad_group_id}`,
1823
+ status: newStatus
1824
+ },
1825
+ updateMask: "status"
1826
+ }], sb, storeId);
1827
+ await sb.from("google_ad_groups").update({
1828
+ google_status: newStatus,
1829
+ updated_at: new Date().toISOString()
1830
+ }).eq("id", id).eq("store_id", storeId);
1831
+ return {
1832
+ success: true,
1833
+ data: {
1834
+ status: newStatus
1835
+ }
1836
+ };
1837
+ }
1838
+ return {
1839
+ success: false,
1840
+ error: `Unknown type: ${type}`
1841
+ };
1842
+ }
1843
+
1844
+ // ====================================================================
1845
+ // SYNC — pull metrics from Google into local DB
1846
+ // ====================================================================
1847
+
1848
+ case "sync":
1849
+ {
1850
+ const int = await getIntegration(sb, storeId);
1851
+ const dateRange = args.date_range || "LAST_30_DAYS";
1852
+ const result = await syncCampaignMetrics(sb, int, storeId, dateRange);
1853
+ return {
1854
+ success: true,
1855
+ data: result
1856
+ };
1857
+ }
1858
+
1859
+ // ====================================================================
1860
+ // SEARCH — raw GAQL queries
1861
+ // ====================================================================
1862
+
1863
+ case "search":
1864
+ {
1865
+ const query = args.query;
1866
+ if (!query) return {
1867
+ success: false,
1868
+ error: "query (GAQL) required"
1869
+ };
1870
+ const int = await getIntegration(sb, storeId);
1871
+ const customerId = args.customer_id || int.customerId;
1872
+ const results = await gaqlSearch(int, query, customerId);
1873
+ return {
1874
+ success: true,
1875
+ data: {
1876
+ results,
1877
+ totalResults: results.length
1878
+ }
1879
+ };
1880
+ }
1881
+
1882
+ // ====================================================================
1883
+ // LIST ACCESSIBLE CUSTOMERS
1884
+ // ====================================================================
1885
+
1886
+ case "list_accessible_customers":
1887
+ {
1888
+ const int = await getIntegration(sb, storeId);
1889
+ const res = await fetch(`${GOOGLE_ADS_BASE}/customers:listAccessibleCustomers`, {
1890
+ method: "GET",
1891
+ headers: authHeaders(int)
1892
+ });
1893
+ if (!res.ok) {
1894
+ const errBody = await res.text();
1895
+ throw new Error(`listAccessibleCustomers failed (${res.status}): ${parseGoogleError(errBody, res.status)}`);
1896
+ }
1897
+ const data = await res.json();
1898
+ return {
1899
+ success: true,
1900
+ data
1901
+ };
1902
+ }
1903
+
1904
+ // ====================================================================
1905
+ // CONVERSION ACTIONS
1906
+ // ====================================================================
1907
+
1908
+ case "create_conversion_action":
1909
+ {
1910
+ const name = args.name;
1911
+ if (!name) return {
1912
+ success: false,
1913
+ error: "name required"
1914
+ };
1915
+ const row = {
1916
+ store_id: storeId,
1917
+ name,
1918
+ category: args.category || "PURCHASE",
1919
+ counting_type: args.counting_type || "ONE_PER_CLICK",
1920
+ attribution_model: args.attribution_model || "GOOGLE_LAST_CLICK",
1921
+ default_value: args.default_value ? Number(args.default_value) : null,
1922
+ status: "DRAFT"
1923
+ };
1924
+ const {
1925
+ data,
1926
+ error
1927
+ } = await sb.from("google_conversion_actions").insert(row).select().single();
1928
+ if (error) return {
1929
+ success: false,
1930
+ error: error.message
1931
+ };
1932
+ return {
1933
+ success: true,
1934
+ data
1935
+ };
1936
+ }
1937
+ case "list_conversion_actions":
1938
+ {
1939
+ const {
1940
+ data,
1941
+ error
1942
+ } = await sb.from("google_conversion_actions").select("*").eq("store_id", storeId).order("created_at", {
1943
+ ascending: false
1944
+ });
1945
+ if (error) return {
1946
+ success: false,
1947
+ error: error.message
1948
+ };
1949
+ return {
1950
+ success: true,
1951
+ data
1952
+ };
1953
+ }
1954
+
1955
+ // ====================================================================
1956
+ // AUDIENCES
1957
+ // ====================================================================
1958
+
1959
+ case "create_audience":
1960
+ {
1961
+ const name = args.name;
1962
+ if (!name) return {
1963
+ success: false,
1964
+ error: "name required"
1965
+ };
1966
+ const row = {
1967
+ store_id: storeId,
1968
+ name,
1969
+ audience_type: args.audience_type || "CUSTOM",
1970
+ description: args.description || null,
1971
+ status: "DRAFT"
1972
+ };
1973
+ const {
1974
+ data,
1975
+ error
1976
+ } = await sb.from("google_audiences").insert(row).select().single();
1977
+ if (error) return {
1978
+ success: false,
1979
+ error: error.message
1980
+ };
1981
+ return {
1982
+ success: true,
1983
+ data
1984
+ };
1985
+ }
1986
+ case "list_audiences":
1987
+ {
1988
+ const {
1989
+ data,
1990
+ error
1991
+ } = await sb.from("google_audiences").select("*").eq("store_id", storeId).order("created_at", {
1992
+ ascending: false
1993
+ });
1994
+ if (error) return {
1995
+ success: false,
1996
+ error: error.message
1997
+ };
1998
+ return {
1999
+ success: true,
2000
+ data
2001
+ };
2002
+ }
2003
+
2004
+ // ====================================================================
2005
+ // KEYWORD IDEAS (Keyword Planner)
2006
+ // ====================================================================
2007
+
2008
+ case "keyword_ideas":
2009
+ {
2010
+ const int = await getIntegration(sb, storeId);
2011
+ const keywords = args.keywords;
2012
+ const url = args.url;
2013
+ if (!keywords?.length && !url) {
2014
+ return {
2015
+ success: false,
2016
+ error: "Provide keywords (array) or url for keyword ideas"
2017
+ };
2018
+ }
2019
+ const body = {
2020
+ language: args.language || "languageConstants/1000",
2021
+ // English
2022
+ geoTargetConstants: args.geo_targets || ["geoTargetConstants/2840"],
2023
+ // US
2024
+ keywordPlanNetwork: "GOOGLE_SEARCH"
2025
+ };
2026
+ if (keywords?.length) {
2027
+ body.keywordSeed = {
2028
+ keywords
2029
+ };
2030
+ }
2031
+ if (url) {
2032
+ body.urlSeed = {
2033
+ url
2034
+ };
2035
+ }
2036
+ const res = await fetch(`${GOOGLE_ADS_BASE}/customers/${int.customerId}:generateKeywordIdeas`, {
2037
+ method: "POST",
2038
+ headers: authHeaders(int),
2039
+ body: JSON.stringify(body)
2040
+ });
2041
+ if (!res.ok) {
2042
+ const errBody = await res.text();
2043
+ throw new Error(`Keyword ideas failed (${res.status}): ${parseGoogleError(errBody, res.status)}`);
2044
+ }
2045
+ const data = await res.json();
2046
+ return {
2047
+ success: true,
2048
+ data
2049
+ };
2050
+ }
2051
+
2052
+ // ====================================================================
2053
+ // CREATE FULL — campaign + ad group + ad + keywords in one call
2054
+ // ====================================================================
2055
+
2056
+ case "create_full":
2057
+ {
2058
+ const name = args.name;
2059
+ if (!name) return {
2060
+ success: false,
2061
+ error: "name required"
2062
+ };
2063
+
2064
+ // 1. Create campaign
2065
+ const campaignRow = {
2066
+ store_id: storeId,
2067
+ name,
2068
+ campaign_type: args.campaign_type || "SEARCH",
2069
+ status: "DRAFT",
2070
+ daily_budget: args.daily_budget ? Number(args.daily_budget) : 10,
2071
+ bidding_strategy: args.bidding_strategy || "MAXIMIZE_CLICKS",
2072
+ bidding_target: args.bidding_target ? Number(args.bidding_target) : null,
2073
+ start_date: args.start_date || null,
2074
+ end_date: args.end_date || null
2075
+ };
2076
+ const {
2077
+ data: campaign,
2078
+ error: campErr
2079
+ } = await sb.from("google_campaigns").insert(campaignRow).select().single();
2080
+ if (campErr) return {
2081
+ success: false,
2082
+ error: campErr.message
2083
+ };
2084
+
2085
+ // 2. Create ad group
2086
+ const adGroupRow = {
2087
+ store_id: storeId,
2088
+ google_campaign_id: campaign.id,
2089
+ name: args.ad_group_name || `${name} — Ad Group`,
2090
+ ad_group_type: args.ad_group_type || "SEARCH_STANDARD",
2091
+ status: "DRAFT",
2092
+ cpc_bid: args.cpc_bid ? Number(args.cpc_bid) : null
2093
+ };
2094
+ const {
2095
+ data: adGroup,
2096
+ error: agErr
2097
+ } = await sb.from("google_ad_groups").insert(adGroupRow).select().single();
2098
+ if (agErr) return {
2099
+ success: false,
2100
+ error: agErr.message
2101
+ };
2102
+
2103
+ // 3. Create ad
2104
+ const adRow = {
2105
+ store_id: storeId,
2106
+ ad_group_id: adGroup.id,
2107
+ ad_type: args.ad_type || "RESPONSIVE_SEARCH_AD",
2108
+ headlines: args.headlines || [],
2109
+ descriptions: args.descriptions || [],
2110
+ final_urls: args.final_urls || [],
2111
+ path1: args.path1 || null,
2112
+ path2: args.path2 || null,
2113
+ creative: args.creative || null,
2114
+ status: "DRAFT"
2115
+ };
2116
+ const {
2117
+ data: ad,
2118
+ error: adErr
2119
+ } = await sb.from("google_ads").insert(adRow).select().single();
2120
+ if (adErr) return {
2121
+ success: false,
2122
+ error: adErr.message
2123
+ };
2124
+
2125
+ // 4. Create keywords (if provided)
2126
+ const keywords = args.keywords;
2127
+ let keywordCount = 0;
2128
+ if (keywords?.length) {
2129
+ const kwRows = keywords.map(kw => ({
2130
+ store_id: storeId,
2131
+ ad_group_id: adGroup.id,
2132
+ text: kw.text,
2133
+ match_type: kw.match_type || "BROAD",
2134
+ status: "DRAFT"
2135
+ }));
2136
+ const {
2137
+ error: kwErr
2138
+ } = await sb.from("google_keywords").insert(kwRows);
2139
+ if (kwErr) throw new Error(`Failed to insert keywords: ${kwErr.message}`);
2140
+ keywordCount = kwRows.length;
2141
+ }
2142
+
2143
+ // 5. Auto-publish if requested
2144
+ let published = false;
2145
+ if (args.auto_publish) {
2146
+ try {
2147
+ const int = await getIntegration(sb, storeId);
2148
+ // PMax: publishCampaignToGoogle handles everything (asset group, assets, links)
2149
+ await publishCampaignToGoogle(sb, int, storeId, campaign.id);
2150
+ if (campaignRow.campaign_type !== "PERFORMANCE_MAX") {
2151
+ await publishAdGroupToGoogle(sb, int, storeId, adGroup.id);
2152
+ await publishAdToGoogle(sb, int, storeId, ad.id);
2153
+ await publishKeywordsToGoogle(sb, int, storeId, adGroup.id);
2154
+ }
2155
+ published = true;
2156
+ } catch (pubErr) {
2157
+ return {
2158
+ success: true,
2159
+ data: {
2160
+ campaign,
2161
+ adGroup,
2162
+ ad,
2163
+ keywordCount,
2164
+ published: false,
2165
+ publishError: pubErr instanceof Error ? pubErr.message : String(pubErr)
2166
+ }
2167
+ };
2168
+ }
2169
+ }
2170
+ return {
2171
+ success: true,
2172
+ data: {
2173
+ campaign,
2174
+ adGroup,
2175
+ ad,
2176
+ keywordCount,
2177
+ published
2178
+ }
2179
+ };
2180
+ }
2181
+
2182
+ // ====================================================================
2183
+ // DEFAULT
2184
+ // ====================================================================
2185
+
2186
+ default:
2187
+ return {
2188
+ success: false,
2189
+ error: `Unknown action: ${action}. Valid actions: create_campaign, list_campaigns, get_campaign, update_campaign, delete_campaign, create_ad_group, list_ad_groups, get_ad_group, update_ad_group, create_ad, list_ads, get_ad, update_ad, create_keyword, list_keywords, update_keyword, delete_keyword, publish, pause, enable, sync, search, list_accessible_customers, keyword_ideas, create_conversion_action, list_conversion_actions, create_audience, list_audiences, create_full`
2190
+ };
2191
+ }
2192
+ } catch (err) {
2193
+ return {
2194
+ success: false,
2195
+ error: err instanceof Error ? err.message : String(err)
2196
+ };
2197
+ }
2198
+ }
2199
+ //# sourceMappingURL=google-ads.js.map