@drawbridge/drawbridge-utils 0.0.169 → 0.0.171
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 +49 -32
- package/dist/connections/index.d.cts +109 -34
- package/dist/connections/index.d.ts +109 -34
- package/dist/connections/index.js +49 -32
- package/dist/providers.cjs +49 -32
- package/dist/providers.js +49 -32
- package/package.json +1 -1
|
@@ -865,10 +865,11 @@ var attentive_default2 = {
|
|
|
865
865
|
sync: async ({ lead, settings, suppressed, token }, { fetcher } = {}) => {
|
|
866
866
|
var _a, _b, _c, _d;
|
|
867
867
|
const segment = settings == null ? void 0 : settings.segment;
|
|
868
|
-
if (!segment) return { message: "No Attentive segment is chosen for this connection.", skipped: true };
|
|
869
868
|
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
869
|
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
|
-
|
|
870
|
+
const request2 = { email: email || null, phone: phone || null, segment: (settings == null ? void 0 : settings.segment) || null };
|
|
871
|
+
if (!segment) return { message: "No Attentive segment is chosen for this connection.", request: request2, skipped: true };
|
|
872
|
+
if (!email && !phone) return { message: "That lead has no email address or phone number to sync.", request: request2, skipped: true };
|
|
872
873
|
const user = {
|
|
873
874
|
...email && { email },
|
|
874
875
|
...phone && { phone }
|
|
@@ -890,6 +891,7 @@ var attentive_default2 = {
|
|
|
890
891
|
});
|
|
891
892
|
return {
|
|
892
893
|
message: "Attentive accepted an unsubscribe for this contact \u2014 they have opted out.",
|
|
894
|
+
request: request2,
|
|
893
895
|
response: { accepted: true, unsubscribed: true }
|
|
894
896
|
};
|
|
895
897
|
}
|
|
@@ -930,6 +932,7 @@ var attentive_default2 = {
|
|
|
930
932
|
// queued them — a merchant who reads "synced" and looks for the person
|
|
931
933
|
// in Attentive a second later has been told the wrong thing.
|
|
932
934
|
message: "Attentive accepted this contact for the segment. Attentive processes these asynchronously, so it appears there shortly.",
|
|
935
|
+
request: request2,
|
|
933
936
|
response: {
|
|
934
937
|
accepted: true,
|
|
935
938
|
...(membership == null ? void 0 : membership.batchJobId) && { batchJobId: membership.batchJobId }
|
|
@@ -2946,7 +2949,7 @@ var drawbridge_default2 = {
|
|
|
2946
2949
|
text: { max: 2e3, type: "string" }
|
|
2947
2950
|
},
|
|
2948
2951
|
triggers: ["schedule.day", "schedule.week", "schedule.month"],
|
|
2949
|
-
usage: { actions:
|
|
2952
|
+
usage: { actions: channels.email.actionsPerSend }
|
|
2950
2953
|
}),
|
|
2951
2954
|
// To organization MEMBERS. Never suppressed — an entrant's opt-out must
|
|
2952
2955
|
// not silence an alert to staff — and not billed.
|
|
@@ -2971,10 +2974,14 @@ var drawbridge_default2 = {
|
|
|
2971
2974
|
subject: { max: 150, type: "string" }
|
|
2972
2975
|
},
|
|
2973
2976
|
triggers: ["lead.insert"],
|
|
2974
|
-
//
|
|
2975
|
-
//
|
|
2976
|
-
//
|
|
2977
|
-
|
|
2977
|
+
// EVERY STEP IN A BILLABLE WORKFLOW COUNTS, team mail included. This
|
|
2978
|
+
// leaves our SendGrid account and costs the same to send as any other
|
|
2979
|
+
// email, so it carries the same weight. Whether anything is charged at
|
|
2980
|
+
// all is the workflow's `billable` tag, not the step's audience — and
|
|
2981
|
+
// this step triggers on lead.insert, so a merchant notifying
|
|
2982
|
+
// themselves on every entry spends against their allowance twice per
|
|
2983
|
+
// lead, once for the entry and once for the notification.
|
|
2984
|
+
usage: { actions: channels.email.actionsPerSend }
|
|
2978
2985
|
}),
|
|
2979
2986
|
// To a LEAD. Suppression applies and the send is billed.
|
|
2980
2987
|
send: () => ({
|
|
@@ -3330,13 +3337,15 @@ var klaviyo_default2 = {
|
|
|
3330
3337
|
// different thing from deleting the profile.
|
|
3331
3338
|
remove: false,
|
|
3332
3339
|
sync: async ({ context, lead, segments, settings, suppressed, token }, { fetcher } = {}) => {
|
|
3333
|
-
var _a, _b, _c;
|
|
3340
|
+
var _a, _b, _c, _d, _e;
|
|
3334
3341
|
const list = settings == null ? void 0 : settings.list;
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3342
|
+
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 };
|
|
3343
|
+
if (!list) return { message: "No Klaviyo list is chosen for this connection.", request: request2, skipped: true };
|
|
3344
|
+
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);
|
|
3345
|
+
if (!email) return { message: "That lead has no email address to sync.", request: request2, skipped: true };
|
|
3338
3346
|
const person = (context == null ? void 0 : context.contact) || null;
|
|
3339
3347
|
const totals = (person == null ? void 0 : person.totals) || {};
|
|
3348
|
+
const count = (value) => typeof value === "number" ? value : (value == null ? void 0 : value.total) || 0;
|
|
3340
3349
|
const profile = await api2("/profile-import", {
|
|
3341
3350
|
fetcher,
|
|
3342
3351
|
method: "POST",
|
|
@@ -3350,13 +3359,13 @@ var klaviyo_default2 = {
|
|
|
3350
3359
|
drawbridge_campaigns: (person.campaigns || []).length,
|
|
3351
3360
|
drawbridge_draws: totals.draws || 0,
|
|
3352
3361
|
drawbridge_entries: totals.entries || 0,
|
|
3353
|
-
drawbridge_orders: totals.orders
|
|
3362
|
+
drawbridge_orders: count(totals.orders),
|
|
3354
3363
|
// Campaign-attributed, NOT lifetime. A merchant running
|
|
3355
3364
|
// Shopify already has lifetime revenue in Klaviyo through
|
|
3356
3365
|
// Klaviyo's own integration; what only we can say is how
|
|
3357
3366
|
// much a campaign drove. Named so the two cannot be
|
|
3358
3367
|
// mistaken for one another in a segment builder.
|
|
3359
|
-
drawbridge_revenue: totals.gross
|
|
3368
|
+
drawbridge_revenue: count(totals.gross)
|
|
3360
3369
|
},
|
|
3361
3370
|
// THE DRAWBRIDGE SEGMENTS THEY ARE IN, as a list property the
|
|
3362
3371
|
// merchant builds Klaviyo segments on top of. Klaviyo owns no
|
|
@@ -3390,8 +3399,8 @@ var klaviyo_default2 = {
|
|
|
3390
3399
|
},
|
|
3391
3400
|
token
|
|
3392
3401
|
});
|
|
3393
|
-
const profileId = (
|
|
3394
|
-
if (!profileId) return { message: "Klaviyo returned no profile id.", skipped: true };
|
|
3402
|
+
const profileId = (_e = profile == null ? void 0 : profile.data) == null ? void 0 : _e.id;
|
|
3403
|
+
if (!profileId) return { message: "Klaviyo returned no profile id.", request: request2, skipped: true };
|
|
3395
3404
|
await api2("/profile-subscription-bulk-create-jobs/", {
|
|
3396
3405
|
fetcher,
|
|
3397
3406
|
method: "POST",
|
|
@@ -3420,6 +3429,7 @@ var klaviyo_default2 = {
|
|
|
3420
3429
|
// Merged into `context` for later steps in this run.
|
|
3421
3430
|
context: { klaviyoProfileId: profileId },
|
|
3422
3431
|
message: suppressed ? "Synced to Klaviyo as unsubscribed \u2014 this contact has opted out." : "Synced to the Klaviyo list.",
|
|
3432
|
+
request: request2,
|
|
3423
3433
|
// Recorded on the run for support to read back, not a write
|
|
3424
3434
|
// instruction — the hook has already written what it needed to.
|
|
3425
3435
|
response: { klaviyoProfileId: profileId }
|
|
@@ -3861,7 +3871,8 @@ var api3 = async (path, { dc, fetcher = fetch, method = "GET", payload, token })
|
|
|
3861
3871
|
return response.status === 204 ? null : response.json();
|
|
3862
3872
|
};
|
|
3863
3873
|
var subscriberHash = (email) => (0, import_node_crypto5.createHash)("md5").update(String(email).trim().toLowerCase()).digest("hex");
|
|
3864
|
-
var
|
|
3874
|
+
var TAG_NAME_LIMIT = 100;
|
|
3875
|
+
var tagName = (title) => ("Drawbridge: " + title).slice(0, TAG_NAME_LIMIT);
|
|
3865
3876
|
var mailchimp_default2 = {
|
|
3866
3877
|
// OAUTH 2, authorization code. Every url below is quoted from
|
|
3867
3878
|
// mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/ rather
|
|
@@ -3999,15 +4010,16 @@ var mailchimp_default2 = {
|
|
|
3999
4010
|
// why there is no create-or-update branch here. Quoted from Mailchimp's
|
|
4000
4011
|
// Marketing API reference for the list-members resource.
|
|
4001
4012
|
sync: async ({ connection: connection2, lead, segments, settings, suppressed, token }, { fetcher, read } = {}) => {
|
|
4002
|
-
var _a, _b, _c;
|
|
4013
|
+
var _a, _b, _c, _d, _e;
|
|
4003
4014
|
const audience = settings == null ? void 0 : settings.audience;
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4015
|
+
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 };
|
|
4016
|
+
if (!audience) return { message: "No Mailchimp audience is chosen for this connection.", request: request2, skipped: true };
|
|
4017
|
+
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);
|
|
4018
|
+
if (!email) return { message: "That lead has no email address to sync.", request: request2, skipped: true };
|
|
4007
4019
|
const hash = subscriberHash(email);
|
|
4008
4020
|
const [firstName, ...restOfName] = String((lead == null ? void 0 : lead.name) || "").trim().split(/\s+/).filter(Boolean);
|
|
4009
4021
|
const lastName = restOfName.join(" ");
|
|
4010
|
-
const phone = ((
|
|
4022
|
+
const phone = ((_e = lead == null ? void 0 : lead.phone) == null ? void 0 : _e.number) || null;
|
|
4011
4023
|
const mergeFields = {
|
|
4012
4024
|
...firstName && { FNAME: firstName },
|
|
4013
4025
|
...lastName && { LNAME: lastName },
|
|
@@ -4064,6 +4076,7 @@ var mailchimp_default2 = {
|
|
|
4064
4076
|
// Merged into `context` for later steps in this run.
|
|
4065
4077
|
context: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash },
|
|
4066
4078
|
message: suppressed ? "Synced to Mailchimp as unsubscribed \u2014 this contact has opted out." : "Synced to the Mailchimp audience.",
|
|
4079
|
+
request: request2,
|
|
4067
4080
|
// Recorded on the run for support to read back, not a write
|
|
4068
4081
|
// instruction — the hook has already written what it needed to.
|
|
4069
4082
|
response: { mailchimpMemberId: (member == null ? void 0 : member.id) || hash }
|
|
@@ -4086,10 +4099,12 @@ var mailchimp_default2 = {
|
|
|
4086
4099
|
// on attaching people to it by name. Both address the same object. The
|
|
4087
4100
|
// segment schema says it outright: "The type of segment. Static segments
|
|
4088
4101
|
// are now known as tags"
|
|
4089
|
-
// (api.mailchimp.com/schema/3.0/
|
|
4102
|
+
// (api.mailchimp.com/schema/3.0/Definitions/Lists/Segments/Response.json,
|
|
4103
|
+
// fetched 2026-09-12 — the root Swagger.json carries no prose, only $refs
|
|
4104
|
+
// into fragment files like this one).
|
|
4090
4105
|
//
|
|
4091
|
-
// IDEMPOTENT ON EVERY PATH: called on create, on rename, on
|
|
4092
|
-
//
|
|
4106
|
+
// IDEMPOTENT ON EVERY PATH: called on create, on rename, on a connection
|
|
4107
|
+
// finishing its configuration, and on the backfill migration, it converges. That is what lets one hook serve
|
|
4093
4108
|
// all four without a create-vs-update branch anywhere else.
|
|
4094
4109
|
register: async ({ connection: connection2, context, manifest, settings, token, workflow }, { fetcher, read } = {}) => {
|
|
4095
4110
|
var _a;
|
|
@@ -5097,7 +5112,7 @@ var shopify_default2 = {
|
|
|
5097
5112
|
//
|
|
5098
5113
|
// The shell has already refused a missing or inactive Shopify connection,
|
|
5099
5114
|
// so what is left is the two things only this hook can know are wrong.
|
|
5100
|
-
product: async ({ connection: connection2, context, workflow }, { mintId } = {}) => {
|
|
5115
|
+
product: async ({ connection: connection2, context, workflow }, { mintId, read } = {}) => {
|
|
5101
5116
|
const request2 = {
|
|
5102
5117
|
numericId: (context == null ? void 0 : context.id) || null,
|
|
5103
5118
|
organizationId: workflow.organization,
|
|
@@ -5106,7 +5121,13 @@ var shopify_default2 = {
|
|
|
5106
5121
|
if (!(context == null ? void 0 : context.id)) return { message: "Skipped \u2014 product webhook payload had no id.", request: request2, response: { skipped: true }, skipped: true };
|
|
5107
5122
|
if (!connection2.shop) return { message: "Skipped \u2014 Shopify connection is missing shop domain.", request: request2, response: { skipped: true }, skipped: true };
|
|
5108
5123
|
const providerId = "gid://shopify/Product/" + context.id;
|
|
5109
|
-
const
|
|
5124
|
+
const query = {
|
|
5125
|
+
"provider.id": providerId,
|
|
5126
|
+
"provider.slug": "shopify",
|
|
5127
|
+
"source.domain": connection2.shop
|
|
5128
|
+
};
|
|
5129
|
+
const existing = (read == null ? void 0 : read.get) ? await read.get({ collection: "product", query }) : null;
|
|
5130
|
+
const productId = (existing == null ? void 0 : existing.id) || mintId();
|
|
5110
5131
|
return {
|
|
5111
5132
|
enqueues: [{
|
|
5112
5133
|
data: { product: productId, providerId, shop: connection2.shop },
|
|
@@ -5136,11 +5157,7 @@ var shopify_default2 = {
|
|
|
5136
5157
|
},
|
|
5137
5158
|
operation: "update",
|
|
5138
5159
|
options: { upsert: true },
|
|
5139
|
-
query
|
|
5140
|
-
"provider.id": providerId,
|
|
5141
|
-
"provider.slug": "shopify",
|
|
5142
|
-
"source.domain": connection2.shop
|
|
5143
|
-
}
|
|
5160
|
+
query
|
|
5144
5161
|
}]
|
|
5145
5162
|
};
|
|
5146
5163
|
}
|
|
@@ -5410,7 +5427,7 @@ var shopify_default2 = {
|
|
|
5410
5427
|
options: { jobId: "connection.update.register." + workflow.connection + "." + (0, import_node_crypto6.randomUUID)() },
|
|
5411
5428
|
queue: "connection"
|
|
5412
5429
|
}],
|
|
5413
|
-
message: ((scopesMissing == null ? void 0 : scopesMissing.length) ? "Health check: ping ok,
|
|
5430
|
+
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." : ""),
|
|
5414
5431
|
request: request2,
|
|
5415
5432
|
response: {
|
|
5416
5433
|
billingProbeFailed,
|
|
@@ -1234,8 +1234,6 @@ var attentive = {
|
|
|
1234
1234
|
// status() already stops a connection reaching Active without a
|
|
1235
1235
|
// segment; this is the belt to that braces. A workflow saved before the
|
|
1236
1236
|
// 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
1237
|
const email = lead?.canonical?.email?.value || lead?.email;
|
|
1240
1238
|
|
|
1241
1239
|
// E.164 OR NOTHING. Attentive requires it, and a national-format number
|
|
@@ -1243,9 +1241,22 @@ var attentive = {
|
|
|
1243
1241
|
// and the email carries the sync instead.
|
|
1244
1242
|
const phone = toE164( lead?.canonical?.phone?.value || lead?.phone );
|
|
1245
1243
|
|
|
1244
|
+
// WHAT WENT TO THE VENDOR, so a sync run in the activity feed can
|
|
1245
|
+
// answer what was actually sent. The three contact syncs were the only
|
|
1246
|
+
// hook family carrying none, and a merchant opening one saw
|
|
1247
|
+
// request : null. Attentive identifies on either channel, so both are
|
|
1248
|
+
// recorded — which of the two carried the sync is the first question
|
|
1249
|
+
// anyone debugging one of these asks.
|
|
1250
|
+
const request = { email : email || null, phone : phone || null, segment : settings?.segment || null };
|
|
1251
|
+
|
|
1252
|
+
// status() already stops a connection reaching Active without a
|
|
1253
|
+
// segment; this is the belt to that braces. A workflow saved before the
|
|
1254
|
+
// segment was chosen must not silently write into nothing.
|
|
1255
|
+
if( ! segment ) return { message : 'No Attentive segment is chosen for this connection.', request, skipped : true };
|
|
1256
|
+
|
|
1246
1257
|
// Their `user` requires phone OR email. With neither there is nobody to
|
|
1247
1258
|
// 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 };
|
|
1259
|
+
if( ! email && ! phone ) return { message : 'That lead has no email address or phone number to sync.', request, skipped : true };
|
|
1249
1260
|
|
|
1250
1261
|
const user = {
|
|
1251
1262
|
...( email && { email }),
|
|
@@ -1285,6 +1296,7 @@ var attentive = {
|
|
|
1285
1296
|
|
|
1286
1297
|
return {
|
|
1287
1298
|
message : 'Attentive accepted an unsubscribe for this contact — they have opted out.',
|
|
1299
|
+
request,
|
|
1288
1300
|
response : { accepted : true, unsubscribed : true }
|
|
1289
1301
|
};
|
|
1290
1302
|
|
|
@@ -1332,6 +1344,7 @@ var attentive = {
|
|
|
1332
1344
|
// queued them — a merchant who reads "synced" and looks for the person
|
|
1333
1345
|
// in Attentive a second later has been told the wrong thing.
|
|
1334
1346
|
message : 'Attentive accepted this contact for the segment. Attentive processes these asynchronously, so it appears there shortly.',
|
|
1347
|
+
request,
|
|
1335
1348
|
response : {
|
|
1336
1349
|
accepted : true,
|
|
1337
1350
|
...( membership?.batchJobId && { batchJobId : membership.batchJobId })
|
|
@@ -3353,7 +3366,7 @@ var drawbridge = {
|
|
|
3353
3366
|
text : { max : 2000, type : 'string' }
|
|
3354
3367
|
},
|
|
3355
3368
|
triggers : [ 'schedule.day', 'schedule.week', 'schedule.month' ],
|
|
3356
|
-
usage : { actions :
|
|
3369
|
+
usage : { actions : channels.email.actionsPerSend }
|
|
3357
3370
|
}),
|
|
3358
3371
|
|
|
3359
3372
|
// To organization MEMBERS. Never suppressed — an entrant's opt-out must
|
|
@@ -3379,10 +3392,14 @@ var drawbridge = {
|
|
|
3379
3392
|
subject : { max : 150, type : 'string' }
|
|
3380
3393
|
},
|
|
3381
3394
|
triggers : [ 'lead.insert' ],
|
|
3382
|
-
//
|
|
3383
|
-
//
|
|
3384
|
-
//
|
|
3385
|
-
|
|
3395
|
+
// EVERY STEP IN A BILLABLE WORKFLOW COUNTS, team mail included. This
|
|
3396
|
+
// leaves our SendGrid account and costs the same to send as any other
|
|
3397
|
+
// email, so it carries the same weight. Whether anything is charged at
|
|
3398
|
+
// all is the workflow's `billable` tag, not the step's audience — and
|
|
3399
|
+
// this step triggers on lead.insert, so a merchant notifying
|
|
3400
|
+
// themselves on every entry spends against their allowance twice per
|
|
3401
|
+
// lead, once for the entry and once for the notification.
|
|
3402
|
+
usage : { actions : channels.email.actionsPerSend }
|
|
3386
3403
|
}),
|
|
3387
3404
|
|
|
3388
3405
|
// To a LEAD. Suppression applies and the send is billed.
|
|
@@ -3835,11 +3852,16 @@ var klaviyo = {
|
|
|
3835
3852
|
// incomplete() already stops a connection reaching Active without a
|
|
3836
3853
|
// list; this is the belt to that braces. A workflow saved before the
|
|
3837
3854
|
// list was chosen must not silently write into nothing.
|
|
3838
|
-
|
|
3855
|
+
// WHAT WENT TO THE VENDOR, so a sync run in the activity feed can answer
|
|
3856
|
+
// what was actually sent. The three contact syncs were the only hook
|
|
3857
|
+
// family that carried none, so a merchant opening one saw request : null.
|
|
3858
|
+
const request = { email : lead?.canonical?.email?.value || lead?.email || null, list : settings?.list || null };
|
|
3859
|
+
|
|
3860
|
+
if( ! list ) return { message : 'No Klaviyo list is chosen for this connection.', request, skipped : true };
|
|
3839
3861
|
|
|
3840
3862
|
const email = lead?.canonical?.email?.value || lead?.email;
|
|
3841
3863
|
|
|
3842
|
-
if( ! email ) return { message : 'That lead has no email address to sync.', skipped : true };
|
|
3864
|
+
if( ! email ) return { message : 'That lead has no email address to sync.', request, skipped : true };
|
|
3843
3865
|
|
|
3844
3866
|
// A Klaviyo profile is keyed on an email, so IDENTITY comes from the
|
|
3845
3867
|
// lead. RANKING comes from the contact behind it, because only a
|
|
@@ -3867,6 +3889,13 @@ var klaviyo = {
|
|
|
3867
3889
|
// fetched 2026-09-09).
|
|
3868
3890
|
//
|
|
3869
3891
|
// IT MERGES, and that is why the blocks below are conditional rather
|
|
3892
|
+
// GROUPED OR FLAT, read as one number. contact.totals.orders and .gross
|
|
3893
|
+
// are { total, conversions, redemptions } now, but a contact the
|
|
3894
|
+
// backfill has not reached still holds the old flat number — and a
|
|
3895
|
+
// naked `totals.orders || 0` would hand Klaviyo the whole object as a
|
|
3896
|
+
// profile property. Klaviyo gets the total either way.
|
|
3897
|
+
const count = ( value ) => typeof value === 'number' ? value : ( value?.total || 0 );
|
|
3898
|
+
|
|
3870
3899
|
// than defaulted. "Not including a field in your request will leave it
|
|
3871
3900
|
// unchanged" — so omitting the totals on a lead.insert preserves
|
|
3872
3901
|
// whatever the last segment run published, where sending zeros would
|
|
@@ -3884,13 +3913,13 @@ var klaviyo = {
|
|
|
3884
3913
|
drawbridge_campaigns : ( person.campaigns || [] ).length,
|
|
3885
3914
|
drawbridge_draws : totals.draws || 0,
|
|
3886
3915
|
drawbridge_entries : totals.entries || 0,
|
|
3887
|
-
drawbridge_orders : totals.orders
|
|
3916
|
+
drawbridge_orders : count( totals.orders ),
|
|
3888
3917
|
// Campaign-attributed, NOT lifetime. A merchant running
|
|
3889
3918
|
// Shopify already has lifetime revenue in Klaviyo through
|
|
3890
3919
|
// Klaviyo's own integration; what only we can say is how
|
|
3891
3920
|
// much a campaign drove. Named so the two cannot be
|
|
3892
3921
|
// mistaken for one another in a segment builder.
|
|
3893
|
-
drawbridge_revenue : totals.gross
|
|
3922
|
+
drawbridge_revenue : count( totals.gross )
|
|
3894
3923
|
}),
|
|
3895
3924
|
// THE DRAWBRIDGE SEGMENTS THEY ARE IN, as a list property the
|
|
3896
3925
|
// merchant builds Klaviyo segments on top of. Klaviyo owns no
|
|
@@ -3927,7 +3956,7 @@ var klaviyo = {
|
|
|
3927
3956
|
|
|
3928
3957
|
const profileId = profile?.data?.id;
|
|
3929
3958
|
|
|
3930
|
-
if( ! profileId ) return { message : 'Klaviyo returned no profile id.', skipped : true };
|
|
3959
|
+
if( ! profileId ) return { message : 'Klaviyo returned no profile id.', request, skipped : true };
|
|
3931
3960
|
|
|
3932
3961
|
// SUPPRESSED PEOPLE ARE SYNCED AS UNSUBSCRIBED, NEVER OMITTED.
|
|
3933
3962
|
//
|
|
@@ -3970,6 +3999,7 @@ var klaviyo = {
|
|
|
3970
3999
|
message : suppressed
|
|
3971
4000
|
? 'Synced to Klaviyo as unsubscribed — this contact has opted out.'
|
|
3972
4001
|
: 'Synced to the Klaviyo list.',
|
|
4002
|
+
request,
|
|
3973
4003
|
// Recorded on the run for support to read back, not a write
|
|
3974
4004
|
// instruction — the hook has already written what it needed to.
|
|
3975
4005
|
response : { klaviyoProfileId : profileId }
|
|
@@ -4616,7 +4646,24 @@ const subscriberHash = ( email ) => createHash( 'md5' )
|
|
|
4616
4646
|
// THE TAG'S NAME, and the one place it is spelled. The member write and the
|
|
4617
4647
|
// register hook must agree character for character — they address the same
|
|
4618
4648
|
// object, one by name and one by id — so a prefix change is one edit here.
|
|
4619
|
-
|
|
4649
|
+
//
|
|
4650
|
+
// TRUNCATED TO 100, because that is Mailchimp's documented ceiling for a tag
|
|
4651
|
+
// name ("Tag names can be a maximum of 100 characters",
|
|
4652
|
+
// mailchimp.com/help/create-add-remove-tags, fetched 2026-09-12) and a segment
|
|
4653
|
+
// title can be 100 on its own — so the prefix pushes any title over 88 past it.
|
|
4654
|
+
// The limit is in their help centre and NOT in the API schema, which constrains
|
|
4655
|
+
// `name` to a bare string, so what the API does with an over-long name is
|
|
4656
|
+
// unspecified: it may reject the whole write or truncate server-side, and a
|
|
4657
|
+
// server-side truncation is the worse outcome because register would then look
|
|
4658
|
+
// for a name Mailchimp had silently changed.
|
|
4659
|
+
//
|
|
4660
|
+
// ponytail: two segments whose first 88 characters match would share one tag.
|
|
4661
|
+
// The upgrade is to append a short hash of the segment id, which costs the name
|
|
4662
|
+
// its readability in the merchant's own audience — not worth it until somebody
|
|
4663
|
+
// actually collides.
|
|
4664
|
+
const TAG_NAME_LIMIT = 100;
|
|
4665
|
+
|
|
4666
|
+
const tagName = ( title ) => ( 'Drawbridge: ' + title ).slice( 0, TAG_NAME_LIMIT );
|
|
4620
4667
|
|
|
4621
4668
|
// Mailchimp — contact sync, not a sender.
|
|
4622
4669
|
var mailchimp = {
|
|
@@ -4773,16 +4820,24 @@ var mailchimp = {
|
|
|
4773
4820
|
|
|
4774
4821
|
const audience = settings?.audience;
|
|
4775
4822
|
|
|
4823
|
+
// WHAT WENT TO THE VENDOR, recorded on the step so the activity feed
|
|
4824
|
+
// can answer "what did we actually send". Every other hook family
|
|
4825
|
+
// carries one; the three contact syncs did not, so a merchant opening
|
|
4826
|
+
// a sync run saw `request : null` and learned nothing — which is
|
|
4827
|
+
// exactly how it was reported. The audience and the address are the
|
|
4828
|
+
// whole identity of this write.
|
|
4829
|
+
const request = { audience : settings?.audience || null, email : lead?.canonical?.email?.value || lead?.email || null };
|
|
4830
|
+
|
|
4776
4831
|
// status() already stops a connection reaching Active without an
|
|
4777
4832
|
// audience; this is the belt to that braces. A workflow saved before
|
|
4778
4833
|
// the audience was chosen must not silently write into nothing.
|
|
4779
|
-
if( ! audience ) return { message : 'No Mailchimp audience is chosen for this connection.', skipped : true };
|
|
4834
|
+
if( ! audience ) return { message : 'No Mailchimp audience is chosen for this connection.', request, skipped : true };
|
|
4780
4835
|
|
|
4781
4836
|
const email = lead?.canonical?.email?.value || lead?.email;
|
|
4782
4837
|
|
|
4783
4838
|
// A Mailchimp member IS an email address — there is no other identity
|
|
4784
4839
|
// to write, so a lead without one is skipped rather than failed.
|
|
4785
|
-
if( ! email ) return { message : 'That lead has no email address to sync.', skipped : true };
|
|
4840
|
+
if( ! email ) return { message : 'That lead has no email address to sync.', request, skipped : true };
|
|
4786
4841
|
|
|
4787
4842
|
const hash = subscriberHash( email );
|
|
4788
4843
|
|
|
@@ -4938,6 +4993,7 @@ var mailchimp = {
|
|
|
4938
4993
|
message : suppressed
|
|
4939
4994
|
? 'Synced to Mailchimp as unsubscribed — this contact has opted out.'
|
|
4940
4995
|
: 'Synced to the Mailchimp audience.',
|
|
4996
|
+
request,
|
|
4941
4997
|
// Recorded on the run for support to read back, not a write
|
|
4942
4998
|
// instruction — the hook has already written what it needed to.
|
|
4943
4999
|
response : { mailchimpMemberId : member?.id || hash }
|
|
@@ -4964,10 +5020,12 @@ var mailchimp = {
|
|
|
4964
5020
|
// on attaching people to it by name. Both address the same object. The
|
|
4965
5021
|
// segment schema says it outright: "The type of segment. Static segments
|
|
4966
5022
|
// are now known as tags"
|
|
4967
|
-
// (api.mailchimp.com/schema/3.0/
|
|
5023
|
+
// (api.mailchimp.com/schema/3.0/Definitions/Lists/Segments/Response.json,
|
|
5024
|
+
// fetched 2026-09-12 — the root Swagger.json carries no prose, only $refs
|
|
5025
|
+
// into fragment files like this one).
|
|
4968
5026
|
//
|
|
4969
|
-
// IDEMPOTENT ON EVERY PATH: called on create, on rename, on
|
|
4970
|
-
//
|
|
5027
|
+
// IDEMPOTENT ON EVERY PATH: called on create, on rename, on a connection
|
|
5028
|
+
// finishing its configuration, and on the backfill migration, it converges. That is what lets one hook serve
|
|
4971
5029
|
// all four without a create-vs-update branch anywhere else.
|
|
4972
5030
|
register : async ( { connection, context, manifest, settings, token, workflow }, { fetcher, read } = {} ) => {
|
|
4973
5031
|
|
|
@@ -5033,7 +5091,9 @@ var mailchimp = {
|
|
|
5033
5091
|
// tag-search matches on PREFIX, not on the exact name: "The search
|
|
5034
5092
|
// query will be compared to each tag as a prefix, so all tags that
|
|
5035
5093
|
// have a name starting with this field will be returned"
|
|
5036
|
-
// (api.mailchimp.com/schema/3.0/
|
|
5094
|
+
// (api.mailchimp.com/schema/3.0/Parameters/PrefixTagSearchName.json,
|
|
5095
|
+
// fetched 2026-09-12 — the TagSearch path only $refs this parameter, and
|
|
5096
|
+
// the root Swagger.json carries no prose at all). So
|
|
5037
5097
|
// "Drawbridge: VIP" answers for "Drawbridge: VIPs" too, and attaching
|
|
5038
5098
|
// the row to the first result would point this segment at a different
|
|
5039
5099
|
// merchant's tag. The exact name is filtered here.
|
|
@@ -6385,7 +6445,7 @@ var shopify = {
|
|
|
6385
6445
|
//
|
|
6386
6446
|
// The shell has already refused a missing or inactive Shopify connection,
|
|
6387
6447
|
// so what is left is the two things only this hook can know are wrong.
|
|
6388
|
-
product : async ( { connection, context, workflow }, { mintId } = {} ) => {
|
|
6448
|
+
product : async ( { connection, context, workflow }, { mintId, read } = {} ) => {
|
|
6389
6449
|
|
|
6390
6450
|
const request = {
|
|
6391
6451
|
numericId : context?.id || null,
|
|
@@ -6401,11 +6461,30 @@ var shopify = {
|
|
|
6401
6461
|
|
|
6402
6462
|
const providerId = 'gid://shopify/Product/' + context.id;
|
|
6403
6463
|
|
|
6404
|
-
//
|
|
6405
|
-
//
|
|
6406
|
-
//
|
|
6407
|
-
//
|
|
6408
|
-
|
|
6464
|
+
// THE ROW'S OWN ID WHEN IT ALREADY EXISTS, a fresh one only when it
|
|
6465
|
+
// does not.
|
|
6466
|
+
//
|
|
6467
|
+
// The write below is an upsert whose `id` sits in $setOnInsert, so a
|
|
6468
|
+
// product we have seen before keeps the id it was first given and a
|
|
6469
|
+
// newly minted one is silently discarded. Minting unconditionally
|
|
6470
|
+
// therefore published an id belonging to no row: the enqueue named a
|
|
6471
|
+
// product the worker then had to fall back off, and the step's
|
|
6472
|
+
// response showed QA a value that matched nothing they could look up
|
|
6473
|
+
// — which is exactly how it was reported.
|
|
6474
|
+
//
|
|
6475
|
+
// Reading first costs one indexed lookup on the same key the upsert
|
|
6476
|
+
// matches. A concurrent redelivery can still have both runs miss and
|
|
6477
|
+
// mint, which is why the job carries the PROVIDER identity too and the
|
|
6478
|
+
// worker falls back to provider + shop — stable either way.
|
|
6479
|
+
const query = {
|
|
6480
|
+
'provider.id' : providerId,
|
|
6481
|
+
'provider.slug' : 'shopify',
|
|
6482
|
+
'source.domain' : connection.shop
|
|
6483
|
+
};
|
|
6484
|
+
|
|
6485
|
+
const existing = read?.get ? await read.get({ collection : 'product', query }) : null;
|
|
6486
|
+
|
|
6487
|
+
const productId = existing?.id || mintId();
|
|
6409
6488
|
|
|
6410
6489
|
return {
|
|
6411
6490
|
enqueues : [ {
|
|
@@ -6436,11 +6515,7 @@ var shopify = {
|
|
|
6436
6515
|
},
|
|
6437
6516
|
operation : 'update',
|
|
6438
6517
|
options : { upsert : true },
|
|
6439
|
-
query
|
|
6440
|
-
'provider.id' : providerId,
|
|
6441
|
-
'provider.slug' : 'shopify',
|
|
6442
|
-
'source.domain' : connection.shop
|
|
6443
|
-
}
|
|
6518
|
+
query
|
|
6444
6519
|
} ]
|
|
6445
6520
|
};
|
|
6446
6521
|
|
|
@@ -6913,10 +6988,10 @@ var shopify = {
|
|
|
6913
6988
|
queue : 'connection'
|
|
6914
6989
|
} ],
|
|
6915
6990
|
message : ( scopesMissing?.length
|
|
6916
|
-
? 'Health check: ping ok,
|
|
6991
|
+
? 'Health check: ping ok, scopes reconciled — connection errored, granted scopes are missing: ' + scopesMissing.join( ', ' ) + '.'
|
|
6917
6992
|
: refreshTokenRotated
|
|
6918
|
-
? 'Health check passed — refresh token rotated, ping ok,
|
|
6919
|
-
: 'Health check passed — ping ok,
|
|
6993
|
+
? 'Health check passed — refresh token rotated, ping ok, scopes reconciled.'
|
|
6994
|
+
: 'Health check passed — ping ok, scopes reconciled.'
|
|
6920
6995
|
)
|
|
6921
6996
|
+ ( sourceRepaired ? ' Billing source repaired from the live shop.' : '' )
|
|
6922
6997
|
+ ( metered === null ? ' No usage line on the store\'s approved plan — order billing needs the merchant to approve the updated plan.' : '' ),
|