@drawbridge/drawbridge-utils 0.0.118 → 0.0.124
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/connections/index.cjs +1610 -183
- package/dist/connections/index.d.cts +2480 -275
- package/dist/connections/index.d.ts +2480 -275
- package/dist/connections/index.js +1601 -179
- package/dist/phone.d.cts +1 -1
- package/dist/phone.d.ts +1 -1
- package/dist/providers.cjs +1587 -240
- package/dist/providers.d.cts +117 -128
- package/dist/providers.d.ts +117 -128
- package/dist/providers.js +1581 -234
- package/dist/sendgrid.cjs +4 -6
- package/dist/sendgrid.d.cts +19 -14
- package/dist/sendgrid.d.ts +19 -14
- package/dist/sendgrid.js +4 -6
- package/dist/twilio.cjs +2 -3
- package/dist/twilio.d.cts +11 -3
- package/dist/twilio.d.ts +11 -3
- package/dist/twilio.js +2 -3
- package/package.json +1 -1
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
import { authToken } from './oauth.js';
|
|
2
2
|
export { consentUrl, pkcePair } from './oauth.js';
|
|
3
|
+
import { toE164, detectCountry } from '../phone.js';
|
|
3
4
|
import { request } from '../http.js';
|
|
4
5
|
import { channels } from '../pricing.js';
|
|
5
|
-
import crypto, { createHmac, timingSafeEqual } from 'node:crypto';
|
|
6
|
+
import crypto, { createHash, createHmac, timingSafeEqual, randomUUID } from 'node:crypto';
|
|
7
|
+
import { customAlphabet } from 'nanoid';
|
|
8
|
+
import { toCanonicalEmail } from '../email.js';
|
|
9
|
+
import { conversionRate } from '../plans.js';
|
|
6
10
|
import { safeRequest } from '../safe-http.js';
|
|
7
|
-
import '
|
|
11
|
+
import 'libphonenumber-js';
|
|
12
|
+
import '../billing.js';
|
|
13
|
+
import '../transactions.js';
|
|
14
|
+
import '@drawbridge/drawbridge-telemetry';
|
|
15
|
+
import '../usage.js';
|
|
8
16
|
import '../features.js';
|
|
9
17
|
import '../index.js';
|
|
10
18
|
import 'currency-codes';
|
|
11
|
-
import 'nanoid';
|
|
12
19
|
import '../color.js';
|
|
13
20
|
import 'tinycolor2';
|
|
14
|
-
import '../usage.js';
|
|
15
|
-
import '../billing.js';
|
|
16
|
-
import '../transactions.js';
|
|
17
|
-
import '@drawbridge/drawbridge-telemetry';
|
|
18
21
|
import 'dns';
|
|
19
22
|
import 'node:http';
|
|
20
23
|
import 'node:https';
|
|
@@ -228,6 +231,246 @@ const HOOKS = Object.freeze({
|
|
|
228
231
|
|
|
229
232
|
});
|
|
230
233
|
|
|
234
|
+
// WHAT A HOOK RETURNS — and, just as much, what it must NOT do.
|
|
235
|
+
//
|
|
236
|
+
// A HOOK DOES THE WORK AND SAYS WHAT HAPPENED. It never records the run, never
|
|
237
|
+
// bills, never enqueues the next step and never decides its own price: that is
|
|
238
|
+
// the shell's, identically for every step. Five keys say what happened, and
|
|
239
|
+
// every one of them is unchanged from the day the shell was written:
|
|
240
|
+
//
|
|
241
|
+
// context merged forward into the next step's payload
|
|
242
|
+
// message the sentence a merchant reads in the run log
|
|
243
|
+
// request what was sent, for support to read back
|
|
244
|
+
// response what came back
|
|
245
|
+
// skipped true when it deliberately did no work, so it does not bill
|
|
246
|
+
//
|
|
247
|
+
// THE PROBLEM THOSE FIVE COULD NOT SOLVE. A hook that had to write a document,
|
|
248
|
+
// enqueue a job or tell an open dashboard something had no way to say so — so it
|
|
249
|
+
// did it, with a controller, a queue and a socket server handed to it. That is
|
|
250
|
+
// why the bodies lived in drawbridge-sync rather than beside the declarations
|
|
251
|
+
// that name them: a published package cannot carry a database, so any hook
|
|
252
|
+
// needing one had to be somewhere else, and "a vendor's logic lives in a repo
|
|
253
|
+
// the vendor file cannot see" is the split these manifests exist to close.
|
|
254
|
+
//
|
|
255
|
+
// So a hook DESCRIBES the side effects instead, and the shell performs them:
|
|
256
|
+
//
|
|
257
|
+
// writes [ { collection, data, ignoreDuplicate, operation, options, query } ]
|
|
258
|
+
// enqueues [ { data, name, options, queue } ]
|
|
259
|
+
// events [ { event, payload, room } ]
|
|
260
|
+
//
|
|
261
|
+
// Each is a plain array of plain objects, in the order the shell must run them,
|
|
262
|
+
// and each descriptor is named for the call it becomes — `operation` is the
|
|
263
|
+
// controller method, `queue` is the queue's own name, `room` is the socket room.
|
|
264
|
+
// No descriptor carries a function: a hook that could hand back a callback would
|
|
265
|
+
// be doing the work again with an extra step.
|
|
266
|
+
//
|
|
267
|
+
// A REJECTION MAY CARRY EFFECTS TOO. Throwing is how a hook rejects, and some
|
|
268
|
+
// rejections have to leave the world tidy — segment.sync locks each segment at
|
|
269
|
+
// `syncing` and every exit, including the failing one, has to put it back or the
|
|
270
|
+
// merchant watches a spinner forever. So the three keys may ride out on the
|
|
271
|
+
// thrown error, and the shell performs them before it records the failure. It is
|
|
272
|
+
// the same precedent as `error.status` and `error.response`, which the shell
|
|
273
|
+
// already reads off a rejection.
|
|
274
|
+
//
|
|
275
|
+
// WHAT IS STILL PASSED IN. Reads are not side effects and are not described:
|
|
276
|
+
// gathering the facts IS the work. A hook receives `read` — the controller's
|
|
277
|
+
// read-only methods and nothing else — so it cannot write through it even by
|
|
278
|
+
// mistake. Anything else a hook genuinely needs is injected the same way the
|
|
279
|
+
// `shopify` SDK is: as an argument, named for what it does.
|
|
280
|
+
const HOOK_EFFECTS = Object.freeze([ 'enqueues', 'events', 'writes' ]);
|
|
281
|
+
|
|
282
|
+
// The controller methods a described write may become. Only the two that a hook
|
|
283
|
+
// has ever needed — a deletion is a cascade, and cascades are owned by
|
|
284
|
+
// drawbridge-sync's streams rather than by a step.
|
|
285
|
+
const WRITE_OPERATIONS = Object.freeze([ 'create', 'update' ]);
|
|
286
|
+
|
|
287
|
+
// READ A HOOK'S EFFECTS, or refuse them.
|
|
288
|
+
//
|
|
289
|
+
// The shell performs whatever comes back here, so a malformed descriptor is a
|
|
290
|
+
// write against the wrong collection or an emit to every open dashboard. Caught
|
|
291
|
+
// at the boundary instead, in the hook's own words, because the alternative is a
|
|
292
|
+
// TypeError inside the controller naming nothing that would help.
|
|
293
|
+
//
|
|
294
|
+
// Absent keys are an EMPTY ARRAY rather than undefined: the caller performs
|
|
295
|
+
// `writes` unconditionally, and a shell that has to null-check three keys is
|
|
296
|
+
// three places one of them gets forgotten.
|
|
297
|
+
// WHAT A HOOK IS HANDED: TWO OBJECTS, so a signature says on sight what is a
|
|
298
|
+
// property of the run and what is a service being passed in.
|
|
299
|
+
//
|
|
300
|
+
// hook( props, options )
|
|
301
|
+
//
|
|
302
|
+
// PROPS are facts — data about this run, this merchant, this payload. OPTIONS
|
|
303
|
+
// are services — capabilities the caller injects, every one either read-only or
|
|
304
|
+
// owned by the shell. A hook that wants a service reaches into its second
|
|
305
|
+
// argument, which is what keeps a fact from quietly becoming a handle.
|
|
306
|
+
//
|
|
307
|
+
// One vocabulary for every manifest. A name below means the same thing in every
|
|
308
|
+
// hook that receives it; a synonym splits the vocabulary and fails the test
|
|
309
|
+
// that walks these.
|
|
310
|
+
//
|
|
311
|
+
// PROPS — facts of the run
|
|
312
|
+
// channel which inbound surface the request arrived on
|
|
313
|
+
// clientId, OUR app's credential VALUES, resolved by the caller from the
|
|
314
|
+
// clientSecret provider collection — auth.disconnect and auth.probe, where
|
|
315
|
+
// the vendor wants them re-presented
|
|
316
|
+
// connection the merchant's connection document
|
|
317
|
+
// contact the person's email — the address, not the lead document
|
|
318
|
+
// context the step's accumulated trigger data
|
|
319
|
+
// cursor, resources.* pagination, always these names
|
|
320
|
+
// limit,
|
|
321
|
+
// search, sort
|
|
322
|
+
// declaration the step declaration the shell resolved for this run
|
|
323
|
+
// doc the document a stream-driven hook acts on
|
|
324
|
+
// email, id the identifiers a contacts hook acts on when there is no doc
|
|
325
|
+
// event the inbound event name the manifest extracted
|
|
326
|
+
// headers the inbound request's headers
|
|
327
|
+
// lead the lead document, resolved by the shell when the run names one
|
|
328
|
+
// manifest the hook's own manifest, for the vendor facts it declares
|
|
329
|
+
// payload the verified inbound body
|
|
330
|
+
// scope the granted scope string being judged
|
|
331
|
+
// secret the credential VALUE the caller resolved for verification
|
|
332
|
+
// settings the connection's decrypted settings
|
|
333
|
+
// step the step document
|
|
334
|
+
// suppressed the opt-out floor's answer for this lead, computed by the shell
|
|
335
|
+
// token a LIVE access token, refreshed by the shell before the call —
|
|
336
|
+
// oauth vendors only; null everywhere else
|
|
337
|
+
// tokens the FRESH GRANT auth.connect receives from the oauth runner —
|
|
338
|
+
// the whole exchange response, not one live token
|
|
339
|
+
// workflow the workflow document
|
|
340
|
+
//
|
|
341
|
+
// OPTIONS — services passed in
|
|
342
|
+
// adminToken() mint or refresh the store token — Shopify's install
|
|
343
|
+
// model, where rotation persists mid-call; a resolver
|
|
344
|
+
// rather than a value, unlike `token`
|
|
345
|
+
// canSend() the opt-out floor, bound to this run's organization
|
|
346
|
+
// chunkSize the worker fleet's fan-out width — deployment tuning,
|
|
347
|
+
// passed, never published
|
|
348
|
+
// dispatch() the caller's own coordinator table, for hooks that are
|
|
349
|
+
// dispatches
|
|
350
|
+
// fetcher fetch-shaped transport: ( url, options )
|
|
351
|
+
// logger the caller's logger
|
|
352
|
+
// mintId() an id minted before the write it belongs to, so one
|
|
353
|
+
// described write can reference another
|
|
354
|
+
// read the controller's READ methods — get, aggregate, count —
|
|
355
|
+
// and never anything that writes; not `controller`, not
|
|
356
|
+
// `db`, because a name that implies writing invites it
|
|
357
|
+
// reconcileScopes() the caller's scope writer-of-record; injected because
|
|
358
|
+
// the hook needs its result
|
|
359
|
+
// request safeRequest-shaped transport: ({ body, headers, method,
|
|
360
|
+
// url }) — a different SHAPE than fetcher, so a different
|
|
361
|
+
// name
|
|
362
|
+
// resolveContact() the one write a description cannot carry — its result
|
|
363
|
+
// decides billing
|
|
364
|
+
// resolveSettings() fresh from wherever the vendor keeps credentials, per
|
|
365
|
+
// call, never a snapshot
|
|
366
|
+
// rotateToken() deliberate rotation, for the health check that rotates
|
|
367
|
+
// before the window closes
|
|
368
|
+
// shopify the SDK namespaces, injected — this package cannot
|
|
369
|
+
// import what depends on it
|
|
370
|
+
// THE ONE EXEMPTION: hooks.auth.token takes a SINGLE bag. It is the OAuth token
|
|
371
|
+
// request itself — called directly by the oauth runner and the refresh path,
|
|
372
|
+
// never through runHook — and its argument is the request's fields (clientId,
|
|
373
|
+
// code, refreshToken, fetcher) as one unit. Splitting it would change a
|
|
374
|
+
// wire-shaped call for symmetry's sake. Callers must never invoke it via
|
|
375
|
+
// runHook with a second bag: the options would be silently discarded, which is
|
|
376
|
+
// exactly the stubbed-fetcher-goes-live failure the split exists to prevent.
|
|
377
|
+
const HOOK_PROPS = Object.freeze([
|
|
378
|
+
'channel', 'clientId', 'clientSecret', 'connection', 'contact', 'context',
|
|
379
|
+
'cursor', 'declaration', 'doc', 'email', 'event', 'headers', 'id', 'lead', 'limit',
|
|
380
|
+
'manifest', 'payload',
|
|
381
|
+
'scope', 'search', 'secret', 'settings', 'sort', 'step', 'suppressed',
|
|
382
|
+
'token', 'tokens', 'workflow'
|
|
383
|
+
]);
|
|
384
|
+
|
|
385
|
+
const HOOK_OPTIONS = Object.freeze([
|
|
386
|
+
'adminToken', 'canSend', 'chunkSize', 'dispatch', 'fetcher', 'logger',
|
|
387
|
+
'mintId', 'read', 'reconcileScopes', 'request', 'resolveContact',
|
|
388
|
+
'resolveSettings', 'rotateToken', 'shopify'
|
|
389
|
+
]);
|
|
390
|
+
|
|
391
|
+
const effectsOf = ( answer ) => {
|
|
392
|
+
|
|
393
|
+
for( const key of HOOK_EFFECTS ){
|
|
394
|
+
|
|
395
|
+
if( answer?.[ key ] !== undefined && ! Array.isArray( answer[ key ] ) ){
|
|
396
|
+
|
|
397
|
+
throw new Error( 'A hook returned ' + key + ' that is not an array' );
|
|
398
|
+
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const enqueues = answer?.enqueues || [];
|
|
404
|
+
const events = answer?.events || [];
|
|
405
|
+
const writes = answer?.writes || [];
|
|
406
|
+
|
|
407
|
+
for( const write of writes ){
|
|
408
|
+
|
|
409
|
+
if( ! write?.collection ) throw new Error( 'A described write names no collection' );
|
|
410
|
+
|
|
411
|
+
if( ! WRITE_OPERATIONS.includes( write?.operation ) ){
|
|
412
|
+
|
|
413
|
+
throw new Error( 'A described write on ' + write.collection + ' needs an operation — one of ' + WRITE_OPERATIONS.join( ', ' ) );
|
|
414
|
+
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if( ! write?.data ) throw new Error( 'A described write on ' + write.collection + ' carries no data' );
|
|
418
|
+
|
|
419
|
+
// AN UPDATE WITH NO QUERY IS EVERY DOCUMENT IN THE COLLECTION. Refused here
|
|
420
|
+
// rather than survived, because the controller would happily run it.
|
|
421
|
+
if( write.operation === 'update' && ! write.query ){
|
|
422
|
+
|
|
423
|
+
throw new Error( 'A described update on ' + write.collection + ' has no query — that is every document in it' );
|
|
424
|
+
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// The controller's create takes no query, so one here is a filter the
|
|
428
|
+
// author believed in and the database never saw.
|
|
429
|
+
if( write.operation === 'create' && write.query ){
|
|
430
|
+
|
|
431
|
+
throw new Error( 'A described create on ' + write.collection + ' carries a query — create does not filter' );
|
|
432
|
+
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
for( const enqueue of enqueues ){
|
|
438
|
+
|
|
439
|
+
if( ! enqueue?.queue ) throw new Error( 'A described enqueue names no queue' );
|
|
440
|
+
|
|
441
|
+
// BullMQ's own first argument. Without it the job lands unnamed and no
|
|
442
|
+
// worker handler matches it — a job accepted and never run.
|
|
443
|
+
if( ! enqueue?.name ) throw new Error( 'A described enqueue on the ' + enqueue.queue + ' queue names no job' );
|
|
444
|
+
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
for( const event of events ){
|
|
448
|
+
|
|
449
|
+
if( ! event?.event ) throw new Error( 'A described event has no name' );
|
|
450
|
+
|
|
451
|
+
// A ROOM IS NOT OPTIONAL. An emit without one reaches every connected
|
|
452
|
+
// dashboard, which is one organization's data arriving in another's tab.
|
|
453
|
+
if( ! event?.room ) throw new Error( 'A described ' + event.event + ' event has no room' );
|
|
454
|
+
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ALL OR NONE. `transaction : true` asks the shell to land every described
|
|
458
|
+
// write in one transaction — for the order/redemption pair, where a
|
|
459
|
+
// half-written attribution is revenue counted twice or not at all. It covers
|
|
460
|
+
// the WRITES only: enqueues and events still run after the writes commit,
|
|
461
|
+
// which is the ordering a job that reads a row it expects to exist depends on.
|
|
462
|
+
const transaction = Boolean( answer?.transaction );
|
|
463
|
+
|
|
464
|
+
if( transaction && ! writes.length ){
|
|
465
|
+
|
|
466
|
+
throw new Error( 'A hook asked for a transaction and described no writes' );
|
|
467
|
+
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return { enqueues, events, transaction, writes };
|
|
471
|
+
|
|
472
|
+
};
|
|
473
|
+
|
|
231
474
|
// WHAT A WORKFLOW STEP CAN BE. Closed, and the LABEL LIVES HERE rather than on a
|
|
232
475
|
// vendor, because a step type belongs to the capability and not to whoever
|
|
233
476
|
// implements it: Klaviyo and Mailchimp both do contacts.sync, and a label taken
|
|
@@ -366,7 +609,9 @@ const GROUPS = Object.freeze([ 'commerce', 'contacts', 'developer', 'messaging'
|
|
|
366
609
|
//
|
|
367
610
|
// authorize the consent url
|
|
368
611
|
// token the exchange/refresh url
|
|
369
|
-
// client the
|
|
612
|
+
// client the NAMES of our own id and secret, never the values — keys into
|
|
613
|
+
// the credential map the provider collection answers, matching a
|
|
614
|
+
// provider field's `credential`
|
|
370
615
|
// redirect our callback path — DECLARED, never derived from the slug. It is
|
|
371
616
|
// registered in the vendor's console and they refuse anything that
|
|
372
617
|
// does not byte-match, so it is a fact about someone else's records
|
|
@@ -531,6 +776,38 @@ var icon$4 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
|
|
|
531
776
|
<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"/>
|
|
532
777
|
</svg>`;
|
|
533
778
|
|
|
779
|
+
// ONE REQUEST SHAPE for every Attentive call, the way Klaviyo's file has one.
|
|
780
|
+
// The path carries its own version — the segments picker is v2 and the
|
|
781
|
+
// subscription writes are v1 — because Attentive versions per resource rather
|
|
782
|
+
// than per API.
|
|
783
|
+
const api$2 = async ( path, { fetcher = fetch, method = 'GET', payload, token } ) => {
|
|
784
|
+
|
|
785
|
+
const response = await fetcher( 'https://api.attentivemobile.com' + path, {
|
|
786
|
+
...( payload && { body : JSON.stringify( payload ) }),
|
|
787
|
+
headers : {
|
|
788
|
+
authorization : 'Bearer ' + token,
|
|
789
|
+
...( payload && { 'content-type' : 'application/json' })
|
|
790
|
+
},
|
|
791
|
+
method,
|
|
792
|
+
signal : AbortSignal.timeout( 15000 )
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
if( ! response.ok ){
|
|
796
|
+
|
|
797
|
+
throw Object.assign(
|
|
798
|
+
new Error( 'Attentive refused the request (' + response.status + ')' ),
|
|
799
|
+
{ status : response.status }
|
|
800
|
+
);
|
|
801
|
+
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// 202 ACCEPTED may carry a body (the bulk-segment job id) or nothing at all,
|
|
805
|
+
// and asking json() for an empty body throws — which would report a write
|
|
806
|
+
// Attentive accepted as a failed step. A body that will not parse answers null.
|
|
807
|
+
return response.json().catch( () => null );
|
|
808
|
+
|
|
809
|
+
};
|
|
810
|
+
|
|
534
811
|
// Attentive — SMS-first marketing, installed as a distributed Attentive app.
|
|
535
812
|
//
|
|
536
813
|
// EVERY VENDOR FACT BELOW IS CITED from docs.attentive.com (fetched 2026-09-01):
|
|
@@ -562,8 +839,9 @@ var icon$4 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
|
|
|
562
839
|
//
|
|
563
840
|
// DORMANT UNTIL REGISTERED. `requires` names the client credentials that only
|
|
564
841
|
// exist once the Attentive app is created (their console: enable distribution,
|
|
565
|
-
// set the redirect URL, Generate Credentials). Until
|
|
566
|
-
// deployment offers this connection — the manifest
|
|
842
|
+
// set the redirect URL, Generate Credentials). Until an admin enters them on
|
|
843
|
+
// the provider screen, no deployment offers this connection — the manifest
|
|
844
|
+
// ships complete and inert.
|
|
567
845
|
//
|
|
568
846
|
// THREE THINGS TO VERIFY AT REGISTRATION, because the docs conflict or are
|
|
569
847
|
// silent, and only a live install answers them:
|
|
@@ -584,9 +862,12 @@ var icon$4 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
|
|
|
584
862
|
var attentive = {
|
|
585
863
|
auth : {
|
|
586
864
|
oauth : {
|
|
587
|
-
// NAMES of the
|
|
588
|
-
//
|
|
589
|
-
//
|
|
865
|
+
// NAMES of the credentials holding OUR app's client — keys into the map
|
|
866
|
+
// the provider collection answers, entered on the admin screen at
|
|
867
|
+
// registration, never before. (The names are the env vars they once
|
|
868
|
+
// were; the vocabulary stayed when the storage moved.) No `headers` on
|
|
869
|
+
// the client: Attentive takes credentials as form fields, which is the
|
|
870
|
+
// runner's default.
|
|
590
871
|
client : {
|
|
591
872
|
id : 'ATTENTIVE_OAUTH_CLIENT_ID',
|
|
592
873
|
secret : 'ATTENTIVE_OAUTH_CLIENT_SECRET'
|
|
@@ -611,9 +892,10 @@ var attentive = {
|
|
|
611
892
|
// has to say so rather than let them believe otherwise.
|
|
612
893
|
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.',
|
|
613
894
|
description : [
|
|
614
|
-
'Attentive is where your SMS marketing lives, and this connection
|
|
895
|
+
'Attentive is where your SMS marketing lives, and this connection syncs the contacts your campaigns collect into an Attentive segment — subscribed for marketing and added to the segment you choose.',
|
|
615
896
|
'You authorize Drawbridge from inside Attentive and can revoke that access there at any time. Drawbridge never sees or stores your Attentive password.',
|
|
616
|
-
'
|
|
897
|
+
'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.',
|
|
898
|
+
'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.'
|
|
617
899
|
],
|
|
618
900
|
excerpt : 'Sync your Drawbridge contacts into an Attentive segment.',
|
|
619
901
|
guide : [
|
|
@@ -633,7 +915,11 @@ var attentive = {
|
|
|
633
915
|
label : 'Attentive segment',
|
|
634
916
|
message : 'Contacts your campaigns collect are synced into this segment.',
|
|
635
917
|
hook : 'resources.audiences',
|
|
636
|
-
required : true
|
|
918
|
+
required : true,
|
|
919
|
+
// CONSUMED BY THE MEMBERSHIP CALL, not by the subscribe. Attentive's
|
|
920
|
+
// /v1/subscriptions takes no segment id — subscription and segment
|
|
921
|
+
// membership are two operations here — so contacts.sync makes both calls
|
|
922
|
+
// and this value is the externalId the second one carries.
|
|
637
923
|
// No `search : false` here, and that is a first: /v2/segments takes a
|
|
638
924
|
// `name` filter (partial match, cited above), so this picker searches
|
|
639
925
|
// the ACCOUNT — Klaviyo and Mailchimp can only match the fetched page.
|
|
@@ -647,17 +933,25 @@ var attentive = {
|
|
|
647
933
|
hooks : {
|
|
648
934
|
|
|
649
935
|
auth : {
|
|
650
|
-
// The exchange already yields the tokens, and Attentive documents no
|
|
651
|
-
// account-identity endpoint to enrich them with — Klaviyo's connect
|
|
652
|
-
// reads the account name back; this has nothing cited to read. The
|
|
653
|
-
// callback stores the tokens and skips enrichment on `unimplemented`.
|
|
654
936
|
// FALSE, NOT {}. `{}` means "supported, implemented in the repo with the
|
|
655
|
-
// dependencies", and nothing anywhere implements either of these —
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
//
|
|
937
|
+
// dependencies", and nothing anywhere implements either of these — there
|
|
938
|
+
// is nothing for them to do. They document no revocation endpoint at all,
|
|
939
|
+
// so disconnect has nothing to call. Recorded as a decision rather than
|
|
940
|
+
// left as an unkept promise.
|
|
941
|
+
//
|
|
942
|
+
// STILL FALSE AFTER LOOKING AGAIN, and this is the reason written down so
|
|
943
|
+
// nobody re-derives it. Klaviyo's connect reads the account name back so
|
|
944
|
+
// the card is not blank; Attentive's card stays blank. There IS an
|
|
945
|
+
// endpoint — GET https://api.attentivemobile.com/v1/me, Bearer, described
|
|
946
|
+
// on docs.attentive.com/pages/authentication/ as returning "information
|
|
947
|
+
// specific to your company" — but its RESPONSE SCHEMA is published
|
|
948
|
+
// nowhere we can read: the docs show the curl and no body. Reading
|
|
949
|
+
// `body.name` would be a guess, and a guess here fails at the worst
|
|
950
|
+
// moment, in the callback, after the merchant has already consented.
|
|
951
|
+
//
|
|
952
|
+
// A live token settles it in one call, alongside the three registration
|
|
953
|
+
// checks in the header. Until then the honest state is a blank field, not
|
|
954
|
+
// a hopeful one.
|
|
661
955
|
connect : false,
|
|
662
956
|
disconnect : false,
|
|
663
957
|
probe : false,
|
|
@@ -682,7 +976,154 @@ var attentive = {
|
|
|
682
976
|
}
|
|
683
977
|
},
|
|
684
978
|
commerce : false,
|
|
685
|
-
|
|
979
|
+
|
|
980
|
+
// The verb the contacts.sync step points at.
|
|
981
|
+
contacts : {
|
|
982
|
+
|
|
983
|
+
// Not yet. Suppression syncs an opt-out as unsubscribed, which is a
|
|
984
|
+
// different thing from erasing the subscriber — Attentive's deletion sits
|
|
985
|
+
// behind their privacy-request API, which is a different grant.
|
|
986
|
+
remove : false,
|
|
987
|
+
|
|
988
|
+
// TWO CALLS, BECAUSE ATTENTIVE HAS TWO IDEAS.
|
|
989
|
+
//
|
|
990
|
+
// Subscribing and being in a segment are NOT the same operation here —
|
|
991
|
+
// unlike Klaviyo, where a subscription is created against the list itself.
|
|
992
|
+
// /v1/subscriptions takes no segment id at all, so the segment a merchant
|
|
993
|
+
// picked on this connection can only be honoured by the bulk segment
|
|
994
|
+
// membership API:
|
|
995
|
+
//
|
|
996
|
+
// subscribe POST /v1/subscriptions
|
|
997
|
+
// { user : { email, phone }, locale, subscriptionType } — the
|
|
998
|
+
// docs require EITHER signUpSourceId OR (locale +
|
|
999
|
+
// subscriptionType), and we hold no sign-up source. 202.
|
|
1000
|
+
//
|
|
1001
|
+
// membership POST /v2/bulk/segments/members
|
|
1002
|
+
// { externalId, members : [ { email, phone } ] }, 1-10,000
|
|
1003
|
+
// members, 202 with a batchJobId
|
|
1004
|
+
// (docs.attentive.com/reference/postbulksegmentmembers).
|
|
1005
|
+
//
|
|
1006
|
+
// unsubscribe POST /v1/subscriptions/unsubscribe
|
|
1007
|
+
// { user, subscriptions : [ { type, channel } ] }. 202.
|
|
1008
|
+
//
|
|
1009
|
+
// EVERY ONE OF THEM ANSWERS 202 ACCEPTED, which means Attentive took the
|
|
1010
|
+
// job, not that it ran — the same distinction the Shopify usage charge
|
|
1011
|
+
// makes between a 202 and a charge. The message below says accepted, and
|
|
1012
|
+
// must keep saying accepted.
|
|
1013
|
+
sync : async ( { lead, settings, suppressed, token }, { fetcher } = {} ) => {
|
|
1014
|
+
|
|
1015
|
+
const segment = settings?.segment;
|
|
1016
|
+
|
|
1017
|
+
// status() already stops a connection reaching Active without a
|
|
1018
|
+
// segment; this is the belt to that braces. A workflow saved before the
|
|
1019
|
+
// segment was chosen must not silently write into nothing.
|
|
1020
|
+
if( ! segment ) return { message : 'No Attentive segment is chosen for this connection.', skipped : true };
|
|
1021
|
+
|
|
1022
|
+
const email = lead?.canonical?.email?.value || lead?.email;
|
|
1023
|
+
|
|
1024
|
+
// E.164 OR NOTHING. Attentive requires it, and a national-format number
|
|
1025
|
+
// is refused with the whole request — so an unparseable one is dropped
|
|
1026
|
+
// and the email carries the sync instead.
|
|
1027
|
+
const phone = toE164( lead?.canonical?.phone?.value || lead?.phone );
|
|
1028
|
+
|
|
1029
|
+
// Their `user` requires phone OR email. With neither there is nobody to
|
|
1030
|
+
// subscribe, so this is a skip rather than a failure.
|
|
1031
|
+
if( ! email && ! phone ) return { message : 'That lead has no email address or phone number to sync.', skipped : true };
|
|
1032
|
+
|
|
1033
|
+
const user = {
|
|
1034
|
+
...( email && { email }),
|
|
1035
|
+
...( phone && { phone })
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
// SUPPRESSED PEOPLE ARE SYNCED AS UNSUBSCRIBED, NEVER OMITTED.
|
|
1039
|
+
//
|
|
1040
|
+
// Omitting them means Attentive never learns they said no, so the
|
|
1041
|
+
// merchant can import them from somewhere else and start texting them
|
|
1042
|
+
// again. Pushing them as unsubscribed makes the suppression travel with
|
|
1043
|
+
// the person, which is the reason this connection is allowed to send
|
|
1044
|
+
// anything at all.
|
|
1045
|
+
//
|
|
1046
|
+
// `suppressed` arrives as an argument because canSend() is sync's — a
|
|
1047
|
+
// manifest cannot reach it, and this rule is too important to infer.
|
|
1048
|
+
//
|
|
1049
|
+
// AND THE SEGMENT IS SKIPPED for them: adding someone who opted out to
|
|
1050
|
+
// a marketing segment is the same mistake as omitting the opt-out,
|
|
1051
|
+
// wearing the other hat.
|
|
1052
|
+
if( suppressed ){
|
|
1053
|
+
|
|
1054
|
+
await api$2( '/v1/subscriptions/unsubscribe', {
|
|
1055
|
+
fetcher,
|
|
1056
|
+
method : 'POST',
|
|
1057
|
+
payload : {
|
|
1058
|
+
// One entry per channel we can actually name them by. MARKETING
|
|
1059
|
+
// is the only type Drawbridge ever subscribed them to.
|
|
1060
|
+
subscriptions : [
|
|
1061
|
+
...( phone ? [ { channel : 'TEXT', type : 'MARKETING' } ] : [] ),
|
|
1062
|
+
...( email ? [ { channel : 'EMAIL', type : 'MARKETING' } ] : [] )
|
|
1063
|
+
],
|
|
1064
|
+
user
|
|
1065
|
+
},
|
|
1066
|
+
token
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
return {
|
|
1070
|
+
message : 'Attentive accepted an unsubscribe for this contact — they have opted out.',
|
|
1071
|
+
response : { accepted : true, unsubscribed : true }
|
|
1072
|
+
};
|
|
1073
|
+
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
await api$2( '/v1/subscriptions', {
|
|
1077
|
+
fetcher,
|
|
1078
|
+
method : 'POST',
|
|
1079
|
+
payload : {
|
|
1080
|
+
// LOCALE, because we hold no signUpSourceId and the docs require
|
|
1081
|
+
// one or the other. The country is READ OFF the number when there
|
|
1082
|
+
// is one — libphonenumber knows it from the calling code — rather
|
|
1083
|
+
// than assumed; only the fallback pair below is a default, and it
|
|
1084
|
+
// is the one value here that no vendor document dictates.
|
|
1085
|
+
//
|
|
1086
|
+
// ponytail: en/US default. A `signUpSourceId` field on the
|
|
1087
|
+
// connection is the upgrade — Attentive's sign-up sources carry
|
|
1088
|
+
// the consent language, which is a better answer than any locale
|
|
1089
|
+
// we can infer — and it replaces this branch entirely.
|
|
1090
|
+
locale : {
|
|
1091
|
+
country : ( phone && detectCountry( phone ) ) || 'US',
|
|
1092
|
+
language : 'en'
|
|
1093
|
+
},
|
|
1094
|
+
subscriptionType : 'MARKETING',
|
|
1095
|
+
user
|
|
1096
|
+
},
|
|
1097
|
+
token
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
// THE SEGMENT THE MERCHANT PICKED, which the subscribe above cannot
|
|
1101
|
+
// carry. One member per call rather than a batch: this hook is one
|
|
1102
|
+
// contact, and their endpoint takes 1-10,000.
|
|
1103
|
+
const membership = await api$2( '/v2/bulk/segments/members', {
|
|
1104
|
+
fetcher,
|
|
1105
|
+
method : 'POST',
|
|
1106
|
+
payload : {
|
|
1107
|
+
externalId : segment,
|
|
1108
|
+
members : [ user ]
|
|
1109
|
+
},
|
|
1110
|
+
token
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
return {
|
|
1114
|
+
// ACCEPTED, NOT LIVE. Both writes answered 202, which means Attentive
|
|
1115
|
+
// queued them — a merchant who reads "synced" and looks for the person
|
|
1116
|
+
// in Attentive a second later has been told the wrong thing.
|
|
1117
|
+
message : 'Attentive accepted this contact for the segment. Attentive processes these asynchronously, so it appears there shortly.',
|
|
1118
|
+
response : {
|
|
1119
|
+
accepted : true,
|
|
1120
|
+
...( membership?.batchJobId && { batchJobId : membership.batchJobId })
|
|
1121
|
+
}
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
},
|
|
686
1127
|
email : false,
|
|
687
1128
|
inbound : false,
|
|
688
1129
|
lifecycle : false,
|
|
@@ -696,7 +1137,7 @@ var attentive = {
|
|
|
696
1137
|
// show a picker quietly missing most of a real account. The response's
|
|
697
1138
|
// only identifier is `externalId`, so an entry without one cannot be
|
|
698
1139
|
// stored and is dropped.
|
|
699
|
-
audiences : async ({ cursor,
|
|
1140
|
+
audiences : async ( { cursor, limit = 100, search, token }, { fetcher } = {} ) => {
|
|
700
1141
|
|
|
701
1142
|
const query = new URLSearchParams({
|
|
702
1143
|
limit : String( Math.min( limit, 1000 ) ),
|
|
@@ -704,24 +1145,7 @@ var attentive = {
|
|
|
704
1145
|
...( search?.value && { name : String( search.value ).trim() } )
|
|
705
1146
|
});
|
|
706
1147
|
|
|
707
|
-
const
|
|
708
|
-
'https://api.attentivemobile.com/v2/segments?' + query,
|
|
709
|
-
{
|
|
710
|
-
headers : { authorization : 'Bearer ' + token },
|
|
711
|
-
signal : AbortSignal.timeout( 15000 )
|
|
712
|
-
}
|
|
713
|
-
);
|
|
714
|
-
|
|
715
|
-
if( ! response.ok ){
|
|
716
|
-
|
|
717
|
-
throw Object.assign(
|
|
718
|
-
new Error( 'Attentive refused the request (' + response.status + ')' ),
|
|
719
|
-
{ status : response.status }
|
|
720
|
-
);
|
|
721
|
-
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
const body = await response.json();
|
|
1148
|
+
const body = await api$2( '/v2/segments?' + query, { fetcher, token });
|
|
725
1149
|
|
|
726
1150
|
return {
|
|
727
1151
|
items : ( body?.segments || [] )
|
|
@@ -745,33 +1169,79 @@ var attentive = {
|
|
|
745
1169
|
|
|
746
1170
|
},
|
|
747
1171
|
icon: icon$4,
|
|
1172
|
+
// DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
|
|
1173
|
+
// what an admin types on the provider screen, and the only declaration of it.
|
|
1174
|
+
// It lives beside `requires`, which names the same variables: the manifest
|
|
1175
|
+
// says what it needs and this says how someone supplies it, so a credential
|
|
1176
|
+
// cannot be required by a vendor that offers nowhere to enter it.
|
|
1177
|
+
//
|
|
1178
|
+
// `redact` marks a secret — never returned by the api, and blank on save means
|
|
1179
|
+
// keep the stored value. `required` drives the live check.
|
|
1180
|
+
provider : {
|
|
1181
|
+
fields : [
|
|
1182
|
+
{ input : 'text', key : 'clientId', credential : 'ATTENTIVE_OAUTH_CLIENT_ID', label : 'Client ID', required : true },
|
|
1183
|
+
{ input : 'password', key : 'clientSecret', credential : 'ATTENTIVE_OAUTH_CLIENT_SECRET', label : 'Client secret', redact : true, required : true }
|
|
1184
|
+
]
|
|
1185
|
+
},
|
|
748
1186
|
requires : [
|
|
749
1187
|
'ATTENTIVE_OAUTH_CLIENT_ID',
|
|
750
1188
|
'ATTENTIVE_OAUTH_CLIENT_SECRET'
|
|
751
1189
|
],
|
|
752
1190
|
slug : 'attentive',
|
|
753
|
-
// A consent with no segment chosen is authenticated and inert — the sync
|
|
754
|
-
//
|
|
1191
|
+
// A consent with no segment chosen is authenticated and inert — the sync needs
|
|
1192
|
+
// somewhere to put people — so the card says Pending rather than Active over
|
|
1193
|
+
// nothing.
|
|
755
1194
|
status : ( data ) => ( data?.settings?.segment ? data.status : 'pending' ),
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
1195
|
+
|
|
1196
|
+
steps : {
|
|
1197
|
+
|
|
1198
|
+
contacts : {
|
|
1199
|
+
|
|
1200
|
+
// A DECLARATION, not the work. The nesting IS the name: this is
|
|
1201
|
+
// `step.contacts.sync`, the string a workflow document stores. Klaviyo and
|
|
1202
|
+
// Mailchimp declare the same type — a step belongs to the capability, not
|
|
1203
|
+
// to whoever implements it — and the connection on the step document is
|
|
1204
|
+
// what says which vendor runs.
|
|
1205
|
+
sync : ({ data }) => ({
|
|
1206
|
+
|
|
1207
|
+
hook : 'contacts.sync',
|
|
1208
|
+
|
|
1209
|
+
// NO ACCOUNT NAME TO INTERPOLATE, unlike Klaviyo: auth.connect is false
|
|
1210
|
+
// because Attentive publishes no account-identity response we can read
|
|
1211
|
+
// (see its comment), and settings.segment is an opaque externalId no
|
|
1212
|
+
// merchant would recognise in a builder label.
|
|
1213
|
+
key : 'Sync contact to Attentive',
|
|
1214
|
+
|
|
1215
|
+
queue : 'connection',
|
|
1216
|
+
|
|
1217
|
+
// Nothing for a merchant to configure on the step itself — the segment
|
|
1218
|
+
// is chosen once on the connection. Declared empty rather than omitted,
|
|
1219
|
+
// so "this step takes no settings" and "nobody thought about settings"
|
|
1220
|
+
// stay different statements.
|
|
1221
|
+
settings : {},
|
|
1222
|
+
|
|
1223
|
+
// BOTH triggers, for the same reason as Klaviyo: lead.insert alone only
|
|
1224
|
+
// ever fires for someone with no history yet, and crossing into a
|
|
1225
|
+
// segment is the other moment a contact is worth pushing.
|
|
1226
|
+
triggers : [ 'lead.insert', 'segment.contact.add' ],
|
|
1227
|
+
|
|
1228
|
+
usage : { actions : 1 }
|
|
1229
|
+
|
|
1230
|
+
})
|
|
1231
|
+
|
|
773
1232
|
}
|
|
774
|
-
|
|
1233
|
+
|
|
1234
|
+
},
|
|
1235
|
+
// WHY, in the merchant's words, and what to do about it.
|
|
1236
|
+
tasks : ( data ) => ( data?.settings?.segment
|
|
1237
|
+
? []
|
|
1238
|
+
: [
|
|
1239
|
+
{
|
|
1240
|
+
message : 'Choose which Attentive segment your contacts should sync into. Until you do, nothing is being synced.',
|
|
1241
|
+
title : 'Choose a segment'
|
|
1242
|
+
}
|
|
1243
|
+
]
|
|
1244
|
+
),
|
|
775
1245
|
title : 'Attentive'
|
|
776
1246
|
};
|
|
777
1247
|
|
|
@@ -809,15 +1279,22 @@ var attentive = {
|
|
|
809
1279
|
|
|
810
1280
|
const HUBSPOT_BASE = 'https://api.hubapi.com';
|
|
811
1281
|
|
|
812
|
-
//
|
|
813
|
-
//
|
|
814
|
-
//
|
|
1282
|
+
// THE TOKEN IS THE CALLER'S TO PASS. It used to fall back to process.env; it
|
|
1283
|
+
// lives encrypted in the `provider` collection now (lib/providers.js).
|
|
1284
|
+
//
|
|
1285
|
+
// THROWS RATHER THAN DEGRADES, once a request is actually being made. Whether
|
|
1286
|
+
// the portal is configured at all is decided by the callers below — the token is
|
|
1287
|
+
// declared optional on purpose — but a path that got as far as here without one
|
|
1288
|
+
// would send `Bearer undefined` and read the 401 as HubSpot being down. The
|
|
1289
|
+
// absence has to be named where it happens.
|
|
815
1290
|
const hubspotRequest = ({ body, fetcher, method, path, query, token }) => {
|
|
816
1291
|
|
|
1292
|
+
if( ! token ) throw new Error( 'HubSpot access token missing — pass token (the drawbridge provider\'s hubspotToken)' );
|
|
1293
|
+
|
|
817
1294
|
return ( fetcher || request )({
|
|
818
1295
|
body,
|
|
819
1296
|
headers : {
|
|
820
|
-
'Authorization' : 'Bearer ' +
|
|
1297
|
+
'Authorization' : 'Bearer ' + token
|
|
821
1298
|
},
|
|
822
1299
|
method,
|
|
823
1300
|
query,
|
|
@@ -1012,13 +1489,16 @@ const contacts = {
|
|
|
1012
1489
|
// FORGET A CONTACT, by id or by email. Account deletion — the caller had
|
|
1013
1490
|
// to search then remove, which is one round trip it should not have to
|
|
1014
1491
|
// know about.
|
|
1015
|
-
remove : async ({ email,
|
|
1016
|
-
|
|
1017
|
-
const key = token || process.env.HUBSPOT_ACCESS_TOKEN;
|
|
1492
|
+
remove : async ( { email, id, token }, { fetcher } = {} ) => {
|
|
1018
1493
|
|
|
1019
|
-
|
|
1494
|
+
// NO TOKEN IS A NO-OP HERE, unlike sendgrid and twilio, and the
|
|
1495
|
+
// difference is what the credential is declared to be: hubspotToken
|
|
1496
|
+
// is the one provider field that is not `required`, because this is
|
|
1497
|
+
// internal CRM tooling no merchant sees. A deployment with no portal
|
|
1498
|
+
// is a supported state, not a missing credential.
|
|
1499
|
+
if( ! token ) return;
|
|
1020
1500
|
|
|
1021
|
-
const contact = id || await lookup({ email, fetcher, token
|
|
1501
|
+
const contact = id || await lookup({ email, fetcher, token });
|
|
1022
1502
|
|
|
1023
1503
|
if( ! contact ) return;
|
|
1024
1504
|
|
|
@@ -1026,7 +1506,7 @@ const contacts = {
|
|
|
1026
1506
|
fetcher,
|
|
1027
1507
|
method : 'DELETE',
|
|
1028
1508
|
path : '/crm/v3/objects/contacts/' + contact,
|
|
1029
|
-
token
|
|
1509
|
+
token
|
|
1030
1510
|
});
|
|
1031
1511
|
|
|
1032
1512
|
},
|
|
@@ -1037,19 +1517,18 @@ const contacts = {
|
|
|
1037
1517
|
// no delete-old-then-create-new.
|
|
1038
1518
|
//
|
|
1039
1519
|
// Prefer the cached hubspotId; fall back to a search; create last.
|
|
1040
|
-
sync : async ({ doc,
|
|
1041
|
-
|
|
1042
|
-
const key = token || process.env.HUBSPOT_ACCESS_TOKEN;
|
|
1520
|
+
sync : async ( { doc, token }, { fetcher } = {} ) => {
|
|
1043
1521
|
|
|
1044
|
-
// NO TOKEN IS A NO-OP, not an error
|
|
1045
|
-
// configured must not crash the user
|
|
1046
|
-
|
|
1522
|
+
// NO TOKEN IS A NO-OP, not an error — see remove() above. A
|
|
1523
|
+
// deployment without a portal configured must not crash the user
|
|
1524
|
+
// stream over internal tooling.
|
|
1525
|
+
if( ! token ) return;
|
|
1047
1526
|
|
|
1048
1527
|
if( doc?.hubspotId ){
|
|
1049
1528
|
|
|
1050
1529
|
try {
|
|
1051
1530
|
|
|
1052
|
-
return ( await send({ doc, fetcher, method : 'PATCH', path : '/crm/v3/objects/contacts/' + doc.hubspotId, token
|
|
1531
|
+
return ( await send({ doc, fetcher, method : 'PATCH', path : '/crm/v3/objects/contacts/' + doc.hubspotId, token }) )?.id;
|
|
1053
1532
|
|
|
1054
1533
|
} catch ( error ){
|
|
1055
1534
|
|
|
@@ -1061,14 +1540,14 @@ const contacts = {
|
|
|
1061
1540
|
|
|
1062
1541
|
}
|
|
1063
1542
|
|
|
1064
|
-
const existing = await lookup({ email : doc?.email, fetcher, token
|
|
1543
|
+
const existing = await lookup({ email : doc?.email, fetcher, token });
|
|
1065
1544
|
|
|
1066
1545
|
return ( await send({
|
|
1067
1546
|
doc,
|
|
1068
1547
|
fetcher,
|
|
1069
1548
|
method : existing ? 'PATCH' : 'POST',
|
|
1070
1549
|
path : existing ? '/crm/v3/objects/contacts/' + existing : '/crm/v3/objects/contacts',
|
|
1071
|
-
token
|
|
1550
|
+
token
|
|
1072
1551
|
}) )?.id;
|
|
1073
1552
|
|
|
1074
1553
|
}
|
|
@@ -1085,7 +1564,7 @@ var icon$3 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
|
|
|
1085
1564
|
<rect width="500" height="500" fill="#BAEC5F"/>
|
|
1086
1565
|
<g clip-path="url(#clip0_2115_2832)">
|
|
1087
1566
|
<path d="M140.224 127.586L174.803 188.73V311.176L140 372.32L176.084 392.031L216.111 321.753V178.278L176.341 108L140.224 127.586Z" fill="#0D1314"/>
|
|
1088
|
-
<path d="M360.
|
|
1567
|
+
<path d="M360.002 127.523L323.694 108.282L284.949 178.498V321.596L322.924 391.749L359.394 372.79L326.225 311.52V188.73L360.002 127.523Z" fill="#0D1314"/>
|
|
1089
1568
|
</g>
|
|
1090
1569
|
<defs>
|
|
1091
1570
|
<clipPath id="clip0_2115_2832">
|
|
@@ -1110,6 +1589,82 @@ var icon$3 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
|
|
|
1110
1589
|
// answers "can this merchant connect it" through `requires` and a plan feature.
|
|
1111
1590
|
// A private one is always present, in every deployment, for every organization —
|
|
1112
1591
|
// so it declares neither, and build() knows not to ask.
|
|
1592
|
+
|
|
1593
|
+
// Replace {{key}} placeholders with values from the run's accumulated context.
|
|
1594
|
+
// An unresolved key is left as it was written rather than blanked: a merchant
|
|
1595
|
+
// reading "Hello {{name}}" knows their template is wrong, where "Hello " reads
|
|
1596
|
+
// like a person with no name.
|
|
1597
|
+
//
|
|
1598
|
+
// Moved here from drawbridge-sync's lib/step.js with the bodies that were its
|
|
1599
|
+
// only callers. It is a string function — nothing about it needed a service.
|
|
1600
|
+
const interpolate = ( template, data ) => {
|
|
1601
|
+
|
|
1602
|
+
if( ! template ) return template;
|
|
1603
|
+
|
|
1604
|
+
return template.replace( /\{\{(\w+)\}\}/g, ( _, key ) => ( data?.[ key ] != null ? String( data[ key ] ) : '{{' + key + '}}' ) );
|
|
1605
|
+
|
|
1606
|
+
};
|
|
1607
|
+
|
|
1608
|
+
// WHO ON THE TEAM GETS TOLD. Shared by email.notify and email.digest, which
|
|
1609
|
+
// resolved it identically — the same forty lines twice, and the dedupe rule is
|
|
1610
|
+
// subtle enough that two copies would eventually differ.
|
|
1611
|
+
//
|
|
1612
|
+
// The OWNER IS ALWAYS A RECIPIENT. `settings.members` is an optional list of
|
|
1613
|
+
// ADDITIONAL people: the members endpoint is owner-gated and the owner is not a
|
|
1614
|
+
// `member` document, so a solo merchant has nothing selectable and could
|
|
1615
|
+
// otherwise not use these steps at all.
|
|
1616
|
+
const teamRecipients = async ({ memberIds = [], organization, read }) => {
|
|
1617
|
+
|
|
1618
|
+
const org = await read.get({ collection : 'organization', query : { id : organization } });
|
|
1619
|
+
|
|
1620
|
+
const owner = org?.owner
|
|
1621
|
+
? await read.get({ collection : 'user', query : { id : org.owner } })
|
|
1622
|
+
: null;
|
|
1623
|
+
|
|
1624
|
+
// Re-scoped to the org and to ACCEPTED members: the id list is stored on the
|
|
1625
|
+
// step and outlives the membership it names — someone who declined, was
|
|
1626
|
+
// removed, or never accepted.
|
|
1627
|
+
const members = memberIds.length
|
|
1628
|
+
? await read.aggregate({
|
|
1629
|
+
collection : 'member',
|
|
1630
|
+
pipeline : [
|
|
1631
|
+
{
|
|
1632
|
+
$match : {
|
|
1633
|
+
id : { $in : memberIds },
|
|
1634
|
+
organization,
|
|
1635
|
+
status : 'accepted'
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
]
|
|
1639
|
+
})
|
|
1640
|
+
: [];
|
|
1641
|
+
|
|
1642
|
+
// Deduped by address, lower-cased and NO FURTHER. The owner is commonly also
|
|
1643
|
+
// named in settings.members and nobody should get two copies. Canonicalizing
|
|
1644
|
+
// (toCanonicalEmail) would be wrong: it strips +tags, so two genuinely
|
|
1645
|
+
// different teammates collapse into one and the second is never told.
|
|
1646
|
+
const seen = new Set();
|
|
1647
|
+
|
|
1648
|
+
return [ owner, ...members ].filter( ( member ) => {
|
|
1649
|
+
|
|
1650
|
+
if( ! member?.id || ! member?.email ) return false;
|
|
1651
|
+
|
|
1652
|
+
const address = member.email.toLowerCase();
|
|
1653
|
+
|
|
1654
|
+
if( seen.has( address ) ) return false;
|
|
1655
|
+
|
|
1656
|
+
seen.add( address );
|
|
1657
|
+
|
|
1658
|
+
return true;
|
|
1659
|
+
|
|
1660
|
+
});
|
|
1661
|
+
|
|
1662
|
+
};
|
|
1663
|
+
|
|
1664
|
+
// ONE NOTIFICATION, DESCRIBED. queue/notification.js owns delivery, the
|
|
1665
|
+
// unsubscribe token and the CAN-SPAM footer — these steps only say who and what.
|
|
1666
|
+
const queueNotification = ( data ) => ({ collection : 'notification', data, operation : 'create' });
|
|
1667
|
+
|
|
1113
1668
|
var drawbridge = {
|
|
1114
1669
|
auth : {
|
|
1115
1670
|
type : 'none'
|
|
@@ -1127,10 +1682,15 @@ var drawbridge = {
|
|
|
1127
1682
|
exclusive : false,
|
|
1128
1683
|
fields : [],
|
|
1129
1684
|
group : 'developer',
|
|
1130
|
-
//
|
|
1131
|
-
//
|
|
1132
|
-
//
|
|
1133
|
-
//
|
|
1685
|
+
// THE BODIES LIVE HERE, beside the declarations that name them. They used to
|
|
1686
|
+
// live in drawbridge-sync because they touch the database, the queues and the
|
|
1687
|
+
// sockets — and a published package cannot carry a controller.
|
|
1688
|
+
//
|
|
1689
|
+
// It does not have to. A hook is a function, so everything it needs is PASSED
|
|
1690
|
+
// IN: `read` for the reads, `canSend` for the opt-out floor, `resolveContact`
|
|
1691
|
+
// for the one write whose RESULT the hook has to count. Everything else a hook
|
|
1692
|
+
// wants done it DESCRIBES — `writes`, `enqueues`, `events` — and the shell
|
|
1693
|
+
// performs it. See lib/connections/contract.js for that shape.
|
|
1134
1694
|
hooks : {
|
|
1135
1695
|
auth : {
|
|
1136
1696
|
// Nothing to connect, revoke, probe or re-scope.
|
|
@@ -1151,46 +1711,538 @@ var drawbridge = {
|
|
|
1151
1711
|
// accounts DRAWBRIDGE holds rather than ones a merchant connects.
|
|
1152
1712
|
contacts,
|
|
1153
1713
|
email : {
|
|
1154
|
-
|
|
1155
|
-
//
|
|
1156
|
-
//
|
|
1157
|
-
//
|
|
1158
|
-
|
|
1159
|
-
//
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1714
|
+
|
|
1715
|
+
// A PERIODIC SUMMARY to the team, on a schedule trigger rather than per
|
|
1716
|
+
// lead.
|
|
1717
|
+
//
|
|
1718
|
+
// The count is the point: `email.notify` tells the owner one lead arrived
|
|
1719
|
+
// and dampens a spike to one message per bucket, which is deliberately not
|
|
1720
|
+
// a count. This is where "you got 43 entries this week" comes from.
|
|
1721
|
+
digest : async ( { context, step, workflow }, { read } = {} ) => {
|
|
1722
|
+
|
|
1723
|
+
// The window comes from the TRIGGER that fired it, so Daily/Weekly/
|
|
1724
|
+
// Monthly each summarise their own period without a second setting to
|
|
1725
|
+
// keep in step.
|
|
1726
|
+
const days = { day : 1, month : 30, week : 7 }[ workflow?.trigger?.event ] || 7;
|
|
1727
|
+
|
|
1728
|
+
const since = new Date( Date.now() - ( days * 24 * 60 * 60 * 1000 ) );
|
|
1729
|
+
const campaign = workflow?.trigger?.filters?.campaign || null;
|
|
1730
|
+
|
|
1731
|
+
const [ counted ] = await read.aggregate({
|
|
1732
|
+
collection : 'lead',
|
|
1733
|
+
pipeline : [
|
|
1734
|
+
{
|
|
1735
|
+
$match : {
|
|
1736
|
+
createdAt : { $gte : since },
|
|
1737
|
+
organization : workflow.organization,
|
|
1738
|
+
...( campaign && { campaigns : { $in : [ campaign ] } })
|
|
1739
|
+
}
|
|
1740
|
+
},
|
|
1741
|
+
{ $count : 'count' }
|
|
1742
|
+
]
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1745
|
+
const count = Number( counted?.count || 0 );
|
|
1746
|
+
|
|
1747
|
+
const request = { campaign, count, days };
|
|
1748
|
+
|
|
1749
|
+
// Nothing happened, so nobody is told. A digest reading "0 new leads" is
|
|
1750
|
+
// mail the merchant did not ask for and would learn to ignore.
|
|
1751
|
+
if( ! count ) return { message : 'No new leads in the period — digest skipped.', request, response : { skipped : true }, skipped : true };
|
|
1752
|
+
|
|
1753
|
+
const recipients = await teamRecipients({
|
|
1754
|
+
memberIds : step.settings?.members || [],
|
|
1755
|
+
organization : workflow.organization,
|
|
1756
|
+
read
|
|
1757
|
+
});
|
|
1758
|
+
|
|
1759
|
+
// `count` joins the interpolation values so a merchant can write
|
|
1760
|
+
// "{{count}} new entries this week" in the step's own copy.
|
|
1761
|
+
const values = { ...context, count };
|
|
1762
|
+
|
|
1763
|
+
return {
|
|
1764
|
+
message : 'Digest of ' + count + ' new lead(s) queued for ' + recipients.length + ' recipient(s).',
|
|
1765
|
+
request,
|
|
1766
|
+
response : { count, notified : recipients.length },
|
|
1767
|
+
writes : recipients.map( ( member ) => queueNotification({
|
|
1768
|
+
audience : 'member',
|
|
1769
|
+
message : interpolate( step.settings?.message, values ),
|
|
1770
|
+
organization : workflow.organization,
|
|
1771
|
+
send : { type : 'email', email : member.email },
|
|
1772
|
+
title : interpolate( step.settings?.subject, values ),
|
|
1773
|
+
workflow : workflow.id
|
|
1774
|
+
}) )
|
|
1775
|
+
};
|
|
1776
|
+
|
|
1777
|
+
},
|
|
1778
|
+
|
|
1779
|
+
// To the organization's OWN PEOPLE. Never suppressed, never
|
|
1780
|
+
// subscription-gated, no unsubscribe footer — telling an org's staff about
|
|
1781
|
+
// their own leads is not commercial mail to a stranger.
|
|
1782
|
+
//
|
|
1783
|
+
// FREE, permanently. The lead that triggered this run already consumed the
|
|
1784
|
+
// billable action, and `members` is a list — billing here would turn one
|
|
1785
|
+
// lead into five more charges and the org would be paying to read its own
|
|
1786
|
+
// mail. The declaration prices it at zero; the shell bills nothing for
|
|
1787
|
+
// zero.
|
|
1788
|
+
notify : async ( { context, step, workflow }, { read } = {} ) => {
|
|
1789
|
+
|
|
1790
|
+
const memberIds = step.settings?.members || [];
|
|
1791
|
+
|
|
1792
|
+
const request = { members : memberIds.length };
|
|
1793
|
+
|
|
1794
|
+
const recipients = await teamRecipients({ memberIds, organization : workflow.organization, read });
|
|
1795
|
+
|
|
1796
|
+
if( ! recipients.length ){
|
|
1797
|
+
|
|
1798
|
+
return {
|
|
1799
|
+
message : 'No owner or accepted member with an email address — team notification skipped.',
|
|
1800
|
+
request,
|
|
1801
|
+
response : { skipped : true },
|
|
1802
|
+
skipped : true
|
|
1803
|
+
};
|
|
1804
|
+
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// SPIKE DAMPER, as a fixed window rather than a sliding one.
|
|
1808
|
+
//
|
|
1809
|
+
// This used `coalesce`, which is supersede-and-delay: every new entry
|
|
1810
|
+
// pre-empted the pending job and re-enqueued it 30 seconds out. Under a
|
|
1811
|
+
// genuine spike — entries arriving faster than one per 30s, which is
|
|
1812
|
+
// what viral means — delivery deferred indefinitely and the owner heard
|
|
1813
|
+
// NOTHING until traffic dropped, precisely when they would most want to
|
|
1814
|
+
// know.
|
|
1815
|
+
//
|
|
1816
|
+
// `key` is first-write-wins against a partial unique index: the first
|
|
1817
|
+
// entry in each bucket creates the notification and it goes out
|
|
1818
|
+
// immediately, the rest collide and are refused. A spike yields at most
|
|
1819
|
+
// one heads-up per recipient per bucket, promptly, instead of one
|
|
1820
|
+
// eventually or never.
|
|
1821
|
+
//
|
|
1822
|
+
// Deliberately NOT a change to `coalesce` itself — stream/transaction.js
|
|
1823
|
+
// uses it for balance and credit notices, and its semantics are not ours
|
|
1824
|
+
// to redefine from here.
|
|
1825
|
+
const bucket = Math.floor( Date.now() / ( 15 * 60 * 1000 ) );
|
|
1826
|
+
|
|
1827
|
+
return {
|
|
1828
|
+
message : 'Team notification queued for ' + recipients.length + ' recipient(s).',
|
|
1829
|
+
request,
|
|
1830
|
+
response : { notified : recipients.length },
|
|
1831
|
+
writes : recipients.map( ( member ) => ({
|
|
1832
|
+
...queueNotification({
|
|
1833
|
+
audience : 'member',
|
|
1834
|
+
// Per workflow, recipient AND bucket, so one recipient's damper
|
|
1835
|
+
// can never swallow another's mail and a later bucket is never
|
|
1836
|
+
// mistaken for a duplicate of an earlier one.
|
|
1837
|
+
key : 'team.notify.' + workflow.id + '.' + member.id + '.' + bucket,
|
|
1838
|
+
message : interpolate( step.settings?.message, context ),
|
|
1839
|
+
organization : workflow.organization,
|
|
1840
|
+
send : { type : 'email', email : member.email },
|
|
1841
|
+
title : interpolate( step.settings?.subject, context ),
|
|
1842
|
+
workflow : workflow.id
|
|
1843
|
+
}),
|
|
1844
|
+
// E11000 IS THE DAMPER WORKING: this recipient has already been
|
|
1845
|
+
// told within the bucket. Declared per write rather than assumed by
|
|
1846
|
+
// the shell, because on every other write here a duplicate key is a
|
|
1847
|
+
// real failure.
|
|
1848
|
+
ignoreDuplicate : true
|
|
1849
|
+
}) )
|
|
1850
|
+
};
|
|
1851
|
+
|
|
1852
|
+
},
|
|
1853
|
+
|
|
1854
|
+
// Drawbridge sends lead-facing email itself — no merchant provider gates
|
|
1855
|
+
// it.
|
|
1856
|
+
//
|
|
1857
|
+
// This QUEUES rather than sends: queue/notification.js owns delivery, the
|
|
1858
|
+
// unsubscribe token and the CAN-SPAM footer. The step's job is to say who
|
|
1859
|
+
// and what, correctly, and to refuse early when it must not send at all.
|
|
1860
|
+
send : async ( { context, step, workflow }, { canSend, read } = {} ) => {
|
|
1861
|
+
|
|
1862
|
+
const to = context?.email;
|
|
1863
|
+
|
|
1864
|
+
if( ! to ) throw new Error( 'No email address on context (context.email is required)' );
|
|
1865
|
+
|
|
1866
|
+
const request = { to };
|
|
1867
|
+
|
|
1868
|
+
// PRE-CHECKED HERE so an opted-out recipient skips WITHOUT billing. The
|
|
1869
|
+
// send() gate in queue/notification.js would cancel the doc anyway, but
|
|
1870
|
+
// only after the action had already counted.
|
|
1871
|
+
//
|
|
1872
|
+
// `canSend` is INJECTED rather than read through `read`: suppression is
|
|
1873
|
+
// the only consent source and canSend is its only reader, so a hook
|
|
1874
|
+
// querying the collection itself would be a second reader of the
|
|
1875
|
+
// opt-out floor — and the second one is the one that gets the query
|
|
1876
|
+
// wrong.
|
|
1877
|
+
const { ok : sendable } = await canSend({ channel : 'email', to });
|
|
1878
|
+
|
|
1879
|
+
if( ! sendable ) return { message : 'Recipient has opted out — skipped.', request, response : { skipped : true }, skipped : true };
|
|
1880
|
+
|
|
1881
|
+
// A LIVE SUBSCRIPTION, in two reads. Free organizations get system mail
|
|
1882
|
+
// only; a lead-facing send is a paid feature.
|
|
1883
|
+
const organization = await read.get({ collection : 'organization', query : { id : workflow.organization } });
|
|
1884
|
+
|
|
1885
|
+
const subscription = organization?.subscription
|
|
1886
|
+
? await read.get({ collection : 'subscription', query : { id : organization.subscription } })
|
|
1887
|
+
: null;
|
|
1888
|
+
|
|
1889
|
+
if( subscription?.status !== 'active' ){
|
|
1890
|
+
|
|
1891
|
+
return {
|
|
1892
|
+
message : 'Organization has no active subscription — workflow-step email skipped.',
|
|
1893
|
+
request,
|
|
1894
|
+
response : { skipped : true },
|
|
1895
|
+
skipped : true
|
|
1896
|
+
};
|
|
1897
|
+
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
return {
|
|
1901
|
+
message : 'Email queued for delivery to ' + to + '.',
|
|
1902
|
+
request,
|
|
1903
|
+
response : { queued : true },
|
|
1904
|
+
// NO `connection` FIELD, deliberately: the platform sends this.
|
|
1905
|
+
// `audience : 'lead'` states what the queue would otherwise infer from
|
|
1906
|
+
// shape.
|
|
1907
|
+
//
|
|
1908
|
+
// `campaign` is not decoration. queue/notification.js mints the
|
|
1909
|
+
// unsubscribe token with it, so it decides whether opting out is
|
|
1910
|
+
// scoped to this campaign or the whole organization, and it names the
|
|
1911
|
+
// campaign in the footer. Sending without it silently broadens every
|
|
1912
|
+
// opt-out to the entire organization.
|
|
1913
|
+
writes : [
|
|
1914
|
+
queueNotification({
|
|
1915
|
+
audience : 'lead',
|
|
1916
|
+
campaign : context?.campaign || null,
|
|
1917
|
+
lead : context?.lead || null,
|
|
1918
|
+
message : interpolate( step.settings?.message, context ),
|
|
1919
|
+
organization : workflow.organization,
|
|
1920
|
+
send : { type : 'email', email : to },
|
|
1921
|
+
title : interpolate( step.settings?.subject, context ),
|
|
1922
|
+
workflow : workflow.id
|
|
1923
|
+
})
|
|
1924
|
+
]
|
|
1925
|
+
};
|
|
1926
|
+
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
},
|
|
1930
|
+
inbound : false,
|
|
1931
|
+
lifecycle : false,
|
|
1932
|
+
resources : {
|
|
1933
|
+
audiences : false,
|
|
1934
|
+
prices : false,
|
|
1935
|
+
products : false,
|
|
1936
|
+
promotions : false
|
|
1937
|
+
},
|
|
1938
|
+
segment : {
|
|
1939
|
+
|
|
1940
|
+
// RECALCULATE SEGMENT MEMBERSHIP. The one FAN-OUT hook: it evaluates every
|
|
1941
|
+
// contact in an organization against every segment, which is too much for
|
|
1942
|
+
// one job, so it returns chunks and the shell defers completion.
|
|
1943
|
+
//
|
|
1944
|
+
// Returning `chunks` is the only thing that makes it different. The
|
|
1945
|
+
// declaration, the guards, the step document and the price are the shell's,
|
|
1946
|
+
// exactly as they are for a step that finishes in one go.
|
|
1947
|
+
sync : async ( { context, step }, { chunkSize, logger, read, resolveContact } = {} ) => {
|
|
1948
|
+
|
|
1949
|
+
// The fan-out width is the WORKER FLEET'S number, not the vendor's — how
|
|
1950
|
+
// much work one job may carry is a fact about the machines running it.
|
|
1951
|
+
// Required rather than defaulted, because a default here would silently
|
|
1952
|
+
// disagree with the deployment's own tuning and nothing would say so.
|
|
1953
|
+
if( ! chunkSize ) throw new Error( 'segment.sync needs chunkSize from the shell' );
|
|
1954
|
+
|
|
1955
|
+
const organization = context?.organization;
|
|
1956
|
+
|
|
1957
|
+
const configured = step?.settings?.segment;
|
|
1958
|
+
|
|
1959
|
+
const request = { organization : organization || null, segmentId : configured || null };
|
|
1960
|
+
|
|
1961
|
+
// A segment sitting at `syncing` with nothing running is a spinner that
|
|
1962
|
+
// never stops, so every exit below puts it back — DESCRIBED, and
|
|
1963
|
+
// performed by the shell. Including the throwing exit: the effects ride
|
|
1964
|
+
// out on the error, which is the only way a rejection can still release
|
|
1965
|
+
// what it locked.
|
|
1966
|
+
const release = ( ids, status = 'active' ) => {
|
|
1967
|
+
|
|
1968
|
+
const released = ( ids || [] ).filter( Boolean );
|
|
1969
|
+
|
|
1970
|
+
return {
|
|
1971
|
+
events : organization
|
|
1972
|
+
? released.map( ( id ) => ({
|
|
1973
|
+
event : 'organization.segments',
|
|
1974
|
+
payload : { id, status },
|
|
1975
|
+
room : 'organization.' + organization
|
|
1976
|
+
}) )
|
|
1977
|
+
: [],
|
|
1978
|
+
writes : released.map( ( id ) => ({
|
|
1979
|
+
collection : 'segment',
|
|
1980
|
+
data : { $set : { status } },
|
|
1981
|
+
operation : 'update',
|
|
1982
|
+
query : { id }
|
|
1983
|
+
}) )
|
|
1984
|
+
};
|
|
1985
|
+
|
|
1986
|
+
};
|
|
1987
|
+
|
|
1988
|
+
if( ! organization ){
|
|
1989
|
+
|
|
1990
|
+
return {
|
|
1991
|
+
...release([ configured ]),
|
|
1992
|
+
message : 'Trigger data missing organization id — cannot sync segments.',
|
|
1993
|
+
request,
|
|
1994
|
+
response : { skipped : true },
|
|
1995
|
+
skipped : true
|
|
1996
|
+
};
|
|
1997
|
+
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
const segments = await read.aggregate({
|
|
2001
|
+
collection : 'segment',
|
|
2002
|
+
pipeline : [ { $match : configured ? { id : configured, organization } : { organization } } ]
|
|
2003
|
+
});
|
|
2004
|
+
|
|
2005
|
+
if( ! segments.length ){
|
|
2006
|
+
|
|
2007
|
+
return {
|
|
2008
|
+
...release([ configured ]),
|
|
2009
|
+
message : 'No segments matched the request — nothing to sync.',
|
|
2010
|
+
request,
|
|
2011
|
+
response : { skipped : true },
|
|
2012
|
+
skipped : true
|
|
2013
|
+
};
|
|
2014
|
+
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
const segmentIds = segments.map( ( entry ) => entry.id );
|
|
2018
|
+
|
|
2019
|
+
try {
|
|
2020
|
+
|
|
2021
|
+
let backfilled = 0;
|
|
2022
|
+
|
|
2023
|
+
// A SYSTEM SEGMENT evaluates contacts, so any lead without one has to
|
|
2024
|
+
// get one first or it can never be a member of anything.
|
|
2025
|
+
if( segments.some( ( entry ) => entry.system ) ){
|
|
2026
|
+
|
|
2027
|
+
const contacted = await read.aggregate({
|
|
2028
|
+
collection : 'contact',
|
|
2029
|
+
pipeline : [
|
|
2030
|
+
{ $match : { organization } },
|
|
2031
|
+
{ $project : { _id : 0, leads : 1 } },
|
|
2032
|
+
{ $unwind : '$leads' },
|
|
2033
|
+
{ $group : { _id : null, ids : { $addToSet : '$leads' } } }
|
|
2034
|
+
]
|
|
2035
|
+
});
|
|
2036
|
+
|
|
2037
|
+
const uncontacted = await read.aggregate({
|
|
2038
|
+
collection : 'lead',
|
|
2039
|
+
pipeline : [
|
|
2040
|
+
{ $match : { id : { $nin : contacted[ 0 ]?.ids || [] }, organization } },
|
|
2041
|
+
{ $project : { _id : 0, id : 1 } }
|
|
2042
|
+
]
|
|
2043
|
+
});
|
|
2044
|
+
|
|
2045
|
+
// THE ONE WRITE THIS HOOK CANNOT DESCRIBE, and the reason it is
|
|
2046
|
+
// injected instead. Its RESULT is an input to what the hook returns:
|
|
2047
|
+
// how many contacts this run had to create decides whether the
|
|
2048
|
+
// chunks bill, and a description cannot be counted before it runs.
|
|
2049
|
+
//
|
|
2050
|
+
// So the caller supplies the collaborator — the same shape as the
|
|
2051
|
+
// `shopify` SDK injection elsewhere in this directory — and the
|
|
2052
|
+
// hook still reaches for no controller of its own.
|
|
2053
|
+
for( const lead of uncontacted ){
|
|
2054
|
+
|
|
2055
|
+
try {
|
|
2056
|
+
|
|
2057
|
+
await resolveContact({ leadId : lead.id });
|
|
2058
|
+
|
|
2059
|
+
backfilled += 1;
|
|
2060
|
+
|
|
2061
|
+
} catch ( error ){
|
|
2062
|
+
|
|
2063
|
+
// A duplicate means a concurrent resolve created it first, which
|
|
2064
|
+
// is the outcome we wanted.
|
|
2065
|
+
if( error.code !== 11000 ) throw error;
|
|
2066
|
+
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
logger?.info?.( 'segment.sync.backfill', { backfilled, organization, uncontacted : uncontacted.length });
|
|
2072
|
+
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
const contacts = await read.aggregate({
|
|
2076
|
+
collection : 'contact',
|
|
2077
|
+
pipeline : [
|
|
2078
|
+
{ $match : { organization } },
|
|
2079
|
+
{ $project : { _id : 0, id : 1 } },
|
|
2080
|
+
{ $sort : { id : 1 } }
|
|
2081
|
+
]
|
|
2082
|
+
});
|
|
2083
|
+
|
|
2084
|
+
if( ! contacts.length ){
|
|
2085
|
+
|
|
2086
|
+
return {
|
|
2087
|
+
...release( segmentIds ),
|
|
2088
|
+
message : 'Organization has no contacts to evaluate against segments.',
|
|
2089
|
+
request,
|
|
2090
|
+
response : { skipped : true },
|
|
2091
|
+
skipped : true
|
|
2092
|
+
};
|
|
2093
|
+
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
const contactIds = contacts.map( ( contact ) => contact.id );
|
|
2097
|
+
|
|
2098
|
+
const org = await read.get({ collection : 'organization', query : { id : organization } });
|
|
2099
|
+
|
|
2100
|
+
// THE FAN-OUT. `chunks` tells the shell to open the step rather than
|
|
2101
|
+
// close it, and the SHELL builds one slot per chunk — the slot is the
|
|
2102
|
+
// step document's own shape, so counting them here was a manifest
|
|
2103
|
+
// carrying a schema that is not its.
|
|
2104
|
+
const chunks = [];
|
|
2105
|
+
|
|
2106
|
+
for( let index = 0 ; index < contactIds.length ; index += chunkSize ){
|
|
2107
|
+
|
|
2108
|
+
chunks.push({
|
|
2109
|
+
contactIds : contactIds.slice( index, index + chunkSize ),
|
|
2110
|
+
organization,
|
|
2111
|
+
segments : segmentIds,
|
|
2112
|
+
// A BACKFILL IS NOT BILLABLE. It creates the contacts this run
|
|
2113
|
+
// then evaluates, so charging for it would bill an organization
|
|
2114
|
+
// for work its own history made necessary.
|
|
2115
|
+
usage : ( context?.billable === true && backfilled === 0 ) ? org?.usage || null : null
|
|
2116
|
+
});
|
|
2117
|
+
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
return {
|
|
2121
|
+
chunks,
|
|
2122
|
+
...( configured && { extra : { segment : configured } }),
|
|
2123
|
+
message : 'Queued ' + contactIds.length + ' contacts across ' + chunks.length + ' chunks for segment evaluation.',
|
|
2124
|
+
queue : 'segment',
|
|
2125
|
+
request : { ...request, segments : segmentIds },
|
|
2126
|
+
response : { chunks : chunks.length, contacts : contactIds.length, segments : segments.length }
|
|
2127
|
+
};
|
|
2128
|
+
|
|
2129
|
+
} catch ( error ){
|
|
2130
|
+
|
|
2131
|
+
// A segment left mid-sync shows as errored rather than syncing forever.
|
|
2132
|
+
// The release rides OUT ON THE ERROR because a throw is how a hook
|
|
2133
|
+
// rejects, and the shell performs a rejection's effects before it
|
|
2134
|
+
// records the failure.
|
|
2135
|
+
throw Object.assign( error, release( segmentIds, 'error' ) );
|
|
2136
|
+
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
},
|
|
2142
|
+
sms : {
|
|
2143
|
+
|
|
2144
|
+
// SMS TO A LEAD, through the merchant's own Twilio connection.
|
|
2145
|
+
//
|
|
2146
|
+
// WITHDRAWN from the builder — twilio went, and a connection-gated step
|
|
2147
|
+
// with no connection to gate on could only ever render permanently
|
|
2148
|
+
// disabled. Stored workflows still carry it, so it still runs.
|
|
2149
|
+
//
|
|
2150
|
+
// It looks its own connection up rather than relying on the shell, because
|
|
2151
|
+
// the step is declared by the PRIVATE drawbridge connection (which has
|
|
2152
|
+
// none) while the credential belongs to twilio (which has no manifest).
|
|
2153
|
+
// Platform SMS will remove that split the way it did for email.
|
|
2154
|
+
send : async ( { context, step, workflow }, { canSend, read } = {} ) => {
|
|
2155
|
+
|
|
2156
|
+
const to = context?.phone?.number;
|
|
2157
|
+
|
|
2158
|
+
if( ! to ) throw new Error( 'No phone number on context (context.phone.number is required)' );
|
|
2159
|
+
|
|
2160
|
+
const request = { to };
|
|
2161
|
+
|
|
2162
|
+
const connection = await read.get({
|
|
2163
|
+
collection : 'connection',
|
|
2164
|
+
query : { organization : workflow.organization, slug : 'twilio', status : 'active' }
|
|
2165
|
+
});
|
|
2166
|
+
|
|
2167
|
+
if( ! connection ) return { message : 'No active Twilio SMS connection — workflow-step SMS skipped.', request, response : { skipped : true }, skipped : true };
|
|
2168
|
+
|
|
2169
|
+
// PRE-CHECKED so an opted-out recipient skips WITHOUT billing. The
|
|
2170
|
+
// carrier opt-out is a legal obligation, not a preference.
|
|
2171
|
+
const { ok : sendable } = await canSend({ channel : 'sms', to : context.phone });
|
|
2172
|
+
|
|
2173
|
+
if( ! sendable ) return { message : 'Recipient has opted out — skipped.', request, response : { skipped : true }, skipped : true };
|
|
2174
|
+
|
|
2175
|
+
return {
|
|
2176
|
+
message : 'SMS queued for delivery to ' + to + ' via twilio.',
|
|
2177
|
+
request,
|
|
2178
|
+
response : { provider : 'twilio', queued : true },
|
|
2179
|
+
// QUEUES rather than sends: queue/notification.js owns delivery, the
|
|
2180
|
+
// carrier opt-out line and the segment count this is billed on.
|
|
2181
|
+
writes : [
|
|
2182
|
+
queueNotification({
|
|
2183
|
+
connection : connection.id,
|
|
2184
|
+
message : interpolate( step.settings?.message, context ),
|
|
2185
|
+
organization : workflow.organization,
|
|
2186
|
+
send : { phone : { number : to }, type : 'phone' },
|
|
2187
|
+
title : interpolate( step.settings?.subject, context ),
|
|
2188
|
+
workflow : workflow.id
|
|
2189
|
+
})
|
|
2190
|
+
]
|
|
2191
|
+
};
|
|
2192
|
+
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
},
|
|
2196
|
+
webhook : false
|
|
2197
|
+
},
|
|
2198
|
+
icon: icon$3,
|
|
2199
|
+
// PRIVATE: never in the catalog, always available to the builder.
|
|
2200
|
+
private : true,
|
|
2201
|
+
// THE PLATFORM'S OWN SENDING CREDENTIALS — SendGrid, Twilio, and the internal
|
|
2202
|
+
// HubSpot portal. No merchant ever sees these; they are what an admin types on
|
|
2203
|
+
// the provider screen so that Drawbridge itself can send.
|
|
2204
|
+
//
|
|
2205
|
+
// They belong on THIS manifest because this is the connection that sends: the
|
|
2206
|
+
// email, sms and segment hooks below are the only things that spend them, and
|
|
2207
|
+
// a private connection is still where a vendor fact lives.
|
|
2208
|
+
//
|
|
2209
|
+
// UNLIKE every public vendor, none of these appear in `requires` — see the
|
|
2210
|
+
// comment there. Availability and configuration are different questions, and a
|
|
2211
|
+
// missing CRM token must not take every base workflow step away.
|
|
2212
|
+
provider : {
|
|
2213
|
+
fields : [
|
|
2214
|
+
{ input : 'email', key : 'accountSender', credential : 'SENDGRID_FROM_ADDRESS', label : 'Account sender', message : 'Verification codes and security alerts send from here.', required : true },
|
|
2215
|
+
{ input : 'password', key : 'apiKey', credential : 'SENDGRID_API_KEY', label : 'SendGrid API key', redact : true, required : true },
|
|
2216
|
+
// NOT required. The CRM sync is best-effort internal tooling and no-ops
|
|
2217
|
+
// without a token — requiring it would make the whole drawbridge provider
|
|
2218
|
+
// read not-live over something no merchant ever sees.
|
|
2219
|
+
{ input : 'password', key : 'hubspotToken', credential : 'HUBSPOT_ACCESS_TOKEN', label : 'HubSpot access token', message : 'Drawbridge\'s own CRM portal. Internal — no merchant sees this.', redact : true },
|
|
2220
|
+
// Optional: SENDGRID_SEND_FROM_ADDRESS is not boot-required in sync
|
|
2221
|
+
// either. Unset, it degrades to the account sender rather than
|
|
2222
|
+
// refusing to start.
|
|
2223
|
+
{ input : 'email', key : 'leadSender', credential : 'SENDGRID_SEND_FROM_ADDRESS', label : 'Lead sender', message : 'The default for lead-facing mail when a merchant has not verified their own domain.' },
|
|
2224
|
+
{ input : 'text', key : 'smsFrom', credential : 'TWILIO_ACCOUNT_FROM', label : 'SMS number', required : true },
|
|
2225
|
+
{ input : 'password', key : 'smsSid', credential : 'TWILIO_ACCOUNT_SID', label : 'Twilio account SID', redact : true, required : true },
|
|
2226
|
+
{ input : 'password', key : 'smsToken', credential : 'TWILIO_AUTH_TOKEN', label : 'Twilio auth token', redact : true, required : true }
|
|
2227
|
+
]
|
|
2228
|
+
},
|
|
2229
|
+
// NOTHING, and HUBSPOT_ACCESS_TOKEN in particular must not be here.
|
|
2230
|
+
//
|
|
2231
|
+
// `requires` gates AVAILABILITY: a name in it that is unset removes the whole
|
|
2232
|
+
// connection. This one is private and contributes every base workflow step —
|
|
2233
|
+
// email.send, sms.send, segment.sync — so gating it on a CRM token would take
|
|
2234
|
+
// all of them away from any deployment without a HubSpot portal, to protect a
|
|
2235
|
+
// sync that is best-effort and already no-ops without a token.
|
|
2236
|
+
//
|
|
2237
|
+
// The test 'a vendor is only available when its environment is configured'
|
|
2238
|
+
// caught exactly that: availableConnections({}) went from [ 'drawbridge' ] to
|
|
2239
|
+
// empty the moment this was added.
|
|
2240
|
+
requires : [],
|
|
2241
|
+
slug : 'drawbridge',
|
|
2242
|
+
// Always on. There is no credential that could go bad and no configuration a
|
|
2243
|
+
// merchant could leave half-finished.
|
|
2244
|
+
status : () => 'active',
|
|
2245
|
+
// DERIVED FROM drawbridge-api/lib/workflows.js, not invented. Every value
|
|
1194
2246
|
// below — trigger, billable, settings — is what that catalog and the workflow
|
|
1195
2247
|
// route already enforce today, because this replaces them rather than
|
|
1196
2248
|
// competing with them.
|
|
@@ -1212,11 +2264,11 @@ var drawbridge = {
|
|
|
1212
2264
|
key : 'Email — Digest',
|
|
1213
2265
|
queue : 'notification',
|
|
1214
2266
|
settings : {
|
|
1215
|
-
// The organization OWNER is always a recipient, resolved
|
|
1216
|
-
// so this is additional recipients rather than the list. It
|
|
1217
|
-
// be required: the members endpoint is owner-gated and the
|
|
1218
|
-
// not a member document, so a solo merchant has nothing to
|
|
1219
|
-
// could never save the step.
|
|
2267
|
+
// The organization OWNER is always a recipient, resolved by the
|
|
2268
|
+
// hook, so this is additional recipients rather than the list. It
|
|
2269
|
+
// cannot be required: the members endpoint is owner-gated and the
|
|
2270
|
+
// owner is not a member document, so a solo merchant has nothing to
|
|
2271
|
+
// pick and could never save the step.
|
|
1220
2272
|
members : { of : 'string', type : 'array' },
|
|
1221
2273
|
message : { required : true, type : 'string' },
|
|
1222
2274
|
subject : { required : true, type : 'string' }
|
|
@@ -1327,11 +2379,11 @@ var drawbridge = {
|
|
|
1327
2379
|
// lib/ directly. A bare .svg import would need a bundler loader and force the
|
|
1328
2380
|
// tests onto dist/, which is a worse trade than one line of wrapper.
|
|
1329
2381
|
var icon$2 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
1330
|
-
<rect width="500" height="500" fill="
|
|
2382
|
+
<rect width="500" height="500" fill="#FF4B32"/>
|
|
1331
2383
|
<path d="M365.047 327.038H134.954V172.964H365.047L316.856 250.001L365.047 327.038Z" fill="#232121"/>
|
|
1332
2384
|
</svg>`;
|
|
1333
2385
|
|
|
1334
|
-
const api = async ( path, { fetcher = fetch, method = 'GET', payload, token } ) => {
|
|
2386
|
+
const api$1 = async ( path, { fetcher = fetch, method = 'GET', payload, token } ) => {
|
|
1335
2387
|
|
|
1336
2388
|
const response = await fetcher( 'https://a.klaviyo.com/api' + path, {
|
|
1337
2389
|
...( payload && { body : JSON.stringify( payload ) }),
|
|
@@ -1395,7 +2447,8 @@ var klaviyo = {
|
|
|
1395
2447
|
// differently, and it says so in hooks.auth.token rather than as a flag here.
|
|
1396
2448
|
auth : {
|
|
1397
2449
|
oauth : {
|
|
1398
|
-
// NAMES the
|
|
2450
|
+
// NAMES the credentials holding OUR application's client — keys into
|
|
2451
|
+
// the stored provider credentials, not env vars. One identity,
|
|
1399
2452
|
// every merchant — the token is the merchant's and arrives from their
|
|
1400
2453
|
// own consent, which is what stops one organization reading another's
|
|
1401
2454
|
// data.
|
|
@@ -1544,9 +2597,9 @@ var klaviyo = {
|
|
|
1544
2597
|
// renders an empty "Klaviyo account" field, because the merchant is
|
|
1545
2598
|
// never asked which account they connected — the consent already
|
|
1546
2599
|
// decided it, and asking again would be a question we can answer.
|
|
1547
|
-
connect : async ({
|
|
2600
|
+
connect : async ( { tokens }, { fetcher } = {} ) => {
|
|
1548
2601
|
|
|
1549
|
-
const body = await api( '/accounts', { fetcher, token : tokens.accessToken });
|
|
2602
|
+
const body = await api$1( '/accounts', { fetcher, token : tokens.accessToken });
|
|
1550
2603
|
|
|
1551
2604
|
const account = body?.data?.[ 0 ];
|
|
1552
2605
|
|
|
@@ -1563,7 +2616,7 @@ var klaviyo = {
|
|
|
1563
2616
|
//
|
|
1564
2617
|
// Basic auth with our client, exactly like the token exchange — the
|
|
1565
2618
|
// token being revoked is the subject, not the credential.
|
|
1566
|
-
disconnect : async ({ clientId, clientSecret, fetcher = fetch
|
|
2619
|
+
disconnect : async ( { clientId, clientSecret, manifest, settings }, { fetcher = fetch } = {} ) => {
|
|
1567
2620
|
|
|
1568
2621
|
const token = settings?.refreshToken || settings?.accessToken;
|
|
1569
2622
|
|
|
@@ -1595,7 +2648,7 @@ var klaviyo = {
|
|
|
1595
2648
|
// the refresh token is the only thing that asks Klaviyo.
|
|
1596
2649
|
//
|
|
1597
2650
|
// It also keeps the grant warm against the 90-day idle window above.
|
|
1598
|
-
probe : async ({ clientId, clientSecret,
|
|
2651
|
+
probe : async ( { clientId, clientSecret, manifest, settings }, { fetcher } = {} ) => {
|
|
1599
2652
|
|
|
1600
2653
|
const token = await accessToken({
|
|
1601
2654
|
clientId,
|
|
@@ -1634,14 +2687,13 @@ var klaviyo = {
|
|
|
1634
2687
|
commerce : false,
|
|
1635
2688
|
|
|
1636
2689
|
// The verb the contacts.sync step points at. It does the work — including
|
|
1637
|
-
// writing the profile id back onto the lead — and returns what happened.
|
|
1638
2690
|
contacts : {
|
|
1639
2691
|
|
|
1640
2692
|
// Not yet. Suppression syncs an opt-out as unsubscribed, which is a
|
|
1641
2693
|
// different thing from deleting the profile.
|
|
1642
2694
|
remove : false,
|
|
1643
2695
|
|
|
1644
|
-
sync : async ({ contact,
|
|
2696
|
+
sync : async ( { contact, lead, settings, suppressed, token }, { fetcher } = {} ) => {
|
|
1645
2697
|
|
|
1646
2698
|
const list = settings?.list;
|
|
1647
2699
|
|
|
@@ -1661,7 +2713,7 @@ var klaviyo = {
|
|
|
1661
2713
|
// exist on a single-address record.
|
|
1662
2714
|
const totals = contact?.totals || {};
|
|
1663
2715
|
|
|
1664
|
-
const profile = await api( '/profiles/', {
|
|
2716
|
+
const profile = await api$1( '/profiles/', {
|
|
1665
2717
|
fetcher,
|
|
1666
2718
|
method : 'POST',
|
|
1667
2719
|
payload : {
|
|
@@ -1702,7 +2754,7 @@ var klaviyo = {
|
|
|
1702
2754
|
//
|
|
1703
2755
|
// `suppressed` arrives as an argument because canSend() is sync's —
|
|
1704
2756
|
// a manifest cannot reach it, and this rule is too important to infer.
|
|
1705
|
-
await api( '/profile-subscription-bulk-create-jobs/', {
|
|
2757
|
+
await api$1( '/profile-subscription-bulk-create-jobs/', {
|
|
1706
2758
|
fetcher,
|
|
1707
2759
|
method : 'POST',
|
|
1708
2760
|
payload : {
|
|
@@ -1768,7 +2820,7 @@ var klaviyo = {
|
|
|
1768
2820
|
// it, so one call quietly returns the first ten lists and an account
|
|
1769
2821
|
// with more shows a picker missing the one they wanted, with nothing to
|
|
1770
2822
|
// indicate anything was cut.
|
|
1771
|
-
audiences : async ({ cursor,
|
|
2823
|
+
audiences : async ( { cursor, limit = 100, search, token }, { fetcher } = {} ) => {
|
|
1772
2824
|
|
|
1773
2825
|
// `limit` is what the CALLER wants back rather than what one request
|
|
1774
2826
|
// can carry — the loop keeps pulling until it has that many or the
|
|
@@ -1783,7 +2835,7 @@ var klaviyo = {
|
|
|
1783
2835
|
|
|
1784
2836
|
while( next && audiences.length < limit && pages < 20 ){
|
|
1785
2837
|
|
|
1786
|
-
const body = await api( next, { fetcher, token });
|
|
2838
|
+
const body = await api$1( next, { fetcher, token });
|
|
1787
2839
|
|
|
1788
2840
|
for( const list of ( body?.data || [] ) ){
|
|
1789
2841
|
|
|
@@ -1830,6 +2882,16 @@ var klaviyo = {
|
|
|
1830
2882
|
|
|
1831
2883
|
},
|
|
1832
2884
|
icon: icon$2,
|
|
2885
|
+
// DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
|
|
2886
|
+
// what an admin types on the provider screen. Declared here rather than in a
|
|
2887
|
+
// table in lib/providers.js, so a vendor's credentials sit beside the
|
|
2888
|
+
// `requires` that names the same variables.
|
|
2889
|
+
provider : {
|
|
2890
|
+
fields : [
|
|
2891
|
+
{ input : 'text', key : 'clientId', credential : 'KLAVIYO_OAUTH_CLIENT_ID', label : 'Client ID', required : true },
|
|
2892
|
+
{ input : 'password', key : 'clientSecret', credential : 'KLAVIYO_OAUTH_CLIENT_SECRET', label : 'Client secret', redact : true, required : true }
|
|
2893
|
+
]
|
|
2894
|
+
},
|
|
1833
2895
|
requires : [
|
|
1834
2896
|
'KLAVIYO_OAUTH_CLIENT_ID',
|
|
1835
2897
|
'KLAVIYO_OAUTH_CLIENT_SECRET'
|
|
@@ -1937,6 +2999,54 @@ const base = ( dc ) => {
|
|
|
1937
2999
|
|
|
1938
3000
|
};
|
|
1939
3001
|
|
|
3002
|
+
// ONE REQUEST SHAPE for the Marketing API, the way Klaviyo's file has one.
|
|
3003
|
+
//
|
|
3004
|
+
// Bearer, not Basic. Mailchimp's fundamentals doc states "API keys and OAuth 2
|
|
3005
|
+
// tokens can be used to make authenticated requests the same way", so one header
|
|
3006
|
+
// serves both — the Basic form this used under keys was never OAuth-compatible.
|
|
3007
|
+
//
|
|
3008
|
+
// The host comes from base( dc ) on every call rather than being captured once:
|
|
3009
|
+
// there is no fixed host, and a request assembled without the stored data centre
|
|
3010
|
+
// is a 404 nobody can read.
|
|
3011
|
+
const api = async ( path, { dc, fetcher = fetch, method = 'GET', payload, token } ) => {
|
|
3012
|
+
|
|
3013
|
+
const response = await fetcher( base( dc ) + path, {
|
|
3014
|
+
...( payload && { body : JSON.stringify( payload ) }),
|
|
3015
|
+
headers : {
|
|
3016
|
+
authorization : 'Bearer ' + token,
|
|
3017
|
+
...( payload && { 'content-type' : 'application/json' })
|
|
3018
|
+
},
|
|
3019
|
+
method,
|
|
3020
|
+
signal : AbortSignal.timeout( 15000 )
|
|
3021
|
+
});
|
|
3022
|
+
|
|
3023
|
+
if( ! response.ok ){
|
|
3024
|
+
|
|
3025
|
+
throw Object.assign(
|
|
3026
|
+
new Error( 'Mailchimp refused the request (' + response.status + ')' ),
|
|
3027
|
+
{ status : response.status }
|
|
3028
|
+
);
|
|
3029
|
+
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
return response.json();
|
|
3033
|
+
|
|
3034
|
+
};
|
|
3035
|
+
|
|
3036
|
+
// THE MEMBER ID IS COMPUTED, not handed back: Mailchimp addresses a member by
|
|
3037
|
+
// "the MD5 hash of the lowercase version of the list member's email address".
|
|
3038
|
+
//
|
|
3039
|
+
// That is what makes PUT an UPSERT — the same person hashes to the same id, so a
|
|
3040
|
+
// resync updates rather than creating a second member — and it is why the
|
|
3041
|
+
// address is lowercased before hashing rather than after: Ada@Example.com and
|
|
3042
|
+
// ada@example.com are one subscriber to Mailchimp and would otherwise be two.
|
|
3043
|
+
//
|
|
3044
|
+
// MD5 is the vendor's choice and nothing here is authenticated by it; it is an
|
|
3045
|
+
// address, not a secret.
|
|
3046
|
+
const subscriberHash = ( email ) => createHash( 'md5' )
|
|
3047
|
+
.update( String( email ).trim().toLowerCase() )
|
|
3048
|
+
.digest( 'hex' );
|
|
3049
|
+
|
|
1940
3050
|
// Mailchimp — contact sync, not a sender.
|
|
1941
3051
|
var mailchimp = {
|
|
1942
3052
|
// OAUTH 2, authorization code. Every url below is quoted from
|
|
@@ -1980,7 +3090,9 @@ var mailchimp = {
|
|
|
1980
3090
|
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.',
|
|
1981
3091
|
description : [
|
|
1982
3092
|
'Drawbridge no longer sends email through Mailchimp. Notification email now sends from Drawbridge itself, and verifying a domain under Networking in your organization settings puts your own brand in the from line.',
|
|
1983
|
-
'
|
|
3093
|
+
'Connecting Mailchimp lets Drawbridge sync the contacts your campaigns collect into a Mailchimp audience, so the people who enter a giveaway can be marketed to alongside the rest of your list.',
|
|
3094
|
+
'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.',
|
|
3095
|
+
'Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before.'
|
|
1984
3096
|
],
|
|
1985
3097
|
excerpt : 'Sync your Drawbridge contacts into a Mailchimp audience.',
|
|
1986
3098
|
guide : [
|
|
@@ -2023,7 +3135,7 @@ var mailchimp = {
|
|
|
2023
3135
|
// The header here is `OAuth <token>`, not Bearer — that is specific to
|
|
2024
3136
|
// the metadata endpoint. Marketing API calls take Bearer; see the
|
|
2025
3137
|
// audiences hook.
|
|
2026
|
-
connect : async ({ fetcher = fetch
|
|
3138
|
+
connect : async ( { tokens }, { fetcher = fetch } = {} ) => {
|
|
2027
3139
|
|
|
2028
3140
|
const response = await fetcher( 'https://login.mailchimp.com/oauth2/metadata', {
|
|
2029
3141
|
headers : {
|
|
@@ -2066,7 +3178,89 @@ var mailchimp = {
|
|
|
2066
3178
|
token : authToken
|
|
2067
3179
|
},
|
|
2068
3180
|
commerce : false,
|
|
2069
|
-
|
|
3181
|
+
|
|
3182
|
+
// The verb the contacts.sync step points at.
|
|
3183
|
+
contacts : {
|
|
3184
|
+
|
|
3185
|
+
// Not yet. Suppression syncs an opt-out as unsubscribed, which is a
|
|
3186
|
+
// different thing from deleting the member — and Mailchimp's own delete is
|
|
3187
|
+
// permanent, so the address can never be re-added.
|
|
3188
|
+
remove : false,
|
|
3189
|
+
|
|
3190
|
+
// PUT /lists/{list_id}/members/{subscriber_hash} — an UPSERT, which is
|
|
3191
|
+
// why there is no create-or-update branch here. Quoted from Mailchimp's
|
|
3192
|
+
// Marketing API reference for the list-members resource.
|
|
3193
|
+
sync : async ( { lead, settings, suppressed, token }, { fetcher } = {} ) => {
|
|
3194
|
+
|
|
3195
|
+
const audience = settings?.audience;
|
|
3196
|
+
|
|
3197
|
+
// status() already stops a connection reaching Active without an
|
|
3198
|
+
// audience; this is the belt to that braces. A workflow saved before
|
|
3199
|
+
// the audience was chosen must not silently write into nothing.
|
|
3200
|
+
if( ! audience ) return { message : 'No Mailchimp audience is chosen for this connection.', skipped : true };
|
|
3201
|
+
|
|
3202
|
+
const email = lead?.canonical?.email?.value || lead?.email;
|
|
3203
|
+
|
|
3204
|
+
// A Mailchimp member IS an email address — there is no other identity
|
|
3205
|
+
// to write, so a lead without one is skipped rather than failed.
|
|
3206
|
+
if( ! email ) return { message : 'That lead has no email address to sync.', skipped : true };
|
|
3207
|
+
|
|
3208
|
+
const hash = subscriberHash( email );
|
|
3209
|
+
|
|
3210
|
+
// SUPPRESSED PEOPLE ARE SYNCED AS UNSUBSCRIBED, NEVER OMITTED.
|
|
3211
|
+
//
|
|
3212
|
+
// Omitting them means Mailchimp never learns they said no, so the
|
|
3213
|
+
// merchant can import them from somewhere else and start mailing them
|
|
3214
|
+
// again. Pushing them as unsubscribed makes the suppression travel with
|
|
3215
|
+
// the person, which is the reason this connection is allowed to send
|
|
3216
|
+
// anything at all.
|
|
3217
|
+
//
|
|
3218
|
+
// `suppressed` arrives as an argument because canSend() is sync's — a
|
|
3219
|
+
// manifest cannot reach it, and this rule is too important to infer.
|
|
3220
|
+
//
|
|
3221
|
+
// STATUS_IF_NEW EVERYWHERE, `status` ONLY TO SUPPRESS, and the
|
|
3222
|
+
// asymmetry is the whole point. status_if_new applies to a member being
|
|
3223
|
+
// CREATED and is ignored for one that already exists, so a resync
|
|
3224
|
+
// cannot overwrite what a subscriber themselves chose — someone who
|
|
3225
|
+
// unsubscribed inside Mailchimp stays unsubscribed. An opt-out is the
|
|
3226
|
+
// one thing allowed to overwrite, because it travels in the direction
|
|
3227
|
+
// that protects the person; so suppression sets `status` outright, and
|
|
3228
|
+
// sets status_if_new alongside it because the upsert leg still needs a
|
|
3229
|
+
// status for a member Mailchimp has never seen.
|
|
3230
|
+
const member = await api( '/lists/' + audience + '/members/' + hash, {
|
|
3231
|
+
dc : settings?.dc,
|
|
3232
|
+
fetcher,
|
|
3233
|
+
method : 'PUT',
|
|
3234
|
+
payload : {
|
|
3235
|
+
email_address : email,
|
|
3236
|
+
// FNAME ONLY. Unlike Klaviyo, Mailchimp's custom fields are not
|
|
3237
|
+
// schemaless — a merge tag that does not exist on the audience is
|
|
3238
|
+
// refused, taking the whole request with it — and FNAME is one of
|
|
3239
|
+
// the two tags every audience is created with. The Drawbridge
|
|
3240
|
+
// totals Klaviyo receives cannot travel until something registers
|
|
3241
|
+
// merge fields on the chosen audience, which is lifecycle.register's
|
|
3242
|
+
// job and is not built.
|
|
3243
|
+
...( lead?.name && { merge_fields : { FNAME : String( lead.name ).trim().split( /\s+/ )[ 0 ] } }),
|
|
3244
|
+
...( suppressed && { status : 'unsubscribed' }),
|
|
3245
|
+
status_if_new : suppressed ? 'unsubscribed' : 'subscribed'
|
|
3246
|
+
},
|
|
3247
|
+
token
|
|
3248
|
+
});
|
|
3249
|
+
|
|
3250
|
+
return {
|
|
3251
|
+
// Merged into `context` for later steps in this run.
|
|
3252
|
+
context : { mailchimpMemberId : member?.id || hash },
|
|
3253
|
+
message : suppressed
|
|
3254
|
+
? 'Synced to Mailchimp as unsubscribed — this contact has opted out.'
|
|
3255
|
+
: 'Synced to the Mailchimp audience.',
|
|
3256
|
+
// Recorded on the run for support to read back, not a write
|
|
3257
|
+
// instruction — the hook has already written what it needed to.
|
|
3258
|
+
response : { mailchimpMemberId : member?.id || hash }
|
|
3259
|
+
};
|
|
3260
|
+
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
},
|
|
2070
3264
|
// Drawbridge sends its own notification email and SMS, and owns its own
|
|
2071
3265
|
// segments — see the private `drawbridge` manifest. A vendor answering
|
|
2072
3266
|
// these would be a second sender, which is the arrangement the platform
|
|
@@ -2086,36 +3280,16 @@ var mailchimp = {
|
|
|
2086
3280
|
// successful — the same silent truncation Klaviyo has, at a different
|
|
2087
3281
|
// number. Paged against total_items so an account past a thousand still
|
|
2088
3282
|
// resolves.
|
|
2089
|
-
audiences : async ({ cursor,
|
|
2090
|
-
|
|
2091
|
-
// Bearer, not Basic. Mailchimp's fundamentals doc states "API keys and
|
|
2092
|
-
// OAuth 2 tokens can be used to make authenticated requests the same
|
|
2093
|
-
// way", so one header serves both — the Basic form this used under
|
|
2094
|
-
// keys was never OAuth-compatible.
|
|
2095
|
-
const dc = settings?.dc;
|
|
3283
|
+
audiences : async ( { cursor, limit = 100, search, settings, token }, { fetcher } = {} ) => {
|
|
2096
3284
|
|
|
2097
3285
|
const count = Math.min( limit, 1000 );
|
|
2098
3286
|
const offset = Number( cursor || 0 );
|
|
2099
3287
|
|
|
2100
|
-
const
|
|
2101
|
-
|
|
2102
|
-
{
|
|
2103
|
-
headers : { authorization : 'Bearer ' + token },
|
|
2104
|
-
signal : AbortSignal.timeout( 15000 )
|
|
2105
|
-
}
|
|
3288
|
+
const body = await api(
|
|
3289
|
+
'/lists?count=' + count + '&offset=' + offset + '&fields=lists.id,lists.name,total_items',
|
|
3290
|
+
{ dc : settings?.dc, fetcher, token }
|
|
2106
3291
|
);
|
|
2107
3292
|
|
|
2108
|
-
if( ! response.ok ){
|
|
2109
|
-
|
|
2110
|
-
throw Object.assign(
|
|
2111
|
-
new Error( 'Mailchimp refused the request (' + response.status + ')' ),
|
|
2112
|
-
{ status : response.status }
|
|
2113
|
-
);
|
|
2114
|
-
|
|
2115
|
-
}
|
|
2116
|
-
|
|
2117
|
-
const body = await response.json();
|
|
2118
|
-
|
|
2119
3293
|
const audiences = ( body?.lists || [] ).map( ( list ) => ({ id : list.id, title : list?.name || list.id }) );
|
|
2120
3294
|
|
|
2121
3295
|
// Mailchimp's /lists takes no name filter, so a term is matched against
|
|
@@ -2150,6 +3324,15 @@ var mailchimp = {
|
|
|
2150
3324
|
webhook : false
|
|
2151
3325
|
},
|
|
2152
3326
|
icon: icon$1,
|
|
3327
|
+
// DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
|
|
3328
|
+
// what an admin types on the provider screen, beside the `requires` naming the
|
|
3329
|
+
// same variables.
|
|
3330
|
+
provider : {
|
|
3331
|
+
fields : [
|
|
3332
|
+
{ input : 'text', key : 'clientId', credential : 'MAILCHIMP_OAUTH_CLIENT_ID', label : 'Client ID', required : true },
|
|
3333
|
+
{ input : 'password', key : 'clientSecret', credential : 'MAILCHIMP_OAUTH_CLIENT_SECRET', label : 'Client secret', redact : true, required : true }
|
|
3334
|
+
]
|
|
3335
|
+
},
|
|
2153
3336
|
// The OAuth client this deployment registered. Without both, the vendor drops
|
|
2154
3337
|
// out of availableConnections rather than offering a Connect button that
|
|
2155
3338
|
// cannot complete.
|
|
@@ -2158,32 +3341,66 @@ var mailchimp = {
|
|
|
2158
3341
|
'MAILCHIMP_OAUTH_CLIENT_SECRET'
|
|
2159
3342
|
],
|
|
2160
3343
|
slug : 'mailchimp',
|
|
2161
|
-
// A
|
|
2162
|
-
//
|
|
2163
|
-
//
|
|
2164
|
-
//
|
|
2165
|
-
//
|
|
3344
|
+
// A grant with no audience chosen is authenticated and useless — the sync has
|
|
3345
|
+
// nowhere to put anyone — so the card must say Pending rather than Active over
|
|
3346
|
+
// nothing. Mailchimp also needs its merge fields created on that audience
|
|
3347
|
+
// before any Drawbridge total can be written to a member — unlike Klaviyo, its
|
|
3348
|
+
// custom fields are not schemaless — so the audience must be picked before
|
|
3349
|
+
// lifecycle.register has anything to register against.
|
|
2166
3350
|
status : ( data ) => ( data?.settings?.audience ? data.status : 'pending' ),
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
3351
|
+
|
|
3352
|
+
steps : {
|
|
3353
|
+
|
|
3354
|
+
contacts : {
|
|
3355
|
+
|
|
3356
|
+
// A DECLARATION, not the work. It names the hook that does the work, and
|
|
3357
|
+
// the nesting IS the name: this is `step.contacts.sync`, the string a
|
|
3358
|
+
// workflow document stores. Klaviyo and Attentive declare the same type —
|
|
3359
|
+
// a step belongs to the capability, not to whoever implements it — and the
|
|
3360
|
+
// connection on the step document is what says which vendor runs.
|
|
3361
|
+
sync : ({ data }) => ({
|
|
3362
|
+
|
|
3363
|
+
hook : 'contacts.sync',
|
|
3364
|
+
|
|
3365
|
+
// NO ACCOUNT NAME TO INTERPOLATE, unlike Klaviyo. Mailchimp's
|
|
3366
|
+
// auth.connect deliberately stores only the data centre (a test pins
|
|
3367
|
+
// that), and settings.audience is an opaque list id no merchant would
|
|
3368
|
+
// recognise in a builder label — so the label names the vendor rather
|
|
3369
|
+
// than showing a string like a1b2c3d4e5.
|
|
3370
|
+
key : 'Sync contact to Mailchimp',
|
|
3371
|
+
|
|
3372
|
+
queue : 'connection',
|
|
3373
|
+
|
|
3374
|
+
// Nothing for a merchant to configure on the step itself — the audience
|
|
3375
|
+
// is chosen once on the connection. Declared empty rather than omitted,
|
|
3376
|
+
// so "this step takes no settings" and "nobody thought about settings"
|
|
3377
|
+
// stay different statements.
|
|
3378
|
+
settings : {},
|
|
3379
|
+
|
|
3380
|
+
// BOTH triggers, for the same reason as Klaviyo: lead.insert alone only
|
|
3381
|
+
// ever fires for someone with no history yet, and crossing into a
|
|
3382
|
+
// segment is the other moment a contact is worth pushing.
|
|
3383
|
+
triggers : [ 'lead.insert', 'segment.contact.add' ],
|
|
3384
|
+
|
|
3385
|
+
// One source for cost: what the builder discloses before a merchant
|
|
3386
|
+
// adds this step, and what is charged when it runs.
|
|
3387
|
+
usage : { actions : 1 }
|
|
3388
|
+
|
|
3389
|
+
})
|
|
3390
|
+
|
|
2185
3391
|
}
|
|
2186
|
-
|
|
3392
|
+
|
|
3393
|
+
},
|
|
3394
|
+
// WHY, in the merchant's words, and what to do about it.
|
|
3395
|
+
tasks : ( data ) => ( data?.settings?.audience
|
|
3396
|
+
? []
|
|
3397
|
+
: [
|
|
3398
|
+
{
|
|
3399
|
+
message : 'Choose which Mailchimp audience your contacts should sync into. Until you do, nothing is being synced.',
|
|
3400
|
+
title : 'Choose an audience'
|
|
3401
|
+
}
|
|
3402
|
+
]
|
|
3403
|
+
),
|
|
2187
3404
|
title : 'Mailchimp'
|
|
2188
3405
|
};
|
|
2189
3406
|
|
|
@@ -2194,24 +3411,39 @@ var mailchimp = {
|
|
|
2194
3411
|
// lib/ directly. A bare .svg import would need a bundler loader and force the
|
|
2195
3412
|
// tests onto dist/, which is a worse trade than one line of wrapper.
|
|
2196
3413
|
var icon = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2197
|
-
<rect width="500" height="500" fill="
|
|
2198
|
-
<path
|
|
2199
|
-
<path d="
|
|
2200
|
-
<path d="M258.993 193.019L249.103 230.052C249.103 230.052 238.079 225.023 225 225.85C205.833 227.059 205.628 239.171 205.824 242.21C206.865 258.756 250.376 262.381 252.821 301.171C254.745 331.688 236.656 352.574 210.592 354.21C179.313 356.19 162.089 337.711 162.089 337.711L168.716 309.472C168.716 309.472 186.052 322.569 199.921 321.686C208.993 321.119 212.228 313.738 211.903 308.514C210.536 286.921 175.102 288.185 172.862 252.695C170.985 222.811 190.57 192.554 233.803 189.821C250.46 188.762 258.993 193.028 258.993 193.028" fill="white"/>
|
|
3414
|
+
<rect width="500" height="500" fill="#95C049"/>
|
|
3415
|
+
<path d="M292.997 147.633C292.997 147.633 289.98 148.495 285.023 150.004C284.161 147.202 282.868 143.969 281.144 140.521C275.54 129.744 267.134 123.925 257.22 123.925C256.574 123.925 255.927 123.925 255.065 124.141C254.849 123.71 254.418 123.494 254.203 123.063C249.892 118.322 244.289 116.166 237.607 116.382C224.676 116.813 211.744 126.081 201.399 142.676C194.071 154.314 188.468 168.97 186.959 180.393C172.088 184.919 161.743 188.152 161.527 188.367C153.984 190.738 153.768 190.954 152.906 198.066C151.613 203.454 132 355.4 132 355.4L294.937 383.633V147.202C294.075 147.418 293.429 147.418 292.997 147.633ZM255.281 159.271C246.66 161.858 237.176 164.875 227.909 167.677C230.495 157.547 235.668 147.418 241.702 140.736C244.073 138.365 247.306 135.564 250.97 133.839C254.634 141.598 255.496 152.159 255.281 159.271ZM237.823 125.003C240.84 125.003 243.427 125.649 245.582 126.943C242.133 128.667 238.685 131.469 235.452 134.702C227.262 143.538 221.012 157.332 218.426 170.479C210.667 172.85 202.908 175.22 195.796 177.376C200.322 156.901 217.779 125.649 237.823 125.003ZM212.607 243.542C213.469 257.335 249.892 260.353 252.048 292.897C253.556 318.545 238.47 336.002 216.701 337.295C190.407 339.02 175.967 323.502 175.967 323.502L181.571 299.794C181.571 299.794 196.011 310.786 207.649 309.924C215.193 309.493 217.995 303.242 217.779 298.932C216.701 280.828 186.959 281.905 185.019 252.163C183.295 227.377 199.675 202.161 235.883 199.79C249.892 198.928 257.005 202.377 257.005 202.377L248.815 233.412C248.815 233.412 239.547 229.102 228.555 229.964C212.607 231.041 212.391 241.171 212.607 243.542ZM263.902 156.685C263.902 150.219 263.039 140.952 260.022 133.193C269.936 135.133 274.678 146.124 276.833 152.806C272.954 153.883 268.643 155.176 263.902 156.685Z" fill="white"/>
|
|
3416
|
+
<path d="M300.325 382.771L368 365.96C368 365.96 338.904 169.185 338.689 167.892C338.473 166.599 337.396 165.737 336.318 165.737C335.24 165.737 316.274 165.306 316.274 165.306C316.274 165.306 304.636 154.099 300.325 149.788V382.771Z" fill="white"/>
|
|
2201
3417
|
</svg>`;
|
|
2202
3418
|
|
|
2203
3419
|
// THE SHARED HALF OF inbound.verify.
|
|
2204
3420
|
//
|
|
2205
3421
|
// A vendor whose scheme is "hash the raw body with a shared secret and compare,
|
|
2206
3422
|
// constant-time, against a header" declares that shape as `inbound.signature`
|
|
2207
|
-
// data on its manifest (algorithm, encoding, the
|
|
3423
|
+
// data on its manifest (algorithm, encoding, the NAME of the credential) and
|
|
2208
3424
|
// wires this straight in as its hook — Shopify does exactly that.
|
|
2209
3425
|
//
|
|
2210
3426
|
// A vendor whose scheme is not that shape — Twilio signs the REGISTERED URL
|
|
2211
3427
|
// rather than the body, a JWT-bodied vendor verifies against a JWKS — writes its
|
|
2212
3428
|
// own inbound.verify instead. That is why verify is a hook and not config: this
|
|
2213
3429
|
// file covers the common case, not the contract.
|
|
2214
|
-
const verifySignature = ({ body, descriptor, headers }) => {
|
|
3430
|
+
const verifySignature = ({ body, descriptor, headers, secret }) => {
|
|
3431
|
+
|
|
3432
|
+
// THE MANIFEST STILL DECLARES THE NAME, the caller supplies the value.
|
|
3433
|
+
// descriptor.signature.secret is 'SHOPIFY_API_SECRET' and stays that way —
|
|
3434
|
+
// naming which credential a vendor needs is the manifest's job. Reading it is
|
|
3435
|
+
// not: the value lives encrypted in the `provider` collection, and a package
|
|
3436
|
+
// that reached into process.env for it would force every service to copy the
|
|
3437
|
+
// collection back into its environment at boot before this could work.
|
|
3438
|
+
//
|
|
3439
|
+
// A 500 rather than the 401 below, because a secret we never loaded is our
|
|
3440
|
+
// misconfiguration and must not be indistinguishable in the logs from the
|
|
3441
|
+
// forged request that gets the same door slammed on it.
|
|
3442
|
+
if( ! secret ){
|
|
3443
|
+
|
|
3444
|
+
throw Object.assign( new Error( 'Missing webhook secret: ' + descriptor.signature.secret ), { status : 500 });
|
|
3445
|
+
|
|
3446
|
+
}
|
|
2215
3447
|
|
|
2216
3448
|
const provided = headers[ descriptor.headers.signature ];
|
|
2217
3449
|
|
|
@@ -2221,33 +3453,102 @@ const verifySignature = ({ body, descriptor, headers }) => {
|
|
|
2221
3453
|
|
|
2222
3454
|
}
|
|
2223
3455
|
|
|
2224
|
-
const digest = createHmac( descriptor.signature.algorithm,
|
|
2225
|
-
.update( body )
|
|
2226
|
-
.digest( descriptor.signature.encoding );
|
|
3456
|
+
const digest = createHmac( descriptor.signature.algorithm, secret )
|
|
3457
|
+
.update( body )
|
|
3458
|
+
.digest( descriptor.signature.encoding );
|
|
3459
|
+
|
|
3460
|
+
// Buffers of different lengths crash timingSafeEqual rather than compare
|
|
3461
|
+
// false — decided here, before the length itself becomes a timing signal.
|
|
3462
|
+
const digestBuffer = Buffer.from( digest, descriptor.signature.encoding );
|
|
3463
|
+
const providedBuffer = Buffer.from( provided, descriptor.signature.encoding );
|
|
3464
|
+
|
|
3465
|
+
if(
|
|
3466
|
+
digestBuffer.length !== providedBuffer.length ||
|
|
3467
|
+
! timingSafeEqual( digestBuffer, providedBuffer )
|
|
3468
|
+
){
|
|
3469
|
+
|
|
3470
|
+
throw Object.assign( new Error( 'Invalid webhook signature' ), { status : 401 });
|
|
3471
|
+
|
|
3472
|
+
}
|
|
3473
|
+
|
|
3474
|
+
// The proof and the parse happen together on purpose. Nothing downstream of
|
|
3475
|
+
// inbound.verify ever sees the raw bytes, which is what makes acting on
|
|
3476
|
+
// unverified data impossible rather than merely discouraged.
|
|
3477
|
+
return JSON.parse( body.toString() );
|
|
3478
|
+
|
|
3479
|
+
};
|
|
3480
|
+
|
|
3481
|
+
// THE SHARED HALF OF inbound.event, for a vendor that puts it in a header.
|
|
3482
|
+
const readEventHeader = ({ descriptor, headers }) => headers[ descriptor.headers.event ];
|
|
3483
|
+
|
|
3484
|
+
// ── ORDER ATTRIBUTION, the vendor's own arithmetic ───────────────────────────
|
|
3485
|
+
//
|
|
3486
|
+
// Orders are attributed via `_drwbrdg_*` line-item properties injected at
|
|
3487
|
+
// add-to-cart (NOT url params); only items tagged with `_drwbrdg_ca` count
|
|
3488
|
+
// toward a conversion's attributed gross. This is Shopify's payload shape, so
|
|
3489
|
+
// it lives with the vendor — it was drawbridge-sync's lib/attribution.js.
|
|
3490
|
+
|
|
3491
|
+
const toLine = ({
|
|
3492
|
+
price,
|
|
3493
|
+
product_id : productId,
|
|
3494
|
+
quantity,
|
|
3495
|
+
title,
|
|
3496
|
+
variant_id : variantId,
|
|
3497
|
+
variant_title : variantTitle
|
|
3498
|
+
}) => ({
|
|
3499
|
+
price : parseFloat( price ) || 0,
|
|
3500
|
+
productId : productId ? 'gid://shopify/Product/' + productId : null,
|
|
3501
|
+
quantity : quantity || 1,
|
|
3502
|
+
title : title || null,
|
|
3503
|
+
variantId : variantId ? 'gid://shopify/ProductVariant/' + variantId : null,
|
|
3504
|
+
variantTitle : variantTitle || null
|
|
3505
|
+
});
|
|
3506
|
+
|
|
3507
|
+
// Reduce an order's line items to the campaign attribution: the first tagged
|
|
3508
|
+
// item's property map wins for ids (attrMap), gross/lines accumulate across
|
|
3509
|
+
// every tagged item. Untagged items contribute nothing.
|
|
3510
|
+
const attributeLineItems = ( lineItems = [] ) => lineItems.reduce(
|
|
3511
|
+
( acc, item ) => {
|
|
3512
|
+
|
|
3513
|
+
const attrs = ( item.properties || [] ).reduce(
|
|
3514
|
+
( map, { name, value } ) => {
|
|
3515
|
+
|
|
3516
|
+
map[ name ] = value;
|
|
3517
|
+
|
|
3518
|
+
return map;
|
|
3519
|
+
|
|
3520
|
+
},
|
|
3521
|
+
{}
|
|
3522
|
+
);
|
|
3523
|
+
|
|
3524
|
+
if( ! attrs[ '_drwbrdg_ca' ] ) return acc;
|
|
3525
|
+
|
|
3526
|
+
if( ! Object.keys( acc.attrMap ).length ) acc.attrMap = attrs;
|
|
3527
|
+
|
|
3528
|
+
const line = toLine( item );
|
|
2227
3529
|
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
const digestBuffer = Buffer.from( digest, descriptor.signature.encoding );
|
|
2231
|
-
const providedBuffer = Buffer.from( provided, descriptor.signature.encoding );
|
|
3530
|
+
acc.attributedGross += line.price * line.quantity;
|
|
3531
|
+
acc.attributedLines.push( line );
|
|
2232
3532
|
|
|
2233
|
-
|
|
2234
|
-
digestBuffer.length !== providedBuffer.length ||
|
|
2235
|
-
! timingSafeEqual( digestBuffer, providedBuffer )
|
|
2236
|
-
){
|
|
3533
|
+
return acc;
|
|
2237
3534
|
|
|
2238
|
-
|
|
3535
|
+
},
|
|
3536
|
+
{ attrMap : {}, attributedGross : 0, attributedLines : [] }
|
|
3537
|
+
);
|
|
2239
3538
|
|
|
2240
|
-
|
|
3539
|
+
// Unambiguous alphabet and length, because a merchant reads these aloud and
|
|
3540
|
+
// types them into a checkout.
|
|
3541
|
+
const generateDiscountCode = customAlphabet( '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', 8 );
|
|
2241
3542
|
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
return JSON.parse( body.toString() );
|
|
3543
|
+
// Rotate before the window closes rather than at it: Shopify's refresh token has
|
|
3544
|
+
// an expiry, and a rotation attempted after it has passed cannot succeed.
|
|
3545
|
+
const REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1000;
|
|
2246
3546
|
|
|
2247
|
-
|
|
3547
|
+
const OAUTH_ERROR_SOURCE = 'oauth';
|
|
2248
3548
|
|
|
2249
|
-
//
|
|
2250
|
-
|
|
3549
|
+
// What Shopify says when the merchant has uninstalled or revoked. Neither is
|
|
3550
|
+
// retryable and both mean the same thing to a merchant: reconnect.
|
|
3551
|
+
const OAUTH_GRANT_REVOKED_CODES = [ 'application_cannot_be_found', 'invalid_grant' ];
|
|
2251
3552
|
|
|
2252
3553
|
// WHERE THIS VENDOR PUTS THINGS ON AN INBOUND REQUEST. Header names are facts
|
|
2253
3554
|
// about someone else's product, so they belong beside the rest of the vendor
|
|
@@ -2334,7 +3635,7 @@ var shopify = {
|
|
|
2334
3635
|
// the App Store listing, and the dashboard must never imply a store can be
|
|
2335
3636
|
// linked from inside it.
|
|
2336
3637
|
redirect : {
|
|
2337
|
-
|
|
3638
|
+
credential : 'SHOPIFY_APP_LISTING_URL',
|
|
2338
3639
|
title : 'View on the Shopify App Store'
|
|
2339
3640
|
}
|
|
2340
3641
|
},
|
|
@@ -2391,20 +3692,670 @@ var shopify = {
|
|
|
2391
3692
|
//
|
|
2392
3693
|
// `shopify` is injected for the same reason it is everywhere else — this
|
|
2393
3694
|
// package cannot import @drawbridge/shopify, which depends on it.
|
|
2394
|
-
scopes : ({ scope, shopify }) => ( scope ? shopify.oauth.missingScopes( scope ) : null ),
|
|
3695
|
+
scopes : ( { scope }, { shopify } = {} ) => ( scope ? shopify.oauth.missingScopes( scope ) : null ),
|
|
2395
3696
|
// Shopify's install grant is exchanged inside its own app flow, not
|
|
2396
3697
|
// through the shared OAuth runner.
|
|
2397
3698
|
token : false
|
|
2398
3699
|
},
|
|
2399
|
-
//
|
|
2400
|
-
//
|
|
2401
|
-
//
|
|
2402
|
-
//
|
|
3700
|
+
// THE VENDOR'S OWN WORK, here in full. Every body describes its writes,
|
|
3701
|
+
// enqueues and events for the shell to perform — see contract.js — and
|
|
3702
|
+
// everything it needs arrives as an argument: `read` (the controller's
|
|
3703
|
+
// read methods, nothing that writes), `shopify` (the SDK, injected because
|
|
3704
|
+
// this package cannot import what depends on it), `adminToken` (minted by
|
|
3705
|
+
// the shell, which persists rotations), `mintId` (so one described write
|
|
3706
|
+
// can reference another), `dispatch` (the caller's own coordinator table,
|
|
3707
|
+
// for the hooks that are dispatches).
|
|
2403
3708
|
commerce : {
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
order
|
|
2407
|
-
|
|
3709
|
+
|
|
3710
|
+
// MINT A DISCOUNT CODE against the merchant's chosen discount, mapped to
|
|
3711
|
+
// one lead — which is what lets an order that redeems it be attributed
|
|
3712
|
+
// back.
|
|
3713
|
+
code : async ( { connection, context, step }, { adminToken, shopify } = {} ) => {
|
|
3714
|
+
|
|
3715
|
+
const discount = step.settings?.discount;
|
|
3716
|
+
|
|
3717
|
+
const request = { email : context?.email || null, lead : context?.lead || null, shop : connection.shop };
|
|
3718
|
+
|
|
3719
|
+
if( ! context?.email ) return { message : 'Lead email is missing.', request, response : { skipped : true }, skipped : true };
|
|
3720
|
+
if( ! context?.lead ) return { message : 'Lead id is missing.', request, response : { skipped : true }, skipped : true };
|
|
3721
|
+
if( ! discount?.id ) return { message : 'Discount is not configured on this step.', request, response : { skipped : true }, skipped : true };
|
|
3722
|
+
|
|
3723
|
+
const adminAccessToken = await adminToken();
|
|
3724
|
+
|
|
3725
|
+
// The customer must exist before a code is mapped to them. An earlier
|
|
3726
|
+
// commerce.customer step usually did this and left the id on the
|
|
3727
|
+
// context; when this step runs alone, it does it here.
|
|
3728
|
+
if( ! context.shopifyCustomerId ){
|
|
3729
|
+
|
|
3730
|
+
const customer = await shopify.admin.getOrCreateCustomer({ adminAccessToken, domain : connection.shop, email : context.email });
|
|
3731
|
+
|
|
3732
|
+
if( ! customer?.id ) return { message : 'Shopify did not return a customer id — create/lookup failed.', request, response : { skipped : true }, skipped : true };
|
|
3733
|
+
|
|
3734
|
+
}
|
|
3735
|
+
|
|
3736
|
+
const discountCode = await shopify.admin.createDiscountCode({
|
|
3737
|
+
adminAccessToken,
|
|
3738
|
+
code : 'DB-' + generateDiscountCode(),
|
|
3739
|
+
discountId : discount.id,
|
|
3740
|
+
domain : connection.shop
|
|
3741
|
+
});
|
|
3742
|
+
|
|
3743
|
+
if( ! discountCode ) return { message : 'Shopify did not return a discount code — create failed.', request, response : { skipped : true }, skipped : true };
|
|
3744
|
+
|
|
3745
|
+
return {
|
|
3746
|
+
context : {
|
|
3747
|
+
shopifyDiscountCode : discountCode.code,
|
|
3748
|
+
shopifyDiscountId : String( discountCode.id )
|
|
3749
|
+
},
|
|
3750
|
+
message : 'Discount code created and linked to lead.',
|
|
3751
|
+
request,
|
|
3752
|
+
response : { code : discountCode.code, id : String( discountCode.id ) },
|
|
3753
|
+
// bypassDocumentValidation because these are vendor ids on a
|
|
3754
|
+
// Drawbridge document the schema does not declare — the
|
|
3755
|
+
// canonical-identity work resolves it properly.
|
|
3756
|
+
writes : [ {
|
|
3757
|
+
collection : 'lead',
|
|
3758
|
+
data : {
|
|
3759
|
+
$set : {
|
|
3760
|
+
shopifyDiscountCode : discountCode.code,
|
|
3761
|
+
shopifyDiscountId : String( discountCode.id )
|
|
3762
|
+
}
|
|
3763
|
+
},
|
|
3764
|
+
operation : 'update',
|
|
3765
|
+
options : { bypassDocumentValidation : true },
|
|
3766
|
+
query : { id : context.lead }
|
|
3767
|
+
} ]
|
|
3768
|
+
};
|
|
3769
|
+
|
|
3770
|
+
},
|
|
3771
|
+
|
|
3772
|
+
// CREATE THE BUYER AT THE STORE, so an order can be attributed to them.
|
|
3773
|
+
//
|
|
3774
|
+
// IDEMPOTENT THREE WAYS, because this runs on every entry and a duplicate
|
|
3775
|
+
// customer at the store is a support ticket: the context may already
|
|
3776
|
+
// carry the id from an earlier step, the lead may already be linked from
|
|
3777
|
+
// an earlier run, and Shopify's own get-or-create settles the rest.
|
|
3778
|
+
customer : async ( { connection, context }, { adminToken, read, shopify } = {} ) => {
|
|
3779
|
+
|
|
3780
|
+
const request = { email : context?.email || null, lead : context?.lead || null, shop : connection.shop };
|
|
3781
|
+
|
|
3782
|
+
if( ! context?.email ) return { message : 'Lead email is missing — cannot create Shopify customer.', request, response : { skipped : true }, skipped : true };
|
|
3783
|
+
if( ! context?.lead ) return { message : 'Lead id is missing — cannot create Shopify customer.', request, response : { skipped : true }, skipped : true };
|
|
3784
|
+
|
|
3785
|
+
// Already known from an earlier step in this run.
|
|
3786
|
+
if( context.shopifyCustomerId ){
|
|
3787
|
+
|
|
3788
|
+
return {
|
|
3789
|
+
context : { shopifyCustomerId : context.shopifyCustomerId },
|
|
3790
|
+
message : 'Trigger data already includes a Shopify customer id — reusing.',
|
|
3791
|
+
request,
|
|
3792
|
+
response : { shopifyCustomerId : context.shopifyCustomerId },
|
|
3793
|
+
// Reusing an id is not a creation, so it does not bill.
|
|
3794
|
+
skipped : true
|
|
3795
|
+
};
|
|
3796
|
+
|
|
3797
|
+
}
|
|
3798
|
+
|
|
3799
|
+
const lead = await read.get({ collection : 'lead', query : { id : context.lead } });
|
|
3800
|
+
|
|
3801
|
+
// Already linked by an earlier run.
|
|
3802
|
+
if( lead?.shopifyCustomerId ){
|
|
3803
|
+
|
|
3804
|
+
return {
|
|
3805
|
+
context : { shopifyCustomerId : lead.shopifyCustomerId },
|
|
3806
|
+
message : 'Lead already has a Shopify customer id — reusing.',
|
|
3807
|
+
request,
|
|
3808
|
+
response : { shopifyCustomerId : lead.shopifyCustomerId },
|
|
3809
|
+
skipped : true
|
|
3810
|
+
};
|
|
3811
|
+
|
|
3812
|
+
}
|
|
3813
|
+
|
|
3814
|
+
const adminAccessToken = await adminToken();
|
|
3815
|
+
|
|
3816
|
+
// Shopify keeps first and last separately; Drawbridge keeps one name.
|
|
3817
|
+
// Split on the first space and give everything after it to the
|
|
3818
|
+
// surname, which is wrong for some names and is what the vendor's
|
|
3819
|
+
// shape allows.
|
|
3820
|
+
const parts = ( lead?.name || '' ).trim().split( /\s+/ ).filter( Boolean );
|
|
3821
|
+
|
|
3822
|
+
const customer = await shopify.admin.getOrCreateCustomer({
|
|
3823
|
+
adminAccessToken,
|
|
3824
|
+
domain : connection.shop,
|
|
3825
|
+
email : context.email,
|
|
3826
|
+
firstName : parts.length ? parts[ 0 ] : null,
|
|
3827
|
+
lastName : parts.length > 1 ? parts.slice( 1 ).join( ' ' ) : null,
|
|
3828
|
+
source : 'drawbridge'
|
|
3829
|
+
});
|
|
3830
|
+
|
|
3831
|
+
if( ! customer?.id ) return { message : 'Shopify did not return a customer id — create/lookup failed.', request, response : { skipped : true }, skipped : true };
|
|
3832
|
+
|
|
3833
|
+
return {
|
|
3834
|
+
context : { shopifyCustomerId : customer.id },
|
|
3835
|
+
message : 'Shopify customer created/linked to lead.',
|
|
3836
|
+
request,
|
|
3837
|
+
response : { shopifyCustomerId : customer.id },
|
|
3838
|
+
// The hook's own result, described beside the call that produced it.
|
|
3839
|
+
writes : [ {
|
|
3840
|
+
collection : 'lead',
|
|
3841
|
+
data : { $set : { shopifyCustomerId : customer.id } },
|
|
3842
|
+
operation : 'update',
|
|
3843
|
+
options : { bypassDocumentValidation : true },
|
|
3844
|
+
query : { id : context.lead }
|
|
3845
|
+
} ]
|
|
3846
|
+
};
|
|
3847
|
+
|
|
3848
|
+
},
|
|
3849
|
+
|
|
3850
|
+
// AN ORDER ARRIVED AT THE STORE. The largest hook in the family, because
|
|
3851
|
+
// attribution genuinely is: an order can reach Drawbridge two ways and
|
|
3852
|
+
// they bill differently.
|
|
3853
|
+
//
|
|
3854
|
+
// CONVERSION — a `_drwbrdg_ca` line-item property, injected at
|
|
3855
|
+
// add-to-cart. Causal: the campaign produced the sale, so
|
|
3856
|
+
// it carries a fee.
|
|
3857
|
+
// REDEMPTION — a DB- discount code matched to a lead. Associative: we
|
|
3858
|
+
// cannot claim we caused the purchase, so it is fee-free.
|
|
3859
|
+
//
|
|
3860
|
+
// Both can be true, and an order already recorded as a conversion can
|
|
3861
|
+
// later have a redemption backfilled onto it — `backfill` below.
|
|
3862
|
+
//
|
|
3863
|
+
// IDEMPOTENT THROUGH THE RETRY, one layer up: two deliveries of the same
|
|
3864
|
+
// order race, the loser's transaction hits a duplicate key, the step
|
|
3865
|
+
// fails and BullMQ redelivers — and the re-run's read at the top finds
|
|
3866
|
+
// what the winner wrote and skips instead of double-billing a merchant
|
|
3867
|
+
// for one purchase. The hook used to loop for this itself; describing
|
|
3868
|
+
// the writes moved the retry to the queue, with the same guarantee.
|
|
3869
|
+
order : async ( { connection, context }, { logger, mintId, read } = {} ) => {
|
|
3870
|
+
|
|
3871
|
+
const {
|
|
3872
|
+
advertisement,
|
|
3873
|
+
created_at : createdAt,
|
|
3874
|
+
currency,
|
|
3875
|
+
customer : orderCustomer,
|
|
3876
|
+
email,
|
|
3877
|
+
id : orderId,
|
|
3878
|
+
line_items : lineItems = [],
|
|
3879
|
+
organization,
|
|
3880
|
+
phone
|
|
3881
|
+
} = context || {};
|
|
3882
|
+
|
|
3883
|
+
const request = { orderId : orderId ? String( orderId ) : null, organization };
|
|
3884
|
+
|
|
3885
|
+
const [ existingOrder, existingRedemption ] = await Promise.all([
|
|
3886
|
+
read.get({ collection : 'order', query : { 'provider.id' : String( orderId ), 'provider.slug' : 'shopify' } }),
|
|
3887
|
+
read.get({ collection : 'redemption', query : { 'provider.id' : String( orderId ), 'provider.slug' : 'shopify' } })
|
|
3888
|
+
]);
|
|
3889
|
+
|
|
3890
|
+
// Already fully recorded. This is the branch the redelivery exists to
|
|
3891
|
+
// reach.
|
|
3892
|
+
if( existingRedemption ){
|
|
3893
|
+
|
|
3894
|
+
return {
|
|
3895
|
+
message : 'Order/redemption already recorded — skipping duplicate.',
|
|
3896
|
+
request,
|
|
3897
|
+
response : {
|
|
3898
|
+
existingOrderId : existingOrder?.id || null,
|
|
3899
|
+
existingRedemptionId : existingRedemption.id,
|
|
3900
|
+
skipped : true
|
|
3901
|
+
},
|
|
3902
|
+
skipped : true
|
|
3903
|
+
};
|
|
3904
|
+
|
|
3905
|
+
}
|
|
3906
|
+
|
|
3907
|
+
const backfill = ! ! existingOrder;
|
|
3908
|
+
|
|
3909
|
+
// ONLY `_drwbrdg_ca`-TAGGED LINES COUNT toward attributed gross.
|
|
3910
|
+
const { attrMap, attributedGross, attributedLines } = attributeLineItems( lineItems );
|
|
3911
|
+
|
|
3912
|
+
const campaign = attrMap[ '_drwbrdg_ca' ] || null;
|
|
3913
|
+
|
|
3914
|
+
const discountCodes = Array.isArray( context?.discount_codes ) ? context.discount_codes : [];
|
|
3915
|
+
const codes = [ ...new Set( discountCodes.map( ( dc ) => dc?.code ).filter( Boolean ) ) ];
|
|
3916
|
+
|
|
3917
|
+
const matchedLeads = codes.length
|
|
3918
|
+
? await read.aggregate({
|
|
3919
|
+
collection : 'lead',
|
|
3920
|
+
pipeline : [ { $match : { organization, shopifyDiscountCode : { $in : codes } } } ]
|
|
3921
|
+
})
|
|
3922
|
+
: [];
|
|
3923
|
+
|
|
3924
|
+
const codeToLead = {};
|
|
3925
|
+
|
|
3926
|
+
for( const found of matchedLeads ){
|
|
3927
|
+
|
|
3928
|
+
if( found.shopifyDiscountCode ) codeToLead[ found.shopifyDiscountCode ] = found;
|
|
3929
|
+
|
|
3930
|
+
}
|
|
3931
|
+
|
|
3932
|
+
const matchedDiscounts = discountCodes
|
|
3933
|
+
.filter( ( dc ) => dc?.code && codeToLead[ dc.code ] )
|
|
3934
|
+
.map( ( dc ) => ({
|
|
3935
|
+
amount : parseFloat( dc.amount ) || 0,
|
|
3936
|
+
code : dc.code,
|
|
3937
|
+
id : codeToLead[ dc.code ].shopifyDiscountId || null
|
|
3938
|
+
}) );
|
|
3939
|
+
|
|
3940
|
+
const matchedLead = matchedDiscounts.length ? codeToLead[ matchedDiscounts[ 0 ].code ] : null;
|
|
3941
|
+
|
|
3942
|
+
const discount = matchedDiscounts.length
|
|
3943
|
+
? {
|
|
3944
|
+
amount : matchedDiscounts.reduce( ( sum, entry ) => sum + entry.amount, 0 ),
|
|
3945
|
+
codes : matchedDiscounts
|
|
3946
|
+
}
|
|
3947
|
+
: null;
|
|
3948
|
+
|
|
3949
|
+
const matchedCodes = new Set( matchedDiscounts.map( ( entry ) => entry.code ) );
|
|
3950
|
+
|
|
3951
|
+
// A DB- code we minted that matched no lead. Logged rather than
|
|
3952
|
+
// ignored: it means a code went out and its lead link was lost, which
|
|
3953
|
+
// is revenue we cannot attribute and nobody would otherwise notice.
|
|
3954
|
+
const unmatched = codes.filter( ( code ) => code.startsWith( 'DB-' ) && ! matchedCodes.has( code ) );
|
|
3955
|
+
|
|
3956
|
+
if( unmatched.length ){
|
|
3957
|
+
|
|
3958
|
+
logger?.warn?.( 'shopify.order.discount.unmatched', {
|
|
3959
|
+
campaign : campaign || null,
|
|
3960
|
+
codes : JSON.stringify( unmatched ),
|
|
3961
|
+
isConversion : ! ! campaign,
|
|
3962
|
+
orderId : String( orderId ),
|
|
3963
|
+
organization
|
|
3964
|
+
});
|
|
3965
|
+
|
|
3966
|
+
}
|
|
3967
|
+
|
|
3968
|
+
if( ( ! campaign && ! discount ) || ( backfill && ! discount ) ){
|
|
3969
|
+
|
|
3970
|
+
return {
|
|
3971
|
+
message : backfill
|
|
3972
|
+
? 'Order already recorded and no Drawbridge discount code matched — nothing to backfill.'
|
|
3973
|
+
: 'Order has no Drawbridge attribution — not recording.',
|
|
3974
|
+
request,
|
|
3975
|
+
response : { skipped : true },
|
|
3976
|
+
skipped : true
|
|
3977
|
+
};
|
|
3978
|
+
|
|
3979
|
+
}
|
|
3980
|
+
|
|
3981
|
+
let advertisementId = null;
|
|
3982
|
+
let affiliateId = null;
|
|
3983
|
+
let campaignOrganization = organization;
|
|
3984
|
+
let gross = 0;
|
|
3985
|
+
let leadId = null;
|
|
3986
|
+
let lines = [];
|
|
3987
|
+
let orderCampaign = null;
|
|
3988
|
+
let pageId = null;
|
|
3989
|
+
|
|
3990
|
+
const isConversion = ! ! campaign;
|
|
3991
|
+
|
|
3992
|
+
const customerPhone = toE164( orderCustomer?.phone || phone ) || null;
|
|
3993
|
+
|
|
3994
|
+
const matchPhones = [ ...new Set([
|
|
3995
|
+
customerPhone,
|
|
3996
|
+
toE164( context?.billing_address?.phone ),
|
|
3997
|
+
toE164( context?.shipping_address?.phone )
|
|
3998
|
+
].filter( Boolean ) ) ];
|
|
3999
|
+
|
|
4000
|
+
if( isConversion ){
|
|
4001
|
+
|
|
4002
|
+
const campaignDoc = await read.get({ collection : 'campaign', query : { id : campaign } });
|
|
4003
|
+
|
|
4004
|
+
// THE CAMPAIGN MUST BELONG TO THE DELIVERING SHOP'S OWN ORG.
|
|
4005
|
+
//
|
|
4006
|
+
// `_drwbrdg_ca` is a line-item property, and on most Shopify themes
|
|
4007
|
+
// a buyer can attach arbitrary line-item properties via cart
|
|
4008
|
+
// permalinks or the AJAX cart API — and campaign ids are
|
|
4009
|
+
// discoverable from public campaign links. Without this check, a $1
|
|
4010
|
+
// order on ANY connected store carrying another org's campaign id
|
|
4011
|
+
// records a conversion under that org: its revenue totals climb,
|
|
4012
|
+
// its usage document is incremented, and its matching leads gain
|
|
4013
|
+
// order counts — a cross-tenant write driven entirely by the buyer.
|
|
4014
|
+
//
|
|
4015
|
+
// A stale or garbage id lands here too, so this is also the null
|
|
4016
|
+
// check: either way the order simply has no Drawbridge attribution.
|
|
4017
|
+
if( ! campaignDoc || campaignDoc.organization !== organization ){
|
|
4018
|
+
|
|
4019
|
+
return {
|
|
4020
|
+
message : 'Order carried a campaign attribution that does not belong to this store — not recording.',
|
|
4021
|
+
request,
|
|
4022
|
+
response : { skipped : true },
|
|
4023
|
+
skipped : true
|
|
4024
|
+
};
|
|
4025
|
+
|
|
4026
|
+
}
|
|
4027
|
+
|
|
4028
|
+
advertisementId = attrMap[ '_drwbrdg_ad' ] || advertisement || null;
|
|
4029
|
+
affiliateId = attrMap[ '_drwbrdg_af' ] || null;
|
|
4030
|
+
campaignOrganization = campaignDoc.organization;
|
|
4031
|
+
gross = attributedGross;
|
|
4032
|
+
lines = attributedLines;
|
|
4033
|
+
orderCampaign = campaign;
|
|
4034
|
+
pageId = attrMap[ '_drwbrdg_pg' ] || null;
|
|
4035
|
+
|
|
4036
|
+
// MATCHED ON EVERY IDENTITY WE HOLD, canonical forms included,
|
|
4037
|
+
// because the address on an order is often not the one they entered
|
|
4038
|
+
// with.
|
|
4039
|
+
const identifiers = [];
|
|
4040
|
+
|
|
4041
|
+
const canonicalEmail = toCanonicalEmail( email );
|
|
4042
|
+
|
|
4043
|
+
if( email ) identifiers.push({ email : email.toLowerCase() });
|
|
4044
|
+
if( canonicalEmail ) identifiers.push({ 'canonical.email.value' : canonicalEmail });
|
|
4045
|
+
if( matchPhones.length ) identifiers.push({ 'phone.number' : { $in : matchPhones } });
|
|
4046
|
+
if( matchPhones.length ) identifiers.push({ 'canonical.phone.value' : { $in : matchPhones } });
|
|
4047
|
+
|
|
4048
|
+
if( identifiers.length ){
|
|
4049
|
+
|
|
4050
|
+
// Narrowed to the campaign first; an org-wide match is the
|
|
4051
|
+
// fallback, because a buyer who entered a different campaign is
|
|
4052
|
+
// still the same person and still worth linking.
|
|
4053
|
+
const lead = await read.get({
|
|
4054
|
+
collection : 'lead',
|
|
4055
|
+
query : {
|
|
4056
|
+
campaigns : { $in : [ campaign ] },
|
|
4057
|
+
organization : campaignOrganization,
|
|
4058
|
+
$or : identifiers
|
|
4059
|
+
}
|
|
4060
|
+
});
|
|
4061
|
+
|
|
4062
|
+
leadId = lead?.id || null;
|
|
4063
|
+
|
|
4064
|
+
if( ! leadId ){
|
|
4065
|
+
|
|
4066
|
+
const orgLead = await read.get({
|
|
4067
|
+
collection : 'lead',
|
|
4068
|
+
query : { organization : campaignOrganization, $or : identifiers }
|
|
4069
|
+
});
|
|
4070
|
+
|
|
4071
|
+
leadId = orgLead?.id || null;
|
|
4072
|
+
|
|
4073
|
+
}
|
|
4074
|
+
|
|
4075
|
+
}
|
|
4076
|
+
|
|
4077
|
+
} else {
|
|
4078
|
+
|
|
4079
|
+
// REDEMPTION. The lead is known from the code, and the whole order
|
|
4080
|
+
// counts as gross — there are no tagged lines to narrow it to.
|
|
4081
|
+
leadId = matchedLead.id;
|
|
4082
|
+
orderCampaign = ( matchedLead.campaigns || [] ).length === 1 ? matchedLead.campaigns[ 0 ] : null;
|
|
4083
|
+
gross = lineItems.reduce( ( sum, item ) => {
|
|
4084
|
+
|
|
4085
|
+
const line = toLine( item );
|
|
4086
|
+
|
|
4087
|
+
return sum + ( line.price * line.quantity );
|
|
4088
|
+
|
|
4089
|
+
}, 0 );
|
|
4090
|
+
lines = lineItems.map( toLine );
|
|
4091
|
+
|
|
4092
|
+
}
|
|
4093
|
+
|
|
4094
|
+
const org = await read.get({ collection : 'organization', query : { id : campaignOrganization } });
|
|
4095
|
+
|
|
4096
|
+
let rate = 0;
|
|
4097
|
+
|
|
4098
|
+
// THE FEE IS THE CONVERSION FEE, and only a conversion carries one. A
|
|
4099
|
+
// redemption is associative — we cannot claim we caused the purchase —
|
|
4100
|
+
// so it is recorded fee-free.
|
|
4101
|
+
if( isConversion ){
|
|
4102
|
+
|
|
4103
|
+
const subscription = await read.get({ collection : 'subscription', query : { id : org?.subscription } });
|
|
4104
|
+
|
|
4105
|
+
rate = conversionRate( subscription );
|
|
4106
|
+
|
|
4107
|
+
}
|
|
4108
|
+
|
|
4109
|
+
const fee = isConversion ? Math.round( gross * rate ) / 100 : 0;
|
|
4110
|
+
const net = Math.round( ( gross - fee ) * 100 ) / 100;
|
|
4111
|
+
|
|
4112
|
+
const currencyCode = ( currency || 'usd' ).toLowerCase();
|
|
4113
|
+
const purchasedAt = new Date( createdAt || Date.now() );
|
|
4114
|
+
|
|
4115
|
+
const customer = ( orderCustomer || email || phone )
|
|
4116
|
+
? {
|
|
4117
|
+
acceptsMarketing : orderCustomer?.email_marketing_consent?.state
|
|
4118
|
+
? orderCustomer.email_marketing_consent.state === 'subscribed'
|
|
4119
|
+
: ( typeof orderCustomer?.accepts_marketing === 'boolean' ? orderCustomer.accepts_marketing : null ),
|
|
4120
|
+
email : orderCustomer?.email || email || null,
|
|
4121
|
+
firstName : orderCustomer?.first_name || null,
|
|
4122
|
+
id : orderCustomer?.id ? String( orderCustomer.id ) : null,
|
|
4123
|
+
lastName : orderCustomer?.last_name || null,
|
|
4124
|
+
phone : customerPhone
|
|
4125
|
+
}
|
|
4126
|
+
: null;
|
|
4127
|
+
|
|
4128
|
+
const source = connection?.source
|
|
4129
|
+
? { domain : connection.source.domain, id : connection.source.id }
|
|
4130
|
+
: undefined;
|
|
4131
|
+
|
|
4132
|
+
// MINTED HERE, because the redemption names its order and the usage
|
|
4133
|
+
// job names both — a description cannot read a write's result, so the
|
|
4134
|
+
// id exists before either does.
|
|
4135
|
+
const orderDocId = existingOrder?.id || ( isConversion && ! backfill ? mintId() : null );
|
|
4136
|
+
|
|
4137
|
+
const writes = [];
|
|
4138
|
+
|
|
4139
|
+
if( isConversion && ! backfill ){
|
|
4140
|
+
|
|
4141
|
+
writes.push({
|
|
4142
|
+
collection : 'order',
|
|
4143
|
+
data : {
|
|
4144
|
+
advertisement : advertisementId,
|
|
4145
|
+
affiliate : affiliateId,
|
|
4146
|
+
campaign : orderCampaign,
|
|
4147
|
+
currency : currencyCode,
|
|
4148
|
+
customer,
|
|
4149
|
+
discount,
|
|
4150
|
+
fee,
|
|
4151
|
+
gross,
|
|
4152
|
+
id : orderDocId,
|
|
4153
|
+
lead : leadId,
|
|
4154
|
+
lines,
|
|
4155
|
+
net,
|
|
4156
|
+
organization : campaignOrganization,
|
|
4157
|
+
page : pageId,
|
|
4158
|
+
provider : { id : String( orderId ), slug : 'shopify' },
|
|
4159
|
+
purchasedAt,
|
|
4160
|
+
rate,
|
|
4161
|
+
source,
|
|
4162
|
+
status : 'completed'
|
|
4163
|
+
},
|
|
4164
|
+
operation : 'create'
|
|
4165
|
+
});
|
|
4166
|
+
|
|
4167
|
+
if( org?.usage ){
|
|
4168
|
+
|
|
4169
|
+
writes.push({
|
|
4170
|
+
collection : 'usage',
|
|
4171
|
+
data : { $inc : { 'totals.revenue' : gross } },
|
|
4172
|
+
operation : 'update',
|
|
4173
|
+
query : { id : org.usage }
|
|
4174
|
+
});
|
|
4175
|
+
|
|
4176
|
+
}
|
|
4177
|
+
|
|
4178
|
+
if( leadId ){
|
|
4179
|
+
|
|
4180
|
+
writes.push({
|
|
4181
|
+
collection : 'lead',
|
|
4182
|
+
data : { $inc : { 'totals.orders' : 1 } },
|
|
4183
|
+
operation : 'update',
|
|
4184
|
+
options : { bypassDocumentValidation : true },
|
|
4185
|
+
query : { id : leadId }
|
|
4186
|
+
});
|
|
4187
|
+
|
|
4188
|
+
}
|
|
4189
|
+
|
|
4190
|
+
}
|
|
4191
|
+
|
|
4192
|
+
if( discount ){
|
|
4193
|
+
|
|
4194
|
+
writes.push({
|
|
4195
|
+
collection : 'redemption',
|
|
4196
|
+
data : {
|
|
4197
|
+
advertisement : advertisementId,
|
|
4198
|
+
affiliate : affiliateId,
|
|
4199
|
+
campaign : orderCampaign,
|
|
4200
|
+
code : matchedDiscounts[ 0 ]?.code || null,
|
|
4201
|
+
currency : currencyCode,
|
|
4202
|
+
customer,
|
|
4203
|
+
discount,
|
|
4204
|
+
gross,
|
|
4205
|
+
lead : leadId,
|
|
4206
|
+
order : orderDocId,
|
|
4207
|
+
organization : campaignOrganization,
|
|
4208
|
+
page : pageId,
|
|
4209
|
+
provider : { id : String( orderId ), slug : 'shopify' },
|
|
4210
|
+
purchasedAt,
|
|
4211
|
+
source,
|
|
4212
|
+
status : 'completed'
|
|
4213
|
+
},
|
|
4214
|
+
operation : 'create'
|
|
4215
|
+
});
|
|
4216
|
+
|
|
4217
|
+
if( org?.usage ){
|
|
4218
|
+
|
|
4219
|
+
writes.push({
|
|
4220
|
+
collection : 'usage',
|
|
4221
|
+
data : { $inc : { 'totals.redemptions' : 1 } },
|
|
4222
|
+
operation : 'update',
|
|
4223
|
+
query : { id : org.usage }
|
|
4224
|
+
});
|
|
4225
|
+
|
|
4226
|
+
}
|
|
4227
|
+
|
|
4228
|
+
if( leadId ){
|
|
4229
|
+
|
|
4230
|
+
writes.push({
|
|
4231
|
+
collection : 'lead',
|
|
4232
|
+
data : { $inc : { 'totals.redemptions' : 1 } },
|
|
4233
|
+
operation : 'update',
|
|
4234
|
+
options : { bypassDocumentValidation : true },
|
|
4235
|
+
query : { id : leadId }
|
|
4236
|
+
});
|
|
4237
|
+
|
|
4238
|
+
}
|
|
4239
|
+
|
|
4240
|
+
}
|
|
4241
|
+
|
|
4242
|
+
// SHOPIFY-BILLED ORGS ARE CHARGED THROUGH SHOPIFY, keyed on the order
|
|
4243
|
+
// id so a redelivery cannot charge twice. Never on a backfill: the fee
|
|
4244
|
+
// was charged when the order was first recorded. Enqueues run after
|
|
4245
|
+
// the transaction commits, so the job can never observe rows that
|
|
4246
|
+
// roll back.
|
|
4247
|
+
const enqueues = ( org?.billingProvider === 'shopify' && fee > 0 && connection?.source?.id && ! backfill )
|
|
4248
|
+
? [ {
|
|
4249
|
+
data : {
|
|
4250
|
+
idempotencyKey : String( orderId ),
|
|
4251
|
+
orderDocId,
|
|
4252
|
+
orderId : String( orderId ),
|
|
4253
|
+
rate,
|
|
4254
|
+
shopId : connection.source.id,
|
|
4255
|
+
// The App Events API returns no event id, so one is generated
|
|
4256
|
+
// here — the event handle plus the order id — and sent as the
|
|
4257
|
+
// event's `reference`. queue/usage.js stamps the same id onto
|
|
4258
|
+
// the order as billed.transaction.
|
|
4259
|
+
transaction : 'drawbridge-orders.' + orderId,
|
|
4260
|
+
value : Math.round( fee * 100 )
|
|
4261
|
+
},
|
|
4262
|
+
name : 'billing',
|
|
4263
|
+
options : { jobId : 'shopify.usage.' + orderId },
|
|
4264
|
+
queue : 'usage'
|
|
4265
|
+
} ]
|
|
4266
|
+
: [];
|
|
4267
|
+
|
|
4268
|
+
return {
|
|
4269
|
+
enqueues,
|
|
4270
|
+
message : backfill
|
|
4271
|
+
? 'Redemption backfilled for an already-recorded order.'
|
|
4272
|
+
: isConversion ? 'Order recorded.' : 'Discount redemption recorded (fee-free).',
|
|
4273
|
+
request,
|
|
4274
|
+
response : {
|
|
4275
|
+
campaign : orderCampaign,
|
|
4276
|
+
currency : currencyCode,
|
|
4277
|
+
discount,
|
|
4278
|
+
fee,
|
|
4279
|
+
gross,
|
|
4280
|
+
lead : leadId,
|
|
4281
|
+
lines : lines.length,
|
|
4282
|
+
net,
|
|
4283
|
+
orderId : String( orderId )
|
|
4284
|
+
},
|
|
4285
|
+
// ONE TRANSACTION. The order, the redemption and both totals
|
|
4286
|
+
// counters land together or not at all — a half-written attribution
|
|
4287
|
+
// is revenue counted twice or not at all, and neither is
|
|
4288
|
+
// recoverable by hand.
|
|
4289
|
+
transaction : writes.length > 0,
|
|
4290
|
+
writes
|
|
4291
|
+
};
|
|
4292
|
+
|
|
4293
|
+
},
|
|
4294
|
+
|
|
4295
|
+
// A PRODUCT CHANGED AT THE STORE. Upserts the product row and hands it to
|
|
4296
|
+
// the product pipeline; the actual field sync happens there.
|
|
4297
|
+
//
|
|
4298
|
+
// The shell has already refused a missing or inactive Shopify connection,
|
|
4299
|
+
// so what is left is the two things only this hook can know are wrong.
|
|
4300
|
+
product : async ( { connection, context, workflow }, { mintId } = {} ) => {
|
|
4301
|
+
|
|
4302
|
+
const request = {
|
|
4303
|
+
numericId : context?.id || null,
|
|
4304
|
+
organizationId : workflow.organization,
|
|
4305
|
+
title : context?.title || null
|
|
4306
|
+
};
|
|
4307
|
+
|
|
4308
|
+
if( ! context?.id ) return { message : 'Skipped — product webhook payload had no id.', request, response : { skipped : true }, skipped : true };
|
|
4309
|
+
|
|
4310
|
+
// The shop domain is half the identity below. Without it the upsert
|
|
4311
|
+
// would match on provider id alone and could collide across stores.
|
|
4312
|
+
if( ! connection.shop ) return { message : 'Skipped — Shopify connection is missing shop domain.', request, response : { skipped : true }, skipped : true };
|
|
4313
|
+
|
|
4314
|
+
const providerId = 'gid://shopify/Product/' + context.id;
|
|
4315
|
+
|
|
4316
|
+
// Minted so the enqueue can name the row this upsert makes — and the
|
|
4317
|
+
// job carries the PROVIDER identity too, because under a concurrent
|
|
4318
|
+
// redelivery this id may be the one that lost the upsert race. The
|
|
4319
|
+
// worker falls back to provider + shop, which are stable either way.
|
|
4320
|
+
const productId = mintId();
|
|
4321
|
+
|
|
4322
|
+
return {
|
|
4323
|
+
enqueues : [ {
|
|
4324
|
+
data : { product : productId, providerId, shop : connection.shop },
|
|
4325
|
+
name : 'workflow',
|
|
4326
|
+
options : { jobId : 'product.workflow.shopify.' + providerId + '.' + Date.now() },
|
|
4327
|
+
queue : 'product.shopify'
|
|
4328
|
+
} ],
|
|
4329
|
+
message : 'Product sync queued from Shopify webhook.',
|
|
4330
|
+
request,
|
|
4331
|
+
response : { productId, providerId, title : context?.title || null },
|
|
4332
|
+
// KEYED ON PROVIDER + SHOP, so the same product in two stores stays
|
|
4333
|
+
// two rows. `connections` accumulates rather than replaces: one
|
|
4334
|
+
// store can be linked to several organizations, and each keeps its
|
|
4335
|
+
// own claim on the row.
|
|
4336
|
+
writes : [ {
|
|
4337
|
+
collection : 'product',
|
|
4338
|
+
data : {
|
|
4339
|
+
$addToSet : { connections : connection.id },
|
|
4340
|
+
$setOnInsert : {
|
|
4341
|
+
id : productId,
|
|
4342
|
+
provider : { id : providerId, slug : 'shopify' },
|
|
4343
|
+
'source.id' : connection.id,
|
|
4344
|
+
status : 'active'
|
|
4345
|
+
}
|
|
4346
|
+
},
|
|
4347
|
+
operation : 'update',
|
|
4348
|
+
options : { upsert : true },
|
|
4349
|
+
query : {
|
|
4350
|
+
'provider.id' : providerId,
|
|
4351
|
+
'provider.slug' : 'shopify',
|
|
4352
|
+
'source.domain' : connection.shop
|
|
4353
|
+
}
|
|
4354
|
+
} ]
|
|
4355
|
+
};
|
|
4356
|
+
|
|
4357
|
+
}
|
|
4358
|
+
|
|
2408
4359
|
},
|
|
2409
4360
|
contacts : { remove : false, sync : false },
|
|
2410
4361
|
|
|
@@ -2423,8 +4374,22 @@ var shopify = {
|
|
|
2423
4374
|
sms : false,
|
|
2424
4375
|
inbound : {
|
|
2425
4376
|
event : ( args ) => readEventHeader({ ...args, descriptor : inbound }),
|
|
2426
|
-
|
|
2427
|
-
|
|
4377
|
+
// One hook over the whole topic table, because that is what this
|
|
4378
|
+
// manifest declares: Shopify processes its own buffered events. The
|
|
4379
|
+
// topic rides in on the context rather than being a second hook name per
|
|
4380
|
+
// topic; the caller's handler table arrives as a prop.
|
|
4381
|
+
process : async ( { context }, { dispatch } = {} ) => {
|
|
4382
|
+
|
|
4383
|
+
const key = 'shopify.' + context?.topic;
|
|
4384
|
+
|
|
4385
|
+
const handled = await dispatch({ data : context?.data, handler : key });
|
|
4386
|
+
|
|
4387
|
+
if( ! handled ) return { message : 'No handler for ' + key, skipped : true };
|
|
4388
|
+
|
|
4389
|
+
return { message : 'Processed ' + key, request : { topic : context?.topic } };
|
|
4390
|
+
|
|
4391
|
+
},
|
|
4392
|
+
receive : ( { channel, event, headers, payload } ) => {
|
|
2428
4393
|
|
|
2429
4394
|
if( channel === 'compliance' && ! COMPLIANCE_TOPICS.has( event ) ){
|
|
2430
4395
|
|
|
@@ -2446,7 +4411,173 @@ var shopify = {
|
|
|
2446
4411
|
},
|
|
2447
4412
|
verify : ( args ) => verifySignature({ ...args, descriptor : inbound })
|
|
2448
4413
|
},
|
|
2449
|
-
lifecycle : {
|
|
4414
|
+
lifecycle : {
|
|
4415
|
+
|
|
4416
|
+
// DISPATCHES INTO THE CALLER'S OWN COORDINATORS. These three are
|
|
4417
|
+
// declarations made true: the work is queue orchestration over
|
|
4418
|
+
// Drawbridge's own collections, which is coordinator work and stays in
|
|
4419
|
+
// the repo that owns the queues. The hook receives the dispatch table as
|
|
4420
|
+
// a prop and picks the entry, so the manifest owns the SEAM — asking
|
|
4421
|
+
// Shopify whether it handles its own lifecycle now gets a real function
|
|
4422
|
+
// instead of `unimplemented` while the work happened anyway.
|
|
4423
|
+
cleanup : async ( { context }, { dispatch } = {} ) => {
|
|
4424
|
+
|
|
4425
|
+
await dispatch({ data : context, handler : 'cleanup' });
|
|
4426
|
+
|
|
4427
|
+
return { message : 'Ran shopify lifecycle.cleanup', request : context || null };
|
|
4428
|
+
|
|
4429
|
+
},
|
|
4430
|
+
|
|
4431
|
+
// KEEP STORE ACCESS WORKING. Not a webhook monitor, despite the name the
|
|
4432
|
+
// step once carried — webhooks are declarative, declared in the app's
|
|
4433
|
+
// toml and applied by Shopify to every install, so nothing here registers
|
|
4434
|
+
// or checks them.
|
|
4435
|
+
//
|
|
4436
|
+
// It rotates the refresh token before its window closes, proves the
|
|
4437
|
+
// access token still works, reconciles the scopes the store granted
|
|
4438
|
+
// against the ones the app now needs, and queues a webhook
|
|
4439
|
+
// reconciliation.
|
|
4440
|
+
health : async ( { connection, workflow }, { adminToken, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {} ) => {
|
|
4441
|
+
|
|
4442
|
+
const request = {
|
|
4443
|
+
connectionId : workflow.connection,
|
|
4444
|
+
organizationId : workflow.organization,
|
|
4445
|
+
shop : connection.shop
|
|
4446
|
+
};
|
|
4447
|
+
|
|
4448
|
+
// Captured BEFORE anything runs. If the calls below fail with
|
|
4449
|
+
// invalid_grant, this is what distinguishes "the merchant revoked us"
|
|
4450
|
+
// from "a concurrent rotation spent the token we were holding" — and
|
|
4451
|
+
// only the first should error the connection.
|
|
4452
|
+
const refreshTokenAtStart = ( await resolveSettings() ).refreshToken || null;
|
|
4453
|
+
|
|
4454
|
+
try {
|
|
4455
|
+
|
|
4456
|
+
const adminAccessToken = await adminToken();
|
|
4457
|
+
|
|
4458
|
+
const settings = await resolveSettings();
|
|
4459
|
+
|
|
4460
|
+
const refreshTokenExpiresAt = settings.refreshTokenExpiresAt;
|
|
4461
|
+
|
|
4462
|
+
const needsRotation = refreshTokenExpiresAt
|
|
4463
|
+
&& new Date( refreshTokenExpiresAt ) < new Date( Date.now() + REFRESH_TOKEN_FRESHNESS_BUFFER_MS );
|
|
4464
|
+
|
|
4465
|
+
let refreshTokenRotated = false;
|
|
4466
|
+
|
|
4467
|
+
if( needsRotation ){
|
|
4468
|
+
|
|
4469
|
+
await rotateToken();
|
|
4470
|
+
|
|
4471
|
+
refreshTokenRotated = true;
|
|
4472
|
+
|
|
4473
|
+
}
|
|
4474
|
+
|
|
4475
|
+
await shopify.oauth.ping({ adminAccessToken, domain : connection.shop });
|
|
4476
|
+
|
|
4477
|
+
// SCOPE DRIFT IS ITS OWN FAILURE. A token can be perfectly valid
|
|
4478
|
+
// while the grant is too narrow, which no ping would ever reveal.
|
|
4479
|
+
// Injected rather than described: reconciliation is the caller's
|
|
4480
|
+
// writer-of-record routine, shared with the webhook drain, and this
|
|
4481
|
+
// hook needs its RESULT to compose the message below.
|
|
4482
|
+
const scopesMissing = await reconcileScopes({ shop : connection.shop });
|
|
4483
|
+
|
|
4484
|
+
return {
|
|
4485
|
+
enqueues : [ {
|
|
4486
|
+
data : {
|
|
4487
|
+
data : {
|
|
4488
|
+
connectionId : workflow.connection,
|
|
4489
|
+
organizationId : workflow.organization
|
|
4490
|
+
},
|
|
4491
|
+
event : 'shopify.register.webhooks'
|
|
4492
|
+
},
|
|
4493
|
+
name : 'register',
|
|
4494
|
+
options : { jobId : 'connection.update.register.' + workflow.connection + '.' + randomUUID() },
|
|
4495
|
+
queue : 'connection'
|
|
4496
|
+
} ],
|
|
4497
|
+
message : scopesMissing?.length
|
|
4498
|
+
? 'Health check: ping ok, webhooks reconciled — connection errored, granted scopes are missing: ' + scopesMissing.join( ', ' ) + '.'
|
|
4499
|
+
: refreshTokenRotated
|
|
4500
|
+
? 'Health check passed — refresh token rotated, ping ok, webhooks reconciled.'
|
|
4501
|
+
: 'Health check passed — ping ok, webhooks reconciled.',
|
|
4502
|
+
request,
|
|
4503
|
+
response : {
|
|
4504
|
+
pingedAt : new Date(),
|
|
4505
|
+
refreshTokenExpiresAt : refreshTokenExpiresAt || null,
|
|
4506
|
+
refreshTokenRotated,
|
|
4507
|
+
scopesMissing,
|
|
4508
|
+
webhookReconciliationQueued : true
|
|
4509
|
+
}
|
|
4510
|
+
};
|
|
4511
|
+
|
|
4512
|
+
} catch ( error ){
|
|
4513
|
+
|
|
4514
|
+
// A REVOKED GRANT IS THE MERCHANT'S TO FIX, so the connection says
|
|
4515
|
+
// so rather than failing silently on a schedule nobody watches. The
|
|
4516
|
+
// write rides OUT ON THE REJECTION — the contract performs a thrown
|
|
4517
|
+
// error's effects — because the step must still fail.
|
|
4518
|
+
if( OAUTH_GRANT_REVOKED_CODES.includes( error.code ) ){
|
|
4519
|
+
|
|
4520
|
+
const current = await read.get({ collection : 'connection', query : { id : connection.id } });
|
|
4521
|
+
|
|
4522
|
+
// Against the FRESH doc, not the one this run started with — a legacy
|
|
4523
|
+
// connection keeps its tokens in its own settings blob, and a
|
|
4524
|
+
// concurrent rotation rewrote that blob after our snapshot.
|
|
4525
|
+
const refreshTokenStored = current ? ( await resolveSettings( current ) ).refreshToken || null : null;
|
|
4526
|
+
|
|
4527
|
+
// A CONCURRENT ROTATION, not a revocation: another job spent the
|
|
4528
|
+
// refresh token between our read and our use of it. Erroring the
|
|
4529
|
+
// connection here would disconnect a store that is working fine.
|
|
4530
|
+
const rotated = error.code === 'invalid_grant' && refreshTokenStored !== refreshTokenAtStart;
|
|
4531
|
+
|
|
4532
|
+
if( current && ! rotated ){
|
|
4533
|
+
|
|
4534
|
+
const others = ( current.errors || [] ).filter( ( entry ) => entry.source !== OAUTH_ERROR_SOURCE );
|
|
4535
|
+
|
|
4536
|
+
error.writes = [ {
|
|
4537
|
+
collection : 'connection',
|
|
4538
|
+
data : {
|
|
4539
|
+
$set : {
|
|
4540
|
+
errors : [
|
|
4541
|
+
...others,
|
|
4542
|
+
{
|
|
4543
|
+
message : 'Shopify disconnected this store. Open the Drawbridge app in your Shopify admin to reconnect.',
|
|
4544
|
+
source : OAUTH_ERROR_SOURCE
|
|
4545
|
+
}
|
|
4546
|
+
],
|
|
4547
|
+
status : 'error'
|
|
4548
|
+
}
|
|
4549
|
+
},
|
|
4550
|
+
operation : 'update',
|
|
4551
|
+
query : { id : connection.id }
|
|
4552
|
+
} ];
|
|
4553
|
+
|
|
4554
|
+
}
|
|
4555
|
+
|
|
4556
|
+
}
|
|
4557
|
+
|
|
4558
|
+
throw error;
|
|
4559
|
+
|
|
4560
|
+
}
|
|
4561
|
+
|
|
4562
|
+
},
|
|
4563
|
+
|
|
4564
|
+
register : async ( { context }, { dispatch } = {} ) => {
|
|
4565
|
+
|
|
4566
|
+
await dispatch({ data : context, handler : 'register' });
|
|
4567
|
+
|
|
4568
|
+
return { message : 'Ran shopify lifecycle.register', request : context || null };
|
|
4569
|
+
|
|
4570
|
+
},
|
|
4571
|
+
|
|
4572
|
+
rehydrate : async ( { context }, { dispatch } = {} ) => {
|
|
4573
|
+
|
|
4574
|
+
await dispatch({ data : context, handler : 'rehydrate' });
|
|
4575
|
+
|
|
4576
|
+
return { message : 'Ran shopify lifecycle.rehydrate', request : context || null };
|
|
4577
|
+
|
|
4578
|
+
}
|
|
4579
|
+
|
|
4580
|
+
},
|
|
2450
4581
|
resources : {
|
|
2451
4582
|
audiences : false,
|
|
2452
4583
|
// Shopify has no separate price resource — a price belongs to a product
|
|
@@ -2467,7 +4598,7 @@ var shopify = {
|
|
|
2467
4598
|
// credential is the caller's job because it is Drawbridge's job: the
|
|
2468
4599
|
// admin token refreshes and writes itself back, which is service work,
|
|
2469
4600
|
// not vendor work.
|
|
2470
|
-
products : async ({ cursor, limit = 100, search, settings,
|
|
4601
|
+
products : async ( { cursor, limit = 100, search, settings, sort }, { shopify } = {} ) => {
|
|
2471
4602
|
|
|
2472
4603
|
const products = await shopify.storefront.getProducts({
|
|
2473
4604
|
cursor,
|
|
@@ -2490,7 +4621,7 @@ var shopify = {
|
|
|
2490
4621
|
|
|
2491
4622
|
},
|
|
2492
4623
|
|
|
2493
|
-
promotions : async ({ cursor, limit = 100, search, settings, shopify }) => {
|
|
4624
|
+
promotions : async ( { cursor, limit = 100, search, settings }, { shopify } = {} ) => {
|
|
2494
4625
|
|
|
2495
4626
|
const discounts = await shopify.admin.getDiscounts({
|
|
2496
4627
|
adminAccessToken : settings?.adminAccessToken,
|
|
@@ -2541,6 +4672,19 @@ var shopify = {
|
|
|
2541
4672
|
: undefined;
|
|
2542
4673
|
|
|
2543
4674
|
},
|
|
4675
|
+
// DRAWBRIDGE'S OWN CREDENTIALS for this vendor, as opposed to a merchant's —
|
|
4676
|
+
// what an admin types on the provider screen. The four names below are exactly
|
|
4677
|
+
// what `requires` gates on, which is the point of declaring them together: a
|
|
4678
|
+
// name required by the manifest and enterable nowhere is a vendor that can
|
|
4679
|
+
// never go live from the admin screen.
|
|
4680
|
+
provider : {
|
|
4681
|
+
fields : [
|
|
4682
|
+
{ input : 'text', key : 'apiKey', credential : 'SHOPIFY_API_KEY', label : 'API key', required : true },
|
|
4683
|
+
{ input : 'password', key : 'apiSecret', credential : 'SHOPIFY_API_SECRET', label : 'API secret', redact : true, required : true },
|
|
4684
|
+
{ input : 'text', key : 'appHandle', credential : 'SHOPIFY_APP_HANDLE', label : 'App handle', required : true },
|
|
4685
|
+
{ input : 'text', key : 'listingUrl', credential : 'SHOPIFY_APP_LISTING_URL', label : 'App listing URL', required : true }
|
|
4686
|
+
]
|
|
4687
|
+
},
|
|
2544
4688
|
// A pre-launch integration: it only surfaces once the App Store listing
|
|
2545
4689
|
// exists and the app is fully configured. Requiring all four means it can
|
|
2546
4690
|
// never render half-configured — and absence of any one excludes the
|
|
@@ -2574,9 +4718,6 @@ var shopify = {
|
|
|
2574
4718
|
// workflow document, and those strings cannot be renamed without a backfill.
|
|
2575
4719
|
//
|
|
2576
4720
|
// EVERY LEAF IS A FUNCTION so a step can read the merchant's own connection.
|
|
2577
|
-
// The bodies these point at still live in drawbridge-sync; moving them is the
|
|
2578
|
-
// next unit, and commerce.order.record is the one that decides whether the
|
|
2579
|
-
// shape holds — 569 lines and 15 controller calls.
|
|
2580
4721
|
steps : {
|
|
2581
4722
|
|
|
2582
4723
|
commerce : {
|
|
@@ -2785,7 +4926,7 @@ var webhook = {
|
|
|
2785
4926
|
// needs Drawbridge's own database, sockets or queues. This one does not.
|
|
2786
4927
|
webhook : {
|
|
2787
4928
|
|
|
2788
|
-
send : async ({ context,
|
|
4929
|
+
send : async ( { context, lead, settings, step }, { request : send = safeRequest } = {} ) => {
|
|
2789
4930
|
|
|
2790
4931
|
const { headers = {}, method = 'POST', url } = step.settings || {};
|
|
2791
4932
|
|
|
@@ -2794,11 +4935,9 @@ var webhook = {
|
|
|
2794
4935
|
if( ! url ) return { message : 'Outgoing webhook URL is not configured for this step.', request, response : { skipped : true }, skipped : true };
|
|
2795
4936
|
|
|
2796
4937
|
// The LEAD, when there is one, rather than the accumulated context. A
|
|
2797
|
-
// receiver wants the entrant's record, not our internal step state
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
: null;
|
|
2801
|
-
|
|
4938
|
+
// receiver wants the entrant's record, not our internal step state —
|
|
4939
|
+
// and the shell already resolved it, because every hook that names a
|
|
4940
|
+
// lead gets the document and its consent together.
|
|
2802
4941
|
const body = lead || context;
|
|
2803
4942
|
|
|
2804
4943
|
request.body = body;
|
|
@@ -2864,7 +5003,7 @@ var webhook = {
|
|
|
2864
5003
|
// pressing Connect will do; afterwards it states the verification the
|
|
2865
5004
|
// merchant's own endpoint has to perform, because a signed payload nobody
|
|
2866
5005
|
// checks is an unsigned payload.
|
|
2867
|
-
tasks : ({ settings }) => ( settings?.secret
|
|
5006
|
+
tasks : ( { settings } ) => ( settings?.secret
|
|
2868
5007
|
? [
|
|
2869
5008
|
{
|
|
2870
5009
|
message : 'Compute HMAC-SHA256( secret, body ) and compare against the X-Drawbridge-Signature header to confirm each payload.',
|
|
@@ -3030,6 +5169,39 @@ const build = ( manifest ) => {
|
|
|
3030
5169
|
|
|
3031
5170
|
}
|
|
3032
5171
|
|
|
5172
|
+
// DRAWBRIDGE'S OWN CREDENTIALS for this vendor, if it has any. A connection
|
|
5173
|
+
// with no third party behind it — webhook — declares no block at all, which is
|
|
5174
|
+
// what keeps it off the provider screen.
|
|
5175
|
+
//
|
|
5176
|
+
// Every field is checked harder than a merchant field: it must be editable
|
|
5177
|
+
// (an admin cannot type into a read-only descriptor, and a credential nobody
|
|
5178
|
+
// can enter is a vendor that can never go live), and a `password` MUST be
|
|
5179
|
+
// redacted. That last one is a SECURITY BOUNDARY rather than tidiness —
|
|
5180
|
+
// providerFields is what the api's redaction derives from, so a password field
|
|
5181
|
+
// that forgot `redact : true` is a platform secret handed back over the wire
|
|
5182
|
+
// to every admin screen that asks.
|
|
5183
|
+
for( const field of manifest.provider?.fields || [] ){
|
|
5184
|
+
|
|
5185
|
+
if( ! field?.key || ! field?.label ){
|
|
5186
|
+
|
|
5187
|
+
throw new Error( manifest.slug + ' declares a provider field with no key or label' );
|
|
5188
|
+
|
|
5189
|
+
}
|
|
5190
|
+
|
|
5191
|
+
if( ! INPUTS.includes( field.input ) ){
|
|
5192
|
+
|
|
5193
|
+
throw new Error( manifest.slug + '.provider.' + field.key + ' needs an input the admin form can render — one of ' + INPUTS.join( ', ' ) );
|
|
5194
|
+
|
|
5195
|
+
}
|
|
5196
|
+
|
|
5197
|
+
if( field.input === 'password' && ! field.redact ){
|
|
5198
|
+
|
|
5199
|
+
throw new Error( manifest.slug + '.provider.' + field.key + ' is a password and must declare redact : true — the api would hand the value back' );
|
|
5200
|
+
|
|
5201
|
+
}
|
|
5202
|
+
|
|
5203
|
+
}
|
|
5204
|
+
|
|
3033
5205
|
// THE ICON RIDES WITH THE MANIFEST, so a vendor cannot name an asset nobody
|
|
3034
5206
|
// added — which is what the old arrangement allowed, with the markup in one
|
|
3035
5207
|
// repo and the file in another.
|
|
@@ -3324,36 +5496,41 @@ const connections = Object.freeze({
|
|
|
3324
5496
|
webhook : build( webhook )
|
|
3325
5497
|
});
|
|
3326
5498
|
|
|
3327
|
-
// A
|
|
5499
|
+
// A STEP TYPE MAY BE SHARED, BUT NOT ITS QUEUE.
|
|
3328
5500
|
//
|
|
3329
|
-
//
|
|
3330
|
-
//
|
|
3331
|
-
//
|
|
3332
|
-
//
|
|
3333
|
-
//
|
|
3334
|
-
// two vendors declaring it would silently collapse into one entry in stepQueues
|
|
3335
|
-
// and one of them would route nowhere.
|
|
5501
|
+
// A step type belongs to the CAPABILITY rather than to whoever implements it —
|
|
5502
|
+
// that is why step.shopify.* became step.commerce.*, and why Klaviyo, Mailchimp
|
|
5503
|
+
// and Attentive all declare step.contacts.sync. Sharing the name is the intent,
|
|
5504
|
+
// and the vendor is carried on the step DOCUMENT (its connection), not in the
|
|
5505
|
+
// type string.
|
|
3336
5506
|
//
|
|
3337
|
-
//
|
|
3338
|
-
//
|
|
5507
|
+
// What must not be shared is the ROUTE. stepQueues is { type : queue }, so two
|
|
5508
|
+
// vendors declaring one type with different queues collapse into a single entry
|
|
5509
|
+
// and whichever loses is enqueued nowhere — a workflow that accepts the step and
|
|
5510
|
+
// silently never runs it. That is the real failure this guard was written for;
|
|
5511
|
+
// the ownership rule was a proxy for it that also refused the case it was
|
|
5512
|
+
// designed to anticipate.
|
|
3339
5513
|
( () => {
|
|
3340
5514
|
|
|
3341
|
-
const
|
|
5515
|
+
const routes = {};
|
|
3342
5516
|
|
|
3343
5517
|
for( const [ slug, manifest ] of Object.entries( connections ) ){
|
|
3344
5518
|
|
|
3345
|
-
for( const [ name ] of leaves( manifest.steps ) ){
|
|
5519
|
+
for( const [ name, step ] of leaves( manifest.steps ) ){
|
|
3346
5520
|
|
|
3347
5521
|
const type = 'step.' + name;
|
|
5522
|
+
const queue = step({})?.queue;
|
|
3348
5523
|
|
|
5524
|
+
if( routes[ type ] && routes[ type ].queue !== queue ){
|
|
3349
5525
|
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
5526
|
+
throw new Error(
|
|
5527
|
+
'Step ' + type + ' routes to ' + routes[ type ].queue + ' for ' + routes[ type ].slug
|
|
5528
|
+
+ ' and ' + queue + ' for ' + slug + ' — one of them would be enqueued nowhere'
|
|
5529
|
+
);
|
|
3353
5530
|
|
|
3354
5531
|
}
|
|
3355
5532
|
|
|
3356
|
-
|
|
5533
|
+
routes[ type ] = { queue, slug };
|
|
3357
5534
|
|
|
3358
5535
|
}
|
|
3359
5536
|
|
|
@@ -3361,10 +5538,13 @@ const connections = Object.freeze({
|
|
|
3361
5538
|
|
|
3362
5539
|
})();
|
|
3363
5540
|
|
|
3364
|
-
// The vendors whose
|
|
3365
|
-
// the
|
|
3366
|
-
//
|
|
3367
|
-
//
|
|
5541
|
+
// The vendors whose credentials are actually present in the map handed in —
|
|
5542
|
+
// which the callers build from the provider collection, with the deployment's
|
|
5543
|
+
// environment answering only for names no provider field owns
|
|
5544
|
+
// (ENCRYPT_CONNECTION_SECRET). Every consumer asks this the same way, so "is
|
|
5545
|
+
// Shopify available" has one answer rather than one per repo — the api once
|
|
5546
|
+
// gated it on four env vars and sync inferred it from a different signal, which
|
|
5547
|
+
// is how the two drifted.
|
|
3368
5548
|
const availableConnections = ( env = {} ) => Object.fromEntries(
|
|
3369
5549
|
Object.entries( connections ).filter(
|
|
3370
5550
|
( [ , manifest ] ) => manifest.requires.every( ( name ) => Boolean( env[ name ] ) )
|
|
@@ -3460,7 +5640,9 @@ const connectFields = ( slug ) => ( connections[ slug ]?.fields || [] )
|
|
|
3460
5640
|
// The four outcomes are the point. "unsupported", "unimplemented", "failed" and
|
|
3461
5641
|
// a result are different answers to why there is no data, and collapsing them is
|
|
3462
5642
|
// how "this vendor cannot do that" becomes indistinguishable from "it broke".
|
|
3463
|
-
|
|
5643
|
+
// TWO OBJECTS: props are facts of the run, options are services being passed
|
|
5644
|
+
// in. A caller with no services to inject omits the second bag.
|
|
5645
|
+
const runHook = async ( slug, name, props = {}, options = {} ) => {
|
|
3464
5646
|
|
|
3465
5647
|
const manifest = connections[ slug ];
|
|
3466
5648
|
|
|
@@ -3468,7 +5650,17 @@ const runHook = async ( slug, name, args = {} ) => {
|
|
|
3468
5650
|
|
|
3469
5651
|
// The hook's own value is the answer — there is no `supports` map to consult,
|
|
3470
5652
|
// and therefore none to disagree with what is actually here.
|
|
3471
|
-
|
|
5653
|
+
//
|
|
5654
|
+
// OWN PROPERTIES ONLY. A bare property walk resolves 'resources.constructor'
|
|
5655
|
+
// to Object.prototype.constructor — a truthy FUNCTION, so every guard on
|
|
5656
|
+
// "does the vendor implement this" passes and runHook calls Object() with
|
|
5657
|
+
// the props, answering the caller's own payload back as a result. With the
|
|
5658
|
+
// api's hook route caching answers in shared Redis, that payload holds a
|
|
5659
|
+
// live admin token. Same fix providerFields already carries.
|
|
5660
|
+
const hook = name.split( '.' ).reduce(
|
|
5661
|
+
( node, key ) => ( node && typeof node === 'object' && Object.hasOwn( node, key ) ) ? node[ key ] : undefined,
|
|
5662
|
+
manifest.hooks
|
|
5663
|
+
);
|
|
3472
5664
|
|
|
3473
5665
|
if( hook === false || hook == null ){
|
|
3474
5666
|
|
|
@@ -3492,7 +5684,16 @@ const runHook = async ( slug, name, args = {} ) => {
|
|
|
3492
5684
|
// disconnect until auth.oauth.urls gathered every vendor address in one
|
|
3493
5685
|
// place, and a hook that cannot see its own manifest would have forced it
|
|
3494
5686
|
// back. Callers never have to know to pass it.
|
|
3495
|
-
|
|
5687
|
+
const result = await hook({ ...props, manifest }, options );
|
|
5688
|
+
|
|
5689
|
+
// THE ANSWER IS CHECKED WHERE IT IS PRODUCED. A hook may describe writes,
|
|
5690
|
+
// enqueues and events for its caller to perform, and a malformed descriptor
|
|
5691
|
+
// is a write against the wrong collection — so it is refused here, at the one
|
|
5692
|
+
// call surface, rather than surviving as far as whoever performs it. A hook
|
|
5693
|
+
// that describes nothing (every resources.* read) validates to nothing.
|
|
5694
|
+
effectsOf( result );
|
|
5695
|
+
|
|
5696
|
+
return { outcome : OUTCOMES.answered, result };
|
|
3496
5697
|
|
|
3497
5698
|
} catch ( error ) {
|
|
3498
5699
|
|
|
@@ -3672,7 +5873,11 @@ const resolveConnection = ( item, data, env = {} ) => {
|
|
|
3672
5873
|
|
|
3673
5874
|
return Object.fromEntries(
|
|
3674
5875
|
Object.entries( item )
|
|
3675
|
-
|
|
5876
|
+
// `provider` is in the list for a different reason than the rest: it is
|
|
5877
|
+
// PLATFORM configuration — which credentials an admin types in for this
|
|
5878
|
+
// vendor — and no merchant-facing resolution has any business carrying
|
|
5879
|
+
// it, even as bare descriptors.
|
|
5880
|
+
.filter( ( [ key ] ) => ! [ 'auth', 'enabled', 'fields', 'hooks', 'inbound', 'provider', 'requires', 'steps', 'supports' ].includes( key ) )
|
|
3676
5881
|
.map( ( [ key, value ] ) => [
|
|
3677
5882
|
key,
|
|
3678
5883
|
( typeof value === 'function' ? value( data, env ) : value )
|
|
@@ -3681,4 +5886,4 @@ const resolveConnection = ( item, data, env = {} ) => {
|
|
|
3681
5886
|
|
|
3682
5887
|
};
|
|
3683
5888
|
|
|
3684
|
-
export { AUTH_TYPES, GROUPS, HOOKS, HOOK_NAMES, INPUTS, OAUTH_FIELDS, OUTCOMES, RETIRED, STATUSES, STEPS, STEP_TYPES, accessToken, authToken, availableConnections, build, catalogConnections, connectFields, connectionSteps, connections, hookSupport, isStale, mergeSettings, projectConnection, publicConnectionKeys, publicSettingsBySlug, redactSettings, resolveConnection, runHook, scopesMessage, stepLabels, stepQueues, stepRoutes, tokenSettings };
|
|
5889
|
+
export { AUTH_TYPES, GROUPS, HOOKS, HOOK_EFFECTS, HOOK_NAMES, HOOK_OPTIONS, HOOK_PROPS, INPUTS, OAUTH_FIELDS, OUTCOMES, RETIRED, STATUSES, STEPS, STEP_TYPES, WRITE_OPERATIONS, accessToken, authToken, availableConnections, build, catalogConnections, connectFields, connectionSteps, connections, effectsOf, hookSupport, isStale, mergeSettings, projectConnection, publicConnectionKeys, publicSettingsBySlug, redactSettings, resolveConnection, runHook, scopesMessage, stepLabels, stepQueues, stepRoutes, tokenSettings };
|