@saltcorn/meta-marketing-api 0.1.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/LICENSE +21 -0
- package/README.md +180 -0
- package/api.js +561 -0
- package/common.js +449 -0
- package/index.js +516 -0
- package/package.json +36 -0
- package/sync-action.js +337 -0
- package/table-provider.js +351 -0
- package/tests/api.test.js +94 -0
- package/tests/common.test.js +186 -0
package/sync-action.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
const Table = require("@saltcorn/data/models/table");
|
|
2
|
+
const Trigger = require("@saltcorn/data/models/trigger");
|
|
3
|
+
const FieldRepeat = require("@saltcorn/data/models/fieldrepeat");
|
|
4
|
+
const {
|
|
5
|
+
objectTypeNames,
|
|
6
|
+
INSIGHTS_LEVELS,
|
|
7
|
+
DATE_PRESETS,
|
|
8
|
+
EFFECTIVE_STATUSES,
|
|
9
|
+
fetchObjects,
|
|
10
|
+
coerceValue,
|
|
11
|
+
} = require("./common");
|
|
12
|
+
|
|
13
|
+
const INSIGHTS_ONLY = { object_type: "Insights" };
|
|
14
|
+
const HAS_STATUS = { object_type: ["Campaigns", "Ad sets", "Ads"] };
|
|
15
|
+
const NEEDS_ACCOUNT = {
|
|
16
|
+
object_type: ["Campaigns", "Ad sets", "Ads", "Ad creatives", "Insights"],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const objMap = (obj, f) => {
|
|
20
|
+
const result = {};
|
|
21
|
+
Object.keys(obj).forEach((k) => {
|
|
22
|
+
result[k] = f(obj[k]);
|
|
23
|
+
});
|
|
24
|
+
return result;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const typeName = (field) =>
|
|
28
|
+
typeof field.type === "string" ? field.type : field.type?.name;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Copy Meta objects into a Saltcorn table. Unlike the table provider, which
|
|
32
|
+
* shows what Meta has right now, this keeps a permanent copy: useful because
|
|
33
|
+
* Meta only keeps insights for a limited period.
|
|
34
|
+
*/
|
|
35
|
+
module.exports = (cfg) => ({
|
|
36
|
+
description:
|
|
37
|
+
"Copy campaigns, ad sets, ads or insights from Meta into a Saltcorn table",
|
|
38
|
+
configFields: async () => {
|
|
39
|
+
const tables = await Table.find({});
|
|
40
|
+
const tableMap = {};
|
|
41
|
+
tables.forEach((t) => (tableMap[t.name] = t));
|
|
42
|
+
|
|
43
|
+
const namedFields = (pred) =>
|
|
44
|
+
objMap(tableMap, (table) =>
|
|
45
|
+
table.fields.filter(pred).map((f) => f.name)
|
|
46
|
+
);
|
|
47
|
+
const strFields = namedFields((f) => typeName(f) === "String");
|
|
48
|
+
const jsonFields = objMap(tableMap, (table) => [
|
|
49
|
+
"",
|
|
50
|
+
...table.fields
|
|
51
|
+
.filter((f) => typeName(f) === "JSON")
|
|
52
|
+
.map((f) => f.name),
|
|
53
|
+
]);
|
|
54
|
+
const allFields = objMap(tableMap, (table) =>
|
|
55
|
+
table.fields.filter((f) => !f.primary_key).map((f) => f.name)
|
|
56
|
+
);
|
|
57
|
+
const triggers = await Trigger.find({});
|
|
58
|
+
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
name: "object_type",
|
|
62
|
+
label: "Object type",
|
|
63
|
+
sublabel: "What to read from Meta",
|
|
64
|
+
type: "String",
|
|
65
|
+
required: true,
|
|
66
|
+
attributes: { options: objectTypeNames },
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "ad_account_id",
|
|
70
|
+
label: "Ad account",
|
|
71
|
+
sublabel:
|
|
72
|
+
"Ad account id, with or without the act_ prefix. Leave blank to use the default ad account from the plugin settings.",
|
|
73
|
+
type: "String",
|
|
74
|
+
showIf: NEEDS_ACCOUNT,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "object_id",
|
|
78
|
+
label: "Report on",
|
|
79
|
+
sublabel:
|
|
80
|
+
"Id of the campaign, ad set or ad to report on. Leave blank to report on the whole ad account.",
|
|
81
|
+
type: "String",
|
|
82
|
+
showIf: INSIGHTS_ONLY,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: "level",
|
|
86
|
+
label: "Level",
|
|
87
|
+
sublabel: "One row per what",
|
|
88
|
+
type: "String",
|
|
89
|
+
attributes: { options: INSIGHTS_LEVELS },
|
|
90
|
+
showIf: INSIGHTS_ONLY,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: "date_preset",
|
|
94
|
+
label: "Date range",
|
|
95
|
+
type: "String",
|
|
96
|
+
attributes: { options: DATE_PRESETS },
|
|
97
|
+
showIf: INSIGHTS_ONLY,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: "since",
|
|
101
|
+
label: "From date",
|
|
102
|
+
sublabel:
|
|
103
|
+
"YYYY-MM-DD. Overrides the date range above when both this and the To date are set.",
|
|
104
|
+
type: "String",
|
|
105
|
+
showIf: INSIGHTS_ONLY,
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: "until",
|
|
109
|
+
label: "To date",
|
|
110
|
+
sublabel: "YYYY-MM-DD",
|
|
111
|
+
type: "String",
|
|
112
|
+
showIf: INSIGHTS_ONLY,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: "time_increment",
|
|
116
|
+
label: "Time increment",
|
|
117
|
+
sublabel:
|
|
118
|
+
"Number of days per row, or monthly or all_days. Blank gives one row for the whole period.",
|
|
119
|
+
type: "String",
|
|
120
|
+
showIf: INSIGHTS_ONLY,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: "breakdowns",
|
|
124
|
+
label: "Breakdowns",
|
|
125
|
+
sublabel: "Comma separated, for example age,gender",
|
|
126
|
+
type: "String",
|
|
127
|
+
showIf: INSIGHTS_ONLY,
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "asynchronous",
|
|
131
|
+
label: "Asynchronous report",
|
|
132
|
+
sublabel:
|
|
133
|
+
"Submit the report as a job and wait for it. Needed for large date ranges.",
|
|
134
|
+
type: "Bool",
|
|
135
|
+
showIf: INSIGHTS_ONLY,
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: "effective_status",
|
|
139
|
+
label: "Statuses",
|
|
140
|
+
sublabel: `Comma separated. Leave blank for all. One or more of: ${EFFECTIVE_STATUSES.join(
|
|
141
|
+
", "
|
|
142
|
+
)}`,
|
|
143
|
+
type: "String",
|
|
144
|
+
showIf: HAS_STATUS,
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
name: "fields",
|
|
148
|
+
label: "Fields",
|
|
149
|
+
sublabel:
|
|
150
|
+
"Comma separated list of Meta API fields. Leave blank for a sensible default set.",
|
|
151
|
+
type: "String",
|
|
152
|
+
fieldview: "textarea",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "filtering",
|
|
156
|
+
label: "Filtering",
|
|
157
|
+
sublabel: "Advanced: a Marketing API filtering clause, as JSON",
|
|
158
|
+
type: "String",
|
|
159
|
+
fieldview: "textarea",
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: "limit",
|
|
163
|
+
label: "Rows per request",
|
|
164
|
+
type: "Integer",
|
|
165
|
+
default: 200,
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "max_pages",
|
|
169
|
+
label: "Maximum pages",
|
|
170
|
+
sublabel: "Stop after this many requests",
|
|
171
|
+
type: "Integer",
|
|
172
|
+
default: 50,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
input_type: "section_header",
|
|
176
|
+
label: "Destination",
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: "table_dest",
|
|
180
|
+
label: "Destination table",
|
|
181
|
+
sublabel: "The Saltcorn table to write to",
|
|
182
|
+
input_type: "select",
|
|
183
|
+
required: true,
|
|
184
|
+
options: tables.map((t) => t.name),
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: "id_field",
|
|
188
|
+
label: "Meta id field",
|
|
189
|
+
sublabel:
|
|
190
|
+
"Text field holding the Meta object id. Rows are matched on this, so each object is stored once.",
|
|
191
|
+
type: "String",
|
|
192
|
+
required: true,
|
|
193
|
+
attributes: { calcOptions: ["table_dest", strFields] },
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: "data_field",
|
|
197
|
+
label: "Raw data field",
|
|
198
|
+
sublabel:
|
|
199
|
+
"Optional JSON field in which to store the whole object as it came from Meta",
|
|
200
|
+
type: "String",
|
|
201
|
+
attributes: { calcOptions: ["table_dest", jsonFields] },
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
input_type: "section_header",
|
|
205
|
+
label: "Field mapping",
|
|
206
|
+
sublabel:
|
|
207
|
+
"Table fields whose name matches a Meta field are filled in automatically. Add a row below for any field that needs a different name.",
|
|
208
|
+
},
|
|
209
|
+
new FieldRepeat({
|
|
210
|
+
name: "field_map",
|
|
211
|
+
fields: [
|
|
212
|
+
{
|
|
213
|
+
name: "table_field",
|
|
214
|
+
label: "Table field",
|
|
215
|
+
type: "String",
|
|
216
|
+
required: true,
|
|
217
|
+
attributes: { calcOptions: ["table_dest", allFields] },
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: "meta_field",
|
|
221
|
+
label: "Meta field",
|
|
222
|
+
sublabel:
|
|
223
|
+
"Name as returned by Meta. Nested values are joined with an underscore, for example creative_thumbnail_url.",
|
|
224
|
+
type: "String",
|
|
225
|
+
required: true,
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
}),
|
|
229
|
+
{
|
|
230
|
+
input_type: "section_header",
|
|
231
|
+
label: "Options",
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
name: "delete_missing",
|
|
235
|
+
label: "Delete missing rows",
|
|
236
|
+
sublabel:
|
|
237
|
+
"Delete rows in the destination table that Meta no longer returns",
|
|
238
|
+
type: "Bool",
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
name: "error_action",
|
|
242
|
+
label: "Error action",
|
|
243
|
+
sublabel: "Run this action if the synchronisation fails",
|
|
244
|
+
type: "String",
|
|
245
|
+
attributes: { options: triggers.map((tr) => tr.name) },
|
|
246
|
+
},
|
|
247
|
+
];
|
|
248
|
+
},
|
|
249
|
+
requireRow: false,
|
|
250
|
+
|
|
251
|
+
run: async ({ configuration, user, req }) => {
|
|
252
|
+
const {
|
|
253
|
+
table_dest,
|
|
254
|
+
id_field,
|
|
255
|
+
data_field,
|
|
256
|
+
field_map,
|
|
257
|
+
delete_missing,
|
|
258
|
+
error_action,
|
|
259
|
+
} = configuration;
|
|
260
|
+
try {
|
|
261
|
+
const table = Table.findOne({ name: table_dest });
|
|
262
|
+
if (!table) throw new Error(`Table not found: ${table_dest}`);
|
|
263
|
+
|
|
264
|
+
const metaRows = await fetchObjects({ cfg, config: configuration });
|
|
265
|
+
|
|
266
|
+
// explicit mappings win over matching by name
|
|
267
|
+
const explicit = {};
|
|
268
|
+
(field_map || []).forEach((m) => {
|
|
269
|
+
if (m?.table_field && m?.meta_field)
|
|
270
|
+
explicit[m.table_field] = m.meta_field;
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const seen = new Set();
|
|
274
|
+
let inserted = 0;
|
|
275
|
+
let updated = 0;
|
|
276
|
+
let deleted = 0;
|
|
277
|
+
|
|
278
|
+
for (const metaRow of metaRows) {
|
|
279
|
+
const key = `${metaRow.id}`;
|
|
280
|
+
if (!metaRow.id) continue;
|
|
281
|
+
seen.add(key);
|
|
282
|
+
const dbRow = {};
|
|
283
|
+
for (const field of table.fields) {
|
|
284
|
+
if (field.primary_key) continue;
|
|
285
|
+
if (field.name === id_field) continue;
|
|
286
|
+
if (data_field && field.name === data_field) {
|
|
287
|
+
dbRow[field.name] = metaRow;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const source = explicit[field.name] || field.name;
|
|
291
|
+
if (typeof metaRow[source] === "undefined") continue;
|
|
292
|
+
dbRow[field.name] = coerceValue(metaRow[source], typeName(field));
|
|
293
|
+
}
|
|
294
|
+
dbRow[id_field] = key;
|
|
295
|
+
const existing = await table.getRow({ [id_field]: key });
|
|
296
|
+
if (existing) {
|
|
297
|
+
await table.updateRow(dbRow, existing[table.pk_name], user);
|
|
298
|
+
updated += 1;
|
|
299
|
+
} else {
|
|
300
|
+
await table.insertRow(dbRow, user);
|
|
301
|
+
inserted += 1;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (delete_missing) {
|
|
306
|
+
const existingRows = await table.getRows({});
|
|
307
|
+
for (const row of existingRows) {
|
|
308
|
+
if (seen.has(`${row[id_field]}`)) continue;
|
|
309
|
+
await table.deleteRows({ [table.pk_name]: row[table.pk_name] }, user);
|
|
310
|
+
deleted += 1;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
inserted,
|
|
316
|
+
updated,
|
|
317
|
+
deleted,
|
|
318
|
+
notify: `Meta sync: ${inserted} new, ${updated} updated${
|
|
319
|
+
delete_missing ? `, ${deleted} deleted` : ""
|
|
320
|
+
}`,
|
|
321
|
+
};
|
|
322
|
+
} catch (e) {
|
|
323
|
+
console.error("Meta sync error", e);
|
|
324
|
+
if (error_action) {
|
|
325
|
+
const trigger = Trigger.findOne({ name: error_action });
|
|
326
|
+
if (trigger)
|
|
327
|
+
await trigger.runWithoutRow({
|
|
328
|
+
user,
|
|
329
|
+
req,
|
|
330
|
+
row: { error: e.message },
|
|
331
|
+
});
|
|
332
|
+
return { error: e.message };
|
|
333
|
+
}
|
|
334
|
+
throw e;
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
});
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
const Form = require("@saltcorn/data/models/form");
|
|
2
|
+
const Field = require("@saltcorn/data/models/field");
|
|
3
|
+
const FieldRepeat = require("@saltcorn/data/models/fieldrepeat");
|
|
4
|
+
const Workflow = require("@saltcorn/data/models/workflow");
|
|
5
|
+
const { getState } = require("@saltcorn/data/db/state");
|
|
6
|
+
const { mkTable } = require("@saltcorn/markup");
|
|
7
|
+
const { div, p, pre, code } = require("@saltcorn/markup/tags");
|
|
8
|
+
const {
|
|
9
|
+
objectTypeNames,
|
|
10
|
+
INSIGHTS_LEVELS,
|
|
11
|
+
DATE_PRESETS,
|
|
12
|
+
EFFECTIVE_STATUSES,
|
|
13
|
+
fetchObjects,
|
|
14
|
+
pushdownParents,
|
|
15
|
+
guessType,
|
|
16
|
+
coerceRow,
|
|
17
|
+
applyWhere,
|
|
18
|
+
rowMatches,
|
|
19
|
+
} = require("./common");
|
|
20
|
+
|
|
21
|
+
const INSIGHTS_ONLY = { object_type: "Insights" };
|
|
22
|
+
const HAS_STATUS = { object_type: ["Campaigns", "Ad sets", "Ads"] };
|
|
23
|
+
const NEEDS_ACCOUNT = {
|
|
24
|
+
object_type: ["Campaigns", "Ad sets", "Ads", "Ad creatives", "Insights"],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Rows are held briefly so that opening a list view does not hit the
|
|
28
|
+
// Marketing API once for every column filter and page click.
|
|
29
|
+
const rowCache = new Map();
|
|
30
|
+
|
|
31
|
+
const pruneCache = () => {
|
|
32
|
+
if (rowCache.size < 50) return;
|
|
33
|
+
const entries = [...rowCache.entries()].sort((a, b) => a[1].at - b[1].at);
|
|
34
|
+
entries.slice(0, entries.length - 25).forEach(([k]) => rowCache.delete(k));
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const getRowsCached = async (pluginCfg, config, where) => {
|
|
38
|
+
const parents = pushdownParents(config?.object_type, where);
|
|
39
|
+
const seconds =
|
|
40
|
+
typeof config?.cache_seconds === "number" ? config.cache_seconds : 60;
|
|
41
|
+
const key = JSON.stringify({ config, parents });
|
|
42
|
+
const hit = rowCache.get(key);
|
|
43
|
+
if (hit && seconds > 0 && Date.now() - hit.at < seconds * 1000)
|
|
44
|
+
return hit.rows;
|
|
45
|
+
const rows = await fetchObjects({ cfg: pluginCfg, config, where });
|
|
46
|
+
if (seconds > 0) {
|
|
47
|
+
rowCache.set(key, { rows, at: Date.now() });
|
|
48
|
+
pruneCache();
|
|
49
|
+
}
|
|
50
|
+
return rows;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const typeOptions = () => {
|
|
54
|
+
const names = getState()?.type_names || [];
|
|
55
|
+
const basic = ["String", "Integer", "Float", "Bool", "Date", "JSON"];
|
|
56
|
+
return names.length ? names : basic;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const configuration_workflow = (pluginCfg) => (req) =>
|
|
60
|
+
new Workflow({
|
|
61
|
+
steps: [
|
|
62
|
+
{
|
|
63
|
+
name: "Meta object",
|
|
64
|
+
form: async () =>
|
|
65
|
+
new Form({
|
|
66
|
+
fields: [
|
|
67
|
+
{
|
|
68
|
+
name: "object_type",
|
|
69
|
+
label: "Object type",
|
|
70
|
+
sublabel: "What this table should show",
|
|
71
|
+
type: "String",
|
|
72
|
+
required: true,
|
|
73
|
+
attributes: { options: objectTypeNames },
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "ad_account_id",
|
|
77
|
+
label: "Ad account",
|
|
78
|
+
sublabel:
|
|
79
|
+
"Ad account id, with or without the act_ prefix. Leave blank to use the default ad account from the plugin settings.",
|
|
80
|
+
type: "String",
|
|
81
|
+
showIf: NEEDS_ACCOUNT,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "object_id",
|
|
85
|
+
label: "Report on",
|
|
86
|
+
sublabel:
|
|
87
|
+
"Id of the campaign, ad set or ad to report on. Leave blank to report on the whole ad account.",
|
|
88
|
+
type: "String",
|
|
89
|
+
showIf: INSIGHTS_ONLY,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "level",
|
|
93
|
+
label: "Level",
|
|
94
|
+
sublabel: "One row per what",
|
|
95
|
+
type: "String",
|
|
96
|
+
attributes: { options: INSIGHTS_LEVELS },
|
|
97
|
+
showIf: INSIGHTS_ONLY,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: "date_preset",
|
|
101
|
+
label: "Date range",
|
|
102
|
+
type: "String",
|
|
103
|
+
attributes: { options: DATE_PRESETS },
|
|
104
|
+
showIf: INSIGHTS_ONLY,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: "since",
|
|
108
|
+
label: "From date",
|
|
109
|
+
sublabel:
|
|
110
|
+
"YYYY-MM-DD. Overrides the date range above when both this and the To date are set.",
|
|
111
|
+
type: "String",
|
|
112
|
+
showIf: INSIGHTS_ONLY,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: "until",
|
|
116
|
+
label: "To date",
|
|
117
|
+
sublabel: "YYYY-MM-DD",
|
|
118
|
+
type: "String",
|
|
119
|
+
showIf: INSIGHTS_ONLY,
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: "time_increment",
|
|
123
|
+
label: "Time increment",
|
|
124
|
+
sublabel:
|
|
125
|
+
"Number of days per row, or monthly or all_days. Blank gives one row for the whole period.",
|
|
126
|
+
type: "String",
|
|
127
|
+
showIf: INSIGHTS_ONLY,
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "breakdowns",
|
|
131
|
+
label: "Breakdowns",
|
|
132
|
+
sublabel:
|
|
133
|
+
"Comma separated, for example age,gender or publisher_platform",
|
|
134
|
+
type: "String",
|
|
135
|
+
showIf: INSIGHTS_ONLY,
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: "action_breakdowns",
|
|
139
|
+
label: "Action breakdowns",
|
|
140
|
+
sublabel: "Comma separated, for example action_type",
|
|
141
|
+
type: "String",
|
|
142
|
+
showIf: INSIGHTS_ONLY,
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: "asynchronous",
|
|
146
|
+
label: "Asynchronous report",
|
|
147
|
+
sublabel:
|
|
148
|
+
"Submit the report as a job and wait for it. Slower to start, but needed for large date ranges.",
|
|
149
|
+
type: "Bool",
|
|
150
|
+
showIf: INSIGHTS_ONLY,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: "effective_status",
|
|
154
|
+
label: "Statuses",
|
|
155
|
+
sublabel: `Comma separated. Leave blank for all. One or more of: ${EFFECTIVE_STATUSES.join(
|
|
156
|
+
", "
|
|
157
|
+
)}`,
|
|
158
|
+
type: "String",
|
|
159
|
+
showIf: HAS_STATUS,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: "fields",
|
|
163
|
+
label: "Fields",
|
|
164
|
+
sublabel:
|
|
165
|
+
"Comma separated list of Meta API fields. Leave blank for a sensible default set.",
|
|
166
|
+
type: "String",
|
|
167
|
+
fieldview: "textarea",
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: "filtering",
|
|
171
|
+
label: "Filtering",
|
|
172
|
+
sublabel:
|
|
173
|
+
'Advanced: a Marketing API filtering clause, for example [{"field":"campaign.name","operator":"CONTAIN","value":"Summer"}]',
|
|
174
|
+
type: "String",
|
|
175
|
+
fieldview: "textarea",
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: "extra_params",
|
|
179
|
+
label: "Extra parameters",
|
|
180
|
+
sublabel:
|
|
181
|
+
'Advanced: further query parameters as JSON, for example {"time_zone":"UTC"}',
|
|
182
|
+
type: "String",
|
|
183
|
+
fieldview: "textarea",
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: "limit",
|
|
187
|
+
label: "Rows per request",
|
|
188
|
+
sublabel: "How many rows to ask Meta for at a time",
|
|
189
|
+
type: "Integer",
|
|
190
|
+
default: 200,
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: "max_pages",
|
|
194
|
+
label: "Maximum pages",
|
|
195
|
+
sublabel: "Stop after this many requests",
|
|
196
|
+
type: "Integer",
|
|
197
|
+
default: 10,
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: "cache_seconds",
|
|
201
|
+
label: "Cache for (seconds)",
|
|
202
|
+
sublabel:
|
|
203
|
+
"How long to reuse rows already read from Meta. 0 disables caching.",
|
|
204
|
+
type: "Integer",
|
|
205
|
+
default: 60,
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
}),
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: "Columns",
|
|
212
|
+
form: async (context) => {
|
|
213
|
+
let rows = [];
|
|
214
|
+
let error;
|
|
215
|
+
try {
|
|
216
|
+
rows = await fetchObjects({ cfg: pluginCfg, config: context });
|
|
217
|
+
} catch (e) {
|
|
218
|
+
error = e.message;
|
|
219
|
+
}
|
|
220
|
+
const names = [];
|
|
221
|
+
rows.slice(0, 50).forEach((r) =>
|
|
222
|
+
Object.keys(r).forEach((k) => {
|
|
223
|
+
if (!names.includes(k)) names.push(k);
|
|
224
|
+
})
|
|
225
|
+
);
|
|
226
|
+
const sample = mkTable(
|
|
227
|
+
names.map((n) => ({ label: n, key: n })),
|
|
228
|
+
rows.slice(0, 5).map((r) => {
|
|
229
|
+
const shown = {};
|
|
230
|
+
names.forEach((n) => {
|
|
231
|
+
shown[n] =
|
|
232
|
+
r[n] && typeof r[n] === "object"
|
|
233
|
+
? JSON.stringify(r[n]).slice(0, 60)
|
|
234
|
+
: r[n];
|
|
235
|
+
});
|
|
236
|
+
return shown;
|
|
237
|
+
})
|
|
238
|
+
);
|
|
239
|
+
const form = new Form({
|
|
240
|
+
blurb: error
|
|
241
|
+
? div(
|
|
242
|
+
{ class: "alert alert-danger" },
|
|
243
|
+
"Could not read from Meta: ",
|
|
244
|
+
error
|
|
245
|
+
)
|
|
246
|
+
: div(
|
|
247
|
+
p(`${rows.length} rows read, showing the first few:`),
|
|
248
|
+
sample
|
|
249
|
+
),
|
|
250
|
+
fields: [
|
|
251
|
+
{
|
|
252
|
+
input_type: "section_header",
|
|
253
|
+
label: "Column types",
|
|
254
|
+
},
|
|
255
|
+
new FieldRepeat({
|
|
256
|
+
name: "columns",
|
|
257
|
+
fields: [
|
|
258
|
+
{
|
|
259
|
+
name: "name",
|
|
260
|
+
label: "Field",
|
|
261
|
+
type: "String",
|
|
262
|
+
required: true,
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
name: "label",
|
|
266
|
+
label: "Label",
|
|
267
|
+
type: "String",
|
|
268
|
+
required: true,
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
name: "type",
|
|
272
|
+
label: "Type",
|
|
273
|
+
type: "String",
|
|
274
|
+
required: true,
|
|
275
|
+
attributes: { options: typeOptions() },
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
name: "primary_key",
|
|
279
|
+
label: "Primary key",
|
|
280
|
+
type: "Bool",
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
}),
|
|
284
|
+
],
|
|
285
|
+
});
|
|
286
|
+
if (!context.columns || !context.columns.length) {
|
|
287
|
+
if (!form.values) form.values = {};
|
|
288
|
+
form.values.columns = names.map((name) => ({
|
|
289
|
+
name,
|
|
290
|
+
label: Field.nameToLabel(name),
|
|
291
|
+
type: guessType(
|
|
292
|
+
name,
|
|
293
|
+
rows.find((r) => typeof r[name] !== "undefined")?.[name]
|
|
294
|
+
),
|
|
295
|
+
primary_key: name === "id",
|
|
296
|
+
}));
|
|
297
|
+
}
|
|
298
|
+
return form;
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
/** The columns of the table, with a primary key guaranteed */
|
|
305
|
+
const providerFields = (cfg) => {
|
|
306
|
+
const columns = (cfg?.columns || []).map((c) => ({ ...c }));
|
|
307
|
+
if (!columns.length) return columns;
|
|
308
|
+
if (!columns.some((c) => c.primary_key)) {
|
|
309
|
+
const idCol = columns.find((c) => c.name === "id") || columns[0];
|
|
310
|
+
idCol.primary_key = true;
|
|
311
|
+
}
|
|
312
|
+
return columns;
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const get_table = (pluginCfg) => (cfg) => {
|
|
316
|
+
const columns = providerFields(cfg);
|
|
317
|
+
return {
|
|
318
|
+
getRows: async (where = {}, opts = {}) => {
|
|
319
|
+
const rows = await getRowsCached(pluginCfg, cfg, where);
|
|
320
|
+
return applyWhere(
|
|
321
|
+
rows.map((r) => coerceRow(r, columns)),
|
|
322
|
+
where,
|
|
323
|
+
opts
|
|
324
|
+
);
|
|
325
|
+
},
|
|
326
|
+
countRows: async (where = {}) => {
|
|
327
|
+
const rows = await getRowsCached(pluginCfg, cfg, where);
|
|
328
|
+
return rows
|
|
329
|
+
.map((r) => coerceRow(r, columns))
|
|
330
|
+
.filter((r) => rowMatches(r, where)).length;
|
|
331
|
+
},
|
|
332
|
+
distinctValues: async (fieldNm) => {
|
|
333
|
+
const rows = await getRowsCached(pluginCfg, cfg, {});
|
|
334
|
+
const seen = new Set();
|
|
335
|
+
rows.map((r) => coerceRow(r, columns)).forEach((r) => {
|
|
336
|
+
const v = r[fieldNm];
|
|
337
|
+
if (v !== null && typeof v !== "undefined" && typeof v !== "object")
|
|
338
|
+
seen.add(v);
|
|
339
|
+
});
|
|
340
|
+
return [...seen].sort();
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
module.exports = (pluginCfg) => ({
|
|
346
|
+
"Meta ads": {
|
|
347
|
+
configuration_workflow: configuration_workflow(pluginCfg),
|
|
348
|
+
fields: (cfg) => providerFields(cfg),
|
|
349
|
+
get_table: get_table(pluginCfg),
|
|
350
|
+
},
|
|
351
|
+
});
|