@drawbridge/drawbridge-utils 0.0.168 → 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 +789 -95
- package/dist/connections/index.d.cts +1186 -94
- package/dist/connections/index.d.ts +1186 -94
- package/dist/connections/index.js +778 -84
- package/dist/providers.cjs +793 -99
- package/dist/providers.js +782 -88
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { authToken } from './oauth.cjs';
|
|
2
2
|
export { consentUrl, pkcePair } from './oauth.cjs';
|
|
3
3
|
import { toE164, detectCountry } from '../phone.cjs';
|
|
4
|
-
import crypto, { createHmac, timingSafeEqual, createVerify, createHash
|
|
4
|
+
import crypto, { randomUUID, createHmac, timingSafeEqual, createVerify, createHash } from 'node:crypto';
|
|
5
5
|
import { request } from '../http.cjs';
|
|
6
6
|
import { channels } from '../pricing.cjs';
|
|
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 : [
|
|
@@ -2591,6 +2899,12 @@ var drawbridge = {
|
|
|
2591
2899
|
},
|
|
2592
2900
|
segment : {
|
|
2593
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
|
+
|
|
2594
2908
|
// RECALCULATE SEGMENT MEMBERSHIP. The one FAN-OUT hook: it evaluates every
|
|
2595
2909
|
// contact in an organization against every segment, which is too much for
|
|
2596
2910
|
// one job, so it returns chunks and the shell defers completion.
|
|
@@ -2956,6 +3270,33 @@ var drawbridge = {
|
|
|
2956
3270
|
// caught exactly that: availableConnections({}) went from [ 'drawbridge' ] to
|
|
2957
3271
|
// empty the moment this was added.
|
|
2958
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
|
+
},
|
|
2959
3300
|
slug : 'drawbridge',
|
|
2960
3301
|
// Always on. There is no credential that could go bad and no configuration a
|
|
2961
3302
|
// merchant could leave half-finished.
|
|
@@ -3171,6 +3512,25 @@ const api$1 = async ( path, { fetcher = fetch, method = 'GET', payload, token }
|
|
|
3171
3512
|
|
|
3172
3513
|
};
|
|
3173
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
|
+
|
|
3174
3534
|
// Klaviyo — contact sync, over OAuth.
|
|
3175
3535
|
//
|
|
3176
3536
|
// EVERYTHING ABOUT THIS VENDOR IS IN THIS FILE. Its copy, the fields it stores,
|
|
@@ -3220,9 +3580,21 @@ var klaviyo = {
|
|
|
3220
3580
|
// exchange, and a copy here would be a second answer that goes stale.
|
|
3221
3581
|
expiry : 90 * 24 * 60 * 60,
|
|
3222
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
|
+
//
|
|
3223
3591
|
// Space separated. accounts:read is required by Klaviyo on every app
|
|
3224
|
-
// and must stay in the list; the rest are what a contact sync
|
|
3225
|
-
|
|
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',
|
|
3226
3598
|
// EVERY VENDOR URL, in one place. `revoke` used to be a literal inside
|
|
3227
3599
|
// the disconnect hook — three vendor addresses, two of them declared,
|
|
3228
3600
|
// which is exactly the kind of split that goes unnoticed.
|
|
@@ -3273,9 +3645,10 @@ var klaviyo = {
|
|
|
3273
3645
|
confirm : 'Disconnecting revokes Drawbridge\'s access to your Klaviyo account. Your profiles and lists stay in both Klaviyo and Drawbridge — neither is deleted.',
|
|
3274
3646
|
|
|
3275
3647
|
description : [
|
|
3276
|
-
'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.',
|
|
3277
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.',
|
|
3278
|
-
'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.'
|
|
3279
3652
|
],
|
|
3280
3653
|
|
|
3281
3654
|
// KEYED BY WHAT FAILED, not nested inside it. Errors are the thing most
|
|
@@ -3536,7 +3909,14 @@ var klaviyo = {
|
|
|
3536
3909
|
// `segments` is null when the run carried no contact document,
|
|
3537
3910
|
// meaning nobody looked — different from [], which means they
|
|
3538
3911
|
// are in none. Null omits the key and merge leaves it alone.
|
|
3539
|
-
...( 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 ) })
|
|
3540
3920
|
}
|
|
3541
3921
|
},
|
|
3542
3922
|
type : 'profile'
|
|
@@ -3599,17 +3979,218 @@ var klaviyo = {
|
|
|
3599
3979
|
|
|
3600
3980
|
},
|
|
3601
3981
|
|
|
3602
|
-
//
|
|
3603
|
-
//
|
|
3604
|
-
//
|
|
3605
|
-
//
|
|
3606
|
-
//
|
|
3607
|
-
// segments — see the private `drawbridge` manifest. A vendor answering
|
|
3608
|
-
// these would be a second sender, which is the arrangement the platform
|
|
3609
|
-
// 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.
|
|
3610
3987
|
email : false,
|
|
3611
|
-
|
|
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.
|
|
3612
4190
|
sms : false,
|
|
4191
|
+
|
|
4192
|
+
// Klaviyo sends us nothing — no inbound message to receive, no signature
|
|
4193
|
+
// to verify.
|
|
3613
4194
|
inbound : false,
|
|
3614
4195
|
|
|
3615
4196
|
// Nothing to set up or tear down at the vendor: the grant is the whole
|
|
@@ -3785,6 +4366,18 @@ var klaviyo = {
|
|
|
3785
4366
|
'KLAVIYO_OAUTH_CLIENT_ID',
|
|
3786
4367
|
'KLAVIYO_OAUTH_CLIENT_SECRET'
|
|
3787
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
|
+
},
|
|
3788
4381
|
slug : 'klaviyo',
|
|
3789
4382
|
// ONE OF THE FOUR STATES AND NOTHING ELSE — the reason sits in `tasks`, which
|
|
3790
4383
|
// is already the merchant-facing copy channel and is already rendered.
|
|
@@ -3815,7 +4408,8 @@ var klaviyo = {
|
|
|
3815
4408
|
hook : 'lifecycle.health',
|
|
3816
4409
|
key : 'Klaviyo Connection Health',
|
|
3817
4410
|
queue : 'connection',
|
|
3818
|
-
system : true
|
|
4411
|
+
system : true,
|
|
4412
|
+
trigger : { event : 'day', type : 'schedule' }
|
|
3819
4413
|
})
|
|
3820
4414
|
}
|
|
3821
4415
|
|
|
@@ -3868,6 +4462,32 @@ var klaviyo = {
|
|
|
3868
4462
|
|
|
3869
4463
|
})
|
|
3870
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
|
+
|
|
3871
4491
|
}
|
|
3872
4492
|
|
|
3873
4493
|
},
|
|
@@ -3876,17 +4496,43 @@ var klaviyo = {
|
|
|
3876
4496
|
// is reconnecting — prompting "choose a list" there asks the merchant to
|
|
3877
4497
|
// configure a grant that no longer exists. `pending` is exactly this task's
|
|
3878
4498
|
// moment: the grant is good and the list is the missing half.
|
|
3879
|
-
tasks : ( data ) =>
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
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 ? [] : [ {
|
|
3883
4517
|
message : 'Choose which Klaviyo list your contacts should sync into. Until you do, nothing is being synced.',
|
|
3884
4518
|
title : 'Choose a list'
|
|
3885
|
-
}
|
|
3886
|
-
]
|
|
3887
|
-
|
|
4519
|
+
} ] )
|
|
4520
|
+
];
|
|
4521
|
+
|
|
4522
|
+
},
|
|
3888
4523
|
|
|
3889
|
-
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
|
+
}
|
|
3890
4536
|
};
|
|
3891
4537
|
|
|
3892
4538
|
// Mailchimp, exported from the brand kit and left as authored — the fills are the
|
|
@@ -3967,6 +4613,11 @@ const subscriberHash = ( email ) => createHash( 'md5' )
|
|
|
3967
4613
|
.update( String( email ).trim().toLowerCase() )
|
|
3968
4614
|
.digest( 'hex' );
|
|
3969
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
|
+
|
|
3970
4621
|
// Mailchimp — contact sync, not a sender.
|
|
3971
4622
|
var mailchimp = {
|
|
3972
4623
|
// OAUTH 2, authorization code. Every url below is quoted from
|
|
@@ -4010,7 +4661,7 @@ var mailchimp = {
|
|
|
4010
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.',
|
|
4011
4662
|
description : [
|
|
4012
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.',
|
|
4013
|
-
'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.',
|
|
4014
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.',
|
|
4015
4666
|
'Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before.'
|
|
4016
4667
|
],
|
|
@@ -4023,6 +4674,7 @@ var mailchimp = {
|
|
|
4023
4674
|
'Sign in to Mailchimp if you are not already, and choose the account to connect.',
|
|
4024
4675
|
'You come back here to pick the audience your contacts should sync into.',
|
|
4025
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.',
|
|
4026
4678
|
'You can remove Drawbridge at any time from the Authorized Apps page in your Mailchimp account.'
|
|
4027
4679
|
]
|
|
4028
4680
|
},
|
|
@@ -4048,10 +4700,9 @@ var mailchimp = {
|
|
|
4048
4700
|
}
|
|
4049
4701
|
],
|
|
4050
4702
|
group : 'contacts',
|
|
4051
|
-
//
|
|
4052
|
-
//
|
|
4053
|
-
//
|
|
4054
|
-
// 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.
|
|
4055
4706
|
hooks : {
|
|
4056
4707
|
|
|
4057
4708
|
auth : {
|
|
@@ -4135,6 +4786,45 @@ var mailchimp = {
|
|
|
4135
4786
|
|
|
4136
4787
|
const hash = subscriberHash( email );
|
|
4137
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
|
+
|
|
4138
4828
|
// SUPPRESSED PEOPLE ARE SYNCED AS UNSUBSCRIBED, NEVER OMITTED.
|
|
4139
4829
|
//
|
|
4140
4830
|
// Omitting them means Mailchimp never learns they said no, so the
|
|
@@ -4161,14 +4851,12 @@ var mailchimp = {
|
|
|
4161
4851
|
method : 'PUT',
|
|
4162
4852
|
payload : {
|
|
4163
4853
|
email_address : email,
|
|
4164
|
-
//
|
|
4165
|
-
//
|
|
4166
|
-
//
|
|
4167
|
-
//
|
|
4168
|
-
//
|
|
4169
|
-
|
|
4170
|
-
// job and is not built.
|
|
4171
|
-
...( 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 }),
|
|
4172
4860
|
...( suppressed && { status : 'unsubscribed' }),
|
|
4173
4861
|
status_if_new : suppressed ? 'unsubscribed' : 'subscribed'
|
|
4174
4862
|
},
|
|
@@ -4184,8 +4872,8 @@ var mailchimp = {
|
|
|
4184
4872
|
// and is not built. A tag needs no setup: "If a tag that does not exist
|
|
4185
4873
|
// is passed in and set as 'active', a new tag will be created"
|
|
4186
4874
|
// (mailchimp.com/developer/marketing/api/list-member-tags/add-or-remove-member-tags,
|
|
4187
|
-
// fetched 2026-09-09). Nothing for the merchant to prepare
|
|
4188
|
-
//
|
|
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.
|
|
4189
4877
|
//
|
|
4190
4878
|
// ACTIVE AND INACTIVE IN ONE CALL, which is what keeps this correct
|
|
4191
4879
|
// over time. Drawbridge segments are dynamic, and nothing dispatches a
|
|
@@ -4226,7 +4914,7 @@ var mailchimp = {
|
|
|
4226
4914
|
.map( ( entry ) => entry.title )
|
|
4227
4915
|
.filter( Boolean )
|
|
4228
4916
|
.map( ( title ) => ({
|
|
4229
|
-
name :
|
|
4917
|
+
name : tagName( title ),
|
|
4230
4918
|
status : joined.has( title ) ? 'active' : 'inactive'
|
|
4231
4919
|
}) );
|
|
4232
4920
|
|
|
@@ -4258,12 +4946,211 @@ var mailchimp = {
|
|
|
4258
4946
|
}
|
|
4259
4947
|
|
|
4260
4948
|
},
|
|
4261
|
-
// Drawbridge sends its own notification email
|
|
4262
|
-
//
|
|
4263
|
-
//
|
|
4264
|
-
// 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.
|
|
4265
4952
|
email : false,
|
|
4266
|
-
|
|
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.
|
|
4267
5154
|
sms : false,
|
|
4268
5155
|
inbound : false,
|
|
4269
5156
|
lifecycle : false,
|
|
@@ -4343,6 +5230,16 @@ var mailchimp = {
|
|
|
4343
5230
|
'MAILCHIMP_OAUTH_CLIENT_ID',
|
|
4344
5231
|
'MAILCHIMP_OAUTH_CLIENT_SECRET'
|
|
4345
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
|
+
},
|
|
4346
5243
|
slug : 'mailchimp',
|
|
4347
5244
|
// A grant with no audience chosen is authenticated and useless — the sync has
|
|
4348
5245
|
// nowhere to put anyone — so the card must say Pending rather than Active over
|
|
@@ -4397,6 +5294,34 @@ var mailchimp = {
|
|
|
4397
5294
|
|
|
4398
5295
|
})
|
|
4399
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
|
+
|
|
4400
5325
|
}
|
|
4401
5326
|
|
|
4402
5327
|
},
|
|
@@ -4414,7 +5339,23 @@ var mailchimp = {
|
|
|
4414
5339
|
}
|
|
4415
5340
|
]
|
|
4416
5341
|
),
|
|
4417
|
-
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
|
+
}
|
|
4418
5359
|
};
|
|
4419
5360
|
|
|
4420
5361
|
// Shopify, exported from the brand kit and left as authored — the fills are the
|
|
@@ -4488,6 +5429,61 @@ const attributeLineItems = ( lineItems = [] ) => lineItems.reduce(
|
|
|
4488
5429
|
// types them into a checkout.
|
|
4489
5430
|
const generateDiscountCode = customAlphabet( '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', 8 );
|
|
4490
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
|
+
|
|
4491
5487
|
// THE USAGE METER'S EVENT HANDLE — the string that decides whether an order's
|
|
4492
5488
|
// billing event bills or is silently ingested as a plain custom event. It must
|
|
4493
5489
|
// equal, case-sensitively, the meter HANDLE configured on the app's plan in
|
|
@@ -4588,7 +5584,7 @@ var shopify = {
|
|
|
4588
5584
|
description : [
|
|
4589
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.',
|
|
4590
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.',
|
|
4591
|
-
'
|
|
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.'
|
|
4592
5588
|
],
|
|
4593
5589
|
errors : {
|
|
4594
5590
|
connect : {
|
|
@@ -4602,7 +5598,7 @@ var shopify = {
|
|
|
4602
5598
|
'Open the Drawbridge listing on the Shopify App Store.',
|
|
4603
5599
|
'Install the app on the store you want to connect. It opens in Shopify admin and stays there.',
|
|
4604
5600
|
'Approve the Drawbridge plan when prompted — during install, or from the connection page here. The connection shows Pending until you do, then Active.',
|
|
4605
|
-
'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.'
|
|
4606
5602
|
],
|
|
4607
5603
|
// Names where the link GOES rather than what it does: installing happens on
|
|
4608
5604
|
// the App Store listing, and the dashboard must never imply a store can be
|
|
@@ -5125,14 +6121,27 @@ var shopify = {
|
|
|
5125
6121
|
? { domain : connection.source.domain, id : connection.source.id }
|
|
5126
6122
|
: undefined;
|
|
5127
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
|
+
|
|
5128
6135
|
// MINTED HERE, because the redemption names its order and the usage
|
|
5129
6136
|
// job names both — a description cannot read a write's result, so the
|
|
5130
|
-
// id exists before either does.
|
|
5131
|
-
|
|
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;
|
|
5132
6141
|
|
|
5133
6142
|
const writes = [];
|
|
5134
6143
|
|
|
5135
|
-
if(
|
|
6144
|
+
if( createsOrder ){
|
|
5136
6145
|
|
|
5137
6146
|
writes.push({
|
|
5138
6147
|
collection : 'order',
|
|
@@ -5154,8 +6163,12 @@ var shopify = {
|
|
|
5154
6163
|
provider : { id : String( orderId ), slug : 'shopify' },
|
|
5155
6164
|
purchasedAt,
|
|
5156
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,
|
|
5157
6169
|
source,
|
|
5158
|
-
status : 'completed'
|
|
6170
|
+
status : 'completed',
|
|
6171
|
+
type : isConversion ? 'conversion' : 'redemption'
|
|
5159
6172
|
},
|
|
5160
6173
|
operation : 'create'
|
|
5161
6174
|
});
|
|
@@ -5164,7 +6177,14 @@ var shopify = {
|
|
|
5164
6177
|
|
|
5165
6178
|
writes.push({
|
|
5166
6179
|
collection : 'usage',
|
|
5167
|
-
|
|
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
|
+
},
|
|
5168
6188
|
operation : 'update',
|
|
5169
6189
|
query : { id : org.usage }
|
|
5170
6190
|
});
|
|
@@ -5175,7 +6195,15 @@ var shopify = {
|
|
|
5175
6195
|
|
|
5176
6196
|
writes.push({
|
|
5177
6197
|
collection : 'lead',
|
|
5178
|
-
|
|
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
|
+
} },
|
|
5179
6207
|
operation : 'update',
|
|
5180
6208
|
options : { bypassDocumentValidation : true },
|
|
5181
6209
|
query : { id : leadId }
|
|
@@ -5198,6 +6226,7 @@ var shopify = {
|
|
|
5198
6226
|
customer,
|
|
5199
6227
|
discount,
|
|
5200
6228
|
gross,
|
|
6229
|
+
id : redemptionDocId,
|
|
5201
6230
|
lead : leadId,
|
|
5202
6231
|
order : orderDocId,
|
|
5203
6232
|
organization : campaignOrganization,
|
|
@@ -5233,6 +6262,23 @@ var shopify = {
|
|
|
5233
6262
|
|
|
5234
6263
|
}
|
|
5235
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
|
+
|
|
5236
6282
|
}
|
|
5237
6283
|
|
|
5238
6284
|
// SHOPIFY-BILLED ORGS ARE CHARGED THROUGH SHOPIFY, keyed on the order
|
|
@@ -6020,10 +7066,24 @@ var shopify = {
|
|
|
6020
7066
|
// The GLOBAL id is what Shopify returns and the bare id is what a
|
|
6021
7067
|
// picker stores, which is why the tail is taken here rather than by
|
|
6022
7068
|
// each caller that happened to remember.
|
|
6023
|
-
items : ( discounts?.edges || [] ).map( ( edge ) =>
|
|
6024
|
-
|
|
6025
|
-
|
|
6026
|
-
|
|
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
|
+
}),
|
|
6027
7087
|
pageInfo : {
|
|
6028
7088
|
endCursor : discounts?.pageInfo?.endCursor || null,
|
|
6029
7089
|
hasNextPage : Boolean( discounts?.pageInfo?.hasNextPage )
|
|
@@ -6039,24 +7099,6 @@ var shopify = {
|
|
|
6039
7099
|
},
|
|
6040
7100
|
icon,
|
|
6041
7101
|
inbound,
|
|
6042
|
-
// THE DEEP LINK into this store's Drawbridge app inside Shopify admin.
|
|
6043
|
-
//
|
|
6044
|
-
// Here rather than in drawbridge-api, which had `slug === 'shopify' && {...}`
|
|
6045
|
-
// in the shared resolver — a hardcoded vendor branch in code every vendor runs
|
|
6046
|
-
// through, which is the arrangement these manifests exist to remove.
|
|
6047
|
-
//
|
|
6048
|
-
// Undefined until a shop is linked, so the Manage button only appears on a
|
|
6049
|
-
// connected connection. The app handle is NAMED by `requires` and read from
|
|
6050
|
-
// the env the resolver passes, never from process.env here.
|
|
6051
|
-
manage : ( data, env ) => {
|
|
6052
|
-
|
|
6053
|
-
const shop = data?.shop || data?.settings?.domain;
|
|
6054
|
-
|
|
6055
|
-
return shop
|
|
6056
|
-
? 'https://admin.shopify.com/store/' + String( shop ).replace( '.myshopify.com', '' ) + '/apps/' + env?.SHOPIFY_APP_HANDLE
|
|
6057
|
-
: undefined;
|
|
6058
|
-
|
|
6059
|
-
},
|
|
6060
7102
|
// DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
|
|
6061
7103
|
// what an admin types on the provider screen. The four names below are exactly
|
|
6062
7104
|
// what `requires` gates on, which is the point of declaring them together: a
|
|
@@ -6102,6 +7144,14 @@ var shopify = {
|
|
|
6102
7144
|
'SHOPIFY_APP_LISTING_URL',
|
|
6103
7145
|
'SHOPIFY_APP_HANDLE'
|
|
6104
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
|
+
},
|
|
6105
7155
|
slug : 'shopify',
|
|
6106
7156
|
// The install is the whole configuration — Shopify hands back the shop and
|
|
6107
7157
|
// there is nothing further to choose. `shop` absent means the install did not
|
|
@@ -6194,8 +7244,11 @@ var shopify = {
|
|
|
6194
7244
|
},
|
|
6195
7245
|
|
|
6196
7246
|
// SYSTEM STEPS: dispatched by drawbridge-sync itself rather than offered
|
|
6197
|
-
// in the builder, so they carry no
|
|
6198
|
-
//
|
|
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.
|
|
6199
7252
|
order : {
|
|
6200
7253
|
record : () => ({
|
|
6201
7254
|
description : 'Records an order and billing charge when a purchase is made via a Drawbridge campaign link.',
|
|
@@ -6231,7 +7284,8 @@ var shopify = {
|
|
|
6231
7284
|
hook : 'lifecycle.health',
|
|
6232
7285
|
key : 'Shopify Connection Health',
|
|
6233
7286
|
queue : 'connection',
|
|
6234
|
-
system : true
|
|
7287
|
+
system : true,
|
|
7288
|
+
trigger : { event : 'day', type : 'schedule' }
|
|
6235
7289
|
})
|
|
6236
7290
|
},
|
|
6237
7291
|
|
|
@@ -6298,7 +7352,34 @@ var shopify = {
|
|
|
6298
7352
|
: []
|
|
6299
7353
|
)
|
|
6300
7354
|
],
|
|
6301
|
-
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
|
+
}
|
|
6302
7383
|
};
|
|
6303
7384
|
|
|
6304
7385
|
// Webhooks — the only connection with no third party behind it. Connecting
|
|
@@ -6355,7 +7436,7 @@ var webhook = {
|
|
|
6355
7436
|
content : {
|
|
6356
7437
|
confirm : 'Disconnecting stops Drawbridge from sending signed webhook payloads to your endpoint.',
|
|
6357
7438
|
description : [
|
|
6358
|
-
'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.',
|
|
6359
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.'
|
|
6360
7441
|
],
|
|
6361
7442
|
excerpt : 'Sign outgoing webhook payloads with an HMAC secret to verify authenticity.',
|
|
@@ -6481,6 +7562,9 @@ var webhook = {
|
|
|
6481
7562
|
// Gated on the encryption secret: without it the signing secret could not be
|
|
6482
7563
|
// stored safely, so the connection must not be offered at all.
|
|
6483
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,
|
|
6484
7568
|
// Outbound only. inbound.* is false because the direction is the point: we
|
|
6485
7569
|
// sign and POST to the merchant's endpoint, they never call us. Every other
|
|
6486
7570
|
// false follows from there being no third party to authenticate against —
|
|
@@ -7475,7 +8559,7 @@ const redactSettings = ({ slug, settings }) => {
|
|
|
7475
8559
|
const publicConnectionKeys = Object.freeze([
|
|
7476
8560
|
'actions',
|
|
7477
8561
|
// API-COMPOSED, not manifest-declared: the api's resolve() builds it from
|
|
7478
|
-
// 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
|
|
7479
8563
|
// connect.type to choose entered-vs-installed, connect.redirect for the App
|
|
7480
8564
|
// Store link, connect.manage for the admin deep link. It was dropped from
|
|
7481
8565
|
// this list when the manifests stopped declaring it, which stripped the
|
|
@@ -7567,7 +8651,15 @@ const resolveConnection = ( item, data, env = {} ) => {
|
|
|
7567
8651
|
// answer baked a Pending badge into Klaviyo, Mailchimp and Attentive
|
|
7568
8652
|
// cards nobody had connected. Status belongs to the caller's own
|
|
7569
8653
|
// read-time pass (api's applyStatus), never to projection.
|
|
7570
|
-
|
|
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 ) )
|
|
7571
8663
|
.map( ( [ key, value ] ) => [
|
|
7572
8664
|
key,
|
|
7573
8665
|
( typeof value === 'function' ? value( data, env ) : value )
|