@aglyn/plugins-crm 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 (136) hide show
  1. package/README.md +2 -0
  2. package/package.json +14 -15
  3. package/src/lib/components/company-import-drawer.d.ts +1 -1
  4. package/src/lib/components/contact-associations-card.js +4 -3
  5. package/src/lib/components/contact-associations-card.js.map +1 -1
  6. package/src/lib/components/crm-booking-zone.d.ts +8 -2
  7. package/src/lib/components/crm-booking-zone.js.map +1 -1
  8. package/src/lib/components/crm-glance-card.js +2 -1
  9. package/src/lib/components/crm-glance-card.js.map +1 -1
  10. package/src/lib/components/deal-products-card.js +27 -16
  11. package/src/lib/components/deal-products-card.js.map +1 -1
  12. package/src/lib/components/lead-campaigns-card.d.ts +4 -4
  13. package/src/lib/components/lead-campaigns-card.js +9 -8
  14. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  15. package/src/lib/components/lead-properties-card.js +4 -0
  16. package/src/lib/components/lead-properties-card.js.map +1 -1
  17. package/src/lib/components/lead-status-chip.js +3 -1
  18. package/src/lib/components/lead-status-chip.js.map +1 -1
  19. package/src/lib/components/leads-bulk-bar.js +7 -9
  20. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  21. package/src/lib/components/leads-section.js +5 -1
  22. package/src/lib/components/leads-section.js.map +1 -1
  23. package/src/lib/components/new-lead-drawer.js +5 -4
  24. package/src/lib/components/new-lead-drawer.js.map +1 -1
  25. package/src/lib/components/reports/activity-card.js +4 -3
  26. package/src/lib/components/reports/activity-card.js.map +1 -1
  27. package/src/lib/components/reports/closed-deals-card.js +4 -3
  28. package/src/lib/components/reports/closed-deals-card.js.map +1 -1
  29. package/src/lib/components/reports/contacts-mix-card.js +3 -2
  30. package/src/lib/components/reports/contacts-mix-card.js.map +1 -1
  31. package/src/lib/components/reports/contacts-trend-card.js +2 -1
  32. package/src/lib/components/reports/contacts-trend-card.js.map +1 -1
  33. package/src/lib/components/reports/forecast-card.js +3 -2
  34. package/src/lib/components/reports/forecast-card.js.map +1 -1
  35. package/src/lib/components/reports/lead-funnel-card.js +8 -6
  36. package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
  37. package/src/lib/components/reports/pipeline-card.js +4 -3
  38. package/src/lib/components/reports/pipeline-card.js.map +1 -1
  39. package/src/lib/components/reports/report-format.d.ts +1 -1
  40. package/src/lib/components/reports/report-format.js.map +1 -1
  41. package/src/lib/components/reports/report-scope.d.ts +1 -1
  42. package/src/lib/components/reports/report-scope.js.map +1 -1
  43. package/src/lib/components/reports/source-conversion-card.js +2 -1
  44. package/src/lib/components/reports/source-conversion-card.js.map +1 -1
  45. package/src/lib/components/reports/tasks-card.js +2 -1
  46. package/src/lib/components/reports/tasks-card.js.map +1 -1
  47. package/src/lib/components/reports/won-lost-by-owner-card.js +3 -2
  48. package/src/lib/components/reports/won-lost-by-owner-card.js.map +1 -1
  49. package/src/lib/components/reports-section.js +4 -4
  50. package/src/lib/components/reports-section.js.map +1 -1
  51. package/src/lib/constants/bundle-common.d.ts +2 -0
  52. package/src/lib/constants/bundle-common.js +1 -0
  53. package/src/lib/constants/bundle-common.js.map +1 -1
  54. package/src/lib/declarations.console-server.d.ts +4 -0
  55. package/src/lib/declarations.console-server.js +12 -0
  56. package/src/lib/declarations.console-server.js.map +1 -1
  57. package/src/lib/declarations.server.d.ts +6 -0
  58. package/src/lib/declarations.server.js +51 -1
  59. package/src/lib/declarations.server.js.map +1 -1
  60. package/src/lib/hooks/use-crm-campaigns.d.ts +2 -2
  61. package/src/lib/hooks/use-crm-campaigns.js +3 -3
  62. package/src/lib/hooks/use-crm-campaigns.js.map +1 -1
  63. package/src/lib/model/campaign-filing-activity.d.ts +1 -1
  64. package/src/lib/model/campaign-filing-activity.js +1 -1
  65. package/src/lib/model/campaign-filing-activity.js.map +1 -1
  66. package/src/lib/model/contact-record.js +1 -1
  67. package/src/lib/model/contact-record.js.map +1 -1
  68. package/src/lib/model/crm-digest.d.ts +45 -3
  69. package/src/lib/model/crm-digest.js +75 -11
  70. package/src/lib/model/crm-digest.js.map +1 -1
  71. package/src/lib/model/crm-inbound.d.ts +162 -0
  72. package/src/lib/model/crm-inbound.js +208 -0
  73. package/src/lib/model/crm-inbound.js.map +1 -0
  74. package/src/lib/model/crm-lead-import.d.ts +1 -1
  75. package/src/lib/model/crm-lead-import.js +4 -4
  76. package/src/lib/model/crm-lead-import.js.map +1 -1
  77. package/src/lib/model/crm-reports.d.ts +467 -0
  78. package/src/lib/model/crm-reports.js +667 -0
  79. package/src/lib/model/crm-reports.js.map +1 -0
  80. package/src/lib/model/lead-filters.d.ts +4 -3
  81. package/src/lib/model/lead-filters.js +7 -5
  82. package/src/lib/model/lead-filters.js.map +1 -1
  83. package/src/lib/plugin.js +36 -0
  84. package/src/lib/plugin.js.map +1 -1
  85. package/src/lib/server/api-v1/crm-leads.js +1 -0
  86. package/src/lib/server/api-v1/crm-leads.js.map +1 -1
  87. package/src/lib/server/api-v1/openapi.js +1 -1
  88. package/src/lib/server/api-v1/openapi.js.map +1 -1
  89. package/src/lib/server/campaign-filing-activity.d.ts +1 -1
  90. package/src/lib/server/campaign-filing-activity.js +7 -11
  91. package/src/lib/server/campaign-filing-activity.js.map +1 -1
  92. package/src/lib/server/capture-contact.js +15 -4
  93. package/src/lib/server/capture-contact.js.map +1 -1
  94. package/src/lib/server/contact-email-engagement.d.ts +33 -0
  95. package/src/lib/server/contact-email-engagement.js +146 -0
  96. package/src/lib/server/contact-email-engagement.js.map +1 -0
  97. package/src/lib/server/contact-update.js +3 -3
  98. package/src/lib/server/contact-update.js.map +1 -1
  99. package/src/lib/server/container-detach.d.ts +31 -0
  100. package/src/lib/server/container-detach.js +115 -0
  101. package/src/lib/server/container-detach.js.map +1 -0
  102. package/src/lib/server/crm-inbound-email.d.ts +162 -0
  103. package/src/lib/server/crm-inbound-email.js +259 -0
  104. package/src/lib/server/crm-inbound-email.js.map +1 -0
  105. package/src/lib/server/crm-records-meter.d.ts +50 -0
  106. package/src/lib/server/crm-records-meter.js +126 -0
  107. package/src/lib/server/crm-records-meter.js.map +1 -0
  108. package/src/lib/server/daily-digest-route.d.ts +2 -8
  109. package/src/lib/server/daily-digest-route.js +15 -25
  110. package/src/lib/server/daily-digest-route.js.map +1 -1
  111. package/src/lib/server/inbound-address.js +5 -3
  112. package/src/lib/server/inbound-address.js.map +1 -1
  113. package/src/lib/server/inbound-route.js +7 -4
  114. package/src/lib/server/inbound-route.js.map +1 -1
  115. package/src/lib/server/lead-campaign-carry.js +3 -3
  116. package/src/lib/server/lead-campaign-carry.js.map +1 -1
  117. package/src/lib/server/lead-create.d.ts +1 -1
  118. package/src/lib/server/lead-create.js +10 -15
  119. package/src/lib/server/lead-create.js.map +1 -1
  120. package/src/lib/server/lead-nurturing.d.ts +23 -0
  121. package/src/lib/server/lead-nurturing.js +82 -0
  122. package/src/lib/server/lead-nurturing.js.map +1 -0
  123. package/src/lib/server/leads-import.js +7 -7
  124. package/src/lib/server/leads-import.js.map +1 -1
  125. package/src/lib/server/record-email-state.js +23 -0
  126. package/src/lib/server/record-email-state.js.map +1 -1
  127. package/src/lib/server/record-timeline.d.ts +12 -0
  128. package/src/lib/server/record-timeline.js +127 -15
  129. package/src/lib/server/record-timeline.js.map +1 -1
  130. package/src/lib/server/task-reminders-route.js +4 -2
  131. package/src/lib/server/task-reminders-route.js.map +1 -1
  132. package/src/lib/server/task-routes.d.ts +2 -0
  133. package/src/lib/server/task-routes.js +22 -3
  134. package/src/lib/server/task-routes.js.map +1 -1
  135. package/src/lib/usage-axes.js +8 -1
  136. package/src/lib/usage-axes.js.map +1 -1
@@ -1,4 +1,4 @@
1
- import { type HostCampaigns } from '@aglyn/tenant-feature-instance';
1
+ import { type ContainerOptions } from '@aglyn/tenant-feature-instance';
2
2
  /**
3
3
  * THE CAMPAIGNS A CRM SURFACE OFFERS AND NAMES, at either level.
4
4
  *
@@ -17,5 +17,5 @@ export declare function useCrmCampaigns(scope: {
17
17
  orgId: string | null | undefined;
18
18
  }, options?: {
19
19
  enabled?: boolean;
20
- }): HostCampaigns;
20
+ }): ContainerOptions;
21
21
  export default useCrmCampaigns;
@@ -14,7 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ 'use client';
17
- import { useHostCampaigns, useOrgCampaigns } from "@aglyn/tenant-feature-instance";
17
+ import { useSiteContainerOptions, useOrgContainerOptions } from "@aglyn/tenant-feature-instance";
18
18
  /**
19
19
  * THE CAMPAIGNS A CRM SURFACE OFFERS AND NAMES, at either level.
20
20
  *
@@ -31,10 +31,10 @@ import { useHostCampaigns, useOrgCampaigns } from "@aglyn/tenant-feature-instanc
31
31
  var _ref;
32
32
  const enabled = (_ref = options == null ? void 0 : options.enabled) != null ? _ref : false;
33
33
  const hostId = scope.hostId || null;
34
- const site = useHostCampaigns(hostId != null ? hostId : undefined, {
34
+ const site = useSiteContainerOptions('campaign', hostId != null ? hostId : undefined, {
35
35
  enabled: enabled && Boolean(hostId)
36
36
  });
37
- const org = useOrgCampaigns(scope.orgId, {
37
+ const org = useOrgContainerOptions('campaign', scope.orgId, {
38
38
  enabled: enabled && !hostId
39
39
  });
40
40
  return hostId ? site : org;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/hooks/use-crm-campaigns.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'use client'\n\nimport {\n useHostCampaigns,\n useOrgCampaigns,\n type HostCampaigns,\n} from '@aglyn/tenant-feature-instance'\n\n/**\n * THE CAMPAIGNS A CRM SURFACE OFFERS AND NAMES, at either level.\n *\n * Campaigns belong to the organization, and so do leads. Under a site the\n * list is the campaigns placed on that site — the ones its hub shows and\n * its captures file under. At the organization level (`hostId` null) it is\n * every campaign in the org, because the list there spans every site and a\n * lead from any of them may be filed under any campaign.\n *\n * Both hooks are called on every render, since a hook may not be called\n * conditionally; only the one for this level is enabled, so only one\n * listener opens.\n */\nexport function useCrmCampaigns(\n scope: {\n hostId: string | null | undefined\n orgId: string | null | undefined\n },\n options?: { enabled?: boolean },\n): HostCampaigns {\n const enabled = options?.enabled ?? false\n const hostId = scope.hostId || null\n const site = useHostCampaigns(hostId ?? undefined, {\n enabled: enabled && Boolean(hostId),\n })\n const org = useOrgCampaigns(scope.orgId, {\n enabled: enabled && !hostId,\n })\n return hostId ? site : org\n}\n\nexport default useCrmCampaigns\n"],"names":["useHostCampaigns","useOrgCampaigns","useCrmCampaigns","scope","options","enabled","hostId","site","undefined","Boolean","org","orgId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;AAEA,SACEA,gBAAgB,EAChBC,eAAe,QAEV,iCAAgC;AAEvC;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,gBACdC,KAGC,EACDC,OAA+B;;IAE/B,MAAMC,kBAAUD,2BAAAA,QAASC,OAAO,mBAAI;IACpC,MAAMC,SAASH,MAAMG,MAAM,IAAI;IAC/B,MAAMC,OAAOP,iBAAiBM,iBAAAA,SAAUE,WAAW;QACjDH,SAASA,WAAWI,QAAQH;IAC9B;IACA,MAAMI,MAAMT,gBAAgBE,MAAMQ,KAAK,EAAE;QACvCN,SAASA,WAAW,CAACC;IACvB;IACA,OAAOA,SAASC,OAAOG;AACzB;AAEA,eAAeR,gBAAe"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/hooks/use-crm-campaigns.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'use client'\n\nimport {\n useSiteContainerOptions,\n useOrgContainerOptions,\n type ContainerOptions,\n} from '@aglyn/tenant-feature-instance'\n\n/**\n * THE CAMPAIGNS A CRM SURFACE OFFERS AND NAMES, at either level.\n *\n * Campaigns belong to the organization, and so do leads. Under a site the\n * list is the campaigns placed on that site — the ones its hub shows and\n * its captures file under. At the organization level (`hostId` null) it is\n * every campaign in the org, because the list there spans every site and a\n * lead from any of them may be filed under any campaign.\n *\n * Both hooks are called on every render, since a hook may not be called\n * conditionally; only the one for this level is enabled, so only one\n * listener opens.\n */\nexport function useCrmCampaigns(\n scope: {\n hostId: string | null | undefined\n orgId: string | null | undefined\n },\n options?: { enabled?: boolean },\n): ContainerOptions {\n const enabled = options?.enabled ?? false\n const hostId = scope.hostId || null\n const site = useSiteContainerOptions('campaign', hostId ?? undefined, {\n enabled: enabled && Boolean(hostId),\n })\n const org = useOrgContainerOptions('campaign', scope.orgId, {\n enabled: enabled && !hostId,\n })\n return hostId ? site : org\n}\n\nexport default useCrmCampaigns\n"],"names":["useSiteContainerOptions","useOrgContainerOptions","useCrmCampaigns","scope","options","enabled","hostId","site","undefined","Boolean","org","orgId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;AAEA,SACEA,uBAAuB,EACvBC,sBAAsB,QAEjB,iCAAgC;AAEvC;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,gBACdC,KAGC,EACDC,OAA+B;;IAE/B,MAAMC,kBAAUD,2BAAAA,QAASC,OAAO,mBAAI;IACpC,MAAMC,SAASH,MAAMG,MAAM,IAAI;IAC/B,MAAMC,OAAOP,wBAAwB,YAAYM,iBAAAA,SAAUE,WAAW;QACpEH,SAASA,WAAWI,QAAQH;IAC9B;IACA,MAAMI,MAAMT,uBAAuB,YAAYE,MAAMQ,KAAK,EAAE;QAC1DN,SAASA,WAAW,CAACC;IACvB;IACA,OAAOA,SAASC,OAAOG;AACzB;AAEA,eAAeR,gBAAe"}
@@ -58,7 +58,7 @@ export declare function campaignFilingBody(action: CampaignFilingAction, campaig
58
58
  /**
59
59
  * The membership a save changed, as the entries it owes: one `filed` per
60
60
  * id the save added, one `removed` per id it dropped. Order-insensitive,
61
- * like `campaignMembershipUnchanged`; a reorder owes nothing.
61
+ * like `containerMembershipUnchanged`; a reorder owes nothing.
62
62
  */
63
63
  export declare function campaignFilingChanges(before: readonly string[], after: readonly string[]): {
64
64
  added: string[];
@@ -50,7 +50,7 @@ import { BUNDLE_ID } from "../constants/bundle-common.js";
50
50
  /**
51
51
  * The membership a save changed, as the entries it owes: one `filed` per
52
52
  * id the save added, one `removed` per id it dropped. Order-insensitive,
53
- * like `campaignMembershipUnchanged`; a reorder owes nothing.
53
+ * like `containerMembershipUnchanged`; a reorder owes nothing.
54
54
  */ export function campaignFilingChanges(before, after) {
55
55
  return {
56
56
  added: after.filter((id)=>!before.includes(id)),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/campaign-filing-activity.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 type { CrmActivity, CrmActivityLink, ScopeToken } from '@aglyn/aglyn'\nimport { BUNDLE_ID } from '../constants/bundle-common'\n\n/**\n * WHAT A FILING WRITES ON THE RECORD'S TIMELINE (AGL-3274).\n *\n * A lead or a contact filed under a campaign — from its own page, the bulk\n * bar, the New lead drawer, or by conversion carrying a lead's campaigns\n * onto the contact it became — showed nothing of it in Activity, so the\n * timeline read \"email sent\" with no line for the act that put the person\n * in the campaign in the first place. Every door now files one entry per\n * campaign changed, and this module is the one place their shape lives:\n * the client hook and the server helper both build the document here, so\n * an entry filed from the page and one filed by the conversion read alike.\n *\n * ## A `note`, not a kind of its own\n *\n * The timeline's kinds are what a PERSON logs — call, email, meeting, note\n * — and a filing is bookkeeping beside them, the way a sequence's \"Enrolled\n * in\" entry is. A `filing` kind would need its own label, glyph, report\n * column and a place in the log dialog where a member could log one by\n * hand, which is the one thing a filing must never be. So it is a note that\n * names its author (the member, or the conversion), carries the CRM's own\n * id as `sourcePluginId` the way a sequence's entries carry Outreach's, and\n * names the campaign twice: by name in the body, by id in `campaignId`.\n */\n\n/** The kind every filing entry is filed as. */\nexport const CAMPAIGN_FILING_ACTIVITY_KIND = 'note' as const\n\n/** What the entry says was done: put in the campaign, or taken out of it. */\nexport type CampaignFilingAction = 'filed' | 'removed'\n\n/** A campaign as a filing entry names it: the container's id and its name at the time. */\nexport interface CampaignFilingRef {\n id: string\n name: string\n}\n\n/**\n * The author a conversion's carry signs its entries with: nobody in the\n * room typed it, and \"A team member\" — the timeline's word for an\n * anonymous entry — would credit a person with what the conversion did.\n */\nexport const CAMPAIGN_FILING_CARRY_BY_NAME = 'Lead conversion'\n\n/** The one line of the entry, as the timeline shows it. */\nexport function campaignFilingBody(action: CampaignFilingAction, campaignName: string): string {\n const name = campaignName.trim() || 'a campaign'\n return action === 'filed' ? `Filed under ${name}` : `Removed from ${name}`\n}\n\n/**\n * The membership a save changed, as the entries it owes: one `filed` per\n * id the save added, one `removed` per id it dropped. Order-insensitive,\n * like `campaignMembershipUnchanged`; a reorder owes nothing.\n */\nexport function campaignFilingChanges(\n before: readonly string[],\n after: readonly string[],\n): { added: string[]; removed: string[] } {\n return {\n added: after.filter((id) => !before.includes(id)),\n removed: before.filter((id) => !after.includes(id)),\n }\n}\n\nexport interface CampaignFilingActivityInput {\n action: CampaignFilingAction\n campaign: CampaignFilingRef\n link: CrmActivityLink\n /** The site the entry carries as provenance — the lead's site, the contact's viewing site. */\n hostId: string\n visibleTo: readonly ScopeToken[]\n /** When it happened, epoch ms. */\n atMs: number\n /** The member who did it, or `''` for the conversion. */\n byUid: string\n /** The member's name as it read, or the carry's fixed author; absent rather than blank. */\n byName?: string | null\n}\n\n/** The document a filing entry is stored as, minus the timestamps the writer stamps. */\nexport function buildCampaignFilingActivity(input: CampaignFilingActivityInput): CrmActivity {\n const byName = String(input.byName ?? '').trim()\n return {\n kind: CAMPAIGN_FILING_ACTIVITY_KIND,\n body: campaignFilingBody(input.action, input.campaign.name),\n atMs: input.atMs,\n byUid: input.byUid,\n ...(byName ? { byName } : {}),\n ...(input.link.contactId ? { contactId: input.link.contactId } : {}),\n ...(input.link.companyId ? { companyId: input.link.companyId } : {}),\n ...(input.link.dealId ? { dealId: input.link.dealId } : {}),\n ...(input.link.leadId ? { leadId: input.link.leadId } : {}),\n hostId: input.hostId,\n visibleTo: [...input.visibleTo],\n sourcePluginId: BUNDLE_ID,\n campaignId: input.campaign.id,\n }\n}\n"],"names":["BUNDLE_ID","CAMPAIGN_FILING_ACTIVITY_KIND","CAMPAIGN_FILING_CARRY_BY_NAME","campaignFilingBody","action","campaignName","name","trim","campaignFilingChanges","before","after","added","filter","id","includes","removed","buildCampaignFilingActivity","input","byName","String","kind","body","campaign","atMs","byUid","link","contactId","companyId","dealId","leadId","hostId","visibleTo","sourcePluginId","campaignId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SAASA,SAAS,QAAQ,gCAA4B;AAEtD;;;;;;;;;;;;;;;;;;;;;;CAsBC,GAED,6CAA6C,GAC7C,OAAO,MAAMC,gCAAgC,OAAe;AAW5D;;;;CAIC,GACD,OAAO,MAAMC,gCAAgC,kBAAiB;AAE9D,yDAAyD,GACzD,OAAO,SAASC,mBAAmBC,MAA4B,EAAEC,YAAoB;IACnF,MAAMC,OAAOD,aAAaE,IAAI,MAAM;IACpC,OAAOH,WAAW,UAAU,CAAC,YAAY,EAAEE,MAAM,GAAG,CAAC,aAAa,EAAEA,MAAM;AAC5E;AAEA;;;;CAIC,GACD,OAAO,SAASE,sBACdC,MAAyB,EACzBC,KAAwB;IAExB,OAAO;QACLC,OAAOD,MAAME,MAAM,CAAC,CAACC,KAAO,CAACJ,OAAOK,QAAQ,CAACD;QAC7CE,SAASN,OAAOG,MAAM,CAAC,CAACC,KAAO,CAACH,MAAMI,QAAQ,CAACD;IACjD;AACF;AAiBA,sFAAsF,GACtF,OAAO,SAASG,4BAA4BC,KAAkC;QACtDA;IAAtB,MAAMC,SAASC,QAAOF,gBAAAA,MAAMC,MAAM,YAAZD,gBAAgB,IAAIV,IAAI;IAC9C,OAAO;QACLa,MAAMnB;QACNoB,MAAMlB,mBAAmBc,MAAMb,MAAM,EAAEa,MAAMK,QAAQ,CAAChB,IAAI;QAC1DiB,MAAMN,MAAMM,IAAI;QAChBC,OAAOP,MAAMO,KAAK;OACdN,SAAS;QAAEA;IAAO,IAAI,CAAC,GACvBD,MAAMQ,IAAI,CAACC,SAAS,GAAG;QAAEA,WAAWT,MAAMQ,IAAI,CAACC,SAAS;IAAC,IAAI,CAAC,GAC9DT,MAAMQ,IAAI,CAACE,SAAS,GAAG;QAAEA,WAAWV,MAAMQ,IAAI,CAACE,SAAS;IAAC,IAAI,CAAC,GAC9DV,MAAMQ,IAAI,CAACG,MAAM,GAAG;QAAEA,QAAQX,MAAMQ,IAAI,CAACG,MAAM;IAAC,IAAI,CAAC,GACrDX,MAAMQ,IAAI,CAACI,MAAM,GAAG;QAAEA,QAAQZ,MAAMQ,IAAI,CAACI,MAAM;IAAC,IAAI,CAAC;QACzDC,QAAQb,MAAMa,MAAM;QACpBC,WAAW;eAAId,MAAMc,SAAS;SAAC;QAC/BC,gBAAgBhC;QAChBiC,YAAYhB,MAAMK,QAAQ,CAACT,EAAE;;AAEjC"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/campaign-filing-activity.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 type { CrmActivity, CrmActivityLink, ScopeToken } from '@aglyn/aglyn'\nimport { BUNDLE_ID } from '../constants/bundle-common'\n\n/**\n * WHAT A FILING WRITES ON THE RECORD'S TIMELINE (AGL-3274).\n *\n * A lead or a contact filed under a campaign — from its own page, the bulk\n * bar, the New lead drawer, or by conversion carrying a lead's campaigns\n * onto the contact it became — showed nothing of it in Activity, so the\n * timeline read \"email sent\" with no line for the act that put the person\n * in the campaign in the first place. Every door now files one entry per\n * campaign changed, and this module is the one place their shape lives:\n * the client hook and the server helper both build the document here, so\n * an entry filed from the page and one filed by the conversion read alike.\n *\n * ## A `note`, not a kind of its own\n *\n * The timeline's kinds are what a PERSON logs — call, email, meeting, note\n * — and a filing is bookkeeping beside them, the way a sequence's \"Enrolled\n * in\" entry is. A `filing` kind would need its own label, glyph, report\n * column and a place in the log dialog where a member could log one by\n * hand, which is the one thing a filing must never be. So it is a note that\n * names its author (the member, or the conversion), carries the CRM's own\n * id as `sourcePluginId` the way a sequence's entries carry Outreach's, and\n * names the campaign twice: by name in the body, by id in `campaignId`.\n */\n\n/** The kind every filing entry is filed as. */\nexport const CAMPAIGN_FILING_ACTIVITY_KIND = 'note' as const\n\n/** What the entry says was done: put in the campaign, or taken out of it. */\nexport type CampaignFilingAction = 'filed' | 'removed'\n\n/** A campaign as a filing entry names it: the container's id and its name at the time. */\nexport interface CampaignFilingRef {\n id: string\n name: string\n}\n\n/**\n * The author a conversion's carry signs its entries with: nobody in the\n * room typed it, and \"A team member\" — the timeline's word for an\n * anonymous entry — would credit a person with what the conversion did.\n */\nexport const CAMPAIGN_FILING_CARRY_BY_NAME = 'Lead conversion'\n\n/** The one line of the entry, as the timeline shows it. */\nexport function campaignFilingBody(action: CampaignFilingAction, campaignName: string): string {\n const name = campaignName.trim() || 'a campaign'\n return action === 'filed' ? `Filed under ${name}` : `Removed from ${name}`\n}\n\n/**\n * The membership a save changed, as the entries it owes: one `filed` per\n * id the save added, one `removed` per id it dropped. Order-insensitive,\n * like `containerMembershipUnchanged`; a reorder owes nothing.\n */\nexport function campaignFilingChanges(\n before: readonly string[],\n after: readonly string[],\n): { added: string[]; removed: string[] } {\n return {\n added: after.filter((id) => !before.includes(id)),\n removed: before.filter((id) => !after.includes(id)),\n }\n}\n\nexport interface CampaignFilingActivityInput {\n action: CampaignFilingAction\n campaign: CampaignFilingRef\n link: CrmActivityLink\n /** The site the entry carries as provenance — the lead's site, the contact's viewing site. */\n hostId: string\n visibleTo: readonly ScopeToken[]\n /** When it happened, epoch ms. */\n atMs: number\n /** The member who did it, or `''` for the conversion. */\n byUid: string\n /** The member's name as it read, or the carry's fixed author; absent rather than blank. */\n byName?: string | null\n}\n\n/** The document a filing entry is stored as, minus the timestamps the writer stamps. */\nexport function buildCampaignFilingActivity(input: CampaignFilingActivityInput): CrmActivity {\n const byName = String(input.byName ?? '').trim()\n return {\n kind: CAMPAIGN_FILING_ACTIVITY_KIND,\n body: campaignFilingBody(input.action, input.campaign.name),\n atMs: input.atMs,\n byUid: input.byUid,\n ...(byName ? { byName } : {}),\n ...(input.link.contactId ? { contactId: input.link.contactId } : {}),\n ...(input.link.companyId ? { companyId: input.link.companyId } : {}),\n ...(input.link.dealId ? { dealId: input.link.dealId } : {}),\n ...(input.link.leadId ? { leadId: input.link.leadId } : {}),\n hostId: input.hostId,\n visibleTo: [...input.visibleTo],\n sourcePluginId: BUNDLE_ID,\n campaignId: input.campaign.id,\n }\n}\n"],"names":["BUNDLE_ID","CAMPAIGN_FILING_ACTIVITY_KIND","CAMPAIGN_FILING_CARRY_BY_NAME","campaignFilingBody","action","campaignName","name","trim","campaignFilingChanges","before","after","added","filter","id","includes","removed","buildCampaignFilingActivity","input","byName","String","kind","body","campaign","atMs","byUid","link","contactId","companyId","dealId","leadId","hostId","visibleTo","sourcePluginId","campaignId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SAASA,SAAS,QAAQ,gCAA4B;AAEtD;;;;;;;;;;;;;;;;;;;;;;CAsBC,GAED,6CAA6C,GAC7C,OAAO,MAAMC,gCAAgC,OAAe;AAW5D;;;;CAIC,GACD,OAAO,MAAMC,gCAAgC,kBAAiB;AAE9D,yDAAyD,GACzD,OAAO,SAASC,mBAAmBC,MAA4B,EAAEC,YAAoB;IACnF,MAAMC,OAAOD,aAAaE,IAAI,MAAM;IACpC,OAAOH,WAAW,UAAU,CAAC,YAAY,EAAEE,MAAM,GAAG,CAAC,aAAa,EAAEA,MAAM;AAC5E;AAEA;;;;CAIC,GACD,OAAO,SAASE,sBACdC,MAAyB,EACzBC,KAAwB;IAExB,OAAO;QACLC,OAAOD,MAAME,MAAM,CAAC,CAACC,KAAO,CAACJ,OAAOK,QAAQ,CAACD;QAC7CE,SAASN,OAAOG,MAAM,CAAC,CAACC,KAAO,CAACH,MAAMI,QAAQ,CAACD;IACjD;AACF;AAiBA,sFAAsF,GACtF,OAAO,SAASG,4BAA4BC,KAAkC;QACtDA;IAAtB,MAAMC,SAASC,QAAOF,gBAAAA,MAAMC,MAAM,YAAZD,gBAAgB,IAAIV,IAAI;IAC9C,OAAO;QACLa,MAAMnB;QACNoB,MAAMlB,mBAAmBc,MAAMb,MAAM,EAAEa,MAAMK,QAAQ,CAAChB,IAAI;QAC1DiB,MAAMN,MAAMM,IAAI;QAChBC,OAAOP,MAAMO,KAAK;OACdN,SAAS;QAAEA;IAAO,IAAI,CAAC,GACvBD,MAAMQ,IAAI,CAACC,SAAS,GAAG;QAAEA,WAAWT,MAAMQ,IAAI,CAACC,SAAS;IAAC,IAAI,CAAC,GAC9DT,MAAMQ,IAAI,CAACE,SAAS,GAAG;QAAEA,WAAWV,MAAMQ,IAAI,CAACE,SAAS;IAAC,IAAI,CAAC,GAC9DV,MAAMQ,IAAI,CAACG,MAAM,GAAG;QAAEA,QAAQX,MAAMQ,IAAI,CAACG,MAAM;IAAC,IAAI,CAAC,GACrDX,MAAMQ,IAAI,CAACI,MAAM,GAAG;QAAEA,QAAQZ,MAAMQ,IAAI,CAACI,MAAM;IAAC,IAAI,CAAC;QACzDC,QAAQb,MAAMa,MAAM;QACpBC,WAAW;eAAId,MAAMc,SAAS;SAAC;QAC/BC,gBAAgBhC;QAChBiC,YAAYhB,MAAMK,QAAQ,CAACT,EAAE;;AAEjC"}
@@ -37,7 +37,7 @@ import { crmShareChipFor } from "./crm-sharing.js";
37
37
  interactions: Aglyn.interactionsForGroup(facet.interactions, group.hostIds),
38
38
  tags: (_facet_tags = facet.tags) != null ? _facet_tags : [],
39
39
  notes: (_facet_notes = facet.notes) != null ? _facet_notes : '',
40
- campaignIds: Aglyn.readContactCampaignIds(row, group.groupId),
40
+ campaignIds: Aglyn.readContactContainerIds(row, group.groupId, 'campaign'),
41
41
  ltvCents: (_facet_ltvCents = facet.ltvCents) != null ? _facet_ltvCents : 0,
42
42
  ordersCount: (_facet_ordersCount = facet.ordersCount) != null ? _facet_ordersCount : 0,
43
43
  phone: (_facet_phone = facet.phone) != null ? _facet_phone : '',
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/contact-record.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 * as Aglyn from '@aglyn/aglyn'\nimport { type CrmShareChip, crmShareChipFor } from './crm-sharing'\nimport type {\n AglynPostalAddress,\n ConsentGroup,\n ContactInteraction,\n ContactLifecycleStage,\n ContactSource,\n} from '@aglyn/aglyn'\n\n/**\n * One contact, as the VIEWING GROUP may see it (AGL-2596).\n *\n * A contact document is shared by every site in the org, and almost nothing\n * on it is legitimately shared: the notes, tags, timeline, profile and\n * commercial figures are the holder's own records and live in that holder's\n * facet. This is the row after the facet read — flat, so the list, the\n * record page and the export can render fields by name without any of them\n * reaching into `facets.{groupId}` for itself. One projection rather than a\n * facet read at every field is what keeps a surface from ever showing\n * another holder's records: there is no field on this shape that came from\n * the top of the document except the two that ARE shared, the address and\n * the canonical name.\n */\nexport interface ContactRecord {\n $id: string\n /**\n * The facet this row was flattened through — the viewing group under a\n * site, the person's primary holder at the org level (AGL-2630). A write\n * that edits the row goes back to the same facet, so a surface that has\n * only the flat record can still address the holder's own fields.\n */\n groupId: string\n /**\n * A site of that holder's group — the viewing site under a site, and at\n * the organization level the site the primary holder was resolved from —\n * which is where a stage move for this row is made and announced. `''` for\n * a row no site holds.\n */\n holderHostId: string\n /** Every site that has captured this person, sorted — the org-level \"Known by\". */\n capturedByHostIds: string[]\n /** The shared identity and the dedupe key. */\n email: string\n /** The other addresses a merge folded into this record (AGL-2625). */\n alternateEmails: string[]\n /** What THIS holder sees — their own override, or the canonical name. */\n name: string\n /** The canonical name: the identity of last resort, shared by every holder. */\n canonicalName: string\n /** This holder's own name for the person, when they have set one. */\n nameOverride: string\n sources: Partial<Record<ContactSource, true>>\n /** This holder's timeline, narrowed to the sites the group covers. */\n interactions: ContactInteraction[]\n tags: string[]\n notes: string\n campaignIds: string[]\n ltvCents: number\n ordersCount: number\n phone: string\n jobTitle: string\n /** This holder's lead source label (AGL-3298), `''` for none. */\n leadSource: string\n companyName: string\n companyId: string\n /**\n * What linking this person to a company has to know — this holder's link,\n * the shared mirror and the other holders' ids — so the properties card\n * and the bulk bar can plan a link from the row without the document. Not\n * a display field: the mirror is an index every reader of the document\n * already holds, and nothing renders it.\n */\n companyLink: Aglyn.ContactCompanyLinkState\n address: AglynPostalAddress | null\n /** This holder's custom field values, keyed by definition key (AGL-2601). */\n custom?: Record<string, Aglyn.ContactCustomValue>\n /** This holder's attached org-library files, by media id (AGL-2662). */\n mediaIds?: string[]\n ownerUid: string\n /** Empty when the holder has not placed the person in the funnel. */\n lifecycleStage: ContactLifecycleStage | ''\n /**\n * When the person last opened or clicked one of this holder's campaigns\n * (AGL-2616), or `null` when they never have since the stamp shipped.\n */\n lastEmailEngagementAtMs: number | null\n /**\n * When the earliest open task against this person is due (AGL-2661), or\n * `null` when nothing is scheduled. SHARED, not the holder's: a task is\n * filed against the contact document, and every holder reading the row\n * sees the same next step.\n */\n nextTaskAtMs: number | null\n /**\n * The last verdict on the address (AGL-3245) — bounced, blocked,\n * unsubscribed, do-not-contact — or `null` when nothing is known. SHARED\n * like the address it is about.\n */\n emailState: Aglyn.EmailState | null\n /**\n * Why the viewing group sees this person when the answer is a share\n * (AGL-3336) — `null` when it holds them, and at the organization level.\n */\n shareChip?: CrmShareChip | null\n createdAt?: unknown\n updatedAt?: unknown\n}\n\n/*\n * Which holder a cross-site reader sees one contact through (AGL-2630) —\n * `contact-holder.ts`, shared with the server's whole-collection export so\n * the file and the organization-level list flatten a contact one way.\n */\nexport { contactPrimaryGroup, NO_HOLDER_GROUP } from './contact-holder'\n\n/** A document off the wire, flattened through one group's facet. */\nexport function contactRecordFromDoc(\n row: Record<string, any>,\n group: ConsentGroup,\n): ContactRecord {\n const facet = Aglyn.readContactFacet(row, group.groupId)\n return {\n $id: String(row['$id'] ?? ''),\n groupId: group.groupId,\n holderHostId: group.hostId,\n capturedByHostIds: Aglyn.contactCaptureHostIds(row),\n email: typeof row['email'] === 'string' ? row['email'] : '',\n alternateEmails: Array.isArray(row[Aglyn.CONTACT_ALTERNATE_EMAILS_FIELD])\n ? (row[Aglyn.CONTACT_ALTERNATE_EMAILS_FIELD] as unknown[]).filter(\n (email): email is string => typeof email === 'string',\n )\n : [],\n name: Aglyn.contactDisplayName(row, group.groupId),\n canonicalName: typeof row['name'] === 'string' ? row['name'] : '',\n nameOverride: facet.name ?? '',\n sources: facet.sources,\n interactions: Aglyn.interactionsForGroup(facet.interactions, group.hostIds),\n tags: facet.tags ?? [],\n notes: facet.notes ?? '',\n campaignIds: Aglyn.readContactCampaignIds(row, group.groupId),\n ltvCents: facet.ltvCents ?? 0,\n ordersCount: facet.ordersCount ?? 0,\n phone: facet.phone ?? '',\n jobTitle: facet.jobTitle ?? '',\n leadSource: facet.leadSource ?? '',\n companyName: facet.companyName ?? '',\n companyId: facet.companyId ?? '',\n companyLink: Aglyn.readContactCompanyLink(row, group.groupId),\n address: facet.address ?? null,\n custom: facet.custom ?? {},\n mediaIds: facet.mediaIds ?? [],\n ownerUid: facet.ownerUid ?? '',\n lifecycleStage: Aglyn.isContactLifecycleStage(facet.lifecycleStage)\n ? facet.lifecycleStage\n : '',\n lastEmailEngagementAtMs:\n typeof facet.lastEmailEngagementAtMs === 'number' &&\n Number.isFinite(facet.lastEmailEngagementAtMs) &&\n facet.lastEmailEngagementAtMs > 0\n ? facet.lastEmailEngagementAtMs\n : null,\n nextTaskAtMs: Aglyn.readNextTaskAtMs(row as { nextTaskAtMs?: unknown }),\n emailState: Aglyn.readEmailState(row),\n shareChip: crmShareChipFor(row, group.hostIds),\n createdAt: row['createdAt'],\n updatedAt: row['updatedAt'],\n }\n}\n\n/**\n * Tags as typed — comma-separated — into the shape the facet stores:\n * lower-cased, trimmed, deduplicated and capped, so a tag typed on the\n * record page and one typed in the create drawer are the same tag to the\n * segment filter that matches on them.\n */\nexport function parseContactTags(input: string): string[] {\n return [\n ...new Set(\n input\n .split(',')\n .map((tag) => tag.trim().toLowerCase().slice(0, 40))\n .filter(Boolean)\n .slice(0, 20),\n ),\n ]\n}\n"],"names":["Aglyn","crmShareChipFor","contactPrimaryGroup","NO_HOLDER_GROUP","contactRecordFromDoc","row","group","facet","readContactFacet","groupId","$id","String","holderHostId","hostId","capturedByHostIds","contactCaptureHostIds","email","alternateEmails","Array","isArray","CONTACT_ALTERNATE_EMAILS_FIELD","filter","name","contactDisplayName","canonicalName","nameOverride","sources","interactions","interactionsForGroup","hostIds","tags","notes","campaignIds","readContactCampaignIds","ltvCents","ordersCount","phone","jobTitle","leadSource","companyName","companyId","companyLink","readContactCompanyLink","address","custom","mediaIds","ownerUid","lifecycleStage","isContactLifecycleStage","lastEmailEngagementAtMs","Number","isFinite","nextTaskAtMs","readNextTaskAtMs","emailState","readEmailState","shareChip","createdAt","updatedAt","parseContactTags","input","Set","split","map","tag","trim","toLowerCase","slice","Boolean"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,eAAc;AACrC,SAA4BC,eAAe,QAAQ,mBAAe;AA4GlE;;;;CAIC,GACD,SAASC,mBAAmB,EAAEC,eAAe,QAAQ,sBAAkB;AAEvE,kEAAkE,GAClE,OAAO,SAASC,qBACdC,GAAwB,EACxBC,KAAmB;QAILD,UAYEE,aAGRA,aACCA,cAEGA,iBACGA,oBACNA,cACGA,iBACEA,mBACCA,oBACFA,kBAEFA,gBACDA,eACEA,iBACAA;IA/BZ,MAAMA,QAAQP,MAAMQ,gBAAgB,CAACH,KAAKC,MAAMG,OAAO;IACvD,OAAO;QACLC,KAAKC,QAAON,WAAAA,GAAG,CAAC,MAAM,YAAVA,WAAc;QAC1BI,SAASH,MAAMG,OAAO;QACtBG,cAAcN,MAAMO,MAAM;QAC1BC,mBAAmBd,MAAMe,qBAAqB,CAACV;QAC/CW,OAAO,OAAOX,GAAG,CAAC,QAAQ,KAAK,WAAWA,GAAG,CAAC,QAAQ,GAAG;QACzDY,iBAAiBC,MAAMC,OAAO,CAACd,GAAG,CAACL,MAAMoB,8BAA8B,CAAC,IACpE,AAACf,GAAG,CAACL,MAAMoB,8BAA8B,CAAC,CAAeC,MAAM,CAC7D,CAACL,QAA2B,OAAOA,UAAU,YAE/C,EAAE;QACNM,MAAMtB,MAAMuB,kBAAkB,CAAClB,KAAKC,MAAMG,OAAO;QACjDe,eAAe,OAAOnB,GAAG,CAAC,OAAO,KAAK,WAAWA,GAAG,CAAC,OAAO,GAAG;QAC/DoB,YAAY,GAAElB,cAAAA,MAAMe,IAAI,YAAVf,cAAc;QAC5BmB,SAASnB,MAAMmB,OAAO;QACtBC,cAAc3B,MAAM4B,oBAAoB,CAACrB,MAAMoB,YAAY,EAAErB,MAAMuB,OAAO;QAC1EC,IAAI,GAAEvB,cAAAA,MAAMuB,IAAI,YAAVvB,cAAc,EAAE;QACtBwB,KAAK,GAAExB,eAAAA,MAAMwB,KAAK,YAAXxB,eAAe;QACtByB,aAAahC,MAAMiC,sBAAsB,CAAC5B,KAAKC,MAAMG,OAAO;QAC5DyB,QAAQ,GAAE3B,kBAAAA,MAAM2B,QAAQ,YAAd3B,kBAAkB;QAC5B4B,WAAW,GAAE5B,qBAAAA,MAAM4B,WAAW,YAAjB5B,qBAAqB;QAClC6B,KAAK,GAAE7B,eAAAA,MAAM6B,KAAK,YAAX7B,eAAe;QACtB8B,QAAQ,GAAE9B,kBAAAA,MAAM8B,QAAQ,YAAd9B,kBAAkB;QAC5B+B,UAAU,GAAE/B,oBAAAA,MAAM+B,UAAU,YAAhB/B,oBAAoB;QAChCgC,WAAW,GAAEhC,qBAAAA,MAAMgC,WAAW,YAAjBhC,qBAAqB;QAClCiC,SAAS,GAAEjC,mBAAAA,MAAMiC,SAAS,YAAfjC,mBAAmB;QAC9BkC,aAAazC,MAAM0C,sBAAsB,CAACrC,KAAKC,MAAMG,OAAO;QAC5DkC,OAAO,GAAEpC,iBAAAA,MAAMoC,OAAO,YAAbpC,iBAAiB;QAC1BqC,MAAM,GAAErC,gBAAAA,MAAMqC,MAAM,YAAZrC,gBAAgB,CAAC;QACzBsC,QAAQ,GAAEtC,kBAAAA,MAAMsC,QAAQ,YAAdtC,kBAAkB,EAAE;QAC9BuC,QAAQ,GAAEvC,kBAAAA,MAAMuC,QAAQ,YAAdvC,kBAAkB;QAC5BwC,gBAAgB/C,MAAMgD,uBAAuB,CAACzC,MAAMwC,cAAc,IAC9DxC,MAAMwC,cAAc,GACpB;QACJE,yBACE,OAAO1C,MAAM0C,uBAAuB,KAAK,YACzCC,OAAOC,QAAQ,CAAC5C,MAAM0C,uBAAuB,KAC7C1C,MAAM0C,uBAAuB,GAAG,IAC5B1C,MAAM0C,uBAAuB,GAC7B;QACNG,cAAcpD,MAAMqD,gBAAgB,CAAChD;QACrCiD,YAAYtD,MAAMuD,cAAc,CAAClD;QACjCmD,WAAWvD,gBAAgBI,KAAKC,MAAMuB,OAAO;QAC7C4B,WAAWpD,GAAG,CAAC,YAAY;QAC3BqD,WAAWrD,GAAG,CAAC,YAAY;IAC7B;AACF;AAEA;;;;;CAKC,GACD,OAAO,SAASsD,iBAAiBC,KAAa;IAC5C,OAAO;WACF,IAAIC,IACLD,MACGE,KAAK,CAAC,KACNC,GAAG,CAAC,CAACC,MAAQA,IAAIC,IAAI,GAAGC,WAAW,GAAGC,KAAK,CAAC,GAAG,KAC/C9C,MAAM,CAAC+C,SACPD,KAAK,CAAC,GAAG;KAEf;AACH"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/contact-record.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 * as Aglyn from '@aglyn/aglyn'\nimport { type CrmShareChip, crmShareChipFor } from './crm-sharing'\nimport type {\n AglynPostalAddress,\n ConsentGroup,\n ContactInteraction,\n ContactLifecycleStage,\n ContactSource,\n} from '@aglyn/aglyn'\n\n/**\n * One contact, as the VIEWING GROUP may see it (AGL-2596).\n *\n * A contact document is shared by every site in the org, and almost nothing\n * on it is legitimately shared: the notes, tags, timeline, profile and\n * commercial figures are the holder's own records and live in that holder's\n * facet. This is the row after the facet read — flat, so the list, the\n * record page and the export can render fields by name without any of them\n * reaching into `facets.{groupId}` for itself. One projection rather than a\n * facet read at every field is what keeps a surface from ever showing\n * another holder's records: there is no field on this shape that came from\n * the top of the document except the two that ARE shared, the address and\n * the canonical name.\n */\nexport interface ContactRecord {\n $id: string\n /**\n * The facet this row was flattened through — the viewing group under a\n * site, the person's primary holder at the org level (AGL-2630). A write\n * that edits the row goes back to the same facet, so a surface that has\n * only the flat record can still address the holder's own fields.\n */\n groupId: string\n /**\n * A site of that holder's group — the viewing site under a site, and at\n * the organization level the site the primary holder was resolved from —\n * which is where a stage move for this row is made and announced. `''` for\n * a row no site holds.\n */\n holderHostId: string\n /** Every site that has captured this person, sorted — the org-level \"Known by\". */\n capturedByHostIds: string[]\n /** The shared identity and the dedupe key. */\n email: string\n /** The other addresses a merge folded into this record (AGL-2625). */\n alternateEmails: string[]\n /** What THIS holder sees — their own override, or the canonical name. */\n name: string\n /** The canonical name: the identity of last resort, shared by every holder. */\n canonicalName: string\n /** This holder's own name for the person, when they have set one. */\n nameOverride: string\n sources: Partial<Record<ContactSource, true>>\n /** This holder's timeline, narrowed to the sites the group covers. */\n interactions: ContactInteraction[]\n tags: string[]\n notes: string\n campaignIds: string[]\n ltvCents: number\n ordersCount: number\n phone: string\n jobTitle: string\n /** This holder's lead source label (AGL-3298), `''` for none. */\n leadSource: string\n companyName: string\n companyId: string\n /**\n * What linking this person to a company has to know — this holder's link,\n * the shared mirror and the other holders' ids — so the properties card\n * and the bulk bar can plan a link from the row without the document. Not\n * a display field: the mirror is an index every reader of the document\n * already holds, and nothing renders it.\n */\n companyLink: Aglyn.ContactCompanyLinkState\n address: AglynPostalAddress | null\n /** This holder's custom field values, keyed by definition key (AGL-2601). */\n custom?: Record<string, Aglyn.ContactCustomValue>\n /** This holder's attached org-library files, by media id (AGL-2662). */\n mediaIds?: string[]\n ownerUid: string\n /** Empty when the holder has not placed the person in the funnel. */\n lifecycleStage: ContactLifecycleStage | ''\n /**\n * When the person last opened or clicked one of this holder's campaigns\n * (AGL-2616), or `null` when they never have since the stamp shipped.\n */\n lastEmailEngagementAtMs: number | null\n /**\n * When the earliest open task against this person is due (AGL-2661), or\n * `null` when nothing is scheduled. SHARED, not the holder's: a task is\n * filed against the contact document, and every holder reading the row\n * sees the same next step.\n */\n nextTaskAtMs: number | null\n /**\n * The last verdict on the address (AGL-3245) — bounced, blocked,\n * unsubscribed, do-not-contact — or `null` when nothing is known. SHARED\n * like the address it is about.\n */\n emailState: Aglyn.EmailState | null\n /**\n * Why the viewing group sees this person when the answer is a share\n * (AGL-3336) — `null` when it holds them, and at the organization level.\n */\n shareChip?: CrmShareChip | null\n createdAt?: unknown\n updatedAt?: unknown\n}\n\n/*\n * Which holder a cross-site reader sees one contact through (AGL-2630) —\n * `contact-holder.ts`, shared with the server's whole-collection export so\n * the file and the organization-level list flatten a contact one way.\n */\nexport { contactPrimaryGroup, NO_HOLDER_GROUP } from './contact-holder'\n\n/** A document off the wire, flattened through one group's facet. */\nexport function contactRecordFromDoc(\n row: Record<string, any>,\n group: ConsentGroup,\n): ContactRecord {\n const facet = Aglyn.readContactFacet(row, group.groupId)\n return {\n $id: String(row['$id'] ?? ''),\n groupId: group.groupId,\n holderHostId: group.hostId,\n capturedByHostIds: Aglyn.contactCaptureHostIds(row),\n email: typeof row['email'] === 'string' ? row['email'] : '',\n alternateEmails: Array.isArray(row[Aglyn.CONTACT_ALTERNATE_EMAILS_FIELD])\n ? (row[Aglyn.CONTACT_ALTERNATE_EMAILS_FIELD] as unknown[]).filter(\n (email): email is string => typeof email === 'string',\n )\n : [],\n name: Aglyn.contactDisplayName(row, group.groupId),\n canonicalName: typeof row['name'] === 'string' ? row['name'] : '',\n nameOverride: facet.name ?? '',\n sources: facet.sources,\n interactions: Aglyn.interactionsForGroup(facet.interactions, group.hostIds),\n tags: facet.tags ?? [],\n notes: facet.notes ?? '',\n campaignIds: Aglyn.readContactContainerIds(row, group.groupId, 'campaign'),\n ltvCents: facet.ltvCents ?? 0,\n ordersCount: facet.ordersCount ?? 0,\n phone: facet.phone ?? '',\n jobTitle: facet.jobTitle ?? '',\n leadSource: facet.leadSource ?? '',\n companyName: facet.companyName ?? '',\n companyId: facet.companyId ?? '',\n companyLink: Aglyn.readContactCompanyLink(row, group.groupId),\n address: facet.address ?? null,\n custom: facet.custom ?? {},\n mediaIds: facet.mediaIds ?? [],\n ownerUid: facet.ownerUid ?? '',\n lifecycleStage: Aglyn.isContactLifecycleStage(facet.lifecycleStage)\n ? facet.lifecycleStage\n : '',\n lastEmailEngagementAtMs:\n typeof facet.lastEmailEngagementAtMs === 'number' &&\n Number.isFinite(facet.lastEmailEngagementAtMs) &&\n facet.lastEmailEngagementAtMs > 0\n ? facet.lastEmailEngagementAtMs\n : null,\n nextTaskAtMs: Aglyn.readNextTaskAtMs(row as { nextTaskAtMs?: unknown }),\n emailState: Aglyn.readEmailState(row),\n shareChip: crmShareChipFor(row, group.hostIds),\n createdAt: row['createdAt'],\n updatedAt: row['updatedAt'],\n }\n}\n\n/**\n * Tags as typed — comma-separated — into the shape the facet stores:\n * lower-cased, trimmed, deduplicated and capped, so a tag typed on the\n * record page and one typed in the create drawer are the same tag to the\n * segment filter that matches on them.\n */\nexport function parseContactTags(input: string): string[] {\n return [\n ...new Set(\n input\n .split(',')\n .map((tag) => tag.trim().toLowerCase().slice(0, 40))\n .filter(Boolean)\n .slice(0, 20),\n ),\n ]\n}\n"],"names":["Aglyn","crmShareChipFor","contactPrimaryGroup","NO_HOLDER_GROUP","contactRecordFromDoc","row","group","facet","readContactFacet","groupId","$id","String","holderHostId","hostId","capturedByHostIds","contactCaptureHostIds","email","alternateEmails","Array","isArray","CONTACT_ALTERNATE_EMAILS_FIELD","filter","name","contactDisplayName","canonicalName","nameOverride","sources","interactions","interactionsForGroup","hostIds","tags","notes","campaignIds","readContactContainerIds","ltvCents","ordersCount","phone","jobTitle","leadSource","companyName","companyId","companyLink","readContactCompanyLink","address","custom","mediaIds","ownerUid","lifecycleStage","isContactLifecycleStage","lastEmailEngagementAtMs","Number","isFinite","nextTaskAtMs","readNextTaskAtMs","emailState","readEmailState","shareChip","createdAt","updatedAt","parseContactTags","input","Set","split","map","tag","trim","toLowerCase","slice","Boolean"],"mappings":"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"}
@@ -26,6 +26,15 @@ import { type CrmLeadFields, type CrmTask } from '@aglyn/aglyn/app-utils/crm';
26
26
  * notification's job, not this one's.
27
27
  */
28
28
  export declare const CRM_DIGEST_DEFAULT_TIME_ZONE = "America/Chicago";
29
+ /**
30
+ * The zone the digest's "today" is drawn in, and the zone every CRM mail
31
+ * and notification prints a task's time in.
32
+ *
33
+ * `CRM_DIGEST_TIME_ZONE` overrides the default for a self-hosted console
34
+ * whose team wakes up elsewhere; a value `Intl` does not know falls back to
35
+ * the default with a warning rather than throwing the whole run away.
36
+ */
37
+ export declare function digestTimeZone(env?: NodeJS.ProcessEnv): string;
29
38
  /** How long a `new` lead may sit untouched before the digest names it. */
30
39
  export declare const CRM_DIGEST_LEAD_AGE_MS: number;
31
40
  /**
@@ -86,12 +95,19 @@ export interface CrmDigestCounts {
86
95
  overdue: number;
87
96
  today: number;
88
97
  leads: number;
98
+ /**
99
+ * The member's leads automated email is reaching (AGL-3446) — open, but
100
+ * not owed, so a figure the email states and never a reason to send one.
101
+ */
102
+ nurturing: number;
89
103
  }
90
104
  /** One member's morning. */
91
105
  export interface CrmMemberDigest {
92
106
  overdue: CrmDigestTask[];
93
107
  today: CrmDigestTask[];
94
108
  leads: CrmDigestLead[];
109
+ /** Leads in `nurturing`, by the same owner-or-site rule as `leads`. */
110
+ nurturing: CrmDigestLead[];
95
111
  }
96
112
  /**
97
113
  * Where a due task stands against the digest's window. Only tasks due
@@ -107,6 +123,15 @@ export declare function crmDigestTaskState(dueAtMs: number, window: CrmDigestWin
107
123
  export declare function isUnworkedLead(lead: Pick<CrmLeadFields, 'status'> & {
108
124
  firstSeenAtMs?: unknown;
109
125
  }, nowMs: number, ageMs?: number): boolean;
126
+ /**
127
+ * Whether a lead is one the digest counts as being nurtured (AGL-3446):
128
+ * a sequence step or a campaign email has reached it and no person has
129
+ * engaged yet. The same age as an unworked lead, so the two figures are
130
+ * read over the same leads — the old ones that would otherwise be owed.
131
+ */
132
+ export declare function isNurturingLead(lead: Pick<CrmLeadFields, 'status'> & {
133
+ firstSeenAtMs?: unknown;
134
+ }, nowMs: number, ageMs?: number): boolean;
110
135
  /**
111
136
  * Whether a lead is THIS member's to work: theirs by owner, or nobody's on
112
137
  * a site they reach. An owned lead is one person's; an unowned one is on
@@ -133,11 +158,13 @@ export declare function crmDigestEntitled(features: Record<string, boolean | und
133
158
  * is a task nobody owes. A lead goes to its owner, or to every member who
134
159
  * reaches the site when it has none. Members with nothing owed are simply
135
160
  * absent from the result, which is what makes "per member with open work"
136
- * cost nothing for everyone else.
161
+ * cost nothing for everyone else — and a nurturing lead is not owed, so it
162
+ * joins a digest that exists and never starts one.
137
163
  */
138
164
  export declare function buildMemberDigests(input: {
139
165
  tasks: readonly CrmDigestTask[];
140
166
  leads: readonly CrmDigestLead[];
167
+ nurturing?: readonly CrmDigestLead[];
141
168
  members: ReadonlyArray<{
142
169
  uid: string;
143
170
  reachesHost: (hostId: string) => boolean;
@@ -145,13 +172,23 @@ export declare function buildMemberDigests(input: {
145
172
  window: CrmDigestWindow;
146
173
  }): Map<string, CrmMemberDigest>;
147
174
  export declare function crmDigestCounts(digest: CrmMemberDigest): CrmDigestCounts;
175
+ /**
176
+ * The line that says the nurturing figure (AGL-3446), or empty when there
177
+ * is none: the leads the unworked list leaves out, and why.
178
+ */
179
+ export declare function composeCrmDigestNurturingLine(counts: Pick<CrmDigestCounts, 'nurturing'>): string;
148
180
  /**
149
181
  * The one line the notification is: "3 tasks due today, 2 overdue, 4
150
182
  * unworked leads". Segments that are zero are left out rather than read as
151
183
  * "0 overdue" — the line is what is owed, and nothing owed is no segment.
152
184
  */
153
- export declare function composeCrmDigestSummary(counts: CrmDigestCounts): string;
154
- export declare function composeCrmDigestSubject(counts: CrmDigestCounts): string;
185
+ export declare function composeCrmDigestSummary(counts: Pick<CrmDigestCounts, 'overdue' | 'today' | 'leads'>): string;
186
+ /**
187
+ * The digest's subject. It names the workspace when it has one: the digest
188
+ * is sent per workspace, and a member of two would otherwise get two mails
189
+ * that read the same.
190
+ */
191
+ export declare function composeCrmDigestSubject(counts: Pick<CrmDigestCounts, 'overdue' | 'today' | 'leads'>, workspaceName?: string): string;
155
192
  /** What the digest email needs from the route besides the digest itself. */
156
193
  export interface CrmDigestEmailInput {
157
194
  digest: CrmMemberDigest;
@@ -159,6 +196,11 @@ export interface CrmDigestEmailInput {
159
196
  timeZone: string;
160
197
  /** The product name the mail reads as — the org's brand when white-labeled. */
161
198
  productName: string;
199
+ /**
200
+ * The workspace the digest is for. The system email shows only the body,
201
+ * so the body names it: a member of two workspaces gets one digest each.
202
+ */
203
+ workspaceName?: string;
162
204
  /** The Tasks section, on the site the member's first task belongs to. */
163
205
  tasksUrl: string;
164
206
  /** The Leads section of a site, for the sites that have unworked leads. */
@@ -46,6 +46,27 @@ import { crmLeadStatus } from "@aglyn/aglyn/app-utils/crm";
46
46
  * "due at 9:00" reminder could not — that reminder is the assignment
47
47
  * notification's job, not this one's.
48
48
  */ export const CRM_DIGEST_DEFAULT_TIME_ZONE = 'America/Chicago';
49
+ /**
50
+ * The zone the digest's "today" is drawn in, and the zone every CRM mail
51
+ * and notification prints a task's time in.
52
+ *
53
+ * `CRM_DIGEST_TIME_ZONE` overrides the default for a self-hosted console
54
+ * whose team wakes up elsewhere; a value `Intl` does not know falls back to
55
+ * the default with a warning rather than throwing the whole run away.
56
+ */ export function digestTimeZone(env = process.env) {
57
+ var _env_CRM_DIGEST_TIME_ZONE;
58
+ const configured = String((_env_CRM_DIGEST_TIME_ZONE = env['CRM_DIGEST_TIME_ZONE']) != null ? _env_CRM_DIGEST_TIME_ZONE : '').trim();
59
+ if (!configured) return CRM_DIGEST_DEFAULT_TIME_ZONE;
60
+ try {
61
+ new Intl.DateTimeFormat('en-US', {
62
+ timeZone: configured
63
+ });
64
+ return configured;
65
+ } catch (unused) {
66
+ console.warn(`crm-daily-digest: CRM_DIGEST_TIME_ZONE "${configured}" is not a zone — ` + `using ${CRM_DIGEST_DEFAULT_TIME_ZONE}`);
67
+ return CRM_DIGEST_DEFAULT_TIME_ZONE;
68
+ }
69
+ }
49
70
  /** How long a `new` lead may sit untouched before the digest names it. */ export const CRM_DIGEST_LEAD_AGE_MS = 2 * 24 * 60 * 60 * 1000;
50
71
  /**
51
72
  * The most due tasks one org's sweep reads.
@@ -105,8 +126,19 @@ export function crmDigestWindow(nowMs, timeZone) {
105
126
  * seen at least {@link CRM_DIGEST_LEAD_AGE_MS} ago.
106
127
  */ export function isUnworkedLead(lead, nowMs, ageMs = CRM_DIGEST_LEAD_AGE_MS) {
107
128
  if (crmLeadStatus(lead) !== 'new') return false;
129
+ return seenBefore(lead, nowMs - ageMs);
130
+ }
131
+ /**
132
+ * Whether a lead is one the digest counts as being nurtured (AGL-3446):
133
+ * a sequence step or a campaign email has reached it and no person has
134
+ * engaged yet. The same age as an unworked lead, so the two figures are
135
+ * read over the same leads — the old ones that would otherwise be owed.
136
+ */ export function isNurturingLead(lead, nowMs, ageMs = CRM_DIGEST_LEAD_AGE_MS) {
137
+ return crmLeadStatus(lead) === 'nurturing' && seenBefore(lead, nowMs - ageMs);
138
+ }
139
+ function seenBefore(lead, cutoffMs) {
108
140
  const firstSeen = Number(lead.firstSeenAtMs);
109
- return Number.isFinite(firstSeen) && firstSeen > 0 && firstSeen <= nowMs - ageMs;
141
+ return Number.isFinite(firstSeen) && firstSeen > 0 && firstSeen <= cutoffMs;
110
142
  }
111
143
  /**
112
144
  * Whether a lead is THIS member's to work: theirs by owner, or nobody's on
@@ -134,9 +166,10 @@ export function crmDigestWindow(nowMs, timeZone) {
134
166
  * is a task nobody owes. A lead goes to its owner, or to every member who
135
167
  * reaches the site when it has none. Members with nothing owed are simply
136
168
  * absent from the result, which is what makes "per member with open work"
137
- * cost nothing for everyone else.
169
+ * cost nothing for everyone else — and a nurturing lead is not owed, so it
170
+ * joins a digest that exists and never starts one.
138
171
  */ export function buildMemberDigests(input) {
139
- const { tasks, leads, members, window } = input;
172
+ const { tasks, leads, nurturing = [], members, window } = input;
140
173
  const digests = new Map();
141
174
  const digestFor = (uid)=>{
142
175
  let digest = digests.get(uid);
@@ -144,7 +177,8 @@ export function crmDigestWindow(nowMs, timeZone) {
144
177
  digest = {
145
178
  overdue: [],
146
179
  today: [],
147
- leads: []
180
+ leads: [],
181
+ nurturing: []
148
182
  };
149
183
  digests.set(uid, digest);
150
184
  }
@@ -165,6 +199,12 @@ export function crmDigestWindow(nowMs, timeZone) {
165
199
  if (leadIsForMember(lead, member)) digestFor(member.uid).leads.push(lead);
166
200
  }
167
201
  }
202
+ for (const lead of nurturing){
203
+ for (const member of members){
204
+ const digest = digests.get(member.uid);
205
+ if (digest && leadIsForMember(lead, member)) digest.nurturing.push(lead);
206
+ }
207
+ }
168
208
  for (const digest of digests.values()){
169
209
  digest.overdue.sort((a, b)=>a.dueAtMs - b.dueAtMs);
170
210
  digest.today.sort((a, b)=>a.dueAtMs - b.dueAtMs);
@@ -176,9 +216,17 @@ export function crmDigestCounts(digest) {
176
216
  return {
177
217
  overdue: digest.overdue.length,
178
218
  today: digest.today.length,
179
- leads: digest.leads.length
219
+ leads: digest.leads.length,
220
+ nurturing: digest.nurturing.length
180
221
  };
181
222
  }
223
+ /**
224
+ * The line that says the nurturing figure (AGL-3446), or empty when there
225
+ * is none: the leads the unworked list leaves out, and why.
226
+ */ export function composeCrmDigestNurturingLine(counts) {
227
+ if (counts.nurturing <= 0) return '';
228
+ return counts.nurturing === 1 ? '1 lead is in sequences or campaigns.' : `${counts.nurturing} leads are in sequences or campaigns.`;
229
+ }
182
230
  const plural = (count, noun)=>`${count} ${noun}${count === 1 ? '' : 's'}`;
183
231
  /**
184
232
  * The one line the notification is: "3 tasks due today, 2 overdue, 4
@@ -193,17 +241,23 @@ const plural = (count, noun)=>`${count} ${noun}${count === 1 ? '' : 's'}`;
193
241
  if (counts.leads > 0) segments.push(`${plural(counts.leads, 'unworked lead')}`);
194
242
  return segments.join(', ');
195
243
  }
196
- export function composeCrmDigestSubject(counts) {
197
- return `Your CRM today: ${composeCrmDigestSummary(counts)}`;
244
+ /**
245
+ * The digest's subject. It names the workspace when it has one: the digest
246
+ * is sent per workspace, and a member of two would otherwise get two mails
247
+ * that read the same.
248
+ */ export function composeCrmDigestSubject(counts, workspaceName) {
249
+ const workspace = workspaceName == null ? void 0 : workspaceName.trim();
250
+ return `Your ${workspace ? `${workspace} ` : ''}CRM today: ${composeCrmDigestSummary(counts)}`;
198
251
  }
199
- function whenInZone(ms, timeZone) {
252
+ /** A task's due time, with the zone named: it is the digest's, not the reader's. */ function whenInZone(ms, timeZone) {
200
253
  return new Date(ms).toLocaleString('en-US', {
201
254
  timeZone,
202
255
  weekday: 'short',
203
256
  month: 'short',
204
257
  day: 'numeric',
205
258
  hour: 'numeric',
206
- minute: '2-digit'
259
+ minute: '2-digit',
260
+ timeZoneName: 'short'
207
261
  });
208
262
  }
209
263
  function dateInZone(ms, timeZone) {
@@ -232,6 +286,7 @@ function section(heading, lines) {
232
286
  * today, then the leads, each capped and counted, and the links last.
233
287
  */ export function composeCrmDigestEmailText(input) {
234
288
  var _input_supportLine;
289
+ var _input_workspaceName;
235
290
  const { digest, nowMs, timeZone, productName } = input;
236
291
  const counts = crmDigestCounts(digest);
237
292
  const day = new Date(nowMs).toLocaleDateString('en-US', {
@@ -240,11 +295,18 @@ function section(heading, lines) {
240
295
  month: 'long',
241
296
  day: 'numeric'
242
297
  });
298
+ const workspace = (_input_workspaceName = input.workspaceName) == null ? void 0 : _input_workspaceName.trim();
243
299
  const lines = [
244
- `Here is your ${productName} CRM for ${day}: ${composeCrmDigestSummary(counts)}.`,
300
+ `Here is your ${productName} CRM${workspace ? ` in ${workspace}` : ''} for ${day}: ` + `${composeCrmDigestSummary(counts)}.`,
245
301
  ''
246
302
  ];
247
- const taskLine = (task)=>`${task.title} · ${whenInZone(task.dueAtMs, timeZone)}`;
303
+ // A task's site, as the lead lines name theirs; an organization task
304
+ // (AGL-2637) has none.
305
+ const taskLine = (task)=>[
306
+ task.title,
307
+ task.hostId ? input.hostName(task.hostId) : '',
308
+ whenInZone(task.dueAtMs, timeZone)
309
+ ].filter(Boolean).join(' · ');
248
310
  if (digest.overdue.length) {
249
311
  lines.push(...section('Overdue', digest.overdue.map(taskLine)));
250
312
  }
@@ -257,6 +319,8 @@ function section(heading, lines) {
257
319
  return `${who} · ${input.hostName(lead.hostId)} · first seen ${dateInZone(lead.firstSeenAtMs, timeZone)}`;
258
320
  })));
259
321
  }
322
+ const nurturingLine = composeCrmDigestNurturingLine(counts);
323
+ if (nurturingLine) lines.push(nurturingLine, '');
260
324
  if (counts.overdue + counts.today > 0) lines.push(`Open your tasks: ${input.tasksUrl}`);
261
325
  const leadHosts = [
262
326
  ...new Set(digest.leads.map((lead)=>lead.hostId))
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-digest.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 * The daily CRM digest (AGL-2619): what one member is owed this morning,\n * computed from the tasks and leads a scheduled route has read.\n *\n * \"Overdue\" and \"due today\" are read off the clock at read time by design\n * (AGL-2599) — nothing stamps them — which is why nothing ever told anybody.\n * The digest is the one reader that runs on a schedule: once a day it\n * counts, per org and per member, the open tasks whose due date has passed\n * or falls today and the leads nobody has worked, and says so once, in the\n * console and by mail. Every judgment here is pure so the route that sends\n * and the spec that proves it read the same arithmetic.\n *\n * ## Whose day\n *\n * A task's \"today\" on screen is the READER's calendar day, from their own\n * clock. A server has no reader, so the digest reads the day in ONE zone —\n * {@link CRM_DIGEST_DEFAULT_TIME_ZONE}, the zone the schedule was chosen for\n * (08:00 there) — taken as a parameter so an org-level setting can supply\n * its own the day one exists. The idempotence key is the UTC day\n * ({@link utcDayKey}), which is a fact about the run and not about anybody's\n * morning.\n */\n\nimport {\n startOfNextZonedDay,\n startOfZonedDay,\n} from '@aglyn/shared-util-timestamp/zoned-time'\nimport { type CrmLeadFields, type CrmTask, crmLeadStatus } from '@aglyn/aglyn/app-utils/crm'\n\n/**\n * The zone the digest's \"today\" is read in when nothing else names one.\n *\n * The schedule fires at 13:00 UTC, which is 08:00 here, so the window this\n * zone draws is the morning the mail arrives in for the people the product\n * is built with. A member elsewhere reads a window that is at most a few\n * hours off their own, which is what a morning summary can tolerate and a\n * \"due at 9:00\" reminder could not — that reminder is the assignment\n * notification's job, not this one's.\n */\nexport const CRM_DIGEST_DEFAULT_TIME_ZONE = 'America/Chicago'\n\n/** How long a `new` lead may sit untouched before the digest names it. */\nexport const CRM_DIGEST_LEAD_AGE_MS = 2 * 24 * 60 * 60 * 1000\n\n/**\n * The most due tasks one org's sweep reads.\n *\n * One query per org, ordered soonest-due first, so an org with more than this\n * many overdue-or-today tasks digests the oldest five hundred and reports the\n * window as full rather than reading without bound. Five hundred is an org\n * whose problem is not that nobody reminded them.\n */\nexport const CRM_DIGEST_TASK_CEILING = 500\n\n/**\n * The most old leads one site's sweep reads.\n *\n * The same shape the Leads section uses, for the same reason it gives: a\n * lead the capture door wrote carries no `status` field, and Firestore\n * cannot select on a field's absence, so \"unworked\" is a filter over a\n * loaded window rather than a query. The window is the newest old leads by\n * first sighting, which is where the ones still worth a call are.\n */\nexport const CRM_DIGEST_LEAD_WINDOW = 200\n\n/** How many items one section of the email lists before \"and N more\". */\nexport const CRM_DIGEST_LIST_MAX = 10\n\n/*==========================================\n * CALENDAR DAYS IN A NAMED ZONE\n *\n * The zone math lives in `@aglyn/shared-util-timestamp/zoned-time`, where\n * booking slots and sending windows read it as well; the digest route and\n * its spec call it by the names below.\n *=========================================*/\n\n/** Midnight at the start of the calendar day `ms` falls in, in `timeZone`. */\nexport function startOfDayInZone(ms: number, timeZone: string): number {\n return startOfZonedDay(ms, timeZone)\n}\n\n/**\n * Midnight at the start of the NEXT calendar day in `timeZone` — built from\n * the calendar rather than by adding 24 hours, for the reason the tasks\n * section gives: a day a clock change falls in is 23 or 25 hours long.\n */\nexport function startOfNextDayInZone(ms: number, timeZone: string): number {\n return startOfNextZonedDay(ms, timeZone)\n}\n\n/** `YYYY-MM-DD` of the UTC day `ms` falls in — the digest's idempotence key. */\nexport function utcDayKey(ms: number): string {\n return new Date(ms).toISOString().slice(0, 10)\n}\n\n/** The half-open window `[start of today, start of tomorrow)` in a zone. */\nexport interface CrmDigestWindow {\n startMs: number\n endMs: number\n}\n\nexport function crmDigestWindow(nowMs: number, timeZone: string): CrmDigestWindow {\n return {\n startMs: startOfDayInZone(nowMs, timeZone),\n endMs: startOfNextDayInZone(nowMs, timeZone),\n }\n}\n\n/*==========================================\n * WHAT A DIGEST IS MADE OF\n *=========================================*/\n\n/** A due task as the digest carries it: the fields a line and a link need. */\nexport interface CrmDigestTask\n extends Pick<CrmTask, 'title' | 'kind' | 'contactId' | 'companyId' | 'dealId'> {\n id: string\n dueAtMs: number\n assigneeUid: string\n /** The site the task was created on — where its link opens. */\n hostId: string\n}\n\n/** An unworked lead as the digest carries it. */\nexport interface CrmDigestLead {\n id: string\n hostId: string\n email: string\n name?: string\n ownerUid?: string\n firstSeenAtMs: number\n}\n\nexport interface CrmDigestCounts {\n overdue: number\n today: number\n leads: number\n}\n\n/** One member's morning. */\nexport interface CrmMemberDigest {\n overdue: CrmDigestTask[]\n today: CrmDigestTask[]\n leads: CrmDigestLead[]\n}\n\n/**\n * Where a due task stands against the digest's window. Only tasks due\n * BEFORE the window's end are read at all, so `later` names a task the\n * query should not have returned and the bucketing refuses to count.\n */\nexport function crmDigestTaskState(\n dueAtMs: number,\n window: CrmDigestWindow,\n): 'overdue' | 'today' | 'later' {\n if (dueAtMs < window.startMs) return 'overdue'\n if (dueAtMs < window.endMs) return 'today'\n return 'later'\n}\n\n/**\n * Whether a lead is one the digest should name: still `new` — which an\n * absent status reads as, because the capture door stamps none — and first\n * seen at least {@link CRM_DIGEST_LEAD_AGE_MS} ago.\n */\nexport function isUnworkedLead(\n lead: Pick<CrmLeadFields, 'status'> & { firstSeenAtMs?: unknown },\n nowMs: number,\n ageMs: number = CRM_DIGEST_LEAD_AGE_MS,\n): boolean {\n if (crmLeadStatus(lead) !== 'new') return false\n const firstSeen = Number(lead.firstSeenAtMs)\n return Number.isFinite(firstSeen) && firstSeen > 0 && firstSeen <= nowMs - ageMs\n}\n\n/**\n * Whether a lead is THIS member's to work: theirs by owner, or nobody's on\n * a site they reach. An owned lead is one person's; an unowned one is on\n * everybody's list until somebody takes it, which is what the digest is for.\n */\nexport function leadIsForMember(\n lead: Pick<CrmDigestLead, 'ownerUid' | 'hostId'>,\n member: { uid: string; reachesHost: (hostId: string) => boolean },\n): boolean {\n if (lead.ownerUid) return lead.ownerUid === member.uid\n return member.reachesHost(lead.hostId)\n}\n\n/**\n * Whether the org's plan carries the CRM (AGL-2611's `features.crm`).\n *\n * Read by key rather than through the typed flag map so this module does not\n * depend on the key existing: the flag lands with the CRM's gate, and a\n * plan that has not declared it is a plan that does not include it — a\n * digest about a surface the plan cannot open would be a reminder to pay.\n */\nexport function crmDigestEntitled(\n features: Record<string, boolean | undefined> | null | undefined,\n): boolean {\n return features?.['crm'] === true\n}\n\n/**\n * Every member's digest, from what the org's sweep read.\n *\n * A task goes to its assignee and to nobody else — an unassigned task is a\n * deliberate choice in the drawer, and an assignee no longer on the roster\n * is a task nobody owes. A lead goes to its owner, or to every member who\n * reaches the site when it has none. Members with nothing owed are simply\n * absent from the result, which is what makes \"per member with open work\"\n * cost nothing for everyone else.\n */\nexport function buildMemberDigests(input: {\n tasks: readonly CrmDigestTask[]\n leads: readonly CrmDigestLead[]\n members: ReadonlyArray<{ uid: string; reachesHost: (hostId: string) => boolean }>\n window: CrmDigestWindow\n}): Map<string, CrmMemberDigest> {\n const { tasks, leads, members, window } = input\n const digests = new Map<string, CrmMemberDigest>()\n const digestFor = (uid: string): CrmMemberDigest => {\n let digest = digests.get(uid)\n if (!digest) {\n digest = { overdue: [], today: [], leads: [] }\n digests.set(uid, digest)\n }\n return digest\n }\n const roster = new Map(members.map((member) => [member.uid, member]))\n for (const task of tasks) {\n if (!task.assigneeUid || !roster.has(task.assigneeUid)) continue\n const state = crmDigestTaskState(task.dueAtMs, window)\n if (state === 'later') continue\n digestFor(task.assigneeUid)[state].push(task)\n }\n for (const lead of leads) {\n for (const member of members) {\n if (leadIsForMember(lead, member)) digestFor(member.uid).leads.push(lead)\n }\n }\n for (const digest of digests.values()) {\n digest.overdue.sort((a, b) => a.dueAtMs - b.dueAtMs)\n digest.today.sort((a, b) => a.dueAtMs - b.dueAtMs)\n digest.leads.sort((a, b) => a.firstSeenAtMs - b.firstSeenAtMs)\n }\n return digests\n}\n\nexport function crmDigestCounts(digest: CrmMemberDigest): CrmDigestCounts {\n return {\n overdue: digest.overdue.length,\n today: digest.today.length,\n leads: digest.leads.length,\n }\n}\n\nconst plural = (count: number, noun: string): string =>\n `${count} ${noun}${count === 1 ? '' : 's'}`\n\n/**\n * The one line the notification is: \"3 tasks due today, 2 overdue, 4\n * unworked leads\". Segments that are zero are left out rather than read as\n * \"0 overdue\" — the line is what is owed, and nothing owed is no segment.\n */\nexport function composeCrmDigestSummary(counts: CrmDigestCounts): string {\n const segments: string[] = []\n if (counts.today > 0) segments.push(`${plural(counts.today, 'task')} due today`)\n if (counts.overdue > 0) {\n segments.push(\n counts.today > 0 ? `${counts.overdue} overdue` : `${plural(counts.overdue, 'task')} overdue`,\n )\n }\n if (counts.leads > 0) segments.push(`${plural(counts.leads, 'unworked lead')}`)\n return segments.join(', ')\n}\n\nexport function composeCrmDigestSubject(counts: CrmDigestCounts): string {\n return `Your CRM today: ${composeCrmDigestSummary(counts)}`\n}\n\n/** What the digest email needs from the route besides the digest itself. */\nexport interface CrmDigestEmailInput {\n digest: CrmMemberDigest\n nowMs: number\n timeZone: string\n /** The product name the mail reads as — the org's brand when white-labeled. */\n productName: string\n /** The Tasks section, on the site the member's first task belongs to. */\n tasksUrl: string\n /** The Leads section of a site, for the sites that have unworked leads. */\n leadsUrl: (hostId: string) => string\n /** Account settings → Notifications, where the digest can be switched off. */\n settingsUrl: string\n /** A site's name for a lead line; the id when the site is unknown. */\n hostName: (hostId: string) => string\n /** The brand's support line, already prefixed, or empty. */\n supportLine?: string\n /**\n * Leave out the closing paragraph and the support line (AGL-3367), for the\n * copy that goes inside a system email: its footer says why the mail came\n * and where to get help, and its body links the settings page.\n */\n omitClosing?: boolean\n}\n\nfunction whenInZone(ms: number, timeZone: string): string {\n return new Date(ms).toLocaleString('en-US', {\n timeZone,\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n })\n}\n\nfunction dateInZone(ms: number, timeZone: string): string {\n return new Date(ms).toLocaleDateString('en-US', {\n timeZone,\n month: 'short',\n day: 'numeric',\n })\n}\n\nfunction section(heading: string, lines: string[]): string[] {\n const shown = lines.slice(0, CRM_DIGEST_LIST_MAX)\n const rest = lines.length - shown.length\n return [\n `${heading} (${lines.length})`,\n ...shown.map((line) => `- ${line}`),\n ...(rest > 0 ? [` and ${rest} more`] : []),\n '',\n ]\n}\n\n/**\n * The plain-text email. Text rather than a designed template because the\n * body is a list, and `sendEmail` synthesizes the HTML part that makes its\n * links live. Overdue first — it is the section a reader acts on — then\n * today, then the leads, each capped and counted, and the links last.\n */\nexport function composeCrmDigestEmailText(input: CrmDigestEmailInput): string {\n const { digest, nowMs, timeZone, productName } = input\n const counts = crmDigestCounts(digest)\n const day = new Date(nowMs).toLocaleDateString('en-US', {\n timeZone,\n weekday: 'long',\n month: 'long',\n day: 'numeric',\n })\n const lines: string[] = [\n `Here is your ${productName} CRM for ${day}: ${composeCrmDigestSummary(counts)}.`,\n '',\n ]\n const taskLine = (task: CrmDigestTask) =>\n `${task.title} · ${whenInZone(task.dueAtMs, timeZone)}`\n if (digest.overdue.length) {\n lines.push(...section('Overdue', digest.overdue.map(taskLine)))\n }\n if (digest.today.length) {\n lines.push(...section('Due today', digest.today.map(taskLine)))\n }\n if (digest.leads.length) {\n lines.push(\n ...section(\n 'Unworked leads',\n digest.leads.map((lead) => {\n const who = lead.name ? `${lead.name} <${lead.email}>` : lead.email\n return `${who} · ${input.hostName(lead.hostId)} · first seen ${dateInZone(\n lead.firstSeenAtMs,\n timeZone,\n )}`\n }),\n ),\n )\n }\n if (counts.overdue + counts.today > 0) lines.push(`Open your tasks: ${input.tasksUrl}`)\n const leadHosts = [...new Set(digest.leads.map((lead) => lead.hostId))].slice(0, 3)\n for (const hostId of leadHosts) {\n lines.push(`Leads on ${input.hostName(hostId)}: ${input.leadsUrl(hostId)}`)\n }\n if (input.omitClosing) return lines.join('\\n').trimEnd()\n lines.push(\n '',\n 'You get this each morning because the Daily CRM digest is on in your ' +\n `notification settings: ${input.settingsUrl}`,\n )\n return lines.join('\\n') + (input.supportLine ?? '')\n}\n"],"names":["startOfNextZonedDay","startOfZonedDay","crmLeadStatus","CRM_DIGEST_DEFAULT_TIME_ZONE","CRM_DIGEST_LEAD_AGE_MS","CRM_DIGEST_TASK_CEILING","CRM_DIGEST_LEAD_WINDOW","CRM_DIGEST_LIST_MAX","startOfDayInZone","ms","timeZone","startOfNextDayInZone","utcDayKey","Date","toISOString","slice","crmDigestWindow","nowMs","startMs","endMs","crmDigestTaskState","dueAtMs","window","isUnworkedLead","lead","ageMs","firstSeen","Number","firstSeenAtMs","isFinite","leadIsForMember","member","ownerUid","uid","reachesHost","hostId","crmDigestEntitled","features","buildMemberDigests","input","tasks","leads","members","digests","Map","digestFor","digest","get","overdue","today","set","roster","map","task","assigneeUid","has","state","push","values","sort","a","b","crmDigestCounts","length","plural","count","noun","composeCrmDigestSummary","counts","segments","join","composeCrmDigestSubject","whenInZone","toLocaleString","weekday","month","day","hour","minute","dateInZone","toLocaleDateString","section","heading","lines","shown","rest","line","composeCrmDigestEmailText","productName","taskLine","title","who","name","email","hostName","tasksUrl","leadHosts","Set","leadsUrl","omitClosing","trimEnd","settingsUrl","supportLine"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-digest.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 * The daily CRM digest (AGL-2619): what one member is owed this morning,\n * computed from the tasks and leads a scheduled route has read.\n *\n * \"Overdue\" and \"due today\" are read off the clock at read time by design\n * (AGL-2599) — nothing stamps them — which is why nothing ever told anybody.\n * The digest is the one reader that runs on a schedule: once a day it\n * counts, per org and per member, the open tasks whose due date has passed\n * or falls today and the leads nobody has worked, and says so once, in the\n * console and by mail. Every judgment here is pure so the route that sends\n * and the spec that proves it read the same arithmetic.\n *\n * ## Whose day\n *\n * A task's \"today\" on screen is the READER's calendar day, from their own\n * clock. A server has no reader, so the digest reads the day in ONE zone —\n * {@link CRM_DIGEST_DEFAULT_TIME_ZONE}, the zone the schedule was chosen for\n * (08:00 there) — taken as a parameter so an org-level setting can supply\n * its own the day one exists. The idempotence key is the UTC day\n * ({@link utcDayKey}), which is a fact about the run and not about anybody's\n * morning.\n */\n\nimport {\n startOfNextZonedDay,\n startOfZonedDay,\n} from '@aglyn/shared-util-timestamp/zoned-time'\nimport { type CrmLeadFields, type CrmTask, crmLeadStatus } from '@aglyn/aglyn/app-utils/crm'\n\n/**\n * The zone the digest's \"today\" is read in when nothing else names one.\n *\n * The schedule fires at 13:00 UTC, which is 08:00 here, so the window this\n * zone draws is the morning the mail arrives in for the people the product\n * is built with. A member elsewhere reads a window that is at most a few\n * hours off their own, which is what a morning summary can tolerate and a\n * \"due at 9:00\" reminder could not — that reminder is the assignment\n * notification's job, not this one's.\n */\nexport const CRM_DIGEST_DEFAULT_TIME_ZONE = 'America/Chicago'\n\n/**\n * The zone the digest's \"today\" is drawn in, and the zone every CRM mail\n * and notification prints a task's time in.\n *\n * `CRM_DIGEST_TIME_ZONE` overrides the default for a self-hosted console\n * whose team wakes up elsewhere; a value `Intl` does not know falls back to\n * the default with a warning rather than throwing the whole run away.\n */\nexport function digestTimeZone(env: NodeJS.ProcessEnv = process.env): string {\n const configured = String(env['CRM_DIGEST_TIME_ZONE'] ?? '').trim()\n if (!configured) return CRM_DIGEST_DEFAULT_TIME_ZONE\n try {\n new Intl.DateTimeFormat('en-US', { timeZone: configured })\n return configured\n } catch {\n console.warn(\n `crm-daily-digest: CRM_DIGEST_TIME_ZONE \"${configured}\" is not a zone — ` +\n `using ${CRM_DIGEST_DEFAULT_TIME_ZONE}`,\n )\n return CRM_DIGEST_DEFAULT_TIME_ZONE\n }\n}\n\n/** How long a `new` lead may sit untouched before the digest names it. */\nexport const CRM_DIGEST_LEAD_AGE_MS = 2 * 24 * 60 * 60 * 1000\n\n/**\n * The most due tasks one org's sweep reads.\n *\n * One query per org, ordered soonest-due first, so an org with more than this\n * many overdue-or-today tasks digests the oldest five hundred and reports the\n * window as full rather than reading without bound. Five hundred is an org\n * whose problem is not that nobody reminded them.\n */\nexport const CRM_DIGEST_TASK_CEILING = 500\n\n/**\n * The most old leads one site's sweep reads.\n *\n * The same shape the Leads section uses, for the same reason it gives: a\n * lead the capture door wrote carries no `status` field, and Firestore\n * cannot select on a field's absence, so \"unworked\" is a filter over a\n * loaded window rather than a query. The window is the newest old leads by\n * first sighting, which is where the ones still worth a call are.\n */\nexport const CRM_DIGEST_LEAD_WINDOW = 200\n\n/** How many items one section of the email lists before \"and N more\". */\nexport const CRM_DIGEST_LIST_MAX = 10\n\n/*==========================================\n * CALENDAR DAYS IN A NAMED ZONE\n *\n * The zone math lives in `@aglyn/shared-util-timestamp/zoned-time`, where\n * booking slots and sending windows read it as well; the digest route and\n * its spec call it by the names below.\n *=========================================*/\n\n/** Midnight at the start of the calendar day `ms` falls in, in `timeZone`. */\nexport function startOfDayInZone(ms: number, timeZone: string): number {\n return startOfZonedDay(ms, timeZone)\n}\n\n/**\n * Midnight at the start of the NEXT calendar day in `timeZone` — built from\n * the calendar rather than by adding 24 hours, for the reason the tasks\n * section gives: a day a clock change falls in is 23 or 25 hours long.\n */\nexport function startOfNextDayInZone(ms: number, timeZone: string): number {\n return startOfNextZonedDay(ms, timeZone)\n}\n\n/** `YYYY-MM-DD` of the UTC day `ms` falls in — the digest's idempotence key. */\nexport function utcDayKey(ms: number): string {\n return new Date(ms).toISOString().slice(0, 10)\n}\n\n/** The half-open window `[start of today, start of tomorrow)` in a zone. */\nexport interface CrmDigestWindow {\n startMs: number\n endMs: number\n}\n\nexport function crmDigestWindow(nowMs: number, timeZone: string): CrmDigestWindow {\n return {\n startMs: startOfDayInZone(nowMs, timeZone),\n endMs: startOfNextDayInZone(nowMs, timeZone),\n }\n}\n\n/*==========================================\n * WHAT A DIGEST IS MADE OF\n *=========================================*/\n\n/** A due task as the digest carries it: the fields a line and a link need. */\nexport interface CrmDigestTask\n extends Pick<CrmTask, 'title' | 'kind' | 'contactId' | 'companyId' | 'dealId'> {\n id: string\n dueAtMs: number\n assigneeUid: string\n /** The site the task was created on — where its link opens. */\n hostId: string\n}\n\n/** An unworked lead as the digest carries it. */\nexport interface CrmDigestLead {\n id: string\n hostId: string\n email: string\n name?: string\n ownerUid?: string\n firstSeenAtMs: number\n}\n\nexport interface CrmDigestCounts {\n overdue: number\n today: number\n leads: number\n /**\n * The member's leads automated email is reaching (AGL-3446) — open, but\n * not owed, so a figure the email states and never a reason to send one.\n */\n nurturing: number\n}\n\n/** One member's morning. */\nexport interface CrmMemberDigest {\n overdue: CrmDigestTask[]\n today: CrmDigestTask[]\n leads: CrmDigestLead[]\n /** Leads in `nurturing`, by the same owner-or-site rule as `leads`. */\n nurturing: CrmDigestLead[]\n}\n\n/**\n * Where a due task stands against the digest's window. Only tasks due\n * BEFORE the window's end are read at all, so `later` names a task the\n * query should not have returned and the bucketing refuses to count.\n */\nexport function crmDigestTaskState(\n dueAtMs: number,\n window: CrmDigestWindow,\n): 'overdue' | 'today' | 'later' {\n if (dueAtMs < window.startMs) return 'overdue'\n if (dueAtMs < window.endMs) return 'today'\n return 'later'\n}\n\n/**\n * Whether a lead is one the digest should name: still `new` — which an\n * absent status reads as, because the capture door stamps none — and first\n * seen at least {@link CRM_DIGEST_LEAD_AGE_MS} ago.\n */\nexport function isUnworkedLead(\n lead: Pick<CrmLeadFields, 'status'> & { firstSeenAtMs?: unknown },\n nowMs: number,\n ageMs: number = CRM_DIGEST_LEAD_AGE_MS,\n): boolean {\n if (crmLeadStatus(lead) !== 'new') return false\n return seenBefore(lead, nowMs - ageMs)\n}\n\n/**\n * Whether a lead is one the digest counts as being nurtured (AGL-3446):\n * a sequence step or a campaign email has reached it and no person has\n * engaged yet. The same age as an unworked lead, so the two figures are\n * read over the same leads — the old ones that would otherwise be owed.\n */\nexport function isNurturingLead(\n lead: Pick<CrmLeadFields, 'status'> & { firstSeenAtMs?: unknown },\n nowMs: number,\n ageMs: number = CRM_DIGEST_LEAD_AGE_MS,\n): boolean {\n return crmLeadStatus(lead) === 'nurturing' && seenBefore(lead, nowMs - ageMs)\n}\n\nfunction seenBefore(lead: { firstSeenAtMs?: unknown }, cutoffMs: number): boolean {\n const firstSeen = Number(lead.firstSeenAtMs)\n return Number.isFinite(firstSeen) && firstSeen > 0 && firstSeen <= cutoffMs\n}\n\n/**\n * Whether a lead is THIS member's to work: theirs by owner, or nobody's on\n * a site they reach. An owned lead is one person's; an unowned one is on\n * everybody's list until somebody takes it, which is what the digest is for.\n */\nexport function leadIsForMember(\n lead: Pick<CrmDigestLead, 'ownerUid' | 'hostId'>,\n member: { uid: string; reachesHost: (hostId: string) => boolean },\n): boolean {\n if (lead.ownerUid) return lead.ownerUid === member.uid\n return member.reachesHost(lead.hostId)\n}\n\n/**\n * Whether the org's plan carries the CRM (AGL-2611's `features.crm`).\n *\n * Read by key rather than through the typed flag map so this module does not\n * depend on the key existing: the flag lands with the CRM's gate, and a\n * plan that has not declared it is a plan that does not include it — a\n * digest about a surface the plan cannot open would be a reminder to pay.\n */\nexport function crmDigestEntitled(\n features: Record<string, boolean | undefined> | null | undefined,\n): boolean {\n return features?.['crm'] === true\n}\n\n/**\n * Every member's digest, from what the org's sweep read.\n *\n * A task goes to its assignee and to nobody else — an unassigned task is a\n * deliberate choice in the drawer, and an assignee no longer on the roster\n * is a task nobody owes. A lead goes to its owner, or to every member who\n * reaches the site when it has none. Members with nothing owed are simply\n * absent from the result, which is what makes \"per member with open work\"\n * cost nothing for everyone else — and a nurturing lead is not owed, so it\n * joins a digest that exists and never starts one.\n */\nexport function buildMemberDigests(input: {\n tasks: readonly CrmDigestTask[]\n leads: readonly CrmDigestLead[]\n nurturing?: readonly CrmDigestLead[]\n members: ReadonlyArray<{ uid: string; reachesHost: (hostId: string) => boolean }>\n window: CrmDigestWindow\n}): Map<string, CrmMemberDigest> {\n const { tasks, leads, nurturing = [], members, window } = input\n const digests = new Map<string, CrmMemberDigest>()\n const digestFor = (uid: string): CrmMemberDigest => {\n let digest = digests.get(uid)\n if (!digest) {\n digest = { overdue: [], today: [], leads: [], nurturing: [] }\n digests.set(uid, digest)\n }\n return digest\n }\n const roster = new Map(members.map((member) => [member.uid, member]))\n for (const task of tasks) {\n if (!task.assigneeUid || !roster.has(task.assigneeUid)) continue\n const state = crmDigestTaskState(task.dueAtMs, window)\n if (state === 'later') continue\n digestFor(task.assigneeUid)[state].push(task)\n }\n for (const lead of leads) {\n for (const member of members) {\n if (leadIsForMember(lead, member)) digestFor(member.uid).leads.push(lead)\n }\n }\n for (const lead of nurturing) {\n for (const member of members) {\n const digest = digests.get(member.uid)\n if (digest && leadIsForMember(lead, member)) digest.nurturing.push(lead)\n }\n }\n for (const digest of digests.values()) {\n digest.overdue.sort((a, b) => a.dueAtMs - b.dueAtMs)\n digest.today.sort((a, b) => a.dueAtMs - b.dueAtMs)\n digest.leads.sort((a, b) => a.firstSeenAtMs - b.firstSeenAtMs)\n }\n return digests\n}\n\nexport function crmDigestCounts(digest: CrmMemberDigest): CrmDigestCounts {\n return {\n overdue: digest.overdue.length,\n today: digest.today.length,\n leads: digest.leads.length,\n nurturing: digest.nurturing.length,\n }\n}\n\n/**\n * The line that says the nurturing figure (AGL-3446), or empty when there\n * is none: the leads the unworked list leaves out, and why.\n */\nexport function composeCrmDigestNurturingLine(counts: Pick<CrmDigestCounts, 'nurturing'>): string {\n if (counts.nurturing <= 0) return ''\n return counts.nurturing === 1\n ? '1 lead is in sequences or campaigns.'\n : `${counts.nurturing} leads are in sequences or campaigns.`\n}\n\nconst plural = (count: number, noun: string): string =>\n `${count} ${noun}${count === 1 ? '' : 's'}`\n\n/**\n * The one line the notification is: \"3 tasks due today, 2 overdue, 4\n * unworked leads\". Segments that are zero are left out rather than read as\n * \"0 overdue\" — the line is what is owed, and nothing owed is no segment.\n */\nexport function composeCrmDigestSummary(\n counts: Pick<CrmDigestCounts, 'overdue' | 'today' | 'leads'>,\n): string {\n const segments: string[] = []\n if (counts.today > 0) segments.push(`${plural(counts.today, 'task')} due today`)\n if (counts.overdue > 0) {\n segments.push(\n counts.today > 0 ? `${counts.overdue} overdue` : `${plural(counts.overdue, 'task')} overdue`,\n )\n }\n if (counts.leads > 0) segments.push(`${plural(counts.leads, 'unworked lead')}`)\n return segments.join(', ')\n}\n\n/**\n * The digest's subject. It names the workspace when it has one: the digest\n * is sent per workspace, and a member of two would otherwise get two mails\n * that read the same.\n */\nexport function composeCrmDigestSubject(\n counts: Pick<CrmDigestCounts, 'overdue' | 'today' | 'leads'>,\n workspaceName?: string,\n): string {\n const workspace = workspaceName?.trim()\n return `Your ${workspace ? `${workspace} ` : ''}CRM today: ${composeCrmDigestSummary(counts)}`\n}\n\n/** What the digest email needs from the route besides the digest itself. */\nexport interface CrmDigestEmailInput {\n digest: CrmMemberDigest\n nowMs: number\n timeZone: string\n /** The product name the mail reads as — the org's brand when white-labeled. */\n productName: string\n /**\n * The workspace the digest is for. The system email shows only the body,\n * so the body names it: a member of two workspaces gets one digest each.\n */\n workspaceName?: string\n /** The Tasks section, on the site the member's first task belongs to. */\n tasksUrl: string\n /** The Leads section of a site, for the sites that have unworked leads. */\n leadsUrl: (hostId: string) => string\n /** Account settings → Notifications, where the digest can be switched off. */\n settingsUrl: string\n /** A site's name for a lead line; the id when the site is unknown. */\n hostName: (hostId: string) => string\n /** The brand's support line, already prefixed, or empty. */\n supportLine?: string\n /**\n * Leave out the closing paragraph and the support line (AGL-3367), for the\n * copy that goes inside a system email: its footer says why the mail came\n * and where to get help, and its body links the settings page.\n */\n omitClosing?: boolean\n}\n\n/** A task's due time, with the zone named: it is the digest's, not the reader's. */\nfunction whenInZone(ms: number, timeZone: string): string {\n return new Date(ms).toLocaleString('en-US', {\n timeZone,\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n timeZoneName: 'short',\n })\n}\n\nfunction dateInZone(ms: number, timeZone: string): string {\n return new Date(ms).toLocaleDateString('en-US', {\n timeZone,\n month: 'short',\n day: 'numeric',\n })\n}\n\nfunction section(heading: string, lines: string[]): string[] {\n const shown = lines.slice(0, CRM_DIGEST_LIST_MAX)\n const rest = lines.length - shown.length\n return [\n `${heading} (${lines.length})`,\n ...shown.map((line) => `- ${line}`),\n ...(rest > 0 ? [` and ${rest} more`] : []),\n '',\n ]\n}\n\n/**\n * The plain-text email. Text rather than a designed template because the\n * body is a list, and `sendEmail` synthesizes the HTML part that makes its\n * links live. Overdue first — it is the section a reader acts on — then\n * today, then the leads, each capped and counted, and the links last.\n */\nexport function composeCrmDigestEmailText(input: CrmDigestEmailInput): string {\n const { digest, nowMs, timeZone, productName } = input\n const counts = crmDigestCounts(digest)\n const day = new Date(nowMs).toLocaleDateString('en-US', {\n timeZone,\n weekday: 'long',\n month: 'long',\n day: 'numeric',\n })\n const workspace = input.workspaceName?.trim()\n const lines: string[] = [\n `Here is your ${productName} CRM${workspace ? ` in ${workspace}` : ''} for ${day}: ` +\n `${composeCrmDigestSummary(counts)}.`,\n '',\n ]\n // A task's site, as the lead lines name theirs; an organization task\n // (AGL-2637) has none.\n const taskLine = (task: CrmDigestTask) =>\n [task.title, task.hostId ? input.hostName(task.hostId) : '', whenInZone(task.dueAtMs, timeZone)]\n .filter(Boolean)\n .join(' · ')\n if (digest.overdue.length) {\n lines.push(...section('Overdue', digest.overdue.map(taskLine)))\n }\n if (digest.today.length) {\n lines.push(...section('Due today', digest.today.map(taskLine)))\n }\n if (digest.leads.length) {\n lines.push(\n ...section(\n 'Unworked leads',\n digest.leads.map((lead) => {\n const who = lead.name ? `${lead.name} <${lead.email}>` : lead.email\n return `${who} · ${input.hostName(lead.hostId)} · first seen ${dateInZone(\n lead.firstSeenAtMs,\n timeZone,\n )}`\n }),\n ),\n )\n }\n const nurturingLine = composeCrmDigestNurturingLine(counts)\n if (nurturingLine) lines.push(nurturingLine, '')\n if (counts.overdue + counts.today > 0) lines.push(`Open your tasks: ${input.tasksUrl}`)\n const leadHosts = [...new Set(digest.leads.map((lead) => lead.hostId))].slice(0, 3)\n for (const hostId of leadHosts) {\n lines.push(`Leads on ${input.hostName(hostId)}: ${input.leadsUrl(hostId)}`)\n }\n if (input.omitClosing) return lines.join('\\n').trimEnd()\n lines.push(\n '',\n 'You get this each morning because the Daily CRM digest is on in your ' +\n `notification settings: ${input.settingsUrl}`,\n )\n return lines.join('\\n') + (input.supportLine ?? '')\n}\n"],"names":["startOfNextZonedDay","startOfZonedDay","crmLeadStatus","CRM_DIGEST_DEFAULT_TIME_ZONE","digestTimeZone","env","process","configured","String","trim","Intl","DateTimeFormat","timeZone","console","warn","CRM_DIGEST_LEAD_AGE_MS","CRM_DIGEST_TASK_CEILING","CRM_DIGEST_LEAD_WINDOW","CRM_DIGEST_LIST_MAX","startOfDayInZone","ms","startOfNextDayInZone","utcDayKey","Date","toISOString","slice","crmDigestWindow","nowMs","startMs","endMs","crmDigestTaskState","dueAtMs","window","isUnworkedLead","lead","ageMs","seenBefore","isNurturingLead","cutoffMs","firstSeen","Number","firstSeenAtMs","isFinite","leadIsForMember","member","ownerUid","uid","reachesHost","hostId","crmDigestEntitled","features","buildMemberDigests","input","tasks","leads","nurturing","members","digests","Map","digestFor","digest","get","overdue","today","set","roster","map","task","assigneeUid","has","state","push","values","sort","a","b","crmDigestCounts","length","composeCrmDigestNurturingLine","counts","plural","count","noun","composeCrmDigestSummary","segments","join","composeCrmDigestSubject","workspaceName","workspace","whenInZone","toLocaleString","weekday","month","day","hour","minute","timeZoneName","dateInZone","toLocaleDateString","section","heading","lines","shown","rest","line","composeCrmDigestEmailText","productName","taskLine","title","hostName","filter","Boolean","who","name","email","nurturingLine","tasksUrl","leadHosts","Set","leadsUrl","omitClosing","trimEnd","settingsUrl","supportLine"],"mappings":"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"}