@automate.ax/integration-contracts 0.139.1 → 0.141.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.
@@ -0,0 +1,327 @@
1
+ import type { Encodable } from "@automate.ax/codec"
2
+ import * as z from "zod"
3
+ import { META_ADS_PAGE_INFO_SCHEMA } from "./schemas"
4
+
5
+ export const META_ADS_GRAPH_API_VERSION = "v26.0"
6
+ const META_ADS_API_BASE_URL = `https://graph.facebook.com/${META_ADS_GRAPH_API_VERSION}/`
7
+ const META_ADS_API_ORIGIN = new URL(META_ADS_API_BASE_URL).origin
8
+ export const META_ADS_OAUTH_SECRET_SCHEMA = z.object({
9
+ accessToken: z.string().min(1),
10
+ expiresAt: z.number().nullable(),
11
+ tokenType: z.string(),
12
+ })
13
+ const META_ERROR_SCHEMA = z.looseObject({
14
+ code: z.number().optional(),
15
+ errorSubcode: z.number().optional(),
16
+ errorUserMessage: z.string().optional(),
17
+ fbtraceId: z.string().optional(),
18
+ isTransient: z.boolean().optional(),
19
+ message: z.string(),
20
+ type: z.string().optional(),
21
+ })
22
+ const ERROR_ENVELOPE_SCHEMA = z.looseObject({ error: META_ERROR_SCHEMA })
23
+ const PAGE_SCHEMA = z.looseObject({
24
+ data: z.unknown(),
25
+ paging: z
26
+ .looseObject({
27
+ cursors: z
28
+ .looseObject({
29
+ after: z.string().optional(),
30
+ before: z.string().optional(),
31
+ })
32
+ .optional(),
33
+ next: z.string().optional(),
34
+ previous: z.string().optional(),
35
+ })
36
+ .optional(),
37
+ })
38
+
39
+ /** Query values serialized by the shared Graph client. */
40
+ type QueryValue = boolean | number | string | string[] | undefined
41
+
42
+ export interface MetaAdsResolvedAccount {
43
+ connectionMethodId: string
44
+ secret: Record<string, unknown>
45
+ serviceId: "meta-ads"
46
+ }
47
+
48
+ export interface MetaAdsRequestOptions<TSchema extends z.ZodType> {
49
+ body?: Encodable
50
+ method?: "DELETE" | "GET" | "PATCH" | "POST"
51
+ query?: Record<string, QueryValue>
52
+ responseSchema: TSchema
53
+ }
54
+
55
+ export interface MetaAdsPage<T> {
56
+ items: T
57
+ pageInfo?: z.output<typeof META_ADS_PAGE_INFO_SCHEMA>
58
+ }
59
+
60
+ /** Structured Graph API failure with Meta quota and trace diagnostics. */
61
+ export class MetaAdsApiError extends Error {
62
+ readonly appUsage?: string
63
+ readonly businessUseCaseUsage?: string
64
+ readonly code?: number
65
+ readonly fbtraceId?: string
66
+ readonly isTransient: boolean
67
+ readonly adAccountUsage?: string
68
+ readonly errorSubcode?: number
69
+ readonly retryAfter?: number
70
+ readonly status: number
71
+
72
+ /**
73
+ * Creates a structured Meta Ads failure.
74
+ *
75
+ * @param options - Transport, provider, and quota details.
76
+ * @param options.adAccountUsage - Raw ad-account usage header.
77
+ * @param options.appUsage - Raw app usage header.
78
+ * @param options.businessUseCaseUsage - Raw business-use-case usage header.
79
+ * @param options.error - Parsed provider error.
80
+ * @param options.retryAfter - Retry delay in seconds.
81
+ * @param options.status - HTTP response status.
82
+ */
83
+ constructor(options: {
84
+ adAccountUsage?: string
85
+ appUsage?: string
86
+ businessUseCaseUsage?: string
87
+ error?: z.output<typeof META_ERROR_SCHEMA>
88
+ retryAfter?: number
89
+ status: number
90
+ }) {
91
+ const providerError = options.error
92
+ super(
93
+ providerError?.message
94
+ ? `Meta Ads API error (${providerError.code ?? options.status}): ${providerError.message}`
95
+ : `Meta Ads API request failed with status ${options.status}.`,
96
+ )
97
+ this.name = "MetaAdsApiError"
98
+ this.adAccountUsage = options.adAccountUsage
99
+ this.appUsage = options.appUsage
100
+ this.businessUseCaseUsage = options.businessUseCaseUsage
101
+ this.code = providerError?.code
102
+ this.errorSubcode = providerError?.errorSubcode
103
+ this.fbtraceId = providerError?.fbtraceId
104
+ this.isTransient = providerError?.isTransient ?? false
105
+ this.retryAfter = options.retryAfter
106
+ this.status = options.status
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Creates the authenticated Graph API helper used by packaged and custom
112
+ * actions.
113
+ *
114
+ * @param account - Resolved Meta Ads OAuth account.
115
+ * @throws When the connection method or stored credential is invalid.
116
+ */
117
+ export function getMetaAdsApi(account: MetaAdsResolvedAccount) {
118
+ if (account.connectionMethodId !== "oauth") {
119
+ throw new Error(
120
+ `Unsupported Meta Ads connection method: ${account.connectionMethodId}`,
121
+ )
122
+ }
123
+ const { accessToken } = META_ADS_OAUTH_SECRET_SCHEMA.parse(account.secret)
124
+
125
+ /**
126
+ * Calls one Graph endpoint and validates its normalized result.
127
+ *
128
+ * @param path - Path relative to the pinned Graph root.
129
+ * @param options - Request options and response schema.
130
+ */
131
+ async function call<TSchema extends z.ZodType>(
132
+ path: string,
133
+ options: MetaAdsRequestOptions<TSchema>,
134
+ ): Promise<z.output<TSchema>> {
135
+ return options.responseSchema.parse(
136
+ fromMeta(await rawRequest(path, options)),
137
+ )
138
+ }
139
+
140
+ /**
141
+ * Calls one Graph edge and preserves cursor metadata.
142
+ *
143
+ * @param path - Path relative to the pinned Graph root.
144
+ * @param options - Request options and item schema.
145
+ */
146
+ async function page<TSchema extends z.ZodType>(
147
+ path: string,
148
+ options: MetaAdsRequestOptions<TSchema>,
149
+ ): Promise<MetaAdsPage<z.output<TSchema>>> {
150
+ const parsed = PAGE_SCHEMA.parse(await rawRequest(path, options))
151
+ const paging = parsed.paging
152
+ return {
153
+ items: options.responseSchema.parse(fromMeta(parsed.data)),
154
+ ...(paging && {
155
+ pageInfo: META_ADS_PAGE_INFO_SCHEMA.parse({
156
+ after: paging.cursors?.after,
157
+ before: paging.cursors?.before,
158
+ next: paging.next,
159
+ previous: paging.previous,
160
+ }),
161
+ }),
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Executes one authenticated request and returns its raw JSON value.
167
+ *
168
+ * @param path - Path relative to the pinned Graph root.
169
+ * @param options - Request method, query, and body.
170
+ */
171
+ async function rawRequest<TSchema extends z.ZodType>(
172
+ path: string,
173
+ options: MetaAdsRequestOptions<TSchema>,
174
+ ) {
175
+ const url = new URL(path.replace(/^\/+/, ""), META_ADS_API_BASE_URL)
176
+ if (
177
+ url.origin !== META_ADS_API_ORIGIN ||
178
+ !url.pathname.startsWith(`/${META_ADS_GRAPH_API_VERSION}/`)
179
+ ) {
180
+ throw new Error(
181
+ `Meta Ads API paths must remain under /${META_ADS_GRAPH_API_VERSION}.`,
182
+ )
183
+ }
184
+ for (const [name, value] of Object.entries(options.query ?? {})) {
185
+ if (value !== undefined) {
186
+ url.searchParams.set(
187
+ toSnakeCase(name),
188
+ Array.isArray(value) ? value.join(",") : String(value),
189
+ )
190
+ }
191
+ }
192
+ const body =
193
+ options.body === undefined ? undefined : toFormData(options.body)
194
+ const response = await fetch(url, {
195
+ body,
196
+ headers: { Authorization: `Bearer ${accessToken}` },
197
+ method: options.method ?? (body ? "POST" : "GET"),
198
+ })
199
+ const payload = parseJson(await response.text())
200
+ if (!response.ok) {
201
+ const error = ERROR_ENVELOPE_SCHEMA.safeParse(fromMeta(payload))
202
+ throw new MetaAdsApiError({
203
+ adAccountUsage: response.headers.get("X-Ad-Account-Usage") ?? undefined,
204
+ appUsage: response.headers.get("X-App-Usage") ?? undefined,
205
+ businessUseCaseUsage:
206
+ response.headers.get("X-Business-Use-Case-Usage") ?? undefined,
207
+ error: error.success ? error.data.error : undefined,
208
+ retryAfter: numberOrUndefined(response.headers.get("Retry-After")),
209
+ status: response.status,
210
+ })
211
+ }
212
+ return payload
213
+ }
214
+
215
+ return { call, page }
216
+ }
217
+
218
+ /**
219
+ * Encodes one public request body for Graph's form boundary.
220
+ *
221
+ * @param value - Codec-safe public request body.
222
+ * @throws When the body is not an object.
223
+ */
224
+ function toFormData(value: Encodable) {
225
+ if (!isRecord(value))
226
+ throw new Error("Meta Ads request bodies must be objects.")
227
+ const form = new URLSearchParams()
228
+ for (const [name, item] of Object.entries(value)) {
229
+ if (item === undefined) continue
230
+ form.set(
231
+ toSnakeCase(name),
232
+ typeof item === "string" ||
233
+ typeof item === "number" ||
234
+ typeof item === "boolean"
235
+ ? String(item)
236
+ : item instanceof Date
237
+ ? item.toISOString()
238
+ : JSON.stringify(toMeta(item)),
239
+ )
240
+ }
241
+ return form
242
+ }
243
+
244
+ /**
245
+ * Converts public camelCase object keys to Meta wire keys recursively.
246
+ *
247
+ * @param value - Codec-safe public value.
248
+ */
249
+ export function toMeta(value: Encodable): Encodable {
250
+ if (value instanceof Date) return value.toISOString()
251
+ if (Array.isArray(value)) return value.map(toMeta)
252
+ if (!isRecord(value)) return value
253
+ return Object.fromEntries(
254
+ Object.entries(value).map(([key, item]) => [
255
+ toSnakeCase(key),
256
+ toMeta(item),
257
+ ]),
258
+ )
259
+ }
260
+
261
+ /**
262
+ * Converts Meta wire keys to public camelCase keys recursively.
263
+ *
264
+ * @param value - Provider value.
265
+ */
266
+ export function fromMeta(value: unknown): unknown {
267
+ if (Array.isArray(value)) return value.map(fromMeta)
268
+ if (!isRecord(value)) return value
269
+ return Object.fromEntries(
270
+ Object.entries(value).map(([key, item]) => [
271
+ toCamelCase(key),
272
+ fromMeta(item),
273
+ ]),
274
+ )
275
+ }
276
+
277
+ /**
278
+ * Converts one camelCase key to snake_case.
279
+ *
280
+ * @param value - Public key.
281
+ */
282
+ function toSnakeCase(value: string) {
283
+ return value.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
284
+ }
285
+
286
+ /**
287
+ * Converts one snake_case key to camelCase.
288
+ *
289
+ * @param value - Provider key.
290
+ */
291
+ function toCamelCase(value: string) {
292
+ return value.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())
293
+ }
294
+
295
+ /**
296
+ * Parses a response body while retaining non-JSON provider failures.
297
+ *
298
+ * @param text - Raw response body.
299
+ */
300
+ function parseJson(text: string): unknown {
301
+ if (text === "") return undefined
302
+ try {
303
+ return JSON.parse(text)
304
+ } catch {
305
+ return text
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Parses an optional finite numeric header.
311
+ *
312
+ * @param value - Raw header value.
313
+ */
314
+ function numberOrUndefined(value: string | null) {
315
+ if (value === null) return undefined
316
+ const number = Number(value)
317
+ return Number.isFinite(number) ? number : undefined
318
+ }
319
+
320
+ /**
321
+ * Narrows one codec-safe object value.
322
+ *
323
+ * @param value - Unknown value.
324
+ */
325
+ function isRecord(value: unknown): value is Record<string, Encodable> {
326
+ return typeof value === "object" && value !== null && !Array.isArray(value)
327
+ }
@@ -0,0 +1,218 @@
1
+ import { encodableSchema } from "@automate.ax/codec"
2
+ import * as z from "zod"
3
+ import { fromMeta } from "./api"
4
+ import { META_ADS_ID_SCHEMA, META_ADS_WEBHOOK_ENVELOPE_SCHEMA } from "./schemas"
5
+
6
+ export const META_ADS_AD_ACCOUNT_FIELDS = [
7
+ "ad_recommendations",
8
+ "creative_fatigue",
9
+ "effective_status",
10
+ "in_process_ad_objects",
11
+ "subscriptions",
12
+ "with_issues_ad_objects",
13
+ ] as const
14
+
15
+ const META_ADS_EVENT_BASE_SCHEMA = z.object({
16
+ accountId: META_ADS_ID_SCHEMA,
17
+ field: z.string(),
18
+ occurredAt: z.number().int().nonnegative(),
19
+ })
20
+
21
+ export const META_ADS_EVENT_SCHEMA = META_ADS_EVENT_BASE_SCHEMA.extend({
22
+ value: encodableSchema,
23
+ })
24
+
25
+ export const META_ADS_EFFECTIVE_STATUS_EVENT_SCHEMA =
26
+ META_ADS_EVENT_BASE_SCHEMA.extend({
27
+ field: z.literal("field_changed"),
28
+ value: z.object({
29
+ changedFields: z.literal("effective_status").array(),
30
+ objectId: META_ADS_ID_SCHEMA,
31
+ objectType: z.enum(["ad", "adset", "campaign"]),
32
+ }),
33
+ })
34
+
35
+ export const META_ADS_SUBSCRIPTION_EVENT_SCHEMA =
36
+ META_ADS_EVENT_BASE_SCHEMA.extend({
37
+ field: z.literal("subscriptions"),
38
+ value: z.object({
39
+ accountId: z.union([META_ADS_ID_SCHEMA, z.number()]).transform(String),
40
+ currentValue: z.string().optional(),
41
+ field: z.string().optional(),
42
+ objectId: z.union([META_ADS_ID_SCHEMA, z.number()]).transform(String),
43
+ objectType: z.enum(["ad", "adset", "campaign"]),
44
+ subscriptionId: z
45
+ .union([META_ADS_ID_SCHEMA, z.number()])
46
+ .transform(String),
47
+ }),
48
+ })
49
+
50
+ export const META_ADS_CREATIVE_FATIGUE_EVENT_SCHEMA =
51
+ META_ADS_EVENT_BASE_SCHEMA.extend({
52
+ field: z.literal("creative_fatigue"),
53
+ value: z.object({
54
+ adAccountId: META_ADS_ID_SCHEMA,
55
+ adgroupId: META_ADS_ID_SCHEMA,
56
+ creativeFatigueLevel: z.enum(["HIGH", "LOW", "MEDIUM"]),
57
+ creativeFatigueMessage: z.string(),
58
+ }),
59
+ })
60
+
61
+ export const META_ADS_RECOMMENDATION_EVENT_SCHEMA =
62
+ META_ADS_EVENT_BASE_SCHEMA.extend({
63
+ field: z.literal("ad_recommendations"),
64
+ value: z.object({
65
+ adAccountId: META_ADS_ID_SCHEMA,
66
+ adObjectIds: META_ADS_ID_SCHEMA.array(),
67
+ recommendationHash: z.string(),
68
+ recommendationMessage: z.string(),
69
+ recommendationSignature: z.string(),
70
+ recommendationStage: z.string(),
71
+ recommendationType: z.string(),
72
+ }),
73
+ })
74
+
75
+ export const META_ADS_PROCESSING_EVENT_SCHEMA =
76
+ META_ADS_EVENT_BASE_SCHEMA.extend({
77
+ field: z.literal("in_process_ad_objects"),
78
+ value: z.object({
79
+ id: META_ADS_ID_SCHEMA,
80
+ level: z.enum(["AD", "AD_SET", "CAMPAIGN", "CREATIVE"]),
81
+ statusName: z.string(),
82
+ }),
83
+ })
84
+
85
+ export const META_ADS_ISSUES_EVENT_SCHEMA = META_ADS_EVENT_BASE_SCHEMA.extend({
86
+ field: z.literal("with_issues_ad_objects"),
87
+ value: z.object({
88
+ errorCode: z.string(),
89
+ errorMessage: z.string(),
90
+ errorSummary: z.string(),
91
+ id: META_ADS_ID_SCHEMA,
92
+ level: z.enum(["AD", "AD_SET", "CAMPAIGN", "CREATIVE"]),
93
+ }),
94
+ })
95
+
96
+ export const META_ADS_LEAD_CREATED_EVENT_SCHEMA = z.object({
97
+ adId: z.string().optional(),
98
+ adgroupId: z.string().optional(),
99
+ createdTime: z.number().int().nonnegative(),
100
+ formId: META_ADS_ID_SCHEMA,
101
+ leadgenId: META_ADS_ID_SCHEMA,
102
+ pageId: META_ADS_ID_SCHEMA,
103
+ })
104
+
105
+ export const META_ADS_TRIGGER_CONFIG_SCHEMA = z.object({
106
+ account: z.string().optional(),
107
+ adAccountId: META_ADS_ID_SCHEMA,
108
+ })
109
+
110
+ export const META_ADS_LEAD_TRIGGER_CONFIG_SCHEMA = z.object({
111
+ account: z.string().optional(),
112
+ pageId: META_ADS_ID_SCHEMA,
113
+ })
114
+
115
+ export interface NormalizedMetaAdsWebhook {
116
+ adAccountEvents: z.output<typeof META_ADS_EVENT_SCHEMA>[]
117
+ leadEvents: z.output<typeof META_ADS_LEAD_CREATED_EVENT_SCHEMA>[]
118
+ }
119
+
120
+ export const metaAdsTriggerContracts = {
121
+ "meta-ads.ad.effectiveStatusChanged": {
122
+ configSchema: META_ADS_TRIGGER_CONFIG_SCHEMA,
123
+ eventSchema: META_ADS_EFFECTIVE_STATUS_EVENT_SCHEMA,
124
+ },
125
+ "meta-ads.ad.recommendation": {
126
+ configSchema: META_ADS_TRIGGER_CONFIG_SCHEMA,
127
+ eventSchema: META_ADS_RECOMMENDATION_EVENT_SCHEMA,
128
+ },
129
+ "meta-ads.adObject.issuesChanged": {
130
+ configSchema: META_ADS_TRIGGER_CONFIG_SCHEMA,
131
+ eventSchema: META_ADS_ISSUES_EVENT_SCHEMA,
132
+ },
133
+ "meta-ads.adObject.processingCompleted": {
134
+ configSchema: META_ADS_TRIGGER_CONFIG_SCHEMA,
135
+ eventSchema: META_ADS_PROCESSING_EVENT_SCHEMA,
136
+ },
137
+ "meta-ads.adSet.effectiveStatusChanged": {
138
+ configSchema: META_ADS_TRIGGER_CONFIG_SCHEMA,
139
+ eventSchema: META_ADS_EFFECTIVE_STATUS_EVENT_SCHEMA,
140
+ },
141
+ "meta-ads.campaign.effectiveStatusChanged": {
142
+ configSchema: META_ADS_TRIGGER_CONFIG_SCHEMA,
143
+ eventSchema: META_ADS_EFFECTIVE_STATUS_EVENT_SCHEMA,
144
+ },
145
+ "meta-ads.creativeFatigue.changed": {
146
+ configSchema: META_ADS_TRIGGER_CONFIG_SCHEMA,
147
+ eventSchema: META_ADS_CREATIVE_FATIGUE_EVENT_SCHEMA,
148
+ },
149
+ "meta-ads.event": {
150
+ configSchema: META_ADS_TRIGGER_CONFIG_SCHEMA,
151
+ eventSchema: META_ADS_EVENT_SCHEMA,
152
+ },
153
+ "meta-ads.insights.milestoneReached": {
154
+ configSchema: META_ADS_TRIGGER_CONFIG_SCHEMA,
155
+ eventSchema: META_ADS_SUBSCRIPTION_EVENT_SCHEMA,
156
+ },
157
+ "meta-ads.insights.updated": {
158
+ configSchema: META_ADS_TRIGGER_CONFIG_SCHEMA,
159
+ eventSchema: META_ADS_SUBSCRIPTION_EVENT_SCHEMA,
160
+ },
161
+ "meta-ads.lead.created": {
162
+ configSchema: META_ADS_LEAD_TRIGGER_CONFIG_SCHEMA,
163
+ eventSchema: META_ADS_LEAD_CREATED_EVENT_SCHEMA,
164
+ },
165
+ "meta-ads.object.created": {
166
+ configSchema: META_ADS_TRIGGER_CONFIG_SCHEMA,
167
+ eventSchema: META_ADS_SUBSCRIPTION_EVENT_SCHEMA,
168
+ },
169
+ "meta-ads.object.updated": {
170
+ configSchema: META_ADS_TRIGGER_CONFIG_SCHEMA,
171
+ eventSchema: META_ADS_SUBSCRIPTION_EVENT_SCHEMA,
172
+ },
173
+ } as const
174
+
175
+ /**
176
+ * Normalizes Meta's Page and ad-account envelopes into stable public events.
177
+ *
178
+ * @param input - Untrusted provider webhook envelope.
179
+ */
180
+ export function normalizeMetaAdsWebhook(
181
+ input: unknown,
182
+ ): NormalizedMetaAdsWebhook {
183
+ const envelope = META_ADS_WEBHOOK_ENVELOPE_SCHEMA.parse(input)
184
+ const adAccountEvents: z.output<typeof META_ADS_EVENT_SCHEMA>[] = []
185
+ const leadEvents: z.output<typeof META_ADS_LEAD_CREATED_EVENT_SCHEMA>[] = []
186
+ for (const entry of envelope.entry) {
187
+ for (const change of entry.changes) {
188
+ if (envelope.object === "page" && change.field === "leadgen") {
189
+ const value = z
190
+ .object({
191
+ ad_id: z.string().optional(),
192
+ adgroup_id: z.string().optional(),
193
+ created_time: z.number().int().nonnegative(),
194
+ form_id: z.string(),
195
+ leadgen_id: z.string(),
196
+ page_id: z.string(),
197
+ })
198
+ .parse(change.value)
199
+ leadEvents.push({
200
+ adId: value.ad_id,
201
+ adgroupId: value.adgroup_id,
202
+ createdTime: value.created_time,
203
+ formId: value.form_id,
204
+ leadgenId: value.leadgen_id,
205
+ pageId: value.page_id,
206
+ })
207
+ } else if (envelope.object === "ad_account") {
208
+ adAccountEvents.push({
209
+ accountId: entry.id,
210
+ field: change.field,
211
+ occurredAt: entry.time,
212
+ value: encodableSchema.parse(fromMeta(change.value)),
213
+ })
214
+ }
215
+ }
216
+ }
217
+ return { adAccountEvents, leadEvents }
218
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./api"
2
+ export * from "./events"
3
+ export * from "./schemas"