@drawbridge/drawbridge-utils 0.0.135 → 0.0.137

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.
@@ -1125,6 +1125,74 @@ var drawbridge_default = `<svg width="500" height="500" viewBox="0 0 500 500" fi
1125
1125
  </defs>
1126
1126
  </svg>`;
1127
1127
 
1128
+ // lib/transactions.js
1129
+ var import_drawbridge_telemetry = require("@drawbridge/drawbridge-telemetry");
1130
+
1131
+ // lib/billing.js
1132
+ var import_drawbridge_telemetry2 = require("@drawbridge/drawbridge-telemetry");
1133
+ var logger = (0, import_drawbridge_telemetry2.createLogger)();
1134
+ var MARKUP = 1.3;
1135
+ var cost = {
1136
+ // gemini-3.5-flash — verified against Google's pricing page 2026-07-09:
1137
+ // $0.15 cached / $1.50 input / $9.00 output per 1M tokens (thinking billed at
1138
+ // output). ~3.6x the retired 2.5-flash output rate.
1139
+ "gemini-3.5-flash": {
1140
+ cached: 15,
1141
+ input: 150,
1142
+ output: 900
1143
+ },
1144
+ // gemini-3.5-flash-lite — verified against Google's pricing page 2026-08-20:
1145
+ // $0.03 cached / $0.30 input / $2.50 output per 1M tokens (thinking billed at
1146
+ // output). A fifth of flash on input, ~a quarter on output. Growth's assistant
1147
+ // ranks the feed on this tier — one call per page — so its rows were the
1148
+ // unpriced ones until now.
1149
+ "gemini-3.5-flash-lite": {
1150
+ cached: 3,
1151
+ input: 30,
1152
+ output: 250
1153
+ },
1154
+ "gemini-2.5-flash": {
1155
+ cached: 3,
1156
+ input: 30,
1157
+ output: 250
1158
+ },
1159
+ "gemini-2.5-flash-image": {
1160
+ cached: 3,
1161
+ input: 30,
1162
+ output: 3e3
1163
+ },
1164
+ // gemini-3-pro-image-preview — verified against Google's pricing page
1165
+ // 2026-08-12: $2.00 input / $12.00 text output per 1M, and image output
1166
+ // tokens at ~$120/1M (a 1K-2K image is 1120 tokens = $0.134, a 4K image
1167
+ // 2000 tokens = $0.24). Encoded the flash-image way: one flat output rate
1168
+ // that reproduces the per-image price from the tokens usageMetadata
1169
+ // reports. Growth's hero generation runs this model today.
1170
+ "gemini-3-pro-image-preview": {
1171
+ cached: 20,
1172
+ input: 200,
1173
+ output: 12e3
1174
+ }
1175
+ };
1176
+ var toolCost = {
1177
+ search: 3.5
1178
+ };
1179
+ var toolPricing = Object.fromEntries(
1180
+ Object.entries(toolCost).map(([tool, value]) => [
1181
+ tool,
1182
+ Math.ceil(value * MARKUP)
1183
+ ])
1184
+ );
1185
+ var pricing = Object.fromEntries(
1186
+ Object.entries(cost).map(([model, rates]) => [
1187
+ model,
1188
+ {
1189
+ cached: Math.round(rates.cached * MARKUP),
1190
+ input: Math.round(rates.input * MARKUP),
1191
+ output: Math.round(rates.output * MARKUP)
1192
+ }
1193
+ ])
1194
+ );
1195
+
1128
1196
  // lib/features.js
1129
1197
  var page = {
1130
1198
  qrcode: {
@@ -1587,74 +1655,6 @@ var conversionRate = (subscription) => {
1587
1655
  return ((_a = resolvePlan(subscription)) == null ? void 0 : _a.conversion) ?? free.conversion;
1588
1656
  };
1589
1657
 
1590
- // lib/transactions.js
1591
- var import_drawbridge_telemetry = require("@drawbridge/drawbridge-telemetry");
1592
-
1593
- // lib/billing.js
1594
- var import_drawbridge_telemetry2 = require("@drawbridge/drawbridge-telemetry");
1595
- var logger = (0, import_drawbridge_telemetry2.createLogger)();
1596
- var MARKUP = 1.3;
1597
- var cost = {
1598
- // gemini-3.5-flash — verified against Google's pricing page 2026-07-09:
1599
- // $0.15 cached / $1.50 input / $9.00 output per 1M tokens (thinking billed at
1600
- // output). ~3.6x the retired 2.5-flash output rate.
1601
- "gemini-3.5-flash": {
1602
- cached: 15,
1603
- input: 150,
1604
- output: 900
1605
- },
1606
- // gemini-3.5-flash-lite — verified against Google's pricing page 2026-08-20:
1607
- // $0.03 cached / $0.30 input / $2.50 output per 1M tokens (thinking billed at
1608
- // output). A fifth of flash on input, ~a quarter on output. Growth's assistant
1609
- // ranks the feed on this tier — one call per page — so its rows were the
1610
- // unpriced ones until now.
1611
- "gemini-3.5-flash-lite": {
1612
- cached: 3,
1613
- input: 30,
1614
- output: 250
1615
- },
1616
- "gemini-2.5-flash": {
1617
- cached: 3,
1618
- input: 30,
1619
- output: 250
1620
- },
1621
- "gemini-2.5-flash-image": {
1622
- cached: 3,
1623
- input: 30,
1624
- output: 3e3
1625
- },
1626
- // gemini-3-pro-image-preview — verified against Google's pricing page
1627
- // 2026-08-12: $2.00 input / $12.00 text output per 1M, and image output
1628
- // tokens at ~$120/1M (a 1K-2K image is 1120 tokens = $0.134, a 4K image
1629
- // 2000 tokens = $0.24). Encoded the flash-image way: one flat output rate
1630
- // that reproduces the per-image price from the tokens usageMetadata
1631
- // reports. Growth's hero generation runs this model today.
1632
- "gemini-3-pro-image-preview": {
1633
- cached: 20,
1634
- input: 200,
1635
- output: 12e3
1636
- }
1637
- };
1638
- var toolCost = {
1639
- search: 3.5
1640
- };
1641
- var toolPricing = Object.fromEntries(
1642
- Object.entries(toolCost).map(([tool, value]) => [
1643
- tool,
1644
- Math.ceil(value * MARKUP)
1645
- ])
1646
- );
1647
- var pricing = Object.fromEntries(
1648
- Object.entries(cost).map(([model, rates]) => [
1649
- model,
1650
- {
1651
- cached: Math.round(rates.cached * MARKUP),
1652
- input: Math.round(rates.input * MARKUP),
1653
- output: Math.round(rates.output * MARKUP)
1654
- }
1655
- ])
1656
- );
1657
-
1658
1658
  // lib/pricing.js
1659
1659
  var emailPlans = {
1660
1660
  essentials50k: {
@@ -1724,6 +1724,33 @@ var sending = {
1724
1724
  plan: emailPlan,
1725
1725
  plans: emailPlans,
1726
1726
  title: emailPlans[emailPlan].title
1727
+ },
1728
+ // What one outbound SMS SEGMENT costs us, against what channels.sms below
1729
+ // sells it for. Here for the same reason the email figures are: this file is
1730
+ // where a rate is found without knowing whose module owns it — and its
1731
+ // absence is why "does an SMS action cover its cost" could not be answered
1732
+ // from this repo at all.
1733
+ //
1734
+ // US toll-free outbound, read from Twilio's SMS pricing page 2026-09-03
1735
+ // (https://www.twilio.com/en-us/sms/pricing/us): $0.0083 per segment, plus a
1736
+ // carrier fee that varies by the DESTINATION carrier — $0.0035 AT&T, $0.0045
1737
+ // T-Mobile / Verizon / US Cellular, $0.004 everywhere else. CENTS per
1738
+ // segment, like every other rate in this file.
1739
+ //
1740
+ // WORST CASE, deliberately. A price floor computed from the average is a
1741
+ // floor that half of real traffic sits underneath.
1742
+ //
1743
+ // A segment is 153 GSM-7 characters once a message spans more than one — or
1744
+ // 67 if the body contains a single emoji or curly quote, which flips the
1745
+ // whole message to UCS-2. Twilio reports the count as `num_segments` on the
1746
+ // Message resource, which is what the send bills on.
1747
+ sms: {
1748
+ baseCents: 0.83,
1749
+ // The dearest carrier fee, not the mean: see WORST CASE above.
1750
+ carrierCents: 0.45,
1751
+ // Stated, not summed — 0.83 + 0.45 is 1.2800000000000002 in binary
1752
+ // floating point, and this number is compared against money.
1753
+ segmentCostCents: 1.28
1727
1754
  }
1728
1755
  };
1729
1756
  var channels = {
@@ -1739,6 +1766,9 @@ var channels = {
1739
1766
  includedInAllowance: false
1740
1767
  }
1741
1768
  };
1769
+ var MINIMUM_ACTION_CENTS = Math.round(
1770
+ sending.sms.segmentCostCents / channels.sms.actionsPerSegment * MARKUP * 1e3
1771
+ ) / 1e3;
1742
1772
 
1743
1773
  // lib/connections/providers/drawbridge.js
1744
1774
  var STOP_KEYWORDS = ["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"];
@@ -1763,13 +1793,16 @@ var teamRecipients = async ({ memberIds = [], organization: organization2, read
1763
1793
  ]
1764
1794
  }) : [];
1765
1795
  const seen = /* @__PURE__ */ new Set();
1766
- return [owner, ...members].filter((member) => {
1767
- if (!(member == null ? void 0 : member.id) || !(member == null ? void 0 : member.email)) return false;
1768
- const address = member.email.toLowerCase();
1769
- if (seen.has(address)) return false;
1770
- seen.add(address);
1771
- return true;
1772
- });
1796
+ return {
1797
+ org,
1798
+ recipients: [owner, ...members].filter((member) => {
1799
+ if (!(member == null ? void 0 : member.id) || !(member == null ? void 0 : member.email)) return false;
1800
+ const address = member.email.toLowerCase();
1801
+ if (seen.has(address)) return false;
1802
+ seen.add(address);
1803
+ return true;
1804
+ })
1805
+ };
1773
1806
  };
1774
1807
  var queueNotification = (data2) => ({ collection: "notification", data: data2, operation: "create" });
1775
1808
  var drawbridge_default2 = {
@@ -1845,7 +1878,7 @@ var drawbridge_default2 = {
1845
1878
  const count = Number((counted == null ? void 0 : counted.count) || 0);
1846
1879
  const request2 = { campaign, count, days };
1847
1880
  if (!count) return { message: "No new leads in the period \u2014 digest skipped.", request: request2, response: { skipped: true }, skipped: true };
1848
- const recipients = await teamRecipients({
1881
+ const { recipients } = await teamRecipients({
1849
1882
  memberIds: ((_d = step.settings) == null ? void 0 : _d.members) || [],
1850
1883
  organization: workflow.organization,
1851
1884
  read
@@ -1881,7 +1914,7 @@ var drawbridge_default2 = {
1881
1914
  var _a;
1882
1915
  const memberIds = ((_a = step.settings) == null ? void 0 : _a.members) || [];
1883
1916
  const request2 = { members: memberIds.length };
1884
- const recipients = await teamRecipients({ memberIds, organization: workflow.organization, read });
1917
+ const { org, recipients } = await teamRecipients({ memberIds, organization: workflow.organization, read });
1885
1918
  if (!recipients.length) {
1886
1919
  return {
1887
1920
  message: "No owner or accepted member with an email address \u2014 team notification skipped.",
@@ -1891,32 +1924,37 @@ var drawbridge_default2 = {
1891
1924
  };
1892
1925
  }
1893
1926
  const bucket = Math.floor(Date.now() / (15 * 60 * 1e3));
1927
+ const campaign = (context == null ? void 0 : context.campaign) ? await read.get({ collection: "campaign", query: { id: context.campaign } }) : null;
1928
+ const link = (context == null ? void 0 : context.campaign) && (context == null ? void 0 : context.lead) ? {
1929
+ href: "/organizations/" + workflow.organization + "/campaigns/" + context.campaign + "/leads/" + context.lead,
1930
+ text: "View lead"
1931
+ } : null;
1932
+ const body = ((context == null ? void 0 : context.email) || "Someone") + " just entered" + ((campaign == null ? void 0 : campaign.title) ? " " + campaign.title : "") + ((org == null ? void 0 : org.title) ? " at " + org.title : "") + ".";
1933
+ const subject = "New lead" + ((campaign == null ? void 0 : campaign.title) ? " \u2014 " + campaign.title : "");
1894
1934
  return {
1895
1935
  message: "Team notification queued for " + recipients.length + " recipient(s).",
1896
1936
  request: request2,
1897
1937
  response: { notified: recipients.length },
1898
- writes: recipients.map((member) => {
1899
- var _a2, _b;
1900
- return {
1901
- ...queueNotification({
1902
- audience: "member",
1903
- // Per workflow, recipient AND bucket, so one recipient's damper
1904
- // can never swallow another's mail and a later bucket is never
1905
- // mistaken for a duplicate of an earlier one.
1906
- key: "team.notify." + workflow.id + "." + member.id + "." + bucket,
1907
- message: interpolate((_a2 = step.settings) == null ? void 0 : _a2.message, context),
1908
- organization: workflow.organization,
1909
- send: { type: "email", email: member.email },
1910
- title: interpolate((_b = step.settings) == null ? void 0 : _b.subject, context),
1911
- workflow: workflow.id
1912
- }),
1913
- // E11000 IS THE DAMPER WORKING: this recipient has already been
1914
- // told within the bucket. Declared per write rather than assumed by
1915
- // the shell, because on every other write here a duplicate key is a
1916
- // real failure.
1917
- ignoreDuplicate: true
1918
- };
1919
- })
1938
+ writes: recipients.map((member) => ({
1939
+ ...queueNotification({
1940
+ audience: "member",
1941
+ // Per workflow, recipient AND bucket, so one recipient's damper
1942
+ // can never swallow another's mail and a later bucket is never
1943
+ // mistaken for a duplicate of an earlier one.
1944
+ key: "team.notify." + workflow.id + "." + member.id + "." + bucket,
1945
+ link,
1946
+ message: body,
1947
+ organization: workflow.organization,
1948
+ send: { type: "email", email: member.email },
1949
+ title: subject,
1950
+ workflow: workflow.id
1951
+ }),
1952
+ // E11000 IS THE DAMPER WORKING: this recipient has already been
1953
+ // told within the bucket. Declared per write rather than assumed by
1954
+ // the shell, because on every other write here a duplicate key is a
1955
+ // real failure.
1956
+ ignoreDuplicate: true
1957
+ }))
1920
1958
  };
1921
1959
  },
1922
1960
  // Drawbridge sends lead-facing email itself — no merchant provider gates
@@ -2322,14 +2360,24 @@ var drawbridge_default2 = {
2322
2360
  }),
2323
2361
  // To organization MEMBERS. Never suppressed — an entrant's opt-out must
2324
2362
  // not silence an alert to staff — and not billed.
2363
+ //
2364
+ // ITS COPY IS WRITTEN, NOT ASKED FOR. This says the same thing every
2365
+ // time — a lead arrived, here it is — so the merchant chooses who hears
2366
+ // it, not how it reads, and the hook composes the subject, the body and
2367
+ // the link to the lead.
2368
+ //
2369
+ // `message` and `subject` stay declared, optional and IGNORED: stored
2370
+ // workflows still carry them and the api validates settings with
2371
+ // noUnknown().strict(), so dropping the keys would 400 the next save of
2372
+ // a workflow built before this.
2325
2373
  notify: () => ({
2326
2374
  hook: "email.notify",
2327
2375
  key: "Email \u2014 Notification",
2328
2376
  queue: "notification",
2329
2377
  settings: {
2330
2378
  members: { of: "string", type: "array" },
2331
- message: { required: true, type: "string" },
2332
- subject: { required: true, type: "string" }
2379
+ message: { type: "string" },
2380
+ subject: { type: "string" }
2333
2381
  },
2334
2382
  triggers: ["lead.insert"],
2335
2383
  // Zero is a PRICE, and a deliberate one. Declared rather than omitted
@@ -1663,19 +1663,25 @@ const teamRecipients = async ({ memberIds = [], organization, read }) => {
1663
1663
  // different teammates collapse into one and the second is never told.
1664
1664
  const seen = new Set();
1665
1665
 
1666
- 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 ) => {
1667
1672
 
1668
- if( ! member?.id || ! member?.email ) return false;
1673
+ if( ! member?.id || ! member?.email ) return false;
1669
1674
 
1670
- const address = member.email.toLowerCase();
1675
+ const address = member.email.toLowerCase();
1671
1676
 
1672
- if( seen.has( address ) ) return false;
1677
+ if( seen.has( address ) ) return false;
1673
1678
 
1674
- seen.add( address );
1679
+ seen.add( address );
1675
1680
 
1676
- return true;
1681
+ return true;
1677
1682
 
1678
- });
1683
+ })
1684
+ };
1679
1685
 
1680
1686
  };
1681
1687
 
@@ -1768,7 +1774,7 @@ var drawbridge = {
1768
1774
  // mail the merchant did not ask for and would learn to ignore.
1769
1775
  if( ! count ) return { message : 'No new leads in the period — digest skipped.', request, response : { skipped : true }, skipped : true };
1770
1776
 
1771
- const recipients = await teamRecipients({
1777
+ const { recipients } = await teamRecipients({
1772
1778
  memberIds : step.settings?.members || [],
1773
1779
  organization : workflow.organization,
1774
1780
  read
@@ -1809,7 +1815,7 @@ var drawbridge = {
1809
1815
 
1810
1816
  const request = { members : memberIds.length };
1811
1817
 
1812
- const recipients = await teamRecipients({ memberIds, organization : workflow.organization, read });
1818
+ const { org, recipients } = await teamRecipients({ memberIds, organization : workflow.organization, read });
1813
1819
 
1814
1820
  if( ! recipients.length ){
1815
1821
 
@@ -1842,6 +1848,43 @@ var drawbridge = {
1842
1848
  // to redefine from here.
1843
1849
  const bucket = Math.floor( Date.now() / ( 15 * 60 * 1000 ) );
1844
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
+
1845
1888
  return {
1846
1889
  message : 'Team notification queued for ' + recipients.length + ' recipient(s).',
1847
1890
  request,
@@ -1853,10 +1896,11 @@ var drawbridge = {
1853
1896
  // can never swallow another's mail and a later bucket is never
1854
1897
  // mistaken for a duplicate of an earlier one.
1855
1898
  key : 'team.notify.' + workflow.id + '.' + member.id + '.' + bucket,
1856
- message : interpolate( step.settings?.message, context ),
1899
+ link,
1900
+ message : body,
1857
1901
  organization : workflow.organization,
1858
1902
  send : { type : 'email', email : member.email },
1859
- title : interpolate( step.settings?.subject, context ),
1903
+ title : subject,
1860
1904
  workflow : workflow.id
1861
1905
  }),
1862
1906
  // E11000 IS THE DAMPER WORKING: this recipient has already been
@@ -2416,14 +2460,24 @@ var drawbridge = {
2416
2460
 
2417
2461
  // To organization MEMBERS. Never suppressed — an entrant's opt-out must
2418
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.
2419
2473
  notify : () => ({
2420
2474
  hook : 'email.notify',
2421
2475
  key : 'Email — Notification',
2422
2476
  queue : 'notification',
2423
2477
  settings : {
2424
2478
  members : { of : 'string', type : 'array' },
2425
- message : { required : true, type : 'string' },
2426
- subject : { required : true, type : 'string' }
2479
+ message : { type : 'string' },
2480
+ subject : { type : 'string' }
2427
2481
  },
2428
2482
  triggers : [ 'lead.insert' ],
2429
2483
  // Zero is a PRICE, and a deliberate one. Declared rather than omitted
@@ -1663,19 +1663,25 @@ const teamRecipients = async ({ memberIds = [], organization, read }) => {
1663
1663
  // different teammates collapse into one and the second is never told.
1664
1664
  const seen = new Set();
1665
1665
 
1666
- 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 ) => {
1667
1672
 
1668
- if( ! member?.id || ! member?.email ) return false;
1673
+ if( ! member?.id || ! member?.email ) return false;
1669
1674
 
1670
- const address = member.email.toLowerCase();
1675
+ const address = member.email.toLowerCase();
1671
1676
 
1672
- if( seen.has( address ) ) return false;
1677
+ if( seen.has( address ) ) return false;
1673
1678
 
1674
- seen.add( address );
1679
+ seen.add( address );
1675
1680
 
1676
- return true;
1681
+ return true;
1677
1682
 
1678
- });
1683
+ })
1684
+ };
1679
1685
 
1680
1686
  };
1681
1687
 
@@ -1768,7 +1774,7 @@ var drawbridge = {
1768
1774
  // mail the merchant did not ask for and would learn to ignore.
1769
1775
  if( ! count ) return { message : 'No new leads in the period — digest skipped.', request, response : { skipped : true }, skipped : true };
1770
1776
 
1771
- const recipients = await teamRecipients({
1777
+ const { recipients } = await teamRecipients({
1772
1778
  memberIds : step.settings?.members || [],
1773
1779
  organization : workflow.organization,
1774
1780
  read
@@ -1809,7 +1815,7 @@ var drawbridge = {
1809
1815
 
1810
1816
  const request = { members : memberIds.length };
1811
1817
 
1812
- const recipients = await teamRecipients({ memberIds, organization : workflow.organization, read });
1818
+ const { org, recipients } = await teamRecipients({ memberIds, organization : workflow.organization, read });
1813
1819
 
1814
1820
  if( ! recipients.length ){
1815
1821
 
@@ -1842,6 +1848,43 @@ var drawbridge = {
1842
1848
  // to redefine from here.
1843
1849
  const bucket = Math.floor( Date.now() / ( 15 * 60 * 1000 ) );
1844
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
+
1845
1888
  return {
1846
1889
  message : 'Team notification queued for ' + recipients.length + ' recipient(s).',
1847
1890
  request,
@@ -1853,10 +1896,11 @@ var drawbridge = {
1853
1896
  // can never swallow another's mail and a later bucket is never
1854
1897
  // mistaken for a duplicate of an earlier one.
1855
1898
  key : 'team.notify.' + workflow.id + '.' + member.id + '.' + bucket,
1856
- message : interpolate( step.settings?.message, context ),
1899
+ link,
1900
+ message : body,
1857
1901
  organization : workflow.organization,
1858
1902
  send : { type : 'email', email : member.email },
1859
- title : interpolate( step.settings?.subject, context ),
1903
+ title : subject,
1860
1904
  workflow : workflow.id
1861
1905
  }),
1862
1906
  // E11000 IS THE DAMPER WORKING: this recipient has already been
@@ -2416,14 +2460,24 @@ var drawbridge = {
2416
2460
 
2417
2461
  // To organization MEMBERS. Never suppressed — an entrant's opt-out must
2418
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.
2419
2473
  notify : () => ({
2420
2474
  hook : 'email.notify',
2421
2475
  key : 'Email — Notification',
2422
2476
  queue : 'notification',
2423
2477
  settings : {
2424
2478
  members : { of : 'string', type : 'array' },
2425
- message : { required : true, type : 'string' },
2426
- subject : { required : true, type : 'string' }
2479
+ message : { type : 'string' },
2480
+ subject : { type : 'string' }
2427
2481
  },
2428
2482
  triggers : [ 'lead.insert' ],
2429
2483
  // Zero is a PRICE, and a deliberate one. Declared rather than omitted