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.
- package/README.md +14 -2
- package/dist/cli/services/agent-loop.js +26 -2
- package/dist/cli/services/agent-loop.js.map +1 -1
- package/dist/cli/services/hooks.js +2 -1
- package/dist/cli/services/hooks.js.map +1 -1
- package/dist/cli/services/telemetry-spans.js +1 -0
- package/dist/cli/services/telemetry-spans.js.map +1 -1
- package/dist/cli/services/telemetry.d.ts +23 -0
- package/dist/cli/services/telemetry.js +45 -1
- package/dist/cli/services/telemetry.js.map +1 -1
- package/dist/server/handlers/__test-utils__/test-db.d.ts +17 -3
- package/dist/server/handlers/__test-utils__/test-db.js +113 -14
- package/dist/server/handlers/__test-utils__/test-db.js.map +1 -1
- package/dist/server/handlers/affiliates.d.ts +9 -0
- package/dist/server/handlers/affiliates.js +197 -0
- package/dist/server/handlers/affiliates.js.map +1 -0
- package/dist/server/handlers/api-docs.d.ts +4 -2
- package/dist/server/handlers/api-docs.js +204 -1681
- package/dist/server/handlers/api-docs.js.map +1 -1
- package/dist/server/handlers/campaigns.d.ts +9 -0
- package/dist/server/handlers/campaigns.js +237 -0
- package/dist/server/handlers/campaigns.js.map +1 -0
- package/dist/server/handlers/catalog-schemas.js +9 -9
- package/dist/server/handlers/catalog-schemas.js.map +1 -1
- package/dist/server/handlers/catalog.js +1 -1
- package/dist/server/handlers/catalog.js.map +1 -1
- package/dist/server/handlers/comms-documents.js +28 -2
- package/dist/server/handlers/comms-documents.js.map +1 -1
- package/dist/server/handlers/comms-pdf-generation.js +25 -3
- package/dist/server/handlers/comms-pdf-generation.js.map +1 -1
- package/dist/server/handlers/comms-pdf-helpers.js +4 -4
- package/dist/server/handlers/comms-pdf-helpers.js.map +1 -1
- package/dist/server/handlers/comms.d.ts +100 -0
- package/dist/server/handlers/comms.js +146 -12
- package/dist/server/handlers/comms.js.map +1 -1
- package/dist/server/handlers/coupons.d.ts +9 -0
- package/dist/server/handlers/coupons.js +220 -0
- package/dist/server/handlers/coupons.js.map +1 -0
- package/dist/server/handlers/embeddings.js +1 -1
- package/dist/server/handlers/embeddings.js.map +1 -1
- package/dist/server/handlers/enrichment.js +2 -622
- package/dist/server/handlers/enrichment.js.map +1 -1
- package/dist/server/handlers/fulfillment.d.ts +9 -0
- package/dist/server/handlers/fulfillment.js +209 -0
- package/dist/server/handlers/fulfillment.js.map +1 -0
- package/dist/server/handlers/google-ads.d.ts +24 -0
- package/dist/server/handlers/google-ads.js +2199 -0
- package/dist/server/handlers/google-ads.js.map +1 -0
- package/dist/server/handlers/invoices.d.ts +9 -0
- package/dist/server/handlers/invoices.js +252 -0
- package/dist/server/handlers/invoices.js.map +1 -0
- package/dist/server/handlers/loyalty.d.ts +9 -0
- package/dist/server/handlers/loyalty.js +197 -0
- package/dist/server/handlers/loyalty.js.map +1 -0
- package/dist/server/handlers/meta-ads-graph-api.js +18 -3
- package/dist/server/handlers/meta-ads-graph-api.js.map +1 -1
- package/dist/server/handlers/phone.d.ts +9 -0
- package/dist/server/handlers/phone.js +197 -0
- package/dist/server/handlers/phone.js.map +1 -0
- package/dist/server/handlers/pipeline.d.ts +9 -0
- package/dist/server/handlers/pipeline.js +277 -0
- package/dist/server/handlers/pipeline.js.map +1 -0
- package/dist/server/handlers/qr-codes.d.ts +9 -0
- package/dist/server/handlers/qr-codes.js +198 -0
- package/dist/server/handlers/qr-codes.js.map +1 -0
- package/dist/server/handlers/reviews.d.ts +9 -0
- package/dist/server/handlers/reviews.js +171 -0
- package/dist/server/handlers/reviews.js.map +1 -0
- package/dist/server/handlers/segments.d.ts +9 -0
- package/dist/server/handlers/segments.js +229 -0
- package/dist/server/handlers/segments.js.map +1 -0
- package/dist/server/handlers/social.d.ts +9 -0
- package/dist/server/handlers/social.js +81 -0
- package/dist/server/handlers/social.js.map +1 -0
- package/dist/server/handlers/tax.d.ts +9 -0
- package/dist/server/handlers/tax.js +182 -0
- package/dist/server/handlers/tax.js.map +1 -0
- package/dist/server/handlers/wallet.d.ts +9 -0
- package/dist/server/handlers/wallet.js +203 -0
- package/dist/server/handlers/wallet.js.map +1 -0
- package/dist/server/handlers/webhooks-mgmt.d.ts +9 -0
- package/dist/server/handlers/webhooks-mgmt.js +181 -0
- package/dist/server/handlers/webhooks-mgmt.js.map +1 -0
- package/dist/server/handlers/wholesale.d.ts +9 -0
- package/dist/server/handlers/wholesale.js +219 -0
- package/dist/server/handlers/wholesale.js.map +1 -0
- package/dist/server/index.js +20 -9
- package/dist/server/index.js.map +1 -1
- package/dist/server/lib/clickhouse-buffer.js +1 -0
- package/dist/server/lib/clickhouse-buffer.js.map +1 -1
- package/dist/server/lib/coa-renderer.d.ts +1 -1
- package/dist/server/lib/coa-renderer.js +32 -10
- package/dist/server/lib/coa-renderer.js.map +1 -1
- package/dist/server/server-worker.d.ts +1 -0
- package/dist/server/server-worker.js +464 -3
- package/dist/server/server-worker.js.map +1 -1
- package/dist/server/tool-router.js +118 -4
- package/dist/server/tool-router.js.map +1 -1
- package/package.json +28 -4
- package/vendor/ink/package.json +0 -2
- 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
|