@aglyn/plugins-crm 1.0.0-beta.219 → 1.0.0-beta.221
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +14 -15
- package/src/lib/components/company-import-drawer.d.ts +1 -1
- package/src/lib/components/contact-associations-card.js +4 -3
- package/src/lib/components/contact-associations-card.js.map +1 -1
- package/src/lib/components/crm-booking-zone.d.ts +8 -2
- package/src/lib/components/crm-booking-zone.js.map +1 -1
- package/src/lib/components/crm-glance-card.js +2 -1
- package/src/lib/components/crm-glance-card.js.map +1 -1
- package/src/lib/components/deal-products-card.js +27 -16
- package/src/lib/components/deal-products-card.js.map +1 -1
- package/src/lib/components/lead-campaigns-card.d.ts +4 -4
- package/src/lib/components/lead-campaigns-card.js +9 -8
- package/src/lib/components/lead-campaigns-card.js.map +1 -1
- package/src/lib/components/lead-properties-card.js +4 -0
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/lead-status-chip.js +3 -1
- package/src/lib/components/lead-status-chip.js.map +1 -1
- package/src/lib/components/leads-bulk-bar.js +7 -9
- package/src/lib/components/leads-bulk-bar.js.map +1 -1
- package/src/lib/components/leads-section.js +5 -1
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/new-lead-drawer.js +5 -4
- package/src/lib/components/new-lead-drawer.js.map +1 -1
- package/src/lib/components/reports/activity-card.js +4 -3
- package/src/lib/components/reports/activity-card.js.map +1 -1
- package/src/lib/components/reports/closed-deals-card.js +4 -3
- package/src/lib/components/reports/closed-deals-card.js.map +1 -1
- package/src/lib/components/reports/contacts-mix-card.js +3 -2
- package/src/lib/components/reports/contacts-mix-card.js.map +1 -1
- package/src/lib/components/reports/contacts-trend-card.js +2 -1
- package/src/lib/components/reports/contacts-trend-card.js.map +1 -1
- package/src/lib/components/reports/forecast-card.js +3 -2
- package/src/lib/components/reports/forecast-card.js.map +1 -1
- package/src/lib/components/reports/lead-funnel-card.js +8 -6
- package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
- package/src/lib/components/reports/pipeline-card.js +4 -3
- package/src/lib/components/reports/pipeline-card.js.map +1 -1
- package/src/lib/components/reports/report-format.d.ts +1 -1
- package/src/lib/components/reports/report-format.js.map +1 -1
- package/src/lib/components/reports/report-scope.d.ts +1 -1
- package/src/lib/components/reports/report-scope.js.map +1 -1
- package/src/lib/components/reports/source-conversion-card.js +2 -1
- package/src/lib/components/reports/source-conversion-card.js.map +1 -1
- package/src/lib/components/reports/tasks-card.js +2 -1
- package/src/lib/components/reports/tasks-card.js.map +1 -1
- package/src/lib/components/reports/won-lost-by-owner-card.js +3 -2
- package/src/lib/components/reports/won-lost-by-owner-card.js.map +1 -1
- package/src/lib/components/reports-section.js +4 -4
- package/src/lib/components/reports-section.js.map +1 -1
- package/src/lib/constants/bundle-common.d.ts +2 -0
- package/src/lib/constants/bundle-common.js +1 -0
- package/src/lib/constants/bundle-common.js.map +1 -1
- package/src/lib/declarations.console-server.d.ts +4 -0
- package/src/lib/declarations.console-server.js +12 -0
- package/src/lib/declarations.console-server.js.map +1 -1
- package/src/lib/declarations.server.d.ts +6 -0
- package/src/lib/declarations.server.js +51 -1
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/hooks/use-crm-campaigns.d.ts +2 -2
- package/src/lib/hooks/use-crm-campaigns.js +3 -3
- package/src/lib/hooks/use-crm-campaigns.js.map +1 -1
- package/src/lib/model/campaign-filing-activity.d.ts +1 -1
- package/src/lib/model/campaign-filing-activity.js +1 -1
- package/src/lib/model/campaign-filing-activity.js.map +1 -1
- package/src/lib/model/contact-record.js +1 -1
- package/src/lib/model/contact-record.js.map +1 -1
- package/src/lib/model/crm-digest.d.ts +45 -3
- package/src/lib/model/crm-digest.js +75 -11
- package/src/lib/model/crm-digest.js.map +1 -1
- package/src/lib/model/crm-inbound.d.ts +162 -0
- package/src/lib/model/crm-inbound.js +208 -0
- package/src/lib/model/crm-inbound.js.map +1 -0
- package/src/lib/model/crm-lead-import.d.ts +1 -1
- package/src/lib/model/crm-lead-import.js +4 -4
- package/src/lib/model/crm-lead-import.js.map +1 -1
- package/src/lib/model/crm-reports.d.ts +467 -0
- package/src/lib/model/crm-reports.js +667 -0
- package/src/lib/model/crm-reports.js.map +1 -0
- package/src/lib/model/lead-filters.d.ts +4 -3
- package/src/lib/model/lead-filters.js +7 -5
- package/src/lib/model/lead-filters.js.map +1 -1
- package/src/lib/plugin.js +36 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/api-v1/crm-leads.js +1 -0
- package/src/lib/server/api-v1/crm-leads.js.map +1 -1
- package/src/lib/server/api-v1/openapi.js +1 -1
- package/src/lib/server/api-v1/openapi.js.map +1 -1
- package/src/lib/server/campaign-filing-activity.d.ts +1 -1
- package/src/lib/server/campaign-filing-activity.js +7 -11
- package/src/lib/server/campaign-filing-activity.js.map +1 -1
- package/src/lib/server/capture-contact.js +19 -5
- package/src/lib/server/capture-contact.js.map +1 -1
- package/src/lib/server/contact-email-engagement.d.ts +33 -0
- package/src/lib/server/contact-email-engagement.js +146 -0
- package/src/lib/server/contact-email-engagement.js.map +1 -0
- package/src/lib/server/contact-update.js +3 -3
- package/src/lib/server/contact-update.js.map +1 -1
- package/src/lib/server/container-detach.d.ts +31 -0
- package/src/lib/server/container-detach.js +115 -0
- package/src/lib/server/container-detach.js.map +1 -0
- package/src/lib/server/crm-inbound-email.d.ts +162 -0
- package/src/lib/server/crm-inbound-email.js +259 -0
- package/src/lib/server/crm-inbound-email.js.map +1 -0
- package/src/lib/server/crm-records-meter.d.ts +50 -0
- package/src/lib/server/crm-records-meter.js +126 -0
- package/src/lib/server/crm-records-meter.js.map +1 -0
- package/src/lib/server/daily-digest-route.d.ts +2 -8
- package/src/lib/server/daily-digest-route.js +15 -25
- package/src/lib/server/daily-digest-route.js.map +1 -1
- package/src/lib/server/inbound-address.js +5 -3
- package/src/lib/server/inbound-address.js.map +1 -1
- package/src/lib/server/inbound-route.js +7 -4
- package/src/lib/server/inbound-route.js.map +1 -1
- package/src/lib/server/lead-campaign-carry.js +3 -3
- package/src/lib/server/lead-campaign-carry.js.map +1 -1
- package/src/lib/server/lead-create.d.ts +1 -1
- package/src/lib/server/lead-create.js +10 -15
- package/src/lib/server/lead-create.js.map +1 -1
- package/src/lib/server/lead-nurturing.d.ts +23 -0
- package/src/lib/server/lead-nurturing.js +82 -0
- package/src/lib/server/lead-nurturing.js.map +1 -0
- package/src/lib/server/leads-import.js +7 -7
- package/src/lib/server/leads-import.js.map +1 -1
- package/src/lib/server/record-email-state.js +23 -0
- package/src/lib/server/record-email-state.js.map +1 -1
- package/src/lib/server/record-timeline.d.ts +12 -0
- package/src/lib/server/record-timeline.js +127 -15
- package/src/lib/server/record-timeline.js.map +1 -1
- package/src/lib/server/task-reminders-route.js +4 -2
- package/src/lib/server/task-reminders-route.js.map +1 -1
- package/src/lib/server/task-routes.d.ts +2 -0
- package/src/lib/server/task-routes.js +22 -3
- package/src/lib/server/task-routes.js.map +1 -1
- package/src/lib/usage-axes.js +8 -1
- package/src/lib/usage-axes.js.map +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
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
|
-
}):
|
|
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 {
|
|
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 =
|
|
34
|
+
const site = useSiteContainerOptions('campaign', hostId != null ? hostId : undefined, {
|
|
35
35
|
enabled: enabled && Boolean(hostId)
|
|
36
36
|
});
|
|
37
|
-
const org =
|
|
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
|
|
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 `
|
|
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 `
|
|
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 `
|
|
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.
|
|
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":"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"}
|
|
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
|
-
|
|
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 <=
|
|
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
|
-
|
|
197
|
-
|
|
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}:
|
|
300
|
+
`Here is your ${productName} CRM${workspace ? ` in ${workspace}` : ''} for ${day}: ` + `${composeCrmDigestSummary(counts)}.`,
|
|
245
301
|
''
|
|
246
302
|
];
|
|
247
|
-
|
|
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":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;CAqBC,GAED,SACEA,mBAAmB,EACnBC,eAAe,QACV,0CAAyC;AAChD,SAA2CC,aAAa,QAAQ,6BAA4B;AAE5F;;;;;;;;;CASC,GACD,OAAO,MAAMC,+BAA+B,kBAAiB;AAE7D,wEAAwE,GACxE,OAAO,MAAMC,yBAAyB,IAAI,KAAK,KAAK,KAAK,KAAI;AAE7D;;;;;;;CAOC,GACD,OAAO,MAAMC,0BAA0B,IAAG;AAE1C;;;;;;;;CAQC,GACD,OAAO,MAAMC,yBAAyB,IAAG;AAEzC,uEAAuE,GACvE,OAAO,MAAMC,sBAAsB,GAAE;AAErC;;;;;;2CAM2C,GAE3C,4EAA4E,GAC5E,OAAO,SAASC,iBAAiBC,EAAU,EAAEC,QAAgB;IAC3D,OAAOT,gBAAgBQ,IAAIC;AAC7B;AAEA;;;;CAIC,GACD,OAAO,SAASC,qBAAqBF,EAAU,EAAEC,QAAgB;IAC/D,OAAOV,oBAAoBS,IAAIC;AACjC;AAEA,8EAA8E,GAC9E,OAAO,SAASE,UAAUH,EAAU;IAClC,OAAO,IAAII,KAAKJ,IAAIK,WAAW,GAAGC,KAAK,CAAC,GAAG;AAC7C;AAQA,OAAO,SAASC,gBAAgBC,KAAa,EAAEP,QAAgB;IAC7D,OAAO;QACLQ,SAASV,iBAAiBS,OAAOP;QACjCS,OAAOR,qBAAqBM,OAAOP;IACrC;AACF;AAuCA;;;;CAIC,GACD,OAAO,SAASU,mBACdC,OAAe,EACfC,MAAuB;IAEvB,IAAID,UAAUC,OAAOJ,OAAO,EAAE,OAAO;IACrC,IAAIG,UAAUC,OAAOH,KAAK,EAAE,OAAO;IACnC,OAAO;AACT;AAEA;;;;CAIC,GACD,OAAO,SAASI,eACdC,IAAiE,EACjEP,KAAa,EACbQ,QAAgBrB,sBAAsB;IAEtC,IAAIF,cAAcsB,UAAU,OAAO,OAAO;IAC1C,MAAME,YAAYC,OAAOH,KAAKI,aAAa;IAC3C,OAAOD,OAAOE,QAAQ,CAACH,cAAcA,YAAY,KAAKA,aAAaT,QAAQQ;AAC7E;AAEA;;;;CAIC,GACD,OAAO,SAASK,gBACdN,IAAgD,EAChDO,MAAiE;IAEjE,IAAIP,KAAKQ,QAAQ,EAAE,OAAOR,KAAKQ,QAAQ,KAAKD,OAAOE,GAAG;IACtD,OAAOF,OAAOG,WAAW,CAACV,KAAKW,MAAM;AACvC;AAEA;;;;;;;CAOC,GACD,OAAO,SAASC,kBACdC,QAAgE;IAEhE,OAAOA,CAAAA,4BAAAA,QAAU,CAAC,MAAM,MAAK;AAC/B;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASC,mBAAmBC,KAKlC;IACC,MAAM,EAAEC,KAAK,EAAEC,KAAK,EAAEC,OAAO,EAAEpB,MAAM,EAAE,GAAGiB;IAC1C,MAAMI,UAAU,IAAIC;IACpB,MAAMC,YAAY,CAACZ;QACjB,IAAIa,SAASH,QAAQI,GAAG,CAACd;QACzB,IAAI,CAACa,QAAQ;YACXA,SAAS;gBAAEE,SAAS,EAAE;gBAAEC,OAAO,EAAE;gBAAER,OAAO,EAAE;YAAC;YAC7CE,QAAQO,GAAG,CAACjB,KAAKa;QACnB;QACA,OAAOA;IACT;IACA,MAAMK,SAAS,IAAIP,IAAIF,QAAQU,GAAG,CAAC,CAACrB,SAAW;YAACA,OAAOE,GAAG;YAAEF;SAAO;IACnE,KAAK,MAAMsB,QAAQb,MAAO;QACxB,IAAI,CAACa,KAAKC,WAAW,IAAI,CAACH,OAAOI,GAAG,CAACF,KAAKC,WAAW,GAAG;QACxD,MAAME,QAAQpC,mBAAmBiC,KAAKhC,OAAO,EAAEC;QAC/C,IAAIkC,UAAU,SAAS;QACvBX,UAAUQ,KAAKC,WAAW,CAAC,CAACE,MAAM,CAACC,IAAI,CAACJ;IAC1C;IACA,KAAK,MAAM7B,QAAQiB,MAAO;QACxB,KAAK,MAAMV,UAAUW,QAAS;YAC5B,IAAIZ,gBAAgBN,MAAMO,SAASc,UAAUd,OAAOE,GAAG,EAAEQ,KAAK,CAACgB,IAAI,CAACjC;QACtE;IACF;IACA,KAAK,MAAMsB,UAAUH,QAAQe,MAAM,GAAI;QACrCZ,OAAOE,OAAO,CAACW,IAAI,CAAC,CAACC,GAAGC,IAAMD,EAAEvC,OAAO,GAAGwC,EAAExC,OAAO;QACnDyB,OAAOG,KAAK,CAACU,IAAI,CAAC,CAACC,GAAGC,IAAMD,EAAEvC,OAAO,GAAGwC,EAAExC,OAAO;QACjDyB,OAAOL,KAAK,CAACkB,IAAI,CAAC,CAACC,GAAGC,IAAMD,EAAEhC,aAAa,GAAGiC,EAAEjC,aAAa;IAC/D;IACA,OAAOe;AACT;AAEA,OAAO,SAASmB,gBAAgBhB,MAAuB;IACrD,OAAO;QACLE,SAASF,OAAOE,OAAO,CAACe,MAAM;QAC9Bd,OAAOH,OAAOG,KAAK,CAACc,MAAM;QAC1BtB,OAAOK,OAAOL,KAAK,CAACsB,MAAM;IAC5B;AACF;AAEA,MAAMC,SAAS,CAACC,OAAeC,OAC7B,GAAGD,MAAM,CAAC,EAAEC,OAAOD,UAAU,IAAI,KAAK,KAAK;AAE7C;;;;CAIC,GACD,OAAO,SAASE,wBAAwBC,MAAuB;IAC7D,MAAMC,WAAqB,EAAE;IAC7B,IAAID,OAAOnB,KAAK,GAAG,GAAGoB,SAASZ,IAAI,CAAC,GAAGO,OAAOI,OAAOnB,KAAK,EAAE,QAAQ,UAAU,CAAC;IAC/E,IAAImB,OAAOpB,OAAO,GAAG,GAAG;QACtBqB,SAASZ,IAAI,CACXW,OAAOnB,KAAK,GAAG,IAAI,GAAGmB,OAAOpB,OAAO,CAAC,QAAQ,CAAC,GAAG,GAAGgB,OAAOI,OAAOpB,OAAO,EAAE,QAAQ,QAAQ,CAAC;IAEhG;IACA,IAAIoB,OAAO3B,KAAK,GAAG,GAAG4B,SAASZ,IAAI,CAAC,GAAGO,OAAOI,OAAO3B,KAAK,EAAE,kBAAkB;IAC9E,OAAO4B,SAASC,IAAI,CAAC;AACvB;AAEA,OAAO,SAASC,wBAAwBH,MAAuB;IAC7D,OAAO,CAAC,gBAAgB,EAAED,wBAAwBC,SAAS;AAC7D;AA2BA,SAASI,WAAW/D,EAAU,EAAEC,QAAgB;IAC9C,OAAO,IAAIG,KAAKJ,IAAIgE,cAAc,CAAC,SAAS;QAC1C/D;QACAgE,SAAS;QACTC,OAAO;QACPC,KAAK;QACLC,MAAM;QACNC,QAAQ;IACV;AACF;AAEA,SAASC,WAAWtE,EAAU,EAAEC,QAAgB;IAC9C,OAAO,IAAIG,KAAKJ,IAAIuE,kBAAkB,CAAC,SAAS;QAC9CtE;QACAiE,OAAO;QACPC,KAAK;IACP;AACF;AAEA,SAASK,QAAQC,OAAe,EAAEC,KAAe;IAC/C,MAAMC,QAAQD,MAAMpE,KAAK,CAAC,GAAGR;IAC7B,MAAM8E,OAAOF,MAAMpB,MAAM,GAAGqB,MAAMrB,MAAM;IACxC,OAAO;QACL,GAAGmB,QAAQ,EAAE,EAAEC,MAAMpB,MAAM,CAAC,CAAC,CAAC;WAC3BqB,MAAMhC,GAAG,CAAC,CAACkC,OAAS,CAAC,EAAE,EAAEA,MAAM;WAC9BD,OAAO,IAAI;YAAC,CAAC,MAAM,EAAEA,KAAK,KAAK,CAAC;SAAC,GAAG,EAAE;QAC1C;KACD;AACH;AAEA;;;;;CAKC,GACD,OAAO,SAASE,0BAA0BhD,KAA0B;QA8CvCA;IA7C3B,MAAM,EAAEO,MAAM,EAAE7B,KAAK,EAAEP,QAAQ,EAAE8E,WAAW,EAAE,GAAGjD;IACjD,MAAM6B,SAASN,gBAAgBhB;IAC/B,MAAM8B,MAAM,IAAI/D,KAAKI,OAAO+D,kBAAkB,CAAC,SAAS;QACtDtE;QACAgE,SAAS;QACTC,OAAO;QACPC,KAAK;IACP;IACA,MAAMO,QAAkB;QACtB,CAAC,aAAa,EAAEK,YAAY,SAAS,EAAEZ,IAAI,EAAE,EAAET,wBAAwBC,QAAQ,CAAC,CAAC;QACjF;KACD;IACD,MAAMqB,WAAW,CAACpC,OAChB,GAAGA,KAAKqC,KAAK,CAAC,GAAG,EAAElB,WAAWnB,KAAKhC,OAAO,EAAEX,WAAW;IACzD,IAAIoC,OAAOE,OAAO,CAACe,MAAM,EAAE;QACzBoB,MAAM1B,IAAI,IAAIwB,QAAQ,WAAWnC,OAAOE,OAAO,CAACI,GAAG,CAACqC;IACtD;IACA,IAAI3C,OAAOG,KAAK,CAACc,MAAM,EAAE;QACvBoB,MAAM1B,IAAI,IAAIwB,QAAQ,aAAanC,OAAOG,KAAK,CAACG,GAAG,CAACqC;IACtD;IACA,IAAI3C,OAAOL,KAAK,CAACsB,MAAM,EAAE;QACvBoB,MAAM1B,IAAI,IACLwB,QACD,kBACAnC,OAAOL,KAAK,CAACW,GAAG,CAAC,CAAC5B;YAChB,MAAMmE,MAAMnE,KAAKoE,IAAI,GAAG,GAAGpE,KAAKoE,IAAI,CAAC,EAAE,EAAEpE,KAAKqE,KAAK,CAAC,CAAC,CAAC,GAAGrE,KAAKqE,KAAK;YACnE,OAAO,GAAGF,IAAI,GAAG,EAAEpD,MAAMuD,QAAQ,CAACtE,KAAKW,MAAM,EAAE,cAAc,EAAE4C,WAC7DvD,KAAKI,aAAa,EAClBlB,WACC;QACL;IAGN;IACA,IAAI0D,OAAOpB,OAAO,GAAGoB,OAAOnB,KAAK,GAAG,GAAGkC,MAAM1B,IAAI,CAAC,CAAC,iBAAiB,EAAElB,MAAMwD,QAAQ,EAAE;IACtF,MAAMC,YAAY;WAAI,IAAIC,IAAInD,OAAOL,KAAK,CAACW,GAAG,CAAC,CAAC5B,OAASA,KAAKW,MAAM;KAAG,CAACpB,KAAK,CAAC,GAAG;IACjF,KAAK,MAAMoB,UAAU6D,UAAW;QAC9Bb,MAAM1B,IAAI,CAAC,CAAC,SAAS,EAAElB,MAAMuD,QAAQ,CAAC3D,QAAQ,EAAE,EAAEI,MAAM2D,QAAQ,CAAC/D,SAAS;IAC5E;IACA,IAAII,MAAM4D,WAAW,EAAE,OAAOhB,MAAMb,IAAI,CAAC,MAAM8B,OAAO;IACtDjB,MAAM1B,IAAI,CACR,IACA,0EACE,CAAC,uBAAuB,EAAElB,MAAM8D,WAAW,EAAE;IAEjD,OAAOlB,MAAMb,IAAI,CAAC,UAAS/B,qBAAAA,MAAM+D,WAAW,YAAjB/D,qBAAqB;AAClD"}
|
|
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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"}
|