@drawbridge/drawbridge-utils 0.0.118 → 0.0.124
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 +1610 -183
- package/dist/connections/index.d.cts +2480 -275
- package/dist/connections/index.d.ts +2480 -275
- package/dist/connections/index.js +1601 -179
- package/dist/phone.d.cts +1 -1
- package/dist/phone.d.ts +1 -1
- package/dist/providers.cjs +1587 -240
- package/dist/providers.d.cts +117 -128
- package/dist/providers.d.ts +117 -128
- package/dist/providers.js +1581 -234
- 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
package/dist/providers.cjs
CHANGED
|
@@ -29,13 +29,13 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
29
29
|
// lib/providers.js
|
|
30
30
|
var providers_exports = {};
|
|
31
31
|
__export(providers_exports, {
|
|
32
|
-
|
|
33
|
-
hydrateEnvironment: () => hydrateEnvironment,
|
|
32
|
+
clearProviderMemo: () => clearProviderMemo,
|
|
34
33
|
isLive: () => isLive,
|
|
35
34
|
mask: () => mask,
|
|
36
35
|
providerCredentials: () => providerCredentials,
|
|
37
36
|
providerEnvNames: () => providerEnvNames,
|
|
38
37
|
providerFields: () => providerFields,
|
|
38
|
+
providerMemo: () => providerMemo,
|
|
39
39
|
providerSettings: () => providerSettings,
|
|
40
40
|
providerSlugs: () => providerSlugs,
|
|
41
41
|
saveProviderSettings: () => saveProviderSettings
|
|
@@ -220,6 +220,53 @@ var HOOKS = Object.freeze({
|
|
|
220
220
|
"promotions"
|
|
221
221
|
])
|
|
222
222
|
});
|
|
223
|
+
var HOOK_EFFECTS = Object.freeze(["enqueues", "events", "writes"]);
|
|
224
|
+
var WRITE_OPERATIONS = Object.freeze(["create", "update"]);
|
|
225
|
+
var HOOK_PROPS = Object.freeze([
|
|
226
|
+
"channel",
|
|
227
|
+
"clientId",
|
|
228
|
+
"clientSecret",
|
|
229
|
+
"connection",
|
|
230
|
+
"contact",
|
|
231
|
+
"context",
|
|
232
|
+
"cursor",
|
|
233
|
+
"declaration",
|
|
234
|
+
"doc",
|
|
235
|
+
"email",
|
|
236
|
+
"event",
|
|
237
|
+
"headers",
|
|
238
|
+
"id",
|
|
239
|
+
"lead",
|
|
240
|
+
"limit",
|
|
241
|
+
"manifest",
|
|
242
|
+
"payload",
|
|
243
|
+
"scope",
|
|
244
|
+
"search",
|
|
245
|
+
"secret",
|
|
246
|
+
"settings",
|
|
247
|
+
"sort",
|
|
248
|
+
"step",
|
|
249
|
+
"suppressed",
|
|
250
|
+
"token",
|
|
251
|
+
"tokens",
|
|
252
|
+
"workflow"
|
|
253
|
+
]);
|
|
254
|
+
var HOOK_OPTIONS = Object.freeze([
|
|
255
|
+
"adminToken",
|
|
256
|
+
"canSend",
|
|
257
|
+
"chunkSize",
|
|
258
|
+
"dispatch",
|
|
259
|
+
"fetcher",
|
|
260
|
+
"logger",
|
|
261
|
+
"mintId",
|
|
262
|
+
"read",
|
|
263
|
+
"reconcileScopes",
|
|
264
|
+
"request",
|
|
265
|
+
"resolveContact",
|
|
266
|
+
"resolveSettings",
|
|
267
|
+
"rotateToken",
|
|
268
|
+
"shopify"
|
|
269
|
+
]);
|
|
223
270
|
var STEPS = Object.freeze({
|
|
224
271
|
"commerce.code.issue": "Issue discount code",
|
|
225
272
|
"commerce.customer.insert": "Create customer",
|
|
@@ -401,13 +448,56 @@ var attentive_default = `<svg width="500" height="500" viewBox="0 0 500 500" fil
|
|
|
401
448
|
<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"/>
|
|
402
449
|
</svg>`;
|
|
403
450
|
|
|
404
|
-
// lib/
|
|
451
|
+
// lib/phone.js
|
|
452
|
+
var import_libphonenumber_js = require("libphonenumber-js");
|
|
453
|
+
var toE164 = (value, country) => {
|
|
454
|
+
if (!value) return null;
|
|
455
|
+
try {
|
|
456
|
+
const parsed = (0, import_libphonenumber_js.parsePhoneNumberFromString)(String(value), country);
|
|
457
|
+
return parsed ? parsed.number : null;
|
|
458
|
+
} catch {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
var detectCountry = (value) => {
|
|
463
|
+
if (!value) return null;
|
|
464
|
+
try {
|
|
465
|
+
const parser = new import_libphonenumber_js.AsYouType();
|
|
466
|
+
parser.input(String(value));
|
|
467
|
+
return parser.getCountry() || null;
|
|
468
|
+
} catch {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
// lib/connections/providers/attentive.js
|
|
474
|
+
var api = async (path, { fetcher = fetch, method = "GET", payload, token }) => {
|
|
475
|
+
const response = await fetcher("https://api.attentivemobile.com" + path, {
|
|
476
|
+
...payload && { body: JSON.stringify(payload) },
|
|
477
|
+
headers: {
|
|
478
|
+
authorization: "Bearer " + token,
|
|
479
|
+
...payload && { "content-type": "application/json" }
|
|
480
|
+
},
|
|
481
|
+
method,
|
|
482
|
+
signal: AbortSignal.timeout(15e3)
|
|
483
|
+
});
|
|
484
|
+
if (!response.ok) {
|
|
485
|
+
throw Object.assign(
|
|
486
|
+
new Error("Attentive refused the request (" + response.status + ")"),
|
|
487
|
+
{ status: response.status }
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
return response.json().catch(() => null);
|
|
491
|
+
};
|
|
405
492
|
var attentive_default2 = {
|
|
406
493
|
auth: {
|
|
407
494
|
oauth: {
|
|
408
|
-
// NAMES of the
|
|
409
|
-
//
|
|
410
|
-
//
|
|
495
|
+
// NAMES of the credentials holding OUR app's client — keys into the map
|
|
496
|
+
// the provider collection answers, entered on the admin screen at
|
|
497
|
+
// registration, never before. (The names are the env vars they once
|
|
498
|
+
// were; the vocabulary stayed when the storage moved.) No `headers` on
|
|
499
|
+
// the client: Attentive takes credentials as form fields, which is the
|
|
500
|
+
// runner's default.
|
|
411
501
|
client: {
|
|
412
502
|
id: "ATTENTIVE_OAUTH_CLIENT_ID",
|
|
413
503
|
secret: "ATTENTIVE_OAUTH_CLIENT_SECRET"
|
|
@@ -432,9 +522,10 @@ var attentive_default2 = {
|
|
|
432
522
|
// has to say so rather than let them believe otherwise.
|
|
433
523
|
confirm: "Disconnecting removes Drawbridge's stored Attentive token. Attentive does not offer a way for us to revoke it, so remove the Drawbridge integration in Attentive as well if you want its access fully withdrawn. Your subscribers stay in both Attentive and Drawbridge \u2014 neither list is deleted.",
|
|
434
524
|
description: [
|
|
435
|
-
"Attentive is where your SMS marketing lives, and this connection
|
|
525
|
+
"Attentive is where your SMS marketing lives, and this connection syncs the contacts your campaigns collect into an Attentive segment \u2014 subscribed for marketing and added to the segment you choose.",
|
|
436
526
|
"You authorize Drawbridge from inside Attentive and can revoke that access there at any time. Drawbridge never sees or stores your Attentive password.",
|
|
437
|
-
"
|
|
527
|
+
"Anyone who has opted out in Drawbridge is sent to Attentive as an unsubscribe rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing.",
|
|
528
|
+
"Attentive accepts these updates and applies them in the background, so a contact appears in your segment shortly after the sync rather than the instant it runs."
|
|
438
529
|
],
|
|
439
530
|
excerpt: "Sync your Drawbridge contacts into an Attentive segment.",
|
|
440
531
|
guide: [
|
|
@@ -455,6 +546,10 @@ var attentive_default2 = {
|
|
|
455
546
|
message: "Contacts your campaigns collect are synced into this segment.",
|
|
456
547
|
hook: "resources.audiences",
|
|
457
548
|
required: true
|
|
549
|
+
// CONSUMED BY THE MEMBERSHIP CALL, not by the subscribe. Attentive's
|
|
550
|
+
// /v1/subscriptions takes no segment id — subscription and segment
|
|
551
|
+
// membership are two operations here — so contacts.sync makes both calls
|
|
552
|
+
// and this value is the externalId the second one carries.
|
|
458
553
|
// No `search : false` here, and that is a first: /v2/segments takes a
|
|
459
554
|
// `name` filter (partial match, cited above), so this picker searches
|
|
460
555
|
// the ACCOUNT — Klaviyo and Mailchimp can only match the fetched page.
|
|
@@ -467,17 +562,25 @@ var attentive_default2 = {
|
|
|
467
562
|
// and contacts.sync are the first to flip.
|
|
468
563
|
hooks: {
|
|
469
564
|
auth: {
|
|
470
|
-
// The exchange already yields the tokens, and Attentive documents no
|
|
471
|
-
// account-identity endpoint to enrich them with — Klaviyo's connect
|
|
472
|
-
// reads the account name back; this has nothing cited to read. The
|
|
473
|
-
// callback stores the tokens and skips enrichment on `unimplemented`.
|
|
474
565
|
// FALSE, NOT {}. `{}` means "supported, implemented in the repo with the
|
|
475
|
-
// dependencies", and nothing anywhere implements either of these —
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
//
|
|
566
|
+
// dependencies", and nothing anywhere implements either of these — there
|
|
567
|
+
// is nothing for them to do. They document no revocation endpoint at all,
|
|
568
|
+
// so disconnect has nothing to call. Recorded as a decision rather than
|
|
569
|
+
// left as an unkept promise.
|
|
570
|
+
//
|
|
571
|
+
// STILL FALSE AFTER LOOKING AGAIN, and this is the reason written down so
|
|
572
|
+
// nobody re-derives it. Klaviyo's connect reads the account name back so
|
|
573
|
+
// the card is not blank; Attentive's card stays blank. There IS an
|
|
574
|
+
// endpoint — GET https://api.attentivemobile.com/v1/me, Bearer, described
|
|
575
|
+
// on docs.attentive.com/pages/authentication/ as returning "information
|
|
576
|
+
// specific to your company" — but its RESPONSE SCHEMA is published
|
|
577
|
+
// nowhere we can read: the docs show the curl and no body. Reading
|
|
578
|
+
// `body.name` would be a guess, and a guess here fails at the worst
|
|
579
|
+
// moment, in the callback, after the merchant has already consented.
|
|
580
|
+
//
|
|
581
|
+
// A live token settles it in one call, alongside the three registration
|
|
582
|
+
// checks in the header. Until then the honest state is a blank field, not
|
|
583
|
+
// a hopeful one.
|
|
481
584
|
connect: false,
|
|
482
585
|
disconnect: false,
|
|
483
586
|
probe: false,
|
|
@@ -499,7 +602,112 @@ var attentive_default2 = {
|
|
|
499
602
|
}
|
|
500
603
|
},
|
|
501
604
|
commerce: false,
|
|
502
|
-
|
|
605
|
+
// The verb the contacts.sync step points at.
|
|
606
|
+
contacts: {
|
|
607
|
+
// Not yet. Suppression syncs an opt-out as unsubscribed, which is a
|
|
608
|
+
// different thing from erasing the subscriber — Attentive's deletion sits
|
|
609
|
+
// behind their privacy-request API, which is a different grant.
|
|
610
|
+
remove: false,
|
|
611
|
+
// TWO CALLS, BECAUSE ATTENTIVE HAS TWO IDEAS.
|
|
612
|
+
//
|
|
613
|
+
// Subscribing and being in a segment are NOT the same operation here —
|
|
614
|
+
// unlike Klaviyo, where a subscription is created against the list itself.
|
|
615
|
+
// /v1/subscriptions takes no segment id at all, so the segment a merchant
|
|
616
|
+
// picked on this connection can only be honoured by the bulk segment
|
|
617
|
+
// membership API:
|
|
618
|
+
//
|
|
619
|
+
// subscribe POST /v1/subscriptions
|
|
620
|
+
// { user : { email, phone }, locale, subscriptionType } — the
|
|
621
|
+
// docs require EITHER signUpSourceId OR (locale +
|
|
622
|
+
// subscriptionType), and we hold no sign-up source. 202.
|
|
623
|
+
//
|
|
624
|
+
// membership POST /v2/bulk/segments/members
|
|
625
|
+
// { externalId, members : [ { email, phone } ] }, 1-10,000
|
|
626
|
+
// members, 202 with a batchJobId
|
|
627
|
+
// (docs.attentive.com/reference/postbulksegmentmembers).
|
|
628
|
+
//
|
|
629
|
+
// unsubscribe POST /v1/subscriptions/unsubscribe
|
|
630
|
+
// { user, subscriptions : [ { type, channel } ] }. 202.
|
|
631
|
+
//
|
|
632
|
+
// EVERY ONE OF THEM ANSWERS 202 ACCEPTED, which means Attentive took the
|
|
633
|
+
// job, not that it ran — the same distinction the Shopify usage charge
|
|
634
|
+
// makes between a 202 and a charge. The message below says accepted, and
|
|
635
|
+
// must keep saying accepted.
|
|
636
|
+
sync: async ({ lead, settings, suppressed, token }, { fetcher } = {}) => {
|
|
637
|
+
var _a, _b, _c, _d;
|
|
638
|
+
const segment = settings == null ? void 0 : settings.segment;
|
|
639
|
+
if (!segment) return { message: "No Attentive segment is chosen for this connection.", skipped: true };
|
|
640
|
+
const email = ((_b = (_a = lead == null ? void 0 : lead.canonical) == null ? void 0 : _a.email) == null ? void 0 : _b.value) || (lead == null ? void 0 : lead.email);
|
|
641
|
+
const phone = toE164(((_d = (_c = lead == null ? void 0 : lead.canonical) == null ? void 0 : _c.phone) == null ? void 0 : _d.value) || (lead == null ? void 0 : lead.phone));
|
|
642
|
+
if (!email && !phone) return { message: "That lead has no email address or phone number to sync.", skipped: true };
|
|
643
|
+
const user = {
|
|
644
|
+
...email && { email },
|
|
645
|
+
...phone && { phone }
|
|
646
|
+
};
|
|
647
|
+
if (suppressed) {
|
|
648
|
+
await api("/v1/subscriptions/unsubscribe", {
|
|
649
|
+
fetcher,
|
|
650
|
+
method: "POST",
|
|
651
|
+
payload: {
|
|
652
|
+
// One entry per channel we can actually name them by. MARKETING
|
|
653
|
+
// is the only type Drawbridge ever subscribed them to.
|
|
654
|
+
subscriptions: [
|
|
655
|
+
...phone ? [{ channel: "TEXT", type: "MARKETING" }] : [],
|
|
656
|
+
...email ? [{ channel: "EMAIL", type: "MARKETING" }] : []
|
|
657
|
+
],
|
|
658
|
+
user
|
|
659
|
+
},
|
|
660
|
+
token
|
|
661
|
+
});
|
|
662
|
+
return {
|
|
663
|
+
message: "Attentive accepted an unsubscribe for this contact \u2014 they have opted out.",
|
|
664
|
+
response: { accepted: true, unsubscribed: true }
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
await api("/v1/subscriptions", {
|
|
668
|
+
fetcher,
|
|
669
|
+
method: "POST",
|
|
670
|
+
payload: {
|
|
671
|
+
// LOCALE, because we hold no signUpSourceId and the docs require
|
|
672
|
+
// one or the other. The country is READ OFF the number when there
|
|
673
|
+
// is one — libphonenumber knows it from the calling code — rather
|
|
674
|
+
// than assumed; only the fallback pair below is a default, and it
|
|
675
|
+
// is the one value here that no vendor document dictates.
|
|
676
|
+
//
|
|
677
|
+
// ponytail: en/US default. A `signUpSourceId` field on the
|
|
678
|
+
// connection is the upgrade — Attentive's sign-up sources carry
|
|
679
|
+
// the consent language, which is a better answer than any locale
|
|
680
|
+
// we can infer — and it replaces this branch entirely.
|
|
681
|
+
locale: {
|
|
682
|
+
country: phone && detectCountry(phone) || "US",
|
|
683
|
+
language: "en"
|
|
684
|
+
},
|
|
685
|
+
subscriptionType: "MARKETING",
|
|
686
|
+
user
|
|
687
|
+
},
|
|
688
|
+
token
|
|
689
|
+
});
|
|
690
|
+
const membership = await api("/v2/bulk/segments/members", {
|
|
691
|
+
fetcher,
|
|
692
|
+
method: "POST",
|
|
693
|
+
payload: {
|
|
694
|
+
externalId: segment,
|
|
695
|
+
members: [user]
|
|
696
|
+
},
|
|
697
|
+
token
|
|
698
|
+
});
|
|
699
|
+
return {
|
|
700
|
+
// ACCEPTED, NOT LIVE. Both writes answered 202, which means Attentive
|
|
701
|
+
// queued them — a merchant who reads "synced" and looks for the person
|
|
702
|
+
// in Attentive a second later has been told the wrong thing.
|
|
703
|
+
message: "Attentive accepted this contact for the segment. Attentive processes these asynchronously, so it appears there shortly.",
|
|
704
|
+
response: {
|
|
705
|
+
accepted: true,
|
|
706
|
+
...(membership == null ? void 0 : membership.batchJobId) && { batchJobId: membership.batchJobId }
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
},
|
|
503
711
|
email: false,
|
|
504
712
|
inbound: false,
|
|
505
713
|
lifecycle: false,
|
|
@@ -512,26 +720,13 @@ var attentive_default2 = {
|
|
|
512
720
|
// show a picker quietly missing most of a real account. The response's
|
|
513
721
|
// only identifier is `externalId`, so an entry without one cannot be
|
|
514
722
|
// stored and is dropped.
|
|
515
|
-
audiences: async ({ cursor,
|
|
723
|
+
audiences: async ({ cursor, limit = 100, search, token }, { fetcher } = {}) => {
|
|
516
724
|
const query = new URLSearchParams({
|
|
517
725
|
limit: String(Math.min(limit, 1e3)),
|
|
518
726
|
...cursor && { cursor },
|
|
519
727
|
...(search == null ? void 0 : search.value) && { name: String(search.value).trim() }
|
|
520
728
|
});
|
|
521
|
-
const
|
|
522
|
-
"https://api.attentivemobile.com/v2/segments?" + query,
|
|
523
|
-
{
|
|
524
|
-
headers: { authorization: "Bearer " + token },
|
|
525
|
-
signal: AbortSignal.timeout(15e3)
|
|
526
|
-
}
|
|
527
|
-
);
|
|
528
|
-
if (!response.ok) {
|
|
529
|
-
throw Object.assign(
|
|
530
|
-
new Error("Attentive refused the request (" + response.status + ")"),
|
|
531
|
-
{ status: response.status }
|
|
532
|
-
);
|
|
533
|
-
}
|
|
534
|
-
const body = await response.json();
|
|
729
|
+
const body = await api("/v2/segments?" + query, { fetcher, token });
|
|
535
730
|
return {
|
|
536
731
|
items: ((body == null ? void 0 : body.segments) || []).filter((segment) => segment == null ? void 0 : segment.externalId).map((segment) => ({ id: segment.externalId, title: (segment == null ? void 0 : segment.name) || segment.externalId })),
|
|
537
732
|
pageInfo: {
|
|
@@ -549,33 +744,67 @@ var attentive_default2 = {
|
|
|
549
744
|
webhook: false
|
|
550
745
|
},
|
|
551
746
|
icon: attentive_default,
|
|
747
|
+
// DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
|
|
748
|
+
// what an admin types on the provider screen, and the only declaration of it.
|
|
749
|
+
// It lives beside `requires`, which names the same variables: the manifest
|
|
750
|
+
// says what it needs and this says how someone supplies it, so a credential
|
|
751
|
+
// cannot be required by a vendor that offers nowhere to enter it.
|
|
752
|
+
//
|
|
753
|
+
// `redact` marks a secret — never returned by the api, and blank on save means
|
|
754
|
+
// keep the stored value. `required` drives the live check.
|
|
755
|
+
provider: {
|
|
756
|
+
fields: [
|
|
757
|
+
{ input: "text", key: "clientId", credential: "ATTENTIVE_OAUTH_CLIENT_ID", label: "Client ID", required: true },
|
|
758
|
+
{ input: "password", key: "clientSecret", credential: "ATTENTIVE_OAUTH_CLIENT_SECRET", label: "Client secret", redact: true, required: true }
|
|
759
|
+
]
|
|
760
|
+
},
|
|
552
761
|
requires: [
|
|
553
762
|
"ATTENTIVE_OAUTH_CLIENT_ID",
|
|
554
763
|
"ATTENTIVE_OAUTH_CLIENT_SECRET"
|
|
555
764
|
],
|
|
556
765
|
slug: "attentive",
|
|
557
|
-
// A consent with no segment chosen is authenticated and inert — the sync
|
|
558
|
-
//
|
|
766
|
+
// A consent with no segment chosen is authenticated and inert — the sync needs
|
|
767
|
+
// somewhere to put people — so the card says Pending rather than Active over
|
|
768
|
+
// nothing.
|
|
559
769
|
status: (data2) => {
|
|
560
770
|
var _a;
|
|
561
771
|
return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.segment) ? data2.status : "pending";
|
|
562
772
|
},
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
773
|
+
steps: {
|
|
774
|
+
contacts: {
|
|
775
|
+
// A DECLARATION, not the work. The nesting IS the name: this is
|
|
776
|
+
// `step.contacts.sync`, the string a workflow document stores. Klaviyo and
|
|
777
|
+
// Mailchimp declare the same type — a step belongs to the capability, not
|
|
778
|
+
// to whoever implements it — and the connection on the step document is
|
|
779
|
+
// what says which vendor runs.
|
|
780
|
+
sync: ({ data: data2 }) => ({
|
|
781
|
+
hook: "contacts.sync",
|
|
782
|
+
// NO ACCOUNT NAME TO INTERPOLATE, unlike Klaviyo: auth.connect is false
|
|
783
|
+
// because Attentive publishes no account-identity response we can read
|
|
784
|
+
// (see its comment), and settings.segment is an opaque externalId no
|
|
785
|
+
// merchant would recognise in a builder label.
|
|
786
|
+
key: "Sync contact to Attentive",
|
|
787
|
+
queue: "connection",
|
|
788
|
+
// Nothing for a merchant to configure on the step itself — the segment
|
|
789
|
+
// is chosen once on the connection. Declared empty rather than omitted,
|
|
790
|
+
// so "this step takes no settings" and "nobody thought about settings"
|
|
791
|
+
// stay different statements.
|
|
792
|
+
settings: {},
|
|
793
|
+
// BOTH triggers, for the same reason as Klaviyo: lead.insert alone only
|
|
794
|
+
// ever fires for someone with no history yet, and crossing into a
|
|
795
|
+
// segment is the other moment a contact is worth pushing.
|
|
796
|
+
triggers: ["lead.insert", "segment.contact.add"],
|
|
797
|
+
usage: { actions: 1 }
|
|
798
|
+
})
|
|
799
|
+
}
|
|
800
|
+
},
|
|
801
|
+
// WHY, in the merchant's words, and what to do about it.
|
|
566
802
|
tasks: (data2) => {
|
|
567
803
|
var _a;
|
|
568
|
-
return [
|
|
569
|
-
...((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.segment) ? [] : [
|
|
570
|
-
{
|
|
571
|
-
message: "Choose which Attentive segment your contacts should sync into. Until you do, nothing is being synced.",
|
|
572
|
-
title: "Choose a segment"
|
|
573
|
-
}
|
|
574
|
-
],
|
|
804
|
+
return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.segment) ? [] : [
|
|
575
805
|
{
|
|
576
|
-
message: "
|
|
577
|
-
title: "
|
|
578
|
-
type: "warning"
|
|
806
|
+
message: "Choose which Attentive segment your contacts should sync into. Until you do, nothing is being synced.",
|
|
807
|
+
title: "Choose a segment"
|
|
579
808
|
}
|
|
580
809
|
];
|
|
581
810
|
},
|
|
@@ -628,10 +857,11 @@ var request = async ({
|
|
|
628
857
|
// lib/hubspot.js
|
|
629
858
|
var HUBSPOT_BASE = "https://api.hubapi.com";
|
|
630
859
|
var hubspotRequest = ({ body, fetcher, method, path, query, token }) => {
|
|
860
|
+
if (!token) throw new Error("HubSpot access token missing \u2014 pass token (the drawbridge provider's hubspotToken)");
|
|
631
861
|
return (fetcher || request)({
|
|
632
862
|
body,
|
|
633
863
|
headers: {
|
|
634
|
-
"Authorization": "Bearer " +
|
|
864
|
+
"Authorization": "Bearer " + token
|
|
635
865
|
},
|
|
636
866
|
method,
|
|
637
867
|
query,
|
|
@@ -733,16 +963,15 @@ var contacts = {
|
|
|
733
963
|
// FORGET A CONTACT, by id or by email. Account deletion — the caller had
|
|
734
964
|
// to search then remove, which is one round trip it should not have to
|
|
735
965
|
// know about.
|
|
736
|
-
remove: async ({ email,
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
const contact = id || await lookup({ email, fetcher, token: key });
|
|
966
|
+
remove: async ({ email, id, token }, { fetcher } = {}) => {
|
|
967
|
+
if (!token) return;
|
|
968
|
+
const contact = id || await lookup({ email, fetcher, token });
|
|
740
969
|
if (!contact) return;
|
|
741
970
|
return hubspotRequest({
|
|
742
971
|
fetcher,
|
|
743
972
|
method: "DELETE",
|
|
744
973
|
path: "/crm/v3/objects/contacts/" + contact,
|
|
745
|
-
token
|
|
974
|
+
token
|
|
746
975
|
});
|
|
747
976
|
},
|
|
748
977
|
// Connect an account to its contact by email, creating it if absent, and
|
|
@@ -751,24 +980,23 @@ var contacts = {
|
|
|
751
980
|
// no delete-old-then-create-new.
|
|
752
981
|
//
|
|
753
982
|
// Prefer the cached hubspotId; fall back to a search; create last.
|
|
754
|
-
sync: async ({ doc,
|
|
983
|
+
sync: async ({ doc, token }, { fetcher } = {}) => {
|
|
755
984
|
var _a, _b;
|
|
756
|
-
|
|
757
|
-
if (!key) return;
|
|
985
|
+
if (!token) return;
|
|
758
986
|
if (doc == null ? void 0 : doc.hubspotId) {
|
|
759
987
|
try {
|
|
760
|
-
return (_a = await send({ doc, fetcher, method: "PATCH", path: "/crm/v3/objects/contacts/" + doc.hubspotId, token
|
|
988
|
+
return (_a = await send({ doc, fetcher, method: "PATCH", path: "/crm/v3/objects/contacts/" + doc.hubspotId, token })) == null ? void 0 : _a.id;
|
|
761
989
|
} catch (error) {
|
|
762
990
|
if ((error == null ? void 0 : error.status) !== 404) throw error;
|
|
763
991
|
}
|
|
764
992
|
}
|
|
765
|
-
const existing = await lookup({ email: doc == null ? void 0 : doc.email, fetcher, token
|
|
993
|
+
const existing = await lookup({ email: doc == null ? void 0 : doc.email, fetcher, token });
|
|
766
994
|
return (_b = await send({
|
|
767
995
|
doc,
|
|
768
996
|
fetcher,
|
|
769
997
|
method: existing ? "PATCH" : "POST",
|
|
770
998
|
path: existing ? "/crm/v3/objects/contacts/" + existing : "/crm/v3/objects/contacts",
|
|
771
|
-
token
|
|
999
|
+
token
|
|
772
1000
|
})) == null ? void 0 : _b.id;
|
|
773
1001
|
}
|
|
774
1002
|
};
|
|
@@ -778,7 +1006,7 @@ var drawbridge_default = `<svg width="500" height="500" viewBox="0 0 500 500" fi
|
|
|
778
1006
|
<rect width="500" height="500" fill="#BAEC5F"/>
|
|
779
1007
|
<g clip-path="url(#clip0_2115_2832)">
|
|
780
1008
|
<path d="M140.224 127.586L174.803 188.73V311.176L140 372.32L176.084 392.031L216.111 321.753V178.278L176.341 108L140.224 127.586Z" fill="#0D1314"/>
|
|
781
|
-
<path d="M360.
|
|
1009
|
+
<path d="M360.002 127.523L323.694 108.282L284.949 178.498V321.596L322.924 391.749L359.394 372.79L326.225 311.52V188.73L360.002 127.523Z" fill="#0D1314"/>
|
|
782
1010
|
</g>
|
|
783
1011
|
<defs>
|
|
784
1012
|
<clipPath id="clip0_2115_2832">
|
|
@@ -1224,6 +1452,36 @@ var plans = {
|
|
|
1224
1452
|
conversion: 0.5
|
|
1225
1453
|
}
|
|
1226
1454
|
};
|
|
1455
|
+
var resolvePlan = (subscription) => {
|
|
1456
|
+
var _a, _b;
|
|
1457
|
+
const custom = subscription == null ? void 0 : subscription.custom;
|
|
1458
|
+
if (!custom) return plans[subscription == null ? void 0 : subscription.plan] ?? free;
|
|
1459
|
+
return {
|
|
1460
|
+
// Reusing all.features / all.limits is what keeps a custom plan the same
|
|
1461
|
+
// SHAPE as a catalog one: the base feature grants every plan carries, the
|
|
1462
|
+
// campaign limits that are always infinite, and members defaulting to
|
|
1463
|
+
// infinite when a deal does not name it.
|
|
1464
|
+
conversion: custom.conversion ?? free.conversion,
|
|
1465
|
+
custom: true,
|
|
1466
|
+
// A custom plan is a negotiated PAID deal, so it carries the paid-tier
|
|
1467
|
+
// baseline whether or not the deal thought to name it. Today that is the
|
|
1468
|
+
// sending domain: every catalog paid tier grants it, and a custom plan
|
|
1469
|
+
// silently lacking it would be a support ticket, not a pricing decision.
|
|
1470
|
+
features: all.features([organization.networking.key, ...((_a = custom.features) == null ? void 0 : _a.granted) || []]),
|
|
1471
|
+
limits: all.limits(((_b = custom.limits) == null ? void 0 : _b.organization) || {}),
|
|
1472
|
+
// A custom plan stores its overage BARE on `custom.overages` — a different
|
|
1473
|
+
// shape from the catalog's nested one. Number() so a deal stored as a string
|
|
1474
|
+
// still resolves to cents-per-action; an unnamed overage stays undefined
|
|
1475
|
+
// (it bills nothing) rather than becoming NaN.
|
|
1476
|
+
actionCents: custom.overages == null ? void 0 : Number(custom.overages),
|
|
1477
|
+
overages: { actions: custom.overages },
|
|
1478
|
+
title: custom.title || "Custom"
|
|
1479
|
+
};
|
|
1480
|
+
};
|
|
1481
|
+
var conversionRate = (subscription) => {
|
|
1482
|
+
var _a;
|
|
1483
|
+
return ((_a = resolvePlan(subscription)) == null ? void 0 : _a.conversion) ?? free.conversion;
|
|
1484
|
+
};
|
|
1227
1485
|
|
|
1228
1486
|
// lib/transactions.js
|
|
1229
1487
|
var import_drawbridge_telemetry = require("@drawbridge/drawbridge-telemetry");
|
|
@@ -1378,7 +1636,36 @@ var channels = {
|
|
|
1378
1636
|
}
|
|
1379
1637
|
};
|
|
1380
1638
|
|
|
1381
|
-
// lib/connections/drawbridge.js
|
|
1639
|
+
// lib/connections/providers/drawbridge.js
|
|
1640
|
+
var interpolate = (template, data2) => {
|
|
1641
|
+
if (!template) return template;
|
|
1642
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => (data2 == null ? void 0 : data2[key]) != null ? String(data2[key]) : "{{" + key + "}}");
|
|
1643
|
+
};
|
|
1644
|
+
var teamRecipients = async ({ memberIds = [], organization: organization2, read }) => {
|
|
1645
|
+
const org = await read.get({ collection: "organization", query: { id: organization2 } });
|
|
1646
|
+
const owner = (org == null ? void 0 : org.owner) ? await read.get({ collection: "user", query: { id: org.owner } }) : null;
|
|
1647
|
+
const members = memberIds.length ? await read.aggregate({
|
|
1648
|
+
collection: "member",
|
|
1649
|
+
pipeline: [
|
|
1650
|
+
{
|
|
1651
|
+
$match: {
|
|
1652
|
+
id: { $in: memberIds },
|
|
1653
|
+
organization: organization2,
|
|
1654
|
+
status: "accepted"
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
]
|
|
1658
|
+
}) : [];
|
|
1659
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1660
|
+
return [owner, ...members].filter((member) => {
|
|
1661
|
+
if (!(member == null ? void 0 : member.id) || !(member == null ? void 0 : member.email)) return false;
|
|
1662
|
+
const address = member.email.toLowerCase();
|
|
1663
|
+
if (seen.has(address)) return false;
|
|
1664
|
+
seen.add(address);
|
|
1665
|
+
return true;
|
|
1666
|
+
});
|
|
1667
|
+
};
|
|
1668
|
+
var queueNotification = (data2) => ({ collection: "notification", data: data2, operation: "create" });
|
|
1382
1669
|
var drawbridge_default2 = {
|
|
1383
1670
|
auth: {
|
|
1384
1671
|
type: "none"
|
|
@@ -1396,10 +1683,15 @@ var drawbridge_default2 = {
|
|
|
1396
1683
|
exclusive: false,
|
|
1397
1684
|
fields: [],
|
|
1398
1685
|
group: "developer",
|
|
1399
|
-
//
|
|
1400
|
-
//
|
|
1401
|
-
//
|
|
1402
|
-
//
|
|
1686
|
+
// THE BODIES LIVE HERE, beside the declarations that name them. They used to
|
|
1687
|
+
// live in drawbridge-sync because they touch the database, the queues and the
|
|
1688
|
+
// sockets — and a published package cannot carry a controller.
|
|
1689
|
+
//
|
|
1690
|
+
// It does not have to. A hook is a function, so everything it needs is PASSED
|
|
1691
|
+
// IN: `read` for the reads, `canSend` for the opt-out floor, `resolveContact`
|
|
1692
|
+
// for the one write whose RESULT the hook has to count. Everything else a hook
|
|
1693
|
+
// wants done it DESCRIBES — `writes`, `enqueues`, `events` — and the shell
|
|
1694
|
+
// performs it. See lib/connections/contract.js for that shape.
|
|
1403
1695
|
hooks: {
|
|
1404
1696
|
auth: {
|
|
1405
1697
|
// Nothing to connect, revoke, probe or re-scope.
|
|
@@ -1420,13 +1712,157 @@ var drawbridge_default2 = {
|
|
|
1420
1712
|
// accounts DRAWBRIDGE holds rather than ones a merchant connects.
|
|
1421
1713
|
contacts,
|
|
1422
1714
|
email: {
|
|
1423
|
-
|
|
1424
|
-
//
|
|
1425
|
-
//
|
|
1426
|
-
//
|
|
1427
|
-
|
|
1428
|
-
//
|
|
1429
|
-
|
|
1715
|
+
// A PERIODIC SUMMARY to the team, on a schedule trigger rather than per
|
|
1716
|
+
// lead.
|
|
1717
|
+
//
|
|
1718
|
+
// The count is the point: `email.notify` tells the owner one lead arrived
|
|
1719
|
+
// and dampens a spike to one message per bucket, which is deliberately not
|
|
1720
|
+
// a count. This is where "you got 43 entries this week" comes from.
|
|
1721
|
+
digest: async ({ context, step, workflow }, { read } = {}) => {
|
|
1722
|
+
var _a, _b, _c, _d;
|
|
1723
|
+
const days = { day: 1, month: 30, week: 7 }[(_a = workflow == null ? void 0 : workflow.trigger) == null ? void 0 : _a.event] || 7;
|
|
1724
|
+
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1e3);
|
|
1725
|
+
const campaign = ((_c = (_b = workflow == null ? void 0 : workflow.trigger) == null ? void 0 : _b.filters) == null ? void 0 : _c.campaign) || null;
|
|
1726
|
+
const [counted] = await read.aggregate({
|
|
1727
|
+
collection: "lead",
|
|
1728
|
+
pipeline: [
|
|
1729
|
+
{
|
|
1730
|
+
$match: {
|
|
1731
|
+
createdAt: { $gte: since },
|
|
1732
|
+
organization: workflow.organization,
|
|
1733
|
+
...campaign && { campaigns: { $in: [campaign] } }
|
|
1734
|
+
}
|
|
1735
|
+
},
|
|
1736
|
+
{ $count: "count" }
|
|
1737
|
+
]
|
|
1738
|
+
});
|
|
1739
|
+
const count = Number((counted == null ? void 0 : counted.count) || 0);
|
|
1740
|
+
const request2 = { campaign, count, days };
|
|
1741
|
+
if (!count) return { message: "No new leads in the period \u2014 digest skipped.", request: request2, response: { skipped: true }, skipped: true };
|
|
1742
|
+
const recipients = await teamRecipients({
|
|
1743
|
+
memberIds: ((_d = step.settings) == null ? void 0 : _d.members) || [],
|
|
1744
|
+
organization: workflow.organization,
|
|
1745
|
+
read
|
|
1746
|
+
});
|
|
1747
|
+
const values = { ...context, count };
|
|
1748
|
+
return {
|
|
1749
|
+
message: "Digest of " + count + " new lead(s) queued for " + recipients.length + " recipient(s).",
|
|
1750
|
+
request: request2,
|
|
1751
|
+
response: { count, notified: recipients.length },
|
|
1752
|
+
writes: recipients.map((member) => {
|
|
1753
|
+
var _a2, _b2;
|
|
1754
|
+
return queueNotification({
|
|
1755
|
+
audience: "member",
|
|
1756
|
+
message: interpolate((_a2 = step.settings) == null ? void 0 : _a2.message, values),
|
|
1757
|
+
organization: workflow.organization,
|
|
1758
|
+
send: { type: "email", email: member.email },
|
|
1759
|
+
title: interpolate((_b2 = step.settings) == null ? void 0 : _b2.subject, values),
|
|
1760
|
+
workflow: workflow.id
|
|
1761
|
+
});
|
|
1762
|
+
})
|
|
1763
|
+
};
|
|
1764
|
+
},
|
|
1765
|
+
// To the organization's OWN PEOPLE. Never suppressed, never
|
|
1766
|
+
// subscription-gated, no unsubscribe footer — telling an org's staff about
|
|
1767
|
+
// their own leads is not commercial mail to a stranger.
|
|
1768
|
+
//
|
|
1769
|
+
// FREE, permanently. The lead that triggered this run already consumed the
|
|
1770
|
+
// billable action, and `members` is a list — billing here would turn one
|
|
1771
|
+
// lead into five more charges and the org would be paying to read its own
|
|
1772
|
+
// mail. The declaration prices it at zero; the shell bills nothing for
|
|
1773
|
+
// zero.
|
|
1774
|
+
notify: async ({ context, step, workflow }, { read } = {}) => {
|
|
1775
|
+
var _a;
|
|
1776
|
+
const memberIds = ((_a = step.settings) == null ? void 0 : _a.members) || [];
|
|
1777
|
+
const request2 = { members: memberIds.length };
|
|
1778
|
+
const recipients = await teamRecipients({ memberIds, organization: workflow.organization, read });
|
|
1779
|
+
if (!recipients.length) {
|
|
1780
|
+
return {
|
|
1781
|
+
message: "No owner or accepted member with an email address \u2014 team notification skipped.",
|
|
1782
|
+
request: request2,
|
|
1783
|
+
response: { skipped: true },
|
|
1784
|
+
skipped: true
|
|
1785
|
+
};
|
|
1786
|
+
}
|
|
1787
|
+
const bucket = Math.floor(Date.now() / (15 * 60 * 1e3));
|
|
1788
|
+
return {
|
|
1789
|
+
message: "Team notification queued for " + recipients.length + " recipient(s).",
|
|
1790
|
+
request: request2,
|
|
1791
|
+
response: { notified: recipients.length },
|
|
1792
|
+
writes: recipients.map((member) => {
|
|
1793
|
+
var _a2, _b;
|
|
1794
|
+
return {
|
|
1795
|
+
...queueNotification({
|
|
1796
|
+
audience: "member",
|
|
1797
|
+
// Per workflow, recipient AND bucket, so one recipient's damper
|
|
1798
|
+
// can never swallow another's mail and a later bucket is never
|
|
1799
|
+
// mistaken for a duplicate of an earlier one.
|
|
1800
|
+
key: "team.notify." + workflow.id + "." + member.id + "." + bucket,
|
|
1801
|
+
message: interpolate((_a2 = step.settings) == null ? void 0 : _a2.message, context),
|
|
1802
|
+
organization: workflow.organization,
|
|
1803
|
+
send: { type: "email", email: member.email },
|
|
1804
|
+
title: interpolate((_b = step.settings) == null ? void 0 : _b.subject, context),
|
|
1805
|
+
workflow: workflow.id
|
|
1806
|
+
}),
|
|
1807
|
+
// E11000 IS THE DAMPER WORKING: this recipient has already been
|
|
1808
|
+
// told within the bucket. Declared per write rather than assumed by
|
|
1809
|
+
// the shell, because on every other write here a duplicate key is a
|
|
1810
|
+
// real failure.
|
|
1811
|
+
ignoreDuplicate: true
|
|
1812
|
+
};
|
|
1813
|
+
})
|
|
1814
|
+
};
|
|
1815
|
+
},
|
|
1816
|
+
// Drawbridge sends lead-facing email itself — no merchant provider gates
|
|
1817
|
+
// it.
|
|
1818
|
+
//
|
|
1819
|
+
// This QUEUES rather than sends: queue/notification.js owns delivery, the
|
|
1820
|
+
// unsubscribe token and the CAN-SPAM footer. The step's job is to say who
|
|
1821
|
+
// and what, correctly, and to refuse early when it must not send at all.
|
|
1822
|
+
send: async ({ context, step, workflow }, { canSend, read } = {}) => {
|
|
1823
|
+
var _a, _b;
|
|
1824
|
+
const to = context == null ? void 0 : context.email;
|
|
1825
|
+
if (!to) throw new Error("No email address on context (context.email is required)");
|
|
1826
|
+
const request2 = { to };
|
|
1827
|
+
const { ok: sendable } = await canSend({ channel: "email", to });
|
|
1828
|
+
if (!sendable) return { message: "Recipient has opted out \u2014 skipped.", request: request2, response: { skipped: true }, skipped: true };
|
|
1829
|
+
const organization2 = await read.get({ collection: "organization", query: { id: workflow.organization } });
|
|
1830
|
+
const subscription = (organization2 == null ? void 0 : organization2.subscription) ? await read.get({ collection: "subscription", query: { id: organization2.subscription } }) : null;
|
|
1831
|
+
if ((subscription == null ? void 0 : subscription.status) !== "active") {
|
|
1832
|
+
return {
|
|
1833
|
+
message: "Organization has no active subscription \u2014 workflow-step email skipped.",
|
|
1834
|
+
request: request2,
|
|
1835
|
+
response: { skipped: true },
|
|
1836
|
+
skipped: true
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
return {
|
|
1840
|
+
message: "Email queued for delivery to " + to + ".",
|
|
1841
|
+
request: request2,
|
|
1842
|
+
response: { queued: true },
|
|
1843
|
+
// NO `connection` FIELD, deliberately: the platform sends this.
|
|
1844
|
+
// `audience : 'lead'` states what the queue would otherwise infer from
|
|
1845
|
+
// shape.
|
|
1846
|
+
//
|
|
1847
|
+
// `campaign` is not decoration. queue/notification.js mints the
|
|
1848
|
+
// unsubscribe token with it, so it decides whether opting out is
|
|
1849
|
+
// scoped to this campaign or the whole organization, and it names the
|
|
1850
|
+
// campaign in the footer. Sending without it silently broadens every
|
|
1851
|
+
// opt-out to the entire organization.
|
|
1852
|
+
writes: [
|
|
1853
|
+
queueNotification({
|
|
1854
|
+
audience: "lead",
|
|
1855
|
+
campaign: (context == null ? void 0 : context.campaign) || null,
|
|
1856
|
+
lead: (context == null ? void 0 : context.lead) || null,
|
|
1857
|
+
message: interpolate((_a = step.settings) == null ? void 0 : _a.message, context),
|
|
1858
|
+
organization: workflow.organization,
|
|
1859
|
+
send: { type: "email", email: to },
|
|
1860
|
+
title: interpolate((_b = step.settings) == null ? void 0 : _b.subject, context),
|
|
1861
|
+
workflow: workflow.id
|
|
1862
|
+
})
|
|
1863
|
+
]
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1430
1866
|
},
|
|
1431
1867
|
inbound: false,
|
|
1432
1868
|
lifecycle: false,
|
|
@@ -1436,13 +1872,207 @@ var drawbridge_default2 = {
|
|
|
1436
1872
|
products: false,
|
|
1437
1873
|
promotions: false
|
|
1438
1874
|
},
|
|
1439
|
-
segment: {
|
|
1440
|
-
|
|
1875
|
+
segment: {
|
|
1876
|
+
// RECALCULATE SEGMENT MEMBERSHIP. The one FAN-OUT hook: it evaluates every
|
|
1877
|
+
// contact in an organization against every segment, which is too much for
|
|
1878
|
+
// one job, so it returns chunks and the shell defers completion.
|
|
1879
|
+
//
|
|
1880
|
+
// Returning `chunks` is the only thing that makes it different. The
|
|
1881
|
+
// declaration, the guards, the step document and the price are the shell's,
|
|
1882
|
+
// exactly as they are for a step that finishes in one go.
|
|
1883
|
+
sync: async ({ context, step }, { chunkSize, logger: logger2, read, resolveContact } = {}) => {
|
|
1884
|
+
var _a, _b, _c;
|
|
1885
|
+
if (!chunkSize) throw new Error("segment.sync needs chunkSize from the shell");
|
|
1886
|
+
const organization2 = context == null ? void 0 : context.organization;
|
|
1887
|
+
const configured = (_a = step == null ? void 0 : step.settings) == null ? void 0 : _a.segment;
|
|
1888
|
+
const request2 = { organization: organization2 || null, segmentId: configured || null };
|
|
1889
|
+
const release = (ids, status = "active") => {
|
|
1890
|
+
const released = (ids || []).filter(Boolean);
|
|
1891
|
+
return {
|
|
1892
|
+
events: organization2 ? released.map((id) => ({
|
|
1893
|
+
event: "organization.segments",
|
|
1894
|
+
payload: { id, status },
|
|
1895
|
+
room: "organization." + organization2
|
|
1896
|
+
})) : [],
|
|
1897
|
+
writes: released.map((id) => ({
|
|
1898
|
+
collection: "segment",
|
|
1899
|
+
data: { $set: { status } },
|
|
1900
|
+
operation: "update",
|
|
1901
|
+
query: { id }
|
|
1902
|
+
}))
|
|
1903
|
+
};
|
|
1904
|
+
};
|
|
1905
|
+
if (!organization2) {
|
|
1906
|
+
return {
|
|
1907
|
+
...release([configured]),
|
|
1908
|
+
message: "Trigger data missing organization id \u2014 cannot sync segments.",
|
|
1909
|
+
request: request2,
|
|
1910
|
+
response: { skipped: true },
|
|
1911
|
+
skipped: true
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
const segments = await read.aggregate({
|
|
1915
|
+
collection: "segment",
|
|
1916
|
+
pipeline: [{ $match: configured ? { id: configured, organization: organization2 } : { organization: organization2 } }]
|
|
1917
|
+
});
|
|
1918
|
+
if (!segments.length) {
|
|
1919
|
+
return {
|
|
1920
|
+
...release([configured]),
|
|
1921
|
+
message: "No segments matched the request \u2014 nothing to sync.",
|
|
1922
|
+
request: request2,
|
|
1923
|
+
response: { skipped: true },
|
|
1924
|
+
skipped: true
|
|
1925
|
+
};
|
|
1926
|
+
}
|
|
1927
|
+
const segmentIds = segments.map((entry) => entry.id);
|
|
1928
|
+
try {
|
|
1929
|
+
let backfilled = 0;
|
|
1930
|
+
if (segments.some((entry) => entry.system)) {
|
|
1931
|
+
const contacted = await read.aggregate({
|
|
1932
|
+
collection: "contact",
|
|
1933
|
+
pipeline: [
|
|
1934
|
+
{ $match: { organization: organization2 } },
|
|
1935
|
+
{ $project: { _id: 0, leads: 1 } },
|
|
1936
|
+
{ $unwind: "$leads" },
|
|
1937
|
+
{ $group: { _id: null, ids: { $addToSet: "$leads" } } }
|
|
1938
|
+
]
|
|
1939
|
+
});
|
|
1940
|
+
const uncontacted = await read.aggregate({
|
|
1941
|
+
collection: "lead",
|
|
1942
|
+
pipeline: [
|
|
1943
|
+
{ $match: { id: { $nin: ((_b = contacted[0]) == null ? void 0 : _b.ids) || [] }, organization: organization2 } },
|
|
1944
|
+
{ $project: { _id: 0, id: 1 } }
|
|
1945
|
+
]
|
|
1946
|
+
});
|
|
1947
|
+
for (const lead of uncontacted) {
|
|
1948
|
+
try {
|
|
1949
|
+
await resolveContact({ leadId: lead.id });
|
|
1950
|
+
backfilled += 1;
|
|
1951
|
+
} catch (error) {
|
|
1952
|
+
if (error.code !== 11e3) throw error;
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
(_c = logger2 == null ? void 0 : logger2.info) == null ? void 0 : _c.call(logger2, "segment.sync.backfill", { backfilled, organization: organization2, uncontacted: uncontacted.length });
|
|
1956
|
+
}
|
|
1957
|
+
const contacts2 = await read.aggregate({
|
|
1958
|
+
collection: "contact",
|
|
1959
|
+
pipeline: [
|
|
1960
|
+
{ $match: { organization: organization2 } },
|
|
1961
|
+
{ $project: { _id: 0, id: 1 } },
|
|
1962
|
+
{ $sort: { id: 1 } }
|
|
1963
|
+
]
|
|
1964
|
+
});
|
|
1965
|
+
if (!contacts2.length) {
|
|
1966
|
+
return {
|
|
1967
|
+
...release(segmentIds),
|
|
1968
|
+
message: "Organization has no contacts to evaluate against segments.",
|
|
1969
|
+
request: request2,
|
|
1970
|
+
response: { skipped: true },
|
|
1971
|
+
skipped: true
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
const contactIds = contacts2.map((contact) => contact.id);
|
|
1975
|
+
const org = await read.get({ collection: "organization", query: { id: organization2 } });
|
|
1976
|
+
const chunks = [];
|
|
1977
|
+
for (let index = 0; index < contactIds.length; index += chunkSize) {
|
|
1978
|
+
chunks.push({
|
|
1979
|
+
contactIds: contactIds.slice(index, index + chunkSize),
|
|
1980
|
+
organization: organization2,
|
|
1981
|
+
segments: segmentIds,
|
|
1982
|
+
// A BACKFILL IS NOT BILLABLE. It creates the contacts this run
|
|
1983
|
+
// then evaluates, so charging for it would bill an organization
|
|
1984
|
+
// for work its own history made necessary.
|
|
1985
|
+
usage: (context == null ? void 0 : context.billable) === true && backfilled === 0 ? (org == null ? void 0 : org.usage) || null : null
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
return {
|
|
1989
|
+
chunks,
|
|
1990
|
+
...configured && { extra: { segment: configured } },
|
|
1991
|
+
message: "Queued " + contactIds.length + " contacts across " + chunks.length + " chunks for segment evaluation.",
|
|
1992
|
+
queue: "segment",
|
|
1993
|
+
request: { ...request2, segments: segmentIds },
|
|
1994
|
+
response: { chunks: chunks.length, contacts: contactIds.length, segments: segments.length }
|
|
1995
|
+
};
|
|
1996
|
+
} catch (error) {
|
|
1997
|
+
throw Object.assign(error, release(segmentIds, "error"));
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
},
|
|
2001
|
+
sms: {
|
|
2002
|
+
// SMS TO A LEAD, through the merchant's own Twilio connection.
|
|
2003
|
+
//
|
|
2004
|
+
// WITHDRAWN from the builder — twilio went, and a connection-gated step
|
|
2005
|
+
// with no connection to gate on could only ever render permanently
|
|
2006
|
+
// disabled. Stored workflows still carry it, so it still runs.
|
|
2007
|
+
//
|
|
2008
|
+
// It looks its own connection up rather than relying on the shell, because
|
|
2009
|
+
// the step is declared by the PRIVATE drawbridge connection (which has
|
|
2010
|
+
// none) while the credential belongs to twilio (which has no manifest).
|
|
2011
|
+
// Platform SMS will remove that split the way it did for email.
|
|
2012
|
+
send: async ({ context, step, workflow }, { canSend, read } = {}) => {
|
|
2013
|
+
var _a, _b, _c;
|
|
2014
|
+
const to = (_a = context == null ? void 0 : context.phone) == null ? void 0 : _a.number;
|
|
2015
|
+
if (!to) throw new Error("No phone number on context (context.phone.number is required)");
|
|
2016
|
+
const request2 = { to };
|
|
2017
|
+
const connection2 = await read.get({
|
|
2018
|
+
collection: "connection",
|
|
2019
|
+
query: { organization: workflow.organization, slug: "twilio", status: "active" }
|
|
2020
|
+
});
|
|
2021
|
+
if (!connection2) return { message: "No active Twilio SMS connection \u2014 workflow-step SMS skipped.", request: request2, response: { skipped: true }, skipped: true };
|
|
2022
|
+
const { ok: sendable } = await canSend({ channel: "sms", to: context.phone });
|
|
2023
|
+
if (!sendable) return { message: "Recipient has opted out \u2014 skipped.", request: request2, response: { skipped: true }, skipped: true };
|
|
2024
|
+
return {
|
|
2025
|
+
message: "SMS queued for delivery to " + to + " via twilio.",
|
|
2026
|
+
request: request2,
|
|
2027
|
+
response: { provider: "twilio", queued: true },
|
|
2028
|
+
// QUEUES rather than sends: queue/notification.js owns delivery, the
|
|
2029
|
+
// carrier opt-out line and the segment count this is billed on.
|
|
2030
|
+
writes: [
|
|
2031
|
+
queueNotification({
|
|
2032
|
+
connection: connection2.id,
|
|
2033
|
+
message: interpolate((_b = step.settings) == null ? void 0 : _b.message, context),
|
|
2034
|
+
organization: workflow.organization,
|
|
2035
|
+
send: { phone: { number: to }, type: "phone" },
|
|
2036
|
+
title: interpolate((_c = step.settings) == null ? void 0 : _c.subject, context),
|
|
2037
|
+
workflow: workflow.id
|
|
2038
|
+
})
|
|
2039
|
+
]
|
|
2040
|
+
};
|
|
2041
|
+
}
|
|
2042
|
+
},
|
|
1441
2043
|
webhook: false
|
|
1442
2044
|
},
|
|
1443
2045
|
icon: drawbridge_default,
|
|
1444
2046
|
// PRIVATE: never in the catalog, always available to the builder.
|
|
1445
2047
|
private: true,
|
|
2048
|
+
// THE PLATFORM'S OWN SENDING CREDENTIALS — SendGrid, Twilio, and the internal
|
|
2049
|
+
// HubSpot portal. No merchant ever sees these; they are what an admin types on
|
|
2050
|
+
// the provider screen so that Drawbridge itself can send.
|
|
2051
|
+
//
|
|
2052
|
+
// They belong on THIS manifest because this is the connection that sends: the
|
|
2053
|
+
// email, sms and segment hooks below are the only things that spend them, and
|
|
2054
|
+
// a private connection is still where a vendor fact lives.
|
|
2055
|
+
//
|
|
2056
|
+
// UNLIKE every public vendor, none of these appear in `requires` — see the
|
|
2057
|
+
// comment there. Availability and configuration are different questions, and a
|
|
2058
|
+
// missing CRM token must not take every base workflow step away.
|
|
2059
|
+
provider: {
|
|
2060
|
+
fields: [
|
|
2061
|
+
{ input: "email", key: "accountSender", credential: "SENDGRID_FROM_ADDRESS", label: "Account sender", message: "Verification codes and security alerts send from here.", required: true },
|
|
2062
|
+
{ input: "password", key: "apiKey", credential: "SENDGRID_API_KEY", label: "SendGrid API key", redact: true, required: true },
|
|
2063
|
+
// NOT required. The CRM sync is best-effort internal tooling and no-ops
|
|
2064
|
+
// without a token — requiring it would make the whole drawbridge provider
|
|
2065
|
+
// read not-live over something no merchant ever sees.
|
|
2066
|
+
{ input: "password", key: "hubspotToken", credential: "HUBSPOT_ACCESS_TOKEN", label: "HubSpot access token", message: "Drawbridge's own CRM portal. Internal \u2014 no merchant sees this.", redact: true },
|
|
2067
|
+
// Optional: SENDGRID_SEND_FROM_ADDRESS is not boot-required in sync
|
|
2068
|
+
// either. Unset, it degrades to the account sender rather than
|
|
2069
|
+
// refusing to start.
|
|
2070
|
+
{ input: "email", key: "leadSender", credential: "SENDGRID_SEND_FROM_ADDRESS", label: "Lead sender", message: "The default for lead-facing mail when a merchant has not verified their own domain." },
|
|
2071
|
+
{ input: "text", key: "smsFrom", credential: "TWILIO_ACCOUNT_FROM", label: "SMS number", required: true },
|
|
2072
|
+
{ input: "password", key: "smsSid", credential: "TWILIO_ACCOUNT_SID", label: "Twilio account SID", redact: true, required: true },
|
|
2073
|
+
{ input: "password", key: "smsToken", credential: "TWILIO_AUTH_TOKEN", label: "Twilio auth token", redact: true, required: true }
|
|
2074
|
+
]
|
|
2075
|
+
},
|
|
1446
2076
|
// NOTHING, and HUBSPOT_ACCESS_TOKEN in particular must not be here.
|
|
1447
2077
|
//
|
|
1448
2078
|
// `requires` gates AVAILABILITY: a name in it that is unset removes the whole
|
|
@@ -1479,11 +2109,11 @@ var drawbridge_default2 = {
|
|
|
1479
2109
|
key: "Email \u2014 Digest",
|
|
1480
2110
|
queue: "notification",
|
|
1481
2111
|
settings: {
|
|
1482
|
-
// The organization OWNER is always a recipient, resolved
|
|
1483
|
-
// so this is additional recipients rather than the list. It
|
|
1484
|
-
// be required: the members endpoint is owner-gated and the
|
|
1485
|
-
// not a member document, so a solo merchant has nothing to
|
|
1486
|
-
// could never save the step.
|
|
2112
|
+
// The organization OWNER is always a recipient, resolved by the
|
|
2113
|
+
// hook, so this is additional recipients rather than the list. It
|
|
2114
|
+
// cannot be required: the members endpoint is owner-gated and the
|
|
2115
|
+
// owner is not a member document, so a solo merchant has nothing to
|
|
2116
|
+
// pick and could never save the step.
|
|
1487
2117
|
members: { of: "string", type: "array" },
|
|
1488
2118
|
message: { required: true, type: "string" },
|
|
1489
2119
|
subject: { required: true, type: "string" }
|
|
@@ -1579,12 +2209,12 @@ var drawbridge_default2 = {
|
|
|
1579
2209
|
|
|
1580
2210
|
// lib/connections/icons/klaviyo.js
|
|
1581
2211
|
var klaviyo_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
1582
|
-
<rect width="500" height="500" fill="
|
|
2212
|
+
<rect width="500" height="500" fill="#FF4B32"/>
|
|
1583
2213
|
<path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
|
|
1584
2214
|
</svg>`;
|
|
1585
2215
|
|
|
1586
|
-
// lib/connections/klaviyo.js
|
|
1587
|
-
var
|
|
2216
|
+
// lib/connections/providers/klaviyo.js
|
|
2217
|
+
var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) => {
|
|
1588
2218
|
const response = await fetcher("https://a.klaviyo.com/api" + path, {
|
|
1589
2219
|
...payload && { body: JSON.stringify(payload) },
|
|
1590
2220
|
headers: {
|
|
@@ -1620,7 +2250,8 @@ var klaviyo_default2 = {
|
|
|
1620
2250
|
// differently, and it says so in hooks.auth.token rather than as a flag here.
|
|
1621
2251
|
auth: {
|
|
1622
2252
|
oauth: {
|
|
1623
|
-
// NAMES the
|
|
2253
|
+
// NAMES the credentials holding OUR application's client — keys into
|
|
2254
|
+
// the stored provider credentials, not env vars. One identity,
|
|
1624
2255
|
// every merchant — the token is the merchant's and arrives from their
|
|
1625
2256
|
// own consent, which is what stops one organization reading another's
|
|
1626
2257
|
// data.
|
|
@@ -1760,9 +2391,9 @@ var klaviyo_default2 = {
|
|
|
1760
2391
|
// renders an empty "Klaviyo account" field, because the merchant is
|
|
1761
2392
|
// never asked which account they connected — the consent already
|
|
1762
2393
|
// decided it, and asking again would be a question we can answer.
|
|
1763
|
-
connect: async ({
|
|
2394
|
+
connect: async ({ tokens }, { fetcher } = {}) => {
|
|
1764
2395
|
var _a, _b, _c;
|
|
1765
|
-
const body = await
|
|
2396
|
+
const body = await api2("/accounts", { fetcher, token: tokens.accessToken });
|
|
1766
2397
|
const account = (_a = body == null ? void 0 : body.data) == null ? void 0 : _a[0];
|
|
1767
2398
|
return {
|
|
1768
2399
|
account: ((_c = (_b = account == null ? void 0 : account.attributes) == null ? void 0 : _b.contact_information) == null ? void 0 : _c.organization_name) || (account == null ? void 0 : account.id) || null,
|
|
@@ -1775,7 +2406,7 @@ var klaviyo_default2 = {
|
|
|
1775
2406
|
//
|
|
1776
2407
|
// Basic auth with our client, exactly like the token exchange — the
|
|
1777
2408
|
// token being revoked is the subject, not the credential.
|
|
1778
|
-
disconnect: async ({ clientId, clientSecret, fetcher = fetch
|
|
2409
|
+
disconnect: async ({ clientId, clientSecret, manifest, settings }, { fetcher = fetch } = {}) => {
|
|
1779
2410
|
const token = (settings == null ? void 0 : settings.refreshToken) || (settings == null ? void 0 : settings.accessToken);
|
|
1780
2411
|
if (!token) return { revoked: false };
|
|
1781
2412
|
const response = await fetcher(manifest.auth.oauth.urls.revoke, {
|
|
@@ -1798,7 +2429,7 @@ var klaviyo_default2 = {
|
|
|
1798
2429
|
// the refresh token is the only thing that asks Klaviyo.
|
|
1799
2430
|
//
|
|
1800
2431
|
// It also keeps the grant warm against the 90-day idle window above.
|
|
1801
|
-
probe: async ({ clientId, clientSecret,
|
|
2432
|
+
probe: async ({ clientId, clientSecret, manifest, settings }, { fetcher } = {}) => {
|
|
1802
2433
|
const token = await accessToken({
|
|
1803
2434
|
clientId,
|
|
1804
2435
|
clientSecret,
|
|
@@ -1828,19 +2459,18 @@ var klaviyo_default2 = {
|
|
|
1828
2459
|
// the store's.
|
|
1829
2460
|
commerce: false,
|
|
1830
2461
|
// The verb the contacts.sync step points at. It does the work — including
|
|
1831
|
-
// writing the profile id back onto the lead — and returns what happened.
|
|
1832
2462
|
contacts: {
|
|
1833
2463
|
// Not yet. Suppression syncs an opt-out as unsubscribed, which is a
|
|
1834
2464
|
// different thing from deleting the profile.
|
|
1835
2465
|
remove: false,
|
|
1836
|
-
sync: async ({ contact,
|
|
2466
|
+
sync: async ({ contact, lead, settings, suppressed, token }, { fetcher } = {}) => {
|
|
1837
2467
|
var _a, _b, _c;
|
|
1838
2468
|
const list = settings == null ? void 0 : settings.list;
|
|
1839
2469
|
if (!list) return { message: "No Klaviyo list is chosen for this connection.", skipped: true };
|
|
1840
2470
|
const email = ((_b = (_a = lead == null ? void 0 : lead.canonical) == null ? void 0 : _a.email) == null ? void 0 : _b.value) || (lead == null ? void 0 : lead.email);
|
|
1841
2471
|
if (!email) return { message: "That lead has no email address to sync.", skipped: true };
|
|
1842
2472
|
const totals = (contact == null ? void 0 : contact.totals) || {};
|
|
1843
|
-
const profile = await
|
|
2473
|
+
const profile = await api2("/profiles/", {
|
|
1844
2474
|
fetcher,
|
|
1845
2475
|
method: "POST",
|
|
1846
2476
|
payload: {
|
|
@@ -1868,7 +2498,7 @@ var klaviyo_default2 = {
|
|
|
1868
2498
|
});
|
|
1869
2499
|
const profileId = (_c = profile == null ? void 0 : profile.data) == null ? void 0 : _c.id;
|
|
1870
2500
|
if (!profileId) return { message: "Klaviyo returned no profile id.", skipped: true };
|
|
1871
|
-
await
|
|
2501
|
+
await api2("/profile-subscription-bulk-create-jobs/", {
|
|
1872
2502
|
fetcher,
|
|
1873
2503
|
method: "POST",
|
|
1874
2504
|
payload: {
|
|
@@ -1925,13 +2555,13 @@ var klaviyo_default2 = {
|
|
|
1925
2555
|
// it, so one call quietly returns the first ten lists and an account
|
|
1926
2556
|
// with more shows a picker missing the one they wanted, with nothing to
|
|
1927
2557
|
// indicate anything was cut.
|
|
1928
|
-
audiences: async ({ cursor,
|
|
2558
|
+
audiences: async ({ cursor, limit = 100, search, token }, { fetcher } = {}) => {
|
|
1929
2559
|
var _a, _b;
|
|
1930
2560
|
const audiences = [];
|
|
1931
2561
|
let next = cursor ? "/lists?page%5Bsize%5D=10&page%5Bcursor%5D=" + encodeURIComponent(cursor) : "/lists?page%5Bsize%5D=10";
|
|
1932
2562
|
let pages = 0;
|
|
1933
2563
|
while (next && audiences.length < limit && pages < 20) {
|
|
1934
|
-
const body = await
|
|
2564
|
+
const body = await api2(next, { fetcher, token });
|
|
1935
2565
|
for (const list of (body == null ? void 0 : body.data) || []) {
|
|
1936
2566
|
audiences.push({ id: list.id, title: ((_a = list == null ? void 0 : list.attributes) == null ? void 0 : _a.name) || list.id });
|
|
1937
2567
|
}
|
|
@@ -1957,6 +2587,16 @@ var klaviyo_default2 = {
|
|
|
1957
2587
|
webhook: false
|
|
1958
2588
|
},
|
|
1959
2589
|
icon: klaviyo_default,
|
|
2590
|
+
// DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
|
|
2591
|
+
// what an admin types on the provider screen. Declared here rather than in a
|
|
2592
|
+
// table in lib/providers.js, so a vendor's credentials sit beside the
|
|
2593
|
+
// `requires` that names the same variables.
|
|
2594
|
+
provider: {
|
|
2595
|
+
fields: [
|
|
2596
|
+
{ input: "text", key: "clientId", credential: "KLAVIYO_OAUTH_CLIENT_ID", label: "Client ID", required: true },
|
|
2597
|
+
{ input: "password", key: "clientSecret", credential: "KLAVIYO_OAUTH_CLIENT_SECRET", label: "Client secret", redact: true, required: true }
|
|
2598
|
+
]
|
|
2599
|
+
},
|
|
1960
2600
|
requires: [
|
|
1961
2601
|
"KLAVIYO_OAUTH_CLIENT_ID",
|
|
1962
2602
|
"KLAVIYO_OAUTH_CLIENT_SECRET"
|
|
@@ -2030,6 +2670,9 @@ var klaviyo_default2 = {
|
|
|
2030
2670
|
title: "Klaviyo"
|
|
2031
2671
|
};
|
|
2032
2672
|
|
|
2673
|
+
// lib/connections/providers/mailchimp.js
|
|
2674
|
+
var import_node_crypto2 = require("crypto");
|
|
2675
|
+
|
|
2033
2676
|
// lib/connections/icons/mailchimp.js
|
|
2034
2677
|
var mailchimp_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2035
2678
|
<rect width="500" height="500" fill="#FFE01B"/>
|
|
@@ -2037,11 +2680,30 @@ var mailchimp_default = `<svg width="500" height="500" viewBox="0 0 500 500" fil
|
|
|
2037
2680
|
<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"/>
|
|
2038
2681
|
</svg>`;
|
|
2039
2682
|
|
|
2040
|
-
// lib/connections/mailchimp.js
|
|
2683
|
+
// lib/connections/providers/mailchimp.js
|
|
2041
2684
|
var base = (dc) => {
|
|
2042
2685
|
if (!dc) throw new Error("This Mailchimp connection has no data centre stored, so there is no host to call");
|
|
2043
2686
|
return "https://" + dc + ".api.mailchimp.com/3.0";
|
|
2044
2687
|
};
|
|
2688
|
+
var api3 = async (path, { dc, fetcher = fetch, method = "GET", payload, token }) => {
|
|
2689
|
+
const response = await fetcher(base(dc) + path, {
|
|
2690
|
+
...payload && { body: JSON.stringify(payload) },
|
|
2691
|
+
headers: {
|
|
2692
|
+
authorization: "Bearer " + token,
|
|
2693
|
+
...payload && { "content-type": "application/json" }
|
|
2694
|
+
},
|
|
2695
|
+
method,
|
|
2696
|
+
signal: AbortSignal.timeout(15e3)
|
|
2697
|
+
});
|
|
2698
|
+
if (!response.ok) {
|
|
2699
|
+
throw Object.assign(
|
|
2700
|
+
new Error("Mailchimp refused the request (" + response.status + ")"),
|
|
2701
|
+
{ status: response.status }
|
|
2702
|
+
);
|
|
2703
|
+
}
|
|
2704
|
+
return response.json();
|
|
2705
|
+
};
|
|
2706
|
+
var subscriberHash = (email) => (0, import_node_crypto2.createHash)("md5").update(String(email).trim().toLowerCase()).digest("hex");
|
|
2045
2707
|
var mailchimp_default2 = {
|
|
2046
2708
|
// OAUTH 2, authorization code. Every url below is quoted from
|
|
2047
2709
|
// mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/ rather
|
|
@@ -2084,7 +2746,9 @@ var mailchimp_default2 = {
|
|
|
2084
2746
|
confirm: "Disconnecting removes Drawbridge's stored Mailchimp access. You can also remove Drawbridge from the Authorized Apps page in your Mailchimp account. Your contacts stay in both Drawbridge and Mailchimp \u2014 neither list is deleted.",
|
|
2085
2747
|
description: [
|
|
2086
2748
|
"Drawbridge no longer sends email through Mailchimp. Notification email now sends from Drawbridge itself, and verifying a domain under Networking in your organization settings puts your own brand in the from line.",
|
|
2087
|
-
"
|
|
2749
|
+
"Connecting Mailchimp lets Drawbridge sync the contacts your campaigns collect into a Mailchimp audience, so the people who enter a giveaway can be marketed to alongside the rest of your list.",
|
|
2750
|
+
"Anyone who has opted out in Drawbridge is synced as unsubscribed rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing.",
|
|
2751
|
+
"Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before."
|
|
2088
2752
|
],
|
|
2089
2753
|
excerpt: "Sync your Drawbridge contacts into a Mailchimp audience.",
|
|
2090
2754
|
guide: [
|
|
@@ -2126,7 +2790,7 @@ var mailchimp_default2 = {
|
|
|
2126
2790
|
// The header here is `OAuth <token>`, not Bearer — that is specific to
|
|
2127
2791
|
// the metadata endpoint. Marketing API calls take Bearer; see the
|
|
2128
2792
|
// audiences hook.
|
|
2129
|
-
connect: async ({ fetcher = fetch
|
|
2793
|
+
connect: async ({ tokens }, { fetcher = fetch } = {}) => {
|
|
2130
2794
|
const response = await fetcher("https://login.mailchimp.com/oauth2/metadata", {
|
|
2131
2795
|
headers: {
|
|
2132
2796
|
authorization: "OAuth " + (tokens == null ? void 0 : tokens.accessToken)
|
|
@@ -2159,7 +2823,51 @@ var mailchimp_default2 = {
|
|
|
2159
2823
|
token: authToken
|
|
2160
2824
|
},
|
|
2161
2825
|
commerce: false,
|
|
2162
|
-
|
|
2826
|
+
// The verb the contacts.sync step points at.
|
|
2827
|
+
contacts: {
|
|
2828
|
+
// Not yet. Suppression syncs an opt-out as unsubscribed, which is a
|
|
2829
|
+
// different thing from deleting the member — and Mailchimp's own delete is
|
|
2830
|
+
// permanent, so the address can never be re-added.
|
|
2831
|
+
remove: false,
|
|
2832
|
+
// PUT /lists/{list_id}/members/{subscriber_hash} — an UPSERT, which is
|
|
2833
|
+
// why there is no create-or-update branch here. Quoted from Mailchimp's
|
|
2834
|
+
// Marketing API reference for the list-members resource.
|
|
2835
|
+
sync: async ({ lead, settings, suppressed, token }, { fetcher } = {}) => {
|
|
2836
|
+
var _a, _b;
|
|
2837
|
+
const audience = settings == null ? void 0 : settings.audience;
|
|
2838
|
+
if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
|
|
2839
|
+
const email = ((_b = (_a = lead == null ? void 0 : lead.canonical) == null ? void 0 : _a.email) == null ? void 0 : _b.value) || (lead == null ? void 0 : lead.email);
|
|
2840
|
+
if (!email) return { message: "That lead has no email address to sync.", skipped: true };
|
|
2841
|
+
const hash = subscriberHash(email);
|
|
2842
|
+
const member = await api3("/lists/" + audience + "/members/" + hash, {
|
|
2843
|
+
dc: settings == null ? void 0 : settings.dc,
|
|
2844
|
+
fetcher,
|
|
2845
|
+
method: "PUT",
|
|
2846
|
+
payload: {
|
|
2847
|
+
email_address: email,
|
|
2848
|
+
// FNAME ONLY. Unlike Klaviyo, Mailchimp's custom fields are not
|
|
2849
|
+
// schemaless — a merge tag that does not exist on the audience is
|
|
2850
|
+
// refused, taking the whole request with it — and FNAME is one of
|
|
2851
|
+
// the two tags every audience is created with. The Drawbridge
|
|
2852
|
+
// totals Klaviyo receives cannot travel until something registers
|
|
2853
|
+
// merge fields on the chosen audience, which is lifecycle.register's
|
|
2854
|
+
// job and is not built.
|
|
2855
|
+
...(lead == null ? void 0 : lead.name) && { merge_fields: { FNAME: String(lead.name).trim().split(/\s+/)[0] } },
|
|
2856
|
+
...suppressed && { status: "unsubscribed" },
|
|
2857
|
+
status_if_new: suppressed ? "unsubscribed" : "subscribed"
|
|
2858
|
+
},
|
|
2859
|
+
token
|
|
2860
|
+
});
|
|
2861
|
+
return {
|
|
2862
|
+
// Merged into `context` for later steps in this run.
|
|
2863
|
+
context: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash },
|
|
2864
|
+
message: suppressed ? "Synced to Mailchimp as unsubscribed \u2014 this contact has opted out." : "Synced to the Mailchimp audience.",
|
|
2865
|
+
// Recorded on the run for support to read back, not a write
|
|
2866
|
+
// instruction — the hook has already written what it needed to.
|
|
2867
|
+
response: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash }
|
|
2868
|
+
};
|
|
2869
|
+
}
|
|
2870
|
+
},
|
|
2163
2871
|
// Drawbridge sends its own notification email and SMS, and owns its own
|
|
2164
2872
|
// segments — see the private `drawbridge` manifest. A vendor answering
|
|
2165
2873
|
// these would be a second sender, which is the arrangement the platform
|
|
@@ -2178,24 +2886,13 @@ var mailchimp_default2 = {
|
|
|
2178
2886
|
// successful — the same silent truncation Klaviyo has, at a different
|
|
2179
2887
|
// number. Paged against total_items so an account past a thousand still
|
|
2180
2888
|
// resolves.
|
|
2181
|
-
audiences: async ({ cursor,
|
|
2182
|
-
const dc = settings == null ? void 0 : settings.dc;
|
|
2889
|
+
audiences: async ({ cursor, limit = 100, search, settings, token }, { fetcher } = {}) => {
|
|
2183
2890
|
const count = Math.min(limit, 1e3);
|
|
2184
2891
|
const offset = Number(cursor || 0);
|
|
2185
|
-
const
|
|
2186
|
-
|
|
2187
|
-
{
|
|
2188
|
-
headers: { authorization: "Bearer " + token },
|
|
2189
|
-
signal: AbortSignal.timeout(15e3)
|
|
2190
|
-
}
|
|
2892
|
+
const body = await api3(
|
|
2893
|
+
"/lists?count=" + count + "&offset=" + offset + "&fields=lists.id,lists.name,total_items",
|
|
2894
|
+
{ dc: settings == null ? void 0 : settings.dc, fetcher, token }
|
|
2191
2895
|
);
|
|
2192
|
-
if (!response.ok) {
|
|
2193
|
-
throw Object.assign(
|
|
2194
|
-
new Error("Mailchimp refused the request (" + response.status + ")"),
|
|
2195
|
-
{ status: response.status }
|
|
2196
|
-
);
|
|
2197
|
-
}
|
|
2198
|
-
const body = await response.json();
|
|
2199
2896
|
const audiences = ((body == null ? void 0 : body.lists) || []).map((list) => ({ id: list.id, title: (list == null ? void 0 : list.name) || list.id }));
|
|
2200
2897
|
const term = String((search == null ? void 0 : search.value) || "").trim().toLowerCase();
|
|
2201
2898
|
const items = term ? audiences.filter((entry) => entry.title.toLowerCase().includes(term)) : audiences;
|
|
@@ -2217,6 +2914,15 @@ var mailchimp_default2 = {
|
|
|
2217
2914
|
webhook: false
|
|
2218
2915
|
},
|
|
2219
2916
|
icon: mailchimp_default,
|
|
2917
|
+
// DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
|
|
2918
|
+
// what an admin types on the provider screen, beside the `requires` naming the
|
|
2919
|
+
// same variables.
|
|
2920
|
+
provider: {
|
|
2921
|
+
fields: [
|
|
2922
|
+
{ input: "text", key: "clientId", credential: "MAILCHIMP_OAUTH_CLIENT_ID", label: "Client ID", required: true },
|
|
2923
|
+
{ input: "password", key: "clientSecret", credential: "MAILCHIMP_OAUTH_CLIENT_SECRET", label: "Client secret", redact: true, required: true }
|
|
2924
|
+
]
|
|
2925
|
+
},
|
|
2220
2926
|
// The OAuth client this deployment registered. Without both, the vendor drops
|
|
2221
2927
|
// out of availableConnections rather than offering a Connect button that
|
|
2222
2928
|
// cannot complete.
|
|
@@ -2225,64 +2931,145 @@ var mailchimp_default2 = {
|
|
|
2225
2931
|
"MAILCHIMP_OAUTH_CLIENT_SECRET"
|
|
2226
2932
|
],
|
|
2227
2933
|
slug: "mailchimp",
|
|
2228
|
-
// A
|
|
2229
|
-
//
|
|
2230
|
-
//
|
|
2231
|
-
//
|
|
2232
|
-
//
|
|
2934
|
+
// A grant with no audience chosen is authenticated and useless — the sync has
|
|
2935
|
+
// nowhere to put anyone — so the card must say Pending rather than Active over
|
|
2936
|
+
// nothing. Mailchimp also needs its merge fields created on that audience
|
|
2937
|
+
// before any Drawbridge total can be written to a member — unlike Klaviyo, its
|
|
2938
|
+
// custom fields are not schemaless — so the audience must be picked before
|
|
2939
|
+
// lifecycle.register has anything to register against.
|
|
2233
2940
|
status: (data2) => {
|
|
2234
2941
|
var _a;
|
|
2235
2942
|
return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.audience) ? data2.status : "pending";
|
|
2236
2943
|
},
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2944
|
+
steps: {
|
|
2945
|
+
contacts: {
|
|
2946
|
+
// A DECLARATION, not the work. It names the hook that does the work, and
|
|
2947
|
+
// the nesting IS the name: this is `step.contacts.sync`, the string a
|
|
2948
|
+
// workflow document stores. Klaviyo and Attentive declare the same type —
|
|
2949
|
+
// a step belongs to the capability, not to whoever implements it — and the
|
|
2950
|
+
// connection on the step document is what says which vendor runs.
|
|
2951
|
+
sync: ({ data: data2 }) => ({
|
|
2952
|
+
hook: "contacts.sync",
|
|
2953
|
+
// NO ACCOUNT NAME TO INTERPOLATE, unlike Klaviyo. Mailchimp's
|
|
2954
|
+
// auth.connect deliberately stores only the data centre (a test pins
|
|
2955
|
+
// that), and settings.audience is an opaque list id no merchant would
|
|
2956
|
+
// recognise in a builder label — so the label names the vendor rather
|
|
2957
|
+
// than showing a string like a1b2c3d4e5.
|
|
2958
|
+
key: "Sync contact to Mailchimp",
|
|
2959
|
+
queue: "connection",
|
|
2960
|
+
// Nothing for a merchant to configure on the step itself — the audience
|
|
2961
|
+
// is chosen once on the connection. Declared empty rather than omitted,
|
|
2962
|
+
// so "this step takes no settings" and "nobody thought about settings"
|
|
2963
|
+
// stay different statements.
|
|
2964
|
+
settings: {},
|
|
2965
|
+
// BOTH triggers, for the same reason as Klaviyo: lead.insert alone only
|
|
2966
|
+
// ever fires for someone with no history yet, and crossing into a
|
|
2967
|
+
// segment is the other moment a contact is worth pushing.
|
|
2968
|
+
triggers: ["lead.insert", "segment.contact.add"],
|
|
2969
|
+
// One source for cost: what the builder discloses before a merchant
|
|
2970
|
+
// adds this step, and what is charged when it runs.
|
|
2971
|
+
usage: { actions: 1 }
|
|
2972
|
+
})
|
|
2973
|
+
}
|
|
2974
|
+
},
|
|
2975
|
+
// WHY, in the merchant's words, and what to do about it.
|
|
2241
2976
|
tasks: (data2) => {
|
|
2242
2977
|
var _a;
|
|
2243
|
-
return [
|
|
2244
|
-
...((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.audience) ? [] : [
|
|
2245
|
-
{
|
|
2246
|
-
message: "Choose which Mailchimp audience your contacts should sync into. Until you do, nothing is being synced.",
|
|
2247
|
-
title: "Choose an audience"
|
|
2248
|
-
}
|
|
2249
|
-
],
|
|
2978
|
+
return ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.audience) ? [] : [
|
|
2250
2979
|
{
|
|
2251
|
-
message: "
|
|
2252
|
-
title: "
|
|
2253
|
-
type: "warning"
|
|
2980
|
+
message: "Choose which Mailchimp audience your contacts should sync into. Until you do, nothing is being synced.",
|
|
2981
|
+
title: "Choose an audience"
|
|
2254
2982
|
}
|
|
2255
2983
|
];
|
|
2256
2984
|
},
|
|
2257
2985
|
title: "Mailchimp"
|
|
2258
2986
|
};
|
|
2259
2987
|
|
|
2988
|
+
// lib/connections/providers/shopify.js
|
|
2989
|
+
var import_node_crypto4 = require("crypto");
|
|
2990
|
+
var import_nanoid2 = require("nanoid");
|
|
2991
|
+
|
|
2260
2992
|
// lib/connections/icons/shopify.js
|
|
2261
2993
|
var shopify_default = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2262
|
-
<rect width="500" height="500" fill="
|
|
2263
|
-
<path
|
|
2264
|
-
<path d="
|
|
2265
|
-
<path d="M258.993 193.019L249.103 230.052C249.103 230.052 238.079 225.023 225 225.85C205.833 227.059 205.628 239.171 205.824 242.21C206.865 258.756 250.376 262.381 252.821 301.171C254.745 331.688 236.656 352.574 210.592 354.21C179.313 356.19 162.089 337.711 162.089 337.711L168.716 309.472C168.716 309.472 186.052 322.569 199.921 321.686C208.993 321.119 212.228 313.738 211.903 308.514C210.536 286.921 175.102 288.185 172.862 252.695C170.985 222.811 190.57 192.554 233.803 189.821C250.46 188.762 258.993 193.028 258.993 193.028" fill="white"/>
|
|
2994
|
+
<rect width="500" height="500" fill="#95C049"/>
|
|
2995
|
+
<path d="M292.997 147.633C292.997 147.633 289.98 148.495 285.023 150.004C284.161 147.202 282.868 143.969 281.144 140.521C275.54 129.744 267.134 123.925 257.22 123.925C256.574 123.925 255.927 123.925 255.065 124.141C254.849 123.71 254.418 123.494 254.203 123.063C249.892 118.322 244.289 116.166 237.607 116.382C224.676 116.813 211.744 126.081 201.399 142.676C194.071 154.314 188.468 168.97 186.959 180.393C172.088 184.919 161.743 188.152 161.527 188.367C153.984 190.738 153.768 190.954 152.906 198.066C151.613 203.454 132 355.4 132 355.4L294.937 383.633V147.202C294.075 147.418 293.429 147.418 292.997 147.633ZM255.281 159.271C246.66 161.858 237.176 164.875 227.909 167.677C230.495 157.547 235.668 147.418 241.702 140.736C244.073 138.365 247.306 135.564 250.97 133.839C254.634 141.598 255.496 152.159 255.281 159.271ZM237.823 125.003C240.84 125.003 243.427 125.649 245.582 126.943C242.133 128.667 238.685 131.469 235.452 134.702C227.262 143.538 221.012 157.332 218.426 170.479C210.667 172.85 202.908 175.22 195.796 177.376C200.322 156.901 217.779 125.649 237.823 125.003ZM212.607 243.542C213.469 257.335 249.892 260.353 252.048 292.897C253.556 318.545 238.47 336.002 216.701 337.295C190.407 339.02 175.967 323.502 175.967 323.502L181.571 299.794C181.571 299.794 196.011 310.786 207.649 309.924C215.193 309.493 217.995 303.242 217.779 298.932C216.701 280.828 186.959 281.905 185.019 252.163C183.295 227.377 199.675 202.161 235.883 199.79C249.892 198.928 257.005 202.377 257.005 202.377L248.815 233.412C248.815 233.412 239.547 229.102 228.555 229.964C212.607 231.041 212.391 241.171 212.607 243.542ZM263.902 156.685C263.902 150.219 263.039 140.952 260.022 133.193C269.936 135.133 274.678 146.124 276.833 152.806C272.954 153.883 268.643 155.176 263.902 156.685Z" fill="white"/>
|
|
2996
|
+
<path d="M300.325 382.771L368 365.96C368 365.96 338.904 169.185 338.689 167.892C338.473 166.599 337.396 165.737 336.318 165.737C335.24 165.737 316.274 165.306 316.274 165.306C316.274 165.306 304.636 154.099 300.325 149.788V382.771Z" fill="white"/>
|
|
2266
2997
|
</svg>`;
|
|
2267
2998
|
|
|
2268
2999
|
// lib/connections/inbound.js
|
|
2269
|
-
var
|
|
2270
|
-
var verifySignature = ({ body, descriptor, headers }) => {
|
|
3000
|
+
var import_node_crypto3 = require("crypto");
|
|
3001
|
+
var verifySignature = ({ body, descriptor, headers, secret }) => {
|
|
3002
|
+
if (!secret) {
|
|
3003
|
+
throw Object.assign(new Error("Missing webhook secret: " + descriptor.signature.secret), { status: 500 });
|
|
3004
|
+
}
|
|
2271
3005
|
const provided = headers[descriptor.headers.signature];
|
|
2272
3006
|
if (!provided) {
|
|
2273
3007
|
throw Object.assign(new Error("Missing webhook signature"), { status: 401 });
|
|
2274
3008
|
}
|
|
2275
|
-
const digest = (0,
|
|
3009
|
+
const digest = (0, import_node_crypto3.createHmac)(descriptor.signature.algorithm, secret).update(body).digest(descriptor.signature.encoding);
|
|
2276
3010
|
const digestBuffer = Buffer.from(digest, descriptor.signature.encoding);
|
|
2277
3011
|
const providedBuffer = Buffer.from(provided, descriptor.signature.encoding);
|
|
2278
|
-
if (digestBuffer.length !== providedBuffer.length || !(0,
|
|
3012
|
+
if (digestBuffer.length !== providedBuffer.length || !(0, import_node_crypto3.timingSafeEqual)(digestBuffer, providedBuffer)) {
|
|
2279
3013
|
throw Object.assign(new Error("Invalid webhook signature"), { status: 401 });
|
|
2280
3014
|
}
|
|
2281
3015
|
return JSON.parse(body.toString());
|
|
2282
3016
|
};
|
|
2283
3017
|
var readEventHeader = ({ descriptor, headers }) => headers[descriptor.headers.event];
|
|
2284
3018
|
|
|
2285
|
-
// lib/
|
|
3019
|
+
// lib/email.js
|
|
3020
|
+
var GMAIL_DOMAINS = /* @__PURE__ */ new Set(["gmail.com", "googlemail.com"]);
|
|
3021
|
+
var toCanonicalEmail = (value) => {
|
|
3022
|
+
if (!value || typeof value !== "string") return null;
|
|
3023
|
+
const email = value.trim().toLowerCase();
|
|
3024
|
+
const at = email.lastIndexOf("@");
|
|
3025
|
+
if (at < 1 || at === email.length - 1) return null;
|
|
3026
|
+
let local = email.slice(0, at);
|
|
3027
|
+
const domain = email.slice(at + 1);
|
|
3028
|
+
const plus = local.indexOf("+");
|
|
3029
|
+
if (plus > 0) local = local.slice(0, plus);
|
|
3030
|
+
if (GMAIL_DOMAINS.has(domain)) local = local.replaceAll(".", "");
|
|
3031
|
+
if (!local) return null;
|
|
3032
|
+
return local + "@" + domain;
|
|
3033
|
+
};
|
|
3034
|
+
|
|
3035
|
+
// lib/connections/providers/shopify.js
|
|
3036
|
+
var toLine = ({
|
|
3037
|
+
price,
|
|
3038
|
+
product_id: productId,
|
|
3039
|
+
quantity,
|
|
3040
|
+
title,
|
|
3041
|
+
variant_id: variantId,
|
|
3042
|
+
variant_title: variantTitle
|
|
3043
|
+
}) => ({
|
|
3044
|
+
price: parseFloat(price) || 0,
|
|
3045
|
+
productId: productId ? "gid://shopify/Product/" + productId : null,
|
|
3046
|
+
quantity: quantity || 1,
|
|
3047
|
+
title: title || null,
|
|
3048
|
+
variantId: variantId ? "gid://shopify/ProductVariant/" + variantId : null,
|
|
3049
|
+
variantTitle: variantTitle || null
|
|
3050
|
+
});
|
|
3051
|
+
var attributeLineItems = (lineItems = []) => lineItems.reduce(
|
|
3052
|
+
(acc, item) => {
|
|
3053
|
+
const attrs = (item.properties || []).reduce(
|
|
3054
|
+
(map, { name, value }) => {
|
|
3055
|
+
map[name] = value;
|
|
3056
|
+
return map;
|
|
3057
|
+
},
|
|
3058
|
+
{}
|
|
3059
|
+
);
|
|
3060
|
+
if (!attrs["_drwbrdg_ca"]) return acc;
|
|
3061
|
+
if (!Object.keys(acc.attrMap).length) acc.attrMap = attrs;
|
|
3062
|
+
const line = toLine(item);
|
|
3063
|
+
acc.attributedGross += line.price * line.quantity;
|
|
3064
|
+
acc.attributedLines.push(line);
|
|
3065
|
+
return acc;
|
|
3066
|
+
},
|
|
3067
|
+
{ attrMap: {}, attributedGross: 0, attributedLines: [] }
|
|
3068
|
+
);
|
|
3069
|
+
var generateDiscountCode = (0, import_nanoid2.customAlphabet)("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 8);
|
|
3070
|
+
var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
|
|
3071
|
+
var OAUTH_ERROR_SOURCE = "oauth";
|
|
3072
|
+
var OAUTH_GRANT_REVOKED_CODES = ["application_cannot_be_found", "invalid_grant"];
|
|
2286
3073
|
var inbound = {
|
|
2287
3074
|
headers: {
|
|
2288
3075
|
event: "x-shopify-topic",
|
|
@@ -2344,7 +3131,7 @@ var shopify_default2 = {
|
|
|
2344
3131
|
// the App Store listing, and the dashboard must never imply a store can be
|
|
2345
3132
|
// linked from inside it.
|
|
2346
3133
|
redirect: {
|
|
2347
|
-
|
|
3134
|
+
credential: "SHOPIFY_APP_LISTING_URL",
|
|
2348
3135
|
title: "View on the Shopify App Store"
|
|
2349
3136
|
}
|
|
2350
3137
|
},
|
|
@@ -2399,20 +3186,476 @@ var shopify_default2 = {
|
|
|
2399
3186
|
//
|
|
2400
3187
|
// `shopify` is injected for the same reason it is everywhere else — this
|
|
2401
3188
|
// package cannot import @drawbridge/shopify, which depends on it.
|
|
2402
|
-
scopes: ({ scope, shopify }) => scope ? shopify.oauth.missingScopes(scope) : null,
|
|
3189
|
+
scopes: ({ scope }, { shopify } = {}) => scope ? shopify.oauth.missingScopes(scope) : null,
|
|
2403
3190
|
// Shopify's install grant is exchanged inside its own app flow, not
|
|
2404
3191
|
// through the shared OAuth runner.
|
|
2405
3192
|
token: false
|
|
2406
3193
|
},
|
|
2407
|
-
//
|
|
2408
|
-
//
|
|
2409
|
-
//
|
|
2410
|
-
//
|
|
3194
|
+
// THE VENDOR'S OWN WORK, here in full. Every body describes its writes,
|
|
3195
|
+
// enqueues and events for the shell to perform — see contract.js — and
|
|
3196
|
+
// everything it needs arrives as an argument: `read` (the controller's
|
|
3197
|
+
// read methods, nothing that writes), `shopify` (the SDK, injected because
|
|
3198
|
+
// this package cannot import what depends on it), `adminToken` (minted by
|
|
3199
|
+
// the shell, which persists rotations), `mintId` (so one described write
|
|
3200
|
+
// can reference another), `dispatch` (the caller's own coordinator table,
|
|
3201
|
+
// for the hooks that are dispatches).
|
|
2411
3202
|
commerce: {
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
3203
|
+
// MINT A DISCOUNT CODE against the merchant's chosen discount, mapped to
|
|
3204
|
+
// one lead — which is what lets an order that redeems it be attributed
|
|
3205
|
+
// back.
|
|
3206
|
+
code: async ({ connection: connection2, context, step }, { adminToken, shopify } = {}) => {
|
|
3207
|
+
var _a;
|
|
3208
|
+
const discount = (_a = step.settings) == null ? void 0 : _a.discount;
|
|
3209
|
+
const request2 = { email: (context == null ? void 0 : context.email) || null, lead: (context == null ? void 0 : context.lead) || null, shop: connection2.shop };
|
|
3210
|
+
if (!(context == null ? void 0 : context.email)) return { message: "Lead email is missing.", request: request2, response: { skipped: true }, skipped: true };
|
|
3211
|
+
if (!(context == null ? void 0 : context.lead)) return { message: "Lead id is missing.", request: request2, response: { skipped: true }, skipped: true };
|
|
3212
|
+
if (!(discount == null ? void 0 : discount.id)) return { message: "Discount is not configured on this step.", request: request2, response: { skipped: true }, skipped: true };
|
|
3213
|
+
const adminAccessToken = await adminToken();
|
|
3214
|
+
if (!context.shopifyCustomerId) {
|
|
3215
|
+
const customer = await shopify.admin.getOrCreateCustomer({ adminAccessToken, domain: connection2.shop, email: context.email });
|
|
3216
|
+
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 };
|
|
3217
|
+
}
|
|
3218
|
+
const discountCode = await shopify.admin.createDiscountCode({
|
|
3219
|
+
adminAccessToken,
|
|
3220
|
+
code: "DB-" + generateDiscountCode(),
|
|
3221
|
+
discountId: discount.id,
|
|
3222
|
+
domain: connection2.shop
|
|
3223
|
+
});
|
|
3224
|
+
if (!discountCode) return { message: "Shopify did not return a discount code \u2014 create failed.", request: request2, response: { skipped: true }, skipped: true };
|
|
3225
|
+
return {
|
|
3226
|
+
context: {
|
|
3227
|
+
shopifyDiscountCode: discountCode.code,
|
|
3228
|
+
shopifyDiscountId: String(discountCode.id)
|
|
3229
|
+
},
|
|
3230
|
+
message: "Discount code created and linked to lead.",
|
|
3231
|
+
request: request2,
|
|
3232
|
+
response: { code: discountCode.code, id: String(discountCode.id) },
|
|
3233
|
+
// bypassDocumentValidation because these are vendor ids on a
|
|
3234
|
+
// Drawbridge document the schema does not declare — the
|
|
3235
|
+
// canonical-identity work resolves it properly.
|
|
3236
|
+
writes: [{
|
|
3237
|
+
collection: "lead",
|
|
3238
|
+
data: {
|
|
3239
|
+
$set: {
|
|
3240
|
+
shopifyDiscountCode: discountCode.code,
|
|
3241
|
+
shopifyDiscountId: String(discountCode.id)
|
|
3242
|
+
}
|
|
3243
|
+
},
|
|
3244
|
+
operation: "update",
|
|
3245
|
+
options: { bypassDocumentValidation: true },
|
|
3246
|
+
query: { id: context.lead }
|
|
3247
|
+
}]
|
|
3248
|
+
};
|
|
3249
|
+
},
|
|
3250
|
+
// CREATE THE BUYER AT THE STORE, so an order can be attributed to them.
|
|
3251
|
+
//
|
|
3252
|
+
// IDEMPOTENT THREE WAYS, because this runs on every entry and a duplicate
|
|
3253
|
+
// customer at the store is a support ticket: the context may already
|
|
3254
|
+
// carry the id from an earlier step, the lead may already be linked from
|
|
3255
|
+
// an earlier run, and Shopify's own get-or-create settles the rest.
|
|
3256
|
+
customer: async ({ connection: connection2, context }, { adminToken, read, shopify } = {}) => {
|
|
3257
|
+
const request2 = { email: (context == null ? void 0 : context.email) || null, lead: (context == null ? void 0 : context.lead) || null, shop: connection2.shop };
|
|
3258
|
+
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 };
|
|
3259
|
+
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 };
|
|
3260
|
+
if (context.shopifyCustomerId) {
|
|
3261
|
+
return {
|
|
3262
|
+
context: { shopifyCustomerId: context.shopifyCustomerId },
|
|
3263
|
+
message: "Trigger data already includes a Shopify customer id \u2014 reusing.",
|
|
3264
|
+
request: request2,
|
|
3265
|
+
response: { shopifyCustomerId: context.shopifyCustomerId },
|
|
3266
|
+
// Reusing an id is not a creation, so it does not bill.
|
|
3267
|
+
skipped: true
|
|
3268
|
+
};
|
|
3269
|
+
}
|
|
3270
|
+
const lead = await read.get({ collection: "lead", query: { id: context.lead } });
|
|
3271
|
+
if (lead == null ? void 0 : lead.shopifyCustomerId) {
|
|
3272
|
+
return {
|
|
3273
|
+
context: { shopifyCustomerId: lead.shopifyCustomerId },
|
|
3274
|
+
message: "Lead already has a Shopify customer id \u2014 reusing.",
|
|
3275
|
+
request: request2,
|
|
3276
|
+
response: { shopifyCustomerId: lead.shopifyCustomerId },
|
|
3277
|
+
skipped: true
|
|
3278
|
+
};
|
|
3279
|
+
}
|
|
3280
|
+
const adminAccessToken = await adminToken();
|
|
3281
|
+
const parts = ((lead == null ? void 0 : lead.name) || "").trim().split(/\s+/).filter(Boolean);
|
|
3282
|
+
const customer = await shopify.admin.getOrCreateCustomer({
|
|
3283
|
+
adminAccessToken,
|
|
3284
|
+
domain: connection2.shop,
|
|
3285
|
+
email: context.email,
|
|
3286
|
+
firstName: parts.length ? parts[0] : null,
|
|
3287
|
+
lastName: parts.length > 1 ? parts.slice(1).join(" ") : null,
|
|
3288
|
+
source: "drawbridge"
|
|
3289
|
+
});
|
|
3290
|
+
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 };
|
|
3291
|
+
return {
|
|
3292
|
+
context: { shopifyCustomerId: customer.id },
|
|
3293
|
+
message: "Shopify customer created/linked to lead.",
|
|
3294
|
+
request: request2,
|
|
3295
|
+
response: { shopifyCustomerId: customer.id },
|
|
3296
|
+
// The hook's own result, described beside the call that produced it.
|
|
3297
|
+
writes: [{
|
|
3298
|
+
collection: "lead",
|
|
3299
|
+
data: { $set: { shopifyCustomerId: customer.id } },
|
|
3300
|
+
operation: "update",
|
|
3301
|
+
options: { bypassDocumentValidation: true },
|
|
3302
|
+
query: { id: context.lead }
|
|
3303
|
+
}]
|
|
3304
|
+
};
|
|
3305
|
+
},
|
|
3306
|
+
// AN ORDER ARRIVED AT THE STORE. The largest hook in the family, because
|
|
3307
|
+
// attribution genuinely is: an order can reach Drawbridge two ways and
|
|
3308
|
+
// they bill differently.
|
|
3309
|
+
//
|
|
3310
|
+
// CONVERSION — a `_drwbrdg_ca` line-item property, injected at
|
|
3311
|
+
// add-to-cart. Causal: the campaign produced the sale, so
|
|
3312
|
+
// it carries a fee.
|
|
3313
|
+
// REDEMPTION — a DB- discount code matched to a lead. Associative: we
|
|
3314
|
+
// cannot claim we caused the purchase, so it is fee-free.
|
|
3315
|
+
//
|
|
3316
|
+
// Both can be true, and an order already recorded as a conversion can
|
|
3317
|
+
// later have a redemption backfilled onto it — `backfill` below.
|
|
3318
|
+
//
|
|
3319
|
+
// IDEMPOTENT THROUGH THE RETRY, one layer up: two deliveries of the same
|
|
3320
|
+
// order race, the loser's transaction hits a duplicate key, the step
|
|
3321
|
+
// fails and BullMQ redelivers — and the re-run's read at the top finds
|
|
3322
|
+
// what the winner wrote and skips instead of double-billing a merchant
|
|
3323
|
+
// for one purchase. The hook used to loop for this itself; describing
|
|
3324
|
+
// the writes moved the retry to the queue, with the same guarantee.
|
|
3325
|
+
order: async ({ connection: connection2, context }, { logger: logger2, mintId, read } = {}) => {
|
|
3326
|
+
var _a, _b, _c, _d, _e, _f;
|
|
3327
|
+
const {
|
|
3328
|
+
advertisement,
|
|
3329
|
+
created_at: createdAt,
|
|
3330
|
+
currency,
|
|
3331
|
+
customer: orderCustomer,
|
|
3332
|
+
email,
|
|
3333
|
+
id: orderId,
|
|
3334
|
+
line_items: lineItems = [],
|
|
3335
|
+
organization: organization2,
|
|
3336
|
+
phone
|
|
3337
|
+
} = context || {};
|
|
3338
|
+
const request2 = { orderId: orderId ? String(orderId) : null, organization: organization2 };
|
|
3339
|
+
const [existingOrder, existingRedemption] = await Promise.all([
|
|
3340
|
+
read.get({ collection: "order", query: { "provider.id": String(orderId), "provider.slug": "shopify" } }),
|
|
3341
|
+
read.get({ collection: "redemption", query: { "provider.id": String(orderId), "provider.slug": "shopify" } })
|
|
3342
|
+
]);
|
|
3343
|
+
if (existingRedemption) {
|
|
3344
|
+
return {
|
|
3345
|
+
message: "Order/redemption already recorded \u2014 skipping duplicate.",
|
|
3346
|
+
request: request2,
|
|
3347
|
+
response: {
|
|
3348
|
+
existingOrderId: (existingOrder == null ? void 0 : existingOrder.id) || null,
|
|
3349
|
+
existingRedemptionId: existingRedemption.id,
|
|
3350
|
+
skipped: true
|
|
3351
|
+
},
|
|
3352
|
+
skipped: true
|
|
3353
|
+
};
|
|
3354
|
+
}
|
|
3355
|
+
const backfill = !!existingOrder;
|
|
3356
|
+
const { attrMap, attributedGross, attributedLines } = attributeLineItems(lineItems);
|
|
3357
|
+
const campaign = attrMap["_drwbrdg_ca"] || null;
|
|
3358
|
+
const discountCodes = Array.isArray(context == null ? void 0 : context.discount_codes) ? context.discount_codes : [];
|
|
3359
|
+
const codes = [...new Set(discountCodes.map((dc) => dc == null ? void 0 : dc.code).filter(Boolean))];
|
|
3360
|
+
const matchedLeads = codes.length ? await read.aggregate({
|
|
3361
|
+
collection: "lead",
|
|
3362
|
+
pipeline: [{ $match: { organization: organization2, shopifyDiscountCode: { $in: codes } } }]
|
|
3363
|
+
}) : [];
|
|
3364
|
+
const codeToLead = {};
|
|
3365
|
+
for (const found of matchedLeads) {
|
|
3366
|
+
if (found.shopifyDiscountCode) codeToLead[found.shopifyDiscountCode] = found;
|
|
3367
|
+
}
|
|
3368
|
+
const matchedDiscounts = discountCodes.filter((dc) => (dc == null ? void 0 : dc.code) && codeToLead[dc.code]).map((dc) => ({
|
|
3369
|
+
amount: parseFloat(dc.amount) || 0,
|
|
3370
|
+
code: dc.code,
|
|
3371
|
+
id: codeToLead[dc.code].shopifyDiscountId || null
|
|
3372
|
+
}));
|
|
3373
|
+
const matchedLead = matchedDiscounts.length ? codeToLead[matchedDiscounts[0].code] : null;
|
|
3374
|
+
const discount = matchedDiscounts.length ? {
|
|
3375
|
+
amount: matchedDiscounts.reduce((sum, entry) => sum + entry.amount, 0),
|
|
3376
|
+
codes: matchedDiscounts
|
|
3377
|
+
} : null;
|
|
3378
|
+
const matchedCodes = new Set(matchedDiscounts.map((entry) => entry.code));
|
|
3379
|
+
const unmatched = codes.filter((code2) => code2.startsWith("DB-") && !matchedCodes.has(code2));
|
|
3380
|
+
if (unmatched.length) {
|
|
3381
|
+
(_a = logger2 == null ? void 0 : logger2.warn) == null ? void 0 : _a.call(logger2, "shopify.order.discount.unmatched", {
|
|
3382
|
+
campaign: campaign || null,
|
|
3383
|
+
codes: JSON.stringify(unmatched),
|
|
3384
|
+
isConversion: !!campaign,
|
|
3385
|
+
orderId: String(orderId),
|
|
3386
|
+
organization: organization2
|
|
3387
|
+
});
|
|
3388
|
+
}
|
|
3389
|
+
if (!campaign && !discount || backfill && !discount) {
|
|
3390
|
+
return {
|
|
3391
|
+
message: backfill ? "Order already recorded and no Drawbridge discount code matched \u2014 nothing to backfill." : "Order has no Drawbridge attribution \u2014 not recording.",
|
|
3392
|
+
request: request2,
|
|
3393
|
+
response: { skipped: true },
|
|
3394
|
+
skipped: true
|
|
3395
|
+
};
|
|
3396
|
+
}
|
|
3397
|
+
let advertisementId = null;
|
|
3398
|
+
let affiliateId = null;
|
|
3399
|
+
let campaignOrganization = organization2;
|
|
3400
|
+
let gross = 0;
|
|
3401
|
+
let leadId = null;
|
|
3402
|
+
let lines = [];
|
|
3403
|
+
let orderCampaign = null;
|
|
3404
|
+
let pageId = null;
|
|
3405
|
+
const isConversion = !!campaign;
|
|
3406
|
+
const customerPhone = toE164((orderCustomer == null ? void 0 : orderCustomer.phone) || phone) || null;
|
|
3407
|
+
const matchPhones = [...new Set([
|
|
3408
|
+
customerPhone,
|
|
3409
|
+
toE164((_b = context == null ? void 0 : context.billing_address) == null ? void 0 : _b.phone),
|
|
3410
|
+
toE164((_c = context == null ? void 0 : context.shipping_address) == null ? void 0 : _c.phone)
|
|
3411
|
+
].filter(Boolean))];
|
|
3412
|
+
if (isConversion) {
|
|
3413
|
+
const campaignDoc = await read.get({ collection: "campaign", query: { id: campaign } });
|
|
3414
|
+
if (!campaignDoc || campaignDoc.organization !== organization2) {
|
|
3415
|
+
return {
|
|
3416
|
+
message: "Order carried a campaign attribution that does not belong to this store \u2014 not recording.",
|
|
3417
|
+
request: request2,
|
|
3418
|
+
response: { skipped: true },
|
|
3419
|
+
skipped: true
|
|
3420
|
+
};
|
|
3421
|
+
}
|
|
3422
|
+
advertisementId = attrMap["_drwbrdg_ad"] || advertisement || null;
|
|
3423
|
+
affiliateId = attrMap["_drwbrdg_af"] || null;
|
|
3424
|
+
campaignOrganization = campaignDoc.organization;
|
|
3425
|
+
gross = attributedGross;
|
|
3426
|
+
lines = attributedLines;
|
|
3427
|
+
orderCampaign = campaign;
|
|
3428
|
+
pageId = attrMap["_drwbrdg_pg"] || null;
|
|
3429
|
+
const identifiers = [];
|
|
3430
|
+
const canonicalEmail = toCanonicalEmail(email);
|
|
3431
|
+
if (email) identifiers.push({ email: email.toLowerCase() });
|
|
3432
|
+
if (canonicalEmail) identifiers.push({ "canonical.email.value": canonicalEmail });
|
|
3433
|
+
if (matchPhones.length) identifiers.push({ "phone.number": { $in: matchPhones } });
|
|
3434
|
+
if (matchPhones.length) identifiers.push({ "canonical.phone.value": { $in: matchPhones } });
|
|
3435
|
+
if (identifiers.length) {
|
|
3436
|
+
const lead = await read.get({
|
|
3437
|
+
collection: "lead",
|
|
3438
|
+
query: {
|
|
3439
|
+
campaigns: { $in: [campaign] },
|
|
3440
|
+
organization: campaignOrganization,
|
|
3441
|
+
$or: identifiers
|
|
3442
|
+
}
|
|
3443
|
+
});
|
|
3444
|
+
leadId = (lead == null ? void 0 : lead.id) || null;
|
|
3445
|
+
if (!leadId) {
|
|
3446
|
+
const orgLead = await read.get({
|
|
3447
|
+
collection: "lead",
|
|
3448
|
+
query: { organization: campaignOrganization, $or: identifiers }
|
|
3449
|
+
});
|
|
3450
|
+
leadId = (orgLead == null ? void 0 : orgLead.id) || null;
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
} else {
|
|
3454
|
+
leadId = matchedLead.id;
|
|
3455
|
+
orderCampaign = (matchedLead.campaigns || []).length === 1 ? matchedLead.campaigns[0] : null;
|
|
3456
|
+
gross = lineItems.reduce((sum, item) => {
|
|
3457
|
+
const line = toLine(item);
|
|
3458
|
+
return sum + line.price * line.quantity;
|
|
3459
|
+
}, 0);
|
|
3460
|
+
lines = lineItems.map(toLine);
|
|
3461
|
+
}
|
|
3462
|
+
const org = await read.get({ collection: "organization", query: { id: campaignOrganization } });
|
|
3463
|
+
let rate = 0;
|
|
3464
|
+
if (isConversion) {
|
|
3465
|
+
const subscription = await read.get({ collection: "subscription", query: { id: org == null ? void 0 : org.subscription } });
|
|
3466
|
+
rate = conversionRate(subscription);
|
|
3467
|
+
}
|
|
3468
|
+
const fee = isConversion ? Math.round(gross * rate) / 100 : 0;
|
|
3469
|
+
const net2 = Math.round((gross - fee) * 100) / 100;
|
|
3470
|
+
const currencyCode = (currency || "usd").toLowerCase();
|
|
3471
|
+
const purchasedAt = new Date(createdAt || Date.now());
|
|
3472
|
+
const customer = orderCustomer || email || phone ? {
|
|
3473
|
+
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,
|
|
3474
|
+
email: (orderCustomer == null ? void 0 : orderCustomer.email) || email || null,
|
|
3475
|
+
firstName: (orderCustomer == null ? void 0 : orderCustomer.first_name) || null,
|
|
3476
|
+
id: (orderCustomer == null ? void 0 : orderCustomer.id) ? String(orderCustomer.id) : null,
|
|
3477
|
+
lastName: (orderCustomer == null ? void 0 : orderCustomer.last_name) || null,
|
|
3478
|
+
phone: customerPhone
|
|
3479
|
+
} : null;
|
|
3480
|
+
const source = (connection2 == null ? void 0 : connection2.source) ? { domain: connection2.source.domain, id: connection2.source.id } : void 0;
|
|
3481
|
+
const orderDocId = (existingOrder == null ? void 0 : existingOrder.id) || (isConversion && !backfill ? mintId() : null);
|
|
3482
|
+
const writes = [];
|
|
3483
|
+
if (isConversion && !backfill) {
|
|
3484
|
+
writes.push({
|
|
3485
|
+
collection: "order",
|
|
3486
|
+
data: {
|
|
3487
|
+
advertisement: advertisementId,
|
|
3488
|
+
affiliate: affiliateId,
|
|
3489
|
+
campaign: orderCampaign,
|
|
3490
|
+
currency: currencyCode,
|
|
3491
|
+
customer,
|
|
3492
|
+
discount,
|
|
3493
|
+
fee,
|
|
3494
|
+
gross,
|
|
3495
|
+
id: orderDocId,
|
|
3496
|
+
lead: leadId,
|
|
3497
|
+
lines,
|
|
3498
|
+
net: net2,
|
|
3499
|
+
organization: campaignOrganization,
|
|
3500
|
+
page: pageId,
|
|
3501
|
+
provider: { id: String(orderId), slug: "shopify" },
|
|
3502
|
+
purchasedAt,
|
|
3503
|
+
rate,
|
|
3504
|
+
source,
|
|
3505
|
+
status: "completed"
|
|
3506
|
+
},
|
|
3507
|
+
operation: "create"
|
|
3508
|
+
});
|
|
3509
|
+
if (org == null ? void 0 : org.usage) {
|
|
3510
|
+
writes.push({
|
|
3511
|
+
collection: "usage",
|
|
3512
|
+
data: { $inc: { "totals.revenue": gross } },
|
|
3513
|
+
operation: "update",
|
|
3514
|
+
query: { id: org.usage }
|
|
3515
|
+
});
|
|
3516
|
+
}
|
|
3517
|
+
if (leadId) {
|
|
3518
|
+
writes.push({
|
|
3519
|
+
collection: "lead",
|
|
3520
|
+
data: { $inc: { "totals.orders": 1 } },
|
|
3521
|
+
operation: "update",
|
|
3522
|
+
options: { bypassDocumentValidation: true },
|
|
3523
|
+
query: { id: leadId }
|
|
3524
|
+
});
|
|
3525
|
+
}
|
|
3526
|
+
}
|
|
3527
|
+
if (discount) {
|
|
3528
|
+
writes.push({
|
|
3529
|
+
collection: "redemption",
|
|
3530
|
+
data: {
|
|
3531
|
+
advertisement: advertisementId,
|
|
3532
|
+
affiliate: affiliateId,
|
|
3533
|
+
campaign: orderCampaign,
|
|
3534
|
+
code: ((_e = matchedDiscounts[0]) == null ? void 0 : _e.code) || null,
|
|
3535
|
+
currency: currencyCode,
|
|
3536
|
+
customer,
|
|
3537
|
+
discount,
|
|
3538
|
+
gross,
|
|
3539
|
+
lead: leadId,
|
|
3540
|
+
order: orderDocId,
|
|
3541
|
+
organization: campaignOrganization,
|
|
3542
|
+
page: pageId,
|
|
3543
|
+
provider: { id: String(orderId), slug: "shopify" },
|
|
3544
|
+
purchasedAt,
|
|
3545
|
+
source,
|
|
3546
|
+
status: "completed"
|
|
3547
|
+
},
|
|
3548
|
+
operation: "create"
|
|
3549
|
+
});
|
|
3550
|
+
if (org == null ? void 0 : org.usage) {
|
|
3551
|
+
writes.push({
|
|
3552
|
+
collection: "usage",
|
|
3553
|
+
data: { $inc: { "totals.redemptions": 1 } },
|
|
3554
|
+
operation: "update",
|
|
3555
|
+
query: { id: org.usage }
|
|
3556
|
+
});
|
|
3557
|
+
}
|
|
3558
|
+
if (leadId) {
|
|
3559
|
+
writes.push({
|
|
3560
|
+
collection: "lead",
|
|
3561
|
+
data: { $inc: { "totals.redemptions": 1 } },
|
|
3562
|
+
operation: "update",
|
|
3563
|
+
options: { bypassDocumentValidation: true },
|
|
3564
|
+
query: { id: leadId }
|
|
3565
|
+
});
|
|
3566
|
+
}
|
|
3567
|
+
}
|
|
3568
|
+
const enqueues = (org == null ? void 0 : org.billingProvider) === "shopify" && fee > 0 && ((_f = connection2 == null ? void 0 : connection2.source) == null ? void 0 : _f.id) && !backfill ? [{
|
|
3569
|
+
data: {
|
|
3570
|
+
idempotencyKey: String(orderId),
|
|
3571
|
+
orderDocId,
|
|
3572
|
+
orderId: String(orderId),
|
|
3573
|
+
rate,
|
|
3574
|
+
shopId: connection2.source.id,
|
|
3575
|
+
// The App Events API returns no event id, so one is generated
|
|
3576
|
+
// here — the event handle plus the order id — and sent as the
|
|
3577
|
+
// event's `reference`. queue/usage.js stamps the same id onto
|
|
3578
|
+
// the order as billed.transaction.
|
|
3579
|
+
transaction: "drawbridge-orders." + orderId,
|
|
3580
|
+
value: Math.round(fee * 100)
|
|
3581
|
+
},
|
|
3582
|
+
name: "billing",
|
|
3583
|
+
options: { jobId: "shopify.usage." + orderId },
|
|
3584
|
+
queue: "usage"
|
|
3585
|
+
}] : [];
|
|
3586
|
+
return {
|
|
3587
|
+
enqueues,
|
|
3588
|
+
message: backfill ? "Redemption backfilled for an already-recorded order." : isConversion ? "Order recorded." : "Discount redemption recorded (fee-free).",
|
|
3589
|
+
request: request2,
|
|
3590
|
+
response: {
|
|
3591
|
+
campaign: orderCampaign,
|
|
3592
|
+
currency: currencyCode,
|
|
3593
|
+
discount,
|
|
3594
|
+
fee,
|
|
3595
|
+
gross,
|
|
3596
|
+
lead: leadId,
|
|
3597
|
+
lines: lines.length,
|
|
3598
|
+
net: net2,
|
|
3599
|
+
orderId: String(orderId)
|
|
3600
|
+
},
|
|
3601
|
+
// ONE TRANSACTION. The order, the redemption and both totals
|
|
3602
|
+
// counters land together or not at all — a half-written attribution
|
|
3603
|
+
// is revenue counted twice or not at all, and neither is
|
|
3604
|
+
// recoverable by hand.
|
|
3605
|
+
transaction: writes.length > 0,
|
|
3606
|
+
writes
|
|
3607
|
+
};
|
|
3608
|
+
},
|
|
3609
|
+
// A PRODUCT CHANGED AT THE STORE. Upserts the product row and hands it to
|
|
3610
|
+
// the product pipeline; the actual field sync happens there.
|
|
3611
|
+
//
|
|
3612
|
+
// The shell has already refused a missing or inactive Shopify connection,
|
|
3613
|
+
// so what is left is the two things only this hook can know are wrong.
|
|
3614
|
+
product: async ({ connection: connection2, context, workflow }, { mintId } = {}) => {
|
|
3615
|
+
const request2 = {
|
|
3616
|
+
numericId: (context == null ? void 0 : context.id) || null,
|
|
3617
|
+
organizationId: workflow.organization,
|
|
3618
|
+
title: (context == null ? void 0 : context.title) || null
|
|
3619
|
+
};
|
|
3620
|
+
if (!(context == null ? void 0 : context.id)) return { message: "Skipped \u2014 product webhook payload had no id.", request: request2, response: { skipped: true }, skipped: true };
|
|
3621
|
+
if (!connection2.shop) return { message: "Skipped \u2014 Shopify connection is missing shop domain.", request: request2, response: { skipped: true }, skipped: true };
|
|
3622
|
+
const providerId = "gid://shopify/Product/" + context.id;
|
|
3623
|
+
const productId = mintId();
|
|
3624
|
+
return {
|
|
3625
|
+
enqueues: [{
|
|
3626
|
+
data: { product: productId, providerId, shop: connection2.shop },
|
|
3627
|
+
name: "workflow",
|
|
3628
|
+
options: { jobId: "product.workflow.shopify." + providerId + "." + Date.now() },
|
|
3629
|
+
queue: "product.shopify"
|
|
3630
|
+
}],
|
|
3631
|
+
message: "Product sync queued from Shopify webhook.",
|
|
3632
|
+
request: request2,
|
|
3633
|
+
response: { productId, providerId, title: (context == null ? void 0 : context.title) || null },
|
|
3634
|
+
// KEYED ON PROVIDER + SHOP, so the same product in two stores stays
|
|
3635
|
+
// two rows. `connections` accumulates rather than replaces: one
|
|
3636
|
+
// store can be linked to several organizations, and each keeps its
|
|
3637
|
+
// own claim on the row.
|
|
3638
|
+
writes: [{
|
|
3639
|
+
collection: "product",
|
|
3640
|
+
data: {
|
|
3641
|
+
$addToSet: { connections: connection2.id },
|
|
3642
|
+
$setOnInsert: {
|
|
3643
|
+
id: productId,
|
|
3644
|
+
provider: { id: providerId, slug: "shopify" },
|
|
3645
|
+
"source.id": connection2.id,
|
|
3646
|
+
status: "active"
|
|
3647
|
+
}
|
|
3648
|
+
},
|
|
3649
|
+
operation: "update",
|
|
3650
|
+
options: { upsert: true },
|
|
3651
|
+
query: {
|
|
3652
|
+
"provider.id": providerId,
|
|
3653
|
+
"provider.slug": "shopify",
|
|
3654
|
+
"source.domain": connection2.shop
|
|
3655
|
+
}
|
|
3656
|
+
}]
|
|
3657
|
+
};
|
|
3658
|
+
}
|
|
2416
3659
|
},
|
|
2417
3660
|
contacts: { remove: false, sync: false },
|
|
2418
3661
|
// verify and event lean entirely on the shared HMAC helper — Shopify's
|
|
@@ -2430,7 +3673,16 @@ var shopify_default2 = {
|
|
|
2430
3673
|
sms: false,
|
|
2431
3674
|
inbound: {
|
|
2432
3675
|
event: (args) => readEventHeader({ ...args, descriptor: inbound }),
|
|
2433
|
-
|
|
3676
|
+
// One hook over the whole topic table, because that is what this
|
|
3677
|
+
// manifest declares: Shopify processes its own buffered events. The
|
|
3678
|
+
// topic rides in on the context rather than being a second hook name per
|
|
3679
|
+
// topic; the caller's handler table arrives as a prop.
|
|
3680
|
+
process: async ({ context }, { dispatch } = {}) => {
|
|
3681
|
+
const key = "shopify." + (context == null ? void 0 : context.topic);
|
|
3682
|
+
const handled = await dispatch({ data: context == null ? void 0 : context.data, handler: key });
|
|
3683
|
+
if (!handled) return { message: "No handler for " + key, skipped: true };
|
|
3684
|
+
return { message: "Processed " + key, request: { topic: context == null ? void 0 : context.topic } };
|
|
3685
|
+
},
|
|
2434
3686
|
receive: ({ channel, event, headers, payload }) => {
|
|
2435
3687
|
if (channel === "compliance" && !COMPLIANCE_TOPICS.has(event)) {
|
|
2436
3688
|
throw Object.assign(new Error("Unrecognized compliance topic: " + event), { status: 401 });
|
|
@@ -2446,7 +3698,107 @@ var shopify_default2 = {
|
|
|
2446
3698
|
},
|
|
2447
3699
|
verify: (args) => verifySignature({ ...args, descriptor: inbound })
|
|
2448
3700
|
},
|
|
2449
|
-
lifecycle: {
|
|
3701
|
+
lifecycle: {
|
|
3702
|
+
// DISPATCHES INTO THE CALLER'S OWN COORDINATORS. These three are
|
|
3703
|
+
// declarations made true: the work is queue orchestration over
|
|
3704
|
+
// Drawbridge's own collections, which is coordinator work and stays in
|
|
3705
|
+
// the repo that owns the queues. The hook receives the dispatch table as
|
|
3706
|
+
// a prop and picks the entry, so the manifest owns the SEAM — asking
|
|
3707
|
+
// Shopify whether it handles its own lifecycle now gets a real function
|
|
3708
|
+
// instead of `unimplemented` while the work happened anyway.
|
|
3709
|
+
cleanup: async ({ context }, { dispatch } = {}) => {
|
|
3710
|
+
await dispatch({ data: context, handler: "cleanup" });
|
|
3711
|
+
return { message: "Ran shopify lifecycle.cleanup", request: context || null };
|
|
3712
|
+
},
|
|
3713
|
+
// KEEP STORE ACCESS WORKING. Not a webhook monitor, despite the name the
|
|
3714
|
+
// step once carried — webhooks are declarative, declared in the app's
|
|
3715
|
+
// toml and applied by Shopify to every install, so nothing here registers
|
|
3716
|
+
// or checks them.
|
|
3717
|
+
//
|
|
3718
|
+
// It rotates the refresh token before its window closes, proves the
|
|
3719
|
+
// access token still works, reconciles the scopes the store granted
|
|
3720
|
+
// against the ones the app now needs, and queues a webhook
|
|
3721
|
+
// reconciliation.
|
|
3722
|
+
health: async ({ connection: connection2, workflow }, { adminToken, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {}) => {
|
|
3723
|
+
const request2 = {
|
|
3724
|
+
connectionId: workflow.connection,
|
|
3725
|
+
organizationId: workflow.organization,
|
|
3726
|
+
shop: connection2.shop
|
|
3727
|
+
};
|
|
3728
|
+
const refreshTokenAtStart = (await resolveSettings()).refreshToken || null;
|
|
3729
|
+
try {
|
|
3730
|
+
const adminAccessToken = await adminToken();
|
|
3731
|
+
const settings = await resolveSettings();
|
|
3732
|
+
const refreshTokenExpiresAt = settings.refreshTokenExpiresAt;
|
|
3733
|
+
const needsRotation = refreshTokenExpiresAt && new Date(refreshTokenExpiresAt) < new Date(Date.now() + REFRESH_TOKEN_FRESHNESS_BUFFER_MS);
|
|
3734
|
+
let refreshTokenRotated = false;
|
|
3735
|
+
if (needsRotation) {
|
|
3736
|
+
await rotateToken();
|
|
3737
|
+
refreshTokenRotated = true;
|
|
3738
|
+
}
|
|
3739
|
+
await shopify.oauth.ping({ adminAccessToken, domain: connection2.shop });
|
|
3740
|
+
const scopesMissing = await reconcileScopes({ shop: connection2.shop });
|
|
3741
|
+
return {
|
|
3742
|
+
enqueues: [{
|
|
3743
|
+
data: {
|
|
3744
|
+
data: {
|
|
3745
|
+
connectionId: workflow.connection,
|
|
3746
|
+
organizationId: workflow.organization
|
|
3747
|
+
},
|
|
3748
|
+
event: "shopify.register.webhooks"
|
|
3749
|
+
},
|
|
3750
|
+
name: "register",
|
|
3751
|
+
options: { jobId: "connection.update.register." + workflow.connection + "." + (0, import_node_crypto4.randomUUID)() },
|
|
3752
|
+
queue: "connection"
|
|
3753
|
+
}],
|
|
3754
|
+
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.",
|
|
3755
|
+
request: request2,
|
|
3756
|
+
response: {
|
|
3757
|
+
pingedAt: /* @__PURE__ */ new Date(),
|
|
3758
|
+
refreshTokenExpiresAt: refreshTokenExpiresAt || null,
|
|
3759
|
+
refreshTokenRotated,
|
|
3760
|
+
scopesMissing,
|
|
3761
|
+
webhookReconciliationQueued: true
|
|
3762
|
+
}
|
|
3763
|
+
};
|
|
3764
|
+
} catch (error) {
|
|
3765
|
+
if (OAUTH_GRANT_REVOKED_CODES.includes(error.code)) {
|
|
3766
|
+
const current = await read.get({ collection: "connection", query: { id: connection2.id } });
|
|
3767
|
+
const refreshTokenStored = current ? (await resolveSettings(current)).refreshToken || null : null;
|
|
3768
|
+
const rotated = error.code === "invalid_grant" && refreshTokenStored !== refreshTokenAtStart;
|
|
3769
|
+
if (current && !rotated) {
|
|
3770
|
+
const others = (current.errors || []).filter((entry) => entry.source !== OAUTH_ERROR_SOURCE);
|
|
3771
|
+
error.writes = [{
|
|
3772
|
+
collection: "connection",
|
|
3773
|
+
data: {
|
|
3774
|
+
$set: {
|
|
3775
|
+
errors: [
|
|
3776
|
+
...others,
|
|
3777
|
+
{
|
|
3778
|
+
message: "Shopify disconnected this store. Open the Drawbridge app in your Shopify admin to reconnect.",
|
|
3779
|
+
source: OAUTH_ERROR_SOURCE
|
|
3780
|
+
}
|
|
3781
|
+
],
|
|
3782
|
+
status: "error"
|
|
3783
|
+
}
|
|
3784
|
+
},
|
|
3785
|
+
operation: "update",
|
|
3786
|
+
query: { id: connection2.id }
|
|
3787
|
+
}];
|
|
3788
|
+
}
|
|
3789
|
+
}
|
|
3790
|
+
throw error;
|
|
3791
|
+
}
|
|
3792
|
+
},
|
|
3793
|
+
register: async ({ context }, { dispatch } = {}) => {
|
|
3794
|
+
await dispatch({ data: context, handler: "register" });
|
|
3795
|
+
return { message: "Ran shopify lifecycle.register", request: context || null };
|
|
3796
|
+
},
|
|
3797
|
+
rehydrate: async ({ context }, { dispatch } = {}) => {
|
|
3798
|
+
await dispatch({ data: context, handler: "rehydrate" });
|
|
3799
|
+
return { message: "Ran shopify lifecycle.rehydrate", request: context || null };
|
|
3800
|
+
}
|
|
3801
|
+
},
|
|
2450
3802
|
resources: {
|
|
2451
3803
|
audiences: false,
|
|
2452
3804
|
// Shopify has no separate price resource — a price belongs to a product
|
|
@@ -2466,7 +3818,7 @@ var shopify_default2 = {
|
|
|
2466
3818
|
// credential is the caller's job because it is Drawbridge's job: the
|
|
2467
3819
|
// admin token refreshes and writes itself back, which is service work,
|
|
2468
3820
|
// not vendor work.
|
|
2469
|
-
products: async ({ cursor, limit = 100, search, settings,
|
|
3821
|
+
products: async ({ cursor, limit = 100, search, settings, sort }, { shopify } = {}) => {
|
|
2470
3822
|
var _a, _b, _c, _d;
|
|
2471
3823
|
const products = await shopify.storefront.getProducts({
|
|
2472
3824
|
cursor,
|
|
@@ -2486,7 +3838,7 @@ var shopify_default2 = {
|
|
|
2486
3838
|
}
|
|
2487
3839
|
};
|
|
2488
3840
|
},
|
|
2489
|
-
promotions: async ({ cursor, limit = 100, search, settings, shopify }) => {
|
|
3841
|
+
promotions: async ({ cursor, limit = 100, search, settings }, { shopify } = {}) => {
|
|
2490
3842
|
var _a, _b;
|
|
2491
3843
|
const discounts = await shopify.admin.getDiscounts({
|
|
2492
3844
|
adminAccessToken: settings == null ? void 0 : settings.adminAccessToken,
|
|
@@ -2532,6 +3884,19 @@ var shopify_default2 = {
|
|
|
2532
3884
|
const shop = (data2 == null ? void 0 : data2.shop) || ((_a = data2 == null ? void 0 : data2.settings) == null ? void 0 : _a.domain);
|
|
2533
3885
|
return shop ? "https://admin.shopify.com/store/" + String(shop).replace(".myshopify.com", "") + "/apps/" + (env == null ? void 0 : env.SHOPIFY_APP_HANDLE) : void 0;
|
|
2534
3886
|
},
|
|
3887
|
+
// DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
|
|
3888
|
+
// what an admin types on the provider screen. The four names below are exactly
|
|
3889
|
+
// what `requires` gates on, which is the point of declaring them together: a
|
|
3890
|
+
// name required by the manifest and enterable nowhere is a vendor that can
|
|
3891
|
+
// never go live from the admin screen.
|
|
3892
|
+
provider: {
|
|
3893
|
+
fields: [
|
|
3894
|
+
{ input: "text", key: "apiKey", credential: "SHOPIFY_API_KEY", label: "API key", required: true },
|
|
3895
|
+
{ input: "password", key: "apiSecret", credential: "SHOPIFY_API_SECRET", label: "API secret", redact: true, required: true },
|
|
3896
|
+
{ input: "text", key: "appHandle", credential: "SHOPIFY_APP_HANDLE", label: "App handle", required: true },
|
|
3897
|
+
{ input: "text", key: "listingUrl", credential: "SHOPIFY_APP_LISTING_URL", label: "App listing URL", required: true }
|
|
3898
|
+
]
|
|
3899
|
+
},
|
|
2535
3900
|
// A pre-launch integration: it only surfaces once the App Store listing
|
|
2536
3901
|
// exists and the app is fully configured. Requiring all four means it can
|
|
2537
3902
|
// never render half-configured — and absence of any one excludes the
|
|
@@ -2565,9 +3930,6 @@ var shopify_default2 = {
|
|
|
2565
3930
|
// workflow document, and those strings cannot be renamed without a backfill.
|
|
2566
3931
|
//
|
|
2567
3932
|
// EVERY LEAF IS A FUNCTION so a step can read the merchant's own connection.
|
|
2568
|
-
// The bodies these point at still live in drawbridge-sync; moving them is the
|
|
2569
|
-
// next unit, and commerce.order.record is the one that decides whether the
|
|
2570
|
-
// shape holds — 569 lines and 15 controller calls.
|
|
2571
3933
|
steps: {
|
|
2572
3934
|
commerce: {
|
|
2573
3935
|
code: {
|
|
@@ -2672,8 +4034,8 @@ var shopify_default2 = {
|
|
|
2672
4034
|
title: "Shopify"
|
|
2673
4035
|
};
|
|
2674
4036
|
|
|
2675
|
-
// lib/connections/webhook.js
|
|
2676
|
-
var
|
|
4037
|
+
// lib/connections/providers/webhook.js
|
|
4038
|
+
var import_node_crypto5 = __toESM(require("crypto"), 1);
|
|
2677
4039
|
|
|
2678
4040
|
// lib/safe-http.js
|
|
2679
4041
|
var import_dns2 = __toESM(require("dns"), 1);
|
|
@@ -2810,7 +4172,7 @@ var safeRequest = async ({
|
|
|
2810
4172
|
}
|
|
2811
4173
|
};
|
|
2812
4174
|
|
|
2813
|
-
// lib/connections/webhook.js
|
|
4175
|
+
// lib/connections/providers/webhook.js
|
|
2814
4176
|
var webhook_default = {
|
|
2815
4177
|
// Connecting GENERATES the secret rather than storing one the merchant typed,
|
|
2816
4178
|
// so the buttons say what actually happens.
|
|
@@ -2897,16 +4259,15 @@ var webhook_default = {
|
|
|
2897
4259
|
// That is the rule the whole split runs on: a hook lives in sync only if it
|
|
2898
4260
|
// needs Drawbridge's own database, sockets or queues. This one does not.
|
|
2899
4261
|
webhook: {
|
|
2900
|
-
send: async ({ context,
|
|
4262
|
+
send: async ({ context, lead, settings, step }, { request: send2 = safeRequest } = {}) => {
|
|
2901
4263
|
const { headers = {}, method = "POST", url } = step.settings || {};
|
|
2902
4264
|
const request2 = { method, url: url || null };
|
|
2903
4265
|
if (!url) return { message: "Outgoing webhook URL is not configured for this step.", request: request2, response: { skipped: true }, skipped: true };
|
|
2904
|
-
const lead = (context == null ? void 0 : context.lead) ? await controller.get({ collection: "lead", query: { id: context.lead } }) : null;
|
|
2905
4266
|
const body = lead || context;
|
|
2906
4267
|
request2.body = body;
|
|
2907
4268
|
const outgoing = { ...headers };
|
|
2908
4269
|
if (settings == null ? void 0 : settings.secret) {
|
|
2909
|
-
outgoing["X-Drawbridge-Signature"] = "sha256=" +
|
|
4270
|
+
outgoing["X-Drawbridge-Signature"] = "sha256=" + import_node_crypto5.default.createHmac("sha256", settings.secret).update(JSON.stringify(body)).digest("hex");
|
|
2910
4271
|
}
|
|
2911
4272
|
const response = await send2({ body, headers: outgoing, method, url });
|
|
2912
4273
|
return { message: "Webhook POSTed to " + url + ".", request: request2, response: response || { delivered: true } };
|
|
@@ -2977,7 +4338,7 @@ var leaves = (node, path = []) => Object.entries(node || {}).flatMap(
|
|
|
2977
4338
|
([key, value]) => typeof value === "function" ? [[[...path, key].join("."), value]] : value && typeof value === "object" ? leaves(value, [...path, key]) : []
|
|
2978
4339
|
);
|
|
2979
4340
|
var build = (manifest) => {
|
|
2980
|
-
var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p, _q;
|
|
4341
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o, _p, _q, _r;
|
|
2981
4342
|
if (!(manifest == null ? void 0 : manifest.slug)) throw new Error("A connection needs a slug");
|
|
2982
4343
|
if (!(manifest == null ? void 0 : manifest.title)) throw new Error(manifest.slug + " needs a title");
|
|
2983
4344
|
if (!(manifest == null ? void 0 : manifest.private) && !(manifest == null ? void 0 : manifest.feature)) throw new Error(manifest.slug + " needs a plan feature key");
|
|
@@ -3007,6 +4368,17 @@ var build = (manifest) => {
|
|
|
3007
4368
|
}
|
|
3008
4369
|
}
|
|
3009
4370
|
}
|
|
4371
|
+
for (const field2 of ((_c = manifest.provider) == null ? void 0 : _c.fields) || []) {
|
|
4372
|
+
if (!(field2 == null ? void 0 : field2.key) || !(field2 == null ? void 0 : field2.label)) {
|
|
4373
|
+
throw new Error(manifest.slug + " declares a provider field with no key or label");
|
|
4374
|
+
}
|
|
4375
|
+
if (!INPUTS.includes(field2.input)) {
|
|
4376
|
+
throw new Error(manifest.slug + ".provider." + field2.key + " needs an input the admin form can render \u2014 one of " + INPUTS.join(", "));
|
|
4377
|
+
}
|
|
4378
|
+
if (field2.input === "password" && !field2.redact) {
|
|
4379
|
+
throw new Error(manifest.slug + ".provider." + field2.key + " is a password and must declare redact : true \u2014 the api would hand the value back");
|
|
4380
|
+
}
|
|
4381
|
+
}
|
|
3010
4382
|
if (typeof (manifest == null ? void 0 : manifest.icon) !== "string" || !manifest.icon.includes("<svg")) {
|
|
3011
4383
|
throw new Error(manifest.slug + " needs an icon \u2014 the svg markup itself, not a path to one");
|
|
3012
4384
|
}
|
|
@@ -3019,23 +4391,23 @@ var build = (manifest) => {
|
|
|
3019
4391
|
if (!GROUPS.includes(manifest == null ? void 0 : manifest.group)) {
|
|
3020
4392
|
throw new Error(manifest.slug + " needs a group \u2014 one of " + GROUPS.join(", "));
|
|
3021
4393
|
}
|
|
3022
|
-
if ((
|
|
4394
|
+
if ((_d = manifest == null ? void 0 : manifest.connect) == null ? void 0 : _d.type) {
|
|
3023
4395
|
throw new Error(manifest.slug + " declares connect.type \u2014 that is auth.type now");
|
|
3024
4396
|
}
|
|
3025
|
-
if (!AUTH_TYPES.includes((
|
|
4397
|
+
if (!AUTH_TYPES.includes((_e = manifest == null ? void 0 : manifest.auth) == null ? void 0 : _e.type)) {
|
|
3026
4398
|
throw new Error(manifest.slug + " needs auth.type \u2014 one of " + AUTH_TYPES.join(", "));
|
|
3027
4399
|
}
|
|
3028
4400
|
if (manifest.auth.type === "oauth") {
|
|
3029
4401
|
for (const field2 of OAUTH_FIELDS) {
|
|
3030
|
-
if (!((
|
|
4402
|
+
if (!((_f = manifest.auth.oauth) == null ? void 0 : _f[field2])) {
|
|
3031
4403
|
throw new Error(manifest.slug + " is oauth and must declare auth.oauth." + field2);
|
|
3032
4404
|
}
|
|
3033
4405
|
}
|
|
3034
|
-
if (typeof ((
|
|
4406
|
+
if (typeof ((_h = (_g = manifest.hooks) == null ? void 0 : _g.auth) == null ? void 0 : _h.token) !== "function") {
|
|
3035
4407
|
throw new Error(manifest.slug + " is oauth and must implement hooks.auth.token \u2014 point it at authToken() or wrap it");
|
|
3036
4408
|
}
|
|
3037
4409
|
for (const url of OAUTH_URLS) {
|
|
3038
|
-
if (!((
|
|
4410
|
+
if (!((_j = (_i = manifest.auth.oauth) == null ? void 0 : _i.urls) == null ? void 0 : _j[url])) {
|
|
3039
4411
|
throw new Error(manifest.slug + " is oauth and must declare auth.oauth.urls." + url);
|
|
3040
4412
|
}
|
|
3041
4413
|
}
|
|
@@ -3045,16 +4417,16 @@ var build = (manifest) => {
|
|
|
3045
4417
|
);
|
|
3046
4418
|
}
|
|
3047
4419
|
}
|
|
3048
|
-
if (implemented(manifest.hooks, "inbound.event") && !((
|
|
4420
|
+
if (implemented(manifest.hooks, "inbound.event") && !((_l = (_k = manifest.inbound) == null ? void 0 : _k.headers) == null ? void 0 : _l.event)) {
|
|
3049
4421
|
throw new Error(manifest.slug + " implements inbound.event but declares no inbound.headers.event");
|
|
3050
4422
|
}
|
|
3051
|
-
if (implemented(manifest.hooks, "inbound.verify") && !((
|
|
4423
|
+
if (implemented(manifest.hooks, "inbound.verify") && !((_n = (_m = manifest.inbound) == null ? void 0 : _m.headers) == null ? void 0 : _n.signature)) {
|
|
3052
4424
|
throw new Error(manifest.slug + " implements inbound.verify but declares no inbound.headers.signature");
|
|
3053
4425
|
}
|
|
3054
4426
|
if (typeof (manifest == null ? void 0 : manifest.status) !== "function") {
|
|
3055
4427
|
throw new Error(manifest.slug + " must declare status( data ) \u2014 return null to accept the connection's own status, or { message, status } to override it");
|
|
3056
4428
|
}
|
|
3057
|
-
if (!Array.isArray((
|
|
4429
|
+
if (!Array.isArray((_o = manifest == null ? void 0 : manifest.content) == null ? void 0 : _o.guide) || !manifest.content.guide.length) {
|
|
3058
4430
|
throw new Error(manifest.slug + " needs content.guide \u2014 an array of steps for its page");
|
|
3059
4431
|
}
|
|
3060
4432
|
if (typeof (manifest == null ? void 0 : manifest.status) !== "function") {
|
|
@@ -3066,8 +4438,8 @@ var build = (manifest) => {
|
|
|
3066
4438
|
}
|
|
3067
4439
|
for (const [domain, verbs] of Object.entries(HOOKS)) {
|
|
3068
4440
|
for (const verb of verbs) {
|
|
3069
|
-
const hook = (
|
|
3070
|
-
if (((
|
|
4441
|
+
const hook = (_q = (_p = manifest.hooks) == null ? void 0 : _p[domain]) == null ? void 0 : _q[verb];
|
|
4442
|
+
if (((_r = manifest.hooks) == null ? void 0 : _r[domain]) === false) continue;
|
|
3071
4443
|
if (hook !== false && !implemented({ [domain]: { [verb]: hook } }, domain + "." + verb)) {
|
|
3072
4444
|
throw new Error(manifest.slug + " must answer hooks." + domain + "." + verb + " \u2014 false, a function, or {} if another repo implements it");
|
|
3073
4445
|
}
|
|
@@ -3113,14 +4485,18 @@ var connections = Object.freeze({
|
|
|
3113
4485
|
webhook: build(webhook_default)
|
|
3114
4486
|
});
|
|
3115
4487
|
(() => {
|
|
3116
|
-
|
|
4488
|
+
var _a;
|
|
4489
|
+
const routes = {};
|
|
3117
4490
|
for (const [slug, manifest] of Object.entries(connections)) {
|
|
3118
|
-
for (const [name] of leaves(manifest.steps)) {
|
|
4491
|
+
for (const [name, step] of leaves(manifest.steps)) {
|
|
3119
4492
|
const type = "step." + name;
|
|
3120
|
-
|
|
3121
|
-
|
|
4493
|
+
const queue = (_a = step({})) == null ? void 0 : _a.queue;
|
|
4494
|
+
if (routes[type] && routes[type].queue !== queue) {
|
|
4495
|
+
throw new Error(
|
|
4496
|
+
"Step " + type + " routes to " + routes[type].queue + " for " + routes[type].slug + " and " + queue + " for " + slug + " \u2014 one of them would be enqueued nowhere"
|
|
4497
|
+
);
|
|
3122
4498
|
}
|
|
3123
|
-
|
|
4499
|
+
routes[type] = { queue, slug };
|
|
3124
4500
|
}
|
|
3125
4501
|
}
|
|
3126
4502
|
})();
|
|
@@ -3214,43 +4590,11 @@ var decrypt = (value) => {
|
|
|
3214
4590
|
};
|
|
3215
4591
|
|
|
3216
4592
|
// lib/providers.js
|
|
3217
|
-
var
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
drawbridge: [
|
|
3223
|
-
{ input: "email", key: "accountSender", env: "SENDGRID_FROM_ADDRESS", label: "Account sender", message: "Verification codes and security alerts send from here.", required: true },
|
|
3224
|
-
{ input: "password", key: "apiKey", env: "SENDGRID_API_KEY", label: "SendGrid API key", redact: true, required: true },
|
|
3225
|
-
// NOT required. The CRM sync is best-effort internal tooling and no-ops
|
|
3226
|
-
// without a token — requiring it would make the whole drawbridge provider
|
|
3227
|
-
// read not-live over something no merchant ever sees.
|
|
3228
|
-
{ input: "password", key: "hubspotToken", env: "HUBSPOT_ACCESS_TOKEN", label: "HubSpot access token", message: "Drawbridge's own CRM portal. Internal \u2014 no merchant sees this.", redact: true },
|
|
3229
|
-
// Optional: SENDGRID_SEND_FROM_ADDRESS is not boot-required in sync
|
|
3230
|
-
// either. Unset, it degrades to the account sender rather than
|
|
3231
|
-
// refusing to start.
|
|
3232
|
-
{ input: "email", key: "leadSender", env: "SENDGRID_SEND_FROM_ADDRESS", label: "Lead sender", message: "The default for lead-facing mail when a merchant has not verified their own domain." },
|
|
3233
|
-
{ input: "text", key: "smsFrom", env: "TWILIO_ACCOUNT_FROM", label: "SMS number", required: true },
|
|
3234
|
-
{ input: "password", key: "smsSid", env: "TWILIO_ACCOUNT_SID", label: "Twilio account SID", redact: true, required: true },
|
|
3235
|
-
{ input: "password", key: "smsToken", env: "TWILIO_AUTH_TOKEN", label: "Twilio auth token", redact: true, required: true }
|
|
3236
|
-
],
|
|
3237
|
-
klaviyo: [
|
|
3238
|
-
{ input: "text", key: "clientId", env: "KLAVIYO_OAUTH_CLIENT_ID", label: "Client ID", required: true },
|
|
3239
|
-
{ input: "password", key: "clientSecret", env: "KLAVIYO_OAUTH_CLIENT_SECRET", label: "Client secret", redact: true, required: true }
|
|
3240
|
-
],
|
|
3241
|
-
mailchimp: [
|
|
3242
|
-
{ input: "text", key: "clientId", env: "MAILCHIMP_OAUTH_CLIENT_ID", label: "Client ID", required: true },
|
|
3243
|
-
{ input: "password", key: "clientSecret", env: "MAILCHIMP_OAUTH_CLIENT_SECRET", label: "Client secret", redact: true, required: true }
|
|
3244
|
-
],
|
|
3245
|
-
shopify: [
|
|
3246
|
-
{ input: "text", key: "apiKey", env: "SHOPIFY_API_KEY", label: "API key", required: true },
|
|
3247
|
-
{ input: "password", key: "apiSecret", env: "SHOPIFY_API_SECRET", label: "API secret", redact: true, required: true },
|
|
3248
|
-
{ input: "text", key: "appHandle", env: "SHOPIFY_APP_HANDLE", label: "App handle", required: true },
|
|
3249
|
-
{ input: "text", key: "listingUrl", env: "SHOPIFY_APP_LISTING_URL", label: "App listing URL", required: true }
|
|
3250
|
-
]
|
|
3251
|
-
};
|
|
3252
|
-
var providerFields = (slug) => Object.hasOwn(FIELDS, slug) ? FIELDS[slug] : [];
|
|
3253
|
-
var providerSlugs = () => Object.keys(FIELDS).sort();
|
|
4593
|
+
var providerFields = (slug) => {
|
|
4594
|
+
var _a;
|
|
4595
|
+
return Object.hasOwn(connections, slug) ? ((_a = connections[slug].provider) == null ? void 0 : _a.fields) || [] : [];
|
|
4596
|
+
};
|
|
4597
|
+
var providerSlugs = () => Object.keys(connections).filter((slug) => connections[slug].provider).sort();
|
|
3254
4598
|
var isLive = (slug, settings) => {
|
|
3255
4599
|
const fields2 = providerFields(slug);
|
|
3256
4600
|
if (!fields2.length) return false;
|
|
@@ -3258,19 +4602,24 @@ var isLive = (slug, settings) => {
|
|
|
3258
4602
|
};
|
|
3259
4603
|
var mask = (value) => {
|
|
3260
4604
|
if (!value) return null;
|
|
3261
|
-
if (String(value).length <
|
|
4605
|
+
if (String(value).length < 16) return "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
|
|
3262
4606
|
return String(value).slice(0, 3) + "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" + String(value).slice(-4);
|
|
3263
4607
|
};
|
|
3264
|
-
var
|
|
3265
|
-
var
|
|
3266
|
-
|
|
4608
|
+
var providerMemo = /* @__PURE__ */ new Map();
|
|
4609
|
+
var MEMO_TTL_MS = 60 * 1e3;
|
|
4610
|
+
var clearProviderMemo = () => providerMemo.clear();
|
|
4611
|
+
var providerSettings = async ({ controller, slug }) => {
|
|
4612
|
+
const memoized = providerMemo.get(slug);
|
|
4613
|
+
if (memoized && Date.now() - memoized.at < MEMO_TTL_MS) return memoized.value;
|
|
4614
|
+
const row = await controller.get({
|
|
3267
4615
|
collection: "provider",
|
|
3268
4616
|
query: { slug }
|
|
3269
4617
|
});
|
|
3270
|
-
const
|
|
3271
|
-
|
|
4618
|
+
const value = (row == null ? void 0 : row.settings) ? decrypt(row.settings) : {};
|
|
4619
|
+
providerMemo.set(slug, { at: Date.now(), value });
|
|
4620
|
+
return value;
|
|
3272
4621
|
};
|
|
3273
|
-
var saveProviderSettings = async ({ authenticated,
|
|
4622
|
+
var saveProviderSettings = async ({ authenticated, clear, controller, settings, slug }) => {
|
|
3274
4623
|
const fields2 = providerFields(slug);
|
|
3275
4624
|
if (!fields2.length) return null;
|
|
3276
4625
|
const existing = await controller.get({
|
|
@@ -3298,38 +4647,36 @@ var saveProviderSettings = async ({ authenticated, cache, clear, controller, set
|
|
|
3298
4647
|
},
|
|
3299
4648
|
query: { slug }
|
|
3300
4649
|
});
|
|
3301
|
-
|
|
4650
|
+
providerMemo.delete(slug);
|
|
3302
4651
|
return result;
|
|
3303
4652
|
};
|
|
3304
4653
|
var providerEnvNames = () => new Set(
|
|
3305
|
-
providerSlugs().flatMap((slug) => providerFields(slug)).map((field2) => field2.
|
|
4654
|
+
providerSlugs().flatMap((slug) => providerFields(slug)).map((field2) => field2.credential).filter(Boolean)
|
|
3306
4655
|
);
|
|
3307
|
-
var providerCredentials = async ({
|
|
4656
|
+
var providerCredentials = async ({ controller }) => {
|
|
3308
4657
|
const credentials2 = {};
|
|
3309
4658
|
for (const slug of providerSlugs()) {
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
const
|
|
3313
|
-
|
|
4659
|
+
try {
|
|
4660
|
+
const settings = await providerSettings({ controller, slug });
|
|
4661
|
+
for (const field2 of providerFields(slug)) {
|
|
4662
|
+
const value = settings == null ? void 0 : settings[field2.key];
|
|
4663
|
+
if (field2.credential && value) credentials2[field2.credential] = value;
|
|
4664
|
+
}
|
|
4665
|
+
} catch {
|
|
4666
|
+
continue;
|
|
3314
4667
|
}
|
|
3315
4668
|
}
|
|
3316
4669
|
return credentials2;
|
|
3317
4670
|
};
|
|
3318
|
-
var hydrateEnvironment = async ({ cache, controller, env = process.env }) => {
|
|
3319
|
-
const credentials2 = await providerCredentials({ cache, controller });
|
|
3320
|
-
for (const name of providerEnvNames()) delete env[name];
|
|
3321
|
-
Object.assign(env, credentials2);
|
|
3322
|
-
return Object.keys(credentials2).sort();
|
|
3323
|
-
};
|
|
3324
4671
|
// Annotate the CommonJS export names for ESM import in node:
|
|
3325
4672
|
0 && (module.exports = {
|
|
3326
|
-
|
|
3327
|
-
hydrateEnvironment,
|
|
4673
|
+
clearProviderMemo,
|
|
3328
4674
|
isLive,
|
|
3329
4675
|
mask,
|
|
3330
4676
|
providerCredentials,
|
|
3331
4677
|
providerEnvNames,
|
|
3332
4678
|
providerFields,
|
|
4679
|
+
providerMemo,
|
|
3333
4680
|
providerSettings,
|
|
3334
4681
|
providerSlugs,
|
|
3335
4682
|
saveProviderSettings
|