@aglyn/plugins-outreach 1.0.0-beta.219 → 1.0.0-beta.220

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.
Files changed (59) hide show
  1. package/package.json +15 -15
  2. package/src/lib/components/compliance-section.d.ts +5 -0
  3. package/src/lib/components/compliance-section.js +233 -149
  4. package/src/lib/components/compliance-section.js.map +1 -1
  5. package/src/lib/components/enrollment-detail.js +2 -2
  6. package/src/lib/components/enrollment-detail.js.map +1 -1
  7. package/src/lib/components/sequence-editor.d.ts +13 -0
  8. package/src/lib/components/sequence-editor.js +508 -302
  9. package/src/lib/components/sequence-editor.js.map +1 -1
  10. package/src/lib/components/sequence-preview.js +25 -10
  11. package/src/lib/components/sequence-preview.js.map +1 -1
  12. package/src/lib/components/sequence-step-card.d.ts +10 -0
  13. package/src/lib/components/sequence-step-card.js +320 -202
  14. package/src/lib/components/sequence-step-card.js.map +1 -1
  15. package/src/lib/engine/compose.js +2 -2
  16. package/src/lib/engine/compose.js.map +1 -1
  17. package/src/lib/engine/delivery-status.js +1 -1
  18. package/src/lib/engine/delivery-status.js.map +1 -1
  19. package/src/lib/engine/enrollment-state.js +2 -2
  20. package/src/lib/engine/enrollment-state.js.map +1 -1
  21. package/src/lib/engine/gates.d.ts +2 -2
  22. package/src/lib/engine/gates.js.map +1 -1
  23. package/src/lib/engine/opt-out-intent.js +1 -1
  24. package/src/lib/engine/opt-out-intent.js.map +1 -1
  25. package/src/lib/engine/sequence-validation.js +2 -2
  26. package/src/lib/engine/sequence-validation.js.map +1 -1
  27. package/src/lib/engine/thread-classification.js +3 -3
  28. package/src/lib/engine/thread-classification.js.map +1 -1
  29. package/src/lib/enrollment/gate-lookups.js +5 -4
  30. package/src/lib/enrollment/gate-lookups.js.map +1 -1
  31. package/src/lib/model/outreach.types.d.ts +1 -1
  32. package/src/lib/model/outreach.types.js.map +1 -1
  33. package/src/lib/model/sales-topic.d.ts +27 -0
  34. package/src/lib/model/sales-topic.js +27 -0
  35. package/src/lib/model/sales-topic.js.map +1 -0
  36. package/src/lib/model/sequence-draft.d.ts +1 -1
  37. package/src/lib/model/sequence-draft.js +2 -2
  38. package/src/lib/model/sequence-draft.js.map +1 -1
  39. package/src/lib/model/stored-records.js +2 -2
  40. package/src/lib/model/stored-records.js.map +1 -1
  41. package/src/lib/routes/curate-routes.js +7 -7
  42. package/src/lib/routes/curate-routes.js.map +1 -1
  43. package/src/lib/routes/enroll-routes.js +10 -13
  44. package/src/lib/routes/enroll-routes.js.map +1 -1
  45. package/src/lib/routes/sequence-routes.js +9 -9
  46. package/src/lib/routes/sequence-routes.js.map +1 -1
  47. package/src/lib/runtime/campaign-credit.js +2 -2
  48. package/src/lib/runtime/campaign-credit.js.map +1 -1
  49. package/src/lib/runtime/campaign-detach.js +3 -2
  50. package/src/lib/runtime/campaign-detach.js.map +1 -1
  51. package/src/lib/runtime/lead-records.d.ts +6 -1
  52. package/src/lib/runtime/lead-records.js +25 -6
  53. package/src/lib/runtime/lead-records.js.map +1 -1
  54. package/src/lib/runtime/platform-runtime-deps.js +3 -3
  55. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  56. package/src/lib/runtime/send-job.js +20 -4
  57. package/src/lib/runtime/send-job.js.map +1 -1
  58. package/src/lib/runtime/sync-job.js +3 -3
  59. package/src/lib/runtime/sync-job.js.map +1 -1
@@ -13,7 +13,7 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ import { CAMPAIGN_MEMBERSHIP_FIELD } from "@aglyn/aglyn/app-utils/campaign-membership";
16
+ */ import { containerMembershipField } from "@aglyn/aglyn/app-utils/container-membership";
17
17
  import { FieldValue } from "firebase-admin/firestore";
18
18
  import { outreachOrgCollection } from "../storage/outreach-records.js";
19
19
  /**
@@ -35,7 +35,8 @@ import { outreachOrgCollection } from "../storage/outreach-records.js";
35
35
  * the campaign's credits: what a sequence produced for a campaign that is
36
36
  * gone is in a report document nobody reads any more, which is the
37
37
  * conversions rollup's own fate.
38
- */ /** Members detached in one write, under the 500-operation batch limit. */ const DETACH_BATCH = 400;
38
+ */ /** The field a sequence or an enrollment holds its campaigns in. */ const CAMPAIGN_MEMBERSHIP_FIELD = containerMembershipField('campaign');
39
+ /** Members detached in one write, under the 500-operation batch limit. */ const DETACH_BATCH = 400;
39
40
  /** How many pages one request walks per collection. */ const DETACH_PASSES = 25;
40
41
  async function detachFrom(collection, campaignId) {
41
42
  const firestore = collection.firestore;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/campaign-detach.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { CAMPAIGN_MEMBERSHIP_FIELD } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport type {\n PluginMembershipDetacher,\n PluginMembershipDetachReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-membership-detach'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\n/**\n * OUTREACH'S SHARE OF A CAMPAIGN DELETION (AGL-3254).\n *\n * A sequence and its enrollments name the campaigns they are in from their\n * own documents, under the organization, in collections the core does not\n * name — so the marketing plugin's deletion cannot reach them and asks\n * every plugin's membership detacher instead. This one clears the campaign\n * id out of every sequence and every enrollment of the org still carrying\n * it, with the same walk the owner makes over its forms: `array-contains`\n * on the automatic single-field index, `arrayRemove` of the one id, in\n * pages under Firestore's batch limit, bounded per request and reporting\n * `remaining` so the deletion holds the container for a second run.\n *\n * Only the campaign membership field is Outreach's to clear: a request\n * about any other container's field answers nothing detached, because the\n * sequences carry no such field. The records themselves stay, and so do\n * the campaign's credits: what a sequence produced for a campaign that is\n * gone is in a report document nobody reads any more, which is the\n * conversions rollup's own fate.\n */\n\n/** Members detached in one write, under the 500-operation batch limit. */\nconst DETACH_BATCH = 400\n\n/** How many pages one request walks per collection. */\nconst DETACH_PASSES = 25\n\nasync function detachFrom(\n collection: FirebaseFirestore.CollectionReference,\n campaignId: string,\n): Promise<PluginMembershipDetachReport> {\n const firestore = collection.firestore\n let detached = 0\n for (let pass = 0; pass < DETACH_PASSES; pass += 1) {\n const page = await collection.where(CAMPAIGN_MEMBERSHIP_FIELD, 'array-contains', campaignId).limit(DETACH_BATCH).get()\n if (page.empty) return { detached, remaining: false }\n const batch = firestore.batch()\n for (const member of page.docs) {\n batch.update(member.ref, { [CAMPAIGN_MEMBERSHIP_FIELD]: FieldValue.arrayRemove(campaignId) })\n }\n await batch.commit()\n detached += page.size\n if (page.size < DETACH_BATCH) return { detached, remaining: false }\n }\n return { detached, remaining: true }\n}\n\n/** The detacher, on the Firestore it is handed. */\nexport function createOutreachCampaignDetacher(deps: {\n firestore(): FirebaseFirestore.Firestore\n}): PluginMembershipDetacher {\n return async ({ orgId, field, id }) => {\n // A site with no organization holds no sequences, and a container held\n // in any other field is not one a sequence joins: nothing to detach.\n if (!orgId || field !== CAMPAIGN_MEMBERSHIP_FIELD) return { detached: 0, remaining: false }\n const firestore = deps.firestore()\n const sequences = await detachFrom(outreachOrgCollection(firestore, orgId, 'sequences'), id)\n const enrollments = await detachFrom(outreachOrgCollection(firestore, orgId, 'enrollments'), id)\n return {\n detached: sequences.detached + enrollments.detached,\n remaining: sequences.remaining || enrollments.remaining,\n }\n }\n}\n"],"names":["CAMPAIGN_MEMBERSHIP_FIELD","FieldValue","outreachOrgCollection","DETACH_BATCH","DETACH_PASSES","detachFrom","collection","campaignId","firestore","detached","pass","page","where","limit","get","empty","remaining","batch","member","docs","update","ref","arrayRemove","commit","size","createOutreachCampaignDetacher","deps","orgId","field","id","sequences","enrollments"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,yBAAyB,QAAQ,6CAA4C;AAKtF,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,qBAAqB,QAAQ,iCAA6B;AAEnE;;;;;;;;;;;;;;;;;;;CAmBC,GAED,wEAAwE,GACxE,MAAMC,eAAe;AAErB,qDAAqD,GACrD,MAAMC,gBAAgB;AAEtB,eAAeC,WACbC,UAAiD,EACjDC,UAAkB;IAElB,MAAMC,YAAYF,WAAWE,SAAS;IACtC,IAAIC,WAAW;IACf,IAAK,IAAIC,OAAO,GAAGA,OAAON,eAAeM,QAAQ,EAAG;QAClD,MAAMC,OAAO,MAAML,WAAWM,KAAK,CAACZ,2BAA2B,kBAAkBO,YAAYM,KAAK,CAACV,cAAcW,GAAG;QACpH,IAAIH,KAAKI,KAAK,EAAE,OAAO;YAAEN;YAAUO,WAAW;QAAM;QACpD,MAAMC,QAAQT,UAAUS,KAAK;QAC7B,KAAK,MAAMC,UAAUP,KAAKQ,IAAI,CAAE;YAC9BF,MAAMG,MAAM,CAACF,OAAOG,GAAG,EAAE;gBAAE,CAACrB,0BAA0B,EAAEC,WAAWqB,WAAW,CAACf;YAAY;QAC7F;QACA,MAAMU,MAAMM,MAAM;QAClBd,YAAYE,KAAKa,IAAI;QACrB,IAAIb,KAAKa,IAAI,GAAGrB,cAAc,OAAO;YAAEM;YAAUO,WAAW;QAAM;IACpE;IACA,OAAO;QAAEP;QAAUO,WAAW;IAAK;AACrC;AAEA,iDAAiD,GACjD,OAAO,SAASS,+BAA+BC,IAE9C;IACC,OAAO,OAAO,EAAEC,KAAK,EAAEC,KAAK,EAAEC,EAAE,EAAE;QAChC,uEAAuE;QACvE,qEAAqE;QACrE,IAAI,CAACF,SAASC,UAAU5B,2BAA2B,OAAO;YAAES,UAAU;YAAGO,WAAW;QAAM;QAC1F,MAAMR,YAAYkB,KAAKlB,SAAS;QAChC,MAAMsB,YAAY,MAAMzB,WAAWH,sBAAsBM,WAAWmB,OAAO,cAAcE;QACzF,MAAME,cAAc,MAAM1B,WAAWH,sBAAsBM,WAAWmB,OAAO,gBAAgBE;QAC7F,OAAO;YACLpB,UAAUqB,UAAUrB,QAAQ,GAAGsB,YAAYtB,QAAQ;YACnDO,WAAWc,UAAUd,SAAS,IAAIe,YAAYf,SAAS;QACzD;IACF;AACF"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/campaign-detach.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { containerMembershipField } from '@aglyn/aglyn/app-utils/container-membership'\nimport type {\n PluginMembershipDetacher,\n PluginMembershipDetachReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-membership-detach'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\n/**\n * OUTREACH'S SHARE OF A CAMPAIGN DELETION (AGL-3254).\n *\n * A sequence and its enrollments name the campaigns they are in from their\n * own documents, under the organization, in collections the core does not\n * name — so the marketing plugin's deletion cannot reach them and asks\n * every plugin's membership detacher instead. This one clears the campaign\n * id out of every sequence and every enrollment of the org still carrying\n * it, with the same walk the owner makes over its forms: `array-contains`\n * on the automatic single-field index, `arrayRemove` of the one id, in\n * pages under Firestore's batch limit, bounded per request and reporting\n * `remaining` so the deletion holds the container for a second run.\n *\n * Only the campaign membership field is Outreach's to clear: a request\n * about any other container's field answers nothing detached, because the\n * sequences carry no such field. The records themselves stay, and so do\n * the campaign's credits: what a sequence produced for a campaign that is\n * gone is in a report document nobody reads any more, which is the\n * conversions rollup's own fate.\n */\n\n/** The field a sequence or an enrollment holds its campaigns in. */\nconst CAMPAIGN_MEMBERSHIP_FIELD = containerMembershipField('campaign')\n\n/** Members detached in one write, under the 500-operation batch limit. */\nconst DETACH_BATCH = 400\n\n/** How many pages one request walks per collection. */\nconst DETACH_PASSES = 25\n\nasync function detachFrom(\n collection: FirebaseFirestore.CollectionReference,\n campaignId: string,\n): Promise<PluginMembershipDetachReport> {\n const firestore = collection.firestore\n let detached = 0\n for (let pass = 0; pass < DETACH_PASSES; pass += 1) {\n const page = await collection.where(CAMPAIGN_MEMBERSHIP_FIELD, 'array-contains', campaignId).limit(DETACH_BATCH).get()\n if (page.empty) return { detached, remaining: false }\n const batch = firestore.batch()\n for (const member of page.docs) {\n batch.update(member.ref, { [CAMPAIGN_MEMBERSHIP_FIELD]: FieldValue.arrayRemove(campaignId) })\n }\n await batch.commit()\n detached += page.size\n if (page.size < DETACH_BATCH) return { detached, remaining: false }\n }\n return { detached, remaining: true }\n}\n\n/** The detacher, on the Firestore it is handed. */\nexport function createOutreachCampaignDetacher(deps: {\n firestore(): FirebaseFirestore.Firestore\n}): PluginMembershipDetacher {\n return async ({ orgId, field, id }) => {\n // A site with no organization holds no sequences, and a container held\n // in any other field is not one a sequence joins: nothing to detach.\n if (!orgId || field !== CAMPAIGN_MEMBERSHIP_FIELD) return { detached: 0, remaining: false }\n const firestore = deps.firestore()\n const sequences = await detachFrom(outreachOrgCollection(firestore, orgId, 'sequences'), id)\n const enrollments = await detachFrom(outreachOrgCollection(firestore, orgId, 'enrollments'), id)\n return {\n detached: sequences.detached + enrollments.detached,\n remaining: sequences.remaining || enrollments.remaining,\n }\n }\n}\n"],"names":["containerMembershipField","FieldValue","outreachOrgCollection","CAMPAIGN_MEMBERSHIP_FIELD","DETACH_BATCH","DETACH_PASSES","detachFrom","collection","campaignId","firestore","detached","pass","page","where","limit","get","empty","remaining","batch","member","docs","update","ref","arrayRemove","commit","size","createOutreachCampaignDetacher","deps","orgId","field","id","sequences","enrollments"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,wBAAwB,QAAQ,8CAA6C;AAKtF,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,qBAAqB,QAAQ,iCAA6B;AAEnE;;;;;;;;;;;;;;;;;;;CAmBC,GAED,kEAAkE,GAClE,MAAMC,4BAA4BH,yBAAyB;AAE3D,wEAAwE,GACxE,MAAMI,eAAe;AAErB,qDAAqD,GACrD,MAAMC,gBAAgB;AAEtB,eAAeC,WACbC,UAAiD,EACjDC,UAAkB;IAElB,MAAMC,YAAYF,WAAWE,SAAS;IACtC,IAAIC,WAAW;IACf,IAAK,IAAIC,OAAO,GAAGA,OAAON,eAAeM,QAAQ,EAAG;QAClD,MAAMC,OAAO,MAAML,WAAWM,KAAK,CAACV,2BAA2B,kBAAkBK,YAAYM,KAAK,CAACV,cAAcW,GAAG;QACpH,IAAIH,KAAKI,KAAK,EAAE,OAAO;YAAEN;YAAUO,WAAW;QAAM;QACpD,MAAMC,QAAQT,UAAUS,KAAK;QAC7B,KAAK,MAAMC,UAAUP,KAAKQ,IAAI,CAAE;YAC9BF,MAAMG,MAAM,CAACF,OAAOG,GAAG,EAAE;gBAAE,CAACnB,0BAA0B,EAAEF,WAAWsB,WAAW,CAACf;YAAY;QAC7F;QACA,MAAMU,MAAMM,MAAM;QAClBd,YAAYE,KAAKa,IAAI;QACrB,IAAIb,KAAKa,IAAI,GAAGrB,cAAc,OAAO;YAAEM;YAAUO,WAAW;QAAM;IACpE;IACA,OAAO;QAAEP;QAAUO,WAAW;IAAK;AACrC;AAEA,iDAAiD,GACjD,OAAO,SAASS,+BAA+BC,IAE9C;IACC,OAAO,OAAO,EAAEC,KAAK,EAAEC,KAAK,EAAEC,EAAE,EAAE;QAChC,uEAAuE;QACvE,qEAAqE;QACrE,IAAI,CAACF,SAASC,UAAU1B,2BAA2B,OAAO;YAAEO,UAAU;YAAGO,WAAW;QAAM;QAC1F,MAAMR,YAAYkB,KAAKlB,SAAS;QAChC,MAAMsB,YAAY,MAAMzB,WAAWJ,sBAAsBO,WAAWmB,OAAO,cAAcE;QACzF,MAAME,cAAc,MAAM1B,WAAWJ,sBAAsBO,WAAWmB,OAAO,gBAAgBE;QAC7F,OAAO;YACLpB,UAAUqB,UAAUrB,QAAQ,GAAGsB,YAAYtB,QAAQ;YACnDO,WAAWc,UAAUd,SAAS,IAAIe,YAAYf,SAAS;QACzD;IACF;AACF"}
@@ -16,7 +16,12 @@
16
16
  */
17
17
  import type { PluginLeadConversionReport, PluginLeadConversionRequest } from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion';
18
18
  type Firestore = FirebaseFirestore.Firestore;
19
- /** A lead nobody has touched, once somebody replied: Working. `true` when it moved. */
19
+ /** A lead nobody has touched, once a step was sent to it: Nurturing. `true` when it moved. */
20
+ export declare function markOutreachLeadNurturing(firestore: Firestore, input: {
21
+ orgId: string;
22
+ leadId: string;
23
+ }): Promise<boolean>;
24
+ /** A lead no person has engaged, once somebody replied: Working. `true` when it moved. */
20
25
  export declare function markOutreachLeadWorking(firestore: Firestore, input: {
21
26
  orgId: string;
22
27
  leadId: string;
@@ -16,11 +16,16 @@
16
16
  */ /**
17
17
  * WHAT A SEQUENCE DOES TO A LEAD'S OWN RECORD (AGL-3234).
18
18
  *
19
- * Two touches, both on `hosts/{hostId}/leads/{leadId}` — the record system's
19
+ * Three touches, all on `orgs/{orgId}/leads/{leadId}` — the record system's
20
20
  * document, written in the record system's vocabulary (`@aglyn/aglyn`'s lead
21
21
  * statuses), never through an import of the CRM plugin:
22
22
  *
23
- * - a REPLY moves a lead nobody has touched from New to Working. Somebody
23
+ * - a SENT STEP moves a lead nobody has touched from New to Nurturing.
24
+ * Automated email is reaching it, so it is not untouched — the digest's
25
+ * unworked list would otherwise name every lead a sequence is working —
26
+ * but no person has engaged yet. Only a send moves it, never the
27
+ * enrollment: a step that never goes out has reached nobody.
28
+ * - a REPLY moves a lead that is New or Nurturing to Working. Somebody
24
29
  * wrote back; the lead is being worked, and a queue that still showed it
25
30
  * as New would send a rep to open a conversation already open. A lead
26
31
  * already Working, closed, or converted is left as it is — a reply does
@@ -38,7 +43,10 @@
38
43
  import { FieldValue } from "firebase-admin/firestore";
39
44
  import { outreachOrgCollection } from "../storage/outreach-records.js";
40
45
  /** Firestore refuses a batch of more than 500 writes. */ const BATCH_LIMIT = 450;
41
- /** A lead nobody has touched, once somebody replied: Working. `true` when it moved. */ export async function markOutreachLeadWorking(firestore, input) {
46
+ /**
47
+ * Move a lead forward to `to` when it stands in one of `from`; `true` when
48
+ * it moved. A converted lead is a contact's history and never moves.
49
+ */ async function advanceOutreachLead(firestore, input, from, to) {
42
50
  try {
43
51
  var _snapshot_data;
44
52
  // A lead is one org row (AGL-3275), so the site no longer names it.
@@ -46,19 +54,30 @@ import { outreachOrgCollection } from "../storage/outreach-records.js";
46
54
  const snapshot = await ref.get();
47
55
  if (!snapshot.exists) return false;
48
56
  const lead = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
49
- if (lead['convertedContactId'] || crmLeadStatus(lead) !== 'new') return false;
57
+ if (lead['convertedContactId'] || !from.includes(crmLeadStatus(lead))) return false;
50
58
  await ref.set({
51
- status: 'working',
59
+ status: to,
52
60
  updatedAt: FieldValue.serverTimestamp()
53
61
  }, {
54
62
  merge: true
55
63
  });
56
64
  return true;
57
65
  } catch (error) {
58
- console.error('[outreach] the lead could not be marked working', input.orgId, input.leadId, error);
66
+ console.error(`[outreach] the lead could not be marked ${to}`, input.orgId, input.leadId, error);
59
67
  return false;
60
68
  }
61
69
  }
70
+ /** A lead nobody has touched, once a step was sent to it: Nurturing. `true` when it moved. */ export function markOutreachLeadNurturing(firestore, input) {
71
+ return advanceOutreachLead(firestore, input, [
72
+ 'new'
73
+ ], 'nurturing');
74
+ }
75
+ /** A lead no person has engaged, once somebody replied: Working. `true` when it moved. */ export function markOutreachLeadWorking(firestore, input) {
76
+ return advanceOutreachLead(firestore, input, [
77
+ 'new',
78
+ 'nurturing'
79
+ ], 'working');
80
+ }
62
81
  /** Every enrollment made on the lead now names the contact it became. */ export async function followOutreachLeadToContact(firestore, input) {
63
82
  const enrollments = outreachOrgCollection(firestore, input.orgId, 'enrollments');
64
83
  const rows = await enrollments.where('leadId', '==', input.leadId).get();
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/lead-records.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHAT A SEQUENCE DOES TO A LEAD'S OWN RECORD (AGL-3234).\n *\n * Two touches, both on `hosts/{hostId}/leads/{leadId}` — the record system's\n * document, written in the record system's vocabulary (`@aglyn/aglyn`'s lead\n * statuses), never through an import of the CRM plugin:\n *\n * - a REPLY moves a lead nobody has touched from New to Working. Somebody\n * wrote back; the lead is being worked, and a queue that still showed it\n * as New would send a rep to open a conversation already open. A lead\n * already Working, closed, or converted is left as it is — a reply does\n * not reopen a verdict or move a contact's record.\n * - a CONVERSION re-points every enrollment made on the lead at the contact\n * it became: the record system tells every plugin through its\n * lead-conversion seam, and this is Sequences' share of it. The\n * enrollment keeps its id, its thread and its place in the steps, and\n * keeps naming the lead beside the contact, so both pages list it.\n *\n * Neither throws: a status that could not be moved is one a rep moves by\n * hand, and an enrollment that could not follow its lead is one the next\n * send stops as `contact_missing` and a rep re-enrolls.\n */\n\nimport { crmLeadStatus } from '@aglyn/aglyn/app-utils/crm'\nimport type {\n PluginLeadConversionReport,\n PluginLeadConversionRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** Firestore refuses a batch of more than 500 writes. */\nconst BATCH_LIMIT = 450\n\n/** A lead nobody has touched, once somebody replied: Working. `true` when it moved. */\nexport async function markOutreachLeadWorking(\n firestore: Firestore,\n input: { orgId: string; leadId: string },\n): Promise<boolean> {\n try {\n // A lead is one org row (AGL-3275), so the site no longer names it.\n const ref = firestore.collection('orgs').doc(input.orgId).collection('leads').doc(input.leadId)\n const snapshot = await ref.get()\n if (!snapshot.exists) return false\n const lead = snapshot.data() ?? {}\n if (lead['convertedContactId'] || crmLeadStatus(lead as never) !== 'new') return false\n await ref.set({ status: 'working', updatedAt: FieldValue.serverTimestamp() }, { merge: true })\n return true\n } catch (error) {\n console.error('[outreach] the lead could not be marked working', input.orgId, input.leadId, error)\n return false\n }\n}\n\n/** Every enrollment made on the lead now names the contact it became. */\nexport async function followOutreachLeadToContact(\n firestore: Firestore,\n input: Pick<PluginLeadConversionRequest, 'orgId' | 'leadId' | 'contactId'>,\n): Promise<PluginLeadConversionReport> {\n const enrollments = outreachOrgCollection(firestore, input.orgId, 'enrollments')\n const rows = await enrollments.where('leadId', '==', input.leadId).get()\n const refs = rows.docs\n .filter((row) => row.get('target') !== 'contact' || String(row.get('contactId') ?? '') !== input.contactId)\n .map((row) => row.ref)\n for (let start = 0; start < refs.length; start += BATCH_LIMIT) {\n const batch = firestore.batch()\n for (const ref of refs.slice(start, start + BATCH_LIMIT)) {\n batch.update(ref, { target: 'contact', contactId: input.contactId, updatedAtMs: Date.now() })\n }\n await batch.commit()\n }\n return { enrollments: refs.length }\n}\n"],"names":["crmLeadStatus","FieldValue","outreachOrgCollection","BATCH_LIMIT","markOutreachLeadWorking","firestore","input","snapshot","ref","collection","doc","orgId","leadId","get","exists","lead","data","set","status","updatedAt","serverTimestamp","merge","error","console","followOutreachLeadToContact","enrollments","rows","where","refs","docs","filter","row","String","contactId","map","start","length","batch","slice","update","target","updatedAtMs","Date","now","commit"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;CAqBC,GAED,SAASA,aAAa,QAAQ,6BAA4B;AAK1D,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,qBAAqB,QAAQ,iCAA6B;AAInE,uDAAuD,GACvD,MAAMC,cAAc;AAEpB,qFAAqF,GACrF,OAAO,eAAeC,wBACpBC,SAAoB,EACpBC,KAAwC;IAExC,IAAI;YAKWC;QAJb,oEAAoE;QACpE,MAAMC,MAAMH,UAAUI,UAAU,CAAC,QAAQC,GAAG,CAACJ,MAAMK,KAAK,EAAEF,UAAU,CAAC,SAASC,GAAG,CAACJ,MAAMM,MAAM;QAC9F,MAAML,WAAW,MAAMC,IAAIK,GAAG;QAC9B,IAAI,CAACN,SAASO,MAAM,EAAE,OAAO;QAC7B,MAAMC,QAAOR,iBAAAA,SAASS,IAAI,cAAbT,iBAAmB,CAAC;QACjC,IAAIQ,IAAI,CAAC,qBAAqB,IAAIf,cAAce,UAAmB,OAAO,OAAO;QACjF,MAAMP,IAAIS,GAAG,CAAC;YAAEC,QAAQ;YAAWC,WAAWlB,WAAWmB,eAAe;QAAG,GAAG;YAAEC,OAAO;QAAK;QAC5F,OAAO;IACT,EAAE,OAAOC,OAAO;QACdC,QAAQD,KAAK,CAAC,mDAAmDhB,MAAMK,KAAK,EAAEL,MAAMM,MAAM,EAAEU;QAC5F,OAAO;IACT;AACF;AAEA,uEAAuE,GACvE,OAAO,eAAeE,4BACpBnB,SAAoB,EACpBC,KAA0E;IAE1E,MAAMmB,cAAcvB,sBAAsBG,WAAWC,MAAMK,KAAK,EAAE;IAClE,MAAMe,OAAO,MAAMD,YAAYE,KAAK,CAAC,UAAU,MAAMrB,MAAMM,MAAM,EAAEC,GAAG;IACtE,MAAMe,OAAOF,KAAKG,IAAI,CACnBC,MAAM,CAAC,CAACC;YAAkDA;eAA1CA,IAAIlB,GAAG,CAAC,cAAc,aAAamB,QAAOD,WAAAA,IAAIlB,GAAG,CAAC,wBAARkB,WAAwB,QAAQzB,MAAM2B,SAAS;OACzGC,GAAG,CAAC,CAACH,MAAQA,IAAIvB,GAAG;IACvB,IAAK,IAAI2B,QAAQ,GAAGA,QAAQP,KAAKQ,MAAM,EAAED,SAAShC,YAAa;QAC7D,MAAMkC,QAAQhC,UAAUgC,KAAK;QAC7B,KAAK,MAAM7B,OAAOoB,KAAKU,KAAK,CAACH,OAAOA,QAAQhC,aAAc;YACxDkC,MAAME,MAAM,CAAC/B,KAAK;gBAAEgC,QAAQ;gBAAWP,WAAW3B,MAAM2B,SAAS;gBAAEQ,aAAaC,KAAKC,GAAG;YAAG;QAC7F;QACA,MAAMN,MAAMO,MAAM;IACpB;IACA,OAAO;QAAEnB,aAAaG,KAAKQ,MAAM;IAAC;AACpC"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/lead-records.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHAT A SEQUENCE DOES TO A LEAD'S OWN RECORD (AGL-3234).\n *\n * Three touches, all on `orgs/{orgId}/leads/{leadId}` — the record system's\n * document, written in the record system's vocabulary (`@aglyn/aglyn`'s lead\n * statuses), never through an import of the CRM plugin:\n *\n * - a SENT STEP moves a lead nobody has touched from New to Nurturing.\n * Automated email is reaching it, so it is not untouched — the digest's\n * unworked list would otherwise name every lead a sequence is working —\n * but no person has engaged yet. Only a send moves it, never the\n * enrollment: a step that never goes out has reached nobody.\n * - a REPLY moves a lead that is New or Nurturing to Working. Somebody\n * wrote back; the lead is being worked, and a queue that still showed it\n * as New would send a rep to open a conversation already open. A lead\n * already Working, closed, or converted is left as it is — a reply does\n * not reopen a verdict or move a contact's record.\n * - a CONVERSION re-points every enrollment made on the lead at the contact\n * it became: the record system tells every plugin through its\n * lead-conversion seam, and this is Sequences' share of it. The\n * enrollment keeps its id, its thread and its place in the steps, and\n * keeps naming the lead beside the contact, so both pages list it.\n *\n * Neither throws: a status that could not be moved is one a rep moves by\n * hand, and an enrollment that could not follow its lead is one the next\n * send stops as `contact_missing` and a rep re-enrolls.\n */\n\nimport { type CrmLeadStatus, crmLeadStatus } from '@aglyn/aglyn/app-utils/crm'\nimport type {\n PluginLeadConversionReport,\n PluginLeadConversionRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** Firestore refuses a batch of more than 500 writes. */\nconst BATCH_LIMIT = 450\n\n/**\n * Move a lead forward to `to` when it stands in one of `from`; `true` when\n * it moved. A converted lead is a contact's history and never moves.\n */\nasync function advanceOutreachLead(\n firestore: Firestore,\n input: { orgId: string; leadId: string },\n from: readonly CrmLeadStatus[],\n to: CrmLeadStatus,\n): Promise<boolean> {\n try {\n // A lead is one org row (AGL-3275), so the site no longer names it.\n const ref = firestore.collection('orgs').doc(input.orgId).collection('leads').doc(input.leadId)\n const snapshot = await ref.get()\n if (!snapshot.exists) return false\n const lead = snapshot.data() ?? {}\n if (lead['convertedContactId'] || !from.includes(crmLeadStatus(lead as never))) return false\n await ref.set({ status: to, updatedAt: FieldValue.serverTimestamp() }, { merge: true })\n return true\n } catch (error) {\n console.error(`[outreach] the lead could not be marked ${to}`, input.orgId, input.leadId, error)\n return false\n }\n}\n\n/** A lead nobody has touched, once a step was sent to it: Nurturing. `true` when it moved. */\nexport function markOutreachLeadNurturing(\n firestore: Firestore,\n input: { orgId: string; leadId: string },\n): Promise<boolean> {\n return advanceOutreachLead(firestore, input, ['new'], 'nurturing')\n}\n\n/** A lead no person has engaged, once somebody replied: Working. `true` when it moved. */\nexport function markOutreachLeadWorking(\n firestore: Firestore,\n input: { orgId: string; leadId: string },\n): Promise<boolean> {\n return advanceOutreachLead(firestore, input, ['new', 'nurturing'], 'working')\n}\n\n/** Every enrollment made on the lead now names the contact it became. */\nexport async function followOutreachLeadToContact(\n firestore: Firestore,\n input: Pick<PluginLeadConversionRequest, 'orgId' | 'leadId' | 'contactId'>,\n): Promise<PluginLeadConversionReport> {\n const enrollments = outreachOrgCollection(firestore, input.orgId, 'enrollments')\n const rows = await enrollments.where('leadId', '==', input.leadId).get()\n const refs = rows.docs\n .filter((row) => row.get('target') !== 'contact' || String(row.get('contactId') ?? '') !== input.contactId)\n .map((row) => row.ref)\n for (let start = 0; start < refs.length; start += BATCH_LIMIT) {\n const batch = firestore.batch()\n for (const ref of refs.slice(start, start + BATCH_LIMIT)) {\n batch.update(ref, { target: 'contact', contactId: input.contactId, updatedAtMs: Date.now() })\n }\n await batch.commit()\n }\n return { enrollments: refs.length }\n}\n"],"names":["crmLeadStatus","FieldValue","outreachOrgCollection","BATCH_LIMIT","advanceOutreachLead","firestore","input","from","to","snapshot","ref","collection","doc","orgId","leadId","get","exists","lead","data","includes","set","status","updatedAt","serverTimestamp","merge","error","console","markOutreachLeadNurturing","markOutreachLeadWorking","followOutreachLeadToContact","enrollments","rows","where","refs","docs","filter","row","String","contactId","map","start","length","batch","slice","update","target","updatedAtMs","Date","now","commit"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED,SAA6BA,aAAa,QAAQ,6BAA4B;AAK9E,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,qBAAqB,QAAQ,iCAA6B;AAInE,uDAAuD,GACvD,MAAMC,cAAc;AAEpB;;;CAGC,GACD,eAAeC,oBACbC,SAAoB,EACpBC,KAAwC,EACxCC,IAA8B,EAC9BC,EAAiB;IAEjB,IAAI;YAKWC;QAJb,oEAAoE;QACpE,MAAMC,MAAML,UAAUM,UAAU,CAAC,QAAQC,GAAG,CAACN,MAAMO,KAAK,EAAEF,UAAU,CAAC,SAASC,GAAG,CAACN,MAAMQ,MAAM;QAC9F,MAAML,WAAW,MAAMC,IAAIK,GAAG;QAC9B,IAAI,CAACN,SAASO,MAAM,EAAE,OAAO;QAC7B,MAAMC,QAAOR,iBAAAA,SAASS,IAAI,cAAbT,iBAAmB,CAAC;QACjC,IAAIQ,IAAI,CAAC,qBAAqB,IAAI,CAACV,KAAKY,QAAQ,CAACnB,cAAciB,QAAiB,OAAO;QACvF,MAAMP,IAAIU,GAAG,CAAC;YAAEC,QAAQb;YAAIc,WAAWrB,WAAWsB,eAAe;QAAG,GAAG;YAAEC,OAAO;QAAK;QACrF,OAAO;IACT,EAAE,OAAOC,OAAO;QACdC,QAAQD,KAAK,CAAC,CAAC,wCAAwC,EAAEjB,IAAI,EAAEF,MAAMO,KAAK,EAAEP,MAAMQ,MAAM,EAAEW;QAC1F,OAAO;IACT;AACF;AAEA,4FAA4F,GAC5F,OAAO,SAASE,0BACdtB,SAAoB,EACpBC,KAAwC;IAExC,OAAOF,oBAAoBC,WAAWC,OAAO;QAAC;KAAM,EAAE;AACxD;AAEA,wFAAwF,GACxF,OAAO,SAASsB,wBACdvB,SAAoB,EACpBC,KAAwC;IAExC,OAAOF,oBAAoBC,WAAWC,OAAO;QAAC;QAAO;KAAY,EAAE;AACrE;AAEA,uEAAuE,GACvE,OAAO,eAAeuB,4BACpBxB,SAAoB,EACpBC,KAA0E;IAE1E,MAAMwB,cAAc5B,sBAAsBG,WAAWC,MAAMO,KAAK,EAAE;IAClE,MAAMkB,OAAO,MAAMD,YAAYE,KAAK,CAAC,UAAU,MAAM1B,MAAMQ,MAAM,EAAEC,GAAG;IACtE,MAAMkB,OAAOF,KAAKG,IAAI,CACnBC,MAAM,CAAC,CAACC;YAAkDA;eAA1CA,IAAIrB,GAAG,CAAC,cAAc,aAAasB,QAAOD,WAAAA,IAAIrB,GAAG,CAAC,wBAARqB,WAAwB,QAAQ9B,MAAMgC,SAAS;OACzGC,GAAG,CAAC,CAACH,MAAQA,IAAI1B,GAAG;IACvB,IAAK,IAAI8B,QAAQ,GAAGA,QAAQP,KAAKQ,MAAM,EAAED,SAASrC,YAAa;QAC7D,MAAMuC,QAAQrC,UAAUqC,KAAK;QAC7B,KAAK,MAAMhC,OAAOuB,KAAKU,KAAK,CAACH,OAAOA,QAAQrC,aAAc;YACxDuC,MAAME,MAAM,CAAClC,KAAK;gBAAEmC,QAAQ;gBAAWP,WAAWhC,MAAMgC,SAAS;gBAAEQ,aAAaC,KAAKC,GAAG;YAAG;QAC7F;QACA,MAAMN,MAAMO,MAAM;IACpB;IACA,OAAO;QAAEnB,aAAaG,KAAKQ,MAAM;IAAC;AACpC"}
@@ -13,7 +13,7 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ import { EMAIL_TOPIC_SALES } from "@aglyn/aglyn/app-utils/email-topics";
16
+ */ import { SALES_TOPIC_ID } from "../model/sales-topic.js";
17
17
  import { platformMailDnsResolver } from "@aglyn/tenant-data-admin/server/email-deliverability";
18
18
  import { checkEntitlement } from "@aglyn/aglyn/app-utils/plan-entitlements";
19
19
  import { isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
@@ -21,7 +21,7 @@ import { pluginRecordTimelineWriter } from "@aglyn/aglyn/plugin-manager/plugin-r
21
21
  import { attributeCampaignConversion, creditCampaignSequenceOutcome } from "@aglyn/tenant-data-admin/server/campaign-conversion-attribution";
22
22
  import { recordEmailCampaignTouch } from "@aglyn/tenant-data-admin/server/email-delivery-log";
23
23
  import { suppressEmail } from "@aglyn/tenant-data-admin/server/email-suppression";
24
- import { recordTopicOptOut } from "@aglyn/tenant-data-admin/server/email-topic-confirmation";
24
+ import { recordTopicOptOut } from "@aglyn/tenant-data-admin/server/topic-subscriptions";
25
25
  import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
26
26
  import { getLockdownVerdict } from "@aglyn/tenant-data-admin/server/lockdown";
27
27
  import { sendOrgMemberNotice } from "@aglyn/tenant-data-admin/server/org-member-notice";
@@ -131,7 +131,7 @@ import { createOutreachUnsubscribeRoute } from "./unsubscribe-route.js";
131
131
  },
132
132
  logOrgActivity: (orgId, actor, action, target)=>logOrgActivity(orgId, actor, action, target),
133
133
  async optOutOfSalesTopic ({ hostId, email }) {
134
- await recordTopicOptOut(hostId, email, EMAIL_TOPIC_SALES, {
134
+ await recordTopicOptOut(hostId, email, SALES_TOPIC_ID, {
135
135
  firestore: firestore()
136
136
  });
137
137
  },
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/platform-runtime-deps.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { EMAIL_TOPIC_SALES } from '@aglyn/aglyn/app-utils/email-topics'\nimport { platformMailDnsResolver } from '@aglyn/tenant-data-admin/server/email-deliverability'\nimport { checkEntitlement } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { isPluginEnabled } from '@aglyn/aglyn/plugin-manager/enabled-plugins'\nimport { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport {\n attributeCampaignConversion,\n creditCampaignSequenceOutcome,\n} from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { recordEmailCampaignTouch } from '@aglyn/tenant-data-admin/server/email-delivery-log'\nimport { suppressEmail } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport { recordTopicOptOut } from '@aglyn/tenant-data-admin/server/email-topic-confirmation'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { getLockdownVerdict } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { sendOrgMemberNotice } from '@aglyn/tenant-data-admin/server/org-member-notice'\nimport { logOrgActivity } from '@aglyn/tenant-data-admin/server/organizations'\nimport { filterEnabledPluginsByReleaseFlags } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { resolveTrackingLinkOrigin } from '@aglyn/tenant-data-admin/server/tracking-hosts'\nimport { sendingWorkspaceFor } from '@aglyn/tenant-data-admin/server/outbound-send-review'\nimport { screenTenantMessage } from '@aglyn/shared-util-email/outbound-screen-gate'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport { composeOutreachMailboxNotice } from '../engine/mailbox-notice'\nimport { openOutreachMailboxClient } from '../mailboxes/mailbox-transport'\nimport { canonicalConsoleOrigin } from '../mailboxes/oauth-redirect'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport type { PluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { outreachShortLinkUrl } from './click-link'\nimport { createOutreachClickRoute, createOutreachShortLinkRoute } from './click-route'\nimport { createOutreachPersonEraser } from './person-erasure'\nimport type { OutreachCampaignCredit, OutreachRuntimeDeps } from './runtime-deps'\nimport { outreachUnsubscribeUrl } from './unsubscribe-link'\nimport { createOutreachUnsubscribeRoute } from './unsubscribe-route'\n\n/**\n * The platform's campaign attribution, for what a sequence produced\n * (AGL-3254): the same join every form, booking and campaign email is\n * credited through, so a sequence's numbers and a campaign send's are two\n * readings of one rule. Each writer already never throws.\n */\nexport function platformOutreachCampaignCredit(\n firestore: () => FirebaseFirestore.Firestore,\n): OutreachCampaignCredit {\n return {\n async credit({ hostId, orgId, campaignIds, outcome, atMs }) {\n await creditCampaignSequenceOutcome({ hostId, orgId, campaignIds, outcome, atMs }, firestore())\n },\n async attributeRecord({ hostId, kind, refId, campaignId, sequenceId, enrollmentId, atMs }) {\n await attributeCampaignConversion(\n {\n hostId,\n kind,\n refId,\n touch: { channel: 'sequence', campaignId, sequenceId, enrollmentId, touchedAtMs: atMs },\n convertedAtMs: atMs,\n },\n firestore(),\n )\n },\n async recordTouch({ hostId, email, campaignId, sequenceId, enrollmentId, atMs }) {\n await recordEmailCampaignTouch({ email, hostId, campaignId, atMs, sequenceId, enrollmentId }, firestore())\n },\n }\n}\n\n/**\n * The platform's own reach for the sending runtime (AGL-2981). Loaded only\n * when a job runs or the unsubscribe route answers — never at boot — so the\n * console pays for these modules when Outreach does work.\n */\nexport function platformOutreachRuntimeDeps(): OutreachRuntimeDeps {\n const firestore = () => firebaseAdmin.app().firestore()\n return {\n firestore,\n now: Date.now,\n random: Math.random,\n campaignCredit: platformOutreachCampaignCredit(firestore),\n openMailbox: (mailboxId) => openOutreachMailboxClient(firestore(), { mailboxId }),\n // The domain's MX (AGL-3326) and, for a domain with none, its address\n // record — through the platform's pinned resolver (AGL-3328), which\n // asks twice before it says a domain takes no mail.\n resolveMx: (domain) => platformMailDnsResolver().resolveMx(domain),\n resolveAddress: (domain) => platformMailDnsResolver().resolveAddress?.(domain) ?? Promise.resolve(false),\n async orgRefusal(orgId, org) {\n if (!isPluginEnabled(org as { enabledPlugins?: string[] }, OUTREACH_PLUGIN_ID)) return 'plugin-disabled'\n if (!checkEntitlement(org, 'outreach')) return 'not-entitled'\n const released = await filterEnabledPluginsByReleaseFlags([OUTREACH_PLUGIN_ID], { orgId })\n return released.length ? null : 'not-released'\n },\n async sendRefusal({ org, uid }) {\n const locked = await getLockdownVerdict({ org, uid, intent: 'write' })\n return locked ? `locked:${locked.scope}` : null\n },\n timeline: () => pluginRecordTimelineWriter()?.writer ?? null,\n logOrgActivity: (orgId, actor, action, target) => logOrgActivity(orgId, actor, action, target),\n async optOutOfSalesTopic({ hostId, email }) {\n await recordTopicOptOut(hostId, email, EMAIL_TOPIC_SALES, { firestore: firestore() })\n },\n async suppressBouncedEmail({ email, hostId }) {\n // The runtime stamps the record itself, with the richer verdict — the\n // domain block, the enrollment — so the list's own stamp stands down.\n await suppressEmail({ email, reason: 'bounce', context: 'outreach', hostId, stampRecord: false, firestore: firestore() })\n },\n async stampRecordEmailState(stamp) {\n await stampRecordEmailState(stamp)\n },\n async notifyMailboxOwner(notice) {\n // The Mailboxes page, by the organization's slug — the page Resume and\n // Reconnect live on (AGL-3244). Without a slug or a console origin the\n // notice still goes, and says where the page is instead.\n const org = await firestore().collection('orgs').doc(notice.orgId).get()\n const slug = typeof org.get('slug') === 'string' ? String(org.get('slug')) : ''\n const origin = canonicalConsoleOrigin()\n const mailboxesUrl = slug && origin ? `${origin}/${encodeURIComponent(slug)}/outreach/mailboxes` : null\n const email = composeOutreachMailboxNotice(notice, { mailboxesUrl })\n const result = await sendOrgMemberNotice({\n orgId: notice.orgId,\n uids: [notice.connectedByUid],\n includeAdmins: true,\n subject: email.subject,\n text: email.text,\n context: 'outreach-mailbox-notice',\n })\n if (!result.sent) console.warn(`[outreach] the mailbox owner was not emailed: ${result.reason}`)\n },\n unsubscribeUrl: (target) => outreachUnsubscribeUrl({ origin: canonicalConsoleOrigin(), target }),\n clickLinkUrl: (linkId, trackingOrigin) =>\n outreachShortLinkUrl({ origin: canonicalConsoleOrigin(), linkId, trackingOrigin }),\n // The sending domain's own `links.` host once the organization has\n // verified it (AGL-3306). A static import like every other\n // `tenant-data-admin` module here: a dynamic one marks the whole library\n // lazy-loaded, and `@nx/enforce-module-boundaries` then refuses every\n // static import of it across the plugin and the console.\n clickLinkOrigin({ orgId, senderAddress }) {\n return resolveTrackingLinkOrigin(firestore(), orgId, senderAddress)\n },\n // The one phishing screen every tenant message passes (AGL-3356). The\n // review module's import installs its store, so a hold files the same\n // abuse-queue row a held newsletter or campaign does.\n async screenMessage({ orgId, org, hostId, host, subject, text, fromName, fromAddress }) {\n const refusal = await screenTenantMessage(\n {\n workspace: sendingWorkspaceFor({ hostId, orgId, org, host }),\n subject,\n fromName,\n fromAddress: fromAddress ?? null,\n bodies: [text],\n context: 'outreach sequence',\n },\n 'outreach email',\n )\n return refusal?.detail ?? null\n },\n }\n}\n\n/** The unsubscribe route on the platform's own reach. */\nexport function platformOutreachUnsubscribeRoute(): PluginWebApiHandler {\n return createOutreachUnsubscribeRoute(platformOutreachRuntimeDeps())\n}\n\n/** The click-tracking redirect on the platform's own reach (AGL-3239). */\nexport function platformOutreachClickRoute(): PluginWebApiHandler {\n return createOutreachClickRoute(platformOutreachRuntimeDeps())\n}\n\n/** The short tracking link's redirect on the platform's own reach (AGL-3297). */\nexport function platformOutreachShortLinkRoute(): PluginWebApiHandler {\n return createOutreachShortLinkRoute(platformOutreachRuntimeDeps())\n}\n\n/** The person eraser on the platform's own Firestore. */\nexport function platformOutreachPersonEraser(): PluginPersonEraser {\n return createOutreachPersonEraser({ firestore: () => firebaseAdmin.app().firestore() })\n}\n"],"names":["EMAIL_TOPIC_SALES","platformMailDnsResolver","checkEntitlement","isPluginEnabled","pluginRecordTimelineWriter","attributeCampaignConversion","creditCampaignSequenceOutcome","recordEmailCampaignTouch","suppressEmail","recordTopicOptOut","firebaseAdmin","getLockdownVerdict","sendOrgMemberNotice","logOrgActivity","filterEnabledPluginsByReleaseFlags","resolveTrackingLinkOrigin","sendingWorkspaceFor","screenTenantMessage","stampRecordEmailState","OUTREACH_PLUGIN_ID","composeOutreachMailboxNotice","openOutreachMailboxClient","canonicalConsoleOrigin","outreachShortLinkUrl","createOutreachClickRoute","createOutreachShortLinkRoute","createOutreachPersonEraser","outreachUnsubscribeUrl","createOutreachUnsubscribeRoute","platformOutreachCampaignCredit","firestore","credit","hostId","orgId","campaignIds","outcome","atMs","attributeRecord","kind","refId","campaignId","sequenceId","enrollmentId","touch","channel","touchedAtMs","convertedAtMs","recordTouch","email","platformOutreachRuntimeDeps","app","now","Date","random","Math","campaignCredit","openMailbox","mailboxId","resolveMx","domain","resolveAddress","Promise","resolve","orgRefusal","org","released","length","sendRefusal","uid","locked","intent","scope","timeline","writer","actor","action","target","optOutOfSalesTopic","suppressBouncedEmail","reason","context","stampRecord","stamp","notifyMailboxOwner","notice","collection","doc","get","slug","String","origin","mailboxesUrl","encodeURIComponent","result","uids","connectedByUid","includeAdmins","subject","text","sent","console","warn","unsubscribeUrl","clickLinkUrl","linkId","trackingOrigin","clickLinkOrigin","senderAddress","screenMessage","host","fromName","fromAddress","refusal","workspace","bodies","detail","platformOutreachUnsubscribeRoute","platformOutreachClickRoute","platformOutreachShortLinkRoute","platformOutreachPersonEraser"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/platform-runtime-deps.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { SALES_TOPIC_ID } from '../model/sales-topic'\nimport { platformMailDnsResolver } from '@aglyn/tenant-data-admin/server/email-deliverability'\nimport { checkEntitlement } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { isPluginEnabled } from '@aglyn/aglyn/plugin-manager/enabled-plugins'\nimport { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport {\n attributeCampaignConversion,\n creditCampaignSequenceOutcome,\n} from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { recordEmailCampaignTouch } from '@aglyn/tenant-data-admin/server/email-delivery-log'\nimport { suppressEmail } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport { recordTopicOptOut } from '@aglyn/tenant-data-admin/server/topic-subscriptions'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { getLockdownVerdict } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { sendOrgMemberNotice } from '@aglyn/tenant-data-admin/server/org-member-notice'\nimport { logOrgActivity } from '@aglyn/tenant-data-admin/server/organizations'\nimport { filterEnabledPluginsByReleaseFlags } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { resolveTrackingLinkOrigin } from '@aglyn/tenant-data-admin/server/tracking-hosts'\nimport { sendingWorkspaceFor } from '@aglyn/tenant-data-admin/server/outbound-send-review'\nimport { screenTenantMessage } from '@aglyn/shared-util-email/outbound-screen-gate'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport { composeOutreachMailboxNotice } from '../engine/mailbox-notice'\nimport { openOutreachMailboxClient } from '../mailboxes/mailbox-transport'\nimport { canonicalConsoleOrigin } from '../mailboxes/oauth-redirect'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport type { PluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { outreachShortLinkUrl } from './click-link'\nimport { createOutreachClickRoute, createOutreachShortLinkRoute } from './click-route'\nimport { createOutreachPersonEraser } from './person-erasure'\nimport type { OutreachCampaignCredit, OutreachRuntimeDeps } from './runtime-deps'\nimport { outreachUnsubscribeUrl } from './unsubscribe-link'\nimport { createOutreachUnsubscribeRoute } from './unsubscribe-route'\n\n/**\n * The platform's campaign attribution, for what a sequence produced\n * (AGL-3254): the same join every form, booking and campaign email is\n * credited through, so a sequence's numbers and a campaign send's are two\n * readings of one rule. Each writer already never throws.\n */\nexport function platformOutreachCampaignCredit(\n firestore: () => FirebaseFirestore.Firestore,\n): OutreachCampaignCredit {\n return {\n async credit({ hostId, orgId, campaignIds, outcome, atMs }) {\n await creditCampaignSequenceOutcome({ hostId, orgId, campaignIds, outcome, atMs }, firestore())\n },\n async attributeRecord({ hostId, kind, refId, campaignId, sequenceId, enrollmentId, atMs }) {\n await attributeCampaignConversion(\n {\n hostId,\n kind,\n refId,\n touch: { channel: 'sequence', campaignId, sequenceId, enrollmentId, touchedAtMs: atMs },\n convertedAtMs: atMs,\n },\n firestore(),\n )\n },\n async recordTouch({ hostId, email, campaignId, sequenceId, enrollmentId, atMs }) {\n await recordEmailCampaignTouch({ email, hostId, campaignId, atMs, sequenceId, enrollmentId }, firestore())\n },\n }\n}\n\n/**\n * The platform's own reach for the sending runtime (AGL-2981). Loaded only\n * when a job runs or the unsubscribe route answers — never at boot — so the\n * console pays for these modules when Outreach does work.\n */\nexport function platformOutreachRuntimeDeps(): OutreachRuntimeDeps {\n const firestore = () => firebaseAdmin.app().firestore()\n return {\n firestore,\n now: Date.now,\n random: Math.random,\n campaignCredit: platformOutreachCampaignCredit(firestore),\n openMailbox: (mailboxId) => openOutreachMailboxClient(firestore(), { mailboxId }),\n // The domain's MX (AGL-3326) and, for a domain with none, its address\n // record — through the platform's pinned resolver (AGL-3328), which\n // asks twice before it says a domain takes no mail.\n resolveMx: (domain) => platformMailDnsResolver().resolveMx(domain),\n resolveAddress: (domain) => platformMailDnsResolver().resolveAddress?.(domain) ?? Promise.resolve(false),\n async orgRefusal(orgId, org) {\n if (!isPluginEnabled(org as { enabledPlugins?: string[] }, OUTREACH_PLUGIN_ID)) return 'plugin-disabled'\n if (!checkEntitlement(org, 'outreach')) return 'not-entitled'\n const released = await filterEnabledPluginsByReleaseFlags([OUTREACH_PLUGIN_ID], { orgId })\n return released.length ? null : 'not-released'\n },\n async sendRefusal({ org, uid }) {\n const locked = await getLockdownVerdict({ org, uid, intent: 'write' })\n return locked ? `locked:${locked.scope}` : null\n },\n timeline: () => pluginRecordTimelineWriter()?.writer ?? null,\n logOrgActivity: (orgId, actor, action, target) => logOrgActivity(orgId, actor, action, target),\n async optOutOfSalesTopic({ hostId, email }) {\n await recordTopicOptOut(hostId, email, SALES_TOPIC_ID, { firestore: firestore() })\n },\n async suppressBouncedEmail({ email, hostId }) {\n // The runtime stamps the record itself, with the richer verdict — the\n // domain block, the enrollment — so the list's own stamp stands down.\n await suppressEmail({ email, reason: 'bounce', context: 'outreach', hostId, stampRecord: false, firestore: firestore() })\n },\n async stampRecordEmailState(stamp) {\n await stampRecordEmailState(stamp)\n },\n async notifyMailboxOwner(notice) {\n // The Mailboxes page, by the organization's slug — the page Resume and\n // Reconnect live on (AGL-3244). Without a slug or a console origin the\n // notice still goes, and says where the page is instead.\n const org = await firestore().collection('orgs').doc(notice.orgId).get()\n const slug = typeof org.get('slug') === 'string' ? String(org.get('slug')) : ''\n const origin = canonicalConsoleOrigin()\n const mailboxesUrl = slug && origin ? `${origin}/${encodeURIComponent(slug)}/outreach/mailboxes` : null\n const email = composeOutreachMailboxNotice(notice, { mailboxesUrl })\n const result = await sendOrgMemberNotice({\n orgId: notice.orgId,\n uids: [notice.connectedByUid],\n includeAdmins: true,\n subject: email.subject,\n text: email.text,\n context: 'outreach-mailbox-notice',\n })\n if (!result.sent) console.warn(`[outreach] the mailbox owner was not emailed: ${result.reason}`)\n },\n unsubscribeUrl: (target) => outreachUnsubscribeUrl({ origin: canonicalConsoleOrigin(), target }),\n clickLinkUrl: (linkId, trackingOrigin) =>\n outreachShortLinkUrl({ origin: canonicalConsoleOrigin(), linkId, trackingOrigin }),\n // The sending domain's own `links.` host once the organization has\n // verified it (AGL-3306). A static import like every other\n // `tenant-data-admin` module here: a dynamic one marks the whole library\n // lazy-loaded, and `@nx/enforce-module-boundaries` then refuses every\n // static import of it across the plugin and the console.\n clickLinkOrigin({ orgId, senderAddress }) {\n return resolveTrackingLinkOrigin(firestore(), orgId, senderAddress)\n },\n // The one phishing screen every tenant message passes (AGL-3356). The\n // review module's import installs its store, so a hold files the same\n // abuse-queue row a held newsletter or campaign does.\n async screenMessage({ orgId, org, hostId, host, subject, text, fromName, fromAddress }) {\n const refusal = await screenTenantMessage(\n {\n workspace: sendingWorkspaceFor({ hostId, orgId, org, host }),\n subject,\n fromName,\n fromAddress: fromAddress ?? null,\n bodies: [text],\n context: 'outreach sequence',\n },\n 'outreach email',\n )\n return refusal?.detail ?? null\n },\n }\n}\n\n/** The unsubscribe route on the platform's own reach. */\nexport function platformOutreachUnsubscribeRoute(): PluginWebApiHandler {\n return createOutreachUnsubscribeRoute(platformOutreachRuntimeDeps())\n}\n\n/** The click-tracking redirect on the platform's own reach (AGL-3239). */\nexport function platformOutreachClickRoute(): PluginWebApiHandler {\n return createOutreachClickRoute(platformOutreachRuntimeDeps())\n}\n\n/** The short tracking link's redirect on the platform's own reach (AGL-3297). */\nexport function platformOutreachShortLinkRoute(): PluginWebApiHandler {\n return createOutreachShortLinkRoute(platformOutreachRuntimeDeps())\n}\n\n/** The person eraser on the platform's own Firestore. */\nexport function platformOutreachPersonEraser(): PluginPersonEraser {\n return createOutreachPersonEraser({ firestore: () => firebaseAdmin.app().firestore() })\n}\n"],"names":["SALES_TOPIC_ID","platformMailDnsResolver","checkEntitlement","isPluginEnabled","pluginRecordTimelineWriter","attributeCampaignConversion","creditCampaignSequenceOutcome","recordEmailCampaignTouch","suppressEmail","recordTopicOptOut","firebaseAdmin","getLockdownVerdict","sendOrgMemberNotice","logOrgActivity","filterEnabledPluginsByReleaseFlags","resolveTrackingLinkOrigin","sendingWorkspaceFor","screenTenantMessage","stampRecordEmailState","OUTREACH_PLUGIN_ID","composeOutreachMailboxNotice","openOutreachMailboxClient","canonicalConsoleOrigin","outreachShortLinkUrl","createOutreachClickRoute","createOutreachShortLinkRoute","createOutreachPersonEraser","outreachUnsubscribeUrl","createOutreachUnsubscribeRoute","platformOutreachCampaignCredit","firestore","credit","hostId","orgId","campaignIds","outcome","atMs","attributeRecord","kind","refId","campaignId","sequenceId","enrollmentId","touch","channel","touchedAtMs","convertedAtMs","recordTouch","email","platformOutreachRuntimeDeps","app","now","Date","random","Math","campaignCredit","openMailbox","mailboxId","resolveMx","domain","resolveAddress","Promise","resolve","orgRefusal","org","released","length","sendRefusal","uid","locked","intent","scope","timeline","writer","actor","action","target","optOutOfSalesTopic","suppressBouncedEmail","reason","context","stampRecord","stamp","notifyMailboxOwner","notice","collection","doc","get","slug","String","origin","mailboxesUrl","encodeURIComponent","result","uids","connectedByUid","includeAdmins","subject","text","sent","console","warn","unsubscribeUrl","clickLinkUrl","linkId","trackingOrigin","clickLinkOrigin","senderAddress","screenMessage","host","fromName","fromAddress","refusal","workspace","bodies","detail","platformOutreachUnsubscribeRoute","platformOutreachClickRoute","platformOutreachShortLinkRoute","platformOutreachPersonEraser"],"mappings":"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"}
@@ -40,6 +40,7 @@ import { creditOutreachFirstSend } from "./campaign-credit.js";
40
40
  import { newOutreachLinkId, outreachStoredLink, outreachStoredOpen } from "./click-link.js";
41
41
  import { applyOutreachEvent } from "./enrollment-events.js";
42
42
  import { outreachSiteHeld } from "./host-suspension.js";
43
+ import { markOutreachLeadNurturing } from "./lead-records.js";
43
44
  import { outreachSendDigest, withRecentSend } from "./mailbox-health-store.js";
44
45
  import { noteOutreachMailboxReconnectRequired } from "./mailbox-notices.js";
45
46
  import { fileOutreachEmail, fileOutreachTask, OUTREACH_TASK_KIND_TO_RECORD } from "./timeline.js";
@@ -992,6 +993,18 @@ async function runEmailStep(deps, firestore, run, input) {
992
993
  enrollment,
993
994
  atMs: record.atMs
994
995
  });
996
+ await nurtureEnrolledLead(firestore, run, enrollment);
997
+ }
998
+ /**
999
+ * A lead an email step reached is no longer untouched (AGL-3446): New moves
1000
+ * to Nurturing. Called only once a send is recorded, so an enrollment whose
1001
+ * steps never go out leaves its lead where it stood.
1002
+ */ async function nurtureEnrolledLead(firestore, run, enrollment) {
1003
+ if (enrollment.target !== 'lead' || !enrollment.leadId) return;
1004
+ await markOutreachLeadNurturing(firestore, {
1005
+ orgId: run.orgId,
1006
+ leadId: enrollment.leadId
1007
+ });
995
1008
  }
996
1009
  /**
997
1010
  * Settles a claim a run died holding. A task step's claim is dropped and
@@ -1050,10 +1063,13 @@ async function runEmailStep(deps, firestore, run, input) {
1050
1063
  claimMessageId: claim.messageId
1051
1064
  });
1052
1065
  // A recovered first email is still the first email (AGL-3254).
1053
- if (completed) await creditOutreachFirstSend(deps, {
1054
- enrollment,
1055
- atMs
1056
- });
1066
+ if (completed) {
1067
+ await creditOutreachFirstSend(deps, {
1068
+ enrollment,
1069
+ atMs
1070
+ });
1071
+ await nurtureEnrolledLead(firestore, run, enrollment);
1072
+ }
1057
1073
  return completed;
1058
1074
  }
1059
1075