@drawbridge/drawbridge-utils 0.0.167 → 0.0.169
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 +793 -109
- package/dist/connections/index.d.cts +1190 -113
- package/dist/connections/index.d.ts +1190 -113
- package/dist/connections/index.js +782 -98
- package/dist/plans.cjs +4 -4
- package/dist/plans.d.cts +4 -4
- package/dist/plans.d.ts +4 -4
- package/dist/plans.js +4 -4
- package/dist/pricing.cjs +4 -4
- package/dist/pricing.js +4 -4
- package/dist/providers.cjs +797 -113
- package/dist/providers.js +786 -102
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { authToken } from './oauth.js';
|
|
2
2
|
export { consentUrl, pkcePair } from './oauth.js';
|
|
3
3
|
import { toE164, detectCountry } from '../phone.js';
|
|
4
|
-
import crypto, { createHmac, timingSafeEqual, createVerify, createHash
|
|
4
|
+
import crypto, { randomUUID, createHmac, timingSafeEqual, createVerify, createHash } from 'node:crypto';
|
|
5
5
|
import { request } from '../http.js';
|
|
6
6
|
import { channels } from '../pricing.js';
|
|
7
7
|
import { customAlphabet } from 'nanoid';
|
|
@@ -186,7 +186,20 @@ const HOOKS = Object.freeze({
|
|
|
186
186
|
|
|
187
187
|
sms : Object.freeze([ 'send' ]),
|
|
188
188
|
|
|
189
|
-
segment : Object.freeze([
|
|
189
|
+
segment : Object.freeze([
|
|
190
|
+
// Make this segment's object exist at the vendor, carrying the segment's
|
|
191
|
+
// current title, and describe the row that points at it. IDEMPOTENT: the
|
|
192
|
+
// same call creates it, renames it after an edit, and backfills a segment
|
|
193
|
+
// that predates the connection — so one hook serves every path and there
|
|
194
|
+
// is no create-vs-update branch to keep in step.
|
|
195
|
+
'register',
|
|
196
|
+
// Remove the vendor object this connection's row points at. Called with
|
|
197
|
+
// the pre-image on a segment delete, because by then the document is gone.
|
|
198
|
+
'remove',
|
|
199
|
+
// Recalculate Drawbridge-side membership. Private to the drawbridge
|
|
200
|
+
// manifest; a vendor does not own who is in a Drawbridge segment.
|
|
201
|
+
'sync'
|
|
202
|
+
]),
|
|
190
203
|
|
|
191
204
|
// OUTBOUND DELIVERY to an address the merchant owns, rather than to a vendor.
|
|
192
205
|
// The Webhooks connection is the only thing here with no third party behind
|
|
@@ -516,6 +529,8 @@ const STEPS = Object.freeze({
|
|
|
516
529
|
'email.digest' : 'Digest',
|
|
517
530
|
'email.notify' : 'Notification',
|
|
518
531
|
'email.send' : 'Send email',
|
|
532
|
+
'segment.register' : 'Register segment',
|
|
533
|
+
'segment.remove' : 'Remove segment',
|
|
519
534
|
'segment.sync' : 'Sync segment',
|
|
520
535
|
'sms.send' : 'Send SMS',
|
|
521
536
|
'webhook.send' : 'Send webhook'
|
|
@@ -715,7 +730,16 @@ const tokenSettings = ({ existing = {}, now = Date.now(), tokens }) => ({
|
|
|
715
730
|
...( tokens.expiresIn && {
|
|
716
731
|
expiresAt : new Date( now + ( tokens.expiresIn * 1000 ) ).toISOString()
|
|
717
732
|
}),
|
|
718
|
-
|
|
733
|
+
// A VENDOR NEED NOT RETURN `scope` ON A REFRESH. Klaviyo documents it on the
|
|
734
|
+
// authorization_code response and documents no response body at all for the
|
|
735
|
+
// refresh grant, so taking the minted value alone drops the stored one. That
|
|
736
|
+
// matters because `scope` is load-bearing: the segment hooks gate on
|
|
737
|
+
// `segments:write` and answer `skipped` when it is absent, so a connection
|
|
738
|
+
// that dropped it disables its whole segment half without failing anything
|
|
739
|
+
// and shows a reconnect task that reconnecting has already fixed.
|
|
740
|
+
...( ( tokens.scope || existing.scope ) && {
|
|
741
|
+
scope : tokens.scope || existing.scope
|
|
742
|
+
})
|
|
719
743
|
});
|
|
720
744
|
|
|
721
745
|
// Returns a token that is good right now, refreshing and persisting first if the
|
|
@@ -792,6 +816,176 @@ var icon$4 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
|
|
|
792
816
|
<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"/>
|
|
793
817
|
</svg>`;
|
|
794
818
|
|
|
819
|
+
// WHERE A DRAWBRIDGE SEGMENT WENT, one row per connection on the segment
|
|
820
|
+
// document. The row is not membership — membership stays as each vendor keeps
|
|
821
|
+
// it, as tags or properties or members — it says which vendor object stands for
|
|
822
|
+
// this segment, and where a merchant can open it.
|
|
823
|
+
//
|
|
824
|
+
// Shared rather than repeated in three manifests because the upsert is the
|
|
825
|
+
// subtle part: two descriptors, each a no-op when the other applies, so a
|
|
826
|
+
// register that changes nothing writes nothing and two concurrent registers
|
|
827
|
+
// converge on ONE ROW rather than two.
|
|
828
|
+
//
|
|
829
|
+
// THE ROW, NOT THE VENDOR OBJECT BEHIND IT. Each manifest searches by name
|
|
830
|
+
// before it creates, which NARROWS the window rather than closing it: two
|
|
831
|
+
// concurrent first registers can both search, both miss and both create, and a
|
|
832
|
+
// drift re-queue carries a deliberately unique job id precisely so it can run
|
|
833
|
+
// alongside a fresh dispatch. What the pair below guarantees is that only one of
|
|
834
|
+
// them is ever pointed at — the loser is an orphaned vendor object, which a
|
|
835
|
+
// merchant can see and delete, and not a row pointing at the wrong thing.
|
|
836
|
+
|
|
837
|
+
// NO `env` HERE, unlike Shopify's urls.manage( data, env ). That third argument
|
|
838
|
+
// is supplied by the api, which has one; a segment url is built inside a hook,
|
|
839
|
+
// and `env` is not in HOOK_PROPS — so a vendor reaching for it would receive
|
|
840
|
+
// undefined on every run. Threading a parameter nothing can fill is a trap for
|
|
841
|
+
// whoever writes the next manifest.
|
|
842
|
+
const row = ({ connection, data, manifest, row : described }) => ({
|
|
843
|
+
connection : connection?.id,
|
|
844
|
+
// ALWAYS A STRING. Mailchimp tag ids are integers, Klaviyo segment ids are
|
|
845
|
+
// strings, and one type in the schema is one comparison in the $or below.
|
|
846
|
+
id : String( described?.id ),
|
|
847
|
+
slug : connection?.slug,
|
|
848
|
+
type : described?.type,
|
|
849
|
+
// NULL, NEVER UNDEFINED: undefined drops the key, and the schema requires it.
|
|
850
|
+
// The url is built HERE, while the settings are decrypted and the vendor
|
|
851
|
+
// facts are in hand — an api reading the row later has neither.
|
|
852
|
+
url : manifest?.urls?.segment?.( { ...described, id : String( described?.id ) }, data ) || null
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
const segmentRowWrites = ({ connection, data, manifest, row : described, segment }) => {
|
|
856
|
+
|
|
857
|
+
const built = row({ connection, data, manifest, row : described });
|
|
858
|
+
|
|
859
|
+
return [
|
|
860
|
+
// PUSH IF ABSENT. The $ne guard is what makes a second concurrent register
|
|
861
|
+
// add nothing rather than a duplicate row for one connection.
|
|
862
|
+
{
|
|
863
|
+
collection : 'segment',
|
|
864
|
+
data : { $push : { connections : built } },
|
|
865
|
+
operation : 'update',
|
|
866
|
+
query : {
|
|
867
|
+
id : segment?.id,
|
|
868
|
+
'connections.connection' : { $ne : connection?.id }
|
|
869
|
+
}
|
|
870
|
+
},
|
|
871
|
+
// SET IF DIFFERENT. $elemMatch selects this connection's row only when one
|
|
872
|
+
// of its three mutable fields disagrees, so the steady state — the same
|
|
873
|
+
// vendor object, the same url — matches nothing and writes nothing.
|
|
874
|
+
{
|
|
875
|
+
collection : 'segment',
|
|
876
|
+
data : { $set : { 'connections.$' : built } },
|
|
877
|
+
operation : 'update',
|
|
878
|
+
query : {
|
|
879
|
+
id : segment?.id,
|
|
880
|
+
connections : {
|
|
881
|
+
$elemMatch : {
|
|
882
|
+
connection : connection?.id,
|
|
883
|
+
$or : [
|
|
884
|
+
{ id : { $ne : built.id } },
|
|
885
|
+
{ type : { $ne : built.type } },
|
|
886
|
+
{ url : { $ne : built.url } }
|
|
887
|
+
]
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
];
|
|
893
|
+
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
const segmentRowRemoveWrites = ({ connection, segment }) => [
|
|
897
|
+
{
|
|
898
|
+
collection : 'segment',
|
|
899
|
+
data : { $pull : { connections : { connection : connection?.id } } },
|
|
900
|
+
operation : 'update',
|
|
901
|
+
query : { id : segment?.id }
|
|
902
|
+
}
|
|
903
|
+
];
|
|
904
|
+
|
|
905
|
+
// THE ROW THIS CONNECTION ALREADY HAS, or undefined. The segments the shell
|
|
906
|
+
// hands a hook carry `connections`, so a register can answer "nothing to do"
|
|
907
|
+
// without a read.
|
|
908
|
+
const segmentRowFor = ({ connection, segment }) => ( segment?.connections || [] )
|
|
909
|
+
.find( ( entry ) => entry?.connection === connection?.id );
|
|
910
|
+
|
|
911
|
+
// THE SEGMENT AS IT IS NOW, not as it was when this run was dispatched.
|
|
912
|
+
//
|
|
913
|
+
// Four places dispatch register under ONE job id, which is what stops four
|
|
914
|
+
// dispatches becoming four vendor round-trips — but it also means the run
|
|
915
|
+
// carries whichever trigger data won the race. Re-reading is how a coalesced
|
|
916
|
+
// run applies the current title rather than a stale one.
|
|
917
|
+
//
|
|
918
|
+
// Falls back to the passed document when no reader was injected. `read` is an
|
|
919
|
+
// optional capability rather than a guaranteed one — a caller invoking runHook
|
|
920
|
+
// without a database reader still gets a working hook, it just applies the
|
|
921
|
+
// document it was handed.
|
|
922
|
+
const currentSegment = async ({ read, segment }) => {
|
|
923
|
+
|
|
924
|
+
if( ! read?.get || ! segment?.id ) return segment;
|
|
925
|
+
|
|
926
|
+
return read.get({
|
|
927
|
+
collection : 'segment',
|
|
928
|
+
query : { id : segment.id }
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
// ONE MORE PASS WHEN THE TITLE MOVED UNDER US.
|
|
934
|
+
//
|
|
935
|
+
// A rename that arrives while a register is ACTIVE is dropped, because the
|
|
936
|
+
// coalescing job id still exists — so the vendor object would keep the old name
|
|
937
|
+
// forever. Re-reading at the end catches it, and the re-queue carries a UNIQUE
|
|
938
|
+
// id, since the coalescing one is what dropped the rename in the first place.
|
|
939
|
+
//
|
|
940
|
+
// THE TIMESTAMP AND THE RANDOM SUFFIX ARE BOTH LOAD-BEARING. The timestamp is
|
|
941
|
+
// for a person reading the queue, who needs to see when a drift pass was
|
|
942
|
+
// raised; the random half is what actually makes the id unique, because two
|
|
943
|
+
// drift re-queues for one segment inside the same millisecond would otherwise
|
|
944
|
+
// collide back into a single job — dropping the very rename this exists to
|
|
945
|
+
// recover.
|
|
946
|
+
//
|
|
947
|
+
// ponytail: NO `priority`, unlike every dispatch that goes through
|
|
948
|
+
// drawbridge-sync's dispatchExecute — which computes one per organization so a
|
|
949
|
+
// tenant spiking past its window is deprioritized behind everyone else. A drift
|
|
950
|
+
// re-queue therefore enters at BullMQ's default and jumps that queue. Not
|
|
951
|
+
// fixable by taking a `priority` argument either: the number comes from an
|
|
952
|
+
// in-process counter in sync that this package cannot see and that a manifest
|
|
953
|
+
// hook is never handed. Closing it means sync stamping a priority onto described
|
|
954
|
+
// enqueues in step-runner's perform(), which is a change to every enqueue every
|
|
955
|
+
// hook describes — worth doing when a second caller wants it, not for one
|
|
956
|
+
// re-queue that fires only on a rename that landed mid-register.
|
|
957
|
+
const driftEnqueues = async ({ applied, read, segment, workflow }) => {
|
|
958
|
+
|
|
959
|
+
// NOTHING TO RE-QUEUE WITHOUT A WORKFLOW. The enqueue names the workflow to
|
|
960
|
+
// execute and keys its job id on the same id, so an absent one queues a job
|
|
961
|
+
// carrying workflowId : undefined under an id holding the literal string
|
|
962
|
+
// "undefined" — a job that cannot run and that collides with every other
|
|
963
|
+
// workflowless drift pass.
|
|
964
|
+
if( ! workflow?.id ) return [];
|
|
965
|
+
|
|
966
|
+
const fresh = await currentSegment({ read, segment });
|
|
967
|
+
|
|
968
|
+
if( ! fresh?.id || fresh.title === applied ) return [];
|
|
969
|
+
|
|
970
|
+
return [ {
|
|
971
|
+
data : {
|
|
972
|
+
triggerData : {
|
|
973
|
+
organization : fresh.organization || workflow?.organization,
|
|
974
|
+
segment : fresh
|
|
975
|
+
},
|
|
976
|
+
workflowId : workflow?.id
|
|
977
|
+
},
|
|
978
|
+
name : 'execute',
|
|
979
|
+
options : {
|
|
980
|
+
jobId : 'workflow.insert.execute.' + workflow?.id + '.segment.register.' + fresh.id + '.drift.' + Date.now() + '.' + randomUUID().slice( 0, 8 ),
|
|
981
|
+
removeOnComplete : true,
|
|
982
|
+
removeOnFail : true
|
|
983
|
+
},
|
|
984
|
+
queue : 'workflow'
|
|
985
|
+
} ];
|
|
986
|
+
|
|
987
|
+
};
|
|
988
|
+
|
|
795
989
|
// ONE REQUEST SHAPE for every Attentive call, the way Klaviyo's file has one.
|
|
796
990
|
// The path carries its own version — the segments picker is v2 and the
|
|
797
991
|
// subscription writes are v1 — because Attentive versions per resource rather
|
|
@@ -908,7 +1102,7 @@ var attentive = {
|
|
|
908
1102
|
// has to say so rather than let them believe otherwise.
|
|
909
1103
|
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 — neither list is deleted.',
|
|
910
1104
|
description : [
|
|
911
|
-
'
|
|
1105
|
+
'This connection syncs the contacts your campaigns collect into your Attentive account — subscribed for marketing, and added to the segment you choose.',
|
|
912
1106
|
'You authorize Drawbridge from inside Attentive and can revoke that access there at any time. Drawbridge never sees or stores your Attentive password.',
|
|
913
1107
|
'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.',
|
|
914
1108
|
'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.'
|
|
@@ -950,10 +1144,9 @@ var attentive = {
|
|
|
950
1144
|
}
|
|
951
1145
|
],
|
|
952
1146
|
group : 'contacts',
|
|
953
|
-
//
|
|
954
|
-
//
|
|
955
|
-
//
|
|
956
|
-
// and contacts.sync are the first to flip.
|
|
1147
|
+
// WHAT THIS VENDOR DOES AND DOES NOT DO is the value of each hook below, not
|
|
1148
|
+
// a paragraph up here that goes stale the moment one of them is implemented
|
|
1149
|
+
// — which is exactly what happened to the note this replaces.
|
|
957
1150
|
hooks : {
|
|
958
1151
|
|
|
959
1152
|
auth : {
|
|
@@ -967,7 +1160,7 @@ var attentive = {
|
|
|
967
1160
|
// nobody re-derives it. Klaviyo's connect reads the account name back so
|
|
968
1161
|
// the card is not blank; Attentive's card stays blank. There IS an
|
|
969
1162
|
// endpoint — GET https://api.attentivemobile.com/v1/me, Bearer, described
|
|
970
|
-
// on docs.attentive.com/
|
|
1163
|
+
// on docs.attentive.com/docs/authentication as returning "information
|
|
971
1164
|
// specific to your company" — but its RESPONSE SCHEMA is published
|
|
972
1165
|
// nowhere we can read: the docs show the curl and no body. Reading
|
|
973
1166
|
// `body.name` would be a guess, and a guess here fails at the worst
|
|
@@ -1187,7 +1380,71 @@ var attentive = {
|
|
|
1187
1380
|
promotions : false
|
|
1188
1381
|
|
|
1189
1382
|
},
|
|
1190
|
-
segment :
|
|
1383
|
+
segment : {
|
|
1384
|
+
|
|
1385
|
+
// A FOUNDATION, AND HONEST ABOUT IT. Attentive's segments API can create
|
|
1386
|
+
// one with an externalId we choose (docs.attentive.com/reference/
|
|
1387
|
+
// createsegment.md, fetched 2026-09-11: POST /v2/segments, `name`
|
|
1388
|
+
// required, `externalId` optional and "auto-generated if not supplied"),
|
|
1389
|
+
// which would give a real per-segment object — but it takes
|
|
1390
|
+
// segments:write, and scopes ride on the app registration, which does not
|
|
1391
|
+
// exist yet.
|
|
1392
|
+
//
|
|
1393
|
+
// So the row points at the connection-level segment the merchant chose,
|
|
1394
|
+
// `type` says so, and turning this into a per-segment object later is a
|
|
1395
|
+
// change to this file and nothing else: create with
|
|
1396
|
+
// externalId = segment.id, PATCH to rename, archive on remove. Their
|
|
1397
|
+
// update and archive endpoints are BOTH keyed by external id
|
|
1398
|
+
// (docs.attentive.com/reference/patchsegmentbyexternalid.md and
|
|
1399
|
+
// /deletesegmentbyexternalid.md, fetched 2026-09-11), so the segment id we
|
|
1400
|
+
// already hold addresses every one of the three calls.
|
|
1401
|
+
//
|
|
1402
|
+
// NO DRIFT CHECK, unlike the other two: the row points at the
|
|
1403
|
+
// connection's own segment and the link is the index page, so nothing
|
|
1404
|
+
// here depends on the Drawbridge segment's title — a rename has nothing
|
|
1405
|
+
// to apply and nothing to race with. That comes back with the
|
|
1406
|
+
// per-segment object.
|
|
1407
|
+
//
|
|
1408
|
+
// THE RE-READ STAYS ALL THE SAME. It changes nothing today, and this is
|
|
1409
|
+
// the simplest of the three registers and therefore the one the next
|
|
1410
|
+
// vendor gets copied from — one job id serves four dispatch sites, so a
|
|
1411
|
+
// copy that trusts context.segment applies whichever trigger data won
|
|
1412
|
+
// the race, at a vendor where the title does matter.
|
|
1413
|
+
register : async ( { connection, context, manifest, settings }, { read } = {} ) => {
|
|
1414
|
+
|
|
1415
|
+
const segment = await currentSegment({ read, segment : context?.segment });
|
|
1416
|
+
|
|
1417
|
+
if( ! segment?.id || segment.system ) return { message : 'That segment is not one this connection publishes.', skipped : true };
|
|
1418
|
+
|
|
1419
|
+
if( ! settings?.segment ) return { message : 'No Attentive segment is chosen for this connection.', skipped : true };
|
|
1420
|
+
|
|
1421
|
+
return {
|
|
1422
|
+
events : [ {
|
|
1423
|
+
event : 'organization.segments',
|
|
1424
|
+
payload : { id : segment.id },
|
|
1425
|
+
room : 'organization.' + connection?.organization
|
|
1426
|
+
} ],
|
|
1427
|
+
message : 'Contacts in this segment are added to the Attentive segment chosen on this connection.',
|
|
1428
|
+
writes : segmentRowWrites({
|
|
1429
|
+
connection,
|
|
1430
|
+
data : { ...connection, settings },
|
|
1431
|
+
manifest,
|
|
1432
|
+
row : { id : settings.segment, type : 'segment' },
|
|
1433
|
+
segment
|
|
1434
|
+
})
|
|
1435
|
+
};
|
|
1436
|
+
|
|
1437
|
+
},
|
|
1438
|
+
|
|
1439
|
+
// NOT OURS TO DELETE. The segment on this connection is the merchant's,
|
|
1440
|
+
// and it is where every Drawbridge segment's contacts go — removing it
|
|
1441
|
+
// because one Drawbridge segment was deleted would empty the others.
|
|
1442
|
+
remove : false,
|
|
1443
|
+
|
|
1444
|
+
// Drawbridge-side membership belongs to the private manifest.
|
|
1445
|
+
sync : false
|
|
1446
|
+
|
|
1447
|
+
},
|
|
1191
1448
|
sms : false,
|
|
1192
1449
|
webhook : false
|
|
1193
1450
|
|
|
@@ -1217,6 +1474,21 @@ var attentive = {
|
|
|
1217
1474
|
'ATTENTIVE_OAUTH_CLIENT_ID',
|
|
1218
1475
|
'ATTENTIVE_OAUTH_CLIENT_SECRET'
|
|
1219
1476
|
],
|
|
1477
|
+
// THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
|
|
1478
|
+
review : {
|
|
1479
|
+
api : 'https://docs.attentive.com/reference/listsegments',
|
|
1480
|
+
dashboard : 'https://docs.attentive.com/docs/segments',
|
|
1481
|
+
// THIS PAGE DOES NOT LIST EVERY SCOPE. Its table names five —
|
|
1482
|
+
// events:write, ecommerce:write, subscriptions:write, attributes:write,
|
|
1483
|
+
// privacy_requests:write — and says nothing about segments:read or
|
|
1484
|
+
// segments:write, which the segments API this manifest calls does take.
|
|
1485
|
+
// The header at the top of this file carries that distinction; it is
|
|
1486
|
+
// repeated here so a reviewer following the link is not misled by what the
|
|
1487
|
+
// table omits (fetched 2026-09-11).
|
|
1488
|
+
scopes : 'https://docs.attentive.com/docs/authentication',
|
|
1489
|
+
content : '2026-09-11',
|
|
1490
|
+
verified : null
|
|
1491
|
+
},
|
|
1220
1492
|
slug : 'attentive',
|
|
1221
1493
|
// A consent with no segment chosen is authenticated and inert — the sync needs
|
|
1222
1494
|
// somewhere to put people — so the card says Pending rather than Active over
|
|
@@ -1263,6 +1535,27 @@ var attentive = {
|
|
|
1263
1535
|
|
|
1264
1536
|
})
|
|
1265
1537
|
|
|
1538
|
+
},
|
|
1539
|
+
|
|
1540
|
+
segment : {
|
|
1541
|
+
|
|
1542
|
+
// SYSTEM, so the builder never offers it and a merchant POST refuses it:
|
|
1543
|
+
// this fires from the segment's own lifecycle, not from a workflow
|
|
1544
|
+
// somebody assembled. The trigger is declared here rather than hard-coded
|
|
1545
|
+
// in drawbridge-sync.
|
|
1546
|
+
//
|
|
1547
|
+
// REGISTER ONLY. There is no remove step because hooks.segment.remove is
|
|
1548
|
+
// declined, and build() refuses a step pointing at a hook this vendor does
|
|
1549
|
+
// not implement — so the two are one decision, enforced at import.
|
|
1550
|
+
register : () => ({
|
|
1551
|
+
description : 'Records which Attentive segment a Drawbridge segment\'s contacts are added to.',
|
|
1552
|
+
hook : 'segment.register',
|
|
1553
|
+
key : 'Attentive Segment Register',
|
|
1554
|
+
queue : 'connection',
|
|
1555
|
+
system : true,
|
|
1556
|
+
trigger : { event : 'segment.register', type : 'event' }
|
|
1557
|
+
})
|
|
1558
|
+
|
|
1266
1559
|
}
|
|
1267
1560
|
|
|
1268
1561
|
},
|
|
@@ -1280,7 +1573,22 @@ var attentive = {
|
|
|
1280
1573
|
}
|
|
1281
1574
|
]
|
|
1282
1575
|
),
|
|
1283
|
-
title : 'Attentive'
|
|
1576
|
+
title : 'Attentive',
|
|
1577
|
+
|
|
1578
|
+
// ATTENTIVE'S SEGMENTS INDEX. There is no documented per-segment url, and the
|
|
1579
|
+
// only identifier we hold is the API's externalId, which their UI may not
|
|
1580
|
+
// path by — so this lands on the list, where the merchant finds it by name.
|
|
1581
|
+
// A per-segment link arrives with the per-segment object (see hooks.segment).
|
|
1582
|
+
//
|
|
1583
|
+
// THE PATH ITSELF IS NOT PUBLISHED ANYWHERE CITABLE, the same gap Klaviyo's
|
|
1584
|
+
// segment url carries. What is on record is that the segments area lives at
|
|
1585
|
+
// ui.attentivemobile.com/segments — its /segments/create/ and /segments/manual
|
|
1586
|
+
// sub-routes are publicly indexed pages (fetched 2026-09-11) — while the /all
|
|
1587
|
+
// tab is not, and Attentive's help centre refuses automated fetches. The dev
|
|
1588
|
+
// walk-through confirms this against a real account before promote.
|
|
1589
|
+
urls : {
|
|
1590
|
+
segment : () => 'https://ui.attentivemobile.com/segments/all'
|
|
1591
|
+
}
|
|
1284
1592
|
};
|
|
1285
1593
|
|
|
1286
1594
|
// THE SHARED HALF OF inbound.verify.
|
|
@@ -2145,7 +2453,7 @@ var drawbridge = {
|
|
|
2145
2453
|
content : {
|
|
2146
2454
|
confirm : 'This connection is part of Drawbridge and cannot be disconnected.',
|
|
2147
2455
|
description : [
|
|
2148
|
-
'Drawbridge sends your notification
|
|
2456
|
+
'Drawbridge sends your notification emails and your entrants\' emails, and recalculates segment membership on a schedule. It is always on and needs nothing connected.'
|
|
2149
2457
|
],
|
|
2150
2458
|
excerpt : 'The steps Drawbridge runs itself.',
|
|
2151
2459
|
guide : [
|
|
@@ -2397,25 +2705,10 @@ var drawbridge = {
|
|
|
2397
2705
|
|
|
2398
2706
|
if( ! sendable ) return { message : 'Recipient has opted out — skipped.', request, response : { skipped : true }, skipped : true };
|
|
2399
2707
|
|
|
2400
|
-
//
|
|
2401
|
-
//
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
const subscription = organization?.subscription
|
|
2405
|
-
? await read.get({ collection : 'subscription', query : { id : organization.subscription } })
|
|
2406
|
-
: null;
|
|
2407
|
-
|
|
2408
|
-
if( subscription?.status !== 'active' ){
|
|
2409
|
-
|
|
2410
|
-
return {
|
|
2411
|
-
message : 'Organization has no active subscription — workflow-step email skipped.',
|
|
2412
|
-
request,
|
|
2413
|
-
response : { skipped : true },
|
|
2414
|
-
skipped : true
|
|
2415
|
-
};
|
|
2416
|
-
|
|
2417
|
-
}
|
|
2418
|
-
|
|
2708
|
+
// NO SUBSCRIPTION CHECK. The send is billed as an action, so the plan's
|
|
2709
|
+
// allowance is the entitlement and a free organization sends within its
|
|
2710
|
+
// 200 like any other. Entries stop at the free cap before a workflow
|
|
2711
|
+
// can fire on them, which is where the cap is enforced.
|
|
2419
2712
|
return {
|
|
2420
2713
|
message : 'Email queued for delivery to ' + to + '.',
|
|
2421
2714
|
request,
|
|
@@ -2606,6 +2899,12 @@ var drawbridge = {
|
|
|
2606
2899
|
},
|
|
2607
2900
|
segment : {
|
|
2608
2901
|
|
|
2902
|
+
// NOT DRAWBRIDGE'S. Registering a segment means creating an object at a
|
|
2903
|
+
// vendor, and this manifest has no vendor behind it — the three that do
|
|
2904
|
+
// implement these.
|
|
2905
|
+
register : false,
|
|
2906
|
+
remove : false,
|
|
2907
|
+
|
|
2609
2908
|
// RECALCULATE SEGMENT MEMBERSHIP. The one FAN-OUT hook: it evaluates every
|
|
2610
2909
|
// contact in an organization against every segment, which is too much for
|
|
2611
2910
|
// one job, so it returns chunks and the shell defers completion.
|
|
@@ -2971,6 +3270,33 @@ var drawbridge = {
|
|
|
2971
3270
|
// caught exactly that: availableConnections({}) went from [ 'drawbridge' ] to
|
|
2972
3271
|
// empty the moment this was added.
|
|
2973
3272
|
requires : [],
|
|
3273
|
+
// PRIVATE, NOT VENDORLESS. SendGrid, Twilio and HubSpot are behind this
|
|
3274
|
+
// manifest, so `false` would be a lie about which reads were made.
|
|
3275
|
+
//
|
|
3276
|
+
// ONE ENTRY PER VENDOR, because three vendors are three reads. A single
|
|
3277
|
+
// citation here would evidence one of them and read as though it covered all
|
|
3278
|
+
// three, which is the omission this key exists to catch.
|
|
3279
|
+
review : {
|
|
3280
|
+
api : {
|
|
3281
|
+
// lib/hubspot.js posts to /crm/v3/objects/contacts.
|
|
3282
|
+
hubspot : 'https://developers.hubspot.com/docs/reference/api/crm/objects/contacts',
|
|
3283
|
+
// lib/sendgrid.js posts to /v3/mail/send.
|
|
3284
|
+
sendgrid : 'https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send',
|
|
3285
|
+
// lib/twilio.js posts to /2010-04-01/Accounts/{Sid}/Messages.json, and
|
|
3286
|
+
// hooks.inbound.verify reads the MessageStatus this resource documents.
|
|
3287
|
+
twilio : 'https://www.twilio.com/docs/messaging/api/message-resource'
|
|
3288
|
+
},
|
|
3289
|
+
dashboard : {
|
|
3290
|
+
hubspot : 'https://knowledge.hubspot.com/contacts/create-contacts',
|
|
3291
|
+
sendgrid : 'https://www.twilio.com/docs/sendgrid/ui/analytics-and-reporting/email-activity-feed',
|
|
3292
|
+
twilio : 'https://www.twilio.com/docs/messaging/guides/debugging-tools'
|
|
3293
|
+
},
|
|
3294
|
+
// An admin types these keys in; there is no merchant consent and no scope
|
|
3295
|
+
// model on any of the three.
|
|
3296
|
+
scopes : false,
|
|
3297
|
+
content : '2026-09-11',
|
|
3298
|
+
verified : null
|
|
3299
|
+
},
|
|
2974
3300
|
slug : 'drawbridge',
|
|
2975
3301
|
// Always on. There is no credential that could go bad and no configuration a
|
|
2976
3302
|
// merchant could leave half-finished.
|
|
@@ -3186,6 +3512,25 @@ const api$1 = async ( path, { fetcher = fetch, method = 'GET', payload, token }
|
|
|
3186
3512
|
|
|
3187
3513
|
};
|
|
3188
3514
|
|
|
3515
|
+
// The segment's name at Klaviyo, spelled once. It is a LABEL — the definition
|
|
3516
|
+
// below keys on the id — so a rename never has to touch a profile.
|
|
3517
|
+
const segmentName = ( title ) => 'Drawbridge: ' + title;
|
|
3518
|
+
|
|
3519
|
+
// THE GRANT THIS NEEDS. Klaviyo's scopes are set on the app and a token carries
|
|
3520
|
+
// only what the merchant consented to, so a connection made before segments were
|
|
3521
|
+
// asked for holds one that cannot write a segment — Create, Update and Delete
|
|
3522
|
+
// Segment each list `segments:write`
|
|
3523
|
+
// (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json, revision
|
|
3524
|
+
// 2026-07-15, fetched 2026-09-11). Answering 403 three times tells the merchant
|
|
3525
|
+
// nothing; this does.
|
|
3526
|
+
//
|
|
3527
|
+
// `scope` is the grant Klaviyo returned on the exchange, which its OAuth guide
|
|
3528
|
+
// describes as "The scopes that this access token has access to for accessing
|
|
3529
|
+
// API resources" (developers.klaviyo.com/en/docs/set_up_oauth, fetched
|
|
3530
|
+
// 2026-09-11) — not the list this manifest asks for, which is why a connection
|
|
3531
|
+
// older than the ask reads as false rather than true.
|
|
3532
|
+
const canManageSegments = ( settings ) => String( settings?.scope || '' ).split( /\s+/ ).includes( 'segments:write' );
|
|
3533
|
+
|
|
3189
3534
|
// Klaviyo — contact sync, over OAuth.
|
|
3190
3535
|
//
|
|
3191
3536
|
// EVERYTHING ABOUT THIS VENDOR IS IN THIS FILE. Its copy, the fields it stores,
|
|
@@ -3235,9 +3580,21 @@ var klaviyo = {
|
|
|
3235
3580
|
// exchange, and a copy here would be a second answer that goes stale.
|
|
3236
3581
|
expiry : 90 * 24 * 60 * 60,
|
|
3237
3582
|
pkce : true,
|
|
3583
|
+
// EVERY SCOPE THE MANIFEST'S HOOKS NEED, not the ones today's hooks use.
|
|
3584
|
+
// Klaviyo holds scopes on the APP — "Pinpoint which scopes your app uses
|
|
3585
|
+
// and set them using a space-separated list"
|
|
3586
|
+
// (developers.klaviyo.com/en/docs/create_a_public_oauth_app, fetched
|
|
3587
|
+
// 2026-09-11) — and a merchant's token only ever carries what they
|
|
3588
|
+
// consented to, so a scope added later is a reconnect for every one of
|
|
3589
|
+
// them. That is what segments cost when they were left out here.
|
|
3590
|
+
//
|
|
3238
3591
|
// Space separated. accounts:read is required by Klaviyo on every app
|
|
3239
|
-
// and must stay in the list; the rest are what a contact sync
|
|
3240
|
-
|
|
3592
|
+
// and must stay in the list; the rest are what a contact sync and the
|
|
3593
|
+
// segment hooks need — Get Segments lists `segments:read`, Create,
|
|
3594
|
+
// Update and Delete Segment each list `segments:write`
|
|
3595
|
+
// (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
|
|
3596
|
+
// revision 2026-07-15, fetched 2026-09-11).
|
|
3597
|
+
scopes : 'accounts:read lists:read lists:write profiles:read profiles:write segments:read segments:write',
|
|
3241
3598
|
// EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
|
|
3242
3599
|
// the disconnect hook — three vendor addresses, two of them declared,
|
|
3243
3600
|
// which is exactly the kind of split that goes unnoticed.
|
|
@@ -3288,9 +3645,10 @@ var klaviyo = {
|
|
|
3288
3645
|
confirm : 'Disconnecting revokes Drawbridge\'s access to your Klaviyo account. Your profiles and lists stay in both Klaviyo and Drawbridge — neither is deleted.',
|
|
3289
3646
|
|
|
3290
3647
|
description : [
|
|
3291
|
-
'Connecting Klaviyo lets Drawbridge sync the contacts your campaigns collect into a Klaviyo list, so the people who enter a giveaway
|
|
3648
|
+
'Connecting Klaviyo lets Drawbridge sync the contacts your campaigns collect into a Klaviyo list, so you can market to the people who enter a giveaway alongside the rest of your audience.',
|
|
3292
3649
|
'You authorize Drawbridge from inside Klaviyo and can revoke that access there at any time. Drawbridge never sees or stores your Klaviyo password, and only asks for the permissions listed on the consent screen.',
|
|
3293
|
-
'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.'
|
|
3650
|
+
'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.',
|
|
3651
|
+
'Drawbridge writes its own properties onto the profiles it syncs: how many of your campaigns someone entered, their entries, draws and orders, the revenue their orders attributed to your campaigns, and which Drawbridge segments they are in. You can build Klaviyo segments and flows on any of them.'
|
|
3294
3652
|
],
|
|
3295
3653
|
|
|
3296
3654
|
// KEYED BY WHAT FAILED, not nested inside it. Errors are the thing most
|
|
@@ -3551,7 +3909,14 @@ var klaviyo = {
|
|
|
3551
3909
|
// `segments` is null when the run carried no contact document,
|
|
3552
3910
|
// meaning nobody looked — different from [], which means they
|
|
3553
3911
|
// are in none. Null omits the key and merge leaves it alone.
|
|
3554
|
-
...( segments && { drawbridge_segments : segments.map( ( entry ) => entry.title ).filter( Boolean ) })
|
|
3912
|
+
...( segments && { drawbridge_segments : segments.map( ( entry ) => entry.title ).filter( Boolean ) }),
|
|
3913
|
+
// THE IDS, which is what a Drawbridge-made segment's definition
|
|
3914
|
+
// filters on. Ids rather than titles, so renaming a segment is a
|
|
3915
|
+
// name change at Klaviyo and not a resync of every profile.
|
|
3916
|
+
//
|
|
3917
|
+
// The titles stay beside them: merchants have been building
|
|
3918
|
+
// their own segments on that array since it shipped.
|
|
3919
|
+
...( segments && { drawbridge_segment_ids : segments.map( ( entry ) => entry.id ).filter( Boolean ) })
|
|
3555
3920
|
}
|
|
3556
3921
|
},
|
|
3557
3922
|
type : 'profile'
|
|
@@ -3614,17 +3979,218 @@ var klaviyo = {
|
|
|
3614
3979
|
|
|
3615
3980
|
},
|
|
3616
3981
|
|
|
3617
|
-
//
|
|
3618
|
-
//
|
|
3619
|
-
//
|
|
3620
|
-
//
|
|
3621
|
-
//
|
|
3622
|
-
// segments — see the private `drawbridge` manifest. A vendor answering
|
|
3623
|
-
// these would be a second sender, which is the arrangement the platform
|
|
3624
|
-
// sender replaced.
|
|
3982
|
+
// Drawbridge sends its own notification email. A vendor answering this
|
|
3983
|
+
// would be a second sender, which is the arrangement the platform sender
|
|
3984
|
+
// replaced. Declined as one line rather than one per verb, because the whole
|
|
3985
|
+
// domain is one decision — still explicit, since absence would not say
|
|
3986
|
+
// whether anybody considered it.
|
|
3625
3987
|
email : false,
|
|
3626
|
-
|
|
3988
|
+
|
|
3989
|
+
// SEGMENTS ARE SHARED, not owned by one side. Drawbridge holds the
|
|
3990
|
+
// membership — see the private `drawbridge` manifest, and `sync : false`
|
|
3991
|
+
// below — while register and remove keep a Klaviyo segment standing for
|
|
3992
|
+
// each Drawbridge segment, so the merchant can target one in their own
|
|
3993
|
+
// flows.
|
|
3994
|
+
segment : {
|
|
3995
|
+
|
|
3996
|
+
// THE KLAVIYO SEGMENT THIS DRAWBRIDGE SEGMENT BECOMES.
|
|
3997
|
+
//
|
|
3998
|
+
// Klaviyo owns no writable membership — its segments are computed from
|
|
3999
|
+
// rules — so the segment we create is DEFINED BY the profile property
|
|
4000
|
+
// contacts.sync writes. The definition filters on the Drawbridge
|
|
4001
|
+
// segment's ID, never its title, which is what makes a rename one PATCH
|
|
4002
|
+
// instead of a resync of every profile in it.
|
|
4003
|
+
register : async ( { connection, context, manifest, settings, token, workflow }, { fetcher, read } = {} ) => {
|
|
4004
|
+
|
|
4005
|
+
// AS IT IS NOW — see the same note on Mailchimp's. One job id serves
|
|
4006
|
+
// four dispatch sites, so the trigger data is whichever one won.
|
|
4007
|
+
const segment = await currentSegment({ read, segment : context?.segment });
|
|
4008
|
+
|
|
4009
|
+
if( ! segment?.id || segment.system ) return { message : 'That segment is not one this connection publishes.', skipped : true };
|
|
4010
|
+
|
|
4011
|
+
if( ! canManageSegments( settings ) ){
|
|
4012
|
+
|
|
4013
|
+
return {
|
|
4014
|
+
message : 'Reconnect Klaviyo to let Drawbridge manage segments — this connection was made before that permission was asked for.',
|
|
4015
|
+
skipped : true
|
|
4016
|
+
};
|
|
4017
|
+
|
|
4018
|
+
}
|
|
4019
|
+
|
|
4020
|
+
const name = segmentName( segment.title );
|
|
4021
|
+
const existing = segmentRowFor({ connection, segment });
|
|
4022
|
+
|
|
4023
|
+
let id = null;
|
|
4024
|
+
|
|
4025
|
+
if( existing?.id ){
|
|
4026
|
+
|
|
4027
|
+
try {
|
|
4028
|
+
|
|
4029
|
+
const found = await api$1( '/segments/' + existing.id, { fetcher, token });
|
|
4030
|
+
|
|
4031
|
+
id = found?.data?.id ?? existing.id;
|
|
4032
|
+
|
|
4033
|
+
if( found?.data?.attributes?.name !== name ){
|
|
4034
|
+
|
|
4035
|
+
// NAME ONLY. Update Segment takes `name` on its own —
|
|
4036
|
+
// nothing in its attributes is required — and the definition
|
|
4037
|
+
// keys on the segment id, which has not changed, so rewriting
|
|
4038
|
+
// it would rebuild the segment for nothing.
|
|
4039
|
+
await api$1( '/segments/' + existing.id, {
|
|
4040
|
+
fetcher,
|
|
4041
|
+
method : 'PATCH',
|
|
4042
|
+
payload : { data : { attributes : { name }, id : existing.id, type : 'segment' } },
|
|
4043
|
+
token
|
|
4044
|
+
});
|
|
4045
|
+
|
|
4046
|
+
}
|
|
4047
|
+
|
|
4048
|
+
} catch ( error ){
|
|
4049
|
+
|
|
4050
|
+
// A 404 means the merchant deleted the segment themselves — fall
|
|
4051
|
+
// through and rebuild rather than failing a step they caused.
|
|
4052
|
+
if( error.status !== 404 ) throw error;
|
|
4053
|
+
|
|
4054
|
+
id = null;
|
|
4055
|
+
|
|
4056
|
+
}
|
|
4057
|
+
|
|
4058
|
+
}
|
|
4059
|
+
|
|
4060
|
+
// BY NAME BEFORE CREATING, which NARROWS the window rather than
|
|
4061
|
+
// closing it. Klaviyo segment names are not unique, so two
|
|
4062
|
+
// overlapping registers — a contact joining seconds after the segment
|
|
4063
|
+
// was made, or a drift re-queue whose deliberately unique job id lets
|
|
4064
|
+
// it run alongside a fresh dispatch — would otherwise leave a second
|
|
4065
|
+
// segment nothing points at. Two concurrent FIRST registers can still
|
|
4066
|
+
// both search, both miss and both create; only one row survives, so
|
|
4067
|
+
// the loser is an orphaned segment rather than a wrong one.
|
|
4068
|
+
//
|
|
4069
|
+
// `equals` is one of the two operators Get Segments allows on `name`,
|
|
4070
|
+
// and a string argument is quoted: "we will accept either single
|
|
4071
|
+
// quoted or double-quoted strings"
|
|
4072
|
+
// (developers.klaviyo.com/en/docs/filtering_, fetched 2026-09-11).
|
|
4073
|
+
// The whole expression is URI-encoded, which the same page requires.
|
|
4074
|
+
//
|
|
4075
|
+
// THE TITLE IS ESCAPED, because it is free text a merchant types and a
|
|
4076
|
+
// double quote in it would otherwise close the literal early — a
|
|
4077
|
+
// malformed filter is a 400 the step retries three times before
|
|
4078
|
+
// failing with no row written. The same page gives the escape: "Single
|
|
4079
|
+
// or double-quoted characters within strings (quoted with like quote
|
|
4080
|
+
// characters) MUST be escaped with a single backslash (i.e. 'Tony\'s
|
|
4081
|
+
// ball')". Only the matching quote needs it, so a double-quoted
|
|
4082
|
+
// literal escapes double quotes and leaves apostrophes alone.
|
|
4083
|
+
if( ! id ){
|
|
4084
|
+
|
|
4085
|
+
const search = await api$1( '/segments?filter=' + encodeURIComponent( 'equals(name,"' + name.replace( /"/g, '\\"' ) + '")' ), { fetcher, token });
|
|
4086
|
+
|
|
4087
|
+
id = ( search?.data || [] ).find( ( entry ) => entry?.attributes?.name === name )?.id ?? null;
|
|
4088
|
+
|
|
4089
|
+
}
|
|
4090
|
+
|
|
4091
|
+
if( ! id ){
|
|
4092
|
+
|
|
4093
|
+
const created = await api$1( '/segments', {
|
|
4094
|
+
fetcher,
|
|
4095
|
+
method : 'POST',
|
|
4096
|
+
// THE DEFINITION IS THE MEMBERSHIP. Create Segment requires one
|
|
4097
|
+
// — `name` and `definition` are both required on its attributes
|
|
4098
|
+
// — and a custom profile property is addressed as
|
|
4099
|
+
// "properties['property name']", tested with a list filter whose
|
|
4100
|
+
// operator is `contains`
|
|
4101
|
+
// (raw.githubusercontent.com/klaviyo/openapi/main/openapi/stable.json,
|
|
4102
|
+
// revision 2026-07-15, fetched 2026-09-11).
|
|
4103
|
+
payload : {
|
|
4104
|
+
data : {
|
|
4105
|
+
attributes : {
|
|
4106
|
+
definition : {
|
|
4107
|
+
condition_groups : [ {
|
|
4108
|
+
conditions : [ {
|
|
4109
|
+
filter : { operator : 'contains', type : 'list', value : segment.id },
|
|
4110
|
+
property : 'properties[\'drawbridge_segment_ids\']',
|
|
4111
|
+
type : 'profile-property'
|
|
4112
|
+
} ]
|
|
4113
|
+
} ]
|
|
4114
|
+
},
|
|
4115
|
+
name
|
|
4116
|
+
},
|
|
4117
|
+
type : 'segment'
|
|
4118
|
+
}
|
|
4119
|
+
},
|
|
4120
|
+
token
|
|
4121
|
+
});
|
|
4122
|
+
|
|
4123
|
+
id = created?.data?.id;
|
|
4124
|
+
|
|
4125
|
+
}
|
|
4126
|
+
|
|
4127
|
+
if( ! id ) return { message : 'Klaviyo returned no segment id.', skipped : true };
|
|
4128
|
+
|
|
4129
|
+
return {
|
|
4130
|
+
// A RENAME THAT ARRIVED WHILE THIS RAN was dropped by the
|
|
4131
|
+
// coalescing job id, so the last thing this does is look again.
|
|
4132
|
+
enqueues : await driftEnqueues({ applied : segment.title, read, segment, workflow }),
|
|
4133
|
+
events : [ {
|
|
4134
|
+
event : 'organization.segments',
|
|
4135
|
+
payload : { id : segment.id },
|
|
4136
|
+
room : 'organization.' + connection?.organization
|
|
4137
|
+
} ],
|
|
4138
|
+
message : 'Klaviyo is carrying this segment as "' + name + '".',
|
|
4139
|
+
writes : segmentRowWrites({
|
|
4140
|
+
connection,
|
|
4141
|
+
data : { ...connection, settings },
|
|
4142
|
+
manifest,
|
|
4143
|
+
row : { id, type : 'segment' },
|
|
4144
|
+
segment
|
|
4145
|
+
})
|
|
4146
|
+
};
|
|
4147
|
+
|
|
4148
|
+
},
|
|
4149
|
+
|
|
4150
|
+
// NO RE-READ. The segment is already deleted; the pre-image is the only
|
|
4151
|
+
// copy, and it carries the row naming what to delete.
|
|
4152
|
+
remove : async ( { connection, context, settings, token }, { fetcher } = {} ) => {
|
|
4153
|
+
|
|
4154
|
+
const segment = context?.segment;
|
|
4155
|
+
const existing = segmentRowFor({ connection, segment });
|
|
4156
|
+
|
|
4157
|
+
if( ! existing?.id ) return { message : 'Klaviyo was never carrying this segment.', skipped : true };
|
|
4158
|
+
|
|
4159
|
+
if( ! canManageSegments( settings ) ){
|
|
4160
|
+
|
|
4161
|
+
return { message : 'Reconnect Klaviyo to let Drawbridge manage segments.', skipped : true };
|
|
4162
|
+
|
|
4163
|
+
}
|
|
4164
|
+
|
|
4165
|
+
try {
|
|
4166
|
+
|
|
4167
|
+
await api$1( '/segments/' + existing.id, { fetcher, method : 'DELETE', token });
|
|
4168
|
+
|
|
4169
|
+
} catch ( error ){
|
|
4170
|
+
|
|
4171
|
+
// ALREADY GONE IS DONE. The merchant may have deleted it, and
|
|
4172
|
+
// retrying a 404 twice more achieves nothing.
|
|
4173
|
+
if( error.status !== 404 ) throw error;
|
|
4174
|
+
|
|
4175
|
+
}
|
|
4176
|
+
|
|
4177
|
+
return {
|
|
4178
|
+
message : 'Klaviyo is no longer carrying this segment.',
|
|
4179
|
+
writes : segmentRowRemoveWrites({ connection, segment })
|
|
4180
|
+
};
|
|
4181
|
+
|
|
4182
|
+
},
|
|
4183
|
+
|
|
4184
|
+
// Drawbridge-side membership belongs to the private manifest.
|
|
4185
|
+
sync : false
|
|
4186
|
+
|
|
4187
|
+
},
|
|
4188
|
+
// Declined for the same reason as `email` above: Drawbridge sends its own
|
|
4189
|
+
// notification SMS, and a vendor answering this would be a second sender.
|
|
3627
4190
|
sms : false,
|
|
4191
|
+
|
|
4192
|
+
// Klaviyo sends us nothing — no inbound message to receive, no signature
|
|
4193
|
+
// to verify.
|
|
3628
4194
|
inbound : false,
|
|
3629
4195
|
|
|
3630
4196
|
// Nothing to set up or tear down at the vendor: the grant is the whole
|
|
@@ -3800,6 +4366,18 @@ var klaviyo = {
|
|
|
3800
4366
|
'KLAVIYO_OAUTH_CLIENT_ID',
|
|
3801
4367
|
'KLAVIYO_OAUTH_CLIENT_SECRET'
|
|
3802
4368
|
],
|
|
4369
|
+
// THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
|
|
4370
|
+
review : {
|
|
4371
|
+
api : 'https://developers.klaviyo.com/en/reference/api_overview',
|
|
4372
|
+
dashboard : 'https://help.klaviyo.com/hc/en-us/articles/115005078647',
|
|
4373
|
+
// THE SCOPE TABLE, not the OAuth walk-through. set_up_oauth carries one
|
|
4374
|
+
// example scope string and nothing to check a manifest against; this page
|
|
4375
|
+
// lists the scopes each API takes, segments:read and segments:write among
|
|
4376
|
+
// them (fetched 2026-09-11).
|
|
4377
|
+
scopes : 'https://developers.klaviyo.com/en/docs/authenticate_',
|
|
4378
|
+
content : '2026-09-11',
|
|
4379
|
+
verified : null
|
|
4380
|
+
},
|
|
3803
4381
|
slug : 'klaviyo',
|
|
3804
4382
|
// ONE OF THE FOUR STATES AND NOTHING ELSE — the reason sits in `tasks`, which
|
|
3805
4383
|
// is already the merchant-facing copy channel and is already rendered.
|
|
@@ -3830,7 +4408,8 @@ var klaviyo = {
|
|
|
3830
4408
|
hook : 'lifecycle.health',
|
|
3831
4409
|
key : 'Klaviyo Connection Health',
|
|
3832
4410
|
queue : 'connection',
|
|
3833
|
-
system : true
|
|
4411
|
+
system : true,
|
|
4412
|
+
trigger : { event : 'day', type : 'schedule' }
|
|
3834
4413
|
})
|
|
3835
4414
|
}
|
|
3836
4415
|
|
|
@@ -3883,6 +4462,32 @@ var klaviyo = {
|
|
|
3883
4462
|
|
|
3884
4463
|
})
|
|
3885
4464
|
|
|
4465
|
+
},
|
|
4466
|
+
|
|
4467
|
+
segment : {
|
|
4468
|
+
|
|
4469
|
+
// SYSTEM, so the builder never offers it and a merchant POST refuses it:
|
|
4470
|
+
// these fire from the segment's own lifecycle, not from a workflow
|
|
4471
|
+
// somebody assembled. The trigger is declared here rather than hard-coded
|
|
4472
|
+
// in drawbridge-sync.
|
|
4473
|
+
register : () => ({
|
|
4474
|
+
description : 'Keeps a matching Klaviyo segment for each Drawbridge segment, built on the segment ids Drawbridge writes onto your profiles.',
|
|
4475
|
+
hook : 'segment.register',
|
|
4476
|
+
key : 'Klaviyo Segment Register',
|
|
4477
|
+
queue : 'connection',
|
|
4478
|
+
system : true,
|
|
4479
|
+
trigger : { event : 'segment.register', type : 'event' }
|
|
4480
|
+
}),
|
|
4481
|
+
|
|
4482
|
+
remove : () => ({
|
|
4483
|
+
description : 'Deletes the Klaviyo segment for a Drawbridge segment when the segment is deleted.',
|
|
4484
|
+
hook : 'segment.remove',
|
|
4485
|
+
key : 'Klaviyo Segment Remove',
|
|
4486
|
+
queue : 'connection',
|
|
4487
|
+
system : true,
|
|
4488
|
+
trigger : { event : 'segment.remove', type : 'event' }
|
|
4489
|
+
})
|
|
4490
|
+
|
|
3886
4491
|
}
|
|
3887
4492
|
|
|
3888
4493
|
},
|
|
@@ -3891,17 +4496,43 @@ var klaviyo = {
|
|
|
3891
4496
|
// is reconnecting — prompting "choose a list" there asks the merchant to
|
|
3892
4497
|
// configure a grant that no longer exists. `pending` is exactly this task's
|
|
3893
4498
|
// moment: the grant is good and the list is the missing half.
|
|
3894
|
-
tasks : ( data ) =>
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
4499
|
+
tasks : ( data ) => {
|
|
4500
|
+
|
|
4501
|
+
if( ! [ 'active', 'pending' ].includes( data?.status ) ) return [];
|
|
4502
|
+
|
|
4503
|
+
// BOTH, WHEN BOTH APPLY. These are independent facts about one connection
|
|
4504
|
+
// — an old grant that cannot manage segments, and a list nobody picked —
|
|
4505
|
+
// and returning only the first means a merchant fixes it, comes back, and
|
|
4506
|
+
// discovers the second. The grant leads because reconnecting is the longer
|
|
4507
|
+
// errand.
|
|
4508
|
+
return [
|
|
4509
|
+
// A connection made before segments were requested is authenticated and
|
|
4510
|
+
// cannot manage them, and no error surfaces anywhere else — the register
|
|
4511
|
+
// runs skip rather than fail.
|
|
4512
|
+
...( canManageSegments( data?.settings ) ? [] : [ {
|
|
4513
|
+
message : 'Drawbridge now keeps a Klaviyo segment in step with each of your Drawbridge segments. Reconnect Klaviyo to allow it.',
|
|
4514
|
+
title : 'Reconnect Klaviyo'
|
|
4515
|
+
} ] ),
|
|
4516
|
+
...( data?.settings?.list ? [] : [ {
|
|
3898
4517
|
message : 'Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.',
|
|
3899
4518
|
title : 'Choose a list'
|
|
3900
|
-
}
|
|
3901
|
-
]
|
|
3902
|
-
|
|
4519
|
+
} ] )
|
|
4520
|
+
];
|
|
4521
|
+
|
|
4522
|
+
},
|
|
3903
4523
|
|
|
3904
|
-
title : 'Klaviyo'
|
|
4524
|
+
title : 'Klaviyo',
|
|
4525
|
+
|
|
4526
|
+
// KLAVIYO PUBLISHES NO DASHBOARD URLS in its API reference. What is on record
|
|
4527
|
+
// is its own help centre on a list: "you can find a list's ID in the URL in
|
|
4528
|
+
// your browser when viewing this list"
|
|
4529
|
+
// (help.klaviyo.com/hc/en-us/articles/115005078647, fetched 2026-09-11), and a
|
|
4530
|
+
// segment's page is the sibling form of it. The path itself is NOT published
|
|
4531
|
+
// anywhere citable, so the dev walk-through confirms this against a real
|
|
4532
|
+
// account before promote.
|
|
4533
|
+
urls : {
|
|
4534
|
+
segment : ( row ) => ( row?.id ? 'https://www.klaviyo.com/segment/' + row.id : null )
|
|
4535
|
+
}
|
|
3905
4536
|
};
|
|
3906
4537
|
|
|
3907
4538
|
// Mailchimp, exported from the brand kit and left as authored — the fills are the
|
|
@@ -3982,6 +4613,11 @@ const subscriberHash = ( email ) => createHash( 'md5' )
|
|
|
3982
4613
|
.update( String( email ).trim().toLowerCase() )
|
|
3983
4614
|
.digest( 'hex' );
|
|
3984
4615
|
|
|
4616
|
+
// THE TAG'S NAME, and the one place it is spelled. The member write and the
|
|
4617
|
+
// register hook must agree character for character — they address the same
|
|
4618
|
+
// object, one by name and one by id — so a prefix change is one edit here.
|
|
4619
|
+
const tagName = ( title ) => 'Drawbridge: ' + title;
|
|
4620
|
+
|
|
3985
4621
|
// Mailchimp — contact sync, not a sender.
|
|
3986
4622
|
var mailchimp = {
|
|
3987
4623
|
// OAUTH 2, authorization code. Every url below is quoted from
|
|
@@ -4025,7 +4661,7 @@ var mailchimp = {
|
|
|
4025
4661
|
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 — neither list is deleted.',
|
|
4026
4662
|
description : [
|
|
4027
4663
|
'Drawbridge no longer sends email through Mailchimp. Notification email now sends from Drawbridge itself, and verifying a domain under Messaging in your organization settings puts your own brand in the from line.',
|
|
4028
|
-
'Connecting Mailchimp lets Drawbridge sync the contacts your campaigns collect into a Mailchimp audience, so the people who enter a giveaway
|
|
4664
|
+
'Connecting Mailchimp lets Drawbridge sync the contacts your campaigns collect into a Mailchimp audience, so you can market to the people who enter a giveaway alongside the rest of your list.',
|
|
4029
4665
|
'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.',
|
|
4030
4666
|
'Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before.'
|
|
4031
4667
|
],
|
|
@@ -4038,6 +4674,7 @@ var mailchimp = {
|
|
|
4038
4674
|
'Sign in to Mailchimp if you are not already, and choose the account to connect.',
|
|
4039
4675
|
'You come back here to pick the audience your contacts should sync into.',
|
|
4040
4676
|
'The connection shows Pending until you pick an audience, then Active.',
|
|
4677
|
+
'Each of your Drawbridge segments appears in that audience as a tag named "Drawbridge: " plus the segment name. Renaming a segment in Drawbridge renames its tag, and deleting the segment deletes the tag.',
|
|
4041
4678
|
'You can remove Drawbridge at any time from the Authorized Apps page in your Mailchimp account.'
|
|
4042
4679
|
]
|
|
4043
4680
|
},
|
|
@@ -4063,10 +4700,9 @@ var mailchimp = {
|
|
|
4063
4700
|
}
|
|
4064
4701
|
],
|
|
4065
4702
|
group : 'contacts',
|
|
4066
|
-
//
|
|
4067
|
-
//
|
|
4068
|
-
//
|
|
4069
|
-
// contacts.sync are the first to flip.
|
|
4703
|
+
// WHAT THIS VENDOR DOES AND DOES NOT DO is the value of each hook below, not
|
|
4704
|
+
// a paragraph up here that goes stale the moment one of them is implemented
|
|
4705
|
+
// — which is exactly what happened to the note this replaces.
|
|
4070
4706
|
hooks : {
|
|
4071
4707
|
|
|
4072
4708
|
auth : {
|
|
@@ -4150,6 +4786,45 @@ var mailchimp = {
|
|
|
4150
4786
|
|
|
4151
4787
|
const hash = subscriberHash( email );
|
|
4152
4788
|
|
|
4789
|
+
// NAME AND PHONE TRAVEL AS MERGE FIELDS, because there is no other
|
|
4790
|
+
// way to write them. Mailchimp's member object has a `full_name`,
|
|
4791
|
+
// but their own schema marks it `"readOnly" : true` — it is DERIVED
|
|
4792
|
+
// from FNAME and LNAME
|
|
4793
|
+
// (api.mailchimp.com/schema/3.0/Definitions/Lists/Members/Response.json,
|
|
4794
|
+
// fetched 2026-09-11), so writing a whole name in one field is not
|
|
4795
|
+
// on offer at any price.
|
|
4796
|
+
//
|
|
4797
|
+
// ONLY THE DEFAULT TAGS. A merge tag the audience does not have is
|
|
4798
|
+
// refused along with the whole member request, and "merge fields
|
|
4799
|
+
// with the tags *|FNAME|*, *|LNAME|*, *|ADDRESS|* and *|PHONE|* are
|
|
4800
|
+
// present by default when an audience is created"
|
|
4801
|
+
// (mailchimp.com/developer/marketing/docs/merge-fields, fetched
|
|
4802
|
+
// 2026-09-11) — so these are the ones safe to send without
|
|
4803
|
+
// registering anything first.
|
|
4804
|
+
//
|
|
4805
|
+
// ADDRESS IS LEFT OUT DELIBERATELY: `lead.address` is an array of
|
|
4806
|
+
// free-text strings and Mailchimp's ADDRESS wants a structured
|
|
4807
|
+
// object (addr1/city/state/zip/country). There is no honest mapping,
|
|
4808
|
+
// and a malformed one takes the member request down with it.
|
|
4809
|
+
//
|
|
4810
|
+
// A lead carries ONE `name`, so the last name is everything after
|
|
4811
|
+
// the first whitespace run — "Ada Lovelace" splits Ada/Lovelace, and
|
|
4812
|
+
// a single-word name sends no LNAME rather than an empty one.
|
|
4813
|
+
const [ firstName, ...restOfName ] = String( lead?.name || '' ).trim().split( /\s+/ ).filter( Boolean );
|
|
4814
|
+
|
|
4815
|
+
const lastName = restOfName.join( ' ' );
|
|
4816
|
+
|
|
4817
|
+
// The raw number the entrant gave, NOT the canonical form: this is a
|
|
4818
|
+
// field the merchant will contact them on, and canonical values are
|
|
4819
|
+
// for identity matching only.
|
|
4820
|
+
const phone = lead?.phone?.number || null;
|
|
4821
|
+
|
|
4822
|
+
const mergeFields = {
|
|
4823
|
+
...( firstName && { FNAME : firstName }),
|
|
4824
|
+
...( lastName && { LNAME : lastName }),
|
|
4825
|
+
...( phone && { PHONE : phone })
|
|
4826
|
+
};
|
|
4827
|
+
|
|
4153
4828
|
// SUPPRESSED PEOPLE ARE SYNCED AS UNSUBSCRIBED, NEVER OMITTED.
|
|
4154
4829
|
//
|
|
4155
4830
|
// Omitting them means Mailchimp never learns they said no, so the
|
|
@@ -4176,14 +4851,12 @@ var mailchimp = {
|
|
|
4176
4851
|
method : 'PUT',
|
|
4177
4852
|
payload : {
|
|
4178
4853
|
email_address : email,
|
|
4179
|
-
//
|
|
4180
|
-
//
|
|
4181
|
-
//
|
|
4182
|
-
//
|
|
4183
|
-
//
|
|
4184
|
-
|
|
4185
|
-
// job and is not built.
|
|
4186
|
-
...( lead?.name && { merge_fields : { FNAME : String( lead.name ).trim().split( /\s+/ )[ 0 ] } }),
|
|
4854
|
+
// Built above. Omitted entirely when there is nothing to say, so a
|
|
4855
|
+
// lead with only an address does not send an empty object. The
|
|
4856
|
+
// Drawbridge totals Klaviyo receives still cannot travel this way —
|
|
4857
|
+
// those are custom tags, and registering them on the chosen audience
|
|
4858
|
+
// is lifecycle.register's job and is not built.
|
|
4859
|
+
...( Object.keys( mergeFields ).length > 0 && { merge_fields : mergeFields }),
|
|
4187
4860
|
...( suppressed && { status : 'unsubscribed' }),
|
|
4188
4861
|
status_if_new : suppressed ? 'unsubscribed' : 'subscribed'
|
|
4189
4862
|
},
|
|
@@ -4199,8 +4872,8 @@ var mailchimp = {
|
|
|
4199
4872
|
// and is not built. A tag needs no setup: "If a tag that does not exist
|
|
4200
4873
|
// is passed in and set as 'active', a new tag will be created"
|
|
4201
4874
|
// (mailchimp.com/developer/marketing/api/list-member-tags/add-or-remove-member-tags,
|
|
4202
|
-
// fetched 2026-09-09). Nothing for the merchant to prepare
|
|
4203
|
-
//
|
|
4875
|
+
// fetched 2026-09-09). Nothing for the merchant to prepare — the guide
|
|
4876
|
+
// says what appears in their audience, not what to set up first.
|
|
4204
4877
|
//
|
|
4205
4878
|
// ACTIVE AND INACTIVE IN ONE CALL, which is what keeps this correct
|
|
4206
4879
|
// over time. Drawbridge segments are dynamic, and nothing dispatches a
|
|
@@ -4241,7 +4914,7 @@ var mailchimp = {
|
|
|
4241
4914
|
.map( ( entry ) => entry.title )
|
|
4242
4915
|
.filter( Boolean )
|
|
4243
4916
|
.map( ( title ) => ({
|
|
4244
|
-
name :
|
|
4917
|
+
name : tagName( title ),
|
|
4245
4918
|
status : joined.has( title ) ? 'active' : 'inactive'
|
|
4246
4919
|
}) );
|
|
4247
4920
|
|
|
@@ -4273,12 +4946,211 @@ var mailchimp = {
|
|
|
4273
4946
|
}
|
|
4274
4947
|
|
|
4275
4948
|
},
|
|
4276
|
-
// Drawbridge sends its own notification email
|
|
4277
|
-
//
|
|
4278
|
-
//
|
|
4279
|
-
// sender replaced.
|
|
4949
|
+
// Drawbridge sends its own notification email. A vendor answering this
|
|
4950
|
+
// would be a second sender, which is the arrangement the platform sender
|
|
4951
|
+
// replaced.
|
|
4280
4952
|
email : false,
|
|
4281
|
-
|
|
4953
|
+
|
|
4954
|
+
// SEGMENTS ARE SHARED, not owned by one side. Drawbridge holds the
|
|
4955
|
+
// membership — see the private `drawbridge` manifest, and `sync : false`
|
|
4956
|
+
// below — while register and remove keep a Mailchimp tag standing for each
|
|
4957
|
+
// Drawbridge segment, so the merchant can target one in their own audience.
|
|
4958
|
+
segment : {
|
|
4959
|
+
|
|
4960
|
+
// THE TAG THIS SEGMENT IS, held by id at last.
|
|
4961
|
+
//
|
|
4962
|
+
// Tags ARE static segments in Mailchimp's model — same collection, same
|
|
4963
|
+
// ids — so this creates one through /segments and the member write goes
|
|
4964
|
+
// on attaching people to it by name. Both address the same object. The
|
|
4965
|
+
// segment schema says it outright: "The type of segment. Static segments
|
|
4966
|
+
// are now known as tags"
|
|
4967
|
+
// (api.mailchimp.com/schema/3.0/Swagger.json, fetched 2026-09-11).
|
|
4968
|
+
//
|
|
4969
|
+
// IDEMPOTENT ON EVERY PATH: called on create, on rename, on the boot
|
|
4970
|
+
// sweep and on backfill, it converges. That is what lets one hook serve
|
|
4971
|
+
// all four without a create-vs-update branch anywhere else.
|
|
4972
|
+
register : async ( { connection, context, manifest, settings, token, workflow }, { fetcher, read } = {} ) => {
|
|
4973
|
+
|
|
4974
|
+
const audience = settings?.audience;
|
|
4975
|
+
|
|
4976
|
+
if( ! audience ) return { message : 'No Mailchimp audience is chosen for this connection.', skipped : true };
|
|
4977
|
+
|
|
4978
|
+
// AS IT IS NOW, not as it was dispatched. Four places queue register
|
|
4979
|
+
// under one job id, so this run carries whichever of them won — and
|
|
4980
|
+
// applying that title would undo a rename that arrived after it.
|
|
4981
|
+
const segment = await currentSegment({ read, segment : context?.segment });
|
|
4982
|
+
|
|
4983
|
+
if( ! segment?.id || segment.system ) return { message : 'That segment is not one this connection publishes.', skipped : true };
|
|
4984
|
+
|
|
4985
|
+
const name = tagName( segment.title );
|
|
4986
|
+
const existing = segmentRowFor({ connection, segment });
|
|
4987
|
+
|
|
4988
|
+
let id = null;
|
|
4989
|
+
|
|
4990
|
+
// BY ID FIRST, because that is the only path that can rename rather
|
|
4991
|
+
// than orphan. A 404 means the merchant deleted the tag themselves —
|
|
4992
|
+
// fall through and rebuild rather than failing a step they caused.
|
|
4993
|
+
if( existing?.id ){
|
|
4994
|
+
|
|
4995
|
+
try {
|
|
4996
|
+
|
|
4997
|
+
const found = await api( '/lists/' + audience + '/segments/' + existing.id, { dc : settings?.dc, fetcher, token });
|
|
4998
|
+
|
|
4999
|
+
id = found?.id ?? existing.id;
|
|
5000
|
+
|
|
5001
|
+
if( found?.name !== name ){
|
|
5002
|
+
|
|
5003
|
+
await api( '/lists/' + audience + '/segments/' + existing.id, {
|
|
5004
|
+
dc : settings?.dc,
|
|
5005
|
+
fetcher,
|
|
5006
|
+
method : 'PATCH',
|
|
5007
|
+
payload : { name },
|
|
5008
|
+
token
|
|
5009
|
+
});
|
|
5010
|
+
|
|
5011
|
+
}
|
|
5012
|
+
|
|
5013
|
+
} catch ( error ){
|
|
5014
|
+
|
|
5015
|
+
if( error.status !== 404 ) throw error;
|
|
5016
|
+
|
|
5017
|
+
id = null;
|
|
5018
|
+
|
|
5019
|
+
}
|
|
5020
|
+
|
|
5021
|
+
}
|
|
5022
|
+
|
|
5023
|
+
// BY NAME BEFORE CREATING, which NARROWS the window rather than
|
|
5024
|
+
// closing it. Two registers can overlap — a contact joining seconds
|
|
5025
|
+
// after the segment was made does it, and a drift re-queue carries a
|
|
5026
|
+
// deliberately unique job id so it can run alongside a fresh dispatch
|
|
5027
|
+
// — and creating without looking leaves a second tag nothing points
|
|
5028
|
+
// at. Two concurrent FIRST registers can still both search, both miss
|
|
5029
|
+
// and both create; only one row survives, so the loser is an orphaned
|
|
5030
|
+
// tag rather than a wrong one. Mailchimp offers no unique-name
|
|
5031
|
+
// constraint to close it properly.
|
|
5032
|
+
//
|
|
5033
|
+
// tag-search matches on PREFIX, not on the exact name: "The search
|
|
5034
|
+
// query will be compared to each tag as a prefix, so all tags that
|
|
5035
|
+
// have a name starting with this field will be returned"
|
|
5036
|
+
// (api.mailchimp.com/schema/3.0/Swagger.json, fetched 2026-09-11). So
|
|
5037
|
+
// "Drawbridge: VIP" answers for "Drawbridge: VIPs" too, and attaching
|
|
5038
|
+
// the row to the first result would point this segment at a different
|
|
5039
|
+
// merchant's tag. The exact name is filtered here.
|
|
5040
|
+
//
|
|
5041
|
+
// NO `count`, because the endpoint takes none: its only parameters are
|
|
5042
|
+
// the list id and `name`
|
|
5043
|
+
// (api.mailchimp.com/schema/3.0/Paths/Lists/TagSearch.json, fetched
|
|
5044
|
+
// 2026-09-11), unlike the audiences hook above where the default of ten
|
|
5045
|
+
// does bite. The response carries `total_items`, which is the one hint
|
|
5046
|
+
// that a page was cut — so if a merchant ever keeps more tags under one
|
|
5047
|
+
// Drawbridge prefix than a page holds, that field is where it shows.
|
|
5048
|
+
if( ! id ){
|
|
5049
|
+
|
|
5050
|
+
const search = await api( '/lists/' + audience + '/tag-search?name=' + encodeURIComponent( name ), { dc : settings?.dc, fetcher, token });
|
|
5051
|
+
|
|
5052
|
+
id = ( search?.tags || [] ).find( ( tag ) => tag?.name === name )?.id ?? null;
|
|
5053
|
+
|
|
5054
|
+
}
|
|
5055
|
+
|
|
5056
|
+
if( ! id ){
|
|
5057
|
+
|
|
5058
|
+
const created = await api( '/lists/' + audience + '/segments', {
|
|
5059
|
+
dc : settings?.dc,
|
|
5060
|
+
fetcher,
|
|
5061
|
+
method : 'POST',
|
|
5062
|
+
// STATIC WITH NO MEMBERS. The member sync attaches people by
|
|
5063
|
+
// name; this call only has to make the object exist. Mailchimp's
|
|
5064
|
+
// own wording for the empty array: "Passing an empty array will
|
|
5065
|
+
// create a static segment without any subscribers."
|
|
5066
|
+
payload : { name, static_segment : [] },
|
|
5067
|
+
token
|
|
5068
|
+
});
|
|
5069
|
+
|
|
5070
|
+
id = created?.id;
|
|
5071
|
+
|
|
5072
|
+
}
|
|
5073
|
+
|
|
5074
|
+
if( ! id ) return { message : 'Mailchimp returned no tag id.', skipped : true };
|
|
5075
|
+
|
|
5076
|
+
// THE AUDIENCE'S WEB ID, which is what the merchant's admin url is
|
|
5077
|
+
// keyed on — the api id in `settings.audience` does not address a page.
|
|
5078
|
+
// Read here rather than at page load, where the data centre is not
|
|
5079
|
+
// decrypted and a vendor round-trip would be on the critical path.
|
|
5080
|
+
const audienceDetail = await api( '/lists/' + audience + '?fields=web_id', { dc : settings?.dc, fetcher, token });
|
|
5081
|
+
|
|
5082
|
+
return {
|
|
5083
|
+
// A RENAME THAT ARRIVED WHILE THIS RAN was dropped by the
|
|
5084
|
+
// coalescing job id, so the last thing this does is look again.
|
|
5085
|
+
enqueues : await driftEnqueues({ applied : segment.title, read, segment, workflow }),
|
|
5086
|
+
events : [ {
|
|
5087
|
+
event : 'organization.segments',
|
|
5088
|
+
payload : { id : segment.id },
|
|
5089
|
+
room : 'organization.' + connection?.organization
|
|
5090
|
+
} ],
|
|
5091
|
+
message : 'Mailchimp is carrying this segment as the tag "' + name + '".',
|
|
5092
|
+
writes : segmentRowWrites({
|
|
5093
|
+
connection,
|
|
5094
|
+
data : { ...connection, settings },
|
|
5095
|
+
manifest,
|
|
5096
|
+
row : { id, type : 'tag', webId : audienceDetail?.web_id },
|
|
5097
|
+
segment
|
|
5098
|
+
})
|
|
5099
|
+
};
|
|
5100
|
+
|
|
5101
|
+
},
|
|
5102
|
+
|
|
5103
|
+
// THE TAG GOES WITH THE SEGMENT. Leaving it behind is the orphan this
|
|
5104
|
+
// whole pair exists to stop — every member would keep a label for a
|
|
5105
|
+
// segment that no longer exists.
|
|
5106
|
+
remove : async ( { connection, context, settings, token }, { fetcher } = {} ) => {
|
|
5107
|
+
|
|
5108
|
+
// NO RE-READ HERE. The segment is already deleted — the pre-image is
|
|
5109
|
+
// the only copy there is, and it carries the rows naming what to
|
|
5110
|
+
// remove.
|
|
5111
|
+
const segment = context?.segment;
|
|
5112
|
+
const existing = segmentRowFor({ connection, segment });
|
|
5113
|
+
|
|
5114
|
+
if( ! existing?.id ) return { message : 'Mailchimp was never carrying this segment.', skipped : true };
|
|
5115
|
+
|
|
5116
|
+
try {
|
|
5117
|
+
|
|
5118
|
+
await api( '/lists/' + settings?.audience + '/segments/' + existing.id, {
|
|
5119
|
+
dc : settings?.dc,
|
|
5120
|
+
fetcher,
|
|
5121
|
+
method : 'DELETE',
|
|
5122
|
+
token
|
|
5123
|
+
});
|
|
5124
|
+
|
|
5125
|
+
} catch ( error ){
|
|
5126
|
+
|
|
5127
|
+
// ALREADY GONE IS DONE. The merchant may have deleted it, and
|
|
5128
|
+
// retrying a 404 twice more achieves nothing.
|
|
5129
|
+
//
|
|
5130
|
+
// Mailchimp writes down no 404 for this call — Delete segment
|
|
5131
|
+
// declares a 204 and a generic problem detail and nothing else
|
|
5132
|
+
// (api.mailchimp.com/schema/3.0/Paths/Lists/Segments/Instance.json,
|
|
5133
|
+
// fetched 2026-09-11) — so this follows from their general error
|
|
5134
|
+
// table rather than from anything documented about deleting a
|
|
5135
|
+
// segment. It is still the right handling: a segment that is not
|
|
5136
|
+
// there is the outcome we wanted.
|
|
5137
|
+
if( error.status !== 404 ) throw error;
|
|
5138
|
+
|
|
5139
|
+
}
|
|
5140
|
+
|
|
5141
|
+
return {
|
|
5142
|
+
message : 'Mailchimp is no longer carrying this segment.',
|
|
5143
|
+
writes : segmentRowRemoveWrites({ connection, segment })
|
|
5144
|
+
};
|
|
5145
|
+
|
|
5146
|
+
},
|
|
5147
|
+
|
|
5148
|
+
// Drawbridge-side membership belongs to the private manifest.
|
|
5149
|
+
sync : false
|
|
5150
|
+
|
|
5151
|
+
},
|
|
5152
|
+
// Declined for the same reason as `email` above: Drawbridge sends its own
|
|
5153
|
+
// notification SMS, and a vendor answering this would be a second sender.
|
|
4282
5154
|
sms : false,
|
|
4283
5155
|
inbound : false,
|
|
4284
5156
|
lifecycle : false,
|
|
@@ -4358,6 +5230,16 @@ var mailchimp = {
|
|
|
4358
5230
|
'MAILCHIMP_OAUTH_CLIENT_ID',
|
|
4359
5231
|
'MAILCHIMP_OAUTH_CLIENT_SECRET'
|
|
4360
5232
|
],
|
|
5233
|
+
// THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
|
|
5234
|
+
review : {
|
|
5235
|
+
api : 'https://mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/',
|
|
5236
|
+
dashboard : 'https://mailchimp.com/help/manage-tags/',
|
|
5237
|
+
// NO SCOPES EXIST. Mailchimp's OAuth guide describes none, and a token is
|
|
5238
|
+
// account-wide — so there is nothing to request and nothing to re-consent.
|
|
5239
|
+
scopes : false,
|
|
5240
|
+
content : '2026-09-11',
|
|
5241
|
+
verified : null
|
|
5242
|
+
},
|
|
4361
5243
|
slug : 'mailchimp',
|
|
4362
5244
|
// A grant with no audience chosen is authenticated and useless — the sync has
|
|
4363
5245
|
// nowhere to put anyone — so the card must say Pending rather than Active over
|
|
@@ -4412,6 +5294,34 @@ var mailchimp = {
|
|
|
4412
5294
|
|
|
4413
5295
|
})
|
|
4414
5296
|
|
|
5297
|
+
},
|
|
5298
|
+
|
|
5299
|
+
segment : {
|
|
5300
|
+
|
|
5301
|
+
// SYSTEM, so the builder never offers it and a merchant POST refuses it:
|
|
5302
|
+
// these fire from the segment's own lifecycle, not from a workflow
|
|
5303
|
+
// somebody assembled.
|
|
5304
|
+
//
|
|
5305
|
+
// The trigger is declared HERE rather than hard-coded in drawbridge-sync,
|
|
5306
|
+
// which is what lets a vendor arrive with its own without a queue edit.
|
|
5307
|
+
register : () => ({
|
|
5308
|
+
description : 'Keeps a matching tag in your Mailchimp audience for each Drawbridge segment, and renames it when the segment is renamed.',
|
|
5309
|
+
hook : 'segment.register',
|
|
5310
|
+
key : 'Mailchimp Segment Register',
|
|
5311
|
+
queue : 'connection',
|
|
5312
|
+
system : true,
|
|
5313
|
+
trigger : { event : 'segment.register', type : 'event' }
|
|
5314
|
+
}),
|
|
5315
|
+
|
|
5316
|
+
remove : () => ({
|
|
5317
|
+
description : 'Deletes the Mailchimp tag for a Drawbridge segment when the segment is deleted.',
|
|
5318
|
+
hook : 'segment.remove',
|
|
5319
|
+
key : 'Mailchimp Segment Remove',
|
|
5320
|
+
queue : 'connection',
|
|
5321
|
+
system : true,
|
|
5322
|
+
trigger : { event : 'segment.remove', type : 'event' }
|
|
5323
|
+
})
|
|
5324
|
+
|
|
4415
5325
|
}
|
|
4416
5326
|
|
|
4417
5327
|
},
|
|
@@ -4429,7 +5339,23 @@ var mailchimp = {
|
|
|
4429
5339
|
}
|
|
4430
5340
|
]
|
|
4431
5341
|
),
|
|
4432
|
-
title : 'Mailchimp'
|
|
5342
|
+
title : 'Mailchimp',
|
|
5343
|
+
// THE MERCHANT'S OWN ADMIN. Mailchimp's list schema states the shape outright:
|
|
5344
|
+
// the web_id field is "The ID used in the Mailchimp web application. View this
|
|
5345
|
+
// list in your Mailchimp account at
|
|
5346
|
+
// https://{dc}.admin.mailchimp.com/lists/members/?id={web_id}"
|
|
5347
|
+
// (api.mailchimp.com/schema/3.0/Definitions/Lists/Response.json, fetched
|
|
5348
|
+
// 2026-09-11).
|
|
5349
|
+
//
|
|
5350
|
+
// It lands on the audience's contacts, where the Drawbridge tag is one filter
|
|
5351
|
+
// away. Mailchimp documents no url that pre-selects a tag, so this stops one
|
|
5352
|
+
// click short rather than guessing at one that could break silently.
|
|
5353
|
+
urls : {
|
|
5354
|
+
segment : ( row, data ) => ( data?.settings?.dc && row?.webId
|
|
5355
|
+
? 'https://' + data.settings.dc + '.admin.mailchimp.com/lists/members/?id=' + row.webId
|
|
5356
|
+
: null
|
|
5357
|
+
)
|
|
5358
|
+
}
|
|
4433
5359
|
};
|
|
4434
5360
|
|
|
4435
5361
|
// Shopify, exported from the brand kit and left as authored — the fills are the
|
|
@@ -4503,6 +5429,61 @@ const attributeLineItems = ( lineItems = [] ) => lineItems.reduce(
|
|
|
4503
5429
|
// types them into a checkout.
|
|
4504
5430
|
const generateDiscountCode = customAlphabet( '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', 8 );
|
|
4505
5431
|
|
|
5432
|
+
// WHY A DISCOUNT CANNOT BACK AN ISSUED CODE, as a sentence for the merchant or
|
|
5433
|
+
// null when nothing is wrong. Every rule here is about the code we are about to
|
|
5434
|
+
// mint being redeemable by an ENTRANT we picked — which is a narrower question
|
|
5435
|
+
// than whether the discount is valid in general, and the reason a discount that
|
|
5436
|
+
// looks fine in Shopify admin can still be the wrong one to choose.
|
|
5437
|
+
//
|
|
5438
|
+
// Field semantics cited in drawbridge-shopify's discountsQuery.
|
|
5439
|
+
const blockedReason = ( discount ) => {
|
|
5440
|
+
|
|
5441
|
+
if( discount?.status === 'EXPIRED' ) return 'This discount has expired.';
|
|
5442
|
+
|
|
5443
|
+
// The union's "anyone" member. Everything else — named customers, a saved
|
|
5444
|
+
// segment, a market — restricts who may redeem, and a code we mint for an
|
|
5445
|
+
// entrant who is not on that list is a code that fails at checkout. Creating
|
|
5446
|
+
// the customer would not fix it: joining a discount's eligibility list is a
|
|
5447
|
+
// separate write we do not make.
|
|
5448
|
+
const buyers = discount?.context?.__typename;
|
|
5449
|
+
|
|
5450
|
+
if( buyers && buyers !== 'DiscountBuyerSelectionAll' ){
|
|
5451
|
+
|
|
5452
|
+
return 'This discount is limited to specific buyers in Shopify, so a code issued to an entrant won\'t work. Set it to all customers to use it here.';
|
|
5453
|
+
|
|
5454
|
+
}
|
|
5455
|
+
// Documented as possibly lagging the true count, so this can only ever
|
|
5456
|
+
// under-report — count >= limit means genuinely exhausted.
|
|
5457
|
+
if( typeof discount?.usageLimit === 'number' && discount.usageLimit > 0
|
|
5458
|
+
&& ( discount?.asyncUsageCount || 0 ) >= discount.usageLimit ){
|
|
5459
|
+
|
|
5460
|
+
return 'This discount has reached its total usage limit.';
|
|
5461
|
+
|
|
5462
|
+
}
|
|
5463
|
+
return null;
|
|
5464
|
+
|
|
5465
|
+
};
|
|
5466
|
+
|
|
5467
|
+
// Usable, but about to behave in a way the merchant did not ask for. Separate
|
|
5468
|
+
// from blockedReason because the answer here is "go ahead, knowing this" — a
|
|
5469
|
+
// scheduled discount is the normal way to set up a campaign in advance, and
|
|
5470
|
+
// refusing it would be wrong.
|
|
5471
|
+
const discountWarning = ( discount ) => {
|
|
5472
|
+
|
|
5473
|
+
if( discount?.status === 'SCHEDULED' ){
|
|
5474
|
+
|
|
5475
|
+
return 'This discount hasn\'t started yet, so codes issued before it does won\'t work until then.';
|
|
5476
|
+
|
|
5477
|
+
}
|
|
5478
|
+
// One redemption per person, which is usually intended — but it is the
|
|
5479
|
+
// difference between a code that can be forwarded and one that cannot, and
|
|
5480
|
+
// merchants do not expect a per-entrant code to also be per-person capped.
|
|
5481
|
+
if( discount?.appliesOncePerCustomer ) return 'Each customer can use this discount only once.';
|
|
5482
|
+
|
|
5483
|
+
return null;
|
|
5484
|
+
|
|
5485
|
+
};
|
|
5486
|
+
|
|
4506
5487
|
// THE USAGE METER'S EVENT HANDLE — the string that decides whether an order's
|
|
4507
5488
|
// billing event bills or is silently ingested as a plain custom event. It must
|
|
4508
5489
|
// equal, case-sensitively, the meter HANDLE configured on the app's plan in
|
|
@@ -4603,7 +5584,7 @@ var shopify = {
|
|
|
4603
5584
|
description : [
|
|
4604
5585
|
'Installing the Drawbridge app from the Shopify App Store links your store to a single Drawbridge organization and makes your product catalog available inside Drawbridge, so you can feature products in your campaigns and advertisements.',
|
|
4605
5586
|
'Drawbridge attributes orders that originate from your campaigns — matched through cart parameters and lead-mapped discount codes — so you can see the revenue each campaign drives.',
|
|
4606
|
-
'
|
|
5587
|
+
'Drawbridge reads your products, records orders placed through your campaigns, and can issue discount codes. Order and product updates reach Drawbridge through the app\'s own webhooks, which Shopify applies when the app is installed.'
|
|
4607
5588
|
],
|
|
4608
5589
|
errors : {
|
|
4609
5590
|
connect : {
|
|
@@ -4617,7 +5598,7 @@ var shopify = {
|
|
|
4617
5598
|
'Open the Drawbridge listing on the Shopify App Store.',
|
|
4618
5599
|
'Install the app on the store you want to connect. It opens in Shopify admin and stays there.',
|
|
4619
5600
|
'Approve the Drawbridge plan when prompted — during install, or from the connection page here. The connection shows Pending until you do, then Active.',
|
|
4620
|
-
'Come back here — the connections list updates on its own once the install
|
|
5601
|
+
'Come back here — the connections list updates on its own once the install finishes.'
|
|
4621
5602
|
],
|
|
4622
5603
|
// Names where the link GOES rather than what it does: installing happens on
|
|
4623
5604
|
// the App Store listing, and the dashboard must never imply a store can be
|
|
@@ -5140,14 +6121,27 @@ var shopify = {
|
|
|
5140
6121
|
? { domain : connection.source.domain, id : connection.source.id }
|
|
5141
6122
|
: undefined;
|
|
5142
6123
|
|
|
6124
|
+
// EVERY PURCHASE GETS AN ORDER DOCUMENT, whichever way it reached us.
|
|
6125
|
+
//
|
|
6126
|
+
// A redemption used to write only a redemption row, which meant the
|
|
6127
|
+
// money existed on the Redemptions page and nowhere else: contact
|
|
6128
|
+
// totals are summed from the ORDER collection, so a real purchase by a
|
|
6129
|
+
// known entrant contributed nothing to their revenue and was invisible
|
|
6130
|
+
// to every revenue segment. `type` keeps the two kinds apart for the
|
|
6131
|
+
// figures that must stay causal (the fee, the Revenue page's
|
|
6132
|
+
// conversion column) without splitting the source of truth in two.
|
|
6133
|
+
const createsOrder = ! backfill && ( isConversion || Boolean( discount ) );
|
|
6134
|
+
|
|
5143
6135
|
// MINTED HERE, because the redemption names its order and the usage
|
|
5144
6136
|
// job names both — a description cannot read a write's result, so the
|
|
5145
|
-
// id exists before either does.
|
|
5146
|
-
|
|
6137
|
+
// id exists before either does. Minted for the redemption too, so the
|
|
6138
|
+
// order can name it back.
|
|
6139
|
+
const orderDocId = existingOrder?.id || ( createsOrder ? mintId() : null );
|
|
6140
|
+
const redemptionDocId = discount ? mintId() : null;
|
|
5147
6141
|
|
|
5148
6142
|
const writes = [];
|
|
5149
6143
|
|
|
5150
|
-
if(
|
|
6144
|
+
if( createsOrder ){
|
|
5151
6145
|
|
|
5152
6146
|
writes.push({
|
|
5153
6147
|
collection : 'order',
|
|
@@ -5169,8 +6163,12 @@ var shopify = {
|
|
|
5169
6163
|
provider : { id : String( orderId ), slug : 'shopify' },
|
|
5170
6164
|
purchasedAt,
|
|
5171
6165
|
rate,
|
|
6166
|
+
// Null on a conversion that matched no code of ours; the
|
|
6167
|
+
// backfill branch below sets it when one arrives later.
|
|
6168
|
+
redemption : redemptionDocId,
|
|
5172
6169
|
source,
|
|
5173
|
-
status : 'completed'
|
|
6170
|
+
status : 'completed',
|
|
6171
|
+
type : isConversion ? 'conversion' : 'redemption'
|
|
5174
6172
|
},
|
|
5175
6173
|
operation : 'create'
|
|
5176
6174
|
});
|
|
@@ -5179,7 +6177,14 @@ var shopify = {
|
|
|
5179
6177
|
|
|
5180
6178
|
writes.push({
|
|
5181
6179
|
collection : 'usage',
|
|
5182
|
-
|
|
6180
|
+
// TWO METERS, NOT ONE SUMMED. `revenue` has always meant
|
|
6181
|
+
// conversion revenue and is the figure the fee is charged
|
|
6182
|
+
// against, so redemption money gets its own key rather than
|
|
6183
|
+
// changing what an existing number means.
|
|
6184
|
+
data : { $inc : isConversion
|
|
6185
|
+
? { 'totals.revenue' : gross }
|
|
6186
|
+
: { 'totals.redemptionRevenue' : gross }
|
|
6187
|
+
},
|
|
5183
6188
|
operation : 'update',
|
|
5184
6189
|
query : { id : org.usage }
|
|
5185
6190
|
});
|
|
@@ -5190,7 +6195,15 @@ var shopify = {
|
|
|
5190
6195
|
|
|
5191
6196
|
writes.push({
|
|
5192
6197
|
collection : 'lead',
|
|
5193
|
-
|
|
6198
|
+
// Same grouped shape the contact carries, so a lead and the
|
|
6199
|
+
// contact built from it cannot be read two different ways.
|
|
6200
|
+
data : { $inc : {
|
|
6201
|
+
'totals.orders.total' : 1,
|
|
6202
|
+
...( isConversion
|
|
6203
|
+
? { 'totals.orders.conversions' : 1 }
|
|
6204
|
+
: { 'totals.orders.redemptions' : 1 }
|
|
6205
|
+
)
|
|
6206
|
+
} },
|
|
5194
6207
|
operation : 'update',
|
|
5195
6208
|
options : { bypassDocumentValidation : true },
|
|
5196
6209
|
query : { id : leadId }
|
|
@@ -5213,6 +6226,7 @@ var shopify = {
|
|
|
5213
6226
|
customer,
|
|
5214
6227
|
discount,
|
|
5215
6228
|
gross,
|
|
6229
|
+
id : redemptionDocId,
|
|
5216
6230
|
lead : leadId,
|
|
5217
6231
|
order : orderDocId,
|
|
5218
6232
|
organization : campaignOrganization,
|
|
@@ -5248,6 +6262,23 @@ var shopify = {
|
|
|
5248
6262
|
|
|
5249
6263
|
}
|
|
5250
6264
|
|
|
6265
|
+
// THE BACKFILL LEG: a conversion recorded earlier, matched to one of
|
|
6266
|
+
// our codes now. The order already exists, so nothing above created
|
|
6267
|
+
// it and nothing has told it which redemption it belongs to — this
|
|
6268
|
+
// is the only write that closes that link. Skipped when the order
|
|
6269
|
+
// was created in this same run, because it was minted carrying the
|
|
6270
|
+
// id already.
|
|
6271
|
+
if( backfill && orderDocId && redemptionDocId ){
|
|
6272
|
+
|
|
6273
|
+
writes.push({
|
|
6274
|
+
collection : 'order',
|
|
6275
|
+
data : { $set : { redemption : redemptionDocId } },
|
|
6276
|
+
operation : 'update',
|
|
6277
|
+
query : { id : orderDocId }
|
|
6278
|
+
});
|
|
6279
|
+
|
|
6280
|
+
}
|
|
6281
|
+
|
|
5251
6282
|
}
|
|
5252
6283
|
|
|
5253
6284
|
// SHOPIFY-BILLED ORGS ARE CHARGED THROUGH SHOPIFY, keyed on the order
|
|
@@ -6035,10 +7066,24 @@ var shopify = {
|
|
|
6035
7066
|
// The GLOBAL id is what Shopify returns and the bare id is what a
|
|
6036
7067
|
// picker stores, which is why the tail is taken here rather than by
|
|
6037
7068
|
// each caller that happened to remember.
|
|
6038
|
-
items : ( discounts?.edges || [] ).map( ( edge ) =>
|
|
6039
|
-
|
|
6040
|
-
|
|
6041
|
-
|
|
7069
|
+
items : ( discounts?.edges || [] ).map( ( edge ) => {
|
|
7070
|
+
|
|
7071
|
+
const node = edge?.node?.codeDiscount || {};
|
|
7072
|
+
|
|
7073
|
+
return {
|
|
7074
|
+
// Null when the discount can be used, a sentence when it cannot.
|
|
7075
|
+
// The picker greys the row and shows this instead of hiding it:
|
|
7076
|
+
// a discount the merchant can see in Shopify admin, missing here
|
|
7077
|
+
// with no explanation, reads as a bug in us.
|
|
7078
|
+
blocked : blockedReason( node ),
|
|
7079
|
+
id : String( edge?.node?.id || '' ).split( '/' ).pop(),
|
|
7080
|
+
// Usable, but not in the way the merchant probably expects.
|
|
7081
|
+
// Shown beside the row without stopping them.
|
|
7082
|
+
warning : discountWarning( node ),
|
|
7083
|
+
title : node.title
|
|
7084
|
+
};
|
|
7085
|
+
|
|
7086
|
+
}),
|
|
6042
7087
|
pageInfo : {
|
|
6043
7088
|
endCursor : discounts?.pageInfo?.endCursor || null,
|
|
6044
7089
|
hasNextPage : Boolean( discounts?.pageInfo?.hasNextPage )
|
|
@@ -6054,24 +7099,6 @@ var shopify = {
|
|
|
6054
7099
|
},
|
|
6055
7100
|
icon,
|
|
6056
7101
|
inbound,
|
|
6057
|
-
// THE DEEP LINK into this store's Drawbridge app inside Shopify admin.
|
|
6058
|
-
//
|
|
6059
|
-
// Here rather than in drawbridge-api, which had `slug === 'shopify' && {...}`
|
|
6060
|
-
// in the shared resolver — a hardcoded vendor branch in code every vendor runs
|
|
6061
|
-
// through, which is the arrangement these manifests exist to remove.
|
|
6062
|
-
//
|
|
6063
|
-
// Undefined until a shop is linked, so the Manage button only appears on a
|
|
6064
|
-
// connected connection. The app handle is NAMED by `requires` and read from
|
|
6065
|
-
// the env the resolver passes, never from process.env here.
|
|
6066
|
-
manage : ( data, env ) => {
|
|
6067
|
-
|
|
6068
|
-
const shop = data?.shop || data?.settings?.domain;
|
|
6069
|
-
|
|
6070
|
-
return shop
|
|
6071
|
-
? 'https://admin.shopify.com/store/' + String( shop ).replace( '.myshopify.com', '' ) + '/apps/' + env?.SHOPIFY_APP_HANDLE
|
|
6072
|
-
: undefined;
|
|
6073
|
-
|
|
6074
|
-
},
|
|
6075
7102
|
// DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
|
|
6076
7103
|
// what an admin types on the provider screen. The four names below are exactly
|
|
6077
7104
|
// what `requires` gates on, which is the point of declaring them together: a
|
|
@@ -6117,6 +7144,14 @@ var shopify = {
|
|
|
6117
7144
|
'SHOPIFY_APP_LISTING_URL',
|
|
6118
7145
|
'SHOPIFY_APP_HANDLE'
|
|
6119
7146
|
],
|
|
7147
|
+
// THE CHECKLIST'S RECEIPT — see drawbridge-docs reference/connection-hooks.md.
|
|
7148
|
+
review : {
|
|
7149
|
+
api : 'https://shopify.dev/docs/api/admin-graphql',
|
|
7150
|
+
dashboard : 'https://help.shopify.com/en/manual/apps',
|
|
7151
|
+
scopes : 'https://shopify.dev/docs/api/usage/access-scopes',
|
|
7152
|
+
content : '2026-09-11',
|
|
7153
|
+
verified : null
|
|
7154
|
+
},
|
|
6120
7155
|
slug : 'shopify',
|
|
6121
7156
|
// The install is the whole configuration — Shopify hands back the shop and
|
|
6122
7157
|
// there is nothing further to choose. `shop` absent means the install did not
|
|
@@ -6209,8 +7244,11 @@ var shopify = {
|
|
|
6209
7244
|
},
|
|
6210
7245
|
|
|
6211
7246
|
// SYSTEM STEPS: dispatched by drawbridge-sync itself rather than offered
|
|
6212
|
-
// in the builder, so they carry no
|
|
6213
|
-
//
|
|
7247
|
+
// in the builder, so they carry no usage. These two are fired by a webhook
|
|
7248
|
+
// arriving rather than by a workflow trigger, so they name none either —
|
|
7249
|
+
// and naming none is what stops a workflow being provisioned for them.
|
|
7250
|
+
// Declared because the routing table and the system-workflow descriptions
|
|
7251
|
+
// both read here.
|
|
6214
7252
|
order : {
|
|
6215
7253
|
record : () => ({
|
|
6216
7254
|
description : 'Records an order and billing charge when a purchase is made via a Drawbridge campaign link.',
|
|
@@ -6246,7 +7284,8 @@ var shopify = {
|
|
|
6246
7284
|
hook : 'lifecycle.health',
|
|
6247
7285
|
key : 'Shopify Connection Health',
|
|
6248
7286
|
queue : 'connection',
|
|
6249
|
-
system : true
|
|
7287
|
+
system : true,
|
|
7288
|
+
trigger : { event : 'day', type : 'schedule' }
|
|
6250
7289
|
})
|
|
6251
7290
|
},
|
|
6252
7291
|
|
|
@@ -6313,7 +7352,34 @@ var shopify = {
|
|
|
6313
7352
|
: []
|
|
6314
7353
|
)
|
|
6315
7354
|
],
|
|
6316
|
-
title : 'Shopify'
|
|
7355
|
+
title : 'Shopify',
|
|
7356
|
+
// THE VENDOR'S OWN ADMIN, one function per thing worth linking to. It lives
|
|
7357
|
+
// here rather than at the top level so a second link (a product, an order)
|
|
7358
|
+
// is a key in this object instead of a new manifest key nobody agreed on.
|
|
7359
|
+
//
|
|
7360
|
+
// AND HERE RATHER THAN IN drawbridge-api, which had `slug === 'shopify' &&
|
|
7361
|
+
// {...}` in the shared resolver — a hardcoded vendor branch in code every
|
|
7362
|
+
// vendor runs through, which is the arrangement these manifests exist to
|
|
7363
|
+
// remove.
|
|
7364
|
+
//
|
|
7365
|
+
// Never projected: the api composes connect.manage from it, and
|
|
7366
|
+
// resolveConnection drops the object, because a url built from settings is
|
|
7367
|
+
// built where the settings are already decrypted.
|
|
7368
|
+
urls : {
|
|
7369
|
+
// Undefined until a shop is linked, so the Manage button only appears on a
|
|
7370
|
+
// connected connection. The app handle is NAMED by `requires` and read from
|
|
7371
|
+
// the env its caller passes — the api's resolve() hands it the stored
|
|
7372
|
+
// credentials, never process.env.
|
|
7373
|
+
manage : ( data, env ) => {
|
|
7374
|
+
|
|
7375
|
+
const shop = data?.shop || data?.settings?.domain;
|
|
7376
|
+
|
|
7377
|
+
return shop
|
|
7378
|
+
? 'https://admin.shopify.com/store/' + String( shop ).replace( '.myshopify.com', '' ) + '/apps/' + env?.SHOPIFY_APP_HANDLE
|
|
7379
|
+
: undefined;
|
|
7380
|
+
|
|
7381
|
+
}
|
|
7382
|
+
}
|
|
6317
7383
|
};
|
|
6318
7384
|
|
|
6319
7385
|
// Webhooks — the only connection with no third party behind it. Connecting
|
|
@@ -6370,7 +7436,7 @@ var webhook = {
|
|
|
6370
7436
|
content : {
|
|
6371
7437
|
confirm : 'Disconnecting stops Drawbridge from sending signed webhook payloads to your endpoint.',
|
|
6372
7438
|
description : [
|
|
6373
|
-
'Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react
|
|
7439
|
+
'Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react to it.',
|
|
6374
7440
|
'Generate a signing secret and Drawbridge signs every request with it. Your endpoint recomputes the signature to confirm each payload genuinely came from Drawbridge before acting on it.'
|
|
6375
7441
|
],
|
|
6376
7442
|
excerpt : 'Sign outgoing webhook payloads with an HMAC secret to verify authenticity.',
|
|
@@ -6496,6 +7562,9 @@ var webhook = {
|
|
|
6496
7562
|
// Gated on the encryption secret: without it the signing secret could not be
|
|
6497
7563
|
// stored safely, so the connection must not be offered at all.
|
|
6498
7564
|
requires : [ 'ENCRYPT_CONNECTION_SECRET' ],
|
|
7565
|
+
// NO THIRD PARTY AT ALL. There is no vendor reference to read, no dashboard
|
|
7566
|
+
// to link to and no scope to request: connecting mints a secret.
|
|
7567
|
+
review : false,
|
|
6499
7568
|
// Outbound only. inbound.* is false because the direction is the point: we
|
|
6500
7569
|
// sign and POST to the merchant's endpoint, they never call us. Every other
|
|
6501
7570
|
// false follows from there being no third party to authenticate against —
|
|
@@ -7490,7 +8559,7 @@ const redactSettings = ({ slug, settings }) => {
|
|
|
7490
8559
|
const publicConnectionKeys = Object.freeze([
|
|
7491
8560
|
'actions',
|
|
7492
8561
|
// API-COMPOSED, not manifest-declared: the api's resolve() builds it from
|
|
7493
|
-
// auth.type, content.redirect and the manifest's manage() — the client reads
|
|
8562
|
+
// auth.type, content.redirect and the manifest's urls.manage() — the client reads
|
|
7494
8563
|
// connect.type to choose entered-vs-installed, connect.redirect for the App
|
|
7495
8564
|
// Store link, connect.manage for the admin deep link. It was dropped from
|
|
7496
8565
|
// this list when the manifests stopped declaring it, which stripped the
|
|
@@ -7582,7 +8651,15 @@ const resolveConnection = ( item, data, env = {} ) => {
|
|
|
7582
8651
|
// answer baked a Pending badge into Klaviyo, Mailchimp and Attentive
|
|
7583
8652
|
// cards nobody had connected. Status belongs to the caller's own
|
|
7584
8653
|
// read-time pass (api's applyStatus), never to projection.
|
|
7585
|
-
|
|
8654
|
+
// `urls` is dropped for the same reason `provider` is: it holds raw
|
|
8655
|
+
// functions, and a url built from stored settings must be built where
|
|
8656
|
+
// those settings are already decrypted — the api calls urls.manage()
|
|
8657
|
+
// itself and composes the result into connect.
|
|
8658
|
+
//
|
|
8659
|
+
// `review` is dropped because it is engineering metadata — which docs
|
|
8660
|
+
// were read, on what day — with no client that reads it. Left in, it
|
|
8661
|
+
// would ride on every connection response a merchant's browser loads.
|
|
8662
|
+
.filter( ( [ key ] ) => ! [ 'auth', 'enabled', 'fields', 'hooks', 'inbound', 'provider', 'requires', 'review', 'status', 'steps', 'supports', 'urls' ].includes( key ) )
|
|
7586
8663
|
.map( ( [ key, value ] ) => [
|
|
7587
8664
|
key,
|
|
7588
8665
|
( typeof value === 'function' ? value( data, env ) : value )
|