@drawbridge/drawbridge-utils 0.0.118 → 0.0.121
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/dist/connections/index.cjs +1182 -75
- package/dist/connections/index.d.cts +1832 -95
- package/dist/connections/index.d.ts +1832 -95
- package/dist/connections/index.js +1175 -73
- package/dist/phone.d.cts +1 -1
- package/dist/phone.d.ts +1 -1
- package/dist/providers.cjs +1154 -95
- package/dist/providers.d.cts +86 -77
- package/dist/providers.d.ts +86 -77
- package/dist/providers.js +1150 -91
- package/dist/sendgrid.cjs +4 -6
- package/dist/sendgrid.d.cts +19 -14
- package/dist/sendgrid.d.ts +19 -14
- package/dist/sendgrid.js +4 -6
- package/dist/twilio.cjs +2 -3
- package/dist/twilio.d.cts +11 -3
- package/dist/twilio.d.ts +11 -3
- package/dist/twilio.js +2 -3
- package/package.json +1 -1
|
@@ -32,7 +32,10 @@ __export(connections_exports, {
|
|
|
32
32
|
AUTH_TYPES: () => AUTH_TYPES,
|
|
33
33
|
GROUPS: () => GROUPS,
|
|
34
34
|
HOOKS: () => HOOKS,
|
|
35
|
+
HOOK_EFFECTS: () => HOOK_EFFECTS,
|
|
35
36
|
HOOK_NAMES: () => HOOK_NAMES,
|
|
37
|
+
HOOK_OPTIONS: () => HOOK_OPTIONS,
|
|
38
|
+
HOOK_PROPS: () => HOOK_PROPS,
|
|
36
39
|
INPUTS: () => INPUTS,
|
|
37
40
|
OAUTH_FIELDS: () => OAUTH_FIELDS,
|
|
38
41
|
OUTCOMES: () => OUTCOMES,
|
|
@@ -40,6 +43,7 @@ __export(connections_exports, {
|
|
|
40
43
|
STATUSES: () => STATUSES,
|
|
41
44
|
STEPS: () => STEPS,
|
|
42
45
|
STEP_TYPES: () => STEP_TYPES,
|
|
46
|
+
WRITE_OPERATIONS: () => WRITE_OPERATIONS,
|
|
43
47
|
accessToken: () => accessToken,
|
|
44
48
|
authToken: () => authToken,
|
|
45
49
|
availableConnections: () => availableConnections,
|
|
@@ -49,6 +53,7 @@ __export(connections_exports, {
|
|
|
49
53
|
connectionSteps: () => connectionSteps,
|
|
50
54
|
connections: () => connections,
|
|
51
55
|
consentUrl: () => consentUrl,
|
|
56
|
+
effectsOf: () => effectsOf,
|
|
52
57
|
hookSupport: () => hookSupport,
|
|
53
58
|
isStale: () => isStale,
|
|
54
59
|
mergeSettings: () => mergeSettings,
|
|
@@ -245,6 +250,89 @@ var HOOKS = Object.freeze({
|
|
|
245
250
|
"promotions"
|
|
246
251
|
])
|
|
247
252
|
});
|
|
253
|
+
var HOOK_EFFECTS = Object.freeze(["enqueues", "events", "writes"]);
|
|
254
|
+
var WRITE_OPERATIONS = Object.freeze(["create", "update"]);
|
|
255
|
+
var HOOK_PROPS = Object.freeze([
|
|
256
|
+
"channel",
|
|
257
|
+
"clientId",
|
|
258
|
+
"clientSecret",
|
|
259
|
+
"connection",
|
|
260
|
+
"contact",
|
|
261
|
+
"context",
|
|
262
|
+
"cursor",
|
|
263
|
+
"declaration",
|
|
264
|
+
"doc",
|
|
265
|
+
"email",
|
|
266
|
+
"event",
|
|
267
|
+
"headers",
|
|
268
|
+
"id",
|
|
269
|
+
"lead",
|
|
270
|
+
"limit",
|
|
271
|
+
"manifest",
|
|
272
|
+
"payload",
|
|
273
|
+
"scope",
|
|
274
|
+
"search",
|
|
275
|
+
"secret",
|
|
276
|
+
"settings",
|
|
277
|
+
"sort",
|
|
278
|
+
"step",
|
|
279
|
+
"suppressed",
|
|
280
|
+
"token",
|
|
281
|
+
"tokens",
|
|
282
|
+
"workflow"
|
|
283
|
+
]);
|
|
284
|
+
var HOOK_OPTIONS = Object.freeze([
|
|
285
|
+
"adminToken",
|
|
286
|
+
"canSend",
|
|
287
|
+
"chunkSize",
|
|
288
|
+
"dispatch",
|
|
289
|
+
"fetcher",
|
|
290
|
+
"logger",
|
|
291
|
+
"mintId",
|
|
292
|
+
"read",
|
|
293
|
+
"reconcileScopes",
|
|
294
|
+
"request",
|
|
295
|
+
"resolveContact",
|
|
296
|
+
"resolveSettings",
|
|
297
|
+
"rotateToken",
|
|
298
|
+
"shopify"
|
|
299
|
+
]);
|
|
300
|
+
var effectsOf = (answer) => {
|
|
301
|
+
for (const key of HOOK_EFFECTS) {
|
|
302
|
+
if ((answer == null ? void 0 : answer[key]) !== void 0 && !Array.isArray(answer[key])) {
|
|
303
|
+
throw new Error("A hook returned " + key + " that is not an array");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const enqueues = (answer == null ? void 0 : answer.enqueues) || [];
|
|
307
|
+
const events = (answer == null ? void 0 : answer.events) || [];
|
|
308
|
+
const writes = (answer == null ? void 0 : answer.writes) || [];
|
|
309
|
+
for (const write of writes) {
|
|
310
|
+
if (!(write == null ? void 0 : write.collection)) throw new Error("A described write names no collection");
|
|
311
|
+
if (!WRITE_OPERATIONS.includes(write == null ? void 0 : write.operation)) {
|
|
312
|
+
throw new Error("A described write on " + write.collection + " needs an operation \u2014 one of " + WRITE_OPERATIONS.join(", "));
|
|
313
|
+
}
|
|
314
|
+
if (!(write == null ? void 0 : write.data)) throw new Error("A described write on " + write.collection + " carries no data");
|
|
315
|
+
if (write.operation === "update" && !write.query) {
|
|
316
|
+
throw new Error("A described update on " + write.collection + " has no query \u2014 that is every document in it");
|
|
317
|
+
}
|
|
318
|
+
if (write.operation === "create" && write.query) {
|
|
319
|
+
throw new Error("A described create on " + write.collection + " carries a query \u2014 create does not filter");
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
for (const enqueue of enqueues) {
|
|
323
|
+
if (!(enqueue == null ? void 0 : enqueue.queue)) throw new Error("A described enqueue names no queue");
|
|
324
|
+
if (!(enqueue == null ? void 0 : enqueue.name)) throw new Error("A described enqueue on the " + enqueue.queue + " queue names no job");
|
|
325
|
+
}
|
|
326
|
+
for (const event of events) {
|
|
327
|
+
if (!(event == null ? void 0 : event.event)) throw new Error("A described event has no name");
|
|
328
|
+
if (!(event == null ? void 0 : event.room)) throw new Error("A described " + event.event + " event has no room");
|
|
329
|
+
}
|
|
330
|
+
const transaction = Boolean(answer == null ? void 0 : answer.transaction);
|
|
331
|
+
if (transaction && !writes.length) {
|
|
332
|
+
throw new Error("A hook asked for a transaction and described no writes");
|
|
333
|
+
}
|
|
334
|
+
return { enqueues, events, transaction, writes };
|
|
335
|
+
};
|
|
248
336
|
var STEPS = Object.freeze({
|
|
249
337
|
"commerce.code.issue": "Issue discount code",
|
|
250
338
|
"commerce.customer.insert": "Create customer",
|
|
@@ -454,13 +542,16 @@ var attentive_default = `<svg width="500" height="500" viewBox="0 0 500 500" fil
|
|
|
454
542
|
<path d="M166.04 261.805C180.228 259.107 195.528 261.893 207.581 269.971C218.512 277.079 226.908 288.136 230.604 300.657C234.835 314.103 233.614 329.124 227.485 341.788C220.097 356.875 205.782 368.543 189.317 372.089C173.957 375.652 157.089 372.386 144.304 363.103C132.971 355.295 124.912 342.989 121.891 329.581C118.943 316.636 120.725 302.656 127.002 290.938C134.699 275.951 149.503 264.933 166.046 261.811" fill="#1E1C1C"/>
|
|
455
543
|
</svg>`;
|
|
456
544
|
|
|
457
|
-
// lib/connections/attentive.js
|
|
545
|
+
// lib/connections/providers/attentive.js
|
|
458
546
|
var attentive_default2 = {
|
|
459
547
|
auth: {
|
|
460
548
|
oauth: {
|
|
461
|
-
// NAMES of the
|
|
462
|
-
//
|
|
463
|
-
//
|
|
549
|
+
// NAMES of the credentials holding OUR app's client — keys into the map
|
|
550
|
+
// the provider collection answers, entered on the admin screen at
|
|
551
|
+
// registration, never before. (The names are the env vars they once
|
|
552
|
+
// were; the vocabulary stayed when the storage moved.) No `headers` on
|
|
553
|
+
// the client: Attentive takes credentials as form fields, which is the
|
|
554
|
+
// runner's default.
|
|
464
555
|
client: {
|
|
465
556
|
id: "ATTENTIVE_OAUTH_CLIENT_ID",
|
|
466
557
|
secret: "ATTENTIVE_OAUTH_CLIENT_SECRET"
|
|
@@ -565,7 +656,7 @@ var attentive_default2 = {
|
|
|
565
656
|
// show a picker quietly missing most of a real account. The response's
|
|
566
657
|
// only identifier is `externalId`, so an entry without one cannot be
|
|
567
658
|
// stored and is dropped.
|
|
568
|
-
audiences: async ({ cursor,
|
|
659
|
+
audiences: async ({ cursor, limit = 100, search, token }, { fetcher = fetch } = {}) => {
|
|
569
660
|
const query = new URLSearchParams({
|
|
570
661
|
limit: String(Math.min(limit, 1e3)),
|
|
571
662
|
...cursor && { cursor },
|
|
@@ -681,10 +772,11 @@ var request = async ({
|
|
|
681
772
|
// lib/hubspot.js
|
|
682
773
|
var HUBSPOT_BASE = "https://api.hubapi.com";
|
|
683
774
|
var hubspotRequest = ({ body, fetcher, method, path, query, token }) => {
|
|
775
|
+
if (!token) throw new Error("HubSpot access token missing \u2014 pass token (the drawbridge provider's hubspotToken)");
|
|
684
776
|
return (fetcher || request)({
|
|
685
777
|
body,
|
|
686
778
|
headers: {
|
|
687
|
-
"Authorization": "Bearer " +
|
|
779
|
+
"Authorization": "Bearer " + token
|
|
688
780
|
},
|
|
689
781
|
method,
|
|
690
782
|
query,
|
|
@@ -786,16 +878,15 @@ var contacts = {
|
|
|
786
878
|
// FORGET A CONTACT, by id or by email. Account deletion — the caller had
|
|
787
879
|
// to search then remove, which is one round trip it should not have to
|
|
788
880
|
// know about.
|
|
789
|
-
remove: async ({ email,
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
const contact = id || await lookup({ email, fetcher, token: key });
|
|
881
|
+
remove: async ({ email, id, token }, { fetcher } = {}) => {
|
|
882
|
+
if (!token) return;
|
|
883
|
+
const contact = id || await lookup({ email, fetcher, token });
|
|
793
884
|
if (!contact) return;
|
|
794
885
|
return hubspotRequest({
|
|
795
886
|
fetcher,
|
|
796
887
|
method: "DELETE",
|
|
797
888
|
path: "/crm/v3/objects/contacts/" + contact,
|
|
798
|
-
token
|
|
889
|
+
token
|
|
799
890
|
});
|
|
800
891
|
},
|
|
801
892
|
// Connect an account to its contact by email, creating it if absent, and
|
|
@@ -804,24 +895,23 @@ var contacts = {
|
|
|
804
895
|
// no delete-old-then-create-new.
|
|
805
896
|
//
|
|
806
897
|
// Prefer the cached hubspotId; fall back to a search; create last.
|
|
807
|
-
sync: async ({ doc,
|
|
898
|
+
sync: async ({ doc, token }, { fetcher } = {}) => {
|
|
808
899
|
var _a, _b;
|
|
809
|
-
|
|
810
|
-
if (!key) return;
|
|
900
|
+
if (!token) return;
|
|
811
901
|
if (doc == null ? void 0 : doc.hubspotId) {
|
|
812
902
|
try {
|
|
813
|
-
return (_a = await send({ doc, fetcher, method: "PATCH", path: "/crm/v3/objects/contacts/" + doc.hubspotId, token
|
|
903
|
+
return (_a = await send({ doc, fetcher, method: "PATCH", path: "/crm/v3/objects/contacts/" + doc.hubspotId, token })) == null ? void 0 : _a.id;
|
|
814
904
|
} catch (error) {
|
|
815
905
|
if ((error == null ? void 0 : error.status) !== 404) throw error;
|
|
816
906
|
}
|
|
817
907
|
}
|
|
818
|
-
const existing = await lookup({ email: doc == null ? void 0 : doc.email, fetcher, token
|
|
908
|
+
const existing = await lookup({ email: doc == null ? void 0 : doc.email, fetcher, token });
|
|
819
909
|
return (_b = await send({
|
|
820
910
|
doc,
|
|
821
911
|
fetcher,
|
|
822
912
|
method: existing ? "PATCH" : "POST",
|
|
823
913
|
path: existing ? "/crm/v3/objects/contacts/" + existing : "/crm/v3/objects/contacts",
|
|
824
|
-
token
|
|
914
|
+
token
|
|
825
915
|
})) == null ? void 0 : _b.id;
|
|
826
916
|
}
|
|
827
917
|
};
|
|
@@ -1277,6 +1367,36 @@ var plans = {
|
|
|
1277
1367
|
conversion: 0.5
|
|
1278
1368
|
}
|
|
1279
1369
|
};
|
|
1370
|
+
var resolvePlan = (subscription) => {
|
|
1371
|
+
var _a, _b;
|
|
1372
|
+
const custom = subscription == null ? void 0 : subscription.custom;
|
|
1373
|
+
if (!custom) return plans[subscription == null ? void 0 : subscription.plan] ?? free;
|
|
1374
|
+
return {
|
|
1375
|
+
// Reusing all.features / all.limits is what keeps a custom plan the same
|
|
1376
|
+
// SHAPE as a catalog one: the base feature grants every plan carries, the
|
|
1377
|
+
// campaign limits that are always infinite, and members defaulting to
|
|
1378
|
+
// infinite when a deal does not name it.
|
|
1379
|
+
conversion: custom.conversion ?? free.conversion,
|
|
1380
|
+
custom: true,
|
|
1381
|
+
// A custom plan is a negotiated PAID deal, so it carries the paid-tier
|
|
1382
|
+
// baseline whether or not the deal thought to name it. Today that is the
|
|
1383
|
+
// sending domain: every catalog paid tier grants it, and a custom plan
|
|
1384
|
+
// silently lacking it would be a support ticket, not a pricing decision.
|
|
1385
|
+
features: all.features([organization.networking.key, ...((_a = custom.features) == null ? void 0 : _a.granted) || []]),
|
|
1386
|
+
limits: all.limits(((_b = custom.limits) == null ? void 0 : _b.organization) || {}),
|
|
1387
|
+
// A custom plan stores its overage BARE on `custom.overages` — a different
|
|
1388
|
+
// shape from the catalog's nested one. Number() so a deal stored as a string
|
|
1389
|
+
// still resolves to cents-per-action; an unnamed overage stays undefined
|
|
1390
|
+
// (it bills nothing) rather than becoming NaN.
|
|
1391
|
+
actionCents: custom.overages == null ? void 0 : Number(custom.overages),
|
|
1392
|
+
overages: { actions: custom.overages },
|
|
1393
|
+
title: custom.title || "Custom"
|
|
1394
|
+
};
|
|
1395
|
+
};
|
|
1396
|
+
var conversionRate = (subscription) => {
|
|
1397
|
+
var _a;
|
|
1398
|
+
return ((_a = resolvePlan(subscription)) == null ? void 0 : _a.conversion) ?? free.conversion;
|
|
1399
|
+
};
|
|
1280
1400
|
|
|
1281
1401
|
// lib/transactions.js
|
|
1282
1402
|
var import_drawbridge_telemetry = require("@drawbridge/drawbridge-telemetry");
|
|
@@ -1431,7 +1551,36 @@ var channels = {
|
|
|
1431
1551
|
}
|
|
1432
1552
|
};
|
|
1433
1553
|
|
|
1434
|
-
// lib/connections/drawbridge.js
|
|
1554
|
+
// lib/connections/providers/drawbridge.js
|
|
1555
|
+
var interpolate = (template, data2) => {
|
|
1556
|
+
if (!template) return template;
|
|
1557
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => (data2 == null ? void 0 : data2[key]) != null ? String(data2[key]) : "{{" + key + "}}");
|
|
1558
|
+
};
|
|
1559
|
+
var teamRecipients = async ({ memberIds = [], organization: organization2, read }) => {
|
|
1560
|
+
const org = await read.get({ collection: "organization", query: { id: organization2 } });
|
|
1561
|
+
const owner = (org == null ? void 0 : org.owner) ? await read.get({ collection: "user", query: { id: org.owner } }) : null;
|
|
1562
|
+
const members = memberIds.length ? await read.aggregate({
|
|
1563
|
+
collection: "member",
|
|
1564
|
+
pipeline: [
|
|
1565
|
+
{
|
|
1566
|
+
$match: {
|
|
1567
|
+
id: { $in: memberIds },
|
|
1568
|
+
organization: organization2,
|
|
1569
|
+
status: "accepted"
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
]
|
|
1573
|
+
}) : [];
|
|
1574
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1575
|
+
return [owner, ...members].filter((member) => {
|
|
1576
|
+
if (!(member == null ? void 0 : member.id) || !(member == null ? void 0 : member.email)) return false;
|
|
1577
|
+
const address = member.email.toLowerCase();
|
|
1578
|
+
if (seen.has(address)) return false;
|
|
1579
|
+
seen.add(address);
|
|
1580
|
+
return true;
|
|
1581
|
+
});
|
|
1582
|
+
};
|
|
1583
|
+
var queueNotification = (data2) => ({ collection: "notification", data: data2, operation: "create" });
|
|
1435
1584
|
var drawbridge_default2 = {
|
|
1436
1585
|
auth: {
|
|
1437
1586
|
type: "none"
|
|
@@ -1449,10 +1598,15 @@ var drawbridge_default2 = {
|
|
|
1449
1598
|
exclusive: false,
|
|
1450
1599
|
fields: [],
|
|
1451
1600
|
group: "developer",
|
|
1452
|
-
//
|
|
1453
|
-
//
|
|
1454
|
-
//
|
|
1455
|
-
//
|
|
1601
|
+
// THE BODIES LIVE HERE, beside the declarations that name them. They used to
|
|
1602
|
+
// live in drawbridge-sync because they touch the database, the queues and the
|
|
1603
|
+
// sockets — and a published package cannot carry a controller.
|
|
1604
|
+
//
|
|
1605
|
+
// It does not have to. A hook is a function, so everything it needs is PASSED
|
|
1606
|
+
// IN: `read` for the reads, `canSend` for the opt-out floor, `resolveContact`
|
|
1607
|
+
// for the one write whose RESULT the hook has to count. Everything else a hook
|
|
1608
|
+
// wants done it DESCRIBES — `writes`, `enqueues`, `events` — and the shell
|
|
1609
|
+
// performs it. See lib/connections/contract.js for that shape.
|
|
1456
1610
|
hooks: {
|
|
1457
1611
|
auth: {
|
|
1458
1612
|
// Nothing to connect, revoke, probe or re-scope.
|
|
@@ -1473,13 +1627,157 @@ var drawbridge_default2 = {
|
|
|
1473
1627
|
// accounts DRAWBRIDGE holds rather than ones a merchant connects.
|
|
1474
1628
|
contacts,
|
|
1475
1629
|
email: {
|
|
1476
|
-
|
|
1477
|
-
//
|
|
1478
|
-
//
|
|
1479
|
-
//
|
|
1480
|
-
|
|
1481
|
-
//
|
|
1482
|
-
|
|
1630
|
+
// A PERIODIC SUMMARY to the team, on a schedule trigger rather than per
|
|
1631
|
+
// lead.
|
|
1632
|
+
//
|
|
1633
|
+
// The count is the point: `email.notify` tells the owner one lead arrived
|
|
1634
|
+
// and dampens a spike to one message per bucket, which is deliberately not
|
|
1635
|
+
// a count. This is where "you got 43 entries this week" comes from.
|
|
1636
|
+
digest: async ({ context, step, workflow }, { read } = {}) => {
|
|
1637
|
+
var _a, _b, _c, _d;
|
|
1638
|
+
const days = { day: 1, month: 30, week: 7 }[(_a = workflow == null ? void 0 : workflow.trigger) == null ? void 0 : _a.event] || 7;
|
|
1639
|
+
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1e3);
|
|
1640
|
+
const campaign = ((_c = (_b = workflow == null ? void 0 : workflow.trigger) == null ? void 0 : _b.filters) == null ? void 0 : _c.campaign) || null;
|
|
1641
|
+
const [counted] = await read.aggregate({
|
|
1642
|
+
collection: "lead",
|
|
1643
|
+
pipeline: [
|
|
1644
|
+
{
|
|
1645
|
+
$match: {
|
|
1646
|
+
createdAt: { $gte: since },
|
|
1647
|
+
organization: workflow.organization,
|
|
1648
|
+
...campaign && { campaigns: { $in: [campaign] } }
|
|
1649
|
+
}
|
|
1650
|
+
},
|
|
1651
|
+
{ $count: "count" }
|
|
1652
|
+
]
|
|
1653
|
+
});
|
|
1654
|
+
const count = Number((counted == null ? void 0 : counted.count) || 0);
|
|
1655
|
+
const request2 = { campaign, count, days };
|
|
1656
|
+
if (!count) return { message: "No new leads in the period \u2014 digest skipped.", request: request2, response: { skipped: true }, skipped: true };
|
|
1657
|
+
const recipients = await teamRecipients({
|
|
1658
|
+
memberIds: ((_d = step.settings) == null ? void 0 : _d.members) || [],
|
|
1659
|
+
organization: workflow.organization,
|
|
1660
|
+
read
|
|
1661
|
+
});
|
|
1662
|
+
const values = { ...context, count };
|
|
1663
|
+
return {
|
|
1664
|
+
message: "Digest of " + count + " new lead(s) queued for " + recipients.length + " recipient(s).",
|
|
1665
|
+
request: request2,
|
|
1666
|
+
response: { count, notified: recipients.length },
|
|
1667
|
+
writes: recipients.map((member) => {
|
|
1668
|
+
var _a2, _b2;
|
|
1669
|
+
return queueNotification({
|
|
1670
|
+
audience: "member",
|
|
1671
|
+
message: interpolate((_a2 = step.settings) == null ? void 0 : _a2.message, values),
|
|
1672
|
+
organization: workflow.organization,
|
|
1673
|
+
send: { type: "email", email: member.email },
|
|
1674
|
+
title: interpolate((_b2 = step.settings) == null ? void 0 : _b2.subject, values),
|
|
1675
|
+
workflow: workflow.id
|
|
1676
|
+
});
|
|
1677
|
+
})
|
|
1678
|
+
};
|
|
1679
|
+
},
|
|
1680
|
+
// To the organization's OWN PEOPLE. Never suppressed, never
|
|
1681
|
+
// subscription-gated, no unsubscribe footer — telling an org's staff about
|
|
1682
|
+
// their own leads is not commercial mail to a stranger.
|
|
1683
|
+
//
|
|
1684
|
+
// FREE, permanently. The lead that triggered this run already consumed the
|
|
1685
|
+
// billable action, and `members` is a list — billing here would turn one
|
|
1686
|
+
// lead into five more charges and the org would be paying to read its own
|
|
1687
|
+
// mail. The declaration prices it at zero; the shell bills nothing for
|
|
1688
|
+
// zero.
|
|
1689
|
+
notify: async ({ context, step, workflow }, { read } = {}) => {
|
|
1690
|
+
var _a;
|
|
1691
|
+
const memberIds = ((_a = step.settings) == null ? void 0 : _a.members) || [];
|
|
1692
|
+
const request2 = { members: memberIds.length };
|
|
1693
|
+
const recipients = await teamRecipients({ memberIds, organization: workflow.organization, read });
|
|
1694
|
+
if (!recipients.length) {
|
|
1695
|
+
return {
|
|
1696
|
+
message: "No owner or accepted member with an email address \u2014 team notification skipped.",
|
|
1697
|
+
request: request2,
|
|
1698
|
+
response: { skipped: true },
|
|
1699
|
+
skipped: true
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
const bucket = Math.floor(Date.now() / (15 * 60 * 1e3));
|
|
1703
|
+
return {
|
|
1704
|
+
message: "Team notification queued for " + recipients.length + " recipient(s).",
|
|
1705
|
+
request: request2,
|
|
1706
|
+
response: { notified: recipients.length },
|
|
1707
|
+
writes: recipients.map((member) => {
|
|
1708
|
+
var _a2, _b;
|
|
1709
|
+
return {
|
|
1710
|
+
...queueNotification({
|
|
1711
|
+
audience: "member",
|
|
1712
|
+
// Per workflow, recipient AND bucket, so one recipient's damper
|
|
1713
|
+
// can never swallow another's mail and a later bucket is never
|
|
1714
|
+
// mistaken for a duplicate of an earlier one.
|
|
1715
|
+
key: "team.notify." + workflow.id + "." + member.id + "." + bucket,
|
|
1716
|
+
message: interpolate((_a2 = step.settings) == null ? void 0 : _a2.message, context),
|
|
1717
|
+
organization: workflow.organization,
|
|
1718
|
+
send: { type: "email", email: member.email },
|
|
1719
|
+
title: interpolate((_b = step.settings) == null ? void 0 : _b.subject, context),
|
|
1720
|
+
workflow: workflow.id
|
|
1721
|
+
}),
|
|
1722
|
+
// E11000 IS THE DAMPER WORKING: this recipient has already been
|
|
1723
|
+
// told within the bucket. Declared per write rather than assumed by
|
|
1724
|
+
// the shell, because on every other write here a duplicate key is a
|
|
1725
|
+
// real failure.
|
|
1726
|
+
ignoreDuplicate: true
|
|
1727
|
+
};
|
|
1728
|
+
})
|
|
1729
|
+
};
|
|
1730
|
+
},
|
|
1731
|
+
// Drawbridge sends lead-facing email itself — no merchant provider gates
|
|
1732
|
+
// it.
|
|
1733
|
+
//
|
|
1734
|
+
// This QUEUES rather than sends: queue/notification.js owns delivery, the
|
|
1735
|
+
// unsubscribe token and the CAN-SPAM footer. The step's job is to say who
|
|
1736
|
+
// and what, correctly, and to refuse early when it must not send at all.
|
|
1737
|
+
send: async ({ context, step, workflow }, { canSend, read } = {}) => {
|
|
1738
|
+
var _a, _b;
|
|
1739
|
+
const to = context == null ? void 0 : context.email;
|
|
1740
|
+
if (!to) throw new Error("No email address on context (context.email is required)");
|
|
1741
|
+
const request2 = { to };
|
|
1742
|
+
const { ok: sendable } = await canSend({ channel: "email", to });
|
|
1743
|
+
if (!sendable) return { message: "Recipient has opted out \u2014 skipped.", request: request2, response: { skipped: true }, skipped: true };
|
|
1744
|
+
const organization2 = await read.get({ collection: "organization", query: { id: workflow.organization } });
|
|
1745
|
+
const subscription = (organization2 == null ? void 0 : organization2.subscription) ? await read.get({ collection: "subscription", query: { id: organization2.subscription } }) : null;
|
|
1746
|
+
if ((subscription == null ? void 0 : subscription.status) !== "active") {
|
|
1747
|
+
return {
|
|
1748
|
+
message: "Organization has no active subscription \u2014 workflow-step email skipped.",
|
|
1749
|
+
request: request2,
|
|
1750
|
+
response: { skipped: true },
|
|
1751
|
+
skipped: true
|
|
1752
|
+
};
|
|
1753
|
+
}
|
|
1754
|
+
return {
|
|
1755
|
+
message: "Email queued for delivery to " + to + ".",
|
|
1756
|
+
request: request2,
|
|
1757
|
+
response: { queued: true },
|
|
1758
|
+
// NO `connection` FIELD, deliberately: the platform sends this.
|
|
1759
|
+
// `audience : 'lead'` states what the queue would otherwise infer from
|
|
1760
|
+
// shape.
|
|
1761
|
+
//
|
|
1762
|
+
// `campaign` is not decoration. queue/notification.js mints the
|
|
1763
|
+
// unsubscribe token with it, so it decides whether opting out is
|
|
1764
|
+
// scoped to this campaign or the whole organization, and it names the
|
|
1765
|
+
// campaign in the footer. Sending without it silently broadens every
|
|
1766
|
+
// opt-out to the entire organization.
|
|
1767
|
+
writes: [
|
|
1768
|
+
queueNotification({
|
|
1769
|
+
audience: "lead",
|
|
1770
|
+
campaign: (context == null ? void 0 : context.campaign) || null,
|
|
1771
|
+
lead: (context == null ? void 0 : context.lead) || null,
|
|
1772
|
+
message: interpolate((_a = step.settings) == null ? void 0 : _a.message, context),
|
|
1773
|
+
organization: workflow.organization,
|
|
1774
|
+
send: { type: "email", email: to },
|
|
1775
|
+
title: interpolate((_b = step.settings) == null ? void 0 : _b.subject, context),
|
|
1776
|
+
workflow: workflow.id
|
|
1777
|
+
})
|
|
1778
|
+
]
|
|
1779
|
+
};
|
|
1780
|
+
}
|
|
1483
1781
|
},
|
|
1484
1782
|
inbound: false,
|
|
1485
1783
|
lifecycle: false,
|
|
@@ -1489,8 +1787,174 @@ var drawbridge_default2 = {
|
|
|
1489
1787
|
products: false,
|
|
1490
1788
|
promotions: false
|
|
1491
1789
|
},
|
|
1492
|
-
segment: {
|
|
1493
|
-
|
|
1790
|
+
segment: {
|
|
1791
|
+
// RECALCULATE SEGMENT MEMBERSHIP. The one FAN-OUT hook: it evaluates every
|
|
1792
|
+
// contact in an organization against every segment, which is too much for
|
|
1793
|
+
// one job, so it returns chunks and the shell defers completion.
|
|
1794
|
+
//
|
|
1795
|
+
// Returning `chunks` is the only thing that makes it different. The
|
|
1796
|
+
// declaration, the guards, the step document and the price are the shell's,
|
|
1797
|
+
// exactly as they are for a step that finishes in one go.
|
|
1798
|
+
sync: async ({ context, step }, { chunkSize, logger: logger2, read, resolveContact } = {}) => {
|
|
1799
|
+
var _a, _b, _c;
|
|
1800
|
+
if (!chunkSize) throw new Error("segment.sync needs chunkSize from the shell");
|
|
1801
|
+
const organization2 = context == null ? void 0 : context.organization;
|
|
1802
|
+
const configured = (_a = step == null ? void 0 : step.settings) == null ? void 0 : _a.segment;
|
|
1803
|
+
const request2 = { organization: organization2 || null, segmentId: configured || null };
|
|
1804
|
+
const release = (ids, status = "active") => {
|
|
1805
|
+
const released = (ids || []).filter(Boolean);
|
|
1806
|
+
return {
|
|
1807
|
+
events: organization2 ? released.map((id) => ({
|
|
1808
|
+
event: "organization.segments",
|
|
1809
|
+
payload: { id, status },
|
|
1810
|
+
room: "organization." + organization2
|
|
1811
|
+
})) : [],
|
|
1812
|
+
writes: released.map((id) => ({
|
|
1813
|
+
collection: "segment",
|
|
1814
|
+
data: { $set: { status } },
|
|
1815
|
+
operation: "update",
|
|
1816
|
+
query: { id }
|
|
1817
|
+
}))
|
|
1818
|
+
};
|
|
1819
|
+
};
|
|
1820
|
+
if (!organization2) {
|
|
1821
|
+
return {
|
|
1822
|
+
...release([configured]),
|
|
1823
|
+
message: "Trigger data missing organization id \u2014 cannot sync segments.",
|
|
1824
|
+
request: request2,
|
|
1825
|
+
response: { skipped: true },
|
|
1826
|
+
skipped: true
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
const segments = await read.aggregate({
|
|
1830
|
+
collection: "segment",
|
|
1831
|
+
pipeline: [{ $match: configured ? { id: configured, organization: organization2 } : { organization: organization2 } }]
|
|
1832
|
+
});
|
|
1833
|
+
if (!segments.length) {
|
|
1834
|
+
return {
|
|
1835
|
+
...release([configured]),
|
|
1836
|
+
message: "No segments matched the request \u2014 nothing to sync.",
|
|
1837
|
+
request: request2,
|
|
1838
|
+
response: { skipped: true },
|
|
1839
|
+
skipped: true
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1842
|
+
const segmentIds = segments.map((entry) => entry.id);
|
|
1843
|
+
try {
|
|
1844
|
+
let backfilled = 0;
|
|
1845
|
+
if (segments.some((entry) => entry.system)) {
|
|
1846
|
+
const contacted = await read.aggregate({
|
|
1847
|
+
collection: "contact",
|
|
1848
|
+
pipeline: [
|
|
1849
|
+
{ $match: { organization: organization2 } },
|
|
1850
|
+
{ $project: { _id: 0, leads: 1 } },
|
|
1851
|
+
{ $unwind: "$leads" },
|
|
1852
|
+
{ $group: { _id: null, ids: { $addToSet: "$leads" } } }
|
|
1853
|
+
]
|
|
1854
|
+
});
|
|
1855
|
+
const uncontacted = await read.aggregate({
|
|
1856
|
+
collection: "lead",
|
|
1857
|
+
pipeline: [
|
|
1858
|
+
{ $match: { id: { $nin: ((_b = contacted[0]) == null ? void 0 : _b.ids) || [] }, organization: organization2 } },
|
|
1859
|
+
{ $project: { _id: 0, id: 1 } }
|
|
1860
|
+
]
|
|
1861
|
+
});
|
|
1862
|
+
for (const lead of uncontacted) {
|
|
1863
|
+
try {
|
|
1864
|
+
await resolveContact({ leadId: lead.id });
|
|
1865
|
+
backfilled += 1;
|
|
1866
|
+
} catch (error) {
|
|
1867
|
+
if (error.code !== 11e3) throw error;
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
(_c = logger2 == null ? void 0 : logger2.info) == null ? void 0 : _c.call(logger2, "segment.sync.backfill", { backfilled, organization: organization2, uncontacted: uncontacted.length });
|
|
1871
|
+
}
|
|
1872
|
+
const contacts2 = await read.aggregate({
|
|
1873
|
+
collection: "contact",
|
|
1874
|
+
pipeline: [
|
|
1875
|
+
{ $match: { organization: organization2 } },
|
|
1876
|
+
{ $project: { _id: 0, id: 1 } },
|
|
1877
|
+
{ $sort: { id: 1 } }
|
|
1878
|
+
]
|
|
1879
|
+
});
|
|
1880
|
+
if (!contacts2.length) {
|
|
1881
|
+
return {
|
|
1882
|
+
...release(segmentIds),
|
|
1883
|
+
message: "Organization has no contacts to evaluate against segments.",
|
|
1884
|
+
request: request2,
|
|
1885
|
+
response: { skipped: true },
|
|
1886
|
+
skipped: true
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
const contactIds = contacts2.map((contact) => contact.id);
|
|
1890
|
+
const org = await read.get({ collection: "organization", query: { id: organization2 } });
|
|
1891
|
+
const chunks = [];
|
|
1892
|
+
for (let index = 0; index < contactIds.length; index += chunkSize) {
|
|
1893
|
+
chunks.push({
|
|
1894
|
+
contactIds: contactIds.slice(index, index + chunkSize),
|
|
1895
|
+
organization: organization2,
|
|
1896
|
+
segments: segmentIds,
|
|
1897
|
+
// A BACKFILL IS NOT BILLABLE. It creates the contacts this run
|
|
1898
|
+
// then evaluates, so charging for it would bill an organization
|
|
1899
|
+
// for work its own history made necessary.
|
|
1900
|
+
usage: (context == null ? void 0 : context.billable) === true && backfilled === 0 ? (org == null ? void 0 : org.usage) || null : null
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
1903
|
+
return {
|
|
1904
|
+
chunks,
|
|
1905
|
+
...configured && { extra: { segment: configured } },
|
|
1906
|
+
message: "Queued " + contactIds.length + " contacts across " + chunks.length + " chunks for segment evaluation.",
|
|
1907
|
+
queue: "segment",
|
|
1908
|
+
request: { ...request2, segments: segmentIds },
|
|
1909
|
+
response: { chunks: chunks.length, contacts: contactIds.length, segments: segments.length }
|
|
1910
|
+
};
|
|
1911
|
+
} catch (error) {
|
|
1912
|
+
throw Object.assign(error, release(segmentIds, "error"));
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
},
|
|
1916
|
+
sms: {
|
|
1917
|
+
// SMS TO A LEAD, through the merchant's own Twilio connection.
|
|
1918
|
+
//
|
|
1919
|
+
// WITHDRAWN from the builder — twilio went, and a connection-gated step
|
|
1920
|
+
// with no connection to gate on could only ever render permanently
|
|
1921
|
+
// disabled. Stored workflows still carry it, so it still runs.
|
|
1922
|
+
//
|
|
1923
|
+
// It looks its own connection up rather than relying on the shell, because
|
|
1924
|
+
// the step is declared by the PRIVATE drawbridge connection (which has
|
|
1925
|
+
// none) while the credential belongs to twilio (which has no manifest).
|
|
1926
|
+
// Platform SMS will remove that split the way it did for email.
|
|
1927
|
+
send: async ({ context, step, workflow }, { canSend, read } = {}) => {
|
|
1928
|
+
var _a, _b, _c;
|
|
1929
|
+
const to = (_a = context == null ? void 0 : context.phone) == null ? void 0 : _a.number;
|
|
1930
|
+
if (!to) throw new Error("No phone number on context (context.phone.number is required)");
|
|
1931
|
+
const request2 = { to };
|
|
1932
|
+
const connection2 = await read.get({
|
|
1933
|
+
collection: "connection",
|
|
1934
|
+
query: { organization: workflow.organization, slug: "twilio", status: "active" }
|
|
1935
|
+
});
|
|
1936
|
+
if (!connection2) return { message: "No active Twilio SMS connection \u2014 workflow-step SMS skipped.", request: request2, response: { skipped: true }, skipped: true };
|
|
1937
|
+
const { ok: sendable } = await canSend({ channel: "sms", to: context.phone });
|
|
1938
|
+
if (!sendable) return { message: "Recipient has opted out \u2014 skipped.", request: request2, response: { skipped: true }, skipped: true };
|
|
1939
|
+
return {
|
|
1940
|
+
message: "SMS queued for delivery to " + to + " via twilio.",
|
|
1941
|
+
request: request2,
|
|
1942
|
+
response: { provider: "twilio", queued: true },
|
|
1943
|
+
// QUEUES rather than sends: queue/notification.js owns delivery, the
|
|
1944
|
+
// carrier opt-out line and the segment count this is billed on.
|
|
1945
|
+
writes: [
|
|
1946
|
+
queueNotification({
|
|
1947
|
+
connection: connection2.id,
|
|
1948
|
+
message: interpolate((_b = step.settings) == null ? void 0 : _b.message, context),
|
|
1949
|
+
organization: workflow.organization,
|
|
1950
|
+
send: { phone: { number: to }, type: "phone" },
|
|
1951
|
+
title: interpolate((_c = step.settings) == null ? void 0 : _c.subject, context),
|
|
1952
|
+
workflow: workflow.id
|
|
1953
|
+
})
|
|
1954
|
+
]
|
|
1955
|
+
};
|
|
1956
|
+
}
|
|
1957
|
+
},
|
|
1494
1958
|
webhook: false
|
|
1495
1959
|
},
|
|
1496
1960
|
icon: drawbridge_default,
|
|
@@ -1532,11 +1996,11 @@ var drawbridge_default2 = {
|
|
|
1532
1996
|
key: "Email \u2014 Digest",
|
|
1533
1997
|
queue: "notification",
|
|
1534
1998
|
settings: {
|
|
1535
|
-
// The organization OWNER is always a recipient, resolved
|
|
1536
|
-
// so this is additional recipients rather than the list. It
|
|
1537
|
-
// be required: the members endpoint is owner-gated and the
|
|
1538
|
-
// not a member document, so a solo merchant has nothing to
|
|
1539
|
-
// could never save the step.
|
|
1999
|
+
// The organization OWNER is always a recipient, resolved by the
|
|
2000
|
+
// hook, so this is additional recipients rather than the list. It
|
|
2001
|
+
// cannot be required: the members endpoint is owner-gated and the
|
|
2002
|
+
// owner is not a member document, so a solo merchant has nothing to
|
|
2003
|
+
// pick and could never save the step.
|
|
1540
2004
|
members: { of: "string", type: "array" },
|
|
1541
2005
|
message: { required: true, type: "string" },
|
|
1542
2006
|
subject: { required: true, type: "string" }
|
|
@@ -1636,7 +2100,7 @@ var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
|
|
|
1636
2100
|
<path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
|
|
1637
2101
|
</svg>`;
|
|
1638
2102
|
|
|
1639
|
-
// lib/connections/klaviyo.js
|
|
2103
|
+
// lib/connections/providers/klaviyo.js
|
|
1640
2104
|
var api = async (path, { fetcher = fetch, method = "GET", payload, token }) => {
|
|
1641
2105
|
const response = await fetcher("https://a.klaviyo.com/api" + path, {
|
|
1642
2106
|
...payload && { body: JSON.stringify(payload) },
|
|
@@ -1673,7 +2137,8 @@ var klaviyo_default2 = {
|
|
|
1673
2137
|
// differently, and it says so in hooks.auth.token rather than as a flag here.
|
|
1674
2138
|
auth: {
|
|
1675
2139
|
oauth: {
|
|
1676
|
-
// NAMES the
|
|
2140
|
+
// NAMES the credentials holding OUR application's client — keys into
|
|
2141
|
+
// the stored provider credentials, not env vars. One identity,
|
|
1677
2142
|
// every merchant — the token is the merchant's and arrives from their
|
|
1678
2143
|
// own consent, which is what stops one organization reading another's
|
|
1679
2144
|
// data.
|
|
@@ -1813,7 +2278,7 @@ var klaviyo_default2 = {
|
|
|
1813
2278
|
// renders an empty "Klaviyo account" field, because the merchant is
|
|
1814
2279
|
// never asked which account they connected — the consent already
|
|
1815
2280
|
// decided it, and asking again would be a question we can answer.
|
|
1816
|
-
connect: async ({
|
|
2281
|
+
connect: async ({ tokens }, { fetcher } = {}) => {
|
|
1817
2282
|
var _a, _b, _c;
|
|
1818
2283
|
const body = await api("/accounts", { fetcher, token: tokens.accessToken });
|
|
1819
2284
|
const account = (_a = body == null ? void 0 : body.data) == null ? void 0 : _a[0];
|
|
@@ -1828,7 +2293,7 @@ var klaviyo_default2 = {
|
|
|
1828
2293
|
//
|
|
1829
2294
|
// Basic auth with our client, exactly like the token exchange — the
|
|
1830
2295
|
// token being revoked is the subject, not the credential.
|
|
1831
|
-
disconnect: async ({ clientId, clientSecret, fetcher = fetch
|
|
2296
|
+
disconnect: async ({ clientId, clientSecret, manifest, settings }, { fetcher = fetch } = {}) => {
|
|
1832
2297
|
const token = (settings == null ? void 0 : settings.refreshToken) || (settings == null ? void 0 : settings.accessToken);
|
|
1833
2298
|
if (!token) return { revoked: false };
|
|
1834
2299
|
const response = await fetcher(manifest.auth.oauth.urls.revoke, {
|
|
@@ -1851,7 +2316,7 @@ var klaviyo_default2 = {
|
|
|
1851
2316
|
// the refresh token is the only thing that asks Klaviyo.
|
|
1852
2317
|
//
|
|
1853
2318
|
// It also keeps the grant warm against the 90-day idle window above.
|
|
1854
|
-
probe: async ({ clientId, clientSecret,
|
|
2319
|
+
probe: async ({ clientId, clientSecret, manifest, settings }, { fetcher } = {}) => {
|
|
1855
2320
|
const token = await accessToken({
|
|
1856
2321
|
clientId,
|
|
1857
2322
|
clientSecret,
|
|
@@ -1881,12 +2346,11 @@ var klaviyo_default2 = {
|
|
|
1881
2346
|
// the store's.
|
|
1882
2347
|
commerce: false,
|
|
1883
2348
|
// The verb the contacts.sync step points at. It does the work — including
|
|
1884
|
-
// writing the profile id back onto the lead — and returns what happened.
|
|
1885
2349
|
contacts: {
|
|
1886
2350
|
// Not yet. Suppression syncs an opt-out as unsubscribed, which is a
|
|
1887
2351
|
// different thing from deleting the profile.
|
|
1888
2352
|
remove: false,
|
|
1889
|
-
sync: async ({ contact,
|
|
2353
|
+
sync: async ({ contact, lead, settings, suppressed, token }, { fetcher } = {}) => {
|
|
1890
2354
|
var _a, _b, _c;
|
|
1891
2355
|
const list = settings == null ? void 0 : settings.list;
|
|
1892
2356
|
if (!list) return { message: "No Klaviyo list is chosen for this connection.", skipped: true };
|
|
@@ -1978,7 +2442,7 @@ var klaviyo_default2 = {
|
|
|
1978
2442
|
// it, so one call quietly returns the first ten lists and an account
|
|
1979
2443
|
// with more shows a picker missing the one they wanted, with nothing to
|
|
1980
2444
|
// indicate anything was cut.
|
|
1981
|
-
audiences: async ({ cursor,
|
|
2445
|
+
audiences: async ({ cursor, limit = 100, search, token }, { fetcher } = {}) => {
|
|
1982
2446
|
var _a, _b;
|
|
1983
2447
|
const audiences = [];
|
|
1984
2448
|
let next = cursor ? "/lists?page%5Bsize%5D=10&page%5Bcursor%5D=" + encodeURIComponent(cursor) : "/lists?page%5Bsize%5D=10";
|
|
@@ -2090,7 +2554,7 @@ var mailchimp_default = `<svg width="500" height="500" viewBox="0 0 500 500" fil
|
|
|
2090
2554
|
<path d="M360.476 284.243C360.35 283.835 359.618 281.204 358.647 278.052L356.621 272.648C360.575 266.696 360.645 261.405 360.124 258.393C359.531 254.519 357.693 250.944 354.89 248.205C351.766 244.94 345.363 241.563 336.385 239.044L331.671 237.736C331.643 237.524 331.418 226.619 331.235 221.933C331.08 218.555 330.798 213.264 329.152 208.058C327.182 200.993 323.791 194.858 319.527 190.876C331.277 178.717 338.594 165.307 338.58 153.81C338.538 131.703 311.379 124.976 277.902 138.851L270.824 141.863C270.795 141.835 258.004 129.283 257.821 129.128C219.63 95.8334 100.327 228.476 138.49 260.687L146.835 267.737C144.581 273.775 143.781 280.259 144.499 286.664C145.414 295.543 149.973 304.029 157.375 310.6C164.411 316.82 173.684 320.788 182.648 320.774C197.494 354.998 231.408 375.965 271.175 377.161C313.842 378.427 349.641 358.403 364.67 322.435C365.641 319.916 369.806 308.546 369.806 298.513C369.792 288.409 364.093 284.229 360.476 284.243ZM185.913 311.149C184.613 311.381 183.293 311.48 181.973 311.445C169.083 311.079 155.166 299.483 153.787 285.735C152.253 270.537 160.02 258.829 173.783 256.071C175.415 255.72 177.414 255.537 179.552 255.635C187.264 256.085 198.606 261.996 201.209 278.784C203.517 293.63 199.858 308.785 185.913 311.149ZM171.545 246.953C163.179 248.499 155.744 253.244 150.817 260.18C148.045 257.873 142.909 253.426 142.008 251.681C134.635 237.693 150.043 210.478 160.823 195.111C187.405 157.145 229.086 128.424 248.393 133.603C251.517 134.503 261.902 146.563 261.902 146.563C261.902 146.563 242.623 157.244 224.724 172.16C200.646 190.735 182.423 217.697 171.545 246.953ZM306.792 305.464C306.937 305.403 307.057 305.295 307.134 305.157C307.211 305.019 307.239 304.86 307.214 304.704C307.205 304.61 307.178 304.519 307.133 304.436C307.088 304.353 307.027 304.28 306.954 304.221C306.88 304.162 306.796 304.118 306.705 304.092C306.614 304.066 306.519 304.059 306.426 304.071C306.426 304.071 286.246 307.054 267.179 300.089C269.247 293.348 274.792 295.754 283.137 296.444C296.108 297.209 309.116 295.802 321.623 292.279C330.25 289.788 341.592 284.905 350.401 277.953C353.384 284.497 354.425 291.674 354.425 291.674C354.425 291.674 356.719 291.265 358.647 292.447C360.476 293.573 361.799 295.895 360.898 301.89C359.027 313.119 354.271 322.224 346.235 330.611C341.236 336.036 335.277 340.492 328.659 343.754C324.983 345.691 321.151 347.32 317.205 348.623C286.964 358.487 256.006 347.638 246.029 324.321C245.224 322.535 244.556 320.691 244.03 318.804C239.781 303.438 243.383 285.032 254.655 273.408C255.372 272.676 256.09 271.804 256.09 270.706C256.09 269.806 255.499 268.835 255.007 268.131C251.066 262.418 237.374 252.666 240.132 233.795C242.088 220.23 253.951 210.689 265.012 211.252L267.826 211.421C272.611 211.702 276.79 212.307 280.73 212.49C287.344 212.758 293.268 211.801 300.304 205.947C302.683 203.949 304.582 202.246 307.791 201.711C308.128 201.627 308.973 201.359 310.647 201.416C312.365 201.485 314.032 202.015 315.474 202.949C321.103 206.693 321.905 215.783 322.214 222.439C322.383 226.225 322.848 235.414 322.988 238.031C323.354 244.054 324.944 244.912 328.125 245.954C329.94 246.573 331.615 246.995 334.077 247.713C341.521 249.781 345.968 251.934 348.754 254.65C350.198 256.049 351.13 257.893 351.4 259.885C352.315 266.316 346.432 274.252 330.925 281.457C313.954 289.324 293.367 291.322 279.154 289.732L274.173 289.169C262.774 287.649 256.315 302.34 263.14 312.402C267.545 318.889 279.52 323.11 291.523 323.11C319.006 323.139 340.142 311.402 348.023 301.242L348.642 300.356C349.008 299.765 348.712 299.469 348.22 299.779C341.817 304.169 313.279 321.619 282.771 316.384C282.771 316.384 279.056 315.765 275.678 314.442C273.005 313.429 267.362 310.811 266.686 305.042C291.27 312.683 306.792 305.478 306.792 305.464ZM220.671 194.971C230.127 184.051 241.765 174.538 252.206 169.219C252.558 169.022 252.938 169.43 252.741 169.754C251.46 172 250.476 174.403 249.814 176.902C249.73 177.282 250.138 177.592 250.461 177.353C256.963 172.934 268.248 168.192 278.155 167.601C278.251 167.584 278.351 167.601 278.436 167.65C278.521 167.698 278.586 167.775 278.621 167.866C278.656 167.957 278.658 168.058 278.627 168.151C278.596 168.244 278.533 168.323 278.451 168.375C276.809 169.634 275.342 171.105 274.088 172.751C273.891 173.032 274.074 173.44 274.426 173.44C281.378 173.483 291.186 175.903 297.56 179.491C297.982 179.745 297.673 180.575 297.209 180.462C287.527 178.253 271.724 176.564 255.288 180.575C240.597 184.149 229.396 189.666 221.248 195.618C220.826 195.899 220.333 195.351 220.671 194.971Z" fill="#231E15"/>
|
|
2091
2555
|
</svg>`;
|
|
2092
2556
|
|
|
2093
|
-
// lib/connections/mailchimp.js
|
|
2557
|
+
// lib/connections/providers/mailchimp.js
|
|
2094
2558
|
var base = (dc) => {
|
|
2095
2559
|
if (!dc) throw new Error("This Mailchimp connection has no data centre stored, so there is no host to call");
|
|
2096
2560
|
return "https://" + dc + ".api.mailchimp.com/3.0";
|
|
@@ -2179,7 +2643,7 @@ var mailchimp_default2 = {
|
|
|
2179
2643
|
// The header here is `OAuth <token>`, not Bearer — that is specific to
|
|
2180
2644
|
// the metadata endpoint. Marketing API calls take Bearer; see the
|
|
2181
2645
|
// audiences hook.
|
|
2182
|
-
connect: async ({ fetcher = fetch
|
|
2646
|
+
connect: async ({ tokens }, { fetcher = fetch } = {}) => {
|
|
2183
2647
|
const response = await fetcher("https://login.mailchimp.com/oauth2/metadata", {
|
|
2184
2648
|
headers: {
|
|
2185
2649
|
authorization: "OAuth " + (tokens == null ? void 0 : tokens.accessToken)
|
|
@@ -2231,7 +2695,7 @@ var mailchimp_default2 = {
|
|
|
2231
2695
|
// successful — the same silent truncation Klaviyo has, at a different
|
|
2232
2696
|
// number. Paged against total_items so an account past a thousand still
|
|
2233
2697
|
// resolves.
|
|
2234
|
-
audiences: async ({ cursor,
|
|
2698
|
+
audiences: async ({ cursor, limit = 100, search, settings, token }, { fetcher = fetch } = {}) => {
|
|
2235
2699
|
const dc = settings == null ? void 0 : settings.dc;
|
|
2236
2700
|
const count = Math.min(limit, 1e3);
|
|
2237
2701
|
const offset = Number(cursor || 0);
|
|
@@ -2310,6 +2774,10 @@ var mailchimp_default2 = {
|
|
|
2310
2774
|
title: "Mailchimp"
|
|
2311
2775
|
};
|
|
2312
2776
|
|
|
2777
|
+
// lib/connections/providers/shopify.js
|
|
2778
|
+
var import_node_crypto3 = require("crypto");
|
|
2779
|
+
var import_nanoid2 = require("nanoid");
|
|
2780
|
+
|
|
2313
2781
|
// lib/connections/icons/shopify.js
|
|
2314
2782
|
var shopify_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2315
2783
|
<rect width="500" height="500" fill="white"/>
|
|
@@ -2320,12 +2788,15 @@ var shopify_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill=
|
|
|
2320
2788
|
|
|
2321
2789
|
// lib/connections/inbound.js
|
|
2322
2790
|
var import_node_crypto2 = require("crypto");
|
|
2323
|
-
var verifySignature = ({ body, descriptor, headers }) => {
|
|
2791
|
+
var verifySignature = ({ body, descriptor, headers, secret }) => {
|
|
2792
|
+
if (!secret) {
|
|
2793
|
+
throw Object.assign(new Error("Missing webhook secret: " + descriptor.signature.secret), { status: 500 });
|
|
2794
|
+
}
|
|
2324
2795
|
const provided = headers[descriptor.headers.signature];
|
|
2325
2796
|
if (!provided) {
|
|
2326
2797
|
throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
|
|
2327
2798
|
}
|
|
2328
|
-
const digest = (0, import_node_crypto2.createHmac)(descriptor.signature.algorithm,
|
|
2799
|
+
const digest = (0, import_node_crypto2.createHmac)(descriptor.signature.algorithm, secret).update(body).digest(descriptor.signature.encoding);
|
|
2329
2800
|
const digestBuffer = Buffer.from(digest, descriptor.signature.encoding);
|
|
2330
2801
|
const providedBuffer = Buffer.from(provided, descriptor.signature.encoding);
|
|
2331
2802
|
if (digestBuffer.length !== providedBuffer.length || !(0, import_node_crypto2.timingSafeEqual)(digestBuffer, providedBuffer)) {
|
|
@@ -2335,7 +2806,72 @@ var verifySignature = ({ body, descriptor, headers }) => {
|
|
|
2335
2806
|
};
|
|
2336
2807
|
var readEventHeader = ({ descriptor, headers }) => headers[descriptor.headers.event];
|
|
2337
2808
|
|
|
2338
|
-
// lib/
|
|
2809
|
+
// lib/email.js
|
|
2810
|
+
var GMAIL_DOMAINS = /* @__PURE__ */ new Set(["gmail.com", "googlemail.com"]);
|
|
2811
|
+
var toCanonicalEmail = (value) => {
|
|
2812
|
+
if (!value || typeof value !== "string") return null;
|
|
2813
|
+
const email = value.trim().toLowerCase();
|
|
2814
|
+
const at = email.lastIndexOf("@");
|
|
2815
|
+
if (at < 1 || at === email.length - 1) return null;
|
|
2816
|
+
let local = email.slice(0, at);
|
|
2817
|
+
const domain = email.slice(at + 1);
|
|
2818
|
+
const plus = local.indexOf("+");
|
|
2819
|
+
if (plus > 0) local = local.slice(0, plus);
|
|
2820
|
+
if (GMAIL_DOMAINS.has(domain)) local = local.replaceAll(".", "");
|
|
2821
|
+
if (!local) return null;
|
|
2822
|
+
return local + "@" + domain;
|
|
2823
|
+
};
|
|
2824
|
+
|
|
2825
|
+
// lib/phone.js
|
|
2826
|
+
var import_libphonenumber_js = require("libphonenumber-js");
|
|
2827
|
+
var toE164 = (value, country) => {
|
|
2828
|
+
if (!value) return null;
|
|
2829
|
+
try {
|
|
2830
|
+
const parsed = (0, import_libphonenumber_js.parsePhoneNumberFromString)(String(value), country);
|
|
2831
|
+
return parsed ? parsed.number : null;
|
|
2832
|
+
} catch {
|
|
2833
|
+
return null;
|
|
2834
|
+
}
|
|
2835
|
+
};
|
|
2836
|
+
|
|
2837
|
+
// lib/connections/providers/shopify.js
|
|
2838
|
+
var toLine = ({
|
|
2839
|
+
price,
|
|
2840
|
+
product_id: productId,
|
|
2841
|
+
quantity,
|
|
2842
|
+
title,
|
|
2843
|
+
variant_id: variantId,
|
|
2844
|
+
variant_title: variantTitle
|
|
2845
|
+
}) => ({
|
|
2846
|
+
price: parseFloat(price) || 0,
|
|
2847
|
+
productId: productId ? "gid://shopify/Product/" + productId : null,
|
|
2848
|
+
quantity: quantity || 1,
|
|
2849
|
+
title: title || null,
|
|
2850
|
+
variantId: variantId ? "gid://shopify/ProductVariant/" + variantId : null,
|
|
2851
|
+
variantTitle: variantTitle || null
|
|
2852
|
+
});
|
|
2853
|
+
var attributeLineItems = (lineItems = []) => lineItems.reduce(
|
|
2854
|
+
(acc, item) => {
|
|
2855
|
+
const attrs = (item.properties || []).reduce(
|
|
2856
|
+
(map, { name, value }) => {
|
|
2857
|
+
map[name] = value;
|
|
2858
|
+
return map;
|
|
2859
|
+
},
|
|
2860
|
+
{}
|
|
2861
|
+
);
|
|
2862
|
+
if (!attrs["_drwbrdg_ca"]) return acc;
|
|
2863
|
+
if (!Object.keys(acc.attrMap).length) acc.attrMap = attrs;
|
|
2864
|
+
const line = toLine(item);
|
|
2865
|
+
acc.attributedGross += line.price * line.quantity;
|
|
2866
|
+
acc.attributedLines.push(line);
|
|
2867
|
+
return acc;
|
|
2868
|
+
},
|
|
2869
|
+
{ attrMap: {}, attributedGross: 0, attributedLines: [] }
|
|
2870
|
+
);
|
|
2871
|
+
var generateDiscountCode = (0, import_nanoid2.customAlphabet)("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 8);
|
|
2872
|
+
var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
|
|
2873
|
+
var OAUTH_ERROR_SOURCE = "oauth";
|
|
2874
|
+
var OAUTH_GRANT_REVOKED_CODES = ["application_cannot_be_found", "invalid_grant"];
|
|
2339
2875
|
var inbound = {
|
|
2340
2876
|
headers: {
|
|
2341
2877
|
event: "x-shopify-topic",
|
|
@@ -2452,20 +2988,476 @@ var shopify_default2 = {
|
|
|
2452
2988
|
//
|
|
2453
2989
|
// `shopify` is injected for the same reason it is everywhere else — this
|
|
2454
2990
|
// package cannot import @drawbridge/shopify, which depends on it.
|
|
2455
|
-
scopes: ({ scope, shopify }) => scope ? shopify.oauth.missingScopes(scope) : null,
|
|
2991
|
+
scopes: ({ scope }, { shopify } = {}) => scope ? shopify.oauth.missingScopes(scope) : null,
|
|
2456
2992
|
// Shopify's install grant is exchanged inside its own app flow, not
|
|
2457
2993
|
// through the shared OAuth runner.
|
|
2458
2994
|
token: false
|
|
2459
2995
|
},
|
|
2460
|
-
//
|
|
2461
|
-
//
|
|
2462
|
-
//
|
|
2463
|
-
//
|
|
2996
|
+
// THE VENDOR'S OWN WORK, here in full. Every body describes its writes,
|
|
2997
|
+
// enqueues and events for the shell to perform — see contract.js — and
|
|
2998
|
+
// everything it needs arrives as an argument: `read` (the controller's
|
|
2999
|
+
// read methods, nothing that writes), `shopify` (the SDK, injected because
|
|
3000
|
+
// this package cannot import what depends on it), `adminToken` (minted by
|
|
3001
|
+
// the shell, which persists rotations), `mintId` (so one described write
|
|
3002
|
+
// can reference another), `dispatch` (the caller's own coordinator table,
|
|
3003
|
+
// for the hooks that are dispatches).
|
|
2464
3004
|
commerce: {
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
3005
|
+
// MINT A DISCOUNT CODE against the merchant's chosen discount, mapped to
|
|
3006
|
+
// one lead — which is what lets an order that redeems it be attributed
|
|
3007
|
+
// back.
|
|
3008
|
+
code: async ({ connection: connection2, context, step }, { adminToken, shopify } = {}) => {
|
|
3009
|
+
var _a;
|
|
3010
|
+
const discount = (_a = step.settings) == null ? void 0 : _a.discount;
|
|
3011
|
+
const request2 = { email: (context == null ? void 0 : context.email) || null, lead: (context == null ? void 0 : context.lead) || null, shop: connection2.shop };
|
|
3012
|
+
if (!(context == null ? void 0 : context.email)) return { message: "Lead email is missing.", request: request2, response: { skipped: true }, skipped: true };
|
|
3013
|
+
if (!(context == null ? void 0 : context.lead)) return { message: "Lead id is missing.", request: request2, response: { skipped: true }, skipped: true };
|
|
3014
|
+
if (!(discount == null ? void 0 : discount.id)) return { message: "Discount is not configured on this step.", request: request2, response: { skipped: true }, skipped: true };
|
|
3015
|
+
const adminAccessToken = await adminToken();
|
|
3016
|
+
if (!context.shopifyCustomerId) {
|
|
3017
|
+
const customer = await shopify.admin.getOrCreateCustomer({ adminAccessToken, domain: connection2.shop, email: context.email });
|
|
3018
|
+
if (!(customer == null ? void 0 : customer.id)) return { message: "Shopify did not return a customer id \u2014 create/lookup failed.", request: request2, response: { skipped: true }, skipped: true };
|
|
3019
|
+
}
|
|
3020
|
+
const discountCode = await shopify.admin.createDiscountCode({
|
|
3021
|
+
adminAccessToken,
|
|
3022
|
+
code: "DB-" + generateDiscountCode(),
|
|
3023
|
+
discountId: discount.id,
|
|
3024
|
+
domain: connection2.shop
|
|
3025
|
+
});
|
|
3026
|
+
if (!discountCode) return { message: "Shopify did not return a discount code \u2014 create failed.", request: request2, response: { skipped: true }, skipped: true };
|
|
3027
|
+
return {
|
|
3028
|
+
context: {
|
|
3029
|
+
shopifyDiscountCode: discountCode.code,
|
|
3030
|
+
shopifyDiscountId: String(discountCode.id)
|
|
3031
|
+
},
|
|
3032
|
+
message: "Discount code created and linked to lead.",
|
|
3033
|
+
request: request2,
|
|
3034
|
+
response: { code: discountCode.code, id: String(discountCode.id) },
|
|
3035
|
+
// bypassDocumentValidation because these are vendor ids on a
|
|
3036
|
+
// Drawbridge document the schema does not declare — the
|
|
3037
|
+
// canonical-identity work resolves it properly.
|
|
3038
|
+
writes: [{
|
|
3039
|
+
collection: "lead",
|
|
3040
|
+
data: {
|
|
3041
|
+
$set: {
|
|
3042
|
+
shopifyDiscountCode: discountCode.code,
|
|
3043
|
+
shopifyDiscountId: String(discountCode.id)
|
|
3044
|
+
}
|
|
3045
|
+
},
|
|
3046
|
+
operation: "update",
|
|
3047
|
+
options: { bypassDocumentValidation: true },
|
|
3048
|
+
query: { id: context.lead }
|
|
3049
|
+
}]
|
|
3050
|
+
};
|
|
3051
|
+
},
|
|
3052
|
+
// CREATE THE BUYER AT THE STORE, so an order can be attributed to them.
|
|
3053
|
+
//
|
|
3054
|
+
// IDEMPOTENT THREE WAYS, because this runs on every entry and a duplicate
|
|
3055
|
+
// customer at the store is a support ticket: the context may already
|
|
3056
|
+
// carry the id from an earlier step, the lead may already be linked from
|
|
3057
|
+
// an earlier run, and Shopify's own get-or-create settles the rest.
|
|
3058
|
+
customer: async ({ connection: connection2, context }, { adminToken, read, shopify } = {}) => {
|
|
3059
|
+
const request2 = { email: (context == null ? void 0 : context.email) || null, lead: (context == null ? void 0 : context.lead) || null, shop: connection2.shop };
|
|
3060
|
+
if (!(context == null ? void 0 : context.email)) return { message: "Lead email is missing \u2014 cannot create Shopify customer.", request: request2, response: { skipped: true }, skipped: true };
|
|
3061
|
+
if (!(context == null ? void 0 : context.lead)) return { message: "Lead id is missing \u2014 cannot create Shopify customer.", request: request2, response: { skipped: true }, skipped: true };
|
|
3062
|
+
if (context.shopifyCustomerId) {
|
|
3063
|
+
return {
|
|
3064
|
+
context: { shopifyCustomerId: context.shopifyCustomerId },
|
|
3065
|
+
message: "Trigger data already includes a Shopify customer id \u2014 reusing.",
|
|
3066
|
+
request: request2,
|
|
3067
|
+
response: { shopifyCustomerId: context.shopifyCustomerId },
|
|
3068
|
+
// Reusing an id is not a creation, so it does not bill.
|
|
3069
|
+
skipped: true
|
|
3070
|
+
};
|
|
3071
|
+
}
|
|
3072
|
+
const lead = await read.get({ collection: "lead", query: { id: context.lead } });
|
|
3073
|
+
if (lead == null ? void 0 : lead.shopifyCustomerId) {
|
|
3074
|
+
return {
|
|
3075
|
+
context: { shopifyCustomerId: lead.shopifyCustomerId },
|
|
3076
|
+
message: "Lead already has a Shopify customer id \u2014 reusing.",
|
|
3077
|
+
request: request2,
|
|
3078
|
+
response: { shopifyCustomerId: lead.shopifyCustomerId },
|
|
3079
|
+
skipped: true
|
|
3080
|
+
};
|
|
3081
|
+
}
|
|
3082
|
+
const adminAccessToken = await adminToken();
|
|
3083
|
+
const parts = ((lead == null ? void 0 : lead.name) || "").trim().split(/\s+/).filter(Boolean);
|
|
3084
|
+
const customer = await shopify.admin.getOrCreateCustomer({
|
|
3085
|
+
adminAccessToken,
|
|
3086
|
+
domain: connection2.shop,
|
|
3087
|
+
email: context.email,
|
|
3088
|
+
firstName: parts.length ? parts[0] : null,
|
|
3089
|
+
lastName: parts.length > 1 ? parts.slice(1).join(" ") : null,
|
|
3090
|
+
source: "drawbridge"
|
|
3091
|
+
});
|
|
3092
|
+
if (!(customer == null ? void 0 : customer.id)) return { message: "Shopify did not return a customer id \u2014 create/lookup failed.", request: request2, response: { skipped: true }, skipped: true };
|
|
3093
|
+
return {
|
|
3094
|
+
context: { shopifyCustomerId: customer.id },
|
|
3095
|
+
message: "Shopify customer created/linked to lead.",
|
|
3096
|
+
request: request2,
|
|
3097
|
+
response: { shopifyCustomerId: customer.id },
|
|
3098
|
+
// The hook's own result, described beside the call that produced it.
|
|
3099
|
+
writes: [{
|
|
3100
|
+
collection: "lead",
|
|
3101
|
+
data: { $set: { shopifyCustomerId: customer.id } },
|
|
3102
|
+
operation: "update",
|
|
3103
|
+
options: { bypassDocumentValidation: true },
|
|
3104
|
+
query: { id: context.lead }
|
|
3105
|
+
}]
|
|
3106
|
+
};
|
|
3107
|
+
},
|
|
3108
|
+
// AN ORDER ARRIVED AT THE STORE. The largest hook in the family, because
|
|
3109
|
+
// attribution genuinely is: an order can reach Drawbridge two ways and
|
|
3110
|
+
// they bill differently.
|
|
3111
|
+
//
|
|
3112
|
+
// CONVERSION — a `_drwbrdg_ca` line-item property, injected at
|
|
3113
|
+
// add-to-cart. Causal: the campaign produced the sale, so
|
|
3114
|
+
// it carries a fee.
|
|
3115
|
+
// REDEMPTION — a DB- discount code matched to a lead. Associative: we
|
|
3116
|
+
// cannot claim we caused the purchase, so it is fee-free.
|
|
3117
|
+
//
|
|
3118
|
+
// Both can be true, and an order already recorded as a conversion can
|
|
3119
|
+
// later have a redemption backfilled onto it — `backfill` below.
|
|
3120
|
+
//
|
|
3121
|
+
// IDEMPOTENT THROUGH THE RETRY, one layer up: two deliveries of the same
|
|
3122
|
+
// order race, the loser's transaction hits a duplicate key, the step
|
|
3123
|
+
// fails and BullMQ redelivers — and the re-run's read at the top finds
|
|
3124
|
+
// what the winner wrote and skips instead of double-billing a merchant
|
|
3125
|
+
// for one purchase. The hook used to loop for this itself; describing
|
|
3126
|
+
// the writes moved the retry to the queue, with the same guarantee.
|
|
3127
|
+
order: async ({ connection: connection2, context }, { logger: logger2, mintId, read } = {}) => {
|
|
3128
|
+
var _a, _b, _c, _d, _e, _f;
|
|
3129
|
+
const {
|
|
3130
|
+
advertisement,
|
|
3131
|
+
created_at: createdAt,
|
|
3132
|
+
currency,
|
|
3133
|
+
customer: orderCustomer,
|
|
3134
|
+
email,
|
|
3135
|
+
id: orderId,
|
|
3136
|
+
line_items: lineItems = [],
|
|
3137
|
+
organization: organization2,
|
|
3138
|
+
phone
|
|
3139
|
+
} = context || {};
|
|
3140
|
+
const request2 = { orderId: orderId ? String(orderId) : null, organization: organization2 };
|
|
3141
|
+
const [existingOrder, existingRedemption] = await Promise.all([
|
|
3142
|
+
read.get({ collection: "order", query: { "provider.id": String(orderId), "provider.slug": "shopify" } }),
|
|
3143
|
+
read.get({ collection: "redemption", query: { "provider.id": String(orderId), "provider.slug": "shopify" } })
|
|
3144
|
+
]);
|
|
3145
|
+
if (existingRedemption) {
|
|
3146
|
+
return {
|
|
3147
|
+
message: "Order/redemption already recorded \u2014 skipping duplicate.",
|
|
3148
|
+
request: request2,
|
|
3149
|
+
response: {
|
|
3150
|
+
existingOrderId: (existingOrder == null ? void 0 : existingOrder.id) || null,
|
|
3151
|
+
existingRedemptionId: existingRedemption.id,
|
|
3152
|
+
skipped: true
|
|
3153
|
+
},
|
|
3154
|
+
skipped: true
|
|
3155
|
+
};
|
|
3156
|
+
}
|
|
3157
|
+
const backfill = !!existingOrder;
|
|
3158
|
+
const { attrMap, attributedGross, attributedLines } = attributeLineItems(lineItems);
|
|
3159
|
+
const campaign = attrMap["_drwbrdg_ca"] || null;
|
|
3160
|
+
const discountCodes = Array.isArray(context == null ? void 0 : context.discount_codes) ? context.discount_codes : [];
|
|
3161
|
+
const codes = [...new Set(discountCodes.map((dc) => dc == null ? void 0 : dc.code).filter(Boolean))];
|
|
3162
|
+
const matchedLeads = codes.length ? await read.aggregate({
|
|
3163
|
+
collection: "lead",
|
|
3164
|
+
pipeline: [{ $match: { organization: organization2, shopifyDiscountCode: { $in: codes } } }]
|
|
3165
|
+
}) : [];
|
|
3166
|
+
const codeToLead = {};
|
|
3167
|
+
for (const found of matchedLeads) {
|
|
3168
|
+
if (found.shopifyDiscountCode) codeToLead[found.shopifyDiscountCode] = found;
|
|
3169
|
+
}
|
|
3170
|
+
const matchedDiscounts = discountCodes.filter((dc) => (dc == null ? void 0 : dc.code) && codeToLead[dc.code]).map((dc) => ({
|
|
3171
|
+
amount: parseFloat(dc.amount) || 0,
|
|
3172
|
+
code: dc.code,
|
|
3173
|
+
id: codeToLead[dc.code].shopifyDiscountId || null
|
|
3174
|
+
}));
|
|
3175
|
+
const matchedLead = matchedDiscounts.length ? codeToLead[matchedDiscounts[0].code] : null;
|
|
3176
|
+
const discount = matchedDiscounts.length ? {
|
|
3177
|
+
amount: matchedDiscounts.reduce((sum, entry) => sum + entry.amount, 0),
|
|
3178
|
+
codes: matchedDiscounts
|
|
3179
|
+
} : null;
|
|
3180
|
+
const matchedCodes = new Set(matchedDiscounts.map((entry) => entry.code));
|
|
3181
|
+
const unmatched = codes.filter((code2) => code2.startsWith("DB-") && !matchedCodes.has(code2));
|
|
3182
|
+
if (unmatched.length) {
|
|
3183
|
+
(_a = logger2 == null ? void 0 : logger2.warn) == null ? void 0 : _a.call(logger2, "shopify.order.discount.unmatched", {
|
|
3184
|
+
campaign: campaign || null,
|
|
3185
|
+
codes: JSON.stringify(unmatched),
|
|
3186
|
+
isConversion: !!campaign,
|
|
3187
|
+
orderId: String(orderId),
|
|
3188
|
+
organization: organization2
|
|
3189
|
+
});
|
|
3190
|
+
}
|
|
3191
|
+
if (!campaign && !discount || backfill && !discount) {
|
|
3192
|
+
return {
|
|
3193
|
+
message: backfill ? "Order already recorded and no Drawbridge discount code matched \u2014 nothing to backfill." : "Order has no Drawbridge attribution \u2014 not recording.",
|
|
3194
|
+
request: request2,
|
|
3195
|
+
response: { skipped: true },
|
|
3196
|
+
skipped: true
|
|
3197
|
+
};
|
|
3198
|
+
}
|
|
3199
|
+
let advertisementId = null;
|
|
3200
|
+
let affiliateId = null;
|
|
3201
|
+
let campaignOrganization = organization2;
|
|
3202
|
+
let gross = 0;
|
|
3203
|
+
let leadId = null;
|
|
3204
|
+
let lines = [];
|
|
3205
|
+
let orderCampaign = null;
|
|
3206
|
+
let pageId = null;
|
|
3207
|
+
const isConversion = !!campaign;
|
|
3208
|
+
const customerPhone = toE164((orderCustomer == null ? void 0 : orderCustomer.phone) || phone) || null;
|
|
3209
|
+
const matchPhones = [...new Set([
|
|
3210
|
+
customerPhone,
|
|
3211
|
+
toE164((_b = context == null ? void 0 : context.billing_address) == null ? void 0 : _b.phone),
|
|
3212
|
+
toE164((_c = context == null ? void 0 : context.shipping_address) == null ? void 0 : _c.phone)
|
|
3213
|
+
].filter(Boolean))];
|
|
3214
|
+
if (isConversion) {
|
|
3215
|
+
const campaignDoc = await read.get({ collection: "campaign", query: { id: campaign } });
|
|
3216
|
+
if (!campaignDoc || campaignDoc.organization !== organization2) {
|
|
3217
|
+
return {
|
|
3218
|
+
message: "Order carried a campaign attribution that does not belong to this store \u2014 not recording.",
|
|
3219
|
+
request: request2,
|
|
3220
|
+
response: { skipped: true },
|
|
3221
|
+
skipped: true
|
|
3222
|
+
};
|
|
3223
|
+
}
|
|
3224
|
+
advertisementId = attrMap["_drwbrdg_ad"] || advertisement || null;
|
|
3225
|
+
affiliateId = attrMap["_drwbrdg_af"] || null;
|
|
3226
|
+
campaignOrganization = campaignDoc.organization;
|
|
3227
|
+
gross = attributedGross;
|
|
3228
|
+
lines = attributedLines;
|
|
3229
|
+
orderCampaign = campaign;
|
|
3230
|
+
pageId = attrMap["_drwbrdg_pg"] || null;
|
|
3231
|
+
const identifiers = [];
|
|
3232
|
+
const canonicalEmail = toCanonicalEmail(email);
|
|
3233
|
+
if (email) identifiers.push({ email: email.toLowerCase() });
|
|
3234
|
+
if (canonicalEmail) identifiers.push({ "canonical.email.value": canonicalEmail });
|
|
3235
|
+
if (matchPhones.length) identifiers.push({ "phone.number": { $in: matchPhones } });
|
|
3236
|
+
if (matchPhones.length) identifiers.push({ "canonical.phone.value": { $in: matchPhones } });
|
|
3237
|
+
if (identifiers.length) {
|
|
3238
|
+
const lead = await read.get({
|
|
3239
|
+
collection: "lead",
|
|
3240
|
+
query: {
|
|
3241
|
+
campaigns: { $in: [campaign] },
|
|
3242
|
+
organization: campaignOrganization,
|
|
3243
|
+
$or: identifiers
|
|
3244
|
+
}
|
|
3245
|
+
});
|
|
3246
|
+
leadId = (lead == null ? void 0 : lead.id) || null;
|
|
3247
|
+
if (!leadId) {
|
|
3248
|
+
const orgLead = await read.get({
|
|
3249
|
+
collection: "lead",
|
|
3250
|
+
query: { organization: campaignOrganization, $or: identifiers }
|
|
3251
|
+
});
|
|
3252
|
+
leadId = (orgLead == null ? void 0 : orgLead.id) || null;
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
} else {
|
|
3256
|
+
leadId = matchedLead.id;
|
|
3257
|
+
orderCampaign = (matchedLead.campaigns || []).length === 1 ? matchedLead.campaigns[0] : null;
|
|
3258
|
+
gross = lineItems.reduce((sum, item) => {
|
|
3259
|
+
const line = toLine(item);
|
|
3260
|
+
return sum + line.price * line.quantity;
|
|
3261
|
+
}, 0);
|
|
3262
|
+
lines = lineItems.map(toLine);
|
|
3263
|
+
}
|
|
3264
|
+
const org = await read.get({ collection: "organization", query: { id: campaignOrganization } });
|
|
3265
|
+
let rate = 0;
|
|
3266
|
+
if (isConversion) {
|
|
3267
|
+
const subscription = await read.get({ collection: "subscription", query: { id: org == null ? void 0 : org.subscription } });
|
|
3268
|
+
rate = conversionRate(subscription);
|
|
3269
|
+
}
|
|
3270
|
+
const fee = isConversion ? Math.round(gross * rate) / 100 : 0;
|
|
3271
|
+
const net2 = Math.round((gross - fee) * 100) / 100;
|
|
3272
|
+
const currencyCode = (currency || "usd").toLowerCase();
|
|
3273
|
+
const purchasedAt = new Date(createdAt || Date.now());
|
|
3274
|
+
const customer = orderCustomer || email || phone ? {
|
|
3275
|
+
acceptsMarketing: ((_d = orderCustomer == null ? void 0 : orderCustomer.email_marketing_consent) == null ? void 0 : _d.state) ? orderCustomer.email_marketing_consent.state === "subscribed" : typeof (orderCustomer == null ? void 0 : orderCustomer.accepts_marketing) === "boolean" ? orderCustomer.accepts_marketing : null,
|
|
3276
|
+
email: (orderCustomer == null ? void 0 : orderCustomer.email) || email || null,
|
|
3277
|
+
firstName: (orderCustomer == null ? void 0 : orderCustomer.first_name) || null,
|
|
3278
|
+
id: (orderCustomer == null ? void 0 : orderCustomer.id) ? String(orderCustomer.id) : null,
|
|
3279
|
+
lastName: (orderCustomer == null ? void 0 : orderCustomer.last_name) || null,
|
|
3280
|
+
phone: customerPhone
|
|
3281
|
+
} : null;
|
|
3282
|
+
const source = (connection2 == null ? void 0 : connection2.source) ? { domain: connection2.source.domain, id: connection2.source.id } : void 0;
|
|
3283
|
+
const orderDocId = (existingOrder == null ? void 0 : existingOrder.id) || (isConversion && !backfill ? mintId() : null);
|
|
3284
|
+
const writes = [];
|
|
3285
|
+
if (isConversion && !backfill) {
|
|
3286
|
+
writes.push({
|
|
3287
|
+
collection: "order",
|
|
3288
|
+
data: {
|
|
3289
|
+
advertisement: advertisementId,
|
|
3290
|
+
affiliate: affiliateId,
|
|
3291
|
+
campaign: orderCampaign,
|
|
3292
|
+
currency: currencyCode,
|
|
3293
|
+
customer,
|
|
3294
|
+
discount,
|
|
3295
|
+
fee,
|
|
3296
|
+
gross,
|
|
3297
|
+
id: orderDocId,
|
|
3298
|
+
lead: leadId,
|
|
3299
|
+
lines,
|
|
3300
|
+
net: net2,
|
|
3301
|
+
organization: campaignOrganization,
|
|
3302
|
+
page: pageId,
|
|
3303
|
+
provider: { id: String(orderId), slug: "shopify" },
|
|
3304
|
+
purchasedAt,
|
|
3305
|
+
rate,
|
|
3306
|
+
source,
|
|
3307
|
+
status: "completed"
|
|
3308
|
+
},
|
|
3309
|
+
operation: "create"
|
|
3310
|
+
});
|
|
3311
|
+
if (org == null ? void 0 : org.usage) {
|
|
3312
|
+
writes.push({
|
|
3313
|
+
collection: "usage",
|
|
3314
|
+
data: { $inc: { "totals.revenue": gross } },
|
|
3315
|
+
operation: "update",
|
|
3316
|
+
query: { id: org.usage }
|
|
3317
|
+
});
|
|
3318
|
+
}
|
|
3319
|
+
if (leadId) {
|
|
3320
|
+
writes.push({
|
|
3321
|
+
collection: "lead",
|
|
3322
|
+
data: { $inc: { "totals.orders": 1 } },
|
|
3323
|
+
operation: "update",
|
|
3324
|
+
options: { bypassDocumentValidation: true },
|
|
3325
|
+
query: { id: leadId }
|
|
3326
|
+
});
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
if (discount) {
|
|
3330
|
+
writes.push({
|
|
3331
|
+
collection: "redemption",
|
|
3332
|
+
data: {
|
|
3333
|
+
advertisement: advertisementId,
|
|
3334
|
+
affiliate: affiliateId,
|
|
3335
|
+
campaign: orderCampaign,
|
|
3336
|
+
code: ((_e = matchedDiscounts[0]) == null ? void 0 : _e.code) || null,
|
|
3337
|
+
currency: currencyCode,
|
|
3338
|
+
customer,
|
|
3339
|
+
discount,
|
|
3340
|
+
gross,
|
|
3341
|
+
lead: leadId,
|
|
3342
|
+
order: orderDocId,
|
|
3343
|
+
organization: campaignOrganization,
|
|
3344
|
+
page: pageId,
|
|
3345
|
+
provider: { id: String(orderId), slug: "shopify" },
|
|
3346
|
+
purchasedAt,
|
|
3347
|
+
source,
|
|
3348
|
+
status: "completed"
|
|
3349
|
+
},
|
|
3350
|
+
operation: "create"
|
|
3351
|
+
});
|
|
3352
|
+
if (org == null ? void 0 : org.usage) {
|
|
3353
|
+
writes.push({
|
|
3354
|
+
collection: "usage",
|
|
3355
|
+
data: { $inc: { "totals.redemptions": 1 } },
|
|
3356
|
+
operation: "update",
|
|
3357
|
+
query: { id: org.usage }
|
|
3358
|
+
});
|
|
3359
|
+
}
|
|
3360
|
+
if (leadId) {
|
|
3361
|
+
writes.push({
|
|
3362
|
+
collection: "lead",
|
|
3363
|
+
data: { $inc: { "totals.redemptions": 1 } },
|
|
3364
|
+
operation: "update",
|
|
3365
|
+
options: { bypassDocumentValidation: true },
|
|
3366
|
+
query: { id: leadId }
|
|
3367
|
+
});
|
|
3368
|
+
}
|
|
3369
|
+
}
|
|
3370
|
+
const enqueues = (org == null ? void 0 : org.billingProvider) === "shopify" && fee > 0 && ((_f = connection2 == null ? void 0 : connection2.source) == null ? void 0 : _f.id) && !backfill ? [{
|
|
3371
|
+
data: {
|
|
3372
|
+
idempotencyKey: String(orderId),
|
|
3373
|
+
orderDocId,
|
|
3374
|
+
orderId: String(orderId),
|
|
3375
|
+
rate,
|
|
3376
|
+
shopId: connection2.source.id,
|
|
3377
|
+
// The App Events API returns no event id, so one is generated
|
|
3378
|
+
// here — the event handle plus the order id — and sent as the
|
|
3379
|
+
// event's `reference`. queue/usage.js stamps the same id onto
|
|
3380
|
+
// the order as billed.transaction.
|
|
3381
|
+
transaction: "drawbridge-orders." + orderId,
|
|
3382
|
+
value: Math.round(fee * 100)
|
|
3383
|
+
},
|
|
3384
|
+
name: "billing",
|
|
3385
|
+
options: { jobId: "shopify.usage." + orderId },
|
|
3386
|
+
queue: "usage"
|
|
3387
|
+
}] : [];
|
|
3388
|
+
return {
|
|
3389
|
+
enqueues,
|
|
3390
|
+
message: backfill ? "Redemption backfilled for an already-recorded order." : isConversion ? "Order recorded." : "Discount redemption recorded (fee-free).",
|
|
3391
|
+
request: request2,
|
|
3392
|
+
response: {
|
|
3393
|
+
campaign: orderCampaign,
|
|
3394
|
+
currency: currencyCode,
|
|
3395
|
+
discount,
|
|
3396
|
+
fee,
|
|
3397
|
+
gross,
|
|
3398
|
+
lead: leadId,
|
|
3399
|
+
lines: lines.length,
|
|
3400
|
+
net: net2,
|
|
3401
|
+
orderId: String(orderId)
|
|
3402
|
+
},
|
|
3403
|
+
// ONE TRANSACTION. The order, the redemption and both totals
|
|
3404
|
+
// counters land together or not at all — a half-written attribution
|
|
3405
|
+
// is revenue counted twice or not at all, and neither is
|
|
3406
|
+
// recoverable by hand.
|
|
3407
|
+
transaction: writes.length > 0,
|
|
3408
|
+
writes
|
|
3409
|
+
};
|
|
3410
|
+
},
|
|
3411
|
+
// A PRODUCT CHANGED AT THE STORE. Upserts the product row and hands it to
|
|
3412
|
+
// the product pipeline; the actual field sync happens there.
|
|
3413
|
+
//
|
|
3414
|
+
// The shell has already refused a missing or inactive Shopify connection,
|
|
3415
|
+
// so what is left is the two things only this hook can know are wrong.
|
|
3416
|
+
product: async ({ connection: connection2, context, workflow }, { mintId } = {}) => {
|
|
3417
|
+
const request2 = {
|
|
3418
|
+
numericId: (context == null ? void 0 : context.id) || null,
|
|
3419
|
+
organizationId: workflow.organization,
|
|
3420
|
+
title: (context == null ? void 0 : context.title) || null
|
|
3421
|
+
};
|
|
3422
|
+
if (!(context == null ? void 0 : context.id)) return { message: "Skipped \u2014 product webhook payload had no id.", request: request2, response: { skipped: true }, skipped: true };
|
|
3423
|
+
if (!connection2.shop) return { message: "Skipped \u2014 Shopify connection is missing shop domain.", request: request2, response: { skipped: true }, skipped: true };
|
|
3424
|
+
const providerId = "gid://shopify/Product/" + context.id;
|
|
3425
|
+
const productId = mintId();
|
|
3426
|
+
return {
|
|
3427
|
+
enqueues: [{
|
|
3428
|
+
data: { product: productId, providerId, shop: connection2.shop },
|
|
3429
|
+
name: "workflow",
|
|
3430
|
+
options: { jobId: "product.workflow.shopify." + providerId + "." + Date.now() },
|
|
3431
|
+
queue: "product.shopify"
|
|
3432
|
+
}],
|
|
3433
|
+
message: "Product sync queued from Shopify webhook.",
|
|
3434
|
+
request: request2,
|
|
3435
|
+
response: { productId, providerId, title: (context == null ? void 0 : context.title) || null },
|
|
3436
|
+
// KEYED ON PROVIDER + SHOP, so the same product in two stores stays
|
|
3437
|
+
// two rows. `connections` accumulates rather than replaces: one
|
|
3438
|
+
// store can be linked to several organizations, and each keeps its
|
|
3439
|
+
// own claim on the row.
|
|
3440
|
+
writes: [{
|
|
3441
|
+
collection: "product",
|
|
3442
|
+
data: {
|
|
3443
|
+
$addToSet: { connections: connection2.id },
|
|
3444
|
+
$setOnInsert: {
|
|
3445
|
+
id: productId,
|
|
3446
|
+
provider: { id: providerId, slug: "shopify" },
|
|
3447
|
+
"source.id": connection2.id,
|
|
3448
|
+
status: "active"
|
|
3449
|
+
}
|
|
3450
|
+
},
|
|
3451
|
+
operation: "update",
|
|
3452
|
+
options: { upsert: true },
|
|
3453
|
+
query: {
|
|
3454
|
+
"provider.id": providerId,
|
|
3455
|
+
"provider.slug": "shopify",
|
|
3456
|
+
"source.domain": connection2.shop
|
|
3457
|
+
}
|
|
3458
|
+
}]
|
|
3459
|
+
};
|
|
3460
|
+
}
|
|
2469
3461
|
},
|
|
2470
3462
|
contacts: { remove: false, sync: false },
|
|
2471
3463
|
// verify and event lean entirely on the shared HMAC helper — Shopify's
|
|
@@ -2483,7 +3475,16 @@ var shopify_default2 = {
|
|
|
2483
3475
|
sms: false,
|
|
2484
3476
|
inbound: {
|
|
2485
3477
|
event: (args) => readEventHeader({ ...args, descriptor: inbound }),
|
|
2486
|
-
|
|
3478
|
+
// One hook over the whole topic table, because that is what this
|
|
3479
|
+
// manifest declares: Shopify processes its own buffered events. The
|
|
3480
|
+
// topic rides in on the context rather than being a second hook name per
|
|
3481
|
+
// topic; the caller's handler table arrives as a prop.
|
|
3482
|
+
process: async ({ context }, { dispatch } = {}) => {
|
|
3483
|
+
const key = "shopify." + (context == null ? void 0 : context.topic);
|
|
3484
|
+
const handled = await dispatch({ data: context == null ? void 0 : context.data, handler: key });
|
|
3485
|
+
if (!handled) return { message: "No handler for " + key, skipped: true };
|
|
3486
|
+
return { message: "Processed " + key, request: { topic: context == null ? void 0 : context.topic } };
|
|
3487
|
+
},
|
|
2487
3488
|
receive: ({ channel, event, headers, payload }) => {
|
|
2488
3489
|
if (channel === "compliance" && !COMPLIANCE_TOPICS.has(event)) {
|
|
2489
3490
|
throw Object.assign(new Error("Unrecognized compliance topic: " + event), { status: 401 });
|
|
@@ -2499,7 +3500,107 @@ var shopify_default2 = {
|
|
|
2499
3500
|
},
|
|
2500
3501
|
verify: (args) => verifySignature({ ...args, descriptor: inbound })
|
|
2501
3502
|
},
|
|
2502
|
-
lifecycle: {
|
|
3503
|
+
lifecycle: {
|
|
3504
|
+
// DISPATCHES INTO THE CALLER'S OWN COORDINATORS. These three are
|
|
3505
|
+
// declarations made true: the work is queue orchestration over
|
|
3506
|
+
// Drawbridge's own collections, which is coordinator work and stays in
|
|
3507
|
+
// the repo that owns the queues. The hook receives the dispatch table as
|
|
3508
|
+
// a prop and picks the entry, so the manifest owns the SEAM — asking
|
|
3509
|
+
// Shopify whether it handles its own lifecycle now gets a real function
|
|
3510
|
+
// instead of `unimplemented` while the work happened anyway.
|
|
3511
|
+
cleanup: async ({ context }, { dispatch } = {}) => {
|
|
3512
|
+
await dispatch({ data: context, handler: "cleanup" });
|
|
3513
|
+
return { message: "Ran shopify lifecycle.cleanup", request: context || null };
|
|
3514
|
+
},
|
|
3515
|
+
// KEEP STORE ACCESS WORKING. Not a webhook monitor, despite the name the
|
|
3516
|
+
// step once carried — webhooks are declarative, declared in the app's
|
|
3517
|
+
// toml and applied by Shopify to every install, so nothing here registers
|
|
3518
|
+
// or checks them.
|
|
3519
|
+
//
|
|
3520
|
+
// It rotates the refresh token before its window closes, proves the
|
|
3521
|
+
// access token still works, reconciles the scopes the store granted
|
|
3522
|
+
// against the ones the app now needs, and queues a webhook
|
|
3523
|
+
// reconciliation.
|
|
3524
|
+
health: async ({ connection: connection2, workflow }, { adminToken, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {}) => {
|
|
3525
|
+
const request2 = {
|
|
3526
|
+
connectionId: workflow.connection,
|
|
3527
|
+
organizationId: workflow.organization,
|
|
3528
|
+
shop: connection2.shop
|
|
3529
|
+
};
|
|
3530
|
+
const refreshTokenAtStart = (await resolveSettings()).refreshToken || null;
|
|
3531
|
+
try {
|
|
3532
|
+
const adminAccessToken = await adminToken();
|
|
3533
|
+
const settings = await resolveSettings();
|
|
3534
|
+
const refreshTokenExpiresAt = settings.refreshTokenExpiresAt;
|
|
3535
|
+
const needsRotation = refreshTokenExpiresAt && new Date(refreshTokenExpiresAt) < new Date(Date.now() + REFRESH_TOKEN_FRESHNESS_BUFFER_MS);
|
|
3536
|
+
let refreshTokenRotated = false;
|
|
3537
|
+
if (needsRotation) {
|
|
3538
|
+
await rotateToken();
|
|
3539
|
+
refreshTokenRotated = true;
|
|
3540
|
+
}
|
|
3541
|
+
await shopify.oauth.ping({ adminAccessToken, domain: connection2.shop });
|
|
3542
|
+
const scopesMissing = await reconcileScopes({ shop: connection2.shop });
|
|
3543
|
+
return {
|
|
3544
|
+
enqueues: [{
|
|
3545
|
+
data: {
|
|
3546
|
+
data: {
|
|
3547
|
+
connectionId: workflow.connection,
|
|
3548
|
+
organizationId: workflow.organization
|
|
3549
|
+
},
|
|
3550
|
+
event: "shopify.register.webhooks"
|
|
3551
|
+
},
|
|
3552
|
+
name: "register",
|
|
3553
|
+
options: { jobId: "connection.update.register." + workflow.connection + "." + (0, import_node_crypto3.randomUUID)() },
|
|
3554
|
+
queue: "connection"
|
|
3555
|
+
}],
|
|
3556
|
+
message: (scopesMissing == null ? void 0 : scopesMissing.length) ? "Health check: ping ok, webhooks reconciled \u2014 connection errored, granted scopes are missing: " + scopesMissing.join(", ") + "." : refreshTokenRotated ? "Health check passed \u2014 refresh token rotated, ping ok, webhooks reconciled." : "Health check passed \u2014 ping ok, webhooks reconciled.",
|
|
3557
|
+
request: request2,
|
|
3558
|
+
response: {
|
|
3559
|
+
pingedAt: /* @__PURE__ */ new Date(),
|
|
3560
|
+
refreshTokenExpiresAt: refreshTokenExpiresAt || null,
|
|
3561
|
+
refreshTokenRotated,
|
|
3562
|
+
scopesMissing,
|
|
3563
|
+
webhookReconciliationQueued: true
|
|
3564
|
+
}
|
|
3565
|
+
};
|
|
3566
|
+
} catch (error) {
|
|
3567
|
+
if (OAUTH_GRANT_REVOKED_CODES.includes(error.code)) {
|
|
3568
|
+
const current = await read.get({ collection: "connection", query: { id: connection2.id } });
|
|
3569
|
+
const refreshTokenStored = current ? (await resolveSettings(current)).refreshToken || null : null;
|
|
3570
|
+
const rotated = error.code === "invalid_grant" && refreshTokenStored !== refreshTokenAtStart;
|
|
3571
|
+
if (current && !rotated) {
|
|
3572
|
+
const others = (current.errors || []).filter((entry) => entry.source !== OAUTH_ERROR_SOURCE);
|
|
3573
|
+
error.writes = [{
|
|
3574
|
+
collection: "connection",
|
|
3575
|
+
data: {
|
|
3576
|
+
$set: {
|
|
3577
|
+
errors: [
|
|
3578
|
+
...others,
|
|
3579
|
+
{
|
|
3580
|
+
message: "Shopify disconnected this store. Open the Drawbridge app in your Shopify admin to reconnect.",
|
|
3581
|
+
source: OAUTH_ERROR_SOURCE
|
|
3582
|
+
}
|
|
3583
|
+
],
|
|
3584
|
+
status: "error"
|
|
3585
|
+
}
|
|
3586
|
+
},
|
|
3587
|
+
operation: "update",
|
|
3588
|
+
query: { id: connection2.id }
|
|
3589
|
+
}];
|
|
3590
|
+
}
|
|
3591
|
+
}
|
|
3592
|
+
throw error;
|
|
3593
|
+
}
|
|
3594
|
+
},
|
|
3595
|
+
register: async ({ context }, { dispatch } = {}) => {
|
|
3596
|
+
await dispatch({ data: context, handler: "register" });
|
|
3597
|
+
return { message: "Ran shopify lifecycle.register", request: context || null };
|
|
3598
|
+
},
|
|
3599
|
+
rehydrate: async ({ context }, { dispatch } = {}) => {
|
|
3600
|
+
await dispatch({ data: context, handler: "rehydrate" });
|
|
3601
|
+
return { message: "Ran shopify lifecycle.rehydrate", request: context || null };
|
|
3602
|
+
}
|
|
3603
|
+
},
|
|
2503
3604
|
resources: {
|
|
2504
3605
|
audiences: false,
|
|
2505
3606
|
// Shopify has no separate price resource — a price belongs to a product
|
|
@@ -2519,7 +3620,7 @@ var shopify_default2 = {
|
|
|
2519
3620
|
// credential is the caller's job because it is Drawbridge's job: the
|
|
2520
3621
|
// admin token refreshes and writes itself back, which is service work,
|
|
2521
3622
|
// not vendor work.
|
|
2522
|
-
products: async ({ cursor, limit = 100, search, settings,
|
|
3623
|
+
products: async ({ cursor, limit = 100, search, settings, sort }, { shopify } = {}) => {
|
|
2523
3624
|
var _a, _b, _c, _d;
|
|
2524
3625
|
const products = await shopify.storefront.getProducts({
|
|
2525
3626
|
cursor,
|
|
@@ -2539,7 +3640,7 @@ var shopify_default2 = {
|
|
|
2539
3640
|
}
|
|
2540
3641
|
};
|
|
2541
3642
|
},
|
|
2542
|
-
promotions: async ({ cursor, limit = 100, search, settings, shopify }) => {
|
|
3643
|
+
promotions: async ({ cursor, limit = 100, search, settings }, { shopify } = {}) => {
|
|
2543
3644
|
var _a, _b;
|
|
2544
3645
|
const discounts = await shopify.admin.getDiscounts({
|
|
2545
3646
|
adminAccessToken: settings == null ? void 0 : settings.adminAccessToken,
|
|
@@ -2618,9 +3719,6 @@ var shopify_default2 = {
|
|
|
2618
3719
|
// workflow document, and those strings cannot be renamed without a backfill.
|
|
2619
3720
|
//
|
|
2620
3721
|
// EVERY LEAF IS A FUNCTION so a step can read the merchant's own connection.
|
|
2621
|
-
// The bodies these point at still live in drawbridge-sync; moving them is the
|
|
2622
|
-
// next unit, and commerce.order.record is the one that decides whether the
|
|
2623
|
-
// shape holds — 569 lines and 15 controller calls.
|
|
2624
3722
|
steps: {
|
|
2625
3723
|
commerce: {
|
|
2626
3724
|
code: {
|
|
@@ -2725,8 +3823,8 @@ var shopify_default2 = {
|
|
|
2725
3823
|
title: "Shopify"
|
|
2726
3824
|
};
|
|
2727
3825
|
|
|
2728
|
-
// lib/connections/webhook.js
|
|
2729
|
-
var
|
|
3826
|
+
// lib/connections/providers/webhook.js
|
|
3827
|
+
var import_node_crypto4 = __toESM(require("crypto"), 1);
|
|
2730
3828
|
|
|
2731
3829
|
// lib/safe-http.js
|
|
2732
3830
|
var import_dns2 = __toESM(require("dns"), 1);
|
|
@@ -2863,7 +3961,7 @@ var safeRequest = async ({
|
|
|
2863
3961
|
}
|
|
2864
3962
|
};
|
|
2865
3963
|
|
|
2866
|
-
// lib/connections/webhook.js
|
|
3964
|
+
// lib/connections/providers/webhook.js
|
|
2867
3965
|
var webhook_default = {
|
|
2868
3966
|
// Connecting GENERATES the secret rather than storing one the merchant typed,
|
|
2869
3967
|
// so the buttons say what actually happens.
|
|
@@ -2950,16 +4048,15 @@ var webhook_default = {
|
|
|
2950
4048
|
// That is the rule the whole split runs on: a hook lives in sync only if it
|
|
2951
4049
|
// needs Drawbridge's own database, sockets or queues. This one does not.
|
|
2952
4050
|
webhook: {
|
|
2953
|
-
send: async ({ context,
|
|
4051
|
+
send: async ({ context, lead, settings, step }, { request: send2 = safeRequest } = {}) => {
|
|
2954
4052
|
const { headers = {}, method = "POST", url } = step.settings || {};
|
|
2955
4053
|
const request2 = { method, url: url || null };
|
|
2956
4054
|
if (!url) return { message: "Outgoing webhook URL is not configured for this step.", request: request2, response: { skipped: true }, skipped: true };
|
|
2957
|
-
const lead = (context == null ? void 0 : context.lead) ? await controller.get({ collection: "lead", query: { id: context.lead } }) : null;
|
|
2958
4055
|
const body = lead || context;
|
|
2959
4056
|
request2.body = body;
|
|
2960
4057
|
const outgoing = { ...headers };
|
|
2961
4058
|
if (settings == null ? void 0 : settings.secret) {
|
|
2962
|
-
outgoing["X-Drawbridge-Signature"] = "sha256=" +
|
|
4059
|
+
outgoing["X-Drawbridge-Signature"] = "sha256=" + import_node_crypto4.default.createHmac("sha256", settings.secret).update(JSON.stringify(body)).digest("hex");
|
|
2963
4060
|
}
|
|
2964
4061
|
const response = await send2({ body, headers: outgoing, method, url });
|
|
2965
4062
|
return { message: "Webhook POSTed to " + url + ".", request: request2, response: response || { delivered: true } };
|
|
@@ -3225,10 +4322,13 @@ var connectFields = (slug) => {
|
|
|
3225
4322
|
secret: Boolean(redact)
|
|
3226
4323
|
}));
|
|
3227
4324
|
};
|
|
3228
|
-
var runHook = async (slug, name,
|
|
4325
|
+
var runHook = async (slug, name, props = {}, options = {}) => {
|
|
3229
4326
|
const manifest = connections[slug];
|
|
3230
4327
|
if (!manifest) return { outcome: OUTCOMES.unsupported, reason: "no such connection: " + slug };
|
|
3231
|
-
const hook = name.split(".").reduce(
|
|
4328
|
+
const hook = name.split(".").reduce(
|
|
4329
|
+
(node, key) => node && typeof node === "object" && Object.hasOwn(node, key) ? node[key] : void 0,
|
|
4330
|
+
manifest.hooks
|
|
4331
|
+
);
|
|
3232
4332
|
if (hook === false || hook == null) {
|
|
3233
4333
|
return { outcome: OUTCOMES.unsupported, reason: slug + " does not implement " + name };
|
|
3234
4334
|
}
|
|
@@ -3236,7 +4336,9 @@ var runHook = async (slug, name, args = {}) => {
|
|
|
3236
4336
|
return { outcome: OUTCOMES.unimplemented, reason: slug + " implements " + name + " outside this package" };
|
|
3237
4337
|
}
|
|
3238
4338
|
try {
|
|
3239
|
-
|
|
4339
|
+
const result = await hook({ ...props, manifest }, options);
|
|
4340
|
+
effectsOf(result);
|
|
4341
|
+
return { outcome: OUTCOMES.answered, result };
|
|
3240
4342
|
} catch (error) {
|
|
3241
4343
|
return { error: (error == null ? void 0 : error.message) || "failed", outcome: OUTCOMES.failed, status: (error == null ? void 0 : error.status) || null };
|
|
3242
4344
|
}
|
|
@@ -3331,7 +4433,10 @@ var resolveConnection = (item, data2, env = {}) => {
|
|
|
3331
4433
|
AUTH_TYPES,
|
|
3332
4434
|
GROUPS,
|
|
3333
4435
|
HOOKS,
|
|
4436
|
+
HOOK_EFFECTS,
|
|
3334
4437
|
HOOK_NAMES,
|
|
4438
|
+
HOOK_OPTIONS,
|
|
4439
|
+
HOOK_PROPS,
|
|
3335
4440
|
INPUTS,
|
|
3336
4441
|
OAUTH_FIELDS,
|
|
3337
4442
|
OUTCOMES,
|
|
@@ -3339,6 +4444,7 @@ var resolveConnection = (item, data2, env = {}) => {
|
|
|
3339
4444
|
STATUSES,
|
|
3340
4445
|
STEPS,
|
|
3341
4446
|
STEP_TYPES,
|
|
4447
|
+
WRITE_OPERATIONS,
|
|
3342
4448
|
accessToken,
|
|
3343
4449
|
authToken,
|
|
3344
4450
|
availableConnections,
|
|
@@ -3348,6 +4454,7 @@ var resolveConnection = (item, data2, env = {}) => {
|
|
|
3348
4454
|
connectionSteps,
|
|
3349
4455
|
connections,
|
|
3350
4456
|
consentUrl,
|
|
4457
|
+
effectsOf,
|
|
3351
4458
|
hookSupport,
|
|
3352
4459
|
isStale,
|
|
3353
4460
|
mergeSettings,
|