@drawbridge/drawbridge-utils 0.0.134 → 0.0.136

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.
@@ -741,8 +741,12 @@ const accessToken = async ({
741
741
 
742
742
  // Expired with no way to mint another. Said plainly rather than returning
743
743
  // a token the vendor will refuse — the merchant has to reconnect and the
744
- // error should say so.
745
- throw new Error( 'This connection has expired and cannot be renewed automatically. Reconnect it.' );
744
+ // error should say so. Same `code` as a refused refresh: both mean the
745
+ // grant is dead and only reconnecting revives it.
746
+ throw Object.assign(
747
+ new Error( 'This connection has expired and cannot be renewed automatically. Reconnect it.' ),
748
+ { code : 'grant_refused' }
749
+ );
746
750
 
747
751
  }
748
752
 
@@ -1659,19 +1663,25 @@ const teamRecipients = async ({ memberIds = [], organization, read }) => {
1659
1663
  // different teammates collapse into one and the second is never told.
1660
1664
  const seen = new Set();
1661
1665
 
1662
- return [ owner, ...members ].filter( ( member ) => {
1666
+ // THE ORG DOCUMENT COMES BACK TOO. It was read here anyway, and notify names
1667
+ // the organization in its copy — a teammate can belong to more than one, so
1668
+ // re-reading it there would be a second query for a document this already has.
1669
+ return {
1670
+ org,
1671
+ recipients : [ owner, ...members ].filter( ( member ) => {
1663
1672
 
1664
- if( ! member?.id || ! member?.email ) return false;
1673
+ if( ! member?.id || ! member?.email ) return false;
1665
1674
 
1666
- const address = member.email.toLowerCase();
1675
+ const address = member.email.toLowerCase();
1667
1676
 
1668
- if( seen.has( address ) ) return false;
1677
+ if( seen.has( address ) ) return false;
1669
1678
 
1670
- seen.add( address );
1679
+ seen.add( address );
1671
1680
 
1672
- return true;
1681
+ return true;
1673
1682
 
1674
- });
1683
+ })
1684
+ };
1675
1685
 
1676
1686
  };
1677
1687
 
@@ -1764,7 +1774,7 @@ var drawbridge = {
1764
1774
  // mail the merchant did not ask for and would learn to ignore.
1765
1775
  if( ! count ) return { message : 'No new leads in the period — digest skipped.', request, response : { skipped : true }, skipped : true };
1766
1776
 
1767
- const recipients = await teamRecipients({
1777
+ const { recipients } = await teamRecipients({
1768
1778
  memberIds : step.settings?.members || [],
1769
1779
  organization : workflow.organization,
1770
1780
  read
@@ -1805,7 +1815,7 @@ var drawbridge = {
1805
1815
 
1806
1816
  const request = { members : memberIds.length };
1807
1817
 
1808
- const recipients = await teamRecipients({ memberIds, organization : workflow.organization, read });
1818
+ const { org, recipients } = await teamRecipients({ memberIds, organization : workflow.organization, read });
1809
1819
 
1810
1820
  if( ! recipients.length ){
1811
1821
 
@@ -1838,6 +1848,43 @@ var drawbridge = {
1838
1848
  // to redefine from here.
1839
1849
  const bucket = Math.floor( Date.now() / ( 15 * 60 * 1000 ) );
1840
1850
 
1851
+ // THE COPY IS OURS, not the merchant's — `step.settings.message` and
1852
+ // `.subject` are ignored here on purpose. The whole message is "a lead
1853
+ // arrived, go and look", so the useful part is the link, not the
1854
+ // wording.
1855
+ //
1856
+ // It NAMES THE CAMPAIGN AND THE ORGANIZATION, because a recipient can
1857
+ // be watching several of both and a subject line reading only "New
1858
+ // lead" makes them open the mail to find out which. The campaign title
1859
+ // is the one extra read this step makes; the org came back with the
1860
+ // recipients.
1861
+ const campaign = context?.campaign
1862
+ ? await read.get({ collection : 'campaign', query : { id : context.campaign } })
1863
+ : null;
1864
+
1865
+ // The href is DASHBOARD-RELATIVE: a manifest must not read
1866
+ // process.env (see lib/connections/index.js), so the deployment's base
1867
+ // url is applied by the sender — drawbridge-sync's queue/notification.js
1868
+ // — before it renders. Absent a campaign or lead on the context there
1869
+ // is nothing to point at, and no button is drawn.
1870
+ const link = ( context?.campaign && context?.lead )
1871
+ ? {
1872
+ href : '/organizations/' + workflow.organization + '/campaigns/' + context.campaign + '/leads/' + context.lead,
1873
+ text : 'View lead'
1874
+ }
1875
+ : null;
1876
+
1877
+ // Every part is optional and every part is dropped when it is missing,
1878
+ // so the worst case reads 'Someone just entered.' rather than a
1879
+ // sentence with holes in it.
1880
+ const body = ( context?.email || 'Someone' )
1881
+ + ' just entered'
1882
+ + ( campaign?.title ? ' ' + campaign.title : '' )
1883
+ + ( org?.title ? ' at ' + org.title : '' )
1884
+ + '.';
1885
+
1886
+ const subject = 'New lead' + ( campaign?.title ? ' — ' + campaign.title : '' );
1887
+
1841
1888
  return {
1842
1889
  message : 'Team notification queued for ' + recipients.length + ' recipient(s).',
1843
1890
  request,
@@ -1849,10 +1896,11 @@ var drawbridge = {
1849
1896
  // can never swallow another's mail and a later bucket is never
1850
1897
  // mistaken for a duplicate of an earlier one.
1851
1898
  key : 'team.notify.' + workflow.id + '.' + member.id + '.' + bucket,
1852
- message : interpolate( step.settings?.message, context ),
1899
+ link,
1900
+ message : body,
1853
1901
  organization : workflow.organization,
1854
1902
  send : { type : 'email', email : member.email },
1855
- title : interpolate( step.settings?.subject, context ),
1903
+ title : subject,
1856
1904
  workflow : workflow.id
1857
1905
  }),
1858
1906
  // E11000 IS THE DAMPER WORKING: this recipient has already been
@@ -2412,14 +2460,24 @@ var drawbridge = {
2412
2460
 
2413
2461
  // To organization MEMBERS. Never suppressed — an entrant's opt-out must
2414
2462
  // not silence an alert to staff — and not billed.
2463
+ //
2464
+ // ITS COPY IS WRITTEN, NOT ASKED FOR. This says the same thing every
2465
+ // time — a lead arrived, here it is — so the merchant chooses who hears
2466
+ // it, not how it reads, and the hook composes the subject, the body and
2467
+ // the link to the lead.
2468
+ //
2469
+ // `message` and `subject` stay declared, optional and IGNORED: stored
2470
+ // workflows still carry them and the api validates settings with
2471
+ // noUnknown().strict(), so dropping the keys would 400 the next save of
2472
+ // a workflow built before this.
2415
2473
  notify : () => ({
2416
2474
  hook : 'email.notify',
2417
2475
  key : 'Email — Notification',
2418
2476
  queue : 'notification',
2419
2477
  settings : {
2420
2478
  members : { of : 'string', type : 'array' },
2421
- message : { required : true, type : 'string' },
2422
- subject : { required : true, type : 'string' }
2479
+ message : { type : 'string' },
2480
+ subject : { type : 'string' }
2423
2481
  },
2424
2482
  triggers : [ 'lead.insert' ],
2425
2483
  // Zero is a PRICE, and a deliberate one. Declared rather than omitted
@@ -2941,8 +2999,84 @@ var klaviyo = {
2941
2999
  inbound : false,
2942
3000
 
2943
3001
  // Nothing to set up or tear down at the vendor: the grant is the whole
2944
- // integration, and revoking it is auth.disconnect's job.
2945
- lifecycle : false,
3002
+ // integration. What CAN rot is the grant itself, so health is the one
3003
+ // lifecycle hook Klaviyo carries.
3004
+ lifecycle : {
3005
+
3006
+ // Nothing to set up, tear down, or re-register at the vendor — the
3007
+ // grant is the whole integration.
3008
+ cleanup : false,
3009
+ register : false,
3010
+ rehydrate : false,
3011
+
3012
+ // KEEP THE GRANT PROVEN AND WARM. The shell mints a fresh access
3013
+ // token before every hook run — for Klaviyo that spends the refresh
3014
+ // token, which is the only question that reaches Klaviyo (see
3015
+ // auth.probe) — so this body running at all proves the grant still
3016
+ // rotates, and the daily run keeps it inside Klaviyo's 90-day idle
3017
+ // window. A dead grant never gets here: the shell's mint throws
3018
+ // `grant_refused` and the step runner errors the connection itself.
3019
+ //
3020
+ // The one call below proves the minted token is HONOURED — mint and
3021
+ // acceptance are different facts, and /accounts is already the call
3022
+ // the connect flow makes (auth.connect), so it needs no new scope.
3023
+ health : async ( { connection, token }, { fetcher, read } = {} ) => {
3024
+
3025
+ const request = { connectionId : connection.id };
3026
+
3027
+ try {
3028
+
3029
+ await api$1( '/accounts', { fetcher, token });
3030
+
3031
+ return {
3032
+ message : 'Health check passed — token minted and accepted.',
3033
+ request,
3034
+ response : { pingedAt : new Date() }
3035
+ };
3036
+
3037
+ } catch ( error ) {
3038
+
3039
+ // The token we JUST minted was refused — a revoked or
3040
+ // downgraded grant, never a transient. The write rides out
3041
+ // on the rejection (the shell performs a thrown error's
3042
+ // effects) because the step must still fail.
3043
+ if( [ 401, 403 ].includes( error.status ) && read ){
3044
+
3045
+ const current = await read.get({ collection : 'connection', query : { id : connection.id } });
3046
+
3047
+ if( current ){
3048
+
3049
+ const others = ( current.errors || [] ).filter( ( entry ) => entry.source !== 'oauth' );
3050
+
3051
+ error.writes = [ {
3052
+ collection : 'connection',
3053
+ data : {
3054
+ $set : {
3055
+ errors : [
3056
+ ...others,
3057
+ {
3058
+ message : 'Klaviyo no longer accepts this connection. Reconnect Klaviyo to resume syncing.',
3059
+ source : 'oauth'
3060
+ }
3061
+ ],
3062
+ status : 'error'
3063
+ }
3064
+ },
3065
+ operation : 'update',
3066
+ query : { id : connection.id }
3067
+ } ];
3068
+
3069
+ }
3070
+
3071
+ }
3072
+
3073
+ throw error;
3074
+
3075
+ }
3076
+
3077
+ }
3078
+
3079
+ },
2946
3080
 
2947
3081
  resources : {
2948
3082
 
@@ -3049,6 +3183,22 @@ var klaviyo = {
3049
3183
 
3050
3184
  steps : {
3051
3185
 
3186
+ connection : {
3187
+
3188
+ // The daily grant check — same step type and workflow shape as
3189
+ // Shopify's, provisioned as a system workflow per connection.
3190
+ health : {
3191
+ check : () => ({
3192
+ description : 'Keeps the Klaviyo grant working — spends the refresh token daily so a revoked or idle grant is reported instead of discovered by a failing sync.',
3193
+ hook : 'lifecycle.health',
3194
+ key : 'Klaviyo Connection Health',
3195
+ queue : 'connection',
3196
+ system : true
3197
+ })
3198
+ }
3199
+
3200
+ },
3201
+
3052
3202
  contacts : {
3053
3203
 
3054
3204
  // A DECLARATION, not the work. It names the hook that does the work, and
@@ -741,8 +741,12 @@ const accessToken = async ({
741
741
 
742
742
  // Expired with no way to mint another. Said plainly rather than returning
743
743
  // a token the vendor will refuse — the merchant has to reconnect and the
744
- // error should say so.
745
- throw new Error( 'This connection has expired and cannot be renewed automatically. Reconnect it.' );
744
+ // error should say so. Same `code` as a refused refresh: both mean the
745
+ // grant is dead and only reconnecting revives it.
746
+ throw Object.assign(
747
+ new Error( 'This connection has expired and cannot be renewed automatically. Reconnect it.' ),
748
+ { code : 'grant_refused' }
749
+ );
746
750
 
747
751
  }
748
752
 
@@ -1659,19 +1663,25 @@ const teamRecipients = async ({ memberIds = [], organization, read }) => {
1659
1663
  // different teammates collapse into one and the second is never told.
1660
1664
  const seen = new Set();
1661
1665
 
1662
- return [ owner, ...members ].filter( ( member ) => {
1666
+ // THE ORG DOCUMENT COMES BACK TOO. It was read here anyway, and notify names
1667
+ // the organization in its copy — a teammate can belong to more than one, so
1668
+ // re-reading it there would be a second query for a document this already has.
1669
+ return {
1670
+ org,
1671
+ recipients : [ owner, ...members ].filter( ( member ) => {
1663
1672
 
1664
- if( ! member?.id || ! member?.email ) return false;
1673
+ if( ! member?.id || ! member?.email ) return false;
1665
1674
 
1666
- const address = member.email.toLowerCase();
1675
+ const address = member.email.toLowerCase();
1667
1676
 
1668
- if( seen.has( address ) ) return false;
1677
+ if( seen.has( address ) ) return false;
1669
1678
 
1670
- seen.add( address );
1679
+ seen.add( address );
1671
1680
 
1672
- return true;
1681
+ return true;
1673
1682
 
1674
- });
1683
+ })
1684
+ };
1675
1685
 
1676
1686
  };
1677
1687
 
@@ -1764,7 +1774,7 @@ var drawbridge = {
1764
1774
  // mail the merchant did not ask for and would learn to ignore.
1765
1775
  if( ! count ) return { message : 'No new leads in the period — digest skipped.', request, response : { skipped : true }, skipped : true };
1766
1776
 
1767
- const recipients = await teamRecipients({
1777
+ const { recipients } = await teamRecipients({
1768
1778
  memberIds : step.settings?.members || [],
1769
1779
  organization : workflow.organization,
1770
1780
  read
@@ -1805,7 +1815,7 @@ var drawbridge = {
1805
1815
 
1806
1816
  const request = { members : memberIds.length };
1807
1817
 
1808
- const recipients = await teamRecipients({ memberIds, organization : workflow.organization, read });
1818
+ const { org, recipients } = await teamRecipients({ memberIds, organization : workflow.organization, read });
1809
1819
 
1810
1820
  if( ! recipients.length ){
1811
1821
 
@@ -1838,6 +1848,43 @@ var drawbridge = {
1838
1848
  // to redefine from here.
1839
1849
  const bucket = Math.floor( Date.now() / ( 15 * 60 * 1000 ) );
1840
1850
 
1851
+ // THE COPY IS OURS, not the merchant's — `step.settings.message` and
1852
+ // `.subject` are ignored here on purpose. The whole message is "a lead
1853
+ // arrived, go and look", so the useful part is the link, not the
1854
+ // wording.
1855
+ //
1856
+ // It NAMES THE CAMPAIGN AND THE ORGANIZATION, because a recipient can
1857
+ // be watching several of both and a subject line reading only "New
1858
+ // lead" makes them open the mail to find out which. The campaign title
1859
+ // is the one extra read this step makes; the org came back with the
1860
+ // recipients.
1861
+ const campaign = context?.campaign
1862
+ ? await read.get({ collection : 'campaign', query : { id : context.campaign } })
1863
+ : null;
1864
+
1865
+ // The href is DASHBOARD-RELATIVE: a manifest must not read
1866
+ // process.env (see lib/connections/index.js), so the deployment's base
1867
+ // url is applied by the sender — drawbridge-sync's queue/notification.js
1868
+ // — before it renders. Absent a campaign or lead on the context there
1869
+ // is nothing to point at, and no button is drawn.
1870
+ const link = ( context?.campaign && context?.lead )
1871
+ ? {
1872
+ href : '/organizations/' + workflow.organization + '/campaigns/' + context.campaign + '/leads/' + context.lead,
1873
+ text : 'View lead'
1874
+ }
1875
+ : null;
1876
+
1877
+ // Every part is optional and every part is dropped when it is missing,
1878
+ // so the worst case reads 'Someone just entered.' rather than a
1879
+ // sentence with holes in it.
1880
+ const body = ( context?.email || 'Someone' )
1881
+ + ' just entered'
1882
+ + ( campaign?.title ? ' ' + campaign.title : '' )
1883
+ + ( org?.title ? ' at ' + org.title : '' )
1884
+ + '.';
1885
+
1886
+ const subject = 'New lead' + ( campaign?.title ? ' — ' + campaign.title : '' );
1887
+
1841
1888
  return {
1842
1889
  message : 'Team notification queued for ' + recipients.length + ' recipient(s).',
1843
1890
  request,
@@ -1849,10 +1896,11 @@ var drawbridge = {
1849
1896
  // can never swallow another's mail and a later bucket is never
1850
1897
  // mistaken for a duplicate of an earlier one.
1851
1898
  key : 'team.notify.' + workflow.id + '.' + member.id + '.' + bucket,
1852
- message : interpolate( step.settings?.message, context ),
1899
+ link,
1900
+ message : body,
1853
1901
  organization : workflow.organization,
1854
1902
  send : { type : 'email', email : member.email },
1855
- title : interpolate( step.settings?.subject, context ),
1903
+ title : subject,
1856
1904
  workflow : workflow.id
1857
1905
  }),
1858
1906
  // E11000 IS THE DAMPER WORKING: this recipient has already been
@@ -2412,14 +2460,24 @@ var drawbridge = {
2412
2460
 
2413
2461
  // To organization MEMBERS. Never suppressed — an entrant's opt-out must
2414
2462
  // not silence an alert to staff — and not billed.
2463
+ //
2464
+ // ITS COPY IS WRITTEN, NOT ASKED FOR. This says the same thing every
2465
+ // time — a lead arrived, here it is — so the merchant chooses who hears
2466
+ // it, not how it reads, and the hook composes the subject, the body and
2467
+ // the link to the lead.
2468
+ //
2469
+ // `message` and `subject` stay declared, optional and IGNORED: stored
2470
+ // workflows still carry them and the api validates settings with
2471
+ // noUnknown().strict(), so dropping the keys would 400 the next save of
2472
+ // a workflow built before this.
2415
2473
  notify : () => ({
2416
2474
  hook : 'email.notify',
2417
2475
  key : 'Email — Notification',
2418
2476
  queue : 'notification',
2419
2477
  settings : {
2420
2478
  members : { of : 'string', type : 'array' },
2421
- message : { required : true, type : 'string' },
2422
- subject : { required : true, type : 'string' }
2479
+ message : { type : 'string' },
2480
+ subject : { type : 'string' }
2423
2481
  },
2424
2482
  triggers : [ 'lead.insert' ],
2425
2483
  // Zero is a PRICE, and a deliberate one. Declared rather than omitted
@@ -2941,8 +2999,84 @@ var klaviyo = {
2941
2999
  inbound : false,
2942
3000
 
2943
3001
  // Nothing to set up or tear down at the vendor: the grant is the whole
2944
- // integration, and revoking it is auth.disconnect's job.
2945
- lifecycle : false,
3002
+ // integration. What CAN rot is the grant itself, so health is the one
3003
+ // lifecycle hook Klaviyo carries.
3004
+ lifecycle : {
3005
+
3006
+ // Nothing to set up, tear down, or re-register at the vendor — the
3007
+ // grant is the whole integration.
3008
+ cleanup : false,
3009
+ register : false,
3010
+ rehydrate : false,
3011
+
3012
+ // KEEP THE GRANT PROVEN AND WARM. The shell mints a fresh access
3013
+ // token before every hook run — for Klaviyo that spends the refresh
3014
+ // token, which is the only question that reaches Klaviyo (see
3015
+ // auth.probe) — so this body running at all proves the grant still
3016
+ // rotates, and the daily run keeps it inside Klaviyo's 90-day idle
3017
+ // window. A dead grant never gets here: the shell's mint throws
3018
+ // `grant_refused` and the step runner errors the connection itself.
3019
+ //
3020
+ // The one call below proves the minted token is HONOURED — mint and
3021
+ // acceptance are different facts, and /accounts is already the call
3022
+ // the connect flow makes (auth.connect), so it needs no new scope.
3023
+ health : async ( { connection, token }, { fetcher, read } = {} ) => {
3024
+
3025
+ const request = { connectionId : connection.id };
3026
+
3027
+ try {
3028
+
3029
+ await api$1( '/accounts', { fetcher, token });
3030
+
3031
+ return {
3032
+ message : 'Health check passed — token minted and accepted.',
3033
+ request,
3034
+ response : { pingedAt : new Date() }
3035
+ };
3036
+
3037
+ } catch ( error ) {
3038
+
3039
+ // The token we JUST minted was refused — a revoked or
3040
+ // downgraded grant, never a transient. The write rides out
3041
+ // on the rejection (the shell performs a thrown error's
3042
+ // effects) because the step must still fail.
3043
+ if( [ 401, 403 ].includes( error.status ) && read ){
3044
+
3045
+ const current = await read.get({ collection : 'connection', query : { id : connection.id } });
3046
+
3047
+ if( current ){
3048
+
3049
+ const others = ( current.errors || [] ).filter( ( entry ) => entry.source !== 'oauth' );
3050
+
3051
+ error.writes = [ {
3052
+ collection : 'connection',
3053
+ data : {
3054
+ $set : {
3055
+ errors : [
3056
+ ...others,
3057
+ {
3058
+ message : 'Klaviyo no longer accepts this connection. Reconnect Klaviyo to resume syncing.',
3059
+ source : 'oauth'
3060
+ }
3061
+ ],
3062
+ status : 'error'
3063
+ }
3064
+ },
3065
+ operation : 'update',
3066
+ query : { id : connection.id }
3067
+ } ];
3068
+
3069
+ }
3070
+
3071
+ }
3072
+
3073
+ throw error;
3074
+
3075
+ }
3076
+
3077
+ }
3078
+
3079
+ },
2946
3080
 
2947
3081
  resources : {
2948
3082
 
@@ -3049,6 +3183,22 @@ var klaviyo = {
3049
3183
 
3050
3184
  steps : {
3051
3185
 
3186
+ connection : {
3187
+
3188
+ // The daily grant check — same step type and workflow shape as
3189
+ // Shopify's, provisioned as a system workflow per connection.
3190
+ health : {
3191
+ check : () => ({
3192
+ description : 'Keeps the Klaviyo grant working — spends the refresh token daily so a revoked or idle grant is reported instead of discovered by a failing sync.',
3193
+ hook : 'lifecycle.health',
3194
+ key : 'Klaviyo Connection Health',
3195
+ queue : 'connection',
3196
+ system : true
3197
+ })
3198
+ }
3199
+
3200
+ },
3201
+
3052
3202
  contacts : {
3053
3203
 
3054
3204
  // A DECLARATION, not the work. It names the hook that does the work, and