@drawbridge/drawbridge-utils 0.0.117 → 0.0.121
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 +1182 -75
- package/dist/connections/index.d.cts +1832 -95
- package/dist/connections/index.d.ts +1832 -95
- package/dist/connections/index.js +1175 -73
- package/dist/phone.d.cts +1 -1
- package/dist/phone.d.ts +1 -1
- package/dist/providers.cjs +4395 -0
- package/dist/providers.d.cts +326 -0
- package/dist/providers.d.ts +326 -0
- package/dist/providers.js +4350 -0
- 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 +6 -1
|
@@ -2,19 +2,22 @@ import { authToken } from './oauth.cjs';
|
|
|
2
2
|
export { consentUrl, pkcePair } from './oauth.cjs';
|
|
3
3
|
import { request } from '../http.cjs';
|
|
4
4
|
import { channels } from '../pricing.cjs';
|
|
5
|
-
import crypto, { createHmac, timingSafeEqual } from 'node:crypto';
|
|
5
|
+
import crypto, { createHmac, timingSafeEqual, randomUUID } from 'node:crypto';
|
|
6
|
+
import { customAlphabet } from 'nanoid';
|
|
7
|
+
import { toCanonicalEmail } from '../email.cjs';
|
|
8
|
+
import { toE164 } from '../phone.cjs';
|
|
9
|
+
import { conversionRate } from '../plans.cjs';
|
|
6
10
|
import { safeRequest } from '../safe-http.cjs';
|
|
7
|
-
import '../
|
|
11
|
+
import '../billing.cjs';
|
|
12
|
+
import '../transactions.cjs';
|
|
13
|
+
import '@drawbridge/drawbridge-telemetry';
|
|
14
|
+
import '../usage.cjs';
|
|
15
|
+
import 'libphonenumber-js';
|
|
8
16
|
import '../features.cjs';
|
|
9
17
|
import '../index.cjs';
|
|
10
18
|
import 'currency-codes';
|
|
11
|
-
import 'nanoid';
|
|
12
19
|
import '../color.cjs';
|
|
13
20
|
import 'tinycolor2';
|
|
14
|
-
import '../usage.cjs';
|
|
15
|
-
import '../billing.cjs';
|
|
16
|
-
import '../transactions.cjs';
|
|
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 (historically
|
|
614
|
+
// env var names, kept as the stable vocabulary)
|
|
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
|
|
@@ -562,8 +807,9 @@ var icon$4 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
|
|
|
562
807
|
//
|
|
563
808
|
// DORMANT UNTIL REGISTERED. `requires` names the client credentials that only
|
|
564
809
|
// 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
|
|
810
|
+
// set the redirect URL, Generate Credentials). Until an admin enters them on
|
|
811
|
+
// the provider screen, no deployment offers this connection — the manifest
|
|
812
|
+
// ships complete and inert.
|
|
567
813
|
//
|
|
568
814
|
// THREE THINGS TO VERIFY AT REGISTRATION, because the docs conflict or are
|
|
569
815
|
// silent, and only a live install answers them:
|
|
@@ -584,9 +830,12 @@ var icon$4 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
|
|
|
584
830
|
var attentive = {
|
|
585
831
|
auth : {
|
|
586
832
|
oauth : {
|
|
587
|
-
// NAMES of the
|
|
588
|
-
//
|
|
589
|
-
//
|
|
833
|
+
// NAMES of the credentials holding OUR app's client — keys into the map
|
|
834
|
+
// the provider collection answers, entered on the admin screen at
|
|
835
|
+
// registration, never before. (The names are the env vars they once
|
|
836
|
+
// were; the vocabulary stayed when the storage moved.) No `headers` on
|
|
837
|
+
// the client: Attentive takes credentials as form fields, which is the
|
|
838
|
+
// runner's default.
|
|
590
839
|
client : {
|
|
591
840
|
id : 'ATTENTIVE_OAUTH_CLIENT_ID',
|
|
592
841
|
secret : 'ATTENTIVE_OAUTH_CLIENT_SECRET'
|
|
@@ -696,7 +945,7 @@ var attentive = {
|
|
|
696
945
|
// show a picker quietly missing most of a real account. The response's
|
|
697
946
|
// only identifier is `externalId`, so an entry without one cannot be
|
|
698
947
|
// stored and is dropped.
|
|
699
|
-
audiences : async ({ cursor,
|
|
948
|
+
audiences : async ( { cursor, limit = 100, search, token }, { fetcher = fetch } = {} ) => {
|
|
700
949
|
|
|
701
950
|
const query = new URLSearchParams({
|
|
702
951
|
limit : String( Math.min( limit, 1000 ) ),
|
|
@@ -809,15 +1058,22 @@ var attentive = {
|
|
|
809
1058
|
|
|
810
1059
|
const HUBSPOT_BASE = 'https://api.hubapi.com';
|
|
811
1060
|
|
|
812
|
-
//
|
|
813
|
-
//
|
|
814
|
-
//
|
|
1061
|
+
// THE TOKEN IS THE CALLER'S TO PASS. It used to fall back to process.env; it
|
|
1062
|
+
// lives encrypted in the `provider` collection now (lib/providers.js).
|
|
1063
|
+
//
|
|
1064
|
+
// THROWS RATHER THAN DEGRADES, once a request is actually being made. Whether
|
|
1065
|
+
// the portal is configured at all is decided by the callers below — the token is
|
|
1066
|
+
// declared optional on purpose — but a path that got as far as here without one
|
|
1067
|
+
// would send `Bearer undefined` and read the 401 as HubSpot being down. The
|
|
1068
|
+
// absence has to be named where it happens.
|
|
815
1069
|
const hubspotRequest = ({ body, fetcher, method, path, query, token }) => {
|
|
816
1070
|
|
|
1071
|
+
if( ! token ) throw new Error( 'HubSpot access token missing — pass token (the drawbridge provider\'s hubspotToken)' );
|
|
1072
|
+
|
|
817
1073
|
return ( fetcher || request )({
|
|
818
1074
|
body,
|
|
819
1075
|
headers : {
|
|
820
|
-
'Authorization' : 'Bearer ' +
|
|
1076
|
+
'Authorization' : 'Bearer ' + token
|
|
821
1077
|
},
|
|
822
1078
|
method,
|
|
823
1079
|
query,
|
|
@@ -1012,13 +1268,16 @@ const contacts = {
|
|
|
1012
1268
|
// FORGET A CONTACT, by id or by email. Account deletion — the caller had
|
|
1013
1269
|
// to search then remove, which is one round trip it should not have to
|
|
1014
1270
|
// know about.
|
|
1015
|
-
remove : async ({ email,
|
|
1016
|
-
|
|
1017
|
-
const key = token || process.env.HUBSPOT_ACCESS_TOKEN;
|
|
1271
|
+
remove : async ( { email, id, token }, { fetcher } = {} ) => {
|
|
1018
1272
|
|
|
1019
|
-
|
|
1273
|
+
// NO TOKEN IS A NO-OP HERE, unlike sendgrid and twilio, and the
|
|
1274
|
+
// difference is what the credential is declared to be: hubspotToken
|
|
1275
|
+
// is the one provider field that is not `required`, because this is
|
|
1276
|
+
// internal CRM tooling no merchant sees. A deployment with no portal
|
|
1277
|
+
// is a supported state, not a missing credential.
|
|
1278
|
+
if( ! token ) return;
|
|
1020
1279
|
|
|
1021
|
-
const contact = id || await lookup({ email, fetcher, token
|
|
1280
|
+
const contact = id || await lookup({ email, fetcher, token });
|
|
1022
1281
|
|
|
1023
1282
|
if( ! contact ) return;
|
|
1024
1283
|
|
|
@@ -1026,7 +1285,7 @@ const contacts = {
|
|
|
1026
1285
|
fetcher,
|
|
1027
1286
|
method : 'DELETE',
|
|
1028
1287
|
path : '/crm/v3/objects/contacts/' + contact,
|
|
1029
|
-
token
|
|
1288
|
+
token
|
|
1030
1289
|
});
|
|
1031
1290
|
|
|
1032
1291
|
},
|
|
@@ -1037,19 +1296,18 @@ const contacts = {
|
|
|
1037
1296
|
// no delete-old-then-create-new.
|
|
1038
1297
|
//
|
|
1039
1298
|
// 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;
|
|
1299
|
+
sync : async ( { doc, token }, { fetcher } = {} ) => {
|
|
1043
1300
|
|
|
1044
|
-
// NO TOKEN IS A NO-OP, not an error
|
|
1045
|
-
// configured must not crash the user
|
|
1046
|
-
|
|
1301
|
+
// NO TOKEN IS A NO-OP, not an error — see remove() above. A
|
|
1302
|
+
// deployment without a portal configured must not crash the user
|
|
1303
|
+
// stream over internal tooling.
|
|
1304
|
+
if( ! token ) return;
|
|
1047
1305
|
|
|
1048
1306
|
if( doc?.hubspotId ){
|
|
1049
1307
|
|
|
1050
1308
|
try {
|
|
1051
1309
|
|
|
1052
|
-
return ( await send({ doc, fetcher, method : 'PATCH', path : '/crm/v3/objects/contacts/' + doc.hubspotId, token
|
|
1310
|
+
return ( await send({ doc, fetcher, method : 'PATCH', path : '/crm/v3/objects/contacts/' + doc.hubspotId, token }) )?.id;
|
|
1053
1311
|
|
|
1054
1312
|
} catch ( error ){
|
|
1055
1313
|
|
|
@@ -1061,14 +1319,14 @@ const contacts = {
|
|
|
1061
1319
|
|
|
1062
1320
|
}
|
|
1063
1321
|
|
|
1064
|
-
const existing = await lookup({ email : doc?.email, fetcher, token
|
|
1322
|
+
const existing = await lookup({ email : doc?.email, fetcher, token });
|
|
1065
1323
|
|
|
1066
1324
|
return ( await send({
|
|
1067
1325
|
doc,
|
|
1068
1326
|
fetcher,
|
|
1069
1327
|
method : existing ? 'PATCH' : 'POST',
|
|
1070
1328
|
path : existing ? '/crm/v3/objects/contacts/' + existing : '/crm/v3/objects/contacts',
|
|
1071
|
-
token
|
|
1329
|
+
token
|
|
1072
1330
|
}) )?.id;
|
|
1073
1331
|
|
|
1074
1332
|
}
|
|
@@ -1110,6 +1368,82 @@ var icon$3 = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xm
|
|
|
1110
1368
|
// answers "can this merchant connect it" through `requires` and a plan feature.
|
|
1111
1369
|
// A private one is always present, in every deployment, for every organization —
|
|
1112
1370
|
// so it declares neither, and build() knows not to ask.
|
|
1371
|
+
|
|
1372
|
+
// Replace {{key}} placeholders with values from the run's accumulated context.
|
|
1373
|
+
// An unresolved key is left as it was written rather than blanked: a merchant
|
|
1374
|
+
// reading "Hello {{name}}" knows their template is wrong, where "Hello " reads
|
|
1375
|
+
// like a person with no name.
|
|
1376
|
+
//
|
|
1377
|
+
// Moved here from drawbridge-sync's lib/step.js with the bodies that were its
|
|
1378
|
+
// only callers. It is a string function — nothing about it needed a service.
|
|
1379
|
+
const interpolate = ( template, data ) => {
|
|
1380
|
+
|
|
1381
|
+
if( ! template ) return template;
|
|
1382
|
+
|
|
1383
|
+
return template.replace( /\{\{(\w+)\}\}/g, ( _, key ) => ( data?.[ key ] != null ? String( data[ key ] ) : '{{' + key + '}}' ) );
|
|
1384
|
+
|
|
1385
|
+
};
|
|
1386
|
+
|
|
1387
|
+
// WHO ON THE TEAM GETS TOLD. Shared by email.notify and email.digest, which
|
|
1388
|
+
// resolved it identically — the same forty lines twice, and the dedupe rule is
|
|
1389
|
+
// subtle enough that two copies would eventually differ.
|
|
1390
|
+
//
|
|
1391
|
+
// The OWNER IS ALWAYS A RECIPIENT. `settings.members` is an optional list of
|
|
1392
|
+
// ADDITIONAL people: the members endpoint is owner-gated and the owner is not a
|
|
1393
|
+
// `member` document, so a solo merchant has nothing selectable and could
|
|
1394
|
+
// otherwise not use these steps at all.
|
|
1395
|
+
const teamRecipients = async ({ memberIds = [], organization, read }) => {
|
|
1396
|
+
|
|
1397
|
+
const org = await read.get({ collection : 'organization', query : { id : organization } });
|
|
1398
|
+
|
|
1399
|
+
const owner = org?.owner
|
|
1400
|
+
? await read.get({ collection : 'user', query : { id : org.owner } })
|
|
1401
|
+
: null;
|
|
1402
|
+
|
|
1403
|
+
// Re-scoped to the org and to ACCEPTED members: the id list is stored on the
|
|
1404
|
+
// step and outlives the membership it names — someone who declined, was
|
|
1405
|
+
// removed, or never accepted.
|
|
1406
|
+
const members = memberIds.length
|
|
1407
|
+
? await read.aggregate({
|
|
1408
|
+
collection : 'member',
|
|
1409
|
+
pipeline : [
|
|
1410
|
+
{
|
|
1411
|
+
$match : {
|
|
1412
|
+
id : { $in : memberIds },
|
|
1413
|
+
organization,
|
|
1414
|
+
status : 'accepted'
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
]
|
|
1418
|
+
})
|
|
1419
|
+
: [];
|
|
1420
|
+
|
|
1421
|
+
// Deduped by address, lower-cased and NO FURTHER. The owner is commonly also
|
|
1422
|
+
// named in settings.members and nobody should get two copies. Canonicalizing
|
|
1423
|
+
// (toCanonicalEmail) would be wrong: it strips +tags, so two genuinely
|
|
1424
|
+
// different teammates collapse into one and the second is never told.
|
|
1425
|
+
const seen = new Set();
|
|
1426
|
+
|
|
1427
|
+
return [ owner, ...members ].filter( ( member ) => {
|
|
1428
|
+
|
|
1429
|
+
if( ! member?.id || ! member?.email ) return false;
|
|
1430
|
+
|
|
1431
|
+
const address = member.email.toLowerCase();
|
|
1432
|
+
|
|
1433
|
+
if( seen.has( address ) ) return false;
|
|
1434
|
+
|
|
1435
|
+
seen.add( address );
|
|
1436
|
+
|
|
1437
|
+
return true;
|
|
1438
|
+
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
};
|
|
1442
|
+
|
|
1443
|
+
// ONE NOTIFICATION, DESCRIBED. queue/notification.js owns delivery, the
|
|
1444
|
+
// unsubscribe token and the CAN-SPAM footer — these steps only say who and what.
|
|
1445
|
+
const queueNotification = ( data ) => ({ collection : 'notification', data, operation : 'create' });
|
|
1446
|
+
|
|
1113
1447
|
var drawbridge = {
|
|
1114
1448
|
auth : {
|
|
1115
1449
|
type : 'none'
|
|
@@ -1127,10 +1461,15 @@ var drawbridge = {
|
|
|
1127
1461
|
exclusive : false,
|
|
1128
1462
|
fields : [],
|
|
1129
1463
|
group : 'developer',
|
|
1130
|
-
//
|
|
1131
|
-
//
|
|
1132
|
-
//
|
|
1133
|
-
//
|
|
1464
|
+
// THE BODIES LIVE HERE, beside the declarations that name them. They used to
|
|
1465
|
+
// live in drawbridge-sync because they touch the database, the queues and the
|
|
1466
|
+
// sockets — and a published package cannot carry a controller.
|
|
1467
|
+
//
|
|
1468
|
+
// It does not have to. A hook is a function, so everything it needs is PASSED
|
|
1469
|
+
// IN: `read` for the reads, `canSend` for the opt-out floor, `resolveContact`
|
|
1470
|
+
// for the one write whose RESULT the hook has to count. Everything else a hook
|
|
1471
|
+
// wants done it DESCRIBES — `writes`, `enqueues`, `events` — and the shell
|
|
1472
|
+
// performs it. See lib/connections/contract.js for that shape.
|
|
1134
1473
|
hooks : {
|
|
1135
1474
|
auth : {
|
|
1136
1475
|
// Nothing to connect, revoke, probe or re-scope.
|
|
@@ -1151,13 +1490,221 @@ var drawbridge = {
|
|
|
1151
1490
|
// accounts DRAWBRIDGE holds rather than ones a merchant connects.
|
|
1152
1491
|
contacts,
|
|
1153
1492
|
email : {
|
|
1154
|
-
|
|
1155
|
-
//
|
|
1156
|
-
//
|
|
1157
|
-
//
|
|
1158
|
-
|
|
1159
|
-
//
|
|
1160
|
-
|
|
1493
|
+
|
|
1494
|
+
// A PERIODIC SUMMARY to the team, on a schedule trigger rather than per
|
|
1495
|
+
// lead.
|
|
1496
|
+
//
|
|
1497
|
+
// The count is the point: `email.notify` tells the owner one lead arrived
|
|
1498
|
+
// and dampens a spike to one message per bucket, which is deliberately not
|
|
1499
|
+
// a count. This is where "you got 43 entries this week" comes from.
|
|
1500
|
+
digest : async ( { context, step, workflow }, { read } = {} ) => {
|
|
1501
|
+
|
|
1502
|
+
// The window comes from the TRIGGER that fired it, so Daily/Weekly/
|
|
1503
|
+
// Monthly each summarise their own period without a second setting to
|
|
1504
|
+
// keep in step.
|
|
1505
|
+
const days = { day : 1, month : 30, week : 7 }[ workflow?.trigger?.event ] || 7;
|
|
1506
|
+
|
|
1507
|
+
const since = new Date( Date.now() - ( days * 24 * 60 * 60 * 1000 ) );
|
|
1508
|
+
const campaign = workflow?.trigger?.filters?.campaign || null;
|
|
1509
|
+
|
|
1510
|
+
const [ counted ] = await read.aggregate({
|
|
1511
|
+
collection : 'lead',
|
|
1512
|
+
pipeline : [
|
|
1513
|
+
{
|
|
1514
|
+
$match : {
|
|
1515
|
+
createdAt : { $gte : since },
|
|
1516
|
+
organization : workflow.organization,
|
|
1517
|
+
...( campaign && { campaigns : { $in : [ campaign ] } })
|
|
1518
|
+
}
|
|
1519
|
+
},
|
|
1520
|
+
{ $count : 'count' }
|
|
1521
|
+
]
|
|
1522
|
+
});
|
|
1523
|
+
|
|
1524
|
+
const count = Number( counted?.count || 0 );
|
|
1525
|
+
|
|
1526
|
+
const request = { campaign, count, days };
|
|
1527
|
+
|
|
1528
|
+
// Nothing happened, so nobody is told. A digest reading "0 new leads" is
|
|
1529
|
+
// mail the merchant did not ask for and would learn to ignore.
|
|
1530
|
+
if( ! count ) return { message : 'No new leads in the period — digest skipped.', request, response : { skipped : true }, skipped : true };
|
|
1531
|
+
|
|
1532
|
+
const recipients = await teamRecipients({
|
|
1533
|
+
memberIds : step.settings?.members || [],
|
|
1534
|
+
organization : workflow.organization,
|
|
1535
|
+
read
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
// `count` joins the interpolation values so a merchant can write
|
|
1539
|
+
// "{{count}} new entries this week" in the step's own copy.
|
|
1540
|
+
const values = { ...context, count };
|
|
1541
|
+
|
|
1542
|
+
return {
|
|
1543
|
+
message : 'Digest of ' + count + ' new lead(s) queued for ' + recipients.length + ' recipient(s).',
|
|
1544
|
+
request,
|
|
1545
|
+
response : { count, notified : recipients.length },
|
|
1546
|
+
writes : recipients.map( ( member ) => queueNotification({
|
|
1547
|
+
audience : 'member',
|
|
1548
|
+
message : interpolate( step.settings?.message, values ),
|
|
1549
|
+
organization : workflow.organization,
|
|
1550
|
+
send : { type : 'email', email : member.email },
|
|
1551
|
+
title : interpolate( step.settings?.subject, values ),
|
|
1552
|
+
workflow : workflow.id
|
|
1553
|
+
}) )
|
|
1554
|
+
};
|
|
1555
|
+
|
|
1556
|
+
},
|
|
1557
|
+
|
|
1558
|
+
// To the organization's OWN PEOPLE. Never suppressed, never
|
|
1559
|
+
// subscription-gated, no unsubscribe footer — telling an org's staff about
|
|
1560
|
+
// their own leads is not commercial mail to a stranger.
|
|
1561
|
+
//
|
|
1562
|
+
// FREE, permanently. The lead that triggered this run already consumed the
|
|
1563
|
+
// billable action, and `members` is a list — billing here would turn one
|
|
1564
|
+
// lead into five more charges and the org would be paying to read its own
|
|
1565
|
+
// mail. The declaration prices it at zero; the shell bills nothing for
|
|
1566
|
+
// zero.
|
|
1567
|
+
notify : async ( { context, step, workflow }, { read } = {} ) => {
|
|
1568
|
+
|
|
1569
|
+
const memberIds = step.settings?.members || [];
|
|
1570
|
+
|
|
1571
|
+
const request = { members : memberIds.length };
|
|
1572
|
+
|
|
1573
|
+
const recipients = await teamRecipients({ memberIds, organization : workflow.organization, read });
|
|
1574
|
+
|
|
1575
|
+
if( ! recipients.length ){
|
|
1576
|
+
|
|
1577
|
+
return {
|
|
1578
|
+
message : 'No owner or accepted member with an email address — team notification skipped.',
|
|
1579
|
+
request,
|
|
1580
|
+
response : { skipped : true },
|
|
1581
|
+
skipped : true
|
|
1582
|
+
};
|
|
1583
|
+
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
// SPIKE DAMPER, as a fixed window rather than a sliding one.
|
|
1587
|
+
//
|
|
1588
|
+
// This used `coalesce`, which is supersede-and-delay: every new entry
|
|
1589
|
+
// pre-empted the pending job and re-enqueued it 30 seconds out. Under a
|
|
1590
|
+
// genuine spike — entries arriving faster than one per 30s, which is
|
|
1591
|
+
// what viral means — delivery deferred indefinitely and the owner heard
|
|
1592
|
+
// NOTHING until traffic dropped, precisely when they would most want to
|
|
1593
|
+
// know.
|
|
1594
|
+
//
|
|
1595
|
+
// `key` is first-write-wins against a partial unique index: the first
|
|
1596
|
+
// entry in each bucket creates the notification and it goes out
|
|
1597
|
+
// immediately, the rest collide and are refused. A spike yields at most
|
|
1598
|
+
// one heads-up per recipient per bucket, promptly, instead of one
|
|
1599
|
+
// eventually or never.
|
|
1600
|
+
//
|
|
1601
|
+
// Deliberately NOT a change to `coalesce` itself — stream/transaction.js
|
|
1602
|
+
// uses it for balance and credit notices, and its semantics are not ours
|
|
1603
|
+
// to redefine from here.
|
|
1604
|
+
const bucket = Math.floor( Date.now() / ( 15 * 60 * 1000 ) );
|
|
1605
|
+
|
|
1606
|
+
return {
|
|
1607
|
+
message : 'Team notification queued for ' + recipients.length + ' recipient(s).',
|
|
1608
|
+
request,
|
|
1609
|
+
response : { notified : recipients.length },
|
|
1610
|
+
writes : recipients.map( ( member ) => ({
|
|
1611
|
+
...queueNotification({
|
|
1612
|
+
audience : 'member',
|
|
1613
|
+
// Per workflow, recipient AND bucket, so one recipient's damper
|
|
1614
|
+
// can never swallow another's mail and a later bucket is never
|
|
1615
|
+
// mistaken for a duplicate of an earlier one.
|
|
1616
|
+
key : 'team.notify.' + workflow.id + '.' + member.id + '.' + bucket,
|
|
1617
|
+
message : interpolate( step.settings?.message, context ),
|
|
1618
|
+
organization : workflow.organization,
|
|
1619
|
+
send : { type : 'email', email : member.email },
|
|
1620
|
+
title : interpolate( step.settings?.subject, context ),
|
|
1621
|
+
workflow : workflow.id
|
|
1622
|
+
}),
|
|
1623
|
+
// E11000 IS THE DAMPER WORKING: this recipient has already been
|
|
1624
|
+
// told within the bucket. Declared per write rather than assumed by
|
|
1625
|
+
// the shell, because on every other write here a duplicate key is a
|
|
1626
|
+
// real failure.
|
|
1627
|
+
ignoreDuplicate : true
|
|
1628
|
+
}) )
|
|
1629
|
+
};
|
|
1630
|
+
|
|
1631
|
+
},
|
|
1632
|
+
|
|
1633
|
+
// Drawbridge sends lead-facing email itself — no merchant provider gates
|
|
1634
|
+
// it.
|
|
1635
|
+
//
|
|
1636
|
+
// This QUEUES rather than sends: queue/notification.js owns delivery, the
|
|
1637
|
+
// unsubscribe token and the CAN-SPAM footer. The step's job is to say who
|
|
1638
|
+
// and what, correctly, and to refuse early when it must not send at all.
|
|
1639
|
+
send : async ( { context, step, workflow }, { canSend, read } = {} ) => {
|
|
1640
|
+
|
|
1641
|
+
const to = context?.email;
|
|
1642
|
+
|
|
1643
|
+
if( ! to ) throw new Error( 'No email address on context (context.email is required)' );
|
|
1644
|
+
|
|
1645
|
+
const request = { to };
|
|
1646
|
+
|
|
1647
|
+
// PRE-CHECKED HERE so an opted-out recipient skips WITHOUT billing. The
|
|
1648
|
+
// send() gate in queue/notification.js would cancel the doc anyway, but
|
|
1649
|
+
// only after the action had already counted.
|
|
1650
|
+
//
|
|
1651
|
+
// `canSend` is INJECTED rather than read through `read`: suppression is
|
|
1652
|
+
// the only consent source and canSend is its only reader, so a hook
|
|
1653
|
+
// querying the collection itself would be a second reader of the
|
|
1654
|
+
// opt-out floor — and the second one is the one that gets the query
|
|
1655
|
+
// wrong.
|
|
1656
|
+
const { ok : sendable } = await canSend({ channel : 'email', to });
|
|
1657
|
+
|
|
1658
|
+
if( ! sendable ) return { message : 'Recipient has opted out — skipped.', request, response : { skipped : true }, skipped : true };
|
|
1659
|
+
|
|
1660
|
+
// A LIVE SUBSCRIPTION, in two reads. Free organizations get system mail
|
|
1661
|
+
// only; a lead-facing send is a paid feature.
|
|
1662
|
+
const organization = await read.get({ collection : 'organization', query : { id : workflow.organization } });
|
|
1663
|
+
|
|
1664
|
+
const subscription = organization?.subscription
|
|
1665
|
+
? await read.get({ collection : 'subscription', query : { id : organization.subscription } })
|
|
1666
|
+
: null;
|
|
1667
|
+
|
|
1668
|
+
if( subscription?.status !== 'active' ){
|
|
1669
|
+
|
|
1670
|
+
return {
|
|
1671
|
+
message : 'Organization has no active subscription — workflow-step email skipped.',
|
|
1672
|
+
request,
|
|
1673
|
+
response : { skipped : true },
|
|
1674
|
+
skipped : true
|
|
1675
|
+
};
|
|
1676
|
+
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
return {
|
|
1680
|
+
message : 'Email queued for delivery to ' + to + '.',
|
|
1681
|
+
request,
|
|
1682
|
+
response : { queued : true },
|
|
1683
|
+
// NO `connection` FIELD, deliberately: the platform sends this.
|
|
1684
|
+
// `audience : 'lead'` states what the queue would otherwise infer from
|
|
1685
|
+
// shape.
|
|
1686
|
+
//
|
|
1687
|
+
// `campaign` is not decoration. queue/notification.js mints the
|
|
1688
|
+
// unsubscribe token with it, so it decides whether opting out is
|
|
1689
|
+
// scoped to this campaign or the whole organization, and it names the
|
|
1690
|
+
// campaign in the footer. Sending without it silently broadens every
|
|
1691
|
+
// opt-out to the entire organization.
|
|
1692
|
+
writes : [
|
|
1693
|
+
queueNotification({
|
|
1694
|
+
audience : 'lead',
|
|
1695
|
+
campaign : context?.campaign || null,
|
|
1696
|
+
lead : context?.lead || null,
|
|
1697
|
+
message : interpolate( step.settings?.message, context ),
|
|
1698
|
+
organization : workflow.organization,
|
|
1699
|
+
send : { type : 'email', email : to },
|
|
1700
|
+
title : interpolate( step.settings?.subject, context ),
|
|
1701
|
+
workflow : workflow.id
|
|
1702
|
+
})
|
|
1703
|
+
]
|
|
1704
|
+
};
|
|
1705
|
+
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1161
1708
|
},
|
|
1162
1709
|
inbound : false,
|
|
1163
1710
|
lifecycle : false,
|
|
@@ -1167,8 +1714,264 @@ var drawbridge = {
|
|
|
1167
1714
|
products : false,
|
|
1168
1715
|
promotions : false
|
|
1169
1716
|
},
|
|
1170
|
-
segment : {
|
|
1171
|
-
|
|
1717
|
+
segment : {
|
|
1718
|
+
|
|
1719
|
+
// RECALCULATE SEGMENT MEMBERSHIP. The one FAN-OUT hook: it evaluates every
|
|
1720
|
+
// contact in an organization against every segment, which is too much for
|
|
1721
|
+
// one job, so it returns chunks and the shell defers completion.
|
|
1722
|
+
//
|
|
1723
|
+
// Returning `chunks` is the only thing that makes it different. The
|
|
1724
|
+
// declaration, the guards, the step document and the price are the shell's,
|
|
1725
|
+
// exactly as they are for a step that finishes in one go.
|
|
1726
|
+
sync : async ( { context, step }, { chunkSize, logger, read, resolveContact } = {} ) => {
|
|
1727
|
+
|
|
1728
|
+
// The fan-out width is the WORKER FLEET'S number, not the vendor's — how
|
|
1729
|
+
// much work one job may carry is a fact about the machines running it.
|
|
1730
|
+
// Required rather than defaulted, because a default here would silently
|
|
1731
|
+
// disagree with the deployment's own tuning and nothing would say so.
|
|
1732
|
+
if( ! chunkSize ) throw new Error( 'segment.sync needs chunkSize from the shell' );
|
|
1733
|
+
|
|
1734
|
+
const organization = context?.organization;
|
|
1735
|
+
|
|
1736
|
+
const configured = step?.settings?.segment;
|
|
1737
|
+
|
|
1738
|
+
const request = { organization : organization || null, segmentId : configured || null };
|
|
1739
|
+
|
|
1740
|
+
// A segment sitting at `syncing` with nothing running is a spinner that
|
|
1741
|
+
// never stops, so every exit below puts it back — DESCRIBED, and
|
|
1742
|
+
// performed by the shell. Including the throwing exit: the effects ride
|
|
1743
|
+
// out on the error, which is the only way a rejection can still release
|
|
1744
|
+
// what it locked.
|
|
1745
|
+
const release = ( ids, status = 'active' ) => {
|
|
1746
|
+
|
|
1747
|
+
const released = ( ids || [] ).filter( Boolean );
|
|
1748
|
+
|
|
1749
|
+
return {
|
|
1750
|
+
events : organization
|
|
1751
|
+
? released.map( ( id ) => ({
|
|
1752
|
+
event : 'organization.segments',
|
|
1753
|
+
payload : { id, status },
|
|
1754
|
+
room : 'organization.' + organization
|
|
1755
|
+
}) )
|
|
1756
|
+
: [],
|
|
1757
|
+
writes : released.map( ( id ) => ({
|
|
1758
|
+
collection : 'segment',
|
|
1759
|
+
data : { $set : { status } },
|
|
1760
|
+
operation : 'update',
|
|
1761
|
+
query : { id }
|
|
1762
|
+
}) )
|
|
1763
|
+
};
|
|
1764
|
+
|
|
1765
|
+
};
|
|
1766
|
+
|
|
1767
|
+
if( ! organization ){
|
|
1768
|
+
|
|
1769
|
+
return {
|
|
1770
|
+
...release([ configured ]),
|
|
1771
|
+
message : 'Trigger data missing organization id — cannot sync segments.',
|
|
1772
|
+
request,
|
|
1773
|
+
response : { skipped : true },
|
|
1774
|
+
skipped : true
|
|
1775
|
+
};
|
|
1776
|
+
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
const segments = await read.aggregate({
|
|
1780
|
+
collection : 'segment',
|
|
1781
|
+
pipeline : [ { $match : configured ? { id : configured, organization } : { organization } } ]
|
|
1782
|
+
});
|
|
1783
|
+
|
|
1784
|
+
if( ! segments.length ){
|
|
1785
|
+
|
|
1786
|
+
return {
|
|
1787
|
+
...release([ configured ]),
|
|
1788
|
+
message : 'No segments matched the request — nothing to sync.',
|
|
1789
|
+
request,
|
|
1790
|
+
response : { skipped : true },
|
|
1791
|
+
skipped : true
|
|
1792
|
+
};
|
|
1793
|
+
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
const segmentIds = segments.map( ( entry ) => entry.id );
|
|
1797
|
+
|
|
1798
|
+
try {
|
|
1799
|
+
|
|
1800
|
+
let backfilled = 0;
|
|
1801
|
+
|
|
1802
|
+
// A SYSTEM SEGMENT evaluates contacts, so any lead without one has to
|
|
1803
|
+
// get one first or it can never be a member of anything.
|
|
1804
|
+
if( segments.some( ( entry ) => entry.system ) ){
|
|
1805
|
+
|
|
1806
|
+
const contacted = await read.aggregate({
|
|
1807
|
+
collection : 'contact',
|
|
1808
|
+
pipeline : [
|
|
1809
|
+
{ $match : { organization } },
|
|
1810
|
+
{ $project : { _id : 0, leads : 1 } },
|
|
1811
|
+
{ $unwind : '$leads' },
|
|
1812
|
+
{ $group : { _id : null, ids : { $addToSet : '$leads' } } }
|
|
1813
|
+
]
|
|
1814
|
+
});
|
|
1815
|
+
|
|
1816
|
+
const uncontacted = await read.aggregate({
|
|
1817
|
+
collection : 'lead',
|
|
1818
|
+
pipeline : [
|
|
1819
|
+
{ $match : { id : { $nin : contacted[ 0 ]?.ids || [] }, organization } },
|
|
1820
|
+
{ $project : { _id : 0, id : 1 } }
|
|
1821
|
+
]
|
|
1822
|
+
});
|
|
1823
|
+
|
|
1824
|
+
// THE ONE WRITE THIS HOOK CANNOT DESCRIBE, and the reason it is
|
|
1825
|
+
// injected instead. Its RESULT is an input to what the hook returns:
|
|
1826
|
+
// how many contacts this run had to create decides whether the
|
|
1827
|
+
// chunks bill, and a description cannot be counted before it runs.
|
|
1828
|
+
//
|
|
1829
|
+
// So the caller supplies the collaborator — the same shape as the
|
|
1830
|
+
// `shopify` SDK injection elsewhere in this directory — and the
|
|
1831
|
+
// hook still reaches for no controller of its own.
|
|
1832
|
+
for( const lead of uncontacted ){
|
|
1833
|
+
|
|
1834
|
+
try {
|
|
1835
|
+
|
|
1836
|
+
await resolveContact({ leadId : lead.id });
|
|
1837
|
+
|
|
1838
|
+
backfilled += 1;
|
|
1839
|
+
|
|
1840
|
+
} catch ( error ){
|
|
1841
|
+
|
|
1842
|
+
// A duplicate means a concurrent resolve created it first, which
|
|
1843
|
+
// is the outcome we wanted.
|
|
1844
|
+
if( error.code !== 11000 ) throw error;
|
|
1845
|
+
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
logger?.info?.( 'segment.sync.backfill', { backfilled, organization, uncontacted : uncontacted.length });
|
|
1851
|
+
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
const contacts = await read.aggregate({
|
|
1855
|
+
collection : 'contact',
|
|
1856
|
+
pipeline : [
|
|
1857
|
+
{ $match : { organization } },
|
|
1858
|
+
{ $project : { _id : 0, id : 1 } },
|
|
1859
|
+
{ $sort : { id : 1 } }
|
|
1860
|
+
]
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
if( ! contacts.length ){
|
|
1864
|
+
|
|
1865
|
+
return {
|
|
1866
|
+
...release( segmentIds ),
|
|
1867
|
+
message : 'Organization has no contacts to evaluate against segments.',
|
|
1868
|
+
request,
|
|
1869
|
+
response : { skipped : true },
|
|
1870
|
+
skipped : true
|
|
1871
|
+
};
|
|
1872
|
+
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
const contactIds = contacts.map( ( contact ) => contact.id );
|
|
1876
|
+
|
|
1877
|
+
const org = await read.get({ collection : 'organization', query : { id : organization } });
|
|
1878
|
+
|
|
1879
|
+
// THE FAN-OUT. `chunks` tells the shell to open the step rather than
|
|
1880
|
+
// close it, and the SHELL builds one slot per chunk — the slot is the
|
|
1881
|
+
// step document's own shape, so counting them here was a manifest
|
|
1882
|
+
// carrying a schema that is not its.
|
|
1883
|
+
const chunks = [];
|
|
1884
|
+
|
|
1885
|
+
for( let index = 0 ; index < contactIds.length ; index += chunkSize ){
|
|
1886
|
+
|
|
1887
|
+
chunks.push({
|
|
1888
|
+
contactIds : contactIds.slice( index, index + chunkSize ),
|
|
1889
|
+
organization,
|
|
1890
|
+
segments : segmentIds,
|
|
1891
|
+
// A BACKFILL IS NOT BILLABLE. It creates the contacts this run
|
|
1892
|
+
// then evaluates, so charging for it would bill an organization
|
|
1893
|
+
// for work its own history made necessary.
|
|
1894
|
+
usage : ( context?.billable === true && backfilled === 0 ) ? org?.usage || null : null
|
|
1895
|
+
});
|
|
1896
|
+
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
return {
|
|
1900
|
+
chunks,
|
|
1901
|
+
...( configured && { extra : { segment : configured } }),
|
|
1902
|
+
message : 'Queued ' + contactIds.length + ' contacts across ' + chunks.length + ' chunks for segment evaluation.',
|
|
1903
|
+
queue : 'segment',
|
|
1904
|
+
request : { ...request, segments : segmentIds },
|
|
1905
|
+
response : { chunks : chunks.length, contacts : contactIds.length, segments : segments.length }
|
|
1906
|
+
};
|
|
1907
|
+
|
|
1908
|
+
} catch ( error ){
|
|
1909
|
+
|
|
1910
|
+
// A segment left mid-sync shows as errored rather than syncing forever.
|
|
1911
|
+
// The release rides OUT ON THE ERROR because a throw is how a hook
|
|
1912
|
+
// rejects, and the shell performs a rejection's effects before it
|
|
1913
|
+
// records the failure.
|
|
1914
|
+
throw Object.assign( error, release( segmentIds, 'error' ) );
|
|
1915
|
+
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
},
|
|
1921
|
+
sms : {
|
|
1922
|
+
|
|
1923
|
+
// SMS TO A LEAD, through the merchant's own Twilio connection.
|
|
1924
|
+
//
|
|
1925
|
+
// WITHDRAWN from the builder — twilio went, and a connection-gated step
|
|
1926
|
+
// with no connection to gate on could only ever render permanently
|
|
1927
|
+
// disabled. Stored workflows still carry it, so it still runs.
|
|
1928
|
+
//
|
|
1929
|
+
// It looks its own connection up rather than relying on the shell, because
|
|
1930
|
+
// the step is declared by the PRIVATE drawbridge connection (which has
|
|
1931
|
+
// none) while the credential belongs to twilio (which has no manifest).
|
|
1932
|
+
// Platform SMS will remove that split the way it did for email.
|
|
1933
|
+
send : async ( { context, step, workflow }, { canSend, read } = {} ) => {
|
|
1934
|
+
|
|
1935
|
+
const to = context?.phone?.number;
|
|
1936
|
+
|
|
1937
|
+
if( ! to ) throw new Error( 'No phone number on context (context.phone.number is required)' );
|
|
1938
|
+
|
|
1939
|
+
const request = { to };
|
|
1940
|
+
|
|
1941
|
+
const connection = await read.get({
|
|
1942
|
+
collection : 'connection',
|
|
1943
|
+
query : { organization : workflow.organization, slug : 'twilio', status : 'active' }
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
if( ! connection ) return { message : 'No active Twilio SMS connection — workflow-step SMS skipped.', request, response : { skipped : true }, skipped : true };
|
|
1947
|
+
|
|
1948
|
+
// PRE-CHECKED so an opted-out recipient skips WITHOUT billing. The
|
|
1949
|
+
// carrier opt-out is a legal obligation, not a preference.
|
|
1950
|
+
const { ok : sendable } = await canSend({ channel : 'sms', to : context.phone });
|
|
1951
|
+
|
|
1952
|
+
if( ! sendable ) return { message : 'Recipient has opted out — skipped.', request, response : { skipped : true }, skipped : true };
|
|
1953
|
+
|
|
1954
|
+
return {
|
|
1955
|
+
message : 'SMS queued for delivery to ' + to + ' via twilio.',
|
|
1956
|
+
request,
|
|
1957
|
+
response : { provider : 'twilio', queued : true },
|
|
1958
|
+
// QUEUES rather than sends: queue/notification.js owns delivery, the
|
|
1959
|
+
// carrier opt-out line and the segment count this is billed on.
|
|
1960
|
+
writes : [
|
|
1961
|
+
queueNotification({
|
|
1962
|
+
connection : connection.id,
|
|
1963
|
+
message : interpolate( step.settings?.message, context ),
|
|
1964
|
+
organization : workflow.organization,
|
|
1965
|
+
send : { phone : { number : to }, type : 'phone' },
|
|
1966
|
+
title : interpolate( step.settings?.subject, context ),
|
|
1967
|
+
workflow : workflow.id
|
|
1968
|
+
})
|
|
1969
|
+
]
|
|
1970
|
+
};
|
|
1971
|
+
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
},
|
|
1172
1975
|
webhook : false
|
|
1173
1976
|
},
|
|
1174
1977
|
icon: icon$3,
|
|
@@ -1212,11 +2015,11 @@ var drawbridge = {
|
|
|
1212
2015
|
key : 'Email — Digest',
|
|
1213
2016
|
queue : 'notification',
|
|
1214
2017
|
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.
|
|
2018
|
+
// The organization OWNER is always a recipient, resolved by the
|
|
2019
|
+
// hook, so this is additional recipients rather than the list. It
|
|
2020
|
+
// cannot be required: the members endpoint is owner-gated and the
|
|
2021
|
+
// owner is not a member document, so a solo merchant has nothing to
|
|
2022
|
+
// pick and could never save the step.
|
|
1220
2023
|
members : { of : 'string', type : 'array' },
|
|
1221
2024
|
message : { required : true, type : 'string' },
|
|
1222
2025
|
subject : { required : true, type : 'string' }
|
|
@@ -1395,7 +2198,8 @@ var klaviyo = {
|
|
|
1395
2198
|
// differently, and it says so in hooks.auth.token rather than as a flag here.
|
|
1396
2199
|
auth : {
|
|
1397
2200
|
oauth : {
|
|
1398
|
-
// NAMES the
|
|
2201
|
+
// NAMES the credentials holding OUR application's client — keys into
|
|
2202
|
+
// the stored provider credentials, not env vars. One identity,
|
|
1399
2203
|
// every merchant — the token is the merchant's and arrives from their
|
|
1400
2204
|
// own consent, which is what stops one organization reading another's
|
|
1401
2205
|
// data.
|
|
@@ -1544,7 +2348,7 @@ var klaviyo = {
|
|
|
1544
2348
|
// renders an empty "Klaviyo account" field, because the merchant is
|
|
1545
2349
|
// never asked which account they connected — the consent already
|
|
1546
2350
|
// decided it, and asking again would be a question we can answer.
|
|
1547
|
-
connect : async ({
|
|
2351
|
+
connect : async ( { tokens }, { fetcher } = {} ) => {
|
|
1548
2352
|
|
|
1549
2353
|
const body = await api( '/accounts', { fetcher, token : tokens.accessToken });
|
|
1550
2354
|
|
|
@@ -1563,7 +2367,7 @@ var klaviyo = {
|
|
|
1563
2367
|
//
|
|
1564
2368
|
// Basic auth with our client, exactly like the token exchange — the
|
|
1565
2369
|
// token being revoked is the subject, not the credential.
|
|
1566
|
-
disconnect : async ({ clientId, clientSecret, fetcher = fetch
|
|
2370
|
+
disconnect : async ( { clientId, clientSecret, manifest, settings }, { fetcher = fetch } = {} ) => {
|
|
1567
2371
|
|
|
1568
2372
|
const token = settings?.refreshToken || settings?.accessToken;
|
|
1569
2373
|
|
|
@@ -1595,7 +2399,7 @@ var klaviyo = {
|
|
|
1595
2399
|
// the refresh token is the only thing that asks Klaviyo.
|
|
1596
2400
|
//
|
|
1597
2401
|
// It also keeps the grant warm against the 90-day idle window above.
|
|
1598
|
-
probe : async ({ clientId, clientSecret,
|
|
2402
|
+
probe : async ( { clientId, clientSecret, manifest, settings }, { fetcher } = {} ) => {
|
|
1599
2403
|
|
|
1600
2404
|
const token = await accessToken({
|
|
1601
2405
|
clientId,
|
|
@@ -1634,14 +2438,13 @@ var klaviyo = {
|
|
|
1634
2438
|
commerce : false,
|
|
1635
2439
|
|
|
1636
2440
|
// 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
2441
|
contacts : {
|
|
1639
2442
|
|
|
1640
2443
|
// Not yet. Suppression syncs an opt-out as unsubscribed, which is a
|
|
1641
2444
|
// different thing from deleting the profile.
|
|
1642
2445
|
remove : false,
|
|
1643
2446
|
|
|
1644
|
-
sync : async ({ contact,
|
|
2447
|
+
sync : async ( { contact, lead, settings, suppressed, token }, { fetcher } = {} ) => {
|
|
1645
2448
|
|
|
1646
2449
|
const list = settings?.list;
|
|
1647
2450
|
|
|
@@ -1768,7 +2571,7 @@ var klaviyo = {
|
|
|
1768
2571
|
// it, so one call quietly returns the first ten lists and an account
|
|
1769
2572
|
// with more shows a picker missing the one they wanted, with nothing to
|
|
1770
2573
|
// indicate anything was cut.
|
|
1771
|
-
audiences : async ({ cursor,
|
|
2574
|
+
audiences : async ( { cursor, limit = 100, search, token }, { fetcher } = {} ) => {
|
|
1772
2575
|
|
|
1773
2576
|
// `limit` is what the CALLER wants back rather than what one request
|
|
1774
2577
|
// can carry — the loop keeps pulling until it has that many or the
|
|
@@ -2023,7 +2826,7 @@ var mailchimp = {
|
|
|
2023
2826
|
// The header here is `OAuth <token>`, not Bearer — that is specific to
|
|
2024
2827
|
// the metadata endpoint. Marketing API calls take Bearer; see the
|
|
2025
2828
|
// audiences hook.
|
|
2026
|
-
connect : async ({ fetcher = fetch
|
|
2829
|
+
connect : async ( { tokens }, { fetcher = fetch } = {} ) => {
|
|
2027
2830
|
|
|
2028
2831
|
const response = await fetcher( 'https://login.mailchimp.com/oauth2/metadata', {
|
|
2029
2832
|
headers : {
|
|
@@ -2086,7 +2889,7 @@ var mailchimp = {
|
|
|
2086
2889
|
// successful — the same silent truncation Klaviyo has, at a different
|
|
2087
2890
|
// number. Paged against total_items so an account past a thousand still
|
|
2088
2891
|
// resolves.
|
|
2089
|
-
audiences : async ({ cursor,
|
|
2892
|
+
audiences : async ( { cursor, limit = 100, search, settings, token }, { fetcher = fetch } = {} ) => {
|
|
2090
2893
|
|
|
2091
2894
|
// Bearer, not Basic. Mailchimp's fundamentals doc states "API keys and
|
|
2092
2895
|
// OAuth 2 tokens can be used to make authenticated requests the same
|
|
@@ -2204,14 +3007,30 @@ var icon = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmln
|
|
|
2204
3007
|
//
|
|
2205
3008
|
// A vendor whose scheme is "hash the raw body with a shared secret and compare,
|
|
2206
3009
|
// constant-time, against a header" declares that shape as `inbound.signature`
|
|
2207
|
-
// data on its manifest (algorithm, encoding, the
|
|
3010
|
+
// data on its manifest (algorithm, encoding, the NAME of the credential) and
|
|
2208
3011
|
// wires this straight in as its hook — Shopify does exactly that.
|
|
2209
3012
|
//
|
|
2210
3013
|
// A vendor whose scheme is not that shape — Twilio signs the REGISTERED URL
|
|
2211
3014
|
// rather than the body, a JWT-bodied vendor verifies against a JWKS — writes its
|
|
2212
3015
|
// own inbound.verify instead. That is why verify is a hook and not config: this
|
|
2213
3016
|
// file covers the common case, not the contract.
|
|
2214
|
-
const verifySignature = ({ body, descriptor, headers }) => {
|
|
3017
|
+
const verifySignature = ({ body, descriptor, headers, secret }) => {
|
|
3018
|
+
|
|
3019
|
+
// THE MANIFEST STILL DECLARES THE NAME, the caller supplies the value.
|
|
3020
|
+
// descriptor.signature.secret is 'SHOPIFY_API_SECRET' and stays that way —
|
|
3021
|
+
// naming which credential a vendor needs is the manifest's job. Reading it is
|
|
3022
|
+
// not: the value lives encrypted in the `provider` collection, and a package
|
|
3023
|
+
// that reached into process.env for it would force every service to copy the
|
|
3024
|
+
// collection back into its environment at boot before this could work.
|
|
3025
|
+
//
|
|
3026
|
+
// A 500 rather than the 401 below, because a secret we never loaded is our
|
|
3027
|
+
// misconfiguration and must not be indistinguishable in the logs from the
|
|
3028
|
+
// forged request that gets the same door slammed on it.
|
|
3029
|
+
if( ! secret ){
|
|
3030
|
+
|
|
3031
|
+
throw Object.assign( new Error( 'Missing webhook secret: ' + descriptor.signature.secret ), { status : 500 });
|
|
3032
|
+
|
|
3033
|
+
}
|
|
2215
3034
|
|
|
2216
3035
|
const provided = headers[ descriptor.headers.signature ];
|
|
2217
3036
|
|
|
@@ -2221,7 +3040,7 @@ const verifySignature = ({ body, descriptor, headers }) => {
|
|
|
2221
3040
|
|
|
2222
3041
|
}
|
|
2223
3042
|
|
|
2224
|
-
const digest = createHmac( descriptor.signature.algorithm,
|
|
3043
|
+
const digest = createHmac( descriptor.signature.algorithm, secret )
|
|
2225
3044
|
.update( body )
|
|
2226
3045
|
.digest( descriptor.signature.encoding );
|
|
2227
3046
|
|
|
@@ -2249,6 +3068,75 @@ const verifySignature = ({ body, descriptor, headers }) => {
|
|
|
2249
3068
|
// THE SHARED HALF OF inbound.event, for a vendor that puts it in a header.
|
|
2250
3069
|
const readEventHeader = ({ descriptor, headers }) => headers[ descriptor.headers.event ];
|
|
2251
3070
|
|
|
3071
|
+
// ── ORDER ATTRIBUTION, the vendor's own arithmetic ───────────────────────────
|
|
3072
|
+
//
|
|
3073
|
+
// Orders are attributed via `_drwbrdg_*` line-item properties injected at
|
|
3074
|
+
// add-to-cart (NOT url params); only items tagged with `_drwbrdg_ca` count
|
|
3075
|
+
// toward a conversion's attributed gross. This is Shopify's payload shape, so
|
|
3076
|
+
// it lives with the vendor — it was drawbridge-sync's lib/attribution.js.
|
|
3077
|
+
|
|
3078
|
+
const toLine = ({
|
|
3079
|
+
price,
|
|
3080
|
+
product_id : productId,
|
|
3081
|
+
quantity,
|
|
3082
|
+
title,
|
|
3083
|
+
variant_id : variantId,
|
|
3084
|
+
variant_title : variantTitle
|
|
3085
|
+
}) => ({
|
|
3086
|
+
price : parseFloat( price ) || 0,
|
|
3087
|
+
productId : productId ? 'gid://shopify/Product/' + productId : null,
|
|
3088
|
+
quantity : quantity || 1,
|
|
3089
|
+
title : title || null,
|
|
3090
|
+
variantId : variantId ? 'gid://shopify/ProductVariant/' + variantId : null,
|
|
3091
|
+
variantTitle : variantTitle || null
|
|
3092
|
+
});
|
|
3093
|
+
|
|
3094
|
+
// Reduce an order's line items to the campaign attribution: the first tagged
|
|
3095
|
+
// item's property map wins for ids (attrMap), gross/lines accumulate across
|
|
3096
|
+
// every tagged item. Untagged items contribute nothing.
|
|
3097
|
+
const attributeLineItems = ( lineItems = [] ) => lineItems.reduce(
|
|
3098
|
+
( acc, item ) => {
|
|
3099
|
+
|
|
3100
|
+
const attrs = ( item.properties || [] ).reduce(
|
|
3101
|
+
( map, { name, value } ) => {
|
|
3102
|
+
|
|
3103
|
+
map[ name ] = value;
|
|
3104
|
+
|
|
3105
|
+
return map;
|
|
3106
|
+
|
|
3107
|
+
},
|
|
3108
|
+
{}
|
|
3109
|
+
);
|
|
3110
|
+
|
|
3111
|
+
if( ! attrs[ '_drwbrdg_ca' ] ) return acc;
|
|
3112
|
+
|
|
3113
|
+
if( ! Object.keys( acc.attrMap ).length ) acc.attrMap = attrs;
|
|
3114
|
+
|
|
3115
|
+
const line = toLine( item );
|
|
3116
|
+
|
|
3117
|
+
acc.attributedGross += line.price * line.quantity;
|
|
3118
|
+
acc.attributedLines.push( line );
|
|
3119
|
+
|
|
3120
|
+
return acc;
|
|
3121
|
+
|
|
3122
|
+
},
|
|
3123
|
+
{ attrMap : {}, attributedGross : 0, attributedLines : [] }
|
|
3124
|
+
);
|
|
3125
|
+
|
|
3126
|
+
// Unambiguous alphabet and length, because a merchant reads these aloud and
|
|
3127
|
+
// types them into a checkout.
|
|
3128
|
+
const generateDiscountCode = customAlphabet( '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', 8 );
|
|
3129
|
+
|
|
3130
|
+
// Rotate before the window closes rather than at it: Shopify's refresh token has
|
|
3131
|
+
// an expiry, and a rotation attempted after it has passed cannot succeed.
|
|
3132
|
+
const REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1000;
|
|
3133
|
+
|
|
3134
|
+
const OAUTH_ERROR_SOURCE = 'oauth';
|
|
3135
|
+
|
|
3136
|
+
// What Shopify says when the merchant has uninstalled or revoked. Neither is
|
|
3137
|
+
// retryable and both mean the same thing to a merchant: reconnect.
|
|
3138
|
+
const OAUTH_GRANT_REVOKED_CODES = [ 'application_cannot_be_found', 'invalid_grant' ];
|
|
3139
|
+
|
|
2252
3140
|
// WHERE THIS VENDOR PUTS THINGS ON AN INBOUND REQUEST. Header names are facts
|
|
2253
3141
|
// about someone else's product, so they belong beside the rest of the vendor
|
|
2254
3142
|
// rather than as string literals in a route — which is where they were, and is
|
|
@@ -2391,20 +3279,670 @@ var shopify = {
|
|
|
2391
3279
|
//
|
|
2392
3280
|
// `shopify` is injected for the same reason it is everywhere else — this
|
|
2393
3281
|
// package cannot import @drawbridge/shopify, which depends on it.
|
|
2394
|
-
scopes : ({ scope, shopify }) => ( scope ? shopify.oauth.missingScopes( scope ) : null ),
|
|
3282
|
+
scopes : ( { scope }, { shopify } = {} ) => ( scope ? shopify.oauth.missingScopes( scope ) : null ),
|
|
2395
3283
|
// Shopify's install grant is exchanged inside its own app flow, not
|
|
2396
3284
|
// through the shared OAuth runner.
|
|
2397
3285
|
token : false
|
|
2398
3286
|
},
|
|
2399
|
-
//
|
|
2400
|
-
//
|
|
2401
|
-
//
|
|
2402
|
-
//
|
|
3287
|
+
// THE VENDOR'S OWN WORK, here in full. Every body describes its writes,
|
|
3288
|
+
// enqueues and events for the shell to perform — see contract.js — and
|
|
3289
|
+
// everything it needs arrives as an argument: `read` (the controller's
|
|
3290
|
+
// read methods, nothing that writes), `shopify` (the SDK, injected because
|
|
3291
|
+
// this package cannot import what depends on it), `adminToken` (minted by
|
|
3292
|
+
// the shell, which persists rotations), `mintId` (so one described write
|
|
3293
|
+
// can reference another), `dispatch` (the caller's own coordinator table,
|
|
3294
|
+
// for the hooks that are dispatches).
|
|
2403
3295
|
commerce : {
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
order
|
|
2407
|
-
|
|
3296
|
+
|
|
3297
|
+
// MINT A DISCOUNT CODE against the merchant's chosen discount, mapped to
|
|
3298
|
+
// one lead — which is what lets an order that redeems it be attributed
|
|
3299
|
+
// back.
|
|
3300
|
+
code : async ( { connection, context, step }, { adminToken, shopify } = {} ) => {
|
|
3301
|
+
|
|
3302
|
+
const discount = step.settings?.discount;
|
|
3303
|
+
|
|
3304
|
+
const request = { email : context?.email || null, lead : context?.lead || null, shop : connection.shop };
|
|
3305
|
+
|
|
3306
|
+
if( ! context?.email ) return { message : 'Lead email is missing.', request, response : { skipped : true }, skipped : true };
|
|
3307
|
+
if( ! context?.lead ) return { message : 'Lead id is missing.', request, response : { skipped : true }, skipped : true };
|
|
3308
|
+
if( ! discount?.id ) return { message : 'Discount is not configured on this step.', request, response : { skipped : true }, skipped : true };
|
|
3309
|
+
|
|
3310
|
+
const adminAccessToken = await adminToken();
|
|
3311
|
+
|
|
3312
|
+
// The customer must exist before a code is mapped to them. An earlier
|
|
3313
|
+
// commerce.customer step usually did this and left the id on the
|
|
3314
|
+
// context; when this step runs alone, it does it here.
|
|
3315
|
+
if( ! context.shopifyCustomerId ){
|
|
3316
|
+
|
|
3317
|
+
const customer = await shopify.admin.getOrCreateCustomer({ adminAccessToken, domain : connection.shop, email : context.email });
|
|
3318
|
+
|
|
3319
|
+
if( ! customer?.id ) return { message : 'Shopify did not return a customer id — create/lookup failed.', request, response : { skipped : true }, skipped : true };
|
|
3320
|
+
|
|
3321
|
+
}
|
|
3322
|
+
|
|
3323
|
+
const discountCode = await shopify.admin.createDiscountCode({
|
|
3324
|
+
adminAccessToken,
|
|
3325
|
+
code : 'DB-' + generateDiscountCode(),
|
|
3326
|
+
discountId : discount.id,
|
|
3327
|
+
domain : connection.shop
|
|
3328
|
+
});
|
|
3329
|
+
|
|
3330
|
+
if( ! discountCode ) return { message : 'Shopify did not return a discount code — create failed.', request, response : { skipped : true }, skipped : true };
|
|
3331
|
+
|
|
3332
|
+
return {
|
|
3333
|
+
context : {
|
|
3334
|
+
shopifyDiscountCode : discountCode.code,
|
|
3335
|
+
shopifyDiscountId : String( discountCode.id )
|
|
3336
|
+
},
|
|
3337
|
+
message : 'Discount code created and linked to lead.',
|
|
3338
|
+
request,
|
|
3339
|
+
response : { code : discountCode.code, id : String( discountCode.id ) },
|
|
3340
|
+
// bypassDocumentValidation because these are vendor ids on a
|
|
3341
|
+
// Drawbridge document the schema does not declare — the
|
|
3342
|
+
// canonical-identity work resolves it properly.
|
|
3343
|
+
writes : [ {
|
|
3344
|
+
collection : 'lead',
|
|
3345
|
+
data : {
|
|
3346
|
+
$set : {
|
|
3347
|
+
shopifyDiscountCode : discountCode.code,
|
|
3348
|
+
shopifyDiscountId : String( discountCode.id )
|
|
3349
|
+
}
|
|
3350
|
+
},
|
|
3351
|
+
operation : 'update',
|
|
3352
|
+
options : { bypassDocumentValidation : true },
|
|
3353
|
+
query : { id : context.lead }
|
|
3354
|
+
} ]
|
|
3355
|
+
};
|
|
3356
|
+
|
|
3357
|
+
},
|
|
3358
|
+
|
|
3359
|
+
// CREATE THE BUYER AT THE STORE, so an order can be attributed to them.
|
|
3360
|
+
//
|
|
3361
|
+
// IDEMPOTENT THREE WAYS, because this runs on every entry and a duplicate
|
|
3362
|
+
// customer at the store is a support ticket: the context may already
|
|
3363
|
+
// carry the id from an earlier step, the lead may already be linked from
|
|
3364
|
+
// an earlier run, and Shopify's own get-or-create settles the rest.
|
|
3365
|
+
customer : async ( { connection, context }, { adminToken, read, shopify } = {} ) => {
|
|
3366
|
+
|
|
3367
|
+
const request = { email : context?.email || null, lead : context?.lead || null, shop : connection.shop };
|
|
3368
|
+
|
|
3369
|
+
if( ! context?.email ) return { message : 'Lead email is missing — cannot create Shopify customer.', request, response : { skipped : true }, skipped : true };
|
|
3370
|
+
if( ! context?.lead ) return { message : 'Lead id is missing — cannot create Shopify customer.', request, response : { skipped : true }, skipped : true };
|
|
3371
|
+
|
|
3372
|
+
// Already known from an earlier step in this run.
|
|
3373
|
+
if( context.shopifyCustomerId ){
|
|
3374
|
+
|
|
3375
|
+
return {
|
|
3376
|
+
context : { shopifyCustomerId : context.shopifyCustomerId },
|
|
3377
|
+
message : 'Trigger data already includes a Shopify customer id — reusing.',
|
|
3378
|
+
request,
|
|
3379
|
+
response : { shopifyCustomerId : context.shopifyCustomerId },
|
|
3380
|
+
// Reusing an id is not a creation, so it does not bill.
|
|
3381
|
+
skipped : true
|
|
3382
|
+
};
|
|
3383
|
+
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3386
|
+
const lead = await read.get({ collection : 'lead', query : { id : context.lead } });
|
|
3387
|
+
|
|
3388
|
+
// Already linked by an earlier run.
|
|
3389
|
+
if( lead?.shopifyCustomerId ){
|
|
3390
|
+
|
|
3391
|
+
return {
|
|
3392
|
+
context : { shopifyCustomerId : lead.shopifyCustomerId },
|
|
3393
|
+
message : 'Lead already has a Shopify customer id — reusing.',
|
|
3394
|
+
request,
|
|
3395
|
+
response : { shopifyCustomerId : lead.shopifyCustomerId },
|
|
3396
|
+
skipped : true
|
|
3397
|
+
};
|
|
3398
|
+
|
|
3399
|
+
}
|
|
3400
|
+
|
|
3401
|
+
const adminAccessToken = await adminToken();
|
|
3402
|
+
|
|
3403
|
+
// Shopify keeps first and last separately; Drawbridge keeps one name.
|
|
3404
|
+
// Split on the first space and give everything after it to the
|
|
3405
|
+
// surname, which is wrong for some names and is what the vendor's
|
|
3406
|
+
// shape allows.
|
|
3407
|
+
const parts = ( lead?.name || '' ).trim().split( /\s+/ ).filter( Boolean );
|
|
3408
|
+
|
|
3409
|
+
const customer = await shopify.admin.getOrCreateCustomer({
|
|
3410
|
+
adminAccessToken,
|
|
3411
|
+
domain : connection.shop,
|
|
3412
|
+
email : context.email,
|
|
3413
|
+
firstName : parts.length ? parts[ 0 ] : null,
|
|
3414
|
+
lastName : parts.length > 1 ? parts.slice( 1 ).join( ' ' ) : null,
|
|
3415
|
+
source : 'drawbridge'
|
|
3416
|
+
});
|
|
3417
|
+
|
|
3418
|
+
if( ! customer?.id ) return { message : 'Shopify did not return a customer id — create/lookup failed.', request, response : { skipped : true }, skipped : true };
|
|
3419
|
+
|
|
3420
|
+
return {
|
|
3421
|
+
context : { shopifyCustomerId : customer.id },
|
|
3422
|
+
message : 'Shopify customer created/linked to lead.',
|
|
3423
|
+
request,
|
|
3424
|
+
response : { shopifyCustomerId : customer.id },
|
|
3425
|
+
// The hook's own result, described beside the call that produced it.
|
|
3426
|
+
writes : [ {
|
|
3427
|
+
collection : 'lead',
|
|
3428
|
+
data : { $set : { shopifyCustomerId : customer.id } },
|
|
3429
|
+
operation : 'update',
|
|
3430
|
+
options : { bypassDocumentValidation : true },
|
|
3431
|
+
query : { id : context.lead }
|
|
3432
|
+
} ]
|
|
3433
|
+
};
|
|
3434
|
+
|
|
3435
|
+
},
|
|
3436
|
+
|
|
3437
|
+
// AN ORDER ARRIVED AT THE STORE. The largest hook in the family, because
|
|
3438
|
+
// attribution genuinely is: an order can reach Drawbridge two ways and
|
|
3439
|
+
// they bill differently.
|
|
3440
|
+
//
|
|
3441
|
+
// CONVERSION — a `_drwbrdg_ca` line-item property, injected at
|
|
3442
|
+
// add-to-cart. Causal: the campaign produced the sale, so
|
|
3443
|
+
// it carries a fee.
|
|
3444
|
+
// REDEMPTION — a DB- discount code matched to a lead. Associative: we
|
|
3445
|
+
// cannot claim we caused the purchase, so it is fee-free.
|
|
3446
|
+
//
|
|
3447
|
+
// Both can be true, and an order already recorded as a conversion can
|
|
3448
|
+
// later have a redemption backfilled onto it — `backfill` below.
|
|
3449
|
+
//
|
|
3450
|
+
// IDEMPOTENT THROUGH THE RETRY, one layer up: two deliveries of the same
|
|
3451
|
+
// order race, the loser's transaction hits a duplicate key, the step
|
|
3452
|
+
// fails and BullMQ redelivers — and the re-run's read at the top finds
|
|
3453
|
+
// what the winner wrote and skips instead of double-billing a merchant
|
|
3454
|
+
// for one purchase. The hook used to loop for this itself; describing
|
|
3455
|
+
// the writes moved the retry to the queue, with the same guarantee.
|
|
3456
|
+
order : async ( { connection, context }, { logger, mintId, read } = {} ) => {
|
|
3457
|
+
|
|
3458
|
+
const {
|
|
3459
|
+
advertisement,
|
|
3460
|
+
created_at : createdAt,
|
|
3461
|
+
currency,
|
|
3462
|
+
customer : orderCustomer,
|
|
3463
|
+
email,
|
|
3464
|
+
id : orderId,
|
|
3465
|
+
line_items : lineItems = [],
|
|
3466
|
+
organization,
|
|
3467
|
+
phone
|
|
3468
|
+
} = context || {};
|
|
3469
|
+
|
|
3470
|
+
const request = { orderId : orderId ? String( orderId ) : null, organization };
|
|
3471
|
+
|
|
3472
|
+
const [ existingOrder, existingRedemption ] = await Promise.all([
|
|
3473
|
+
read.get({ collection : 'order', query : { 'provider.id' : String( orderId ), 'provider.slug' : 'shopify' } }),
|
|
3474
|
+
read.get({ collection : 'redemption', query : { 'provider.id' : String( orderId ), 'provider.slug' : 'shopify' } })
|
|
3475
|
+
]);
|
|
3476
|
+
|
|
3477
|
+
// Already fully recorded. This is the branch the redelivery exists to
|
|
3478
|
+
// reach.
|
|
3479
|
+
if( existingRedemption ){
|
|
3480
|
+
|
|
3481
|
+
return {
|
|
3482
|
+
message : 'Order/redemption already recorded — skipping duplicate.',
|
|
3483
|
+
request,
|
|
3484
|
+
response : {
|
|
3485
|
+
existingOrderId : existingOrder?.id || null,
|
|
3486
|
+
existingRedemptionId : existingRedemption.id,
|
|
3487
|
+
skipped : true
|
|
3488
|
+
},
|
|
3489
|
+
skipped : true
|
|
3490
|
+
};
|
|
3491
|
+
|
|
3492
|
+
}
|
|
3493
|
+
|
|
3494
|
+
const backfill = ! ! existingOrder;
|
|
3495
|
+
|
|
3496
|
+
// ONLY `_drwbrdg_ca`-TAGGED LINES COUNT toward attributed gross.
|
|
3497
|
+
const { attrMap, attributedGross, attributedLines } = attributeLineItems( lineItems );
|
|
3498
|
+
|
|
3499
|
+
const campaign = attrMap[ '_drwbrdg_ca' ] || null;
|
|
3500
|
+
|
|
3501
|
+
const discountCodes = Array.isArray( context?.discount_codes ) ? context.discount_codes : [];
|
|
3502
|
+
const codes = [ ...new Set( discountCodes.map( ( dc ) => dc?.code ).filter( Boolean ) ) ];
|
|
3503
|
+
|
|
3504
|
+
const matchedLeads = codes.length
|
|
3505
|
+
? await read.aggregate({
|
|
3506
|
+
collection : 'lead',
|
|
3507
|
+
pipeline : [ { $match : { organization, shopifyDiscountCode : { $in : codes } } } ]
|
|
3508
|
+
})
|
|
3509
|
+
: [];
|
|
3510
|
+
|
|
3511
|
+
const codeToLead = {};
|
|
3512
|
+
|
|
3513
|
+
for( const found of matchedLeads ){
|
|
3514
|
+
|
|
3515
|
+
if( found.shopifyDiscountCode ) codeToLead[ found.shopifyDiscountCode ] = found;
|
|
3516
|
+
|
|
3517
|
+
}
|
|
3518
|
+
|
|
3519
|
+
const matchedDiscounts = discountCodes
|
|
3520
|
+
.filter( ( dc ) => dc?.code && codeToLead[ dc.code ] )
|
|
3521
|
+
.map( ( dc ) => ({
|
|
3522
|
+
amount : parseFloat( dc.amount ) || 0,
|
|
3523
|
+
code : dc.code,
|
|
3524
|
+
id : codeToLead[ dc.code ].shopifyDiscountId || null
|
|
3525
|
+
}) );
|
|
3526
|
+
|
|
3527
|
+
const matchedLead = matchedDiscounts.length ? codeToLead[ matchedDiscounts[ 0 ].code ] : null;
|
|
3528
|
+
|
|
3529
|
+
const discount = matchedDiscounts.length
|
|
3530
|
+
? {
|
|
3531
|
+
amount : matchedDiscounts.reduce( ( sum, entry ) => sum + entry.amount, 0 ),
|
|
3532
|
+
codes : matchedDiscounts
|
|
3533
|
+
}
|
|
3534
|
+
: null;
|
|
3535
|
+
|
|
3536
|
+
const matchedCodes = new Set( matchedDiscounts.map( ( entry ) => entry.code ) );
|
|
3537
|
+
|
|
3538
|
+
// A DB- code we minted that matched no lead. Logged rather than
|
|
3539
|
+
// ignored: it means a code went out and its lead link was lost, which
|
|
3540
|
+
// is revenue we cannot attribute and nobody would otherwise notice.
|
|
3541
|
+
const unmatched = codes.filter( ( code ) => code.startsWith( 'DB-' ) && ! matchedCodes.has( code ) );
|
|
3542
|
+
|
|
3543
|
+
if( unmatched.length ){
|
|
3544
|
+
|
|
3545
|
+
logger?.warn?.( 'shopify.order.discount.unmatched', {
|
|
3546
|
+
campaign : campaign || null,
|
|
3547
|
+
codes : JSON.stringify( unmatched ),
|
|
3548
|
+
isConversion : ! ! campaign,
|
|
3549
|
+
orderId : String( orderId ),
|
|
3550
|
+
organization
|
|
3551
|
+
});
|
|
3552
|
+
|
|
3553
|
+
}
|
|
3554
|
+
|
|
3555
|
+
if( ( ! campaign && ! discount ) || ( backfill && ! discount ) ){
|
|
3556
|
+
|
|
3557
|
+
return {
|
|
3558
|
+
message : backfill
|
|
3559
|
+
? 'Order already recorded and no Drawbridge discount code matched — nothing to backfill.'
|
|
3560
|
+
: 'Order has no Drawbridge attribution — not recording.',
|
|
3561
|
+
request,
|
|
3562
|
+
response : { skipped : true },
|
|
3563
|
+
skipped : true
|
|
3564
|
+
};
|
|
3565
|
+
|
|
3566
|
+
}
|
|
3567
|
+
|
|
3568
|
+
let advertisementId = null;
|
|
3569
|
+
let affiliateId = null;
|
|
3570
|
+
let campaignOrganization = organization;
|
|
3571
|
+
let gross = 0;
|
|
3572
|
+
let leadId = null;
|
|
3573
|
+
let lines = [];
|
|
3574
|
+
let orderCampaign = null;
|
|
3575
|
+
let pageId = null;
|
|
3576
|
+
|
|
3577
|
+
const isConversion = ! ! campaign;
|
|
3578
|
+
|
|
3579
|
+
const customerPhone = toE164( orderCustomer?.phone || phone ) || null;
|
|
3580
|
+
|
|
3581
|
+
const matchPhones = [ ...new Set([
|
|
3582
|
+
customerPhone,
|
|
3583
|
+
toE164( context?.billing_address?.phone ),
|
|
3584
|
+
toE164( context?.shipping_address?.phone )
|
|
3585
|
+
].filter( Boolean ) ) ];
|
|
3586
|
+
|
|
3587
|
+
if( isConversion ){
|
|
3588
|
+
|
|
3589
|
+
const campaignDoc = await read.get({ collection : 'campaign', query : { id : campaign } });
|
|
3590
|
+
|
|
3591
|
+
// THE CAMPAIGN MUST BELONG TO THE DELIVERING SHOP'S OWN ORG.
|
|
3592
|
+
//
|
|
3593
|
+
// `_drwbrdg_ca` is a line-item property, and on most Shopify themes
|
|
3594
|
+
// a buyer can attach arbitrary line-item properties via cart
|
|
3595
|
+
// permalinks or the AJAX cart API — and campaign ids are
|
|
3596
|
+
// discoverable from public campaign links. Without this check, a $1
|
|
3597
|
+
// order on ANY connected store carrying another org's campaign id
|
|
3598
|
+
// records a conversion under that org: its revenue totals climb,
|
|
3599
|
+
// its usage document is incremented, and its matching leads gain
|
|
3600
|
+
// order counts — a cross-tenant write driven entirely by the buyer.
|
|
3601
|
+
//
|
|
3602
|
+
// A stale or garbage id lands here too, so this is also the null
|
|
3603
|
+
// check: either way the order simply has no Drawbridge attribution.
|
|
3604
|
+
if( ! campaignDoc || campaignDoc.organization !== organization ){
|
|
3605
|
+
|
|
3606
|
+
return {
|
|
3607
|
+
message : 'Order carried a campaign attribution that does not belong to this store — not recording.',
|
|
3608
|
+
request,
|
|
3609
|
+
response : { skipped : true },
|
|
3610
|
+
skipped : true
|
|
3611
|
+
};
|
|
3612
|
+
|
|
3613
|
+
}
|
|
3614
|
+
|
|
3615
|
+
advertisementId = attrMap[ '_drwbrdg_ad' ] || advertisement || null;
|
|
3616
|
+
affiliateId = attrMap[ '_drwbrdg_af' ] || null;
|
|
3617
|
+
campaignOrganization = campaignDoc.organization;
|
|
3618
|
+
gross = attributedGross;
|
|
3619
|
+
lines = attributedLines;
|
|
3620
|
+
orderCampaign = campaign;
|
|
3621
|
+
pageId = attrMap[ '_drwbrdg_pg' ] || null;
|
|
3622
|
+
|
|
3623
|
+
// MATCHED ON EVERY IDENTITY WE HOLD, canonical forms included,
|
|
3624
|
+
// because the address on an order is often not the one they entered
|
|
3625
|
+
// with.
|
|
3626
|
+
const identifiers = [];
|
|
3627
|
+
|
|
3628
|
+
const canonicalEmail = toCanonicalEmail( email );
|
|
3629
|
+
|
|
3630
|
+
if( email ) identifiers.push({ email : email.toLowerCase() });
|
|
3631
|
+
if( canonicalEmail ) identifiers.push({ 'canonical.email.value' : canonicalEmail });
|
|
3632
|
+
if( matchPhones.length ) identifiers.push({ 'phone.number' : { $in : matchPhones } });
|
|
3633
|
+
if( matchPhones.length ) identifiers.push({ 'canonical.phone.value' : { $in : matchPhones } });
|
|
3634
|
+
|
|
3635
|
+
if( identifiers.length ){
|
|
3636
|
+
|
|
3637
|
+
// Narrowed to the campaign first; an org-wide match is the
|
|
3638
|
+
// fallback, because a buyer who entered a different campaign is
|
|
3639
|
+
// still the same person and still worth linking.
|
|
3640
|
+
const lead = await read.get({
|
|
3641
|
+
collection : 'lead',
|
|
3642
|
+
query : {
|
|
3643
|
+
campaigns : { $in : [ campaign ] },
|
|
3644
|
+
organization : campaignOrganization,
|
|
3645
|
+
$or : identifiers
|
|
3646
|
+
}
|
|
3647
|
+
});
|
|
3648
|
+
|
|
3649
|
+
leadId = lead?.id || null;
|
|
3650
|
+
|
|
3651
|
+
if( ! leadId ){
|
|
3652
|
+
|
|
3653
|
+
const orgLead = await read.get({
|
|
3654
|
+
collection : 'lead',
|
|
3655
|
+
query : { organization : campaignOrganization, $or : identifiers }
|
|
3656
|
+
});
|
|
3657
|
+
|
|
3658
|
+
leadId = orgLead?.id || null;
|
|
3659
|
+
|
|
3660
|
+
}
|
|
3661
|
+
|
|
3662
|
+
}
|
|
3663
|
+
|
|
3664
|
+
} else {
|
|
3665
|
+
|
|
3666
|
+
// REDEMPTION. The lead is known from the code, and the whole order
|
|
3667
|
+
// counts as gross — there are no tagged lines to narrow it to.
|
|
3668
|
+
leadId = matchedLead.id;
|
|
3669
|
+
orderCampaign = ( matchedLead.campaigns || [] ).length === 1 ? matchedLead.campaigns[ 0 ] : null;
|
|
3670
|
+
gross = lineItems.reduce( ( sum, item ) => {
|
|
3671
|
+
|
|
3672
|
+
const line = toLine( item );
|
|
3673
|
+
|
|
3674
|
+
return sum + ( line.price * line.quantity );
|
|
3675
|
+
|
|
3676
|
+
}, 0 );
|
|
3677
|
+
lines = lineItems.map( toLine );
|
|
3678
|
+
|
|
3679
|
+
}
|
|
3680
|
+
|
|
3681
|
+
const org = await read.get({ collection : 'organization', query : { id : campaignOrganization } });
|
|
3682
|
+
|
|
3683
|
+
let rate = 0;
|
|
3684
|
+
|
|
3685
|
+
// THE FEE IS THE CONVERSION FEE, and only a conversion carries one. A
|
|
3686
|
+
// redemption is associative — we cannot claim we caused the purchase —
|
|
3687
|
+
// so it is recorded fee-free.
|
|
3688
|
+
if( isConversion ){
|
|
3689
|
+
|
|
3690
|
+
const subscription = await read.get({ collection : 'subscription', query : { id : org?.subscription } });
|
|
3691
|
+
|
|
3692
|
+
rate = conversionRate( subscription );
|
|
3693
|
+
|
|
3694
|
+
}
|
|
3695
|
+
|
|
3696
|
+
const fee = isConversion ? Math.round( gross * rate ) / 100 : 0;
|
|
3697
|
+
const net = Math.round( ( gross - fee ) * 100 ) / 100;
|
|
3698
|
+
|
|
3699
|
+
const currencyCode = ( currency || 'usd' ).toLowerCase();
|
|
3700
|
+
const purchasedAt = new Date( createdAt || Date.now() );
|
|
3701
|
+
|
|
3702
|
+
const customer = ( orderCustomer || email || phone )
|
|
3703
|
+
? {
|
|
3704
|
+
acceptsMarketing : orderCustomer?.email_marketing_consent?.state
|
|
3705
|
+
? orderCustomer.email_marketing_consent.state === 'subscribed'
|
|
3706
|
+
: ( typeof orderCustomer?.accepts_marketing === 'boolean' ? orderCustomer.accepts_marketing : null ),
|
|
3707
|
+
email : orderCustomer?.email || email || null,
|
|
3708
|
+
firstName : orderCustomer?.first_name || null,
|
|
3709
|
+
id : orderCustomer?.id ? String( orderCustomer.id ) : null,
|
|
3710
|
+
lastName : orderCustomer?.last_name || null,
|
|
3711
|
+
phone : customerPhone
|
|
3712
|
+
}
|
|
3713
|
+
: null;
|
|
3714
|
+
|
|
3715
|
+
const source = connection?.source
|
|
3716
|
+
? { domain : connection.source.domain, id : connection.source.id }
|
|
3717
|
+
: undefined;
|
|
3718
|
+
|
|
3719
|
+
// MINTED HERE, because the redemption names its order and the usage
|
|
3720
|
+
// job names both — a description cannot read a write's result, so the
|
|
3721
|
+
// id exists before either does.
|
|
3722
|
+
const orderDocId = existingOrder?.id || ( isConversion && ! backfill ? mintId() : null );
|
|
3723
|
+
|
|
3724
|
+
const writes = [];
|
|
3725
|
+
|
|
3726
|
+
if( isConversion && ! backfill ){
|
|
3727
|
+
|
|
3728
|
+
writes.push({
|
|
3729
|
+
collection : 'order',
|
|
3730
|
+
data : {
|
|
3731
|
+
advertisement : advertisementId,
|
|
3732
|
+
affiliate : affiliateId,
|
|
3733
|
+
campaign : orderCampaign,
|
|
3734
|
+
currency : currencyCode,
|
|
3735
|
+
customer,
|
|
3736
|
+
discount,
|
|
3737
|
+
fee,
|
|
3738
|
+
gross,
|
|
3739
|
+
id : orderDocId,
|
|
3740
|
+
lead : leadId,
|
|
3741
|
+
lines,
|
|
3742
|
+
net,
|
|
3743
|
+
organization : campaignOrganization,
|
|
3744
|
+
page : pageId,
|
|
3745
|
+
provider : { id : String( orderId ), slug : 'shopify' },
|
|
3746
|
+
purchasedAt,
|
|
3747
|
+
rate,
|
|
3748
|
+
source,
|
|
3749
|
+
status : 'completed'
|
|
3750
|
+
},
|
|
3751
|
+
operation : 'create'
|
|
3752
|
+
});
|
|
3753
|
+
|
|
3754
|
+
if( org?.usage ){
|
|
3755
|
+
|
|
3756
|
+
writes.push({
|
|
3757
|
+
collection : 'usage',
|
|
3758
|
+
data : { $inc : { 'totals.revenue' : gross } },
|
|
3759
|
+
operation : 'update',
|
|
3760
|
+
query : { id : org.usage }
|
|
3761
|
+
});
|
|
3762
|
+
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
if( leadId ){
|
|
3766
|
+
|
|
3767
|
+
writes.push({
|
|
3768
|
+
collection : 'lead',
|
|
3769
|
+
data : { $inc : { 'totals.orders' : 1 } },
|
|
3770
|
+
operation : 'update',
|
|
3771
|
+
options : { bypassDocumentValidation : true },
|
|
3772
|
+
query : { id : leadId }
|
|
3773
|
+
});
|
|
3774
|
+
|
|
3775
|
+
}
|
|
3776
|
+
|
|
3777
|
+
}
|
|
3778
|
+
|
|
3779
|
+
if( discount ){
|
|
3780
|
+
|
|
3781
|
+
writes.push({
|
|
3782
|
+
collection : 'redemption',
|
|
3783
|
+
data : {
|
|
3784
|
+
advertisement : advertisementId,
|
|
3785
|
+
affiliate : affiliateId,
|
|
3786
|
+
campaign : orderCampaign,
|
|
3787
|
+
code : matchedDiscounts[ 0 ]?.code || null,
|
|
3788
|
+
currency : currencyCode,
|
|
3789
|
+
customer,
|
|
3790
|
+
discount,
|
|
3791
|
+
gross,
|
|
3792
|
+
lead : leadId,
|
|
3793
|
+
order : orderDocId,
|
|
3794
|
+
organization : campaignOrganization,
|
|
3795
|
+
page : pageId,
|
|
3796
|
+
provider : { id : String( orderId ), slug : 'shopify' },
|
|
3797
|
+
purchasedAt,
|
|
3798
|
+
source,
|
|
3799
|
+
status : 'completed'
|
|
3800
|
+
},
|
|
3801
|
+
operation : 'create'
|
|
3802
|
+
});
|
|
3803
|
+
|
|
3804
|
+
if( org?.usage ){
|
|
3805
|
+
|
|
3806
|
+
writes.push({
|
|
3807
|
+
collection : 'usage',
|
|
3808
|
+
data : { $inc : { 'totals.redemptions' : 1 } },
|
|
3809
|
+
operation : 'update',
|
|
3810
|
+
query : { id : org.usage }
|
|
3811
|
+
});
|
|
3812
|
+
|
|
3813
|
+
}
|
|
3814
|
+
|
|
3815
|
+
if( leadId ){
|
|
3816
|
+
|
|
3817
|
+
writes.push({
|
|
3818
|
+
collection : 'lead',
|
|
3819
|
+
data : { $inc : { 'totals.redemptions' : 1 } },
|
|
3820
|
+
operation : 'update',
|
|
3821
|
+
options : { bypassDocumentValidation : true },
|
|
3822
|
+
query : { id : leadId }
|
|
3823
|
+
});
|
|
3824
|
+
|
|
3825
|
+
}
|
|
3826
|
+
|
|
3827
|
+
}
|
|
3828
|
+
|
|
3829
|
+
// SHOPIFY-BILLED ORGS ARE CHARGED THROUGH SHOPIFY, keyed on the order
|
|
3830
|
+
// id so a redelivery cannot charge twice. Never on a backfill: the fee
|
|
3831
|
+
// was charged when the order was first recorded. Enqueues run after
|
|
3832
|
+
// the transaction commits, so the job can never observe rows that
|
|
3833
|
+
// roll back.
|
|
3834
|
+
const enqueues = ( org?.billingProvider === 'shopify' && fee > 0 && connection?.source?.id && ! backfill )
|
|
3835
|
+
? [ {
|
|
3836
|
+
data : {
|
|
3837
|
+
idempotencyKey : String( orderId ),
|
|
3838
|
+
orderDocId,
|
|
3839
|
+
orderId : String( orderId ),
|
|
3840
|
+
rate,
|
|
3841
|
+
shopId : connection.source.id,
|
|
3842
|
+
// The App Events API returns no event id, so one is generated
|
|
3843
|
+
// here — the event handle plus the order id — and sent as the
|
|
3844
|
+
// event's `reference`. queue/usage.js stamps the same id onto
|
|
3845
|
+
// the order as billed.transaction.
|
|
3846
|
+
transaction : 'drawbridge-orders.' + orderId,
|
|
3847
|
+
value : Math.round( fee * 100 )
|
|
3848
|
+
},
|
|
3849
|
+
name : 'billing',
|
|
3850
|
+
options : { jobId : 'shopify.usage.' + orderId },
|
|
3851
|
+
queue : 'usage'
|
|
3852
|
+
} ]
|
|
3853
|
+
: [];
|
|
3854
|
+
|
|
3855
|
+
return {
|
|
3856
|
+
enqueues,
|
|
3857
|
+
message : backfill
|
|
3858
|
+
? 'Redemption backfilled for an already-recorded order.'
|
|
3859
|
+
: isConversion ? 'Order recorded.' : 'Discount redemption recorded (fee-free).',
|
|
3860
|
+
request,
|
|
3861
|
+
response : {
|
|
3862
|
+
campaign : orderCampaign,
|
|
3863
|
+
currency : currencyCode,
|
|
3864
|
+
discount,
|
|
3865
|
+
fee,
|
|
3866
|
+
gross,
|
|
3867
|
+
lead : leadId,
|
|
3868
|
+
lines : lines.length,
|
|
3869
|
+
net,
|
|
3870
|
+
orderId : String( orderId )
|
|
3871
|
+
},
|
|
3872
|
+
// ONE TRANSACTION. The order, the redemption and both totals
|
|
3873
|
+
// counters land together or not at all — a half-written attribution
|
|
3874
|
+
// is revenue counted twice or not at all, and neither is
|
|
3875
|
+
// recoverable by hand.
|
|
3876
|
+
transaction : writes.length > 0,
|
|
3877
|
+
writes
|
|
3878
|
+
};
|
|
3879
|
+
|
|
3880
|
+
},
|
|
3881
|
+
|
|
3882
|
+
// A PRODUCT CHANGED AT THE STORE. Upserts the product row and hands it to
|
|
3883
|
+
// the product pipeline; the actual field sync happens there.
|
|
3884
|
+
//
|
|
3885
|
+
// The shell has already refused a missing or inactive Shopify connection,
|
|
3886
|
+
// so what is left is the two things only this hook can know are wrong.
|
|
3887
|
+
product : async ( { connection, context, workflow }, { mintId } = {} ) => {
|
|
3888
|
+
|
|
3889
|
+
const request = {
|
|
3890
|
+
numericId : context?.id || null,
|
|
3891
|
+
organizationId : workflow.organization,
|
|
3892
|
+
title : context?.title || null
|
|
3893
|
+
};
|
|
3894
|
+
|
|
3895
|
+
if( ! context?.id ) return { message : 'Skipped — product webhook payload had no id.', request, response : { skipped : true }, skipped : true };
|
|
3896
|
+
|
|
3897
|
+
// The shop domain is half the identity below. Without it the upsert
|
|
3898
|
+
// would match on provider id alone and could collide across stores.
|
|
3899
|
+
if( ! connection.shop ) return { message : 'Skipped — Shopify connection is missing shop domain.', request, response : { skipped : true }, skipped : true };
|
|
3900
|
+
|
|
3901
|
+
const providerId = 'gid://shopify/Product/' + context.id;
|
|
3902
|
+
|
|
3903
|
+
// Minted so the enqueue can name the row this upsert makes — and the
|
|
3904
|
+
// job carries the PROVIDER identity too, because under a concurrent
|
|
3905
|
+
// redelivery this id may be the one that lost the upsert race. The
|
|
3906
|
+
// worker falls back to provider + shop, which are stable either way.
|
|
3907
|
+
const productId = mintId();
|
|
3908
|
+
|
|
3909
|
+
return {
|
|
3910
|
+
enqueues : [ {
|
|
3911
|
+
data : { product : productId, providerId, shop : connection.shop },
|
|
3912
|
+
name : 'workflow',
|
|
3913
|
+
options : { jobId : 'product.workflow.shopify.' + providerId + '.' + Date.now() },
|
|
3914
|
+
queue : 'product.shopify'
|
|
3915
|
+
} ],
|
|
3916
|
+
message : 'Product sync queued from Shopify webhook.',
|
|
3917
|
+
request,
|
|
3918
|
+
response : { productId, providerId, title : context?.title || null },
|
|
3919
|
+
// KEYED ON PROVIDER + SHOP, so the same product in two stores stays
|
|
3920
|
+
// two rows. `connections` accumulates rather than replaces: one
|
|
3921
|
+
// store can be linked to several organizations, and each keeps its
|
|
3922
|
+
// own claim on the row.
|
|
3923
|
+
writes : [ {
|
|
3924
|
+
collection : 'product',
|
|
3925
|
+
data : {
|
|
3926
|
+
$addToSet : { connections : connection.id },
|
|
3927
|
+
$setOnInsert : {
|
|
3928
|
+
id : productId,
|
|
3929
|
+
provider : { id : providerId, slug : 'shopify' },
|
|
3930
|
+
'source.id' : connection.id,
|
|
3931
|
+
status : 'active'
|
|
3932
|
+
}
|
|
3933
|
+
},
|
|
3934
|
+
operation : 'update',
|
|
3935
|
+
options : { upsert : true },
|
|
3936
|
+
query : {
|
|
3937
|
+
'provider.id' : providerId,
|
|
3938
|
+
'provider.slug' : 'shopify',
|
|
3939
|
+
'source.domain' : connection.shop
|
|
3940
|
+
}
|
|
3941
|
+
} ]
|
|
3942
|
+
};
|
|
3943
|
+
|
|
3944
|
+
}
|
|
3945
|
+
|
|
2408
3946
|
},
|
|
2409
3947
|
contacts : { remove : false, sync : false },
|
|
2410
3948
|
|
|
@@ -2423,8 +3961,22 @@ var shopify = {
|
|
|
2423
3961
|
sms : false,
|
|
2424
3962
|
inbound : {
|
|
2425
3963
|
event : ( args ) => readEventHeader({ ...args, descriptor : inbound }),
|
|
2426
|
-
|
|
2427
|
-
|
|
3964
|
+
// One hook over the whole topic table, because that is what this
|
|
3965
|
+
// manifest declares: Shopify processes its own buffered events. The
|
|
3966
|
+
// topic rides in on the context rather than being a second hook name per
|
|
3967
|
+
// topic; the caller's handler table arrives as a prop.
|
|
3968
|
+
process : async ( { context }, { dispatch } = {} ) => {
|
|
3969
|
+
|
|
3970
|
+
const key = 'shopify.' + context?.topic;
|
|
3971
|
+
|
|
3972
|
+
const handled = await dispatch({ data : context?.data, handler : key });
|
|
3973
|
+
|
|
3974
|
+
if( ! handled ) return { message : 'No handler for ' + key, skipped : true };
|
|
3975
|
+
|
|
3976
|
+
return { message : 'Processed ' + key, request : { topic : context?.topic } };
|
|
3977
|
+
|
|
3978
|
+
},
|
|
3979
|
+
receive : ( { channel, event, headers, payload } ) => {
|
|
2428
3980
|
|
|
2429
3981
|
if( channel === 'compliance' && ! COMPLIANCE_TOPICS.has( event ) ){
|
|
2430
3982
|
|
|
@@ -2446,7 +3998,173 @@ var shopify = {
|
|
|
2446
3998
|
},
|
|
2447
3999
|
verify : ( args ) => verifySignature({ ...args, descriptor : inbound })
|
|
2448
4000
|
},
|
|
2449
|
-
lifecycle : {
|
|
4001
|
+
lifecycle : {
|
|
4002
|
+
|
|
4003
|
+
// DISPATCHES INTO THE CALLER'S OWN COORDINATORS. These three are
|
|
4004
|
+
// declarations made true: the work is queue orchestration over
|
|
4005
|
+
// Drawbridge's own collections, which is coordinator work and stays in
|
|
4006
|
+
// the repo that owns the queues. The hook receives the dispatch table as
|
|
4007
|
+
// a prop and picks the entry, so the manifest owns the SEAM — asking
|
|
4008
|
+
// Shopify whether it handles its own lifecycle now gets a real function
|
|
4009
|
+
// instead of `unimplemented` while the work happened anyway.
|
|
4010
|
+
cleanup : async ( { context }, { dispatch } = {} ) => {
|
|
4011
|
+
|
|
4012
|
+
await dispatch({ data : context, handler : 'cleanup' });
|
|
4013
|
+
|
|
4014
|
+
return { message : 'Ran shopify lifecycle.cleanup', request : context || null };
|
|
4015
|
+
|
|
4016
|
+
},
|
|
4017
|
+
|
|
4018
|
+
// KEEP STORE ACCESS WORKING. Not a webhook monitor, despite the name the
|
|
4019
|
+
// step once carried — webhooks are declarative, declared in the app's
|
|
4020
|
+
// toml and applied by Shopify to every install, so nothing here registers
|
|
4021
|
+
// or checks them.
|
|
4022
|
+
//
|
|
4023
|
+
// It rotates the refresh token before its window closes, proves the
|
|
4024
|
+
// access token still works, reconciles the scopes the store granted
|
|
4025
|
+
// against the ones the app now needs, and queues a webhook
|
|
4026
|
+
// reconciliation.
|
|
4027
|
+
health : async ( { connection, workflow }, { adminToken, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {} ) => {
|
|
4028
|
+
|
|
4029
|
+
const request = {
|
|
4030
|
+
connectionId : workflow.connection,
|
|
4031
|
+
organizationId : workflow.organization,
|
|
4032
|
+
shop : connection.shop
|
|
4033
|
+
};
|
|
4034
|
+
|
|
4035
|
+
// Captured BEFORE anything runs. If the calls below fail with
|
|
4036
|
+
// invalid_grant, this is what distinguishes "the merchant revoked us"
|
|
4037
|
+
// from "a concurrent rotation spent the token we were holding" — and
|
|
4038
|
+
// only the first should error the connection.
|
|
4039
|
+
const refreshTokenAtStart = ( await resolveSettings() ).refreshToken || null;
|
|
4040
|
+
|
|
4041
|
+
try {
|
|
4042
|
+
|
|
4043
|
+
const adminAccessToken = await adminToken();
|
|
4044
|
+
|
|
4045
|
+
const settings = await resolveSettings();
|
|
4046
|
+
|
|
4047
|
+
const refreshTokenExpiresAt = settings.refreshTokenExpiresAt;
|
|
4048
|
+
|
|
4049
|
+
const needsRotation = refreshTokenExpiresAt
|
|
4050
|
+
&& new Date( refreshTokenExpiresAt ) < new Date( Date.now() + REFRESH_TOKEN_FRESHNESS_BUFFER_MS );
|
|
4051
|
+
|
|
4052
|
+
let refreshTokenRotated = false;
|
|
4053
|
+
|
|
4054
|
+
if( needsRotation ){
|
|
4055
|
+
|
|
4056
|
+
await rotateToken();
|
|
4057
|
+
|
|
4058
|
+
refreshTokenRotated = true;
|
|
4059
|
+
|
|
4060
|
+
}
|
|
4061
|
+
|
|
4062
|
+
await shopify.oauth.ping({ adminAccessToken, domain : connection.shop });
|
|
4063
|
+
|
|
4064
|
+
// SCOPE DRIFT IS ITS OWN FAILURE. A token can be perfectly valid
|
|
4065
|
+
// while the grant is too narrow, which no ping would ever reveal.
|
|
4066
|
+
// Injected rather than described: reconciliation is the caller's
|
|
4067
|
+
// writer-of-record routine, shared with the webhook drain, and this
|
|
4068
|
+
// hook needs its RESULT to compose the message below.
|
|
4069
|
+
const scopesMissing = await reconcileScopes({ shop : connection.shop });
|
|
4070
|
+
|
|
4071
|
+
return {
|
|
4072
|
+
enqueues : [ {
|
|
4073
|
+
data : {
|
|
4074
|
+
data : {
|
|
4075
|
+
connectionId : workflow.connection,
|
|
4076
|
+
organizationId : workflow.organization
|
|
4077
|
+
},
|
|
4078
|
+
event : 'shopify.register.webhooks'
|
|
4079
|
+
},
|
|
4080
|
+
name : 'register',
|
|
4081
|
+
options : { jobId : 'connection.update.register.' + workflow.connection + '.' + randomUUID() },
|
|
4082
|
+
queue : 'connection'
|
|
4083
|
+
} ],
|
|
4084
|
+
message : scopesMissing?.length
|
|
4085
|
+
? 'Health check: ping ok, webhooks reconciled — connection errored, granted scopes are missing: ' + scopesMissing.join( ', ' ) + '.'
|
|
4086
|
+
: refreshTokenRotated
|
|
4087
|
+
? 'Health check passed — refresh token rotated, ping ok, webhooks reconciled.'
|
|
4088
|
+
: 'Health check passed — ping ok, webhooks reconciled.',
|
|
4089
|
+
request,
|
|
4090
|
+
response : {
|
|
4091
|
+
pingedAt : new Date(),
|
|
4092
|
+
refreshTokenExpiresAt : refreshTokenExpiresAt || null,
|
|
4093
|
+
refreshTokenRotated,
|
|
4094
|
+
scopesMissing,
|
|
4095
|
+
webhookReconciliationQueued : true
|
|
4096
|
+
}
|
|
4097
|
+
};
|
|
4098
|
+
|
|
4099
|
+
} catch ( error ){
|
|
4100
|
+
|
|
4101
|
+
// A REVOKED GRANT IS THE MERCHANT'S TO FIX, so the connection says
|
|
4102
|
+
// so rather than failing silently on a schedule nobody watches. The
|
|
4103
|
+
// write rides OUT ON THE REJECTION — the contract performs a thrown
|
|
4104
|
+
// error's effects — because the step must still fail.
|
|
4105
|
+
if( OAUTH_GRANT_REVOKED_CODES.includes( error.code ) ){
|
|
4106
|
+
|
|
4107
|
+
const current = await read.get({ collection : 'connection', query : { id : connection.id } });
|
|
4108
|
+
|
|
4109
|
+
// Against the FRESH doc, not the one this run started with — a legacy
|
|
4110
|
+
// connection keeps its tokens in its own settings blob, and a
|
|
4111
|
+
// concurrent rotation rewrote that blob after our snapshot.
|
|
4112
|
+
const refreshTokenStored = current ? ( await resolveSettings( current ) ).refreshToken || null : null;
|
|
4113
|
+
|
|
4114
|
+
// A CONCURRENT ROTATION, not a revocation: another job spent the
|
|
4115
|
+
// refresh token between our read and our use of it. Erroring the
|
|
4116
|
+
// connection here would disconnect a store that is working fine.
|
|
4117
|
+
const rotated = error.code === 'invalid_grant' && refreshTokenStored !== refreshTokenAtStart;
|
|
4118
|
+
|
|
4119
|
+
if( current && ! rotated ){
|
|
4120
|
+
|
|
4121
|
+
const others = ( current.errors || [] ).filter( ( entry ) => entry.source !== OAUTH_ERROR_SOURCE );
|
|
4122
|
+
|
|
4123
|
+
error.writes = [ {
|
|
4124
|
+
collection : 'connection',
|
|
4125
|
+
data : {
|
|
4126
|
+
$set : {
|
|
4127
|
+
errors : [
|
|
4128
|
+
...others,
|
|
4129
|
+
{
|
|
4130
|
+
message : 'Shopify disconnected this store. Open the Drawbridge app in your Shopify admin to reconnect.',
|
|
4131
|
+
source : OAUTH_ERROR_SOURCE
|
|
4132
|
+
}
|
|
4133
|
+
],
|
|
4134
|
+
status : 'error'
|
|
4135
|
+
}
|
|
4136
|
+
},
|
|
4137
|
+
operation : 'update',
|
|
4138
|
+
query : { id : connection.id }
|
|
4139
|
+
} ];
|
|
4140
|
+
|
|
4141
|
+
}
|
|
4142
|
+
|
|
4143
|
+
}
|
|
4144
|
+
|
|
4145
|
+
throw error;
|
|
4146
|
+
|
|
4147
|
+
}
|
|
4148
|
+
|
|
4149
|
+
},
|
|
4150
|
+
|
|
4151
|
+
register : async ( { context }, { dispatch } = {} ) => {
|
|
4152
|
+
|
|
4153
|
+
await dispatch({ data : context, handler : 'register' });
|
|
4154
|
+
|
|
4155
|
+
return { message : 'Ran shopify lifecycle.register', request : context || null };
|
|
4156
|
+
|
|
4157
|
+
},
|
|
4158
|
+
|
|
4159
|
+
rehydrate : async ( { context }, { dispatch } = {} ) => {
|
|
4160
|
+
|
|
4161
|
+
await dispatch({ data : context, handler : 'rehydrate' });
|
|
4162
|
+
|
|
4163
|
+
return { message : 'Ran shopify lifecycle.rehydrate', request : context || null };
|
|
4164
|
+
|
|
4165
|
+
}
|
|
4166
|
+
|
|
4167
|
+
},
|
|
2450
4168
|
resources : {
|
|
2451
4169
|
audiences : false,
|
|
2452
4170
|
// Shopify has no separate price resource — a price belongs to a product
|
|
@@ -2467,7 +4185,7 @@ var shopify = {
|
|
|
2467
4185
|
// credential is the caller's job because it is Drawbridge's job: the
|
|
2468
4186
|
// admin token refreshes and writes itself back, which is service work,
|
|
2469
4187
|
// not vendor work.
|
|
2470
|
-
products : async ({ cursor, limit = 100, search, settings,
|
|
4188
|
+
products : async ( { cursor, limit = 100, search, settings, sort }, { shopify } = {} ) => {
|
|
2471
4189
|
|
|
2472
4190
|
const products = await shopify.storefront.getProducts({
|
|
2473
4191
|
cursor,
|
|
@@ -2490,7 +4208,7 @@ var shopify = {
|
|
|
2490
4208
|
|
|
2491
4209
|
},
|
|
2492
4210
|
|
|
2493
|
-
promotions : async ({ cursor, limit = 100, search, settings, shopify }) => {
|
|
4211
|
+
promotions : async ( { cursor, limit = 100, search, settings }, { shopify } = {} ) => {
|
|
2494
4212
|
|
|
2495
4213
|
const discounts = await shopify.admin.getDiscounts({
|
|
2496
4214
|
adminAccessToken : settings?.adminAccessToken,
|
|
@@ -2574,9 +4292,6 @@ var shopify = {
|
|
|
2574
4292
|
// workflow document, and those strings cannot be renamed without a backfill.
|
|
2575
4293
|
//
|
|
2576
4294
|
// 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
4295
|
steps : {
|
|
2581
4296
|
|
|
2582
4297
|
commerce : {
|
|
@@ -2785,7 +4500,7 @@ var webhook = {
|
|
|
2785
4500
|
// needs Drawbridge's own database, sockets or queues. This one does not.
|
|
2786
4501
|
webhook : {
|
|
2787
4502
|
|
|
2788
|
-
send : async ({ context,
|
|
4503
|
+
send : async ( { context, lead, settings, step }, { request : send = safeRequest } = {} ) => {
|
|
2789
4504
|
|
|
2790
4505
|
const { headers = {}, method = 'POST', url } = step.settings || {};
|
|
2791
4506
|
|
|
@@ -2794,11 +4509,9 @@ var webhook = {
|
|
|
2794
4509
|
if( ! url ) return { message : 'Outgoing webhook URL is not configured for this step.', request, response : { skipped : true }, skipped : true };
|
|
2795
4510
|
|
|
2796
4511
|
// 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
|
-
|
|
4512
|
+
// receiver wants the entrant's record, not our internal step state —
|
|
4513
|
+
// and the shell already resolved it, because every hook that names a
|
|
4514
|
+
// lead gets the document and its consent together.
|
|
2802
4515
|
const body = lead || context;
|
|
2803
4516
|
|
|
2804
4517
|
request.body = body;
|
|
@@ -2864,7 +4577,7 @@ var webhook = {
|
|
|
2864
4577
|
// pressing Connect will do; afterwards it states the verification the
|
|
2865
4578
|
// merchant's own endpoint has to perform, because a signed payload nobody
|
|
2866
4579
|
// checks is an unsigned payload.
|
|
2867
|
-
tasks : ({ settings }) => ( settings?.secret
|
|
4580
|
+
tasks : ( { settings } ) => ( settings?.secret
|
|
2868
4581
|
? [
|
|
2869
4582
|
{
|
|
2870
4583
|
message : 'Compute HMAC-SHA256( secret, body ) and compare against the X-Drawbridge-Signature header to confirm each payload.',
|
|
@@ -3361,10 +5074,13 @@ const connections = Object.freeze({
|
|
|
3361
5074
|
|
|
3362
5075
|
})();
|
|
3363
5076
|
|
|
3364
|
-
// The vendors whose
|
|
3365
|
-
// the
|
|
3366
|
-
//
|
|
3367
|
-
//
|
|
5077
|
+
// The vendors whose credentials are actually present in the map handed in —
|
|
5078
|
+
// which the callers build from the provider collection, with the deployment's
|
|
5079
|
+
// environment answering only for names no provider field owns
|
|
5080
|
+
// (ENCRYPT_CONNECTION_SECRET). Every consumer asks this the same way, so "is
|
|
5081
|
+
// Shopify available" has one answer rather than one per repo — the api once
|
|
5082
|
+
// gated it on four env vars and sync inferred it from a different signal, which
|
|
5083
|
+
// is how the two drifted.
|
|
3368
5084
|
const availableConnections = ( env = {} ) => Object.fromEntries(
|
|
3369
5085
|
Object.entries( connections ).filter(
|
|
3370
5086
|
( [ , manifest ] ) => manifest.requires.every( ( name ) => Boolean( env[ name ] ) )
|
|
@@ -3460,7 +5176,9 @@ const connectFields = ( slug ) => ( connections[ slug ]?.fields || [] )
|
|
|
3460
5176
|
// The four outcomes are the point. "unsupported", "unimplemented", "failed" and
|
|
3461
5177
|
// a result are different answers to why there is no data, and collapsing them is
|
|
3462
5178
|
// how "this vendor cannot do that" becomes indistinguishable from "it broke".
|
|
3463
|
-
|
|
5179
|
+
// TWO OBJECTS: props are facts of the run, options are services being passed
|
|
5180
|
+
// in. A caller with no services to inject omits the second bag.
|
|
5181
|
+
const runHook = async ( slug, name, props = {}, options = {} ) => {
|
|
3464
5182
|
|
|
3465
5183
|
const manifest = connections[ slug ];
|
|
3466
5184
|
|
|
@@ -3468,7 +5186,17 @@ const runHook = async ( slug, name, args = {} ) => {
|
|
|
3468
5186
|
|
|
3469
5187
|
// The hook's own value is the answer — there is no `supports` map to consult,
|
|
3470
5188
|
// and therefore none to disagree with what is actually here.
|
|
3471
|
-
|
|
5189
|
+
//
|
|
5190
|
+
// OWN PROPERTIES ONLY. A bare property walk resolves 'resources.constructor'
|
|
5191
|
+
// to Object.prototype.constructor — a truthy FUNCTION, so every guard on
|
|
5192
|
+
// "does the vendor implement this" passes and runHook calls Object() with
|
|
5193
|
+
// the props, answering the caller's own payload back as a result. With the
|
|
5194
|
+
// api's hook route caching answers in shared Redis, that payload holds a
|
|
5195
|
+
// live admin token. Same fix providerFields already carries.
|
|
5196
|
+
const hook = name.split( '.' ).reduce(
|
|
5197
|
+
( node, key ) => ( node && typeof node === 'object' && Object.hasOwn( node, key ) ) ? node[ key ] : undefined,
|
|
5198
|
+
manifest.hooks
|
|
5199
|
+
);
|
|
3472
5200
|
|
|
3473
5201
|
if( hook === false || hook == null ){
|
|
3474
5202
|
|
|
@@ -3492,7 +5220,16 @@ const runHook = async ( slug, name, args = {} ) => {
|
|
|
3492
5220
|
// disconnect until auth.oauth.urls gathered every vendor address in one
|
|
3493
5221
|
// place, and a hook that cannot see its own manifest would have forced it
|
|
3494
5222
|
// back. Callers never have to know to pass it.
|
|
3495
|
-
|
|
5223
|
+
const result = await hook({ ...props, manifest }, options );
|
|
5224
|
+
|
|
5225
|
+
// THE ANSWER IS CHECKED WHERE IT IS PRODUCED. A hook may describe writes,
|
|
5226
|
+
// enqueues and events for its caller to perform, and a malformed descriptor
|
|
5227
|
+
// is a write against the wrong collection — so it is refused here, at the one
|
|
5228
|
+
// call surface, rather than surviving as far as whoever performs it. A hook
|
|
5229
|
+
// that describes nothing (every resources.* read) validates to nothing.
|
|
5230
|
+
effectsOf( result );
|
|
5231
|
+
|
|
5232
|
+
return { outcome : OUTCOMES.answered, result };
|
|
3496
5233
|
|
|
3497
5234
|
} catch ( error ) {
|
|
3498
5235
|
|
|
@@ -3681,4 +5418,4 @@ const resolveConnection = ( item, data, env = {} ) => {
|
|
|
3681
5418
|
|
|
3682
5419
|
};
|
|
3683
5420
|
|
|
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 };
|
|
5421
|
+
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 };
|