whale-code 6.4.0
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 +95 -0
- package/bin/swag-agent.js +9 -0
- package/bin/swagmanager-mcp.js +321 -0
- package/dist/cli/app.d.ts +26 -0
- package/dist/cli/app.js +64 -0
- package/dist/cli/chat/AgentSelector.d.ts +14 -0
- package/dist/cli/chat/AgentSelector.js +14 -0
- package/dist/cli/chat/ChatApp.d.ts +9 -0
- package/dist/cli/chat/ChatApp.js +267 -0
- package/dist/cli/chat/ChatInput.d.ts +39 -0
- package/dist/cli/chat/ChatInput.js +509 -0
- package/dist/cli/chat/MarkdownText.d.ts +10 -0
- package/dist/cli/chat/MarkdownText.js +20 -0
- package/dist/cli/chat/MessageList.d.ts +37 -0
- package/dist/cli/chat/MessageList.js +80 -0
- package/dist/cli/chat/ModelSelector.d.ts +20 -0
- package/dist/cli/chat/ModelSelector.js +73 -0
- package/dist/cli/chat/RewindViewer.d.ts +26 -0
- package/dist/cli/chat/RewindViewer.js +185 -0
- package/dist/cli/chat/StoreSelector.d.ts +14 -0
- package/dist/cli/chat/StoreSelector.js +24 -0
- package/dist/cli/chat/StreamingText.d.ts +12 -0
- package/dist/cli/chat/StreamingText.js +12 -0
- package/dist/cli/chat/SubagentPanel.d.ts +45 -0
- package/dist/cli/chat/SubagentPanel.js +110 -0
- package/dist/cli/chat/TeamPanel.d.ts +21 -0
- package/dist/cli/chat/TeamPanel.js +42 -0
- package/dist/cli/chat/ToolIndicator.d.ts +25 -0
- package/dist/cli/chat/ToolIndicator.js +436 -0
- package/dist/cli/chat/hooks/useAgentLoop.d.ts +39 -0
- package/dist/cli/chat/hooks/useAgentLoop.js +382 -0
- package/dist/cli/chat/hooks/useSlashCommands.d.ts +37 -0
- package/dist/cli/chat/hooks/useSlashCommands.js +387 -0
- package/dist/cli/commands/config-cmd.d.ts +10 -0
- package/dist/cli/commands/config-cmd.js +99 -0
- package/dist/cli/commands/doctor.d.ts +14 -0
- package/dist/cli/commands/doctor.js +172 -0
- package/dist/cli/commands/init.d.ts +16 -0
- package/dist/cli/commands/init.js +278 -0
- package/dist/cli/commands/mcp.d.ts +12 -0
- package/dist/cli/commands/mcp.js +162 -0
- package/dist/cli/login/LoginApp.d.ts +7 -0
- package/dist/cli/login/LoginApp.js +157 -0
- package/dist/cli/print-mode.d.ts +31 -0
- package/dist/cli/print-mode.js +202 -0
- package/dist/cli/serve-mode.d.ts +37 -0
- package/dist/cli/serve-mode.js +636 -0
- package/dist/cli/services/agent-definitions.d.ts +25 -0
- package/dist/cli/services/agent-definitions.js +91 -0
- package/dist/cli/services/agent-events.d.ts +178 -0
- package/dist/cli/services/agent-events.js +175 -0
- package/dist/cli/services/agent-loop.d.ts +90 -0
- package/dist/cli/services/agent-loop.js +762 -0
- package/dist/cli/services/agent-worker-base.d.ts +97 -0
- package/dist/cli/services/agent-worker-base.js +220 -0
- package/dist/cli/services/auth-service.d.ts +30 -0
- package/dist/cli/services/auth-service.js +160 -0
- package/dist/cli/services/background-processes.d.ts +126 -0
- package/dist/cli/services/background-processes.js +318 -0
- package/dist/cli/services/browser-auth.d.ts +24 -0
- package/dist/cli/services/browser-auth.js +180 -0
- package/dist/cli/services/claude-md-loader.d.ts +16 -0
- package/dist/cli/services/claude-md-loader.js +58 -0
- package/dist/cli/services/config-store.d.ts +47 -0
- package/dist/cli/services/config-store.js +79 -0
- package/dist/cli/services/debug-log.d.ts +10 -0
- package/dist/cli/services/debug-log.js +52 -0
- package/dist/cli/services/error-logger.d.ts +58 -0
- package/dist/cli/services/error-logger.js +269 -0
- package/dist/cli/services/file-history.d.ts +21 -0
- package/dist/cli/services/file-history.js +83 -0
- package/dist/cli/services/format-server-response.d.ts +16 -0
- package/dist/cli/services/format-server-response.js +440 -0
- package/dist/cli/services/git-context.d.ts +11 -0
- package/dist/cli/services/git-context.js +66 -0
- package/dist/cli/services/hooks.d.ts +85 -0
- package/dist/cli/services/hooks.js +258 -0
- package/dist/cli/services/interactive-tools.d.ts +125 -0
- package/dist/cli/services/interactive-tools.js +260 -0
- package/dist/cli/services/keybinding-manager.d.ts +52 -0
- package/dist/cli/services/keybinding-manager.js +115 -0
- package/dist/cli/services/local-tools.d.ts +22 -0
- package/dist/cli/services/local-tools.js +697 -0
- package/dist/cli/services/lsp-manager.d.ts +18 -0
- package/dist/cli/services/lsp-manager.js +717 -0
- package/dist/cli/services/mcp-client.d.ts +48 -0
- package/dist/cli/services/mcp-client.js +157 -0
- package/dist/cli/services/memory-manager.d.ts +16 -0
- package/dist/cli/services/memory-manager.js +57 -0
- package/dist/cli/services/model-manager.d.ts +18 -0
- package/dist/cli/services/model-manager.js +71 -0
- package/dist/cli/services/model-router.d.ts +26 -0
- package/dist/cli/services/model-router.js +149 -0
- package/dist/cli/services/permission-modes.d.ts +13 -0
- package/dist/cli/services/permission-modes.js +43 -0
- package/dist/cli/services/rewind.d.ts +84 -0
- package/dist/cli/services/rewind.js +194 -0
- package/dist/cli/services/ripgrep.d.ts +28 -0
- package/dist/cli/services/ripgrep.js +138 -0
- package/dist/cli/services/sandbox.d.ts +29 -0
- package/dist/cli/services/sandbox.js +97 -0
- package/dist/cli/services/server-tools.d.ts +61 -0
- package/dist/cli/services/server-tools.js +543 -0
- package/dist/cli/services/session-persistence.d.ts +23 -0
- package/dist/cli/services/session-persistence.js +99 -0
- package/dist/cli/services/subagent-worker.d.ts +19 -0
- package/dist/cli/services/subagent-worker.js +41 -0
- package/dist/cli/services/subagent.d.ts +47 -0
- package/dist/cli/services/subagent.js +647 -0
- package/dist/cli/services/system-prompt.d.ts +7 -0
- package/dist/cli/services/system-prompt.js +198 -0
- package/dist/cli/services/team-lead.d.ts +73 -0
- package/dist/cli/services/team-lead.js +512 -0
- package/dist/cli/services/team-state.d.ts +77 -0
- package/dist/cli/services/team-state.js +398 -0
- package/dist/cli/services/teammate.d.ts +31 -0
- package/dist/cli/services/teammate.js +689 -0
- package/dist/cli/services/telemetry.d.ts +61 -0
- package/dist/cli/services/telemetry.js +209 -0
- package/dist/cli/services/tools/agent-tools.d.ts +14 -0
- package/dist/cli/services/tools/agent-tools.js +347 -0
- package/dist/cli/services/tools/file-ops.d.ts +15 -0
- package/dist/cli/services/tools/file-ops.js +487 -0
- package/dist/cli/services/tools/search-tools.d.ts +8 -0
- package/dist/cli/services/tools/search-tools.js +186 -0
- package/dist/cli/services/tools/shell-exec.d.ts +10 -0
- package/dist/cli/services/tools/shell-exec.js +168 -0
- package/dist/cli/services/tools/task-manager.d.ts +28 -0
- package/dist/cli/services/tools/task-manager.js +209 -0
- package/dist/cli/services/tools/web-tools.d.ts +11 -0
- package/dist/cli/services/tools/web-tools.js +395 -0
- package/dist/cli/setup/SetupApp.d.ts +9 -0
- package/dist/cli/setup/SetupApp.js +191 -0
- package/dist/cli/shared/MatrixIntro.d.ts +4 -0
- package/dist/cli/shared/MatrixIntro.js +83 -0
- package/dist/cli/shared/Theme.d.ts +74 -0
- package/dist/cli/shared/Theme.js +127 -0
- package/dist/cli/shared/WhaleBanner.d.ts +10 -0
- package/dist/cli/shared/WhaleBanner.js +12 -0
- package/dist/cli/shared/markdown.d.ts +21 -0
- package/dist/cli/shared/markdown.js +756 -0
- package/dist/cli/status/StatusApp.d.ts +4 -0
- package/dist/cli/status/StatusApp.js +105 -0
- package/dist/cli/stores/StoreApp.d.ts +7 -0
- package/dist/cli/stores/StoreApp.js +81 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +538 -0
- package/dist/local-agent/connection.d.ts +48 -0
- package/dist/local-agent/connection.js +332 -0
- package/dist/local-agent/discovery.d.ts +18 -0
- package/dist/local-agent/discovery.js +146 -0
- package/dist/local-agent/executor.d.ts +34 -0
- package/dist/local-agent/executor.js +241 -0
- package/dist/local-agent/index.d.ts +14 -0
- package/dist/local-agent/index.js +198 -0
- package/dist/node/adapters/base.d.ts +35 -0
- package/dist/node/adapters/base.js +10 -0
- package/dist/node/adapters/discord.d.ts +29 -0
- package/dist/node/adapters/discord.js +299 -0
- package/dist/node/adapters/email.d.ts +23 -0
- package/dist/node/adapters/email.js +218 -0
- package/dist/node/adapters/imessage.d.ts +17 -0
- package/dist/node/adapters/imessage.js +118 -0
- package/dist/node/adapters/slack.d.ts +26 -0
- package/dist/node/adapters/slack.js +259 -0
- package/dist/node/adapters/sms.d.ts +23 -0
- package/dist/node/adapters/sms.js +161 -0
- package/dist/node/adapters/telegram.d.ts +17 -0
- package/dist/node/adapters/telegram.js +101 -0
- package/dist/node/adapters/webchat.d.ts +27 -0
- package/dist/node/adapters/webchat.js +160 -0
- package/dist/node/adapters/whatsapp.d.ts +28 -0
- package/dist/node/adapters/whatsapp.js +230 -0
- package/dist/node/cli.d.ts +2 -0
- package/dist/node/cli.js +325 -0
- package/dist/node/config.d.ts +17 -0
- package/dist/node/config.js +31 -0
- package/dist/node/runtime.d.ts +50 -0
- package/dist/node/runtime.js +351 -0
- package/dist/server/handlers/__test-utils__/mock-supabase.d.ts +11 -0
- package/dist/server/handlers/__test-utils__/mock-supabase.js +393 -0
- package/dist/server/handlers/analytics.d.ts +17 -0
- package/dist/server/handlers/analytics.js +266 -0
- package/dist/server/handlers/api-keys.d.ts +6 -0
- package/dist/server/handlers/api-keys.js +221 -0
- package/dist/server/handlers/billing.d.ts +33 -0
- package/dist/server/handlers/billing.js +272 -0
- package/dist/server/handlers/browser.d.ts +10 -0
- package/dist/server/handlers/browser.js +517 -0
- package/dist/server/handlers/catalog.d.ts +99 -0
- package/dist/server/handlers/catalog.js +976 -0
- package/dist/server/handlers/comms.d.ts +254 -0
- package/dist/server/handlers/comms.js +588 -0
- package/dist/server/handlers/creations.d.ts +6 -0
- package/dist/server/handlers/creations.js +479 -0
- package/dist/server/handlers/crm.d.ts +89 -0
- package/dist/server/handlers/crm.js +538 -0
- package/dist/server/handlers/discovery.d.ts +6 -0
- package/dist/server/handlers/discovery.js +288 -0
- package/dist/server/handlers/embeddings.d.ts +92 -0
- package/dist/server/handlers/embeddings.js +197 -0
- package/dist/server/handlers/enrichment.d.ts +8 -0
- package/dist/server/handlers/enrichment.js +768 -0
- package/dist/server/handlers/image-gen.d.ts +6 -0
- package/dist/server/handlers/image-gen.js +409 -0
- package/dist/server/handlers/inventory.d.ts +319 -0
- package/dist/server/handlers/inventory.js +447 -0
- package/dist/server/handlers/kali.d.ts +10 -0
- package/dist/server/handlers/kali.js +210 -0
- package/dist/server/handlers/llm-providers.d.ts +6 -0
- package/dist/server/handlers/llm-providers.js +673 -0
- package/dist/server/handlers/local-agent.d.ts +6 -0
- package/dist/server/handlers/local-agent.js +118 -0
- package/dist/server/handlers/meta-ads.d.ts +111 -0
- package/dist/server/handlers/meta-ads.js +2279 -0
- package/dist/server/handlers/nodes.d.ts +33 -0
- package/dist/server/handlers/nodes.js +699 -0
- package/dist/server/handlers/operations.d.ts +138 -0
- package/dist/server/handlers/operations.js +131 -0
- package/dist/server/handlers/platform.d.ts +23 -0
- package/dist/server/handlers/platform.js +227 -0
- package/dist/server/handlers/supply-chain.d.ts +19 -0
- package/dist/server/handlers/supply-chain.js +327 -0
- package/dist/server/handlers/transcription.d.ts +17 -0
- package/dist/server/handlers/transcription.js +121 -0
- package/dist/server/handlers/video-gen.d.ts +6 -0
- package/dist/server/handlers/video-gen.js +466 -0
- package/dist/server/handlers/voice.d.ts +8 -0
- package/dist/server/handlers/voice.js +1146 -0
- package/dist/server/handlers/workflow-steps.d.ts +86 -0
- package/dist/server/handlers/workflow-steps.js +2349 -0
- package/dist/server/handlers/workflows.d.ts +7 -0
- package/dist/server/handlers/workflows.js +989 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +2427 -0
- package/dist/server/lib/batch-client.d.ts +80 -0
- package/dist/server/lib/batch-client.js +467 -0
- package/dist/server/lib/code-worker-pool.d.ts +31 -0
- package/dist/server/lib/code-worker-pool.js +224 -0
- package/dist/server/lib/code-worker.d.ts +1 -0
- package/dist/server/lib/code-worker.js +188 -0
- package/dist/server/lib/compaction-service.d.ts +32 -0
- package/dist/server/lib/compaction-service.js +162 -0
- package/dist/server/lib/logger.d.ts +19 -0
- package/dist/server/lib/logger.js +46 -0
- package/dist/server/lib/otel.d.ts +38 -0
- package/dist/server/lib/otel.js +126 -0
- package/dist/server/lib/pg-rate-limiter.d.ts +21 -0
- package/dist/server/lib/pg-rate-limiter.js +86 -0
- package/dist/server/lib/prompt-sanitizer.d.ts +37 -0
- package/dist/server/lib/prompt-sanitizer.js +177 -0
- package/dist/server/lib/provider-capabilities.d.ts +85 -0
- package/dist/server/lib/provider-capabilities.js +190 -0
- package/dist/server/lib/provider-failover.d.ts +74 -0
- package/dist/server/lib/provider-failover.js +210 -0
- package/dist/server/lib/rate-limiter.d.ts +39 -0
- package/dist/server/lib/rate-limiter.js +147 -0
- package/dist/server/lib/server-agent-loop.d.ts +107 -0
- package/dist/server/lib/server-agent-loop.js +667 -0
- package/dist/server/lib/server-subagent.d.ts +78 -0
- package/dist/server/lib/server-subagent.js +203 -0
- package/dist/server/lib/session-checkpoint.d.ts +51 -0
- package/dist/server/lib/session-checkpoint.js +145 -0
- package/dist/server/lib/ssrf-guard.d.ts +13 -0
- package/dist/server/lib/ssrf-guard.js +240 -0
- package/dist/server/lib/supabase-client.d.ts +7 -0
- package/dist/server/lib/supabase-client.js +78 -0
- package/dist/server/lib/template-resolver.d.ts +31 -0
- package/dist/server/lib/template-resolver.js +215 -0
- package/dist/server/lib/utils.d.ts +16 -0
- package/dist/server/lib/utils.js +147 -0
- package/dist/server/local-agent-gateway.d.ts +82 -0
- package/dist/server/local-agent-gateway.js +426 -0
- package/dist/server/providers/anthropic.d.ts +20 -0
- package/dist/server/providers/anthropic.js +199 -0
- package/dist/server/providers/bedrock.d.ts +20 -0
- package/dist/server/providers/bedrock.js +194 -0
- package/dist/server/providers/gemini.d.ts +24 -0
- package/dist/server/providers/gemini.js +486 -0
- package/dist/server/providers/openai.d.ts +24 -0
- package/dist/server/providers/openai.js +522 -0
- package/dist/server/providers/registry.d.ts +32 -0
- package/dist/server/providers/registry.js +58 -0
- package/dist/server/providers/shared.d.ts +32 -0
- package/dist/server/providers/shared.js +124 -0
- package/dist/server/providers/types.d.ts +92 -0
- package/dist/server/providers/types.js +12 -0
- package/dist/server/proxy-handlers.d.ts +6 -0
- package/dist/server/proxy-handlers.js +89 -0
- package/dist/server/tool-router.d.ts +149 -0
- package/dist/server/tool-router.js +803 -0
- package/dist/server/validation.d.ts +24 -0
- package/dist/server/validation.js +301 -0
- package/dist/server/worker.d.ts +19 -0
- package/dist/server/worker.js +201 -0
- package/dist/setup.d.ts +8 -0
- package/dist/setup.js +181 -0
- package/dist/shared/agent-core.d.ts +157 -0
- package/dist/shared/agent-core.js +534 -0
- package/dist/shared/anthropic-types.d.ts +105 -0
- package/dist/shared/anthropic-types.js +7 -0
- package/dist/shared/api-client.d.ts +90 -0
- package/dist/shared/api-client.js +379 -0
- package/dist/shared/constants.d.ts +33 -0
- package/dist/shared/constants.js +80 -0
- package/dist/shared/sse-parser.d.ts +26 -0
- package/dist/shared/sse-parser.js +259 -0
- package/dist/shared/tool-dispatch.d.ts +52 -0
- package/dist/shared/tool-dispatch.js +191 -0
- package/dist/shared/types.d.ts +72 -0
- package/dist/shared/types.js +7 -0
- package/dist/updater.d.ts +25 -0
- package/dist/updater.js +140 -0
- package/dist/webchat/widget.d.ts +0 -0
- package/dist/webchat/widget.js +397 -0
- package/package.json +95 -0
- package/src/cli/services/builtin-skills/commit.md +19 -0
- package/src/cli/services/builtin-skills/review-pr.md +21 -0
- package/src/cli/services/builtin-skills/review.md +18 -0
|
@@ -0,0 +1,2279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meta Ads Handler — campaign/ad set/ad CRUD + publish to Meta Graph API.
|
|
3
|
+
*
|
|
4
|
+
* V2 features added:
|
|
5
|
+
* - Reporting breakdowns (age, gender, placement, device, region)
|
|
6
|
+
* - Ad preview URLs
|
|
7
|
+
* - Custom audiences (website, customer list, lookalike)
|
|
8
|
+
* - Lead forms (instant form creation + lead retrieval)
|
|
9
|
+
* - Conversion tracking (pixels, custom conversions)
|
|
10
|
+
* - Ad rules / automation (auto-pause, auto-budget)
|
|
11
|
+
* - Ad scheduling / dayparting
|
|
12
|
+
* - Placement control (Feed, Stories, Reels, etc.)
|
|
13
|
+
* - Frequency capping
|
|
14
|
+
* - Campaign Budget Optimization (CBO)
|
|
15
|
+
* - Carousel ads (multi-card)
|
|
16
|
+
* - Dynamic creative (multi-asset optimization)
|
|
17
|
+
* - Reach estimation / audience insights
|
|
18
|
+
*
|
|
19
|
+
* Lessons baked in from live testing against Meta Marketing API v21:
|
|
20
|
+
* - Budget lives on ad sets, NOT campaigns (unless CBO is enabled)
|
|
21
|
+
* - is_adset_budget_sharing_enabled: false required on campaign publish (non-CBO)
|
|
22
|
+
* - bid_strategy always required on ad set publish (even LOWEST_COST)
|
|
23
|
+
* - targeting_automation.advantage_audience: 0 required (Advantage+ flag)
|
|
24
|
+
* - DRAFT status invalid for Meta — always mapped to PAUSED
|
|
25
|
+
* - special_ad_categories only used at publish time (not a DB column)
|
|
26
|
+
* - Video URLs uploaded to Meta during publish → video_id
|
|
27
|
+
* - Image URLs uploaded to Meta during publish → image_hash
|
|
28
|
+
*/
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// CONSTANTS
|
|
31
|
+
// ============================================================================
|
|
32
|
+
const META_API_VERSION = "v21.0";
|
|
33
|
+
const META_BASE = `https://graph.facebook.com/${META_API_VERSION}`;
|
|
34
|
+
/** Valid optimization goals per campaign objective (Meta Marketing API v21). */
|
|
35
|
+
const VALID_GOALS = {
|
|
36
|
+
OUTCOME_AWARENESS: ["REACH", "IMPRESSIONS", "AD_RECALL_LIFT", "THRUPLAY"],
|
|
37
|
+
OUTCOME_TRAFFIC: ["LINK_CLICKS", "LANDING_PAGE_VIEWS", "REACH", "IMPRESSIONS"],
|
|
38
|
+
OUTCOME_ENGAGEMENT: ["THRUPLAY", "POST_ENGAGEMENT", "VIDEO_VIEWS", "REACH"],
|
|
39
|
+
OUTCOME_LEADS: ["LEAD_GENERATION", "LINK_CLICKS", "QUALITY_LEAD", "REACH"],
|
|
40
|
+
OUTCOME_APP_PROMOTION: ["APP_INSTALLS", "LINK_CLICKS", "APP_EVENTS", "VALUE"],
|
|
41
|
+
OUTCOME_SALES: ["OFFSITE_CONVERSIONS", "LINK_CLICKS", "VALUE", "REACH"],
|
|
42
|
+
};
|
|
43
|
+
/** Default optimization goal per objective. */
|
|
44
|
+
function defaultGoal(objective) {
|
|
45
|
+
return VALID_GOALS[objective]?.[0] ?? "LINK_CLICKS";
|
|
46
|
+
}
|
|
47
|
+
/** Validate goal against objective, falling back to the best default. */
|
|
48
|
+
function validateGoal(goal, objective) {
|
|
49
|
+
const valid = VALID_GOALS[objective];
|
|
50
|
+
if (!valid)
|
|
51
|
+
return goal;
|
|
52
|
+
return valid.includes(goal) ? goal : valid[0];
|
|
53
|
+
}
|
|
54
|
+
/** Billing event depends on optimization goal. */
|
|
55
|
+
function billingEvent(goal) {
|
|
56
|
+
return goal === "THRUPLAY" ? "THRUPLAY" : "IMPRESSIONS";
|
|
57
|
+
}
|
|
58
|
+
/** Map any status to Meta-valid status. Meta only accepts ACTIVE/PAUSED/DELETED/ARCHIVED. */
|
|
59
|
+
function metaStatus(status) {
|
|
60
|
+
if (!status || status === "DRAFT")
|
|
61
|
+
return "PAUSED";
|
|
62
|
+
return status;
|
|
63
|
+
}
|
|
64
|
+
// ============================================================================
|
|
65
|
+
// GRAPH API — low-level Meta helpers
|
|
66
|
+
// ============================================================================
|
|
67
|
+
/** Real Meta IDs are purely numeric. Local/Supabase IDs contain non-digit chars. */
|
|
68
|
+
function isLocalId(id) {
|
|
69
|
+
if (!id)
|
|
70
|
+
return true;
|
|
71
|
+
return /[^0-9]/.test(id);
|
|
72
|
+
}
|
|
73
|
+
/** RFC 3986 percent-encode. */
|
|
74
|
+
function enc(v) {
|
|
75
|
+
return encodeURIComponent(v);
|
|
76
|
+
}
|
|
77
|
+
/** Build form-encoded body from params object + access_token. */
|
|
78
|
+
function formBody(params, token) {
|
|
79
|
+
const parts = [`access_token=${enc(token)}`];
|
|
80
|
+
for (const [k, v] of Object.entries(params)) {
|
|
81
|
+
if (v === null || v === undefined)
|
|
82
|
+
continue;
|
|
83
|
+
parts.push(`${k}=${enc(typeof v === "object" ? JSON.stringify(v) : String(v))}`);
|
|
84
|
+
}
|
|
85
|
+
return parts.join("&");
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Parse Meta API error responses into human-readable messages.
|
|
89
|
+
* Extracts error_user_msg when available (much better than raw JSON).
|
|
90
|
+
*/
|
|
91
|
+
function parseMetaError(body, path, status) {
|
|
92
|
+
try {
|
|
93
|
+
const json = JSON.parse(body);
|
|
94
|
+
const err = json.error || {};
|
|
95
|
+
const userMsg = err.error_user_msg || err.message || "Unknown error";
|
|
96
|
+
const code = err.code ? ` (code ${err.code})` : "";
|
|
97
|
+
return `Meta API ${path}: ${userMsg}${code}`;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return `Meta API ${path}: ${status} — ${body.slice(0, 300)}`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** GET from Meta Graph API with auto-pagination. */
|
|
104
|
+
async function graphGet(path, fields, token, extra) {
|
|
105
|
+
let url = `${META_BASE}/${path}?fields=${fields}&access_token=${enc(token)}&limit=200`;
|
|
106
|
+
if (extra)
|
|
107
|
+
url += `&${extra}`;
|
|
108
|
+
const res = await fetch(url);
|
|
109
|
+
if (!res.ok) {
|
|
110
|
+
const body = await res.text();
|
|
111
|
+
throw new Error(parseMetaError(body, path, res.status));
|
|
112
|
+
}
|
|
113
|
+
const page1 = (await res.json());
|
|
114
|
+
// Single-object response — return as-is
|
|
115
|
+
if (!Array.isArray(page1.data))
|
|
116
|
+
return page1;
|
|
117
|
+
// List response — paginate up to 20 pages
|
|
118
|
+
const all = [...page1.data];
|
|
119
|
+
let next = page1.paging?.next;
|
|
120
|
+
let pages = 1;
|
|
121
|
+
while (next && pages < 20) {
|
|
122
|
+
const r = await fetch(next);
|
|
123
|
+
if (!r.ok)
|
|
124
|
+
break;
|
|
125
|
+
const j = (await r.json());
|
|
126
|
+
if (Array.isArray(j.data))
|
|
127
|
+
all.push(...j.data);
|
|
128
|
+
next = j.paging?.next;
|
|
129
|
+
pages++;
|
|
130
|
+
}
|
|
131
|
+
return { data: all };
|
|
132
|
+
}
|
|
133
|
+
/** POST to Meta Graph API with form encoding. */
|
|
134
|
+
async function graphPost(path, params, token) {
|
|
135
|
+
const res = await fetch(`${META_BASE}/${path}`, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
138
|
+
body: formBody(params, token),
|
|
139
|
+
});
|
|
140
|
+
if (!res.ok) {
|
|
141
|
+
const body = await res.text();
|
|
142
|
+
throw new Error(parseMetaError(body, path, res.status));
|
|
143
|
+
}
|
|
144
|
+
return (await res.json());
|
|
145
|
+
}
|
|
146
|
+
/** DELETE from Meta Graph API. */
|
|
147
|
+
async function graphDelete(path, token) {
|
|
148
|
+
const res = await fetch(`${META_BASE}/${path}?access_token=${enc(token)}`, {
|
|
149
|
+
method: "DELETE",
|
|
150
|
+
});
|
|
151
|
+
if (!res.ok) {
|
|
152
|
+
const body = await res.text();
|
|
153
|
+
throw new Error(parseMetaError(body, path, res.status));
|
|
154
|
+
}
|
|
155
|
+
return (await res.json());
|
|
156
|
+
}
|
|
157
|
+
// ============================================================================
|
|
158
|
+
// CREDENTIALS
|
|
159
|
+
// ============================================================================
|
|
160
|
+
async function getIntegration(sb, storeId) {
|
|
161
|
+
const { data, error } = await sb
|
|
162
|
+
.from("meta_integrations")
|
|
163
|
+
.select("access_token_encrypted, ad_account_id, page_id, pixel_id, instagram_business_id, token_expires_at, status")
|
|
164
|
+
.eq("store_id", storeId)
|
|
165
|
+
.limit(1)
|
|
166
|
+
.single();
|
|
167
|
+
if (error || !data?.access_token_encrypted || !data?.ad_account_id) {
|
|
168
|
+
throw new Error("Meta not connected. Go to Settings → Meta Connection to link your account.");
|
|
169
|
+
}
|
|
170
|
+
// Warn if token is expired (but still return it — some operations may work with refresh)
|
|
171
|
+
if (data.token_expires_at) {
|
|
172
|
+
const expiresAt = new Date(data.token_expires_at);
|
|
173
|
+
if (expiresAt < new Date()) {
|
|
174
|
+
throw new Error(`Meta access token expired at ${expiresAt.toISOString()}. Reconnect in the CRM app (Settings → Meta Connection).`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
accessToken: data.access_token_encrypted,
|
|
179
|
+
adAccountId: data.ad_account_id,
|
|
180
|
+
pageId: data.page_id || null,
|
|
181
|
+
pixelId: data.pixel_id || null,
|
|
182
|
+
instagramAccountId: data.instagram_business_id || null,
|
|
183
|
+
tokenExpiresAt: data.token_expires_at || null,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/** Page Access Token cache (user token → page token). Avoids re-fetching every call. */
|
|
187
|
+
const pageTokenCache = new Map();
|
|
188
|
+
/**
|
|
189
|
+
* Exchange user access token for a Page Access Token.
|
|
190
|
+
* Required for lead forms, page-level APIs, and some creative operations.
|
|
191
|
+
* Cached for 5 minutes.
|
|
192
|
+
*/
|
|
193
|
+
async function getPageToken(int) {
|
|
194
|
+
if (!int.pageId)
|
|
195
|
+
throw new Error("No Facebook Page linked. Add page_id in Meta Connection settings.");
|
|
196
|
+
const cacheKey = `${int.pageId}:${int.accessToken.slice(-10)}`;
|
|
197
|
+
const cached = pageTokenCache.get(cacheKey);
|
|
198
|
+
if (cached && Date.now() - cached.fetchedAt < 300_000)
|
|
199
|
+
return cached.token;
|
|
200
|
+
const result = await graphGet(int.pageId, "access_token", int.accessToken);
|
|
201
|
+
const pageToken = result.access_token;
|
|
202
|
+
if (!pageToken)
|
|
203
|
+
throw new Error("Could not get Page Access Token. Ensure your token has pages_manage_ads permission.");
|
|
204
|
+
pageTokenCache.set(cacheKey, { token: pageToken, fetchedAt: Date.now() });
|
|
205
|
+
// Evict old entries
|
|
206
|
+
if (pageTokenCache.size > 20)
|
|
207
|
+
pageTokenCache.delete([...pageTokenCache.keys()][0]);
|
|
208
|
+
return pageToken;
|
|
209
|
+
}
|
|
210
|
+
// ============================================================================
|
|
211
|
+
// TARGETING BUILDER — smart resolution from natural language
|
|
212
|
+
// ============================================================================
|
|
213
|
+
async function resolveAudienceIds(sb, ids) {
|
|
214
|
+
if (!ids.length)
|
|
215
|
+
return [];
|
|
216
|
+
const { data } = await sb
|
|
217
|
+
.from("meta_audiences")
|
|
218
|
+
.select("id, meta_audience_id")
|
|
219
|
+
.in("id", ids);
|
|
220
|
+
return (data || [])
|
|
221
|
+
.filter((r) => r.meta_audience_id)
|
|
222
|
+
.map((r) => ({ id: r.meta_audience_id }));
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Resolve natural-language location strings to Meta geo_locations.
|
|
226
|
+
* Accepts: ["Charlotte, NC", "US", "New York", "90210"]
|
|
227
|
+
* Returns: { countries: [...], cities: [...], regions: [...], zips: [...] }
|
|
228
|
+
*/
|
|
229
|
+
async function resolveLocations(int, locations) {
|
|
230
|
+
const geo = {};
|
|
231
|
+
const countries = [];
|
|
232
|
+
const regions = [];
|
|
233
|
+
const cities = [];
|
|
234
|
+
const zips = [];
|
|
235
|
+
for (const loc of locations) {
|
|
236
|
+
const trimmed = loc.trim();
|
|
237
|
+
if (!trimmed)
|
|
238
|
+
continue;
|
|
239
|
+
// 2-letter country code
|
|
240
|
+
if (/^[A-Z]{2}$/i.test(trimmed)) {
|
|
241
|
+
countries.push(trimmed.toUpperCase());
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
// ZIP code (5-digit US)
|
|
245
|
+
if (/^\d{5}$/.test(trimmed)) {
|
|
246
|
+
try {
|
|
247
|
+
const url = `${META_BASE}/search?type=adgeolocation&q=${enc(trimmed)}&location_types=["zip"]&access_token=${enc(int.accessToken)}&limit=1`;
|
|
248
|
+
const res = await fetch(url);
|
|
249
|
+
if (res.ok) {
|
|
250
|
+
const json = (await res.json());
|
|
251
|
+
const results = json.data;
|
|
252
|
+
if (results?.[0]?.key) {
|
|
253
|
+
zips.push({ key: results[0].key });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch { /* fall through to general search */ }
|
|
259
|
+
zips.push({ key: trimmed });
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
// General search — let Meta figure out if it's a city, region, etc.
|
|
263
|
+
try {
|
|
264
|
+
const url = `${META_BASE}/search?type=adgeolocation&q=${enc(trimmed)}&access_token=${enc(int.accessToken)}&limit=3`;
|
|
265
|
+
const res = await fetch(url);
|
|
266
|
+
if (!res.ok)
|
|
267
|
+
continue;
|
|
268
|
+
const json = (await res.json());
|
|
269
|
+
const results = json.data;
|
|
270
|
+
if (!results?.length)
|
|
271
|
+
continue;
|
|
272
|
+
const best = results[0];
|
|
273
|
+
const key = best.key;
|
|
274
|
+
const type = best.type;
|
|
275
|
+
switch (type) {
|
|
276
|
+
case "country":
|
|
277
|
+
countries.push(key);
|
|
278
|
+
break;
|
|
279
|
+
case "region":
|
|
280
|
+
regions.push({ key });
|
|
281
|
+
break;
|
|
282
|
+
case "city":
|
|
283
|
+
cities.push({ key, radius: 25, distance_unit: "mile" });
|
|
284
|
+
break;
|
|
285
|
+
case "zip":
|
|
286
|
+
zips.push({ key });
|
|
287
|
+
break;
|
|
288
|
+
case "geo_market":
|
|
289
|
+
(geo.geo_markets || (geo.geo_markets = []));
|
|
290
|
+
geo.geo_markets.push({ key });
|
|
291
|
+
break;
|
|
292
|
+
default:
|
|
293
|
+
if (key)
|
|
294
|
+
cities.push({ key, radius: 25, distance_unit: "mile" });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch { /* skip unresolvable locations */ }
|
|
298
|
+
}
|
|
299
|
+
if (countries.length)
|
|
300
|
+
geo.countries = countries;
|
|
301
|
+
if (regions.length)
|
|
302
|
+
geo.regions = regions;
|
|
303
|
+
if (cities.length)
|
|
304
|
+
geo.cities = cities;
|
|
305
|
+
if (zips.length)
|
|
306
|
+
geo.zips = zips;
|
|
307
|
+
// Fallback: if nothing resolved, default to US
|
|
308
|
+
if (!Object.keys(geo).length)
|
|
309
|
+
geo.countries = ["US"];
|
|
310
|
+
return geo;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Resolve natural-language interest strings to Meta interest IDs.
|
|
314
|
+
* Accepts: ["streetwear", "hip hop music", "cannabis"]
|
|
315
|
+
*/
|
|
316
|
+
async function resolveInterests(int, interests) {
|
|
317
|
+
const resolved = [];
|
|
318
|
+
for (const term of interests) {
|
|
319
|
+
const trimmed = term.trim();
|
|
320
|
+
if (!trimmed)
|
|
321
|
+
continue;
|
|
322
|
+
try {
|
|
323
|
+
const url = `${META_BASE}/search?type=adinterest&q=${enc(trimmed)}&access_token=${enc(int.accessToken)}&limit=3`;
|
|
324
|
+
const res = await fetch(url);
|
|
325
|
+
if (!res.ok)
|
|
326
|
+
continue;
|
|
327
|
+
const json = (await res.json());
|
|
328
|
+
const results = json.data;
|
|
329
|
+
if (results?.length) {
|
|
330
|
+
const best = results[0];
|
|
331
|
+
if (best.id && best.name) {
|
|
332
|
+
resolved.push({ id: String(best.id), name: best.name });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
catch { /* skip unresolvable interests */ }
|
|
337
|
+
}
|
|
338
|
+
return resolved;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Convert targeting args → Meta API targeting spec.
|
|
342
|
+
* Accepts THREE formats (auto-detected).
|
|
343
|
+
*/
|
|
344
|
+
async function buildTargeting(sb, raw, int) {
|
|
345
|
+
const fallback = {
|
|
346
|
+
geo_locations: { countries: ["US"] },
|
|
347
|
+
targeting_automation: { advantage_audience: 0 },
|
|
348
|
+
};
|
|
349
|
+
let t;
|
|
350
|
+
if (!raw)
|
|
351
|
+
return fallback;
|
|
352
|
+
if (typeof raw === "string") {
|
|
353
|
+
try {
|
|
354
|
+
t = JSON.parse(raw);
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return fallback;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
t = raw;
|
|
362
|
+
}
|
|
363
|
+
// Already in Meta format (synced from Meta) — ensure advantage_audience is set
|
|
364
|
+
if (t.geo_locations) {
|
|
365
|
+
if (!t.targeting_automation)
|
|
366
|
+
t.targeting_automation = { advantage_audience: 0 };
|
|
367
|
+
return t;
|
|
368
|
+
}
|
|
369
|
+
const result = {};
|
|
370
|
+
// === LOCATIONS ===
|
|
371
|
+
const simpleLocs = t.locations;
|
|
372
|
+
if (Array.isArray(simpleLocs) && simpleLocs.length > 0 && typeof simpleLocs[0] === "string" && int) {
|
|
373
|
+
result.geo_locations = await resolveLocations(int, simpleLocs);
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
const locs = t.locations_v2;
|
|
377
|
+
if (Array.isArray(locs) && locs.length > 0) {
|
|
378
|
+
const geo = {};
|
|
379
|
+
const countries = [];
|
|
380
|
+
const regions = [];
|
|
381
|
+
const cities = [];
|
|
382
|
+
const zips = [];
|
|
383
|
+
const markets = [];
|
|
384
|
+
for (const loc of locs) {
|
|
385
|
+
if (!loc.key || !loc.type)
|
|
386
|
+
continue;
|
|
387
|
+
switch (loc.type) {
|
|
388
|
+
case "country":
|
|
389
|
+
countries.push(loc.key);
|
|
390
|
+
break;
|
|
391
|
+
case "region":
|
|
392
|
+
regions.push({ key: loc.key });
|
|
393
|
+
break;
|
|
394
|
+
case "city":
|
|
395
|
+
cities.push({ key: loc.key });
|
|
396
|
+
break;
|
|
397
|
+
case "zip":
|
|
398
|
+
zips.push({ key: loc.key });
|
|
399
|
+
break;
|
|
400
|
+
case "geo_market":
|
|
401
|
+
markets.push({ key: loc.key });
|
|
402
|
+
break;
|
|
403
|
+
default: countries.push(loc.key);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (countries.length)
|
|
407
|
+
geo.countries = countries;
|
|
408
|
+
if (regions.length)
|
|
409
|
+
geo.regions = regions;
|
|
410
|
+
if (cities.length)
|
|
411
|
+
geo.cities = cities;
|
|
412
|
+
if (zips.length)
|
|
413
|
+
geo.zips = zips;
|
|
414
|
+
if (markets.length)
|
|
415
|
+
geo.geo_markets = markets;
|
|
416
|
+
result.geo_locations = geo;
|
|
417
|
+
}
|
|
418
|
+
else if (typeof t.locations === "string" && t.locations) {
|
|
419
|
+
result.geo_locations = { countries: t.locations.split(",").map(s => s.trim().toUpperCase()) };
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
result.geo_locations = { countries: ["US"] };
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// === DEMOGRAPHICS ===
|
|
426
|
+
if (t.age_min != null)
|
|
427
|
+
result.age_min = Math.max(Number(t.age_min) || 18, 13);
|
|
428
|
+
if (t.age_max != null)
|
|
429
|
+
result.age_max = Math.min(Number(t.age_max) || 65, 65);
|
|
430
|
+
if (t.genders === "male")
|
|
431
|
+
result.genders = [1];
|
|
432
|
+
else if (t.genders === "female")
|
|
433
|
+
result.genders = [2];
|
|
434
|
+
// === INTERESTS ===
|
|
435
|
+
const simpleInts = t.interests;
|
|
436
|
+
if (Array.isArray(simpleInts) && simpleInts.length > 0 && typeof simpleInts[0] === "string" && int) {
|
|
437
|
+
const resolved = await resolveInterests(int, simpleInts);
|
|
438
|
+
if (resolved.length)
|
|
439
|
+
result.flexible_spec = [{ interests: resolved }];
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
const ints = t.interests_v2;
|
|
443
|
+
if (Array.isArray(ints) && ints.length > 0) {
|
|
444
|
+
const valid = ints.filter(i => i.id && i.name).map(i => ({ id: i.id, name: i.name }));
|
|
445
|
+
if (valid.length)
|
|
446
|
+
result.flexible_spec = [{ interests: valid }];
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// === CUSTOM AUDIENCES ===
|
|
450
|
+
if (typeof t.custom_audiences === "string" && t.custom_audiences) {
|
|
451
|
+
const resolved = await resolveAudienceIds(sb, t.custom_audiences.split(",").map(s => s.trim()));
|
|
452
|
+
if (resolved.length)
|
|
453
|
+
result.custom_audiences = resolved;
|
|
454
|
+
}
|
|
455
|
+
else if (Array.isArray(t.custom_audiences)) {
|
|
456
|
+
result.custom_audiences = t.custom_audiences;
|
|
457
|
+
}
|
|
458
|
+
// === EXCLUDED AUDIENCES ===
|
|
459
|
+
if (typeof t.excluded_custom_audiences === "string" && t.excluded_custom_audiences) {
|
|
460
|
+
const resolved = await resolveAudienceIds(sb, t.excluded_custom_audiences.split(",").map(s => s.trim()));
|
|
461
|
+
if (resolved.length)
|
|
462
|
+
result.excluded_custom_audiences = resolved;
|
|
463
|
+
}
|
|
464
|
+
// === Required: Advantage audience flag ===
|
|
465
|
+
result.targeting_automation = { advantage_audience: 0 };
|
|
466
|
+
// === PUBLISHER PLATFORMS (placement control) ===
|
|
467
|
+
if (Array.isArray(t.publisher_platforms)) {
|
|
468
|
+
result.publisher_platforms = t.publisher_platforms;
|
|
469
|
+
}
|
|
470
|
+
if (Array.isArray(t.facebook_positions)) {
|
|
471
|
+
result.facebook_positions = t.facebook_positions;
|
|
472
|
+
}
|
|
473
|
+
if (Array.isArray(t.instagram_positions)) {
|
|
474
|
+
result.instagram_positions = t.instagram_positions;
|
|
475
|
+
}
|
|
476
|
+
if (Array.isArray(t.audience_network_positions)) {
|
|
477
|
+
result.audience_network_positions = t.audience_network_positions;
|
|
478
|
+
}
|
|
479
|
+
if (Array.isArray(t.messenger_positions)) {
|
|
480
|
+
result.messenger_positions = t.messenger_positions;
|
|
481
|
+
}
|
|
482
|
+
return result;
|
|
483
|
+
}
|
|
484
|
+
// ============================================================================
|
|
485
|
+
// MEDIA UPLOAD
|
|
486
|
+
// ============================================================================
|
|
487
|
+
/** Upload image URL to Meta → returns image_hash. */
|
|
488
|
+
async function uploadImage(int, url) {
|
|
489
|
+
try {
|
|
490
|
+
const r = await graphPost(`${int.adAccountId}/adimages`, { url }, int.accessToken);
|
|
491
|
+
const images = r.images;
|
|
492
|
+
if (images) {
|
|
493
|
+
const first = Object.values(images)[0];
|
|
494
|
+
if (first?.hash)
|
|
495
|
+
return first.hash;
|
|
496
|
+
}
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Upload video URL to Meta → returns video_id.
|
|
505
|
+
* Meta's async video upload: POST the URL, poll until ready.
|
|
506
|
+
*/
|
|
507
|
+
async function uploadVideo(int, videoUrl) {
|
|
508
|
+
let videoId = null;
|
|
509
|
+
const errors = [];
|
|
510
|
+
// --- Strategy 1: file_url via query params ---
|
|
511
|
+
try {
|
|
512
|
+
const url = `${META_BASE}/${int.adAccountId}/advideos?access_token=${enc(int.accessToken)}&file_url=${enc(videoUrl)}`;
|
|
513
|
+
const res = await fetch(url, { method: "POST" });
|
|
514
|
+
if (res.ok) {
|
|
515
|
+
const json = (await res.json());
|
|
516
|
+
videoId = json.id;
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
errors.push(`advideos/file_url: ${(await res.text()).slice(0, 200)}`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
catch (e) {
|
|
523
|
+
errors.push(`advideos/file_url: ${e instanceof Error ? e.message : String(e)}`);
|
|
524
|
+
}
|
|
525
|
+
// --- Strategy 2: binary upload via FormData ---
|
|
526
|
+
if (!videoId) {
|
|
527
|
+
try {
|
|
528
|
+
const downloadRes = await fetch(videoUrl);
|
|
529
|
+
if (!downloadRes.ok)
|
|
530
|
+
throw new Error(`download failed: ${downloadRes.status}`);
|
|
531
|
+
const blob = await downloadRes.blob();
|
|
532
|
+
const form = new FormData();
|
|
533
|
+
form.append("access_token", int.accessToken);
|
|
534
|
+
form.append("source", blob, "video.mp4");
|
|
535
|
+
const res = await fetch(`${META_BASE}/${int.adAccountId}/advideos`, { method: "POST", body: form });
|
|
536
|
+
if (res.ok) {
|
|
537
|
+
const json = (await res.json());
|
|
538
|
+
videoId = json.id;
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
errors.push(`advideos/binary: ${(await res.text()).slice(0, 200)}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
catch (e) {
|
|
545
|
+
errors.push(`advideos/binary: ${e instanceof Error ? e.message : String(e)}`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
// --- Strategy 3: Page video upload ---
|
|
549
|
+
if (!videoId && int.pageId) {
|
|
550
|
+
try {
|
|
551
|
+
const downloadRes = await fetch(videoUrl);
|
|
552
|
+
if (!downloadRes.ok)
|
|
553
|
+
throw new Error(`download failed: ${downloadRes.status}`);
|
|
554
|
+
const blob = await downloadRes.blob();
|
|
555
|
+
const form = new FormData();
|
|
556
|
+
form.append("access_token", int.accessToken);
|
|
557
|
+
form.append("source", blob, "video.mp4");
|
|
558
|
+
form.append("published", "false");
|
|
559
|
+
form.append("no_story", "true");
|
|
560
|
+
const res = await fetch(`${META_BASE}/${int.pageId}/videos`, { method: "POST", body: form });
|
|
561
|
+
if (res.ok) {
|
|
562
|
+
const json = (await res.json());
|
|
563
|
+
videoId = json.id;
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
errors.push(`page/videos: ${(await res.text()).slice(0, 200)}`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
catch (e) {
|
|
570
|
+
errors.push(`page/videos: ${e instanceof Error ? e.message : String(e)}`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
if (!videoId) {
|
|
574
|
+
throw new Error(`Video upload failed. Tried 3 strategies:\n${errors.join("\n")}`);
|
|
575
|
+
}
|
|
576
|
+
// Poll for processing (up to 90s)
|
|
577
|
+
for (let i = 0; i < 18; i++) {
|
|
578
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
579
|
+
try {
|
|
580
|
+
const s = await graphGet(videoId, "status", int.accessToken);
|
|
581
|
+
const phase = s.status?.processing_phase;
|
|
582
|
+
if (phase === "ready" || phase === "complete")
|
|
583
|
+
return videoId;
|
|
584
|
+
if (phase === "error")
|
|
585
|
+
throw new Error(`Meta video processing failed for ${videoId}`);
|
|
586
|
+
}
|
|
587
|
+
catch (e) {
|
|
588
|
+
if (e instanceof Error && e.message.includes("processing failed"))
|
|
589
|
+
throw e;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return videoId;
|
|
593
|
+
}
|
|
594
|
+
// ============================================================================
|
|
595
|
+
// OBJECT STORY SPEC
|
|
596
|
+
// ============================================================================
|
|
597
|
+
function buildStorySpec(creative, pageId, imageHash, metaVideoId, carouselHashes) {
|
|
598
|
+
// --- CAROUSEL ---
|
|
599
|
+
if (carouselHashes && carouselHashes.length > 0) {
|
|
600
|
+
const childAttachments = carouselHashes.map(({ hash, videoId, card }) => {
|
|
601
|
+
const child = {};
|
|
602
|
+
if (card.link_url)
|
|
603
|
+
child.link = card.link_url;
|
|
604
|
+
if (card.headline)
|
|
605
|
+
child.name = card.headline;
|
|
606
|
+
if (card.description)
|
|
607
|
+
child.description = card.description;
|
|
608
|
+
if (videoId) {
|
|
609
|
+
child.video_id = videoId;
|
|
610
|
+
if (hash)
|
|
611
|
+
child.image_hash = hash;
|
|
612
|
+
}
|
|
613
|
+
else if (hash) {
|
|
614
|
+
child.image_hash = hash;
|
|
615
|
+
}
|
|
616
|
+
else if (card.image_url) {
|
|
617
|
+
child.picture = card.image_url;
|
|
618
|
+
}
|
|
619
|
+
if (card.call_to_action && card.link_url) {
|
|
620
|
+
child.call_to_action = { type: card.call_to_action, value: { link: card.link_url } };
|
|
621
|
+
}
|
|
622
|
+
return child;
|
|
623
|
+
});
|
|
624
|
+
const linkData = {
|
|
625
|
+
child_attachments: childAttachments,
|
|
626
|
+
multi_share_optimized: true,
|
|
627
|
+
};
|
|
628
|
+
if (creative?.link_url)
|
|
629
|
+
linkData.link = creative.link_url;
|
|
630
|
+
if (creative?.body)
|
|
631
|
+
linkData.message = creative.body;
|
|
632
|
+
return { page_id: pageId, link_data: linkData };
|
|
633
|
+
}
|
|
634
|
+
const isVideo = metaVideoId || creative?.object_type?.toUpperCase() === "VIDEO";
|
|
635
|
+
if (isVideo && (metaVideoId || creative?.video_id)) {
|
|
636
|
+
const vid = { video_id: metaVideoId || creative.video_id };
|
|
637
|
+
if (creative?.body)
|
|
638
|
+
vid.message = creative.body;
|
|
639
|
+
if (creative?.title)
|
|
640
|
+
vid.title = creative.title;
|
|
641
|
+
if (imageHash)
|
|
642
|
+
vid.image_hash = imageHash;
|
|
643
|
+
else if (creative?.image_url)
|
|
644
|
+
vid.image_url = creative.image_url;
|
|
645
|
+
if (creative?.call_to_action && creative?.link_url) {
|
|
646
|
+
vid.call_to_action = { type: creative.call_to_action, value: { link: creative.link_url } };
|
|
647
|
+
}
|
|
648
|
+
return { page_id: pageId, video_data: vid };
|
|
649
|
+
}
|
|
650
|
+
const link = {};
|
|
651
|
+
if (creative?.link_url)
|
|
652
|
+
link.link = creative.link_url;
|
|
653
|
+
if (creative?.body)
|
|
654
|
+
link.message = creative.body;
|
|
655
|
+
if (creative?.title)
|
|
656
|
+
link.name = creative.title;
|
|
657
|
+
if (imageHash)
|
|
658
|
+
link.image_hash = imageHash;
|
|
659
|
+
else if (creative?.image_url)
|
|
660
|
+
link.picture = creative.image_url;
|
|
661
|
+
if (creative?.call_to_action && creative?.link_url) {
|
|
662
|
+
link.call_to_action = { type: creative.call_to_action, value: { link: creative.link_url } };
|
|
663
|
+
}
|
|
664
|
+
return { page_id: pageId, link_data: link };
|
|
665
|
+
}
|
|
666
|
+
// ============================================================================
|
|
667
|
+
// PUBLISH PIPELINE
|
|
668
|
+
// ============================================================================
|
|
669
|
+
async function publishCampaign(sb, storeId, campaignId) {
|
|
670
|
+
const { data: c, error } = await sb
|
|
671
|
+
.from("meta_campaigns").select("*").eq("id", campaignId).single();
|
|
672
|
+
if (error || !c)
|
|
673
|
+
throw new Error(`Campaign not found: ${campaignId}`);
|
|
674
|
+
// Already published — skip
|
|
675
|
+
if (!isLocalId(c.meta_campaign_id))
|
|
676
|
+
return { metaCampaignId: c.meta_campaign_id };
|
|
677
|
+
const int = await getIntegration(sb, storeId);
|
|
678
|
+
const params = {
|
|
679
|
+
name: c.name || "Campaign",
|
|
680
|
+
objective: c.objective || "OUTCOME_AWARENESS",
|
|
681
|
+
status: "PAUSED",
|
|
682
|
+
special_ad_categories: ["NONE"],
|
|
683
|
+
};
|
|
684
|
+
// CBO: budget at campaign level
|
|
685
|
+
if (c.is_cbo) {
|
|
686
|
+
params.is_adset_budget_sharing_enabled = true;
|
|
687
|
+
if (c.daily_budget > 0)
|
|
688
|
+
params.daily_budget = Math.round(c.daily_budget * 100);
|
|
689
|
+
if (c.lifetime_budget > 0)
|
|
690
|
+
params.lifetime_budget = Math.round(c.lifetime_budget * 100);
|
|
691
|
+
if (c.campaign_bid_strategy)
|
|
692
|
+
params.bid_strategy = c.campaign_bid_strategy;
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
params.is_adset_budget_sharing_enabled = false;
|
|
696
|
+
}
|
|
697
|
+
if (c.start_time)
|
|
698
|
+
params.start_time = c.start_time;
|
|
699
|
+
if (c.stop_time)
|
|
700
|
+
params.stop_time = c.stop_time;
|
|
701
|
+
const result = await graphPost(`${int.adAccountId}/campaigns`, params, int.accessToken);
|
|
702
|
+
const metaId = result.id;
|
|
703
|
+
if (!metaId)
|
|
704
|
+
throw new Error("Meta returned no campaign ID");
|
|
705
|
+
await sb.from("meta_campaigns").update({
|
|
706
|
+
meta_campaign_id: metaId,
|
|
707
|
+
meta_account_id: int.adAccountId,
|
|
708
|
+
last_synced_at: new Date().toISOString(),
|
|
709
|
+
}).eq("id", campaignId);
|
|
710
|
+
return { metaCampaignId: metaId };
|
|
711
|
+
}
|
|
712
|
+
async function publishAdSet(sb, storeId, adSetId) {
|
|
713
|
+
const { data: as_, error } = await sb
|
|
714
|
+
.from("meta_ad_sets").select("*").eq("id", adSetId).single();
|
|
715
|
+
if (error || !as_)
|
|
716
|
+
throw new Error(`Ad set not found: ${adSetId}`);
|
|
717
|
+
if (!isLocalId(as_.meta_ad_set_id)) {
|
|
718
|
+
return { metaAdSetId: as_.meta_ad_set_id, metaCampaignId: "" };
|
|
719
|
+
}
|
|
720
|
+
const int = await getIntegration(sb, storeId);
|
|
721
|
+
// Cascade: publish parent campaign if needed
|
|
722
|
+
const campaignUUID = as_.meta_campaign_id;
|
|
723
|
+
if (!campaignUUID)
|
|
724
|
+
throw new Error("Ad set has no campaign reference");
|
|
725
|
+
const { data: camp } = await sb
|
|
726
|
+
.from("meta_campaigns").select("*").eq("id", campaignUUID).single();
|
|
727
|
+
if (!camp)
|
|
728
|
+
throw new Error(`Parent campaign not found: ${campaignUUID}`);
|
|
729
|
+
let metaCampaignId = camp.meta_campaign_id || "";
|
|
730
|
+
if (isLocalId(metaCampaignId)) {
|
|
731
|
+
const pub = await publishCampaign(sb, storeId, campaignUUID);
|
|
732
|
+
metaCampaignId = pub.metaCampaignId;
|
|
733
|
+
}
|
|
734
|
+
// CBO detection: check if campaign has budget on Meta
|
|
735
|
+
const isCBO = !!camp.is_cbo;
|
|
736
|
+
let campaignHasCBO = isCBO;
|
|
737
|
+
if (!isCBO) {
|
|
738
|
+
try {
|
|
739
|
+
const mc = await graphGet(metaCampaignId, "daily_budget,lifetime_budget,bid_strategy", int.accessToken);
|
|
740
|
+
if (mc.daily_budget || mc.lifetime_budget) {
|
|
741
|
+
campaignHasCBO = true;
|
|
742
|
+
if (mc.bid_strategy && mc.bid_strategy !== "LOWEST_COST_WITHOUT_CAP") {
|
|
743
|
+
await graphPost(metaCampaignId, { bid_strategy: "LOWEST_COST_WITHOUT_CAP" }, int.accessToken);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
catch { /* proceed without CBO check */ }
|
|
748
|
+
}
|
|
749
|
+
const optGoal = validateGoal(as_.optimization_goal || "LINK_CLICKS", camp.objective || "OUTCOME_AWARENESS");
|
|
750
|
+
// Build targeting with placement control
|
|
751
|
+
const targetingSpec = await buildTargeting(sb, as_.targeting, int);
|
|
752
|
+
// Merge placement config from ad set columns into targeting
|
|
753
|
+
if (as_.publisher_platforms) {
|
|
754
|
+
const pp = typeof as_.publisher_platforms === "string"
|
|
755
|
+
? JSON.parse(as_.publisher_platforms) : as_.publisher_platforms;
|
|
756
|
+
if (Array.isArray(pp) && pp.length)
|
|
757
|
+
targetingSpec.publisher_platforms = pp;
|
|
758
|
+
}
|
|
759
|
+
if (as_.facebook_positions) {
|
|
760
|
+
const fp = typeof as_.facebook_positions === "string"
|
|
761
|
+
? JSON.parse(as_.facebook_positions) : as_.facebook_positions;
|
|
762
|
+
if (Array.isArray(fp) && fp.length)
|
|
763
|
+
targetingSpec.facebook_positions = fp;
|
|
764
|
+
}
|
|
765
|
+
if (as_.instagram_positions) {
|
|
766
|
+
const ip = typeof as_.instagram_positions === "string"
|
|
767
|
+
? JSON.parse(as_.instagram_positions) : as_.instagram_positions;
|
|
768
|
+
if (Array.isArray(ip) && ip.length)
|
|
769
|
+
targetingSpec.instagram_positions = ip;
|
|
770
|
+
}
|
|
771
|
+
if (as_.audience_network_positions) {
|
|
772
|
+
const anp = typeof as_.audience_network_positions === "string"
|
|
773
|
+
? JSON.parse(as_.audience_network_positions) : as_.audience_network_positions;
|
|
774
|
+
if (Array.isArray(anp) && anp.length)
|
|
775
|
+
targetingSpec.audience_network_positions = anp;
|
|
776
|
+
}
|
|
777
|
+
if (as_.messenger_positions) {
|
|
778
|
+
const mp = typeof as_.messenger_positions === "string"
|
|
779
|
+
? JSON.parse(as_.messenger_positions) : as_.messenger_positions;
|
|
780
|
+
if (Array.isArray(mp) && mp.length)
|
|
781
|
+
targetingSpec.messenger_positions = mp;
|
|
782
|
+
}
|
|
783
|
+
const params = {
|
|
784
|
+
campaign_id: metaCampaignId,
|
|
785
|
+
name: as_.name || "Ad Set",
|
|
786
|
+
billing_event: billingEvent(optGoal),
|
|
787
|
+
optimization_goal: optGoal,
|
|
788
|
+
status: "PAUSED",
|
|
789
|
+
targeting: targetingSpec,
|
|
790
|
+
};
|
|
791
|
+
if (!campaignHasCBO) {
|
|
792
|
+
// Ad set level budget + bid strategy (always required for non-CBO)
|
|
793
|
+
if (as_.daily_budget > 0)
|
|
794
|
+
params.daily_budget = Math.round(as_.daily_budget * 100);
|
|
795
|
+
if (as_.lifetime_budget > 0)
|
|
796
|
+
params.lifetime_budget = Math.round(as_.lifetime_budget * 100);
|
|
797
|
+
const bs = as_.bid_strategy || "LOWEST_COST_WITHOUT_CAP";
|
|
798
|
+
params.bid_strategy = bs;
|
|
799
|
+
if (bs !== "LOWEST_COST_WITHOUT_CAP" && as_.bid_amount > 0) {
|
|
800
|
+
params.bid_amount = Math.round(as_.bid_amount * 100);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
if (as_.start_time)
|
|
804
|
+
params.start_time = as_.start_time;
|
|
805
|
+
if (as_.end_time) {
|
|
806
|
+
params.end_time = as_.end_time;
|
|
807
|
+
}
|
|
808
|
+
else if (!campaignHasCBO && as_.lifetime_budget > 0) {
|
|
809
|
+
params.end_time = new Date(Date.now() + 30 * 86400000).toISOString();
|
|
810
|
+
}
|
|
811
|
+
// --- Ad scheduling / dayparting ---
|
|
812
|
+
if (as_.adset_schedule) {
|
|
813
|
+
const schedule = typeof as_.adset_schedule === "string"
|
|
814
|
+
? JSON.parse(as_.adset_schedule) : as_.adset_schedule;
|
|
815
|
+
if (Array.isArray(schedule) && schedule.length) {
|
|
816
|
+
params.adset_schedule = schedule;
|
|
817
|
+
// Dayparting requires lifetime budget or pacing_type USER_DEFINED
|
|
818
|
+
params.pacing_type = ["day_parting"];
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
// --- Frequency capping ---
|
|
822
|
+
if (as_.frequency_control_specs) {
|
|
823
|
+
const fcs = typeof as_.frequency_control_specs === "string"
|
|
824
|
+
? JSON.parse(as_.frequency_control_specs) : as_.frequency_control_specs;
|
|
825
|
+
if (Array.isArray(fcs) && fcs.length) {
|
|
826
|
+
params.frequency_control_specs = fcs;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
// --- Promoted object (conversion tracking) ---
|
|
830
|
+
if (as_.promoted_object) {
|
|
831
|
+
const po = typeof as_.promoted_object === "string"
|
|
832
|
+
? JSON.parse(as_.promoted_object) : as_.promoted_object;
|
|
833
|
+
if (po && typeof po === "object" && Object.keys(po).length) {
|
|
834
|
+
params.promoted_object = po;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
// --- Dynamic creative flag ---
|
|
838
|
+
if (as_.is_dynamic_creative) {
|
|
839
|
+
params.is_dynamic_creative = true;
|
|
840
|
+
}
|
|
841
|
+
const result = await graphPost(`${int.adAccountId}/adsets`, params, int.accessToken);
|
|
842
|
+
const metaId = result.id;
|
|
843
|
+
if (!metaId)
|
|
844
|
+
throw new Error("Meta returned no ad set ID");
|
|
845
|
+
await sb.from("meta_ad_sets").update({
|
|
846
|
+
meta_ad_set_id: metaId,
|
|
847
|
+
last_synced_at: new Date().toISOString(),
|
|
848
|
+
}).eq("id", adSetId);
|
|
849
|
+
return { metaAdSetId: metaId, metaCampaignId };
|
|
850
|
+
}
|
|
851
|
+
async function publishAd(sb, storeId, adId) {
|
|
852
|
+
const { data: ad, error } = await sb
|
|
853
|
+
.from("meta_ads").select("*").eq("id", adId).single();
|
|
854
|
+
if (error || !ad)
|
|
855
|
+
throw new Error(`Ad not found: ${adId}`);
|
|
856
|
+
if (!isLocalId(ad.meta_ad_id)) {
|
|
857
|
+
return { metaAdId: ad.meta_ad_id, creativeId: ad.creative_id || "", metaAdSetId: "", metaCampaignId: "" };
|
|
858
|
+
}
|
|
859
|
+
const int = await getIntegration(sb, storeId);
|
|
860
|
+
// Cascade: publish parent ad set (which cascades to campaign)
|
|
861
|
+
const adSetUUID = ad.meta_ad_set_id;
|
|
862
|
+
if (!adSetUUID)
|
|
863
|
+
throw new Error("Ad has no ad set reference");
|
|
864
|
+
const { data: adSet } = await sb
|
|
865
|
+
.from("meta_ad_sets").select("*").eq("id", adSetUUID).single();
|
|
866
|
+
if (!adSet)
|
|
867
|
+
throw new Error(`Parent ad set not found: ${adSetUUID}`);
|
|
868
|
+
let metaAdSetId = adSet.meta_ad_set_id || "";
|
|
869
|
+
let metaCampaignId = "";
|
|
870
|
+
if (isLocalId(metaAdSetId)) {
|
|
871
|
+
const pub = await publishAdSet(sb, storeId, adSetUUID);
|
|
872
|
+
metaAdSetId = pub.metaAdSetId;
|
|
873
|
+
metaCampaignId = pub.metaCampaignId;
|
|
874
|
+
}
|
|
875
|
+
if (!int.pageId)
|
|
876
|
+
throw new Error("No Facebook Page linked. Add page_id in Meta Connection settings.");
|
|
877
|
+
const creative = (ad.creative || {});
|
|
878
|
+
const isCarousel = Array.isArray(creative.carousel_cards) && creative.carousel_cards.length > 0;
|
|
879
|
+
const isDynamic = !!creative.asset_feed_spec;
|
|
880
|
+
const wantsVideo = !!(creative.video_url || creative.video_id || creative.object_type?.toUpperCase() === "VIDEO");
|
|
881
|
+
let imageHash = null;
|
|
882
|
+
let metaVideoId = null;
|
|
883
|
+
let videoWarning = null;
|
|
884
|
+
// --- CAROUSEL: upload each card's media ---
|
|
885
|
+
if (isCarousel) {
|
|
886
|
+
const carouselHashes = [];
|
|
887
|
+
for (const card of creative.carousel_cards) {
|
|
888
|
+
let cardHash = null;
|
|
889
|
+
let cardVideoId = null;
|
|
890
|
+
if (card.video_url) {
|
|
891
|
+
try {
|
|
892
|
+
cardVideoId = await uploadVideo(int, card.video_url);
|
|
893
|
+
}
|
|
894
|
+
catch { /* skip video, fall to image */ }
|
|
895
|
+
if (card.image_url)
|
|
896
|
+
cardHash = await uploadImage(int, card.image_url);
|
|
897
|
+
}
|
|
898
|
+
else if (card.image_url) {
|
|
899
|
+
cardHash = await uploadImage(int, card.image_url);
|
|
900
|
+
}
|
|
901
|
+
carouselHashes.push({ hash: cardHash, videoId: cardVideoId, card });
|
|
902
|
+
}
|
|
903
|
+
const oss = buildStorySpec(creative, int.pageId, null, null, carouselHashes);
|
|
904
|
+
const crResult = await graphPost(`${int.adAccountId}/adcreatives`, { name: `${ad.name || "Ad"} Creative`, object_story_spec: oss }, int.accessToken);
|
|
905
|
+
const creativeId = crResult.id;
|
|
906
|
+
if (!creativeId)
|
|
907
|
+
throw new Error("Meta returned no creative ID");
|
|
908
|
+
const adResult = await graphPost(`${int.adAccountId}/ads`, {
|
|
909
|
+
name: ad.name || "Ad",
|
|
910
|
+
adset_id: metaAdSetId,
|
|
911
|
+
creative: { creative_id: creativeId },
|
|
912
|
+
status: metaStatus(ad.status),
|
|
913
|
+
}, int.accessToken);
|
|
914
|
+
const metaAdId = adResult.id;
|
|
915
|
+
if (!metaAdId)
|
|
916
|
+
throw new Error("Meta returned no ad ID");
|
|
917
|
+
await sb.from("meta_ads").update({
|
|
918
|
+
meta_ad_id: metaAdId,
|
|
919
|
+
creative_id: creativeId,
|
|
920
|
+
last_synced_at: new Date().toISOString(),
|
|
921
|
+
}).eq("id", adId);
|
|
922
|
+
return { metaAdId, creativeId, metaAdSetId, metaCampaignId };
|
|
923
|
+
}
|
|
924
|
+
// --- DYNAMIC CREATIVE: asset_feed_spec ---
|
|
925
|
+
if (isDynamic && creative.asset_feed_spec) {
|
|
926
|
+
const spec = creative.asset_feed_spec;
|
|
927
|
+
// Upload images in the asset feed
|
|
928
|
+
if (Array.isArray(spec.images)) {
|
|
929
|
+
for (const img of spec.images) {
|
|
930
|
+
if (img.url && !img.hash) {
|
|
931
|
+
const hash = await uploadImage(int, img.url);
|
|
932
|
+
if (hash) {
|
|
933
|
+
img.hash = hash;
|
|
934
|
+
delete img.url;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
const crParams = {
|
|
940
|
+
name: `${ad.name || "Ad"} Dynamic Creative`,
|
|
941
|
+
asset_feed_spec: spec,
|
|
942
|
+
object_story_spec: { page_id: int.pageId, link_data: { link: creative.link_url || "" } },
|
|
943
|
+
};
|
|
944
|
+
const crResult = await graphPost(`${int.adAccountId}/adcreatives`, crParams, int.accessToken);
|
|
945
|
+
const creativeId = crResult.id;
|
|
946
|
+
if (!creativeId)
|
|
947
|
+
throw new Error("Meta returned no creative ID");
|
|
948
|
+
const adResult = await graphPost(`${int.adAccountId}/ads`, {
|
|
949
|
+
name: ad.name || "Ad",
|
|
950
|
+
adset_id: metaAdSetId,
|
|
951
|
+
creative: { creative_id: creativeId },
|
|
952
|
+
status: metaStatus(ad.status),
|
|
953
|
+
}, int.accessToken);
|
|
954
|
+
const metaAdId = adResult.id;
|
|
955
|
+
if (!metaAdId)
|
|
956
|
+
throw new Error("Meta returned no ad ID");
|
|
957
|
+
await sb.from("meta_ads").update({
|
|
958
|
+
meta_ad_id: metaAdId,
|
|
959
|
+
creative_id: creativeId,
|
|
960
|
+
last_synced_at: new Date().toISOString(),
|
|
961
|
+
}).eq("id", adId);
|
|
962
|
+
return { metaAdId, creativeId, metaAdSetId, metaCampaignId };
|
|
963
|
+
}
|
|
964
|
+
// --- STANDARD (single image/video) ---
|
|
965
|
+
if (wantsVideo && creative.video_url) {
|
|
966
|
+
try {
|
|
967
|
+
metaVideoId = await uploadVideo(int, creative.video_url);
|
|
968
|
+
}
|
|
969
|
+
catch (e) {
|
|
970
|
+
videoWarning = `Video upload failed (${e instanceof Error ? e.message.slice(0, 150) : "unknown"}). Published as image ad instead. To fix: add ads_management and pages_manage_posts permissions to your Meta App.`;
|
|
971
|
+
}
|
|
972
|
+
if (creative.image_url) {
|
|
973
|
+
imageHash = await uploadImage(int, creative.image_url);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
else if (wantsVideo && creative.video_id) {
|
|
977
|
+
metaVideoId = creative.video_id;
|
|
978
|
+
if (creative.image_url)
|
|
979
|
+
imageHash = await uploadImage(int, creative.image_url);
|
|
980
|
+
}
|
|
981
|
+
else if (creative.image_url) {
|
|
982
|
+
imageHash = await uploadImage(int, creative.image_url);
|
|
983
|
+
}
|
|
984
|
+
// Create creative on Meta
|
|
985
|
+
const oss = buildStorySpec(creative, int.pageId, imageHash, metaVideoId);
|
|
986
|
+
const crParams = {
|
|
987
|
+
name: `${ad.name || "Ad"} Creative`,
|
|
988
|
+
object_story_spec: oss,
|
|
989
|
+
};
|
|
990
|
+
// Attach lead form if specified
|
|
991
|
+
if (creative.lead_form_id) {
|
|
992
|
+
const ossObj = oss;
|
|
993
|
+
if (ossObj.link_data) {
|
|
994
|
+
ossObj.link_data = { ...ossObj.link_data, lead_gen_form_id: creative.lead_form_id };
|
|
995
|
+
}
|
|
996
|
+
crParams.object_story_spec = ossObj;
|
|
997
|
+
}
|
|
998
|
+
const crResult = await graphPost(`${int.adAccountId}/adcreatives`, crParams, int.accessToken);
|
|
999
|
+
const creativeId = crResult.id;
|
|
1000
|
+
if (!creativeId)
|
|
1001
|
+
throw new Error("Meta returned no creative ID");
|
|
1002
|
+
// Create ad
|
|
1003
|
+
const adResult = await graphPost(`${int.adAccountId}/ads`, {
|
|
1004
|
+
name: ad.name || "Ad",
|
|
1005
|
+
adset_id: metaAdSetId,
|
|
1006
|
+
creative: { creative_id: creativeId },
|
|
1007
|
+
status: metaStatus(ad.status),
|
|
1008
|
+
}, int.accessToken);
|
|
1009
|
+
const metaAdId = adResult.id;
|
|
1010
|
+
if (!metaAdId)
|
|
1011
|
+
throw new Error("Meta returned no ad ID");
|
|
1012
|
+
await sb.from("meta_ads").update({
|
|
1013
|
+
meta_ad_id: metaAdId,
|
|
1014
|
+
creative_id: creativeId,
|
|
1015
|
+
last_synced_at: new Date().toISOString(),
|
|
1016
|
+
}).eq("id", adId);
|
|
1017
|
+
return {
|
|
1018
|
+
metaAdId, creativeId, metaAdSetId, metaCampaignId,
|
|
1019
|
+
...(videoWarning && { warning: videoWarning }),
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
// ============================================================================
|
|
1023
|
+
// MAIN HANDLER
|
|
1024
|
+
// ============================================================================
|
|
1025
|
+
export async function handleMetaAds(sb, args, storeId) {
|
|
1026
|
+
if (!storeId)
|
|
1027
|
+
return { success: false, error: "store_id required" };
|
|
1028
|
+
const action = args.action;
|
|
1029
|
+
try {
|
|
1030
|
+
switch (action) {
|
|
1031
|
+
// ==================================================================
|
|
1032
|
+
// CREATE
|
|
1033
|
+
// ==================================================================
|
|
1034
|
+
case "create_campaign": {
|
|
1035
|
+
const name = args.name;
|
|
1036
|
+
if (!name)
|
|
1037
|
+
return { success: false, error: "name required" };
|
|
1038
|
+
const row = {
|
|
1039
|
+
store_id: storeId,
|
|
1040
|
+
meta_campaign_id: `local_${crypto.randomUUID().slice(0, 8)}`,
|
|
1041
|
+
meta_account_id: "draft",
|
|
1042
|
+
name,
|
|
1043
|
+
objective: args.objective || "OUTCOME_AWARENESS",
|
|
1044
|
+
status: "DRAFT",
|
|
1045
|
+
};
|
|
1046
|
+
if (args.daily_budget)
|
|
1047
|
+
row.daily_budget = Number(args.daily_budget);
|
|
1048
|
+
if (args.lifetime_budget)
|
|
1049
|
+
row.lifetime_budget = Number(args.lifetime_budget);
|
|
1050
|
+
if (args.start_time)
|
|
1051
|
+
row.start_time = args.start_time;
|
|
1052
|
+
if (args.stop_time)
|
|
1053
|
+
row.stop_time = args.stop_time;
|
|
1054
|
+
// CBO support
|
|
1055
|
+
if (args.is_cbo === true)
|
|
1056
|
+
row.is_cbo = true;
|
|
1057
|
+
if (args.campaign_bid_strategy)
|
|
1058
|
+
row.campaign_bid_strategy = args.campaign_bid_strategy;
|
|
1059
|
+
const { data, error } = await sb.from("meta_campaigns").insert(row).select().single();
|
|
1060
|
+
if (error)
|
|
1061
|
+
return { success: false, error: error.message };
|
|
1062
|
+
return { success: true, data };
|
|
1063
|
+
}
|
|
1064
|
+
case "create_ad_set": {
|
|
1065
|
+
const campaignId = args.campaign_id;
|
|
1066
|
+
const name = args.name;
|
|
1067
|
+
if (!campaignId)
|
|
1068
|
+
return { success: false, error: "campaign_id required" };
|
|
1069
|
+
if (!name)
|
|
1070
|
+
return { success: false, error: "name required" };
|
|
1071
|
+
const { data: camp } = await sb
|
|
1072
|
+
.from("meta_campaigns").select("id, objective")
|
|
1073
|
+
.eq("id", campaignId).eq("store_id", storeId).single();
|
|
1074
|
+
if (!camp)
|
|
1075
|
+
return { success: false, error: `Campaign not found: ${campaignId}` };
|
|
1076
|
+
const optGoal = args.optimization_goal
|
|
1077
|
+
? validateGoal(args.optimization_goal, camp.objective || "OUTCOME_AWARENESS")
|
|
1078
|
+
: defaultGoal(camp.objective || "OUTCOME_AWARENESS");
|
|
1079
|
+
const row = {
|
|
1080
|
+
store_id: storeId,
|
|
1081
|
+
meta_ad_set_id: `local_${crypto.randomUUID().slice(0, 8)}`,
|
|
1082
|
+
meta_campaign_id: campaignId,
|
|
1083
|
+
name,
|
|
1084
|
+
optimization_goal: optGoal,
|
|
1085
|
+
billing_event: billingEvent(optGoal),
|
|
1086
|
+
status: "DRAFT",
|
|
1087
|
+
};
|
|
1088
|
+
if (args.daily_budget)
|
|
1089
|
+
row.daily_budget = Number(args.daily_budget);
|
|
1090
|
+
if (args.lifetime_budget)
|
|
1091
|
+
row.lifetime_budget = Number(args.lifetime_budget);
|
|
1092
|
+
if (args.bid_strategy)
|
|
1093
|
+
row.bid_strategy = args.bid_strategy;
|
|
1094
|
+
if (args.bid_amount)
|
|
1095
|
+
row.bid_amount = Number(args.bid_amount);
|
|
1096
|
+
if (args.start_time)
|
|
1097
|
+
row.start_time = args.start_time;
|
|
1098
|
+
if (args.end_time)
|
|
1099
|
+
row.end_time = args.end_time;
|
|
1100
|
+
if (args.targeting) {
|
|
1101
|
+
row.targeting = typeof args.targeting === "string" ? args.targeting : JSON.stringify(args.targeting);
|
|
1102
|
+
}
|
|
1103
|
+
// V2: scheduling, placements, frequency, conversions, dynamic creative
|
|
1104
|
+
if (args.adset_schedule) {
|
|
1105
|
+
row.adset_schedule = typeof args.adset_schedule === "string"
|
|
1106
|
+
? args.adset_schedule : JSON.stringify(args.adset_schedule);
|
|
1107
|
+
}
|
|
1108
|
+
if (args.publisher_platforms) {
|
|
1109
|
+
row.publisher_platforms = typeof args.publisher_platforms === "string"
|
|
1110
|
+
? args.publisher_platforms : JSON.stringify(args.publisher_platforms);
|
|
1111
|
+
}
|
|
1112
|
+
if (args.facebook_positions) {
|
|
1113
|
+
row.facebook_positions = typeof args.facebook_positions === "string"
|
|
1114
|
+
? args.facebook_positions : JSON.stringify(args.facebook_positions);
|
|
1115
|
+
}
|
|
1116
|
+
if (args.instagram_positions) {
|
|
1117
|
+
row.instagram_positions = typeof args.instagram_positions === "string"
|
|
1118
|
+
? args.instagram_positions : JSON.stringify(args.instagram_positions);
|
|
1119
|
+
}
|
|
1120
|
+
if (args.audience_network_positions) {
|
|
1121
|
+
row.audience_network_positions = typeof args.audience_network_positions === "string"
|
|
1122
|
+
? args.audience_network_positions : JSON.stringify(args.audience_network_positions);
|
|
1123
|
+
}
|
|
1124
|
+
if (args.messenger_positions) {
|
|
1125
|
+
row.messenger_positions = typeof args.messenger_positions === "string"
|
|
1126
|
+
? args.messenger_positions : JSON.stringify(args.messenger_positions);
|
|
1127
|
+
}
|
|
1128
|
+
if (args.frequency_control_specs) {
|
|
1129
|
+
row.frequency_control_specs = typeof args.frequency_control_specs === "string"
|
|
1130
|
+
? args.frequency_control_specs : JSON.stringify(args.frequency_control_specs);
|
|
1131
|
+
}
|
|
1132
|
+
if (args.promoted_object) {
|
|
1133
|
+
row.promoted_object = typeof args.promoted_object === "string"
|
|
1134
|
+
? args.promoted_object : JSON.stringify(args.promoted_object);
|
|
1135
|
+
}
|
|
1136
|
+
if (args.is_dynamic_creative === true)
|
|
1137
|
+
row.is_dynamic_creative = true;
|
|
1138
|
+
const { data, error } = await sb.from("meta_ad_sets").insert(row).select().single();
|
|
1139
|
+
if (error)
|
|
1140
|
+
return { success: false, error: error.message };
|
|
1141
|
+
return { success: true, data };
|
|
1142
|
+
}
|
|
1143
|
+
case "create_ad": {
|
|
1144
|
+
const adSetId = args.ad_set_id;
|
|
1145
|
+
const name = args.name;
|
|
1146
|
+
if (!adSetId)
|
|
1147
|
+
return { success: false, error: "ad_set_id required" };
|
|
1148
|
+
if (!name)
|
|
1149
|
+
return { success: false, error: "name required" };
|
|
1150
|
+
const { data: adSet } = await sb
|
|
1151
|
+
.from("meta_ad_sets").select("id")
|
|
1152
|
+
.eq("id", adSetId).eq("store_id", storeId).single();
|
|
1153
|
+
if (!adSet)
|
|
1154
|
+
return { success: false, error: `Ad set not found: ${adSetId}` };
|
|
1155
|
+
const hasVideo = !!args.video_url;
|
|
1156
|
+
const creative = {
|
|
1157
|
+
title: args.headline || undefined,
|
|
1158
|
+
body: args.body || undefined,
|
|
1159
|
+
image_url: args.image_url || undefined,
|
|
1160
|
+
video_url: args.video_url || undefined,
|
|
1161
|
+
link_url: args.link_url || undefined,
|
|
1162
|
+
call_to_action: args.call_to_action || "LEARN_MORE",
|
|
1163
|
+
object_type: hasVideo ? "VIDEO" : "SHARE",
|
|
1164
|
+
video_id: args.video_id || undefined,
|
|
1165
|
+
};
|
|
1166
|
+
// V2: carousel cards
|
|
1167
|
+
if (args.carousel_cards && Array.isArray(args.carousel_cards)) {
|
|
1168
|
+
creative.carousel_cards = args.carousel_cards;
|
|
1169
|
+
creative.object_type = "SHARE";
|
|
1170
|
+
}
|
|
1171
|
+
// V2: dynamic creative asset feed
|
|
1172
|
+
if (args.asset_feed_spec && typeof args.asset_feed_spec === "object") {
|
|
1173
|
+
creative.asset_feed_spec = args.asset_feed_spec;
|
|
1174
|
+
}
|
|
1175
|
+
// V2: lead form
|
|
1176
|
+
if (args.lead_form_id) {
|
|
1177
|
+
creative.lead_form_id = args.lead_form_id;
|
|
1178
|
+
}
|
|
1179
|
+
const { data, error } = await sb.from("meta_ads").insert({
|
|
1180
|
+
store_id: storeId,
|
|
1181
|
+
meta_ad_id: `local_${crypto.randomUUID().slice(0, 8)}`,
|
|
1182
|
+
meta_ad_set_id: adSetId,
|
|
1183
|
+
name,
|
|
1184
|
+
creative,
|
|
1185
|
+
status: "DRAFT",
|
|
1186
|
+
}).select().single();
|
|
1187
|
+
if (error)
|
|
1188
|
+
return { success: false, error: error.message };
|
|
1189
|
+
return { success: true, data };
|
|
1190
|
+
}
|
|
1191
|
+
// ==================================================================
|
|
1192
|
+
// CREATE FULL — campaign + ad set + ad in one call
|
|
1193
|
+
// ==================================================================
|
|
1194
|
+
case "create_full": {
|
|
1195
|
+
const name = args.name;
|
|
1196
|
+
if (!name)
|
|
1197
|
+
return { success: false, error: "name required (used as campaign name)" };
|
|
1198
|
+
const objective = args.objective || "OUTCOME_AWARENESS";
|
|
1199
|
+
const optGoal = args.optimization_goal
|
|
1200
|
+
? validateGoal(args.optimization_goal, objective)
|
|
1201
|
+
: defaultGoal(objective);
|
|
1202
|
+
const wantPublish = args.auto_publish === true;
|
|
1203
|
+
// 1. Campaign
|
|
1204
|
+
const campRow = {
|
|
1205
|
+
store_id: storeId,
|
|
1206
|
+
meta_campaign_id: `local_${crypto.randomUUID().slice(0, 8)}`,
|
|
1207
|
+
meta_account_id: "draft",
|
|
1208
|
+
name,
|
|
1209
|
+
objective,
|
|
1210
|
+
status: "DRAFT",
|
|
1211
|
+
};
|
|
1212
|
+
if (args.daily_budget)
|
|
1213
|
+
campRow.daily_budget = Number(args.daily_budget);
|
|
1214
|
+
if (args.lifetime_budget)
|
|
1215
|
+
campRow.lifetime_budget = Number(args.lifetime_budget);
|
|
1216
|
+
if (args.start_time)
|
|
1217
|
+
campRow.start_time = args.start_time;
|
|
1218
|
+
if (args.stop_time)
|
|
1219
|
+
campRow.stop_time = args.stop_time;
|
|
1220
|
+
if (args.is_cbo === true)
|
|
1221
|
+
campRow.is_cbo = true;
|
|
1222
|
+
if (args.campaign_bid_strategy)
|
|
1223
|
+
campRow.campaign_bid_strategy = args.campaign_bid_strategy;
|
|
1224
|
+
const { data: camp, error: e1 } = await sb.from("meta_campaigns")
|
|
1225
|
+
.insert(campRow).select().single();
|
|
1226
|
+
if (e1)
|
|
1227
|
+
return { success: false, error: `Campaign: ${e1.message}` };
|
|
1228
|
+
// 2. Ad Set
|
|
1229
|
+
const adSetName = args.ad_set_name || `${name} — Ad Set`;
|
|
1230
|
+
const adSetRow = {
|
|
1231
|
+
store_id: storeId,
|
|
1232
|
+
meta_ad_set_id: `local_${crypto.randomUUID().slice(0, 8)}`,
|
|
1233
|
+
meta_campaign_id: camp.id,
|
|
1234
|
+
name: adSetName,
|
|
1235
|
+
optimization_goal: optGoal,
|
|
1236
|
+
billing_event: billingEvent(optGoal),
|
|
1237
|
+
status: "DRAFT",
|
|
1238
|
+
};
|
|
1239
|
+
// Budget goes on ad set (unless CBO)
|
|
1240
|
+
if (!campRow.is_cbo && args.daily_budget)
|
|
1241
|
+
adSetRow.daily_budget = Number(args.daily_budget);
|
|
1242
|
+
if (!campRow.is_cbo && args.lifetime_budget)
|
|
1243
|
+
adSetRow.lifetime_budget = Number(args.lifetime_budget);
|
|
1244
|
+
if (args.bid_strategy)
|
|
1245
|
+
adSetRow.bid_strategy = args.bid_strategy;
|
|
1246
|
+
if (args.start_time)
|
|
1247
|
+
adSetRow.start_time = args.start_time;
|
|
1248
|
+
if (args.end_time)
|
|
1249
|
+
adSetRow.end_time = args.end_time;
|
|
1250
|
+
if (args.targeting) {
|
|
1251
|
+
adSetRow.targeting = typeof args.targeting === "string"
|
|
1252
|
+
? args.targeting : JSON.stringify(args.targeting);
|
|
1253
|
+
}
|
|
1254
|
+
// V2 ad set fields
|
|
1255
|
+
if (args.adset_schedule) {
|
|
1256
|
+
adSetRow.adset_schedule = typeof args.adset_schedule === "string"
|
|
1257
|
+
? args.adset_schedule : JSON.stringify(args.adset_schedule);
|
|
1258
|
+
}
|
|
1259
|
+
if (args.publisher_platforms) {
|
|
1260
|
+
adSetRow.publisher_platforms = typeof args.publisher_platforms === "string"
|
|
1261
|
+
? args.publisher_platforms : JSON.stringify(args.publisher_platforms);
|
|
1262
|
+
}
|
|
1263
|
+
if (args.facebook_positions) {
|
|
1264
|
+
adSetRow.facebook_positions = typeof args.facebook_positions === "string"
|
|
1265
|
+
? args.facebook_positions : JSON.stringify(args.facebook_positions);
|
|
1266
|
+
}
|
|
1267
|
+
if (args.instagram_positions) {
|
|
1268
|
+
adSetRow.instagram_positions = typeof args.instagram_positions === "string"
|
|
1269
|
+
? args.instagram_positions : JSON.stringify(args.instagram_positions);
|
|
1270
|
+
}
|
|
1271
|
+
if (args.frequency_control_specs) {
|
|
1272
|
+
adSetRow.frequency_control_specs = typeof args.frequency_control_specs === "string"
|
|
1273
|
+
? args.frequency_control_specs : JSON.stringify(args.frequency_control_specs);
|
|
1274
|
+
}
|
|
1275
|
+
if (args.promoted_object) {
|
|
1276
|
+
adSetRow.promoted_object = typeof args.promoted_object === "string"
|
|
1277
|
+
? args.promoted_object : JSON.stringify(args.promoted_object);
|
|
1278
|
+
}
|
|
1279
|
+
if (args.is_dynamic_creative === true)
|
|
1280
|
+
adSetRow.is_dynamic_creative = true;
|
|
1281
|
+
const { data: adSet, error: e2 } = await sb.from("meta_ad_sets")
|
|
1282
|
+
.insert(adSetRow).select().single();
|
|
1283
|
+
if (e2)
|
|
1284
|
+
return { success: false, error: `Ad set: ${e2.message}` };
|
|
1285
|
+
// 3. Ad
|
|
1286
|
+
const hasVideo = !!args.video_url;
|
|
1287
|
+
const adName = args.ad_name || `${name} — Ad`;
|
|
1288
|
+
const creative = {
|
|
1289
|
+
title: args.headline || undefined,
|
|
1290
|
+
body: args.body || undefined,
|
|
1291
|
+
image_url: args.image_url || undefined,
|
|
1292
|
+
video_url: args.video_url || undefined,
|
|
1293
|
+
link_url: args.link_url || undefined,
|
|
1294
|
+
call_to_action: args.call_to_action || "LEARN_MORE",
|
|
1295
|
+
object_type: hasVideo ? "VIDEO" : "SHARE",
|
|
1296
|
+
video_id: args.video_id || undefined,
|
|
1297
|
+
};
|
|
1298
|
+
if (args.carousel_cards && Array.isArray(args.carousel_cards)) {
|
|
1299
|
+
creative.carousel_cards = args.carousel_cards;
|
|
1300
|
+
creative.object_type = "SHARE";
|
|
1301
|
+
}
|
|
1302
|
+
if (args.asset_feed_spec && typeof args.asset_feed_spec === "object") {
|
|
1303
|
+
creative.asset_feed_spec = args.asset_feed_spec;
|
|
1304
|
+
}
|
|
1305
|
+
if (args.lead_form_id) {
|
|
1306
|
+
creative.lead_form_id = args.lead_form_id;
|
|
1307
|
+
}
|
|
1308
|
+
const { data: ad, error: e3 } = await sb.from("meta_ads").insert({
|
|
1309
|
+
store_id: storeId,
|
|
1310
|
+
meta_ad_id: `local_${crypto.randomUUID().slice(0, 8)}`,
|
|
1311
|
+
meta_ad_set_id: adSet.id,
|
|
1312
|
+
name: adName,
|
|
1313
|
+
creative,
|
|
1314
|
+
status: "DRAFT",
|
|
1315
|
+
}).select().single();
|
|
1316
|
+
if (e3)
|
|
1317
|
+
return { success: false, error: `Ad: ${e3.message}` };
|
|
1318
|
+
// 4. Auto-publish if requested
|
|
1319
|
+
if (wantPublish) {
|
|
1320
|
+
try {
|
|
1321
|
+
const pubResult = await publishAd(sb, storeId, ad.id);
|
|
1322
|
+
const { data: finalCamp } = await sb.from("meta_campaigns").select("*").eq("id", camp.id).single();
|
|
1323
|
+
const { data: finalAdSet } = await sb.from("meta_ad_sets").select("*").eq("id", adSet.id).single();
|
|
1324
|
+
const { data: finalAd } = await sb.from("meta_ads").select("*").eq("id", ad.id).single();
|
|
1325
|
+
return {
|
|
1326
|
+
success: true,
|
|
1327
|
+
published: true,
|
|
1328
|
+
data: {
|
|
1329
|
+
campaign: finalCamp,
|
|
1330
|
+
ad_set: finalAdSet,
|
|
1331
|
+
ad: finalAd,
|
|
1332
|
+
meta_ids: {
|
|
1333
|
+
campaign_id: pubResult.metaCampaignId,
|
|
1334
|
+
ad_set_id: pubResult.metaAdSetId,
|
|
1335
|
+
creative_id: pubResult.creativeId,
|
|
1336
|
+
ad_id: pubResult.metaAdId,
|
|
1337
|
+
},
|
|
1338
|
+
...(("warning" in pubResult) && { warning: pubResult.warning }),
|
|
1339
|
+
},
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
catch (pubErr) {
|
|
1343
|
+
return {
|
|
1344
|
+
success: true,
|
|
1345
|
+
published: false,
|
|
1346
|
+
data: {
|
|
1347
|
+
campaign: camp,
|
|
1348
|
+
ad_set: adSet,
|
|
1349
|
+
ad: ad,
|
|
1350
|
+
publish_error: pubErr instanceof Error ? pubErr.message : String(pubErr),
|
|
1351
|
+
hint: "Drafts created. Fix the error and retry: publish(type=ad, id=" + ad.id + ")",
|
|
1352
|
+
},
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
return {
|
|
1357
|
+
success: true,
|
|
1358
|
+
published: false,
|
|
1359
|
+
data: {
|
|
1360
|
+
campaign: camp,
|
|
1361
|
+
ad_set: adSet,
|
|
1362
|
+
ad: ad,
|
|
1363
|
+
hint: "Created as drafts. Set auto_publish=true to publish immediately, or call publish(type=ad, id=" + ad.id + ").",
|
|
1364
|
+
},
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
// ==================================================================
|
|
1368
|
+
// READ
|
|
1369
|
+
// ==================================================================
|
|
1370
|
+
case "list_campaigns": {
|
|
1371
|
+
let q = sb.from("meta_campaigns").select("*")
|
|
1372
|
+
.eq("store_id", storeId).order("created_at", { ascending: false });
|
|
1373
|
+
if (args.status)
|
|
1374
|
+
q = q.eq("status", args.status);
|
|
1375
|
+
q = q.limit(args.limit || 25);
|
|
1376
|
+
const { data, error } = await q;
|
|
1377
|
+
if (error)
|
|
1378
|
+
return { success: false, error: error.message };
|
|
1379
|
+
return { success: true, count: data?.length ?? 0, data };
|
|
1380
|
+
}
|
|
1381
|
+
case "list_ad_sets": {
|
|
1382
|
+
let q = sb.from("meta_ad_sets").select("*")
|
|
1383
|
+
.eq("store_id", storeId).order("created_at", { ascending: false });
|
|
1384
|
+
if (args.campaign_id)
|
|
1385
|
+
q = q.eq("meta_campaign_id", args.campaign_id);
|
|
1386
|
+
if (args.status)
|
|
1387
|
+
q = q.eq("status", args.status);
|
|
1388
|
+
q = q.limit(args.limit || 25);
|
|
1389
|
+
const { data, error } = await q;
|
|
1390
|
+
if (error)
|
|
1391
|
+
return { success: false, error: error.message };
|
|
1392
|
+
return { success: true, count: data?.length ?? 0, data };
|
|
1393
|
+
}
|
|
1394
|
+
case "list_ads": {
|
|
1395
|
+
let q = sb.from("meta_ads").select("*")
|
|
1396
|
+
.eq("store_id", storeId).order("created_at", { ascending: false });
|
|
1397
|
+
if (args.ad_set_id)
|
|
1398
|
+
q = q.eq("meta_ad_set_id", args.ad_set_id);
|
|
1399
|
+
if (args.status)
|
|
1400
|
+
q = q.eq("status", args.status);
|
|
1401
|
+
q = q.limit(args.limit || 25);
|
|
1402
|
+
const { data, error } = await q;
|
|
1403
|
+
if (error)
|
|
1404
|
+
return { success: false, error: error.message };
|
|
1405
|
+
return { success: true, count: data?.length ?? 0, data };
|
|
1406
|
+
}
|
|
1407
|
+
case "get_campaign": {
|
|
1408
|
+
const id = (args.campaign_id || args.id);
|
|
1409
|
+
if (!id)
|
|
1410
|
+
return { success: false, error: "campaign_id required" };
|
|
1411
|
+
const { data: c, error } = await sb.from("meta_campaigns").select("*")
|
|
1412
|
+
.eq("id", id).eq("store_id", storeId).single();
|
|
1413
|
+
if (error)
|
|
1414
|
+
return { success: false, error: error.message };
|
|
1415
|
+
const { data: sets } = await sb.from("meta_ad_sets").select("*")
|
|
1416
|
+
.eq("meta_campaign_id", id).order("created_at", { ascending: false });
|
|
1417
|
+
return { success: true, data: { ...c, ad_sets: sets || [] } };
|
|
1418
|
+
}
|
|
1419
|
+
case "get_ad_set": {
|
|
1420
|
+
const id = (args.ad_set_id || args.id);
|
|
1421
|
+
if (!id)
|
|
1422
|
+
return { success: false, error: "ad_set_id required" };
|
|
1423
|
+
const { data: s, error } = await sb.from("meta_ad_sets").select("*")
|
|
1424
|
+
.eq("id", id).eq("store_id", storeId).single();
|
|
1425
|
+
if (error)
|
|
1426
|
+
return { success: false, error: error.message };
|
|
1427
|
+
const { data: ads } = await sb.from("meta_ads").select("*")
|
|
1428
|
+
.eq("meta_ad_set_id", id).order("created_at", { ascending: false });
|
|
1429
|
+
return { success: true, data: { ...s, ads: ads || [] } };
|
|
1430
|
+
}
|
|
1431
|
+
case "get_ad": {
|
|
1432
|
+
const id = (args.ad_id || args.id);
|
|
1433
|
+
if (!id)
|
|
1434
|
+
return { success: false, error: "ad_id required" };
|
|
1435
|
+
const { data, error } = await sb.from("meta_ads").select("*")
|
|
1436
|
+
.eq("id", id).eq("store_id", storeId).single();
|
|
1437
|
+
if (error)
|
|
1438
|
+
return { success: false, error: error.message };
|
|
1439
|
+
return { success: true, data };
|
|
1440
|
+
}
|
|
1441
|
+
// ==================================================================
|
|
1442
|
+
// UPDATE
|
|
1443
|
+
// ==================================================================
|
|
1444
|
+
case "update_campaign": {
|
|
1445
|
+
const id = (args.campaign_id || args.id);
|
|
1446
|
+
if (!id)
|
|
1447
|
+
return { success: false, error: "campaign_id required" };
|
|
1448
|
+
const u = {};
|
|
1449
|
+
if (args.name !== undefined)
|
|
1450
|
+
u.name = args.name;
|
|
1451
|
+
if (args.objective !== undefined)
|
|
1452
|
+
u.objective = args.objective;
|
|
1453
|
+
if (args.daily_budget !== undefined)
|
|
1454
|
+
u.daily_budget = Number(args.daily_budget);
|
|
1455
|
+
if (args.lifetime_budget !== undefined)
|
|
1456
|
+
u.lifetime_budget = Number(args.lifetime_budget);
|
|
1457
|
+
if (args.status !== undefined)
|
|
1458
|
+
u.status = args.status;
|
|
1459
|
+
if (args.start_time !== undefined)
|
|
1460
|
+
u.start_time = args.start_time;
|
|
1461
|
+
if (args.stop_time !== undefined)
|
|
1462
|
+
u.stop_time = args.stop_time;
|
|
1463
|
+
if (args.is_cbo !== undefined)
|
|
1464
|
+
u.is_cbo = args.is_cbo;
|
|
1465
|
+
if (args.campaign_bid_strategy !== undefined)
|
|
1466
|
+
u.campaign_bid_strategy = args.campaign_bid_strategy;
|
|
1467
|
+
if (!Object.keys(u).length)
|
|
1468
|
+
return { success: false, error: "No fields to update" };
|
|
1469
|
+
const { data, error } = await sb.from("meta_campaigns").update(u)
|
|
1470
|
+
.eq("id", id).eq("store_id", storeId).select().single();
|
|
1471
|
+
if (error)
|
|
1472
|
+
return { success: false, error: error.message };
|
|
1473
|
+
return { success: true, data };
|
|
1474
|
+
}
|
|
1475
|
+
case "update_ad_set": {
|
|
1476
|
+
const id = (args.ad_set_id || args.id);
|
|
1477
|
+
if (!id)
|
|
1478
|
+
return { success: false, error: "ad_set_id required" };
|
|
1479
|
+
const u = {};
|
|
1480
|
+
if (args.name !== undefined)
|
|
1481
|
+
u.name = args.name;
|
|
1482
|
+
if (args.daily_budget !== undefined)
|
|
1483
|
+
u.daily_budget = Number(args.daily_budget);
|
|
1484
|
+
if (args.lifetime_budget !== undefined)
|
|
1485
|
+
u.lifetime_budget = Number(args.lifetime_budget);
|
|
1486
|
+
if (args.optimization_goal !== undefined)
|
|
1487
|
+
u.optimization_goal = args.optimization_goal;
|
|
1488
|
+
if (args.bid_strategy !== undefined)
|
|
1489
|
+
u.bid_strategy = args.bid_strategy;
|
|
1490
|
+
if (args.bid_amount !== undefined)
|
|
1491
|
+
u.bid_amount = Number(args.bid_amount);
|
|
1492
|
+
if (args.status !== undefined)
|
|
1493
|
+
u.status = args.status;
|
|
1494
|
+
if (args.start_time !== undefined)
|
|
1495
|
+
u.start_time = args.start_time;
|
|
1496
|
+
if (args.end_time !== undefined)
|
|
1497
|
+
u.end_time = args.end_time;
|
|
1498
|
+
if (args.targeting !== undefined) {
|
|
1499
|
+
u.targeting = typeof args.targeting === "string" ? args.targeting : JSON.stringify(args.targeting);
|
|
1500
|
+
}
|
|
1501
|
+
// V2 fields
|
|
1502
|
+
if (args.adset_schedule !== undefined) {
|
|
1503
|
+
u.adset_schedule = typeof args.adset_schedule === "string"
|
|
1504
|
+
? args.adset_schedule : JSON.stringify(args.adset_schedule);
|
|
1505
|
+
}
|
|
1506
|
+
if (args.publisher_platforms !== undefined) {
|
|
1507
|
+
u.publisher_platforms = typeof args.publisher_platforms === "string"
|
|
1508
|
+
? args.publisher_platforms : JSON.stringify(args.publisher_platforms);
|
|
1509
|
+
}
|
|
1510
|
+
if (args.facebook_positions !== undefined) {
|
|
1511
|
+
u.facebook_positions = typeof args.facebook_positions === "string"
|
|
1512
|
+
? args.facebook_positions : JSON.stringify(args.facebook_positions);
|
|
1513
|
+
}
|
|
1514
|
+
if (args.instagram_positions !== undefined) {
|
|
1515
|
+
u.instagram_positions = typeof args.instagram_positions === "string"
|
|
1516
|
+
? args.instagram_positions : JSON.stringify(args.instagram_positions);
|
|
1517
|
+
}
|
|
1518
|
+
if (args.audience_network_positions !== undefined) {
|
|
1519
|
+
u.audience_network_positions = typeof args.audience_network_positions === "string"
|
|
1520
|
+
? args.audience_network_positions : JSON.stringify(args.audience_network_positions);
|
|
1521
|
+
}
|
|
1522
|
+
if (args.messenger_positions !== undefined) {
|
|
1523
|
+
u.messenger_positions = typeof args.messenger_positions === "string"
|
|
1524
|
+
? args.messenger_positions : JSON.stringify(args.messenger_positions);
|
|
1525
|
+
}
|
|
1526
|
+
if (args.frequency_control_specs !== undefined) {
|
|
1527
|
+
u.frequency_control_specs = typeof args.frequency_control_specs === "string"
|
|
1528
|
+
? args.frequency_control_specs : JSON.stringify(args.frequency_control_specs);
|
|
1529
|
+
}
|
|
1530
|
+
if (args.promoted_object !== undefined) {
|
|
1531
|
+
u.promoted_object = typeof args.promoted_object === "string"
|
|
1532
|
+
? args.promoted_object : JSON.stringify(args.promoted_object);
|
|
1533
|
+
}
|
|
1534
|
+
if (args.is_dynamic_creative !== undefined)
|
|
1535
|
+
u.is_dynamic_creative = args.is_dynamic_creative;
|
|
1536
|
+
if (!Object.keys(u).length)
|
|
1537
|
+
return { success: false, error: "No fields to update" };
|
|
1538
|
+
const { data, error } = await sb.from("meta_ad_sets").update(u)
|
|
1539
|
+
.eq("id", id).eq("store_id", storeId).select().single();
|
|
1540
|
+
if (error)
|
|
1541
|
+
return { success: false, error: error.message };
|
|
1542
|
+
return { success: true, data };
|
|
1543
|
+
}
|
|
1544
|
+
case "update_ad": {
|
|
1545
|
+
const id = (args.ad_id || args.id);
|
|
1546
|
+
if (!id)
|
|
1547
|
+
return { success: false, error: "ad_id required" };
|
|
1548
|
+
const u = {};
|
|
1549
|
+
if (args.name !== undefined)
|
|
1550
|
+
u.name = args.name;
|
|
1551
|
+
if (args.status !== undefined)
|
|
1552
|
+
u.status = args.status;
|
|
1553
|
+
// Merge creative fields
|
|
1554
|
+
if (args.headline !== undefined || args.body !== undefined || args.image_url !== undefined ||
|
|
1555
|
+
args.video_url !== undefined || args.link_url !== undefined ||
|
|
1556
|
+
args.call_to_action !== undefined || args.video_id !== undefined ||
|
|
1557
|
+
args.carousel_cards !== undefined || args.asset_feed_spec !== undefined ||
|
|
1558
|
+
args.lead_form_id !== undefined) {
|
|
1559
|
+
const { data: existing } = await sb.from("meta_ads").select("creative").eq("id", id).single();
|
|
1560
|
+
const prev = (existing?.creative || {});
|
|
1561
|
+
u.creative = {
|
|
1562
|
+
...prev,
|
|
1563
|
+
...(args.headline !== undefined && { title: args.headline }),
|
|
1564
|
+
...(args.body !== undefined && { body: args.body }),
|
|
1565
|
+
...(args.image_url !== undefined && { image_url: args.image_url }),
|
|
1566
|
+
...(args.video_url !== undefined && { video_url: args.video_url, object_type: "VIDEO" }),
|
|
1567
|
+
...(args.link_url !== undefined && { link_url: args.link_url }),
|
|
1568
|
+
...(args.call_to_action !== undefined && { call_to_action: args.call_to_action }),
|
|
1569
|
+
...(args.video_id !== undefined && { video_id: args.video_id, object_type: "VIDEO" }),
|
|
1570
|
+
...(args.carousel_cards !== undefined && { carousel_cards: args.carousel_cards }),
|
|
1571
|
+
...(args.asset_feed_spec !== undefined && { asset_feed_spec: args.asset_feed_spec }),
|
|
1572
|
+
...(args.lead_form_id !== undefined && { lead_form_id: args.lead_form_id }),
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
if (!Object.keys(u).length)
|
|
1576
|
+
return { success: false, error: "No fields to update" };
|
|
1577
|
+
const { data, error } = await sb.from("meta_ads").update(u)
|
|
1578
|
+
.eq("id", id).eq("store_id", storeId).select().single();
|
|
1579
|
+
if (error)
|
|
1580
|
+
return { success: false, error: error.message };
|
|
1581
|
+
return { success: true, data };
|
|
1582
|
+
}
|
|
1583
|
+
// ==================================================================
|
|
1584
|
+
// DELETE
|
|
1585
|
+
// ==================================================================
|
|
1586
|
+
case "delete": {
|
|
1587
|
+
const id = args.id;
|
|
1588
|
+
const type = args.type;
|
|
1589
|
+
if (!id)
|
|
1590
|
+
return { success: false, error: "id required" };
|
|
1591
|
+
if (!type)
|
|
1592
|
+
return { success: false, error: "type required (campaign, ad_set, ad)" };
|
|
1593
|
+
const table = type === "campaign" ? "meta_campaigns"
|
|
1594
|
+
: type === "ad_set" ? "meta_ad_sets"
|
|
1595
|
+
: type === "ad" ? "meta_ads" : null;
|
|
1596
|
+
const metaIdCol = type === "campaign" ? "meta_campaign_id"
|
|
1597
|
+
: type === "ad_set" ? "meta_ad_set_id"
|
|
1598
|
+
: type === "ad" ? "meta_ad_id" : null;
|
|
1599
|
+
if (!table || !metaIdCol)
|
|
1600
|
+
return { success: false, error: `Invalid type: ${type}` };
|
|
1601
|
+
const { data: record } = await sb.from(table).select(metaIdCol).eq("id", id).single();
|
|
1602
|
+
const metaId = record?.[metaIdCol];
|
|
1603
|
+
let metaDeleted = false;
|
|
1604
|
+
if (metaId && !isLocalId(metaId)) {
|
|
1605
|
+
const int = await getIntegration(sb, storeId);
|
|
1606
|
+
if (int) {
|
|
1607
|
+
try {
|
|
1608
|
+
await graphPost(metaId, { status: "DELETED" }, int.accessToken);
|
|
1609
|
+
metaDeleted = true;
|
|
1610
|
+
}
|
|
1611
|
+
catch (e) {
|
|
1612
|
+
console.warn(`[meta_ads] Meta delete failed for ${metaId}:`, e);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
const { error } = await sb.from(table).delete().eq("id", id).eq("store_id", storeId);
|
|
1617
|
+
if (error)
|
|
1618
|
+
return { success: false, error: error.message };
|
|
1619
|
+
return { success: true, data: { deleted: id, type, meta_deleted: metaDeleted } };
|
|
1620
|
+
}
|
|
1621
|
+
// ==================================================================
|
|
1622
|
+
// PUBLISH
|
|
1623
|
+
// ==================================================================
|
|
1624
|
+
case "publish": {
|
|
1625
|
+
const type = args.type;
|
|
1626
|
+
const id = args.id;
|
|
1627
|
+
if (!type)
|
|
1628
|
+
return { success: false, error: "type required (campaign, ad_set, ad)" };
|
|
1629
|
+
if (!id)
|
|
1630
|
+
return { success: false, error: "id required" };
|
|
1631
|
+
let result;
|
|
1632
|
+
switch (type) {
|
|
1633
|
+
case "campaign":
|
|
1634
|
+
result = await publishCampaign(sb, storeId, id);
|
|
1635
|
+
break;
|
|
1636
|
+
case "ad_set":
|
|
1637
|
+
result = await publishAdSet(sb, storeId, id);
|
|
1638
|
+
break;
|
|
1639
|
+
case "ad":
|
|
1640
|
+
result = await publishAd(sb, storeId, id);
|
|
1641
|
+
break;
|
|
1642
|
+
default: return { success: false, error: `Invalid type: ${type}` };
|
|
1643
|
+
}
|
|
1644
|
+
// Audit trail (fire-and-forget)
|
|
1645
|
+
sb.from("audit_logs").insert({
|
|
1646
|
+
store_id: storeId,
|
|
1647
|
+
action: `meta_publish_${type}`,
|
|
1648
|
+
resource_type: `meta_${type === "ad_set" ? "ad_set" : type}`,
|
|
1649
|
+
resource_id: id,
|
|
1650
|
+
details: result,
|
|
1651
|
+
source: "agent_chat",
|
|
1652
|
+
severity: "info",
|
|
1653
|
+
}).then(() => { });
|
|
1654
|
+
return { success: true, data: result };
|
|
1655
|
+
}
|
|
1656
|
+
// ==================================================================
|
|
1657
|
+
// SYNC — pull latest stats from Meta (with optional breakdowns)
|
|
1658
|
+
// ==================================================================
|
|
1659
|
+
case "sync": {
|
|
1660
|
+
const id = (args.campaign_id || args.id);
|
|
1661
|
+
if (!id)
|
|
1662
|
+
return { success: false, error: "campaign_id required" };
|
|
1663
|
+
const { data: c } = await sb.from("meta_campaigns").select("meta_campaign_id")
|
|
1664
|
+
.eq("id", id).eq("store_id", storeId).single();
|
|
1665
|
+
if (!c)
|
|
1666
|
+
return { success: false, error: `Campaign not found: ${id}` };
|
|
1667
|
+
if (isLocalId(c.meta_campaign_id))
|
|
1668
|
+
return { success: false, error: "Campaign not published yet. Publish first." };
|
|
1669
|
+
const int = await getIntegration(sb, storeId);
|
|
1670
|
+
const stats = await graphGet(c.meta_campaign_id, "name,status,effective_status,daily_budget,lifetime_budget,budget_remaining,start_time,stop_time", int.accessToken);
|
|
1671
|
+
const u = { last_synced_at: new Date().toISOString() };
|
|
1672
|
+
if (stats.status)
|
|
1673
|
+
u.status = stats.status;
|
|
1674
|
+
if (stats.effective_status)
|
|
1675
|
+
u.effective_status = stats.effective_status;
|
|
1676
|
+
if (stats.daily_budget)
|
|
1677
|
+
u.daily_budget = Number(stats.daily_budget) / 100;
|
|
1678
|
+
if (stats.lifetime_budget)
|
|
1679
|
+
u.lifetime_budget = Number(stats.lifetime_budget) / 100;
|
|
1680
|
+
if (stats.budget_remaining)
|
|
1681
|
+
u.budget_remaining = Number(stats.budget_remaining) / 100;
|
|
1682
|
+
// Insights (with optional breakdowns)
|
|
1683
|
+
try {
|
|
1684
|
+
const breakdowns = args.breakdowns;
|
|
1685
|
+
let extra = "date_preset=maximum";
|
|
1686
|
+
if (breakdowns)
|
|
1687
|
+
extra += `&breakdowns=${enc(breakdowns)}`;
|
|
1688
|
+
const ins = await graphGet(`${c.meta_campaign_id}/insights`, "spend,impressions,clicks,reach,cpc,cpm,ctr,actions", int.accessToken, extra);
|
|
1689
|
+
const results = ins.data;
|
|
1690
|
+
if (breakdowns && results?.length) {
|
|
1691
|
+
// Return breakdown data without saving to DB (too granular)
|
|
1692
|
+
u.raw_insights = results;
|
|
1693
|
+
}
|
|
1694
|
+
else {
|
|
1695
|
+
const d = results?.[0];
|
|
1696
|
+
if (d) {
|
|
1697
|
+
if (d.spend)
|
|
1698
|
+
u.spend = Number(d.spend);
|
|
1699
|
+
if (d.impressions)
|
|
1700
|
+
u.impressions = Number(d.impressions);
|
|
1701
|
+
if (d.clicks)
|
|
1702
|
+
u.clicks = Number(d.clicks);
|
|
1703
|
+
if (d.reach)
|
|
1704
|
+
u.reach = Number(d.reach);
|
|
1705
|
+
if (d.cpc)
|
|
1706
|
+
u.cpc = Number(d.cpc);
|
|
1707
|
+
if (d.cpm)
|
|
1708
|
+
u.cpm = Number(d.cpm);
|
|
1709
|
+
if (d.ctr)
|
|
1710
|
+
u.ctr = Number(d.ctr);
|
|
1711
|
+
u.raw_insights = d;
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
catch { /* insights may not exist yet */ }
|
|
1716
|
+
await sb.from("meta_campaigns").update(u).eq("id", id);
|
|
1717
|
+
const { data: refreshed } = await sb.from("meta_campaigns").select("*").eq("id", id).single();
|
|
1718
|
+
return { success: true, data: refreshed };
|
|
1719
|
+
}
|
|
1720
|
+
// ==================================================================
|
|
1721
|
+
// INSIGHTS BREAKDOWN — detailed performance by dimension
|
|
1722
|
+
// ==================================================================
|
|
1723
|
+
case "insights_breakdown": {
|
|
1724
|
+
const id = (args.campaign_id || args.ad_set_id || args.ad_id || args.id);
|
|
1725
|
+
if (!id)
|
|
1726
|
+
return { success: false, error: "campaign_id, ad_set_id, or ad_id required" };
|
|
1727
|
+
const breakdowns = args.breakdowns || "age,gender";
|
|
1728
|
+
// Determine which table to look up the Meta ID
|
|
1729
|
+
let metaId = null;
|
|
1730
|
+
if (args.campaign_id || (!args.ad_set_id && !args.ad_id)) {
|
|
1731
|
+
const { data } = await sb.from("meta_campaigns").select("meta_campaign_id")
|
|
1732
|
+
.eq("id", id).eq("store_id", storeId).single();
|
|
1733
|
+
metaId = data?.meta_campaign_id;
|
|
1734
|
+
}
|
|
1735
|
+
else if (args.ad_set_id) {
|
|
1736
|
+
const { data } = await sb.from("meta_ad_sets").select("meta_ad_set_id")
|
|
1737
|
+
.eq("id", id).eq("store_id", storeId).single();
|
|
1738
|
+
metaId = data?.meta_ad_set_id;
|
|
1739
|
+
}
|
|
1740
|
+
else if (args.ad_id) {
|
|
1741
|
+
const { data } = await sb.from("meta_ads").select("meta_ad_id")
|
|
1742
|
+
.eq("id", id).eq("store_id", storeId).single();
|
|
1743
|
+
metaId = data?.meta_ad_id;
|
|
1744
|
+
}
|
|
1745
|
+
if (!metaId || isLocalId(metaId)) {
|
|
1746
|
+
return { success: false, error: "Entity not published yet. Publish first." };
|
|
1747
|
+
}
|
|
1748
|
+
const int = await getIntegration(sb, storeId);
|
|
1749
|
+
const datePreset = args.date_preset || "last_30d";
|
|
1750
|
+
const fields = args.fields || "spend,impressions,clicks,reach,cpc,cpm,ctr,actions,cost_per_action_type";
|
|
1751
|
+
const extra = `breakdowns=${enc(breakdowns)}&date_preset=${enc(datePreset)}`;
|
|
1752
|
+
const ins = await graphGet(`${metaId}/insights`, fields, int.accessToken, extra);
|
|
1753
|
+
return { success: true, data: ins.data || [] };
|
|
1754
|
+
}
|
|
1755
|
+
// ==================================================================
|
|
1756
|
+
// AD PREVIEW — get preview URLs
|
|
1757
|
+
// ==================================================================
|
|
1758
|
+
case "ad_preview": {
|
|
1759
|
+
const id = (args.ad_id || args.id);
|
|
1760
|
+
if (!id)
|
|
1761
|
+
return { success: false, error: "ad_id required" };
|
|
1762
|
+
const { data: ad } = await sb.from("meta_ads").select("meta_ad_id")
|
|
1763
|
+
.eq("id", id).eq("store_id", storeId).single();
|
|
1764
|
+
if (!ad)
|
|
1765
|
+
return { success: false, error: `Ad not found: ${id}` };
|
|
1766
|
+
if (isLocalId(ad.meta_ad_id)) {
|
|
1767
|
+
return { success: false, error: "Ad not published yet. Publish first to get preview." };
|
|
1768
|
+
}
|
|
1769
|
+
const int = await getIntegration(sb, storeId);
|
|
1770
|
+
const format = args.ad_format || "DESKTOP_FEED_STANDARD";
|
|
1771
|
+
const url = `${META_BASE}/${ad.meta_ad_id}/previews?ad_format=${enc(format)}&access_token=${enc(int.accessToken)}`;
|
|
1772
|
+
const res = await fetch(url);
|
|
1773
|
+
if (!res.ok) {
|
|
1774
|
+
const body = await res.text();
|
|
1775
|
+
return { success: false, error: parseMetaError(body, "previews", res.status) };
|
|
1776
|
+
}
|
|
1777
|
+
const json = (await res.json());
|
|
1778
|
+
return { success: true, data: json.data || [] };
|
|
1779
|
+
}
|
|
1780
|
+
// ==================================================================
|
|
1781
|
+
// CUSTOM AUDIENCES
|
|
1782
|
+
// ==================================================================
|
|
1783
|
+
case "create_audience": {
|
|
1784
|
+
const name = args.name;
|
|
1785
|
+
if (!name)
|
|
1786
|
+
return { success: false, error: "name required" };
|
|
1787
|
+
const int = await getIntegration(sb, storeId);
|
|
1788
|
+
const subtype = args.subtype || "CUSTOM";
|
|
1789
|
+
const metaParams = {
|
|
1790
|
+
name,
|
|
1791
|
+
subtype,
|
|
1792
|
+
description: args.description || "",
|
|
1793
|
+
customer_file_source: args.customer_file_source || "USER_PROVIDED_ONLY",
|
|
1794
|
+
};
|
|
1795
|
+
// Website audience (requires pixel)
|
|
1796
|
+
if (subtype === "WEBSITE") {
|
|
1797
|
+
const pixelForAudience = args.pixel_id || int.pixelId;
|
|
1798
|
+
if (!pixelForAudience)
|
|
1799
|
+
return { success: false, error: "pixel_id required for website audiences (none configured — set one in Settings → Meta Connection)" };
|
|
1800
|
+
metaParams.rule = args.rule || { inclusions: { operator: "or", rules: [{ event_sources: [{ id: pixelForAudience, type: "pixel" }], retention_seconds: (Number(args.retention_days) || 30) * 86400 }] } };
|
|
1801
|
+
if (args.retention_days)
|
|
1802
|
+
metaParams.retention_days = Number(args.retention_days);
|
|
1803
|
+
if (args.prefill)
|
|
1804
|
+
metaParams.prefill = true;
|
|
1805
|
+
}
|
|
1806
|
+
// Engagement audience
|
|
1807
|
+
if (subtype === "ENGAGEMENT") {
|
|
1808
|
+
metaParams.rule = args.rule;
|
|
1809
|
+
}
|
|
1810
|
+
const result = await graphPost(`${int.adAccountId}/customaudiences`, metaParams, int.accessToken);
|
|
1811
|
+
const metaAudienceId = result.id;
|
|
1812
|
+
// Save to local DB
|
|
1813
|
+
const { data, error } = await sb.from("meta_audiences").insert({
|
|
1814
|
+
store_id: storeId,
|
|
1815
|
+
meta_audience_id: metaAudienceId || null,
|
|
1816
|
+
name,
|
|
1817
|
+
description: args.description || null,
|
|
1818
|
+
audience_type: "custom",
|
|
1819
|
+
subtype,
|
|
1820
|
+
rule: args.rule || null,
|
|
1821
|
+
customer_file_source: args.customer_file_source || null,
|
|
1822
|
+
retention_days: args.retention_days ? Number(args.retention_days) : null,
|
|
1823
|
+
}).select().single();
|
|
1824
|
+
if (error)
|
|
1825
|
+
return { success: false, error: error.message };
|
|
1826
|
+
return { success: true, data: { ...data, meta_audience_id: metaAudienceId } };
|
|
1827
|
+
}
|
|
1828
|
+
case "list_audiences": {
|
|
1829
|
+
// Fetch from Meta for live data
|
|
1830
|
+
const int = await getIntegration(sb, storeId);
|
|
1831
|
+
const fields = "id,name,description,subtype,approximate_count,delivery_status,operation_status,time_created,time_updated";
|
|
1832
|
+
const result = await graphGet(`${int.adAccountId}/customaudiences`, fields, int.accessToken);
|
|
1833
|
+
// Also sync to local DB
|
|
1834
|
+
const audiences = (result.data || []);
|
|
1835
|
+
for (const aud of audiences) {
|
|
1836
|
+
await sb.from("meta_audiences").upsert({
|
|
1837
|
+
store_id: storeId,
|
|
1838
|
+
meta_audience_id: aud.id,
|
|
1839
|
+
name: aud.name,
|
|
1840
|
+
description: aud.description || null,
|
|
1841
|
+
subtype: aud.subtype || null,
|
|
1842
|
+
approximate_count: aud.approximate_count ? Number(aud.approximate_count) : null,
|
|
1843
|
+
updated_at: new Date().toISOString(),
|
|
1844
|
+
}, { onConflict: "meta_audience_id", ignoreDuplicates: false }).then(() => { });
|
|
1845
|
+
}
|
|
1846
|
+
return { success: true, count: audiences.length, data: audiences };
|
|
1847
|
+
}
|
|
1848
|
+
case "get_audience": {
|
|
1849
|
+
const id = (args.audience_id || args.id);
|
|
1850
|
+
if (!id)
|
|
1851
|
+
return { success: false, error: "audience_id required" };
|
|
1852
|
+
// Check if it's a local UUID or Meta ID
|
|
1853
|
+
let metaId = id;
|
|
1854
|
+
if (isLocalId(id)) {
|
|
1855
|
+
const { data } = await sb.from("meta_audiences").select("meta_audience_id")
|
|
1856
|
+
.eq("id", id).single();
|
|
1857
|
+
if (!data?.meta_audience_id)
|
|
1858
|
+
return { success: false, error: `Audience not found: ${id}` };
|
|
1859
|
+
metaId = data.meta_audience_id;
|
|
1860
|
+
}
|
|
1861
|
+
const int = await getIntegration(sb, storeId);
|
|
1862
|
+
const fields = "id,name,description,subtype,approximate_count,delivery_status,operation_status,rule,lookalike_spec,time_created,time_updated";
|
|
1863
|
+
const result = await graphGet(metaId, fields, int.accessToken);
|
|
1864
|
+
return { success: true, data: result };
|
|
1865
|
+
}
|
|
1866
|
+
case "delete_audience": {
|
|
1867
|
+
const id = (args.audience_id || args.id);
|
|
1868
|
+
if (!id)
|
|
1869
|
+
return { success: false, error: "audience_id required" };
|
|
1870
|
+
let metaId = id;
|
|
1871
|
+
if (isLocalId(id)) {
|
|
1872
|
+
const { data } = await sb.from("meta_audiences").select("meta_audience_id")
|
|
1873
|
+
.eq("id", id).single();
|
|
1874
|
+
if (data?.meta_audience_id)
|
|
1875
|
+
metaId = data.meta_audience_id;
|
|
1876
|
+
}
|
|
1877
|
+
const int = await getIntegration(sb, storeId);
|
|
1878
|
+
let metaDeleted = false;
|
|
1879
|
+
if (!isLocalId(metaId)) {
|
|
1880
|
+
try {
|
|
1881
|
+
await graphDelete(metaId, int.accessToken);
|
|
1882
|
+
metaDeleted = true;
|
|
1883
|
+
}
|
|
1884
|
+
catch { /* continue */ }
|
|
1885
|
+
}
|
|
1886
|
+
// Delete from local DB
|
|
1887
|
+
if (isLocalId(id)) {
|
|
1888
|
+
await sb.from("meta_audiences").delete().eq("id", id);
|
|
1889
|
+
}
|
|
1890
|
+
else {
|
|
1891
|
+
await sb.from("meta_audiences").delete().eq("meta_audience_id", id).eq("store_id", storeId);
|
|
1892
|
+
}
|
|
1893
|
+
return { success: true, data: { deleted: id, meta_deleted: metaDeleted } };
|
|
1894
|
+
}
|
|
1895
|
+
case "create_lookalike": {
|
|
1896
|
+
const sourceId = (args.source_audience_id || args.source_id);
|
|
1897
|
+
if (!sourceId)
|
|
1898
|
+
return { success: false, error: "source_audience_id required (Meta audience ID)" };
|
|
1899
|
+
const int = await getIntegration(sb, storeId);
|
|
1900
|
+
const name = args.name || `Lookalike — ${sourceId}`;
|
|
1901
|
+
const country = args.country || "US";
|
|
1902
|
+
const ratio = Number(args.ratio) || 0.01; // 1% default
|
|
1903
|
+
// Resolve source Meta ID if local UUID given
|
|
1904
|
+
let sourceMetaId = sourceId;
|
|
1905
|
+
if (isLocalId(sourceId)) {
|
|
1906
|
+
const { data } = await sb.from("meta_audiences").select("meta_audience_id")
|
|
1907
|
+
.eq("id", sourceId).single();
|
|
1908
|
+
if (!data?.meta_audience_id)
|
|
1909
|
+
return { success: false, error: `Source audience not found: ${sourceId}` };
|
|
1910
|
+
sourceMetaId = data.meta_audience_id;
|
|
1911
|
+
}
|
|
1912
|
+
const result = await graphPost(`${int.adAccountId}/customaudiences`, {
|
|
1913
|
+
name,
|
|
1914
|
+
subtype: "LOOKALIKE",
|
|
1915
|
+
origin_audience_id: sourceMetaId,
|
|
1916
|
+
lookalike_spec: JSON.stringify({
|
|
1917
|
+
type: "custom_ratio",
|
|
1918
|
+
ratio,
|
|
1919
|
+
country,
|
|
1920
|
+
}),
|
|
1921
|
+
}, int.accessToken);
|
|
1922
|
+
const metaAudienceId = result.id;
|
|
1923
|
+
// Save to local DB
|
|
1924
|
+
const { data, error } = await sb.from("meta_audiences").insert({
|
|
1925
|
+
store_id: storeId,
|
|
1926
|
+
meta_audience_id: metaAudienceId || null,
|
|
1927
|
+
name,
|
|
1928
|
+
audience_type: "lookalike",
|
|
1929
|
+
subtype: "LOOKALIKE",
|
|
1930
|
+
lookalike_spec: { source_id: sourceMetaId, country, ratio },
|
|
1931
|
+
}).select().single();
|
|
1932
|
+
if (error)
|
|
1933
|
+
return { success: false, error: error.message };
|
|
1934
|
+
return { success: true, data: { ...data, meta_audience_id: metaAudienceId } };
|
|
1935
|
+
}
|
|
1936
|
+
// ==================================================================
|
|
1937
|
+
// LEAD FORMS
|
|
1938
|
+
// ==================================================================
|
|
1939
|
+
case "create_lead_form": {
|
|
1940
|
+
const name = args.name;
|
|
1941
|
+
if (!name)
|
|
1942
|
+
return { success: false, error: "name required" };
|
|
1943
|
+
const int = await getIntegration(sb, storeId);
|
|
1944
|
+
if (!int.pageId)
|
|
1945
|
+
return { success: false, error: "No Facebook Page linked. Required for lead forms." };
|
|
1946
|
+
const questions = args.questions || [
|
|
1947
|
+
{ type: "FULL_NAME" },
|
|
1948
|
+
{ type: "EMAIL" },
|
|
1949
|
+
{ type: "PHONE" },
|
|
1950
|
+
];
|
|
1951
|
+
const privacyUrl = args.privacy_policy_url || "https://example.com/privacy";
|
|
1952
|
+
const metaParams = {
|
|
1953
|
+
name,
|
|
1954
|
+
questions: JSON.stringify(questions),
|
|
1955
|
+
privacy_policy_url: privacyUrl,
|
|
1956
|
+
privacy_policy: JSON.stringify({ url: privacyUrl, link_text: "Privacy Policy" }),
|
|
1957
|
+
follow_up_action_url: privacyUrl,
|
|
1958
|
+
follow_up_action_text: "Visit Website",
|
|
1959
|
+
};
|
|
1960
|
+
if (args.context_card) {
|
|
1961
|
+
metaParams.context_card = JSON.stringify(args.context_card);
|
|
1962
|
+
}
|
|
1963
|
+
if (args.thank_you_page) {
|
|
1964
|
+
metaParams.thank_you_page = JSON.stringify(args.thank_you_page);
|
|
1965
|
+
}
|
|
1966
|
+
const pageToken = await getPageToken(int);
|
|
1967
|
+
const result = await graphPost(`${int.pageId}/leadgen_forms`, metaParams, pageToken);
|
|
1968
|
+
const metaFormId = result.id;
|
|
1969
|
+
const { data, error } = await sb.from("meta_lead_forms").insert({
|
|
1970
|
+
store_id: storeId,
|
|
1971
|
+
meta_form_id: metaFormId || null,
|
|
1972
|
+
page_id: int.pageId,
|
|
1973
|
+
name,
|
|
1974
|
+
questions,
|
|
1975
|
+
privacy_policy: args.privacy_policy_url ? { url: args.privacy_policy_url } : null,
|
|
1976
|
+
context_card: args.context_card || null,
|
|
1977
|
+
thank_you_page: args.thank_you_page || null,
|
|
1978
|
+
}).select().single();
|
|
1979
|
+
if (error)
|
|
1980
|
+
return { success: false, error: error.message };
|
|
1981
|
+
return { success: true, data: { ...data, meta_form_id: metaFormId } };
|
|
1982
|
+
}
|
|
1983
|
+
case "list_lead_forms": {
|
|
1984
|
+
const int = await getIntegration(sb, storeId);
|
|
1985
|
+
if (!int.pageId)
|
|
1986
|
+
return { success: false, error: "No Facebook Page linked." };
|
|
1987
|
+
const pageToken = await getPageToken(int);
|
|
1988
|
+
const fields = "id,name,status,questions,privacy_policy_url,created_time,leads_count";
|
|
1989
|
+
const result = await graphGet(`${int.pageId}/leadgen_forms`, fields, pageToken);
|
|
1990
|
+
return { success: true, data: result.data || [] };
|
|
1991
|
+
}
|
|
1992
|
+
case "get_leads": {
|
|
1993
|
+
const formId = (args.form_id || args.lead_form_id);
|
|
1994
|
+
if (!formId)
|
|
1995
|
+
return { success: false, error: "form_id required (Meta lead form ID)" };
|
|
1996
|
+
// Resolve local UUID to Meta ID
|
|
1997
|
+
let metaFormId = formId;
|
|
1998
|
+
if (isLocalId(formId)) {
|
|
1999
|
+
const { data } = await sb.from("meta_lead_forms").select("meta_form_id")
|
|
2000
|
+
.eq("id", formId).single();
|
|
2001
|
+
if (!data?.meta_form_id)
|
|
2002
|
+
return { success: false, error: `Lead form not found: ${formId}` };
|
|
2003
|
+
metaFormId = data.meta_form_id;
|
|
2004
|
+
}
|
|
2005
|
+
const int = await getIntegration(sb, storeId);
|
|
2006
|
+
const pageToken = await getPageToken(int);
|
|
2007
|
+
const fields = "id,created_time,field_data,ad_id,ad_name,campaign_id,campaign_name";
|
|
2008
|
+
const result = await graphGet(`${metaFormId}/leads`, fields, pageToken);
|
|
2009
|
+
return { success: true, data: result.data || [] };
|
|
2010
|
+
}
|
|
2011
|
+
// ==================================================================
|
|
2012
|
+
// CONVERSION TRACKING — Pixels & Custom Conversions
|
|
2013
|
+
// ==================================================================
|
|
2014
|
+
case "list_pixels": {
|
|
2015
|
+
const int = await getIntegration(sb, storeId);
|
|
2016
|
+
const fields = "id,name,code,last_fired_time,is_unavailable,creation_time";
|
|
2017
|
+
const result = await graphGet(`${int.adAccountId}/adspixels`, fields, int.accessToken);
|
|
2018
|
+
const pixels = (result.data || []);
|
|
2019
|
+
// Sync to local DB
|
|
2020
|
+
for (const px of pixels) {
|
|
2021
|
+
await sb.from("meta_pixels").upsert({
|
|
2022
|
+
store_id: storeId,
|
|
2023
|
+
meta_pixel_id: px.id,
|
|
2024
|
+
name: px.name,
|
|
2025
|
+
code: px.code || null,
|
|
2026
|
+
last_fired_time: px.last_fired_time || null,
|
|
2027
|
+
is_active: !px.is_unavailable,
|
|
2028
|
+
}, { onConflict: "meta_pixel_id", ignoreDuplicates: false }).then(() => { });
|
|
2029
|
+
}
|
|
2030
|
+
return { success: true, count: pixels.length, data: pixels };
|
|
2031
|
+
}
|
|
2032
|
+
case "create_pixel": {
|
|
2033
|
+
const name = args.name;
|
|
2034
|
+
if (!name)
|
|
2035
|
+
return { success: false, error: "name required" };
|
|
2036
|
+
const int = await getIntegration(sb, storeId);
|
|
2037
|
+
// 1. Create pixel on Meta
|
|
2038
|
+
const result = await graphPost(`${int.adAccountId}/adspixels`, { name }, int.accessToken);
|
|
2039
|
+
const metaPixelId = result.id;
|
|
2040
|
+
if (!metaPixelId)
|
|
2041
|
+
return { success: false, error: "Meta did not return a pixel ID" };
|
|
2042
|
+
// 2. Fetch full pixel info (including code snippet)
|
|
2043
|
+
const pixelInfo = await graphGet(metaPixelId, "id,name,code,last_fired_time", int.accessToken);
|
|
2044
|
+
// 3. Save to local DB
|
|
2045
|
+
const { data, error } = await sb.from("meta_pixels").insert({
|
|
2046
|
+
store_id: storeId,
|
|
2047
|
+
meta_pixel_id: metaPixelId,
|
|
2048
|
+
name,
|
|
2049
|
+
code: pixelInfo.code || null,
|
|
2050
|
+
is_active: true,
|
|
2051
|
+
}).select().single();
|
|
2052
|
+
if (error)
|
|
2053
|
+
return { success: false, error: error.message };
|
|
2054
|
+
return {
|
|
2055
|
+
success: true,
|
|
2056
|
+
data: { ...data, meta_pixel_id: metaPixelId, code: pixelInfo.code || null },
|
|
2057
|
+
message: `Pixel "${name}" created on Meta (ID: ${metaPixelId}). Install the pixel code on your website to start tracking.`,
|
|
2058
|
+
};
|
|
2059
|
+
}
|
|
2060
|
+
case "delete_pixel": {
|
|
2061
|
+
const pixelId = (args.pixel_id || args.id);
|
|
2062
|
+
if (!pixelId)
|
|
2063
|
+
return { success: false, error: "pixel_id required (local UUID or meta_pixel_id)" };
|
|
2064
|
+
// Try to find by local UUID first, then by meta_pixel_id
|
|
2065
|
+
let row = null;
|
|
2066
|
+
const { data: byId } = await sb.from("meta_pixels").select("*").eq("id", pixelId).maybeSingle();
|
|
2067
|
+
if (byId) {
|
|
2068
|
+
row = byId;
|
|
2069
|
+
}
|
|
2070
|
+
else {
|
|
2071
|
+
const { data: byMetaId } = await sb.from("meta_pixels").select("*").eq("meta_pixel_id", pixelId).eq("store_id", storeId).maybeSingle();
|
|
2072
|
+
row = byMetaId;
|
|
2073
|
+
}
|
|
2074
|
+
if (!row)
|
|
2075
|
+
return { success: false, error: `Pixel not found: ${pixelId}` };
|
|
2076
|
+
// Delete from local DB (Meta pixels can't be deleted via API, only archived)
|
|
2077
|
+
const { error } = await sb.from("meta_pixels").delete().eq("id", row.id);
|
|
2078
|
+
if (error)
|
|
2079
|
+
return { success: false, error: error.message };
|
|
2080
|
+
return { success: true, message: `Pixel "${row.name}" removed from store.` };
|
|
2081
|
+
}
|
|
2082
|
+
case "create_custom_conversion": {
|
|
2083
|
+
const name = args.name;
|
|
2084
|
+
if (!name)
|
|
2085
|
+
return { success: false, error: "name required" };
|
|
2086
|
+
const int = await getIntegration(sb, storeId);
|
|
2087
|
+
const pixelId = args.pixel_id || int.pixelId;
|
|
2088
|
+
if (!pixelId)
|
|
2089
|
+
return { success: false, error: "pixel_id required (none configured — set one in Settings → Meta Connection)" };
|
|
2090
|
+
const metaParams = {
|
|
2091
|
+
name,
|
|
2092
|
+
event_source_id: pixelId,
|
|
2093
|
+
custom_event_type: args.custom_event_type || "OTHER",
|
|
2094
|
+
rule: JSON.stringify(args.rule || { url: { i_contains: "" } }),
|
|
2095
|
+
};
|
|
2096
|
+
if (args.default_conversion_value) {
|
|
2097
|
+
metaParams.default_conversion_value = Number(args.default_conversion_value);
|
|
2098
|
+
}
|
|
2099
|
+
const result = await graphPost(`${int.adAccountId}/customconversions`, metaParams, int.accessToken);
|
|
2100
|
+
const metaConversionId = result.id;
|
|
2101
|
+
const { data, error } = await sb.from("meta_custom_conversions").insert({
|
|
2102
|
+
store_id: storeId,
|
|
2103
|
+
meta_conversion_id: metaConversionId || null,
|
|
2104
|
+
name,
|
|
2105
|
+
pixel_id: pixelId,
|
|
2106
|
+
rule: args.rule || null,
|
|
2107
|
+
default_conversion_value: args.default_conversion_value ? Number(args.default_conversion_value) : null,
|
|
2108
|
+
custom_event_type: args.custom_event_type || "OTHER",
|
|
2109
|
+
}).select().single();
|
|
2110
|
+
if (error)
|
|
2111
|
+
return { success: false, error: error.message };
|
|
2112
|
+
return { success: true, data: { ...data, meta_conversion_id: metaConversionId } };
|
|
2113
|
+
}
|
|
2114
|
+
case "list_custom_conversions": {
|
|
2115
|
+
const int = await getIntegration(sb, storeId);
|
|
2116
|
+
const fields = "id,name,pixel,custom_event_type,rule,default_conversion_value,creation_time,last_fired_time";
|
|
2117
|
+
const result = await graphGet(`${int.adAccountId}/customconversions`, fields, int.accessToken);
|
|
2118
|
+
return { success: true, data: result.data || [] };
|
|
2119
|
+
}
|
|
2120
|
+
// ==================================================================
|
|
2121
|
+
// AD RULES / AUTOMATION
|
|
2122
|
+
// ==================================================================
|
|
2123
|
+
case "create_rule": {
|
|
2124
|
+
const name = args.name;
|
|
2125
|
+
if (!name)
|
|
2126
|
+
return { success: false, error: "name required" };
|
|
2127
|
+
const int = await getIntegration(sb, storeId);
|
|
2128
|
+
const evaluationSpec = args.evaluation_spec;
|
|
2129
|
+
const executionSpec = args.execution_spec;
|
|
2130
|
+
if (!evaluationSpec)
|
|
2131
|
+
return { success: false, error: "evaluation_spec required" };
|
|
2132
|
+
if (!executionSpec)
|
|
2133
|
+
return { success: false, error: "execution_spec required" };
|
|
2134
|
+
const entityType = args.entity_type || "CAMPAIGN";
|
|
2135
|
+
const scheduleSpec = args.schedule_spec || { schedule_type: "SEMI_HOURLY" };
|
|
2136
|
+
// Meta requires entity_type as a filter inside evaluation_spec.filters
|
|
2137
|
+
const evalSpecFinal = { ...evaluationSpec };
|
|
2138
|
+
delete evalSpecFinal.entity_type; // not valid as root key
|
|
2139
|
+
if (!evalSpecFinal.filters)
|
|
2140
|
+
evalSpecFinal.filters = [];
|
|
2141
|
+
const filters = evalSpecFinal.filters;
|
|
2142
|
+
const hasEntityFilter = filters.some((f) => f.field === "entity_type");
|
|
2143
|
+
if (!hasEntityFilter) {
|
|
2144
|
+
filters.push({ field: "entity_type", value: entityType, operator: "EQUAL" });
|
|
2145
|
+
}
|
|
2146
|
+
const hasTimePreset = filters.some((f) => f.field === "time_preset");
|
|
2147
|
+
if (!hasTimePreset) {
|
|
2148
|
+
filters.push({ field: "time_preset", value: "LAST_7_DAYS", operator: "EQUAL" });
|
|
2149
|
+
}
|
|
2150
|
+
const metaParams = {
|
|
2151
|
+
name,
|
|
2152
|
+
evaluation_spec: JSON.stringify(evalSpecFinal),
|
|
2153
|
+
execution_spec: JSON.stringify(executionSpec),
|
|
2154
|
+
schedule_spec: JSON.stringify(scheduleSpec),
|
|
2155
|
+
};
|
|
2156
|
+
const result = await graphPost(`${int.adAccountId}/adrules_library`, metaParams, int.accessToken);
|
|
2157
|
+
const metaRuleId = result.id;
|
|
2158
|
+
const { data, error } = await sb.from("meta_ad_rules").insert({
|
|
2159
|
+
store_id: storeId,
|
|
2160
|
+
meta_rule_id: metaRuleId || null,
|
|
2161
|
+
name,
|
|
2162
|
+
evaluation_spec: evalSpecFinal,
|
|
2163
|
+
execution_spec: executionSpec,
|
|
2164
|
+
schedule_spec: scheduleSpec,
|
|
2165
|
+
entity_type: entityType,
|
|
2166
|
+
}).select().single();
|
|
2167
|
+
if (error)
|
|
2168
|
+
return { success: false, error: error.message };
|
|
2169
|
+
return { success: true, data: { ...data, meta_rule_id: metaRuleId } };
|
|
2170
|
+
}
|
|
2171
|
+
case "list_rules": {
|
|
2172
|
+
const int = await getIntegration(sb, storeId);
|
|
2173
|
+
const fields = "id,name,status,evaluation_spec,execution_spec,schedule_spec,created_time,updated_time";
|
|
2174
|
+
const result = await graphGet(`${int.adAccountId}/adrules_library`, fields, int.accessToken);
|
|
2175
|
+
return { success: true, data: result.data || [] };
|
|
2176
|
+
}
|
|
2177
|
+
case "delete_rule": {
|
|
2178
|
+
const id = (args.rule_id || args.id);
|
|
2179
|
+
if (!id)
|
|
2180
|
+
return { success: false, error: "rule_id required" };
|
|
2181
|
+
let metaId = id;
|
|
2182
|
+
if (isLocalId(id)) {
|
|
2183
|
+
const { data } = await sb.from("meta_ad_rules").select("meta_rule_id")
|
|
2184
|
+
.eq("id", id).single();
|
|
2185
|
+
if (data?.meta_rule_id)
|
|
2186
|
+
metaId = data.meta_rule_id;
|
|
2187
|
+
}
|
|
2188
|
+
const int = await getIntegration(sb, storeId);
|
|
2189
|
+
let metaDeleted = false;
|
|
2190
|
+
if (!isLocalId(metaId)) {
|
|
2191
|
+
try {
|
|
2192
|
+
await graphDelete(metaId, int.accessToken);
|
|
2193
|
+
metaDeleted = true;
|
|
2194
|
+
}
|
|
2195
|
+
catch { /* continue */ }
|
|
2196
|
+
}
|
|
2197
|
+
// Delete from local DB
|
|
2198
|
+
if (isLocalId(id)) {
|
|
2199
|
+
await sb.from("meta_ad_rules").delete().eq("id", id);
|
|
2200
|
+
}
|
|
2201
|
+
else {
|
|
2202
|
+
await sb.from("meta_ad_rules").delete().eq("meta_rule_id", id).eq("store_id", storeId);
|
|
2203
|
+
}
|
|
2204
|
+
return { success: true, data: { deleted: id, meta_deleted: metaDeleted } };
|
|
2205
|
+
}
|
|
2206
|
+
// ==================================================================
|
|
2207
|
+
// REACH ESTIMATE / AUDIENCE INSIGHTS
|
|
2208
|
+
// ==================================================================
|
|
2209
|
+
case "reach_estimate": {
|
|
2210
|
+
const int = await getIntegration(sb, storeId);
|
|
2211
|
+
const targetingSpec = args.targeting;
|
|
2212
|
+
if (!targetingSpec)
|
|
2213
|
+
return { success: false, error: "targeting required (targeting spec object)" };
|
|
2214
|
+
const resolvedTargeting = await buildTargeting(sb, targetingSpec, int);
|
|
2215
|
+
// Meta v21 uses delivery_estimate with GET, targeting_spec as query param
|
|
2216
|
+
const extra = `targeting_spec=${encodeURIComponent(JSON.stringify(resolvedTargeting))}&optimization_goal=${encodeURIComponent(args.optimize_for || "REACH")}`;
|
|
2217
|
+
const result = await graphGet(`${int.adAccountId}/delivery_estimate`, "daily_outcomes_curve,estimate_dau,estimate_ready", int.accessToken, extra);
|
|
2218
|
+
return { success: true, data: result.data || result };
|
|
2219
|
+
}
|
|
2220
|
+
case "audience_insights": {
|
|
2221
|
+
const id = (args.ad_set_id || args.id);
|
|
2222
|
+
if (!id)
|
|
2223
|
+
return { success: false, error: "ad_set_id required" };
|
|
2224
|
+
const { data: adSet } = await sb.from("meta_ad_sets").select("meta_ad_set_id, targeting")
|
|
2225
|
+
.eq("id", id).eq("store_id", storeId).single();
|
|
2226
|
+
if (!adSet)
|
|
2227
|
+
return { success: false, error: `Ad set not found: ${id}` };
|
|
2228
|
+
const int = await getIntegration(sb, storeId);
|
|
2229
|
+
// If published, get delivery insights
|
|
2230
|
+
if (!isLocalId(adSet.meta_ad_set_id)) {
|
|
2231
|
+
const fields = "reach,impressions,frequency,actions,age_targeting,gender_targeting";
|
|
2232
|
+
const result = await graphGet(`${adSet.meta_ad_set_id}/insights`, fields, int.accessToken, "breakdowns=age,gender&date_preset=last_30d");
|
|
2233
|
+
return { success: true, data: result.data || [] };
|
|
2234
|
+
}
|
|
2235
|
+
// If draft, estimate reach
|
|
2236
|
+
const targeting = adSet.targeting
|
|
2237
|
+
? (typeof adSet.targeting === "string" ? JSON.parse(adSet.targeting) : adSet.targeting)
|
|
2238
|
+
: null;
|
|
2239
|
+
if (!targeting)
|
|
2240
|
+
return { success: false, error: "No targeting configured on this ad set" };
|
|
2241
|
+
const resolvedTargeting = await buildTargeting(sb, targeting, int);
|
|
2242
|
+
const extra = `targeting_spec=${encodeURIComponent(JSON.stringify(resolvedTargeting))}&optimization_goal=REACH`;
|
|
2243
|
+
const estimate = await graphGet(`${int.adAccountId}/delivery_estimate`, "daily_outcomes_curve,estimate_dau,estimate_ready", int.accessToken, extra);
|
|
2244
|
+
return { success: true, data: { type: "estimate", ...(Array.isArray(estimate.data) ? estimate.data[0] : estimate) } };
|
|
2245
|
+
}
|
|
2246
|
+
// ==================================================================
|
|
2247
|
+
// SEARCH TARGETING — find valid Meta interest/location IDs
|
|
2248
|
+
// ==================================================================
|
|
2249
|
+
case "search_targeting": {
|
|
2250
|
+
const query = args.query;
|
|
2251
|
+
const type = args.targeting_type || "adinterest";
|
|
2252
|
+
if (!query)
|
|
2253
|
+
return { success: false, error: "query required (search term for interests/locations)" };
|
|
2254
|
+
const int = await getIntegration(sb, storeId);
|
|
2255
|
+
const validTypes = ["adinterest", "adgeolocation", "adlocale", "adTargetingCategory"];
|
|
2256
|
+
const searchType = validTypes.includes(type) ? type : "adinterest";
|
|
2257
|
+
const url = `${META_BASE}/search?type=${searchType}&q=${enc(query)}&access_token=${enc(int.accessToken)}&limit=25`;
|
|
2258
|
+
const res = await fetch(url);
|
|
2259
|
+
if (!res.ok) {
|
|
2260
|
+
const body = await res.text();
|
|
2261
|
+
return { success: false, error: parseMetaError(body, "search", res.status) };
|
|
2262
|
+
}
|
|
2263
|
+
const json = await res.json();
|
|
2264
|
+
return { success: true, data: json.data || [] };
|
|
2265
|
+
}
|
|
2266
|
+
// ==================================================================
|
|
2267
|
+
// DEFAULT
|
|
2268
|
+
// ==================================================================
|
|
2269
|
+
default:
|
|
2270
|
+
return {
|
|
2271
|
+
success: false,
|
|
2272
|
+
error: `Unknown action: ${action}. Valid: create_campaign, create_ad_set, create_ad, create_full, list_campaigns, list_ad_sets, list_ads, get_campaign, get_ad_set, get_ad, update_campaign, update_ad_set, update_ad, delete, publish, sync, search_targeting, insights_breakdown, ad_preview, create_audience, list_audiences, get_audience, delete_audience, create_lookalike, create_lead_form, list_lead_forms, get_leads, list_pixels, create_custom_conversion, list_custom_conversions, create_rule, list_rules, delete_rule, reach_estimate, audience_insights.`,
|
|
2273
|
+
};
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
catch (err) {
|
|
2277
|
+
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
2278
|
+
}
|
|
2279
|
+
}
|