@drawbridge/drawbridge-utils 0.0.170 → 0.0.172
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 +55 -39
- package/dist/connections/index.d.cts +158 -53
- package/dist/connections/index.d.ts +158 -53
- package/dist/connections/index.js +55 -39
- package/dist/providers.cjs +55 -39
- package/dist/providers.js +55 -39
- package/package.json +2 -2
|
@@ -618,6 +618,7 @@ var row = ({ connection: connection2, data: data2, manifest, row: described }) =
|
|
|
618
618
|
};
|
|
619
619
|
};
|
|
620
620
|
var segmentRowWrites = ({ connection: connection2, data: data2, manifest, row: described, segment }) => {
|
|
621
|
+
if ((described == null ? void 0 : described.id) === void 0 || (described == null ? void 0 : described.id) === null) return [];
|
|
621
622
|
const built = row({ connection: connection2, data: data2, manifest, row: described });
|
|
622
623
|
return [
|
|
623
624
|
// PUSH IF ABSENT. The $ne guard is what makes a second concurrent register
|
|
@@ -865,10 +866,11 @@ var attentive_default2 = {
|
|
|
865
866
|
sync: async ({ lead, settings, suppressed, token }, { fetcher } = {}) => {
|
|
866
867
|
var _a, _b, _c, _d;
|
|
867
868
|
const segment = settings == null ? void 0 : settings.segment;
|
|
868
|
-
if (!segment) return { message: "No Attentive segment is chosen for this connection.", skipped: true };
|
|
869
869
|
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);
|
|
870
870
|
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));
|
|
871
|
-
|
|
871
|
+
const request2 = { email: email || null, phone: phone || null, segment: (settings == null ? void 0 : settings.segment) || null };
|
|
872
|
+
if (!segment) return { message: "No Attentive segment is chosen for this connection.", request: request2, skipped: true };
|
|
873
|
+
if (!email && !phone) return { message: "That lead has no email address or phone number to sync.", request: request2, skipped: true };
|
|
872
874
|
const user = {
|
|
873
875
|
...email && { email },
|
|
874
876
|
...phone && { phone }
|
|
@@ -890,6 +892,7 @@ var attentive_default2 = {
|
|
|
890
892
|
});
|
|
891
893
|
return {
|
|
892
894
|
message: "Attentive accepted an unsubscribe for this contact \u2014 they have opted out.",
|
|
895
|
+
request: request2,
|
|
893
896
|
response: { accepted: true, unsubscribed: true }
|
|
894
897
|
};
|
|
895
898
|
}
|
|
@@ -930,6 +933,7 @@ var attentive_default2 = {
|
|
|
930
933
|
// queued them — a merchant who reads "synced" and looks for the person
|
|
931
934
|
// in Attentive a second later has been told the wrong thing.
|
|
932
935
|
message: "Attentive accepted this contact for the segment. Attentive processes these asynchronously, so it appears there shortly.",
|
|
936
|
+
request: request2,
|
|
933
937
|
response: {
|
|
934
938
|
accepted: true,
|
|
935
939
|
...(membership == null ? void 0 : membership.batchJobId) && { batchJobId: membership.batchJobId }
|
|
@@ -2946,7 +2950,7 @@ var drawbridge_default2 = {
|
|
|
2946
2950
|
text: { max: 2e3, type: "string" }
|
|
2947
2951
|
},
|
|
2948
2952
|
triggers: ["schedule.day", "schedule.week", "schedule.month"],
|
|
2949
|
-
usage: { actions:
|
|
2953
|
+
usage: { actions: channels.email.actionsPerSend }
|
|
2950
2954
|
}),
|
|
2951
2955
|
// To organization MEMBERS. Never suppressed — an entrant's opt-out must
|
|
2952
2956
|
// not silence an alert to staff — and not billed.
|
|
@@ -2971,10 +2975,14 @@ var drawbridge_default2 = {
|
|
|
2971
2975
|
subject: { max: 150, type: "string" }
|
|
2972
2976
|
},
|
|
2973
2977
|
triggers: ["lead.insert"],
|
|
2974
|
-
//
|
|
2975
|
-
//
|
|
2976
|
-
//
|
|
2977
|
-
|
|
2978
|
+
// EVERY STEP IN A BILLABLE WORKFLOW COUNTS, team mail included. This
|
|
2979
|
+
// leaves our SendGrid account and costs the same to send as any other
|
|
2980
|
+
// email, so it carries the same weight. Whether anything is charged at
|
|
2981
|
+
// all is the workflow's `billable` tag, not the step's audience — and
|
|
2982
|
+
// this step triggers on lead.insert, so a merchant notifying
|
|
2983
|
+
// themselves on every entry spends against their allowance twice per
|
|
2984
|
+
// lead, once for the entry and once for the notification.
|
|
2985
|
+
usage: { actions: channels.email.actionsPerSend }
|
|
2978
2986
|
}),
|
|
2979
2987
|
// To a LEAD. Suppression applies and the send is billed.
|
|
2980
2988
|
send: () => ({
|
|
@@ -3083,7 +3091,7 @@ var api2 = async (path, { fetcher = fetch, method = "GET", payload, token }) =>
|
|
|
3083
3091
|
}
|
|
3084
3092
|
return response.status === 204 ? null : response.json();
|
|
3085
3093
|
};
|
|
3086
|
-
var segmentName = (title) => "Drawbridge: " + title;
|
|
3094
|
+
var segmentName = (title, id) => "Drawbridge: " + title + " (" + String(id).slice(-6) + ")";
|
|
3087
3095
|
var canManageSegments = (settings) => String((settings == null ? void 0 : settings.scope) || "").split(/\s+/).includes("segments:write");
|
|
3088
3096
|
var klaviyo_default2 = {
|
|
3089
3097
|
// OAuth 2.1, and PKCE is REQUIRED rather than recommended: Klaviyo refuses an
|
|
@@ -3330,11 +3338,12 @@ var klaviyo_default2 = {
|
|
|
3330
3338
|
// different thing from deleting the profile.
|
|
3331
3339
|
remove: false,
|
|
3332
3340
|
sync: async ({ context, lead, segments, settings, suppressed, token }, { fetcher } = {}) => {
|
|
3333
|
-
var _a, _b, _c;
|
|
3341
|
+
var _a, _b, _c, _d, _e;
|
|
3334
3342
|
const list = settings == null ? void 0 : settings.list;
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3343
|
+
const request2 = { 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) || null, list: (settings == null ? void 0 : settings.list) || null };
|
|
3344
|
+
if (!list) return { message: "No Klaviyo list is chosen for this connection.", request: request2, skipped: true };
|
|
3345
|
+
const email = ((_d = (_c = lead == null ? void 0 : lead.canonical) == null ? void 0 : _c.email) == null ? void 0 : _d.value) || (lead == null ? void 0 : lead.email);
|
|
3346
|
+
if (!email) return { message: "That lead has no email address to sync.", request: request2, skipped: true };
|
|
3338
3347
|
const person = (context == null ? void 0 : context.contact) || null;
|
|
3339
3348
|
const totals = (person == null ? void 0 : person.totals) || {};
|
|
3340
3349
|
const count = (value) => typeof value === "number" ? value : (value == null ? void 0 : value.total) || 0;
|
|
@@ -3391,8 +3400,8 @@ var klaviyo_default2 = {
|
|
|
3391
3400
|
},
|
|
3392
3401
|
token
|
|
3393
3402
|
});
|
|
3394
|
-
const profileId = (
|
|
3395
|
-
if (!profileId) return { message: "Klaviyo returned no profile id.", skipped: true };
|
|
3403
|
+
const profileId = (_e = profile == null ? void 0 : profile.data) == null ? void 0 : _e.id;
|
|
3404
|
+
if (!profileId) return { message: "Klaviyo returned no profile id.", request: request2, skipped: true };
|
|
3396
3405
|
await api2("/profile-subscription-bulk-create-jobs/", {
|
|
3397
3406
|
fetcher,
|
|
3398
3407
|
method: "POST",
|
|
@@ -3421,6 +3430,7 @@ var klaviyo_default2 = {
|
|
|
3421
3430
|
// Merged into `context` for later steps in this run.
|
|
3422
3431
|
context: { klaviyoProfileId: profileId },
|
|
3423
3432
|
message: suppressed ? "Synced to Klaviyo as unsubscribed \u2014 this contact has opted out." : "Synced to the Klaviyo list.",
|
|
3433
|
+
request: request2,
|
|
3424
3434
|
// Recorded on the run for support to read back, not a write
|
|
3425
3435
|
// instruction — the hook has already written what it needed to.
|
|
3426
3436
|
response: { klaviyoProfileId: profileId }
|
|
@@ -3456,7 +3466,7 @@ var klaviyo_default2 = {
|
|
|
3456
3466
|
skipped: true
|
|
3457
3467
|
};
|
|
3458
3468
|
}
|
|
3459
|
-
const name = segmentName(segment.title);
|
|
3469
|
+
const name = segmentName(segment.title, segment.id);
|
|
3460
3470
|
const existing = segmentRowFor({ connection: connection2, segment });
|
|
3461
3471
|
let id = null;
|
|
3462
3472
|
if (existing == null ? void 0 : existing.id) {
|
|
@@ -3863,7 +3873,10 @@ var api3 = async (path, { dc, fetcher = fetch, method = "GET", payload, token })
|
|
|
3863
3873
|
};
|
|
3864
3874
|
var subscriberHash = (email) => (0, import_node_crypto5.createHash)("md5").update(String(email).trim().toLowerCase()).digest("hex");
|
|
3865
3875
|
var TAG_NAME_LIMIT = 100;
|
|
3866
|
-
var tagName = (title) =>
|
|
3876
|
+
var tagName = (title, id) => {
|
|
3877
|
+
const suffix = " (" + String(id).slice(-6) + ")";
|
|
3878
|
+
return ("Drawbridge: " + title).slice(0, TAG_NAME_LIMIT - suffix.length) + suffix;
|
|
3879
|
+
};
|
|
3867
3880
|
var mailchimp_default2 = {
|
|
3868
3881
|
// OAUTH 2, authorization code. Every url below is quoted from
|
|
3869
3882
|
// mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/ rather
|
|
@@ -4001,15 +4014,16 @@ var mailchimp_default2 = {
|
|
|
4001
4014
|
// why there is no create-or-update branch here. Quoted from Mailchimp's
|
|
4002
4015
|
// Marketing API reference for the list-members resource.
|
|
4003
4016
|
sync: async ({ connection: connection2, lead, segments, settings, suppressed, token }, { fetcher, read } = {}) => {
|
|
4004
|
-
var _a, _b, _c;
|
|
4017
|
+
var _a, _b, _c, _d, _e;
|
|
4005
4018
|
const audience = settings == null ? void 0 : settings.audience;
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4019
|
+
const request2 = { audience: (settings == null ? void 0 : settings.audience) || null, 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) || null };
|
|
4020
|
+
if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", request: request2, skipped: true };
|
|
4021
|
+
const email = ((_d = (_c = lead == null ? void 0 : lead.canonical) == null ? void 0 : _c.email) == null ? void 0 : _d.value) || (lead == null ? void 0 : lead.email);
|
|
4022
|
+
if (!email) return { message: "That lead has no email address to sync.", request: request2, skipped: true };
|
|
4009
4023
|
const hash = subscriberHash(email);
|
|
4010
4024
|
const [firstName, ...restOfName] = String((lead == null ? void 0 : lead.name) || "").trim().split(/\s+/).filter(Boolean);
|
|
4011
4025
|
const lastName = restOfName.join(" ");
|
|
4012
|
-
const phone = ((
|
|
4026
|
+
const phone = ((_e = lead == null ? void 0 : lead.phone) == null ? void 0 : _e.number) || null;
|
|
4013
4027
|
const mergeFields = {
|
|
4014
4028
|
...firstName && { FNAME: firstName },
|
|
4015
4029
|
...lastName && { LNAME: lastName },
|
|
@@ -4043,14 +4057,17 @@ var mailchimp_default2 = {
|
|
|
4043
4057
|
}
|
|
4044
4058
|
},
|
|
4045
4059
|
{
|
|
4046
|
-
|
|
4060
|
+
// THE ID AS WELL AS THE TITLE, because the tag name carries it —
|
|
4061
|
+
// see tagName. Without it every name here would end in the
|
|
4062
|
+
// string 'undefined' and match nothing register wrote.
|
|
4063
|
+
$project: { _id: 0, id: 1, title: 1 }
|
|
4047
4064
|
}
|
|
4048
4065
|
]
|
|
4049
4066
|
});
|
|
4050
|
-
const joined = new Set(segments.map((entry) => entry.
|
|
4051
|
-
const tags = (owned || []).
|
|
4052
|
-
name: tagName(title),
|
|
4053
|
-
status: joined.has(
|
|
4067
|
+
const joined = new Set(segments.map((entry) => entry.id));
|
|
4068
|
+
const tags = (owned || []).filter((entry) => entry.id && entry.title).map((entry) => ({
|
|
4069
|
+
name: tagName(entry.title, entry.id),
|
|
4070
|
+
status: joined.has(entry.id) ? "active" : "inactive"
|
|
4054
4071
|
}));
|
|
4055
4072
|
if (tags.length > 0) {
|
|
4056
4073
|
await api3("/lists/" + audience + "/members/" + hash + "/tags", {
|
|
@@ -4066,6 +4083,7 @@ var mailchimp_default2 = {
|
|
|
4066
4083
|
// Merged into `context` for later steps in this run.
|
|
4067
4084
|
context: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash },
|
|
4068
4085
|
message: suppressed ? "Synced to Mailchimp as unsubscribed \u2014 this contact has opted out." : "Synced to the Mailchimp audience.",
|
|
4086
|
+
request: request2,
|
|
4069
4087
|
// Recorded on the run for support to read back, not a write
|
|
4070
4088
|
// instruction — the hook has already written what it needed to.
|
|
4071
4089
|
response: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash }
|
|
@@ -4101,7 +4119,7 @@ var mailchimp_default2 = {
|
|
|
4101
4119
|
if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", skipped: true };
|
|
4102
4120
|
const segment = await currentSegment({ read, segment: context == null ? void 0 : context.segment });
|
|
4103
4121
|
if (!(segment == null ? void 0 : segment.id) || segment.system) return { message: "That segment is not one this connection publishes.", skipped: true };
|
|
4104
|
-
const name = tagName(segment.title);
|
|
4122
|
+
const name = tagName(segment.title, segment.id);
|
|
4105
4123
|
const existing = segmentRowFor({ connection: connection2, segment });
|
|
4106
4124
|
let id = null;
|
|
4107
4125
|
if (existing == null ? void 0 : existing.id) {
|
|
@@ -4654,10 +4672,6 @@ var shopify_default2 = {
|
|
|
4654
4672
|
if (!(context == null ? void 0 : context.lead)) return { message: "Lead id is missing.", request: request2, response: { skipped: true }, skipped: true };
|
|
4655
4673
|
if (!(discount == null ? void 0 : discount.id)) return { message: "Discount is not configured on this step.", request: request2, response: { skipped: true }, skipped: true };
|
|
4656
4674
|
const adminAccessToken = await adminToken();
|
|
4657
|
-
if (!context.shopifyCustomerId) {
|
|
4658
|
-
const customer = await shopify.admin.getOrCreateCustomer({ adminAccessToken, domain: connection2.shop, email: context.email });
|
|
4659
|
-
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 };
|
|
4660
|
-
}
|
|
4661
4675
|
const discountCode = await shopify.admin.createDiscountCode({
|
|
4662
4676
|
adminAccessToken,
|
|
4663
4677
|
code: "DB-" + generateDiscountCode(),
|
|
@@ -5101,7 +5115,7 @@ var shopify_default2 = {
|
|
|
5101
5115
|
//
|
|
5102
5116
|
// The shell has already refused a missing or inactive Shopify connection,
|
|
5103
5117
|
// so what is left is the two things only this hook can know are wrong.
|
|
5104
|
-
product: async ({ connection: connection2, context, workflow }, { mintId } = {}) => {
|
|
5118
|
+
product: async ({ connection: connection2, context, workflow }, { mintId, read } = {}) => {
|
|
5105
5119
|
const request2 = {
|
|
5106
5120
|
numericId: (context == null ? void 0 : context.id) || null,
|
|
5107
5121
|
organizationId: workflow.organization,
|
|
@@ -5110,7 +5124,13 @@ var shopify_default2 = {
|
|
|
5110
5124
|
if (!(context == null ? void 0 : context.id)) return { message: "Skipped \u2014 product webhook payload had no id.", request: request2, response: { skipped: true }, skipped: true };
|
|
5111
5125
|
if (!connection2.shop) return { message: "Skipped \u2014 Shopify connection is missing shop domain.", request: request2, response: { skipped: true }, skipped: true };
|
|
5112
5126
|
const providerId = "gid://shopify/Product/" + context.id;
|
|
5113
|
-
const
|
|
5127
|
+
const query = {
|
|
5128
|
+
"provider.id": providerId,
|
|
5129
|
+
"provider.slug": "shopify",
|
|
5130
|
+
"source.domain": connection2.shop
|
|
5131
|
+
};
|
|
5132
|
+
const existing = (read == null ? void 0 : read.get) ? await read.get({ collection: "product", query }) : null;
|
|
5133
|
+
const productId = (existing == null ? void 0 : existing.id) || mintId();
|
|
5114
5134
|
return {
|
|
5115
5135
|
enqueues: [{
|
|
5116
5136
|
data: { product: productId, providerId, shop: connection2.shop },
|
|
@@ -5140,11 +5160,7 @@ var shopify_default2 = {
|
|
|
5140
5160
|
},
|
|
5141
5161
|
operation: "update",
|
|
5142
5162
|
options: { upsert: true },
|
|
5143
|
-
query
|
|
5144
|
-
"provider.id": providerId,
|
|
5145
|
-
"provider.slug": "shopify",
|
|
5146
|
-
"source.domain": connection2.shop
|
|
5147
|
-
}
|
|
5163
|
+
query
|
|
5148
5164
|
}]
|
|
5149
5165
|
};
|
|
5150
5166
|
}
|
|
@@ -5414,7 +5430,7 @@ var shopify_default2 = {
|
|
|
5414
5430
|
options: { jobId: "connection.update.register." + workflow.connection + "." + (0, import_node_crypto6.randomUUID)() },
|
|
5415
5431
|
queue: "connection"
|
|
5416
5432
|
}],
|
|
5417
|
-
message: ((scopesMissing == null ? void 0 : scopesMissing.length) ? "Health check: ping ok,
|
|
5433
|
+
message: ((scopesMissing == null ? void 0 : scopesMissing.length) ? "Health check: ping ok, scopes reconciled \u2014 connection errored, granted scopes are missing: " + scopesMissing.join(", ") + "." : refreshTokenRotated ? "Health check passed \u2014 refresh token rotated, ping ok, scopes reconciled." : "Health check passed \u2014 ping ok, scopes reconciled.") + (sourceRepaired ? " Billing source repaired from the live shop." : "") + (metered === null ? " No usage line on the store's approved plan \u2014 order billing needs the merchant to approve the updated plan." : ""),
|
|
5418
5434
|
request: request2,
|
|
5419
5435
|
response: {
|
|
5420
5436
|
billingProbeFailed,
|
|
@@ -854,6 +854,13 @@ const row = ({ connection, data, manifest, row : described }) => ({
|
|
|
854
854
|
|
|
855
855
|
const segmentRowWrites = ({ connection, data, manifest, row : described, segment }) => {
|
|
856
856
|
|
|
857
|
+
// NO ID, NO ROW. `String( undefined )` is the string 'undefined', which is a
|
|
858
|
+
// perfectly valid string as far as the api's schema is concerned — so a row
|
|
859
|
+
// built without a vendor id would store and then render as a live-looking
|
|
860
|
+
// link to an object that does not exist. The three hooks all guard before
|
|
861
|
+
// they get here; this is the guard that does not depend on them remembering.
|
|
862
|
+
if( described?.id === undefined || described?.id === null ) return [];
|
|
863
|
+
|
|
857
864
|
const built = row({ connection, data, manifest, row : described });
|
|
858
865
|
|
|
859
866
|
return [
|
|
@@ -1234,8 +1241,6 @@ var attentive = {
|
|
|
1234
1241
|
// status() already stops a connection reaching Active without a
|
|
1235
1242
|
// segment; this is the belt to that braces. A workflow saved before the
|
|
1236
1243
|
// segment was chosen must not silently write into nothing.
|
|
1237
|
-
if( ! segment ) return { message : 'No Attentive segment is chosen for this connection.', skipped : true };
|
|
1238
|
-
|
|
1239
1244
|
const email = lead?.canonical?.email?.value || lead?.email;
|
|
1240
1245
|
|
|
1241
1246
|
// E.164 OR NOTHING. Attentive requires it, and a national-format number
|
|
@@ -1243,9 +1248,22 @@ var attentive = {
|
|
|
1243
1248
|
// and the email carries the sync instead.
|
|
1244
1249
|
const phone = toE164( lead?.canonical?.phone?.value || lead?.phone );
|
|
1245
1250
|
|
|
1251
|
+
// WHAT WENT TO THE VENDOR, so a sync run in the activity feed can
|
|
1252
|
+
// answer what was actually sent. The three contact syncs were the only
|
|
1253
|
+
// hook family carrying none, and a merchant opening one saw
|
|
1254
|
+
// request : null. Attentive identifies on either channel, so both are
|
|
1255
|
+
// recorded — which of the two carried the sync is the first question
|
|
1256
|
+
// anyone debugging one of these asks.
|
|
1257
|
+
const request = { email : email || null, phone : phone || null, segment : settings?.segment || null };
|
|
1258
|
+
|
|
1259
|
+
// status() already stops a connection reaching Active without a
|
|
1260
|
+
// segment; this is the belt to that braces. A workflow saved before the
|
|
1261
|
+
// segment was chosen must not silently write into nothing.
|
|
1262
|
+
if( ! segment ) return { message : 'No Attentive segment is chosen for this connection.', request, skipped : true };
|
|
1263
|
+
|
|
1246
1264
|
// Their `user` requires phone OR email. With neither there is nobody to
|
|
1247
1265
|
// subscribe, so this is a skip rather than a failure.
|
|
1248
|
-
if( ! email && ! phone ) return { message : 'That lead has no email address or phone number to sync.', skipped : true };
|
|
1266
|
+
if( ! email && ! phone ) return { message : 'That lead has no email address or phone number to sync.', request, skipped : true };
|
|
1249
1267
|
|
|
1250
1268
|
const user = {
|
|
1251
1269
|
...( email && { email }),
|
|
@@ -1285,6 +1303,7 @@ var attentive = {
|
|
|
1285
1303
|
|
|
1286
1304
|
return {
|
|
1287
1305
|
message : 'Attentive accepted an unsubscribe for this contact — they have opted out.',
|
|
1306
|
+
request,
|
|
1288
1307
|
response : { accepted : true, unsubscribed : true }
|
|
1289
1308
|
};
|
|
1290
1309
|
|
|
@@ -1332,6 +1351,7 @@ var attentive = {
|
|
|
1332
1351
|
// queued them — a merchant who reads "synced" and looks for the person
|
|
1333
1352
|
// in Attentive a second later has been told the wrong thing.
|
|
1334
1353
|
message : 'Attentive accepted this contact for the segment. Attentive processes these asynchronously, so it appears there shortly.',
|
|
1354
|
+
request,
|
|
1335
1355
|
response : {
|
|
1336
1356
|
accepted : true,
|
|
1337
1357
|
...( membership?.batchJobId && { batchJobId : membership.batchJobId })
|
|
@@ -3353,7 +3373,7 @@ var drawbridge = {
|
|
|
3353
3373
|
text : { max : 2000, type : 'string' }
|
|
3354
3374
|
},
|
|
3355
3375
|
triggers : [ 'schedule.day', 'schedule.week', 'schedule.month' ],
|
|
3356
|
-
usage : { actions :
|
|
3376
|
+
usage : { actions : channels.email.actionsPerSend }
|
|
3357
3377
|
}),
|
|
3358
3378
|
|
|
3359
3379
|
// To organization MEMBERS. Never suppressed — an entrant's opt-out must
|
|
@@ -3379,10 +3399,14 @@ var drawbridge = {
|
|
|
3379
3399
|
subject : { max : 150, type : 'string' }
|
|
3380
3400
|
},
|
|
3381
3401
|
triggers : [ 'lead.insert' ],
|
|
3382
|
-
//
|
|
3383
|
-
//
|
|
3384
|
-
//
|
|
3385
|
-
|
|
3402
|
+
// EVERY STEP IN A BILLABLE WORKFLOW COUNTS, team mail included. This
|
|
3403
|
+
// leaves our SendGrid account and costs the same to send as any other
|
|
3404
|
+
// email, so it carries the same weight. Whether anything is charged at
|
|
3405
|
+
// all is the workflow's `billable` tag, not the step's audience — and
|
|
3406
|
+
// this step triggers on lead.insert, so a merchant notifying
|
|
3407
|
+
// themselves on every entry spends against their allowance twice per
|
|
3408
|
+
// lead, once for the entry and once for the notification.
|
|
3409
|
+
usage : { actions : channels.email.actionsPerSend }
|
|
3386
3410
|
}),
|
|
3387
3411
|
|
|
3388
3412
|
// To a LEAD. Suppression applies and the send is billed.
|
|
@@ -3514,7 +3538,30 @@ const api$1 = async ( path, { fetcher = fetch, method = 'GET', payload, token }
|
|
|
3514
3538
|
|
|
3515
3539
|
// The segment's name at Klaviyo, spelled once. It is a LABEL — the definition
|
|
3516
3540
|
// below keys on the id — so a rename never has to touch a profile.
|
|
3517
|
-
|
|
3541
|
+
//
|
|
3542
|
+
// THE ID IS IN THE LABEL TOO, and that part is not cosmetic. Nothing makes a
|
|
3543
|
+
// THE ID IS IN THE NAME BECAUSE THE NAME IS NOT AN IDENTITY. Keyed on title
|
|
3544
|
+
// alone, a second segment called "VIP" found the first one's Klaviyo segment by
|
|
3545
|
+
// name and adopted it: both rows then pointed at one segment whose definition
|
|
3546
|
+
// computes the FIRST one's membership, so the merchant sent to the wrong people,
|
|
3547
|
+
// and deleting either segment deleted that object out from under the other with
|
|
3548
|
+
// nothing to repair it.
|
|
3549
|
+
//
|
|
3550
|
+
// The api now carries a unique index on (organization, title) and a 409 on both
|
|
3551
|
+
// create and rename, so that pair can no longer be created. The suffix stays
|
|
3552
|
+
// anyway: it is what makes the damage impossible rather than merely unlikely,
|
|
3553
|
+
// and it is the only part of this that holds if a title ever reaches Klaviyo
|
|
3554
|
+
// from somewhere that did not go through the route.
|
|
3555
|
+
//
|
|
3556
|
+
// KLAVIYO IS NOT DOING THIS FOR US. Create Segment describes `name` only as "A
|
|
3557
|
+
// helpful name to label the segment" and documents no uniqueness constraint and
|
|
3558
|
+
// no duplicate-name error (developers.klaviyo.com/en/reference/create_segment,
|
|
3559
|
+
// fetched 2026-09-12), so the search cannot lean on the vendor. What IS
|
|
3560
|
+
// documented is that filter comparisons are case-sensitive
|
|
3561
|
+
// (developers.klaviyo.com/en/docs/filtering_, same date), which is why the
|
|
3562
|
+
// find-by-name below re-checks with === and agrees with the vendor rather than
|
|
3563
|
+
// guessing.
|
|
3564
|
+
const segmentName = ( title, id ) => 'Drawbridge: ' + title + ' (' + String( id ).slice( -6 ) + ')';
|
|
3518
3565
|
|
|
3519
3566
|
// THE GRANT THIS NEEDS. Klaviyo's scopes are set on the app and a token carries
|
|
3520
3567
|
// only what the merchant consented to, so a connection made before segments were
|
|
@@ -3835,11 +3882,16 @@ var klaviyo = {
|
|
|
3835
3882
|
// incomplete() already stops a connection reaching Active without a
|
|
3836
3883
|
// list; this is the belt to that braces. A workflow saved before the
|
|
3837
3884
|
// list was chosen must not silently write into nothing.
|
|
3838
|
-
|
|
3885
|
+
// WHAT WENT TO THE VENDOR, so a sync run in the activity feed can answer
|
|
3886
|
+
// what was actually sent. The three contact syncs were the only hook
|
|
3887
|
+
// family that carried none, so a merchant opening one saw request : null.
|
|
3888
|
+
const request = { email : lead?.canonical?.email?.value || lead?.email || null, list : settings?.list || null };
|
|
3889
|
+
|
|
3890
|
+
if( ! list ) return { message : 'No Klaviyo list is chosen for this connection.', request, skipped : true };
|
|
3839
3891
|
|
|
3840
3892
|
const email = lead?.canonical?.email?.value || lead?.email;
|
|
3841
3893
|
|
|
3842
|
-
if( ! email ) return { message : 'That lead has no email address to sync.', skipped : true };
|
|
3894
|
+
if( ! email ) return { message : 'That lead has no email address to sync.', request, skipped : true };
|
|
3843
3895
|
|
|
3844
3896
|
// A Klaviyo profile is keyed on an email, so IDENTITY comes from the
|
|
3845
3897
|
// lead. RANKING comes from the contact behind it, because only a
|
|
@@ -3934,7 +3986,7 @@ var klaviyo = {
|
|
|
3934
3986
|
|
|
3935
3987
|
const profileId = profile?.data?.id;
|
|
3936
3988
|
|
|
3937
|
-
if( ! profileId ) return { message : 'Klaviyo returned no profile id.', skipped : true };
|
|
3989
|
+
if( ! profileId ) return { message : 'Klaviyo returned no profile id.', request, skipped : true };
|
|
3938
3990
|
|
|
3939
3991
|
// SUPPRESSED PEOPLE ARE SYNCED AS UNSUBSCRIBED, NEVER OMITTED.
|
|
3940
3992
|
//
|
|
@@ -3977,6 +4029,7 @@ var klaviyo = {
|
|
|
3977
4029
|
message : suppressed
|
|
3978
4030
|
? 'Synced to Klaviyo as unsubscribed — this contact has opted out.'
|
|
3979
4031
|
: 'Synced to the Klaviyo list.',
|
|
4032
|
+
request,
|
|
3980
4033
|
// Recorded on the run for support to read back, not a write
|
|
3981
4034
|
// instruction — the hook has already written what it needed to.
|
|
3982
4035
|
response : { klaviyoProfileId : profileId }
|
|
@@ -4024,7 +4077,7 @@ var klaviyo = {
|
|
|
4024
4077
|
|
|
4025
4078
|
}
|
|
4026
4079
|
|
|
4027
|
-
const name = segmentName( segment.title );
|
|
4080
|
+
const name = segmentName( segment.title, segment.id );
|
|
4028
4081
|
const existing = segmentRowFor({ connection, segment });
|
|
4029
4082
|
|
|
4030
4083
|
let id = null;
|
|
@@ -4634,13 +4687,29 @@ const subscriberHash = ( email ) => createHash( 'md5' )
|
|
|
4634
4687
|
// server-side truncation is the worse outcome because register would then look
|
|
4635
4688
|
// for a name Mailchimp had silently changed.
|
|
4636
4689
|
//
|
|
4637
|
-
//
|
|
4638
|
-
//
|
|
4639
|
-
//
|
|
4640
|
-
//
|
|
4690
|
+
// THE SEGMENT ID IS IN THE NAME, and it has to be. Nothing makes a Drawbridge
|
|
4691
|
+
// segment title unique — no index on the collection, no check in the route, no
|
|
4692
|
+
// check in the form — so two segments called "VIP" are ordinary. Keyed on title
|
|
4693
|
+
// alone, the second one's register finds the first one's tag, adopts it, and
|
|
4694
|
+
// both rows point at one object: the merchant's two segments are one tag, and
|
|
4695
|
+
// deleting either segment deletes the tag out from under the other, leaving a
|
|
4696
|
+
// row pointing at an id that no longer exists and nothing to repair it.
|
|
4697
|
+
//
|
|
4698
|
+
// The last six characters of the id are enough to separate them and short
|
|
4699
|
+
// enough to leave the title readable in the merchant's own audience.
|
|
4700
|
+
//
|
|
4701
|
+
// A rename now changes the name and keeps the suffix, so register's find-by-name
|
|
4702
|
+
// still matches after one — and the truncation below cuts the TITLE rather than
|
|
4703
|
+
// the suffix for the same reason.
|
|
4641
4704
|
const TAG_NAME_LIMIT = 100;
|
|
4642
4705
|
|
|
4643
|
-
const tagName = ( title ) =>
|
|
4706
|
+
const tagName = ( title, id ) => {
|
|
4707
|
+
|
|
4708
|
+
const suffix = ' (' + String( id ).slice( -6 ) + ')';
|
|
4709
|
+
|
|
4710
|
+
return ( 'Drawbridge: ' + title ).slice( 0, TAG_NAME_LIMIT - suffix.length ) + suffix;
|
|
4711
|
+
|
|
4712
|
+
};
|
|
4644
4713
|
|
|
4645
4714
|
// Mailchimp — contact sync, not a sender.
|
|
4646
4715
|
var mailchimp = {
|
|
@@ -4797,16 +4866,24 @@ var mailchimp = {
|
|
|
4797
4866
|
|
|
4798
4867
|
const audience = settings?.audience;
|
|
4799
4868
|
|
|
4869
|
+
// WHAT WENT TO THE VENDOR, recorded on the step so the activity feed
|
|
4870
|
+
// can answer "what did we actually send". Every other hook family
|
|
4871
|
+
// carries one; the three contact syncs did not, so a merchant opening
|
|
4872
|
+
// a sync run saw `request : null` and learned nothing — which is
|
|
4873
|
+
// exactly how it was reported. The audience and the address are the
|
|
4874
|
+
// whole identity of this write.
|
|
4875
|
+
const request = { audience : settings?.audience || null, email : lead?.canonical?.email?.value || lead?.email || null };
|
|
4876
|
+
|
|
4800
4877
|
// status() already stops a connection reaching Active without an
|
|
4801
4878
|
// audience; this is the belt to that braces. A workflow saved before
|
|
4802
4879
|
// the audience was chosen must not silently write into nothing.
|
|
4803
|
-
if( ! audience ) return { message : 'No Mailchimp audience is chosen for this connection.', skipped : true };
|
|
4880
|
+
if( ! audience ) return { message : 'No Mailchimp audience is chosen for this connection.', request, skipped : true };
|
|
4804
4881
|
|
|
4805
4882
|
const email = lead?.canonical?.email?.value || lead?.email;
|
|
4806
4883
|
|
|
4807
4884
|
// A Mailchimp member IS an email address — there is no other identity
|
|
4808
4885
|
// to write, so a lead without one is skipped rather than failed.
|
|
4809
|
-
if( ! email ) return { message : 'That lead has no email address to sync.', skipped : true };
|
|
4886
|
+
if( ! email ) return { message : 'That lead has no email address to sync.', request, skipped : true };
|
|
4810
4887
|
|
|
4811
4888
|
const hash = subscriberHash( email );
|
|
4812
4889
|
|
|
@@ -4927,19 +5004,21 @@ var mailchimp = {
|
|
|
4927
5004
|
}
|
|
4928
5005
|
},
|
|
4929
5006
|
{
|
|
4930
|
-
|
|
5007
|
+
// THE ID AS WELL AS THE TITLE, because the tag name carries it —
|
|
5008
|
+
// see tagName. Without it every name here would end in the
|
|
5009
|
+
// string 'undefined' and match nothing register wrote.
|
|
5010
|
+
$project : { _id : 0, id : 1, title : 1 }
|
|
4931
5011
|
}
|
|
4932
5012
|
]
|
|
4933
5013
|
});
|
|
4934
5014
|
|
|
4935
|
-
const joined = new Set( segments.map( ( entry ) => entry.
|
|
5015
|
+
const joined = new Set( segments.map( ( entry ) => entry.id ) );
|
|
4936
5016
|
|
|
4937
5017
|
const tags = ( owned || [] )
|
|
4938
|
-
.
|
|
4939
|
-
.
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
status : joined.has( title ) ? 'active' : 'inactive'
|
|
5018
|
+
.filter( ( entry ) => entry.id && entry.title )
|
|
5019
|
+
.map( ( entry ) => ({
|
|
5020
|
+
name : tagName( entry.title, entry.id ),
|
|
5021
|
+
status : joined.has( entry.id ) ? 'active' : 'inactive'
|
|
4943
5022
|
}) );
|
|
4944
5023
|
|
|
4945
5024
|
if( tags.length > 0 ){
|
|
@@ -4962,6 +5041,7 @@ var mailchimp = {
|
|
|
4962
5041
|
message : suppressed
|
|
4963
5042
|
? 'Synced to Mailchimp as unsubscribed — this contact has opted out.'
|
|
4964
5043
|
: 'Synced to the Mailchimp audience.',
|
|
5044
|
+
request,
|
|
4965
5045
|
// Recorded on the run for support to read back, not a write
|
|
4966
5046
|
// instruction — the hook has already written what it needed to.
|
|
4967
5047
|
response : { mailchimpMemberId : member?.id || hash }
|
|
@@ -5008,7 +5088,7 @@ var mailchimp = {
|
|
|
5008
5088
|
|
|
5009
5089
|
if( ! segment?.id || segment.system ) return { message : 'That segment is not one this connection publishes.', skipped : true };
|
|
5010
5090
|
|
|
5011
|
-
const name = tagName( segment.title );
|
|
5091
|
+
const name = tagName( segment.title, segment.id );
|
|
5012
5092
|
const existing = segmentRowFor({ connection, segment });
|
|
5013
5093
|
|
|
5014
5094
|
let id = null;
|
|
@@ -5736,23 +5816,33 @@ var shopify = {
|
|
|
5736
5816
|
|
|
5737
5817
|
const request = { email : context?.email || null, lead : context?.lead || null, shop : connection.shop };
|
|
5738
5818
|
|
|
5819
|
+
// KEPT even though nothing below sends the address anywhere. A code
|
|
5820
|
+
// that reaches nobody is a wasted action and a merchant's wasted
|
|
5821
|
+
// allowance: issuing is only half a workflow, and the email step after
|
|
5822
|
+
// it needs somewhere to send the code.
|
|
5739
5823
|
if( ! context?.email ) return { message : 'Lead email is missing.', request, response : { skipped : true }, skipped : true };
|
|
5740
5824
|
if( ! context?.lead ) return { message : 'Lead id is missing.', request, response : { skipped : true }, skipped : true };
|
|
5741
5825
|
if( ! discount?.id ) return { message : 'Discount is not configured on this step.', request, response : { skipped : true }, skipped : true };
|
|
5742
5826
|
|
|
5743
5827
|
const adminAccessToken = await adminToken();
|
|
5744
5828
|
|
|
5745
|
-
//
|
|
5746
|
-
//
|
|
5747
|
-
//
|
|
5748
|
-
|
|
5749
|
-
|
|
5750
|
-
|
|
5751
|
-
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
5829
|
+
// NOTHING IS SENT TO SHOPIFY TO LINK THE CODE TO THE PERSON, because
|
|
5830
|
+
// there is nothing to send: discountRedeemCodeBulkAdd takes a discount
|
|
5831
|
+
// and a list of codes, and no customer
|
|
5832
|
+
// (shopify.dev/docs/api/admin-graphql/2026-04/mutations/discountRedeemCodeBulkAdd).
|
|
5833
|
+
// The link lives entirely on our side — `lead.shopifyDiscountCode`
|
|
5834
|
+
// below — and attribution happens when the order comes back carrying
|
|
5835
|
+
// the code, matched org-scoped against that field by commerce.order.
|
|
5836
|
+
//
|
|
5837
|
+
// This step used to call getOrCreateCustomer first, on the stated
|
|
5838
|
+
// reasoning that "the customer must exist before a code is mapped to
|
|
5839
|
+
// them". No code was ever mapped to them: the result was assigned and
|
|
5840
|
+
// discarded, createDiscountCode never received it, and redemption
|
|
5841
|
+
// attribution never consulted it. All it did was create a customer in
|
|
5842
|
+
// the merchant's store as a side effect of issuing a discount — which
|
|
5843
|
+
// QA reported as surprising, and was right to. A merchant who wants the
|
|
5844
|
+
// buyer to exist at the store adds the Create customer step, which is
|
|
5845
|
+
// what that step is for.
|
|
5756
5846
|
const discountCode = await shopify.admin.createDiscountCode({
|
|
5757
5847
|
adminAccessToken,
|
|
5758
5848
|
code : 'DB-' + generateDiscountCode(),
|
|
@@ -6413,7 +6503,7 @@ var shopify = {
|
|
|
6413
6503
|
//
|
|
6414
6504
|
// The shell has already refused a missing or inactive Shopify connection,
|
|
6415
6505
|
// so what is left is the two things only this hook can know are wrong.
|
|
6416
|
-
product : async ( { connection, context, workflow }, { mintId } = {} ) => {
|
|
6506
|
+
product : async ( { connection, context, workflow }, { mintId, read } = {} ) => {
|
|
6417
6507
|
|
|
6418
6508
|
const request = {
|
|
6419
6509
|
numericId : context?.id || null,
|
|
@@ -6429,11 +6519,30 @@ var shopify = {
|
|
|
6429
6519
|
|
|
6430
6520
|
const providerId = 'gid://shopify/Product/' + context.id;
|
|
6431
6521
|
|
|
6432
|
-
//
|
|
6433
|
-
//
|
|
6434
|
-
//
|
|
6435
|
-
//
|
|
6436
|
-
|
|
6522
|
+
// THE ROW'S OWN ID WHEN IT ALREADY EXISTS, a fresh one only when it
|
|
6523
|
+
// does not.
|
|
6524
|
+
//
|
|
6525
|
+
// The write below is an upsert whose `id` sits in $setOnInsert, so a
|
|
6526
|
+
// product we have seen before keeps the id it was first given and a
|
|
6527
|
+
// newly minted one is silently discarded. Minting unconditionally
|
|
6528
|
+
// therefore published an id belonging to no row: the enqueue named a
|
|
6529
|
+
// product the worker then had to fall back off, and the step's
|
|
6530
|
+
// response showed QA a value that matched nothing they could look up
|
|
6531
|
+
// — which is exactly how it was reported.
|
|
6532
|
+
//
|
|
6533
|
+
// Reading first costs one indexed lookup on the same key the upsert
|
|
6534
|
+
// matches. A concurrent redelivery can still have both runs miss and
|
|
6535
|
+
// mint, which is why the job carries the PROVIDER identity too and the
|
|
6536
|
+
// worker falls back to provider + shop — stable either way.
|
|
6537
|
+
const query = {
|
|
6538
|
+
'provider.id' : providerId,
|
|
6539
|
+
'provider.slug' : 'shopify',
|
|
6540
|
+
'source.domain' : connection.shop
|
|
6541
|
+
};
|
|
6542
|
+
|
|
6543
|
+
const existing = read?.get ? await read.get({ collection : 'product', query }) : null;
|
|
6544
|
+
|
|
6545
|
+
const productId = existing?.id || mintId();
|
|
6437
6546
|
|
|
6438
6547
|
return {
|
|
6439
6548
|
enqueues : [ {
|
|
@@ -6464,11 +6573,7 @@ var shopify = {
|
|
|
6464
6573
|
},
|
|
6465
6574
|
operation : 'update',
|
|
6466
6575
|
options : { upsert : true },
|
|
6467
|
-
query
|
|
6468
|
-
'provider.id' : providerId,
|
|
6469
|
-
'provider.slug' : 'shopify',
|
|
6470
|
-
'source.domain' : connection.shop
|
|
6471
|
-
}
|
|
6576
|
+
query
|
|
6472
6577
|
} ]
|
|
6473
6578
|
};
|
|
6474
6579
|
|
|
@@ -6941,10 +7046,10 @@ var shopify = {
|
|
|
6941
7046
|
queue : 'connection'
|
|
6942
7047
|
} ],
|
|
6943
7048
|
message : ( scopesMissing?.length
|
|
6944
|
-
? 'Health check: ping ok,
|
|
7049
|
+
? 'Health check: ping ok, scopes reconciled — connection errored, granted scopes are missing: ' + scopesMissing.join( ', ' ) + '.'
|
|
6945
7050
|
: refreshTokenRotated
|
|
6946
|
-
? 'Health check passed — refresh token rotated, ping ok,
|
|
6947
|
-
: 'Health check passed — ping ok,
|
|
7051
|
+
? 'Health check passed — refresh token rotated, ping ok, scopes reconciled.'
|
|
7052
|
+
: 'Health check passed — ping ok, scopes reconciled.'
|
|
6948
7053
|
)
|
|
6949
7054
|
+ ( sourceRepaired ? ' Billing source repaired from the live shop.' : '' )
|
|
6950
7055
|
+ ( metered === null ? ' No usage line on the store\'s approved plan — order billing needs the merchant to approve the updated plan.' : '' ),
|