@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.
Files changed (136) hide show
  1. package/README.md +2 -0
  2. package/package.json +14 -15
  3. package/src/lib/components/company-import-drawer.d.ts +1 -1
  4. package/src/lib/components/contact-associations-card.js +4 -3
  5. package/src/lib/components/contact-associations-card.js.map +1 -1
  6. package/src/lib/components/crm-booking-zone.d.ts +8 -2
  7. package/src/lib/components/crm-booking-zone.js.map +1 -1
  8. package/src/lib/components/crm-glance-card.js +2 -1
  9. package/src/lib/components/crm-glance-card.js.map +1 -1
  10. package/src/lib/components/deal-products-card.js +27 -16
  11. package/src/lib/components/deal-products-card.js.map +1 -1
  12. package/src/lib/components/lead-campaigns-card.d.ts +4 -4
  13. package/src/lib/components/lead-campaigns-card.js +9 -8
  14. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  15. package/src/lib/components/lead-properties-card.js +4 -0
  16. package/src/lib/components/lead-properties-card.js.map +1 -1
  17. package/src/lib/components/lead-status-chip.js +3 -1
  18. package/src/lib/components/lead-status-chip.js.map +1 -1
  19. package/src/lib/components/leads-bulk-bar.js +7 -9
  20. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  21. package/src/lib/components/leads-section.js +5 -1
  22. package/src/lib/components/leads-section.js.map +1 -1
  23. package/src/lib/components/new-lead-drawer.js +5 -4
  24. package/src/lib/components/new-lead-drawer.js.map +1 -1
  25. package/src/lib/components/reports/activity-card.js +4 -3
  26. package/src/lib/components/reports/activity-card.js.map +1 -1
  27. package/src/lib/components/reports/closed-deals-card.js +4 -3
  28. package/src/lib/components/reports/closed-deals-card.js.map +1 -1
  29. package/src/lib/components/reports/contacts-mix-card.js +3 -2
  30. package/src/lib/components/reports/contacts-mix-card.js.map +1 -1
  31. package/src/lib/components/reports/contacts-trend-card.js +2 -1
  32. package/src/lib/components/reports/contacts-trend-card.js.map +1 -1
  33. package/src/lib/components/reports/forecast-card.js +3 -2
  34. package/src/lib/components/reports/forecast-card.js.map +1 -1
  35. package/src/lib/components/reports/lead-funnel-card.js +8 -6
  36. package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
  37. package/src/lib/components/reports/pipeline-card.js +4 -3
  38. package/src/lib/components/reports/pipeline-card.js.map +1 -1
  39. package/src/lib/components/reports/report-format.d.ts +1 -1
  40. package/src/lib/components/reports/report-format.js.map +1 -1
  41. package/src/lib/components/reports/report-scope.d.ts +1 -1
  42. package/src/lib/components/reports/report-scope.js.map +1 -1
  43. package/src/lib/components/reports/source-conversion-card.js +2 -1
  44. package/src/lib/components/reports/source-conversion-card.js.map +1 -1
  45. package/src/lib/components/reports/tasks-card.js +2 -1
  46. package/src/lib/components/reports/tasks-card.js.map +1 -1
  47. package/src/lib/components/reports/won-lost-by-owner-card.js +3 -2
  48. package/src/lib/components/reports/won-lost-by-owner-card.js.map +1 -1
  49. package/src/lib/components/reports-section.js +4 -4
  50. package/src/lib/components/reports-section.js.map +1 -1
  51. package/src/lib/constants/bundle-common.d.ts +2 -0
  52. package/src/lib/constants/bundle-common.js +1 -0
  53. package/src/lib/constants/bundle-common.js.map +1 -1
  54. package/src/lib/declarations.console-server.d.ts +4 -0
  55. package/src/lib/declarations.console-server.js +12 -0
  56. package/src/lib/declarations.console-server.js.map +1 -1
  57. package/src/lib/declarations.server.d.ts +6 -0
  58. package/src/lib/declarations.server.js +51 -1
  59. package/src/lib/declarations.server.js.map +1 -1
  60. package/src/lib/hooks/use-crm-campaigns.d.ts +2 -2
  61. package/src/lib/hooks/use-crm-campaigns.js +3 -3
  62. package/src/lib/hooks/use-crm-campaigns.js.map +1 -1
  63. package/src/lib/model/campaign-filing-activity.d.ts +1 -1
  64. package/src/lib/model/campaign-filing-activity.js +1 -1
  65. package/src/lib/model/campaign-filing-activity.js.map +1 -1
  66. package/src/lib/model/contact-record.js +1 -1
  67. package/src/lib/model/contact-record.js.map +1 -1
  68. package/src/lib/model/crm-digest.d.ts +45 -3
  69. package/src/lib/model/crm-digest.js +75 -11
  70. package/src/lib/model/crm-digest.js.map +1 -1
  71. package/src/lib/model/crm-inbound.d.ts +162 -0
  72. package/src/lib/model/crm-inbound.js +208 -0
  73. package/src/lib/model/crm-inbound.js.map +1 -0
  74. package/src/lib/model/crm-lead-import.d.ts +1 -1
  75. package/src/lib/model/crm-lead-import.js +4 -4
  76. package/src/lib/model/crm-lead-import.js.map +1 -1
  77. package/src/lib/model/crm-reports.d.ts +467 -0
  78. package/src/lib/model/crm-reports.js +667 -0
  79. package/src/lib/model/crm-reports.js.map +1 -0
  80. package/src/lib/model/lead-filters.d.ts +4 -3
  81. package/src/lib/model/lead-filters.js +7 -5
  82. package/src/lib/model/lead-filters.js.map +1 -1
  83. package/src/lib/plugin.js +36 -0
  84. package/src/lib/plugin.js.map +1 -1
  85. package/src/lib/server/api-v1/crm-leads.js +1 -0
  86. package/src/lib/server/api-v1/crm-leads.js.map +1 -1
  87. package/src/lib/server/api-v1/openapi.js +1 -1
  88. package/src/lib/server/api-v1/openapi.js.map +1 -1
  89. package/src/lib/server/campaign-filing-activity.d.ts +1 -1
  90. package/src/lib/server/campaign-filing-activity.js +7 -11
  91. package/src/lib/server/campaign-filing-activity.js.map +1 -1
  92. package/src/lib/server/capture-contact.js +19 -5
  93. package/src/lib/server/capture-contact.js.map +1 -1
  94. package/src/lib/server/contact-email-engagement.d.ts +33 -0
  95. package/src/lib/server/contact-email-engagement.js +146 -0
  96. package/src/lib/server/contact-email-engagement.js.map +1 -0
  97. package/src/lib/server/contact-update.js +3 -3
  98. package/src/lib/server/contact-update.js.map +1 -1
  99. package/src/lib/server/container-detach.d.ts +31 -0
  100. package/src/lib/server/container-detach.js +115 -0
  101. package/src/lib/server/container-detach.js.map +1 -0
  102. package/src/lib/server/crm-inbound-email.d.ts +162 -0
  103. package/src/lib/server/crm-inbound-email.js +259 -0
  104. package/src/lib/server/crm-inbound-email.js.map +1 -0
  105. package/src/lib/server/crm-records-meter.d.ts +50 -0
  106. package/src/lib/server/crm-records-meter.js +126 -0
  107. package/src/lib/server/crm-records-meter.js.map +1 -0
  108. package/src/lib/server/daily-digest-route.d.ts +2 -8
  109. package/src/lib/server/daily-digest-route.js +15 -25
  110. package/src/lib/server/daily-digest-route.js.map +1 -1
  111. package/src/lib/server/inbound-address.js +5 -3
  112. package/src/lib/server/inbound-address.js.map +1 -1
  113. package/src/lib/server/inbound-route.js +7 -4
  114. package/src/lib/server/inbound-route.js.map +1 -1
  115. package/src/lib/server/lead-campaign-carry.js +3 -3
  116. package/src/lib/server/lead-campaign-carry.js.map +1 -1
  117. package/src/lib/server/lead-create.d.ts +1 -1
  118. package/src/lib/server/lead-create.js +10 -15
  119. package/src/lib/server/lead-create.js.map +1 -1
  120. package/src/lib/server/lead-nurturing.d.ts +23 -0
  121. package/src/lib/server/lead-nurturing.js +82 -0
  122. package/src/lib/server/lead-nurturing.js.map +1 -0
  123. package/src/lib/server/leads-import.js +7 -7
  124. package/src/lib/server/leads-import.js.map +1 -1
  125. package/src/lib/server/record-email-state.js +23 -0
  126. package/src/lib/server/record-email-state.js.map +1 -1
  127. package/src/lib/server/record-timeline.d.ts +12 -0
  128. package/src/lib/server/record-timeline.js +127 -15
  129. package/src/lib/server/record-timeline.js.map +1 -1
  130. package/src/lib/server/task-reminders-route.js +4 -2
  131. package/src/lib/server/task-reminders-route.js.map +1 -1
  132. package/src/lib/server/task-routes.d.ts +2 -0
  133. package/src/lib/server/task-routes.js +22 -3
  134. package/src/lib/server/task-routes.js.map +1 -1
  135. package/src/lib/usage-axes.js +8 -1
  136. package/src/lib/usage-axes.js.map +1 -1
@@ -0,0 +1,259 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { consentGroupForHost, contactCaptureHostIds, crmActivityLogHasRoom, crmScopeTokens, emailAddressOf, emailDomainOf, emailExcerpt, findMemberByEmailAddress, forwardedSection, htmlToPlainText, personKey, readContactFacet } from "@aglyn/aglyn/server";
18
+ import { visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
19
+ import { findContactByEmail } from "@aglyn/tenant-data-admin/server/contact-email-index";
20
+ import { createCrmEmailActivity, crmCapturedEmailActivityRef, newCrmActivityRef } from "@aglyn/tenant-data-admin/server/crm-email-activity";
21
+ import { countCrmActivitiesForRecord } from "@aglyn/tenant-data-admin/server/crm-records";
22
+ import { buildCrmCapturedEmailActivity, crmCapturedEmailKey, crmInboundCandidates, isCrmInboundAddress, isCrmInboundToken, mintCrmInboundToken } from "../model/crm-inbound.js";
23
+ /** The most candidate addresses one message is matched by. */ export const CRM_INBOUND_CANDIDATE_MAX = 6;
24
+ /** The most sites whose leads are consulted for one message. */ export const CRM_INBOUND_LEAD_HOST_MAX = 50;
25
+ /**
26
+ * The org's capture token, minted the first time anybody asks and kept
27
+ * thereafter; with `rotate`, replaced. A transaction, so two members
28
+ * opening the settings page at once mint one token and not two, and a
29
+ * rotation lands on the token the reader saw.
30
+ */ export async function ensureCrmInboundToken(firestore, orgId, options = {}) {
31
+ var _options_nowMs;
32
+ const rotate = options.rotate === true;
33
+ const nowMs = (_options_nowMs = options.nowMs) != null ? _options_nowMs : Date.now();
34
+ const orgRef = firestore.collection('orgs').doc(orgId);
35
+ return firestore.runTransaction(async (tx)=>{
36
+ var _snapshot_get;
37
+ const snapshot = await tx.get(orgRef);
38
+ if (!snapshot.exists) throw new Error(`[crm-inbound] unknown org ${orgId}`);
39
+ const current = (_snapshot_get = snapshot.get('crmInbound')) != null ? _snapshot_get : null;
40
+ const held = isCrmInboundToken(current == null ? void 0 : current.token);
41
+ if (held && !rotate) {
42
+ const createdAtMs = Number(current == null ? void 0 : current.createdAtMs);
43
+ return _extends({
44
+ token: String(current == null ? void 0 : current.token),
45
+ createdAtMs: Number.isFinite(createdAtMs) && createdAtMs > 0 ? createdAtMs : nowMs
46
+ }, typeof (current == null ? void 0 : current.rotatedAtMs) === 'number' ? {
47
+ rotatedAtMs: current.rotatedAtMs
48
+ } : {}, {
49
+ rotated: false
50
+ });
51
+ }
52
+ const createdAtMs = held && Number.isFinite(Number(current == null ? void 0 : current.createdAtMs)) && Number(current == null ? void 0 : current.createdAtMs) > 0 ? Number(current == null ? void 0 : current.createdAtMs) : nowMs;
53
+ const next = _extends({
54
+ token: mintCrmInboundToken(),
55
+ createdAtMs
56
+ }, held ? {
57
+ rotatedAtMs: nowMs
58
+ } : {});
59
+ tx.update(orgRef, {
60
+ crmInbound: next
61
+ });
62
+ return _extends({}, next, {
63
+ rotated: held
64
+ });
65
+ });
66
+ }
67
+ /**
68
+ * The org a capture token names, or `null`. One equality on
69
+ * `crmInbound.token`, which Firestore indexes on its own; a token that is
70
+ * not one by shape is refused before the query, so a malformed local part
71
+ * costs no read.
72
+ */ export async function findOrgByCrmInboundToken(firestore, token) {
73
+ var _doc_data;
74
+ if (!isCrmInboundToken(token)) return null;
75
+ const page = await firestore.collection('orgs').where('crmInbound.token', '==', token).limit(1).get();
76
+ const doc = page.docs[0];
77
+ if (!doc) return null;
78
+ return {
79
+ orgId: doc.id,
80
+ org: _extends({
81
+ $id: doc.id
82
+ }, (_doc_data = doc.data()) != null ? _doc_data : {})
83
+ };
84
+ }
85
+ /**
86
+ * Every site of the organization, by id, up to the ceiling — the set a
87
+ * message's lead lookup sweeps. One equality on `hosts.orgId`.
88
+ */ export async function crmInboundHostIds(firestore, orgId) {
89
+ const page = await firestore.collection('hosts').where('orgId', '==', orgId).limit(CRM_INBOUND_LEAD_HOST_MAX).get();
90
+ return page.docs.map((doc)=>String(doc.id));
91
+ }
92
+ /**
93
+ * The first candidate that is a contact of the org — through the address
94
+ * index, so a merged record's alternate address answers — or a lead on any
95
+ * of its sites. Candidates are tried in the order the rule gave them and
96
+ * the first hit wins; a contact is looked for before a lead at each
97
+ * address, because a converted lead's address is its contact's.
98
+ */ export async function matchCrmInboundCorrespondent(firestore, input) {
99
+ const { orgId, org } = input;
100
+ const orgRef = firestore.collection('orgs').doc(orgId);
101
+ const contacts = orgRef.collection('contacts');
102
+ const tokensOf = (record)=>{
103
+ const raw = record['visibleTo'];
104
+ return Array.isArray(raw) ? raw.map((token)=>String(token)) : [];
105
+ };
106
+ for (const candidate of input.candidates.slice(0, CRM_INBOUND_CANDIDATE_MAX)){
107
+ const contact = await findContactByEmail(contacts, candidate.email);
108
+ if (contact) {
109
+ var _contact_data, _record_hostId;
110
+ const record = (_contact_data = contact.data()) != null ? _contact_data : {};
111
+ const hostId = String((_record_hostId = record['hostId']) != null ? _record_hostId : '').trim() || contactCaptureHostIds(record)[0] || '';
112
+ const link = {
113
+ contactId: contact.id
114
+ };
115
+ if (hostId) {
116
+ // The company the capturing site files this person under, so the
117
+ // row shows on the company's log as a sent email's does.
118
+ const group = consentGroupForHost(org, hostId);
119
+ const facet = readContactFacet(record, group.groupId);
120
+ if (facet.companyId) link.companyId = facet.companyId;
121
+ }
122
+ return {
123
+ email: candidate.email,
124
+ direction: candidate.direction,
125
+ kind: 'contact',
126
+ link,
127
+ hostId,
128
+ visibleTo: tokensOf(record)
129
+ };
130
+ }
131
+ const key = personKey(candidate.email);
132
+ if (!key) continue;
133
+ /*
134
+ * ONE ROW, then which of the named sites may see it (AGL-3275).
135
+ *
136
+ * This used to read a lead per site because there was one per site. There
137
+ * is one now, so the read is single and the LOOP is what it always really
138
+ * was: deciding whose mailbox this reply belongs to. A site that cannot
139
+ * see the row is not that site, which is what `visibleToHost` answers —
140
+ * and skipping it is what keeps an agency's clients apart on a shared row.
141
+ */ const lead = await orgRef.collection('leads').doc(key).get();
142
+ for (const hostId of input.hostIds.slice(0, CRM_INBOUND_LEAD_HOST_MAX)){
143
+ var _lead_get;
144
+ if (!lead.exists) break;
145
+ if (!visibleToHost(lead.get('visibleTo'), hostId)) continue;
146
+ const link = {
147
+ leadId: lead.id
148
+ };
149
+ // A converted lead's mail belongs on the contact it became as well.
150
+ const converted = String((_lead_get = lead.get('convertedContactId')) != null ? _lead_get : '').trim();
151
+ if (converted) link.contactId = converted;
152
+ return {
153
+ email: candidate.email,
154
+ direction: candidate.direction,
155
+ kind: 'lead',
156
+ link,
157
+ hostId,
158
+ // A lead carries no tokens of its own: the site's scope, as every
159
+ // record created from that site is stamped.
160
+ visibleTo: crmScopeTokens(org, consentGroupForHost(org, hostId))
161
+ };
162
+ }
163
+ }
164
+ return null;
165
+ }
166
+ /**
167
+ * Files one received message on the record it was with (AGL-2657).
168
+ *
169
+ * The order is the cost: the candidate rule is arithmetic; the match is
170
+ * the reads that decide everything after it; a message filed before is
171
+ * answered off one read of the row it would write, before the ceiling's
172
+ * aggregate; the ceiling comes last and refuses rather than dropping a row
173
+ * on a full log. The write is a `create`, so two deliveries racing past
174
+ * the read still yield one row. The BODY never leaves this function except
175
+ * as the bounded excerpt in the row.
176
+ */ export async function fileCrmInboundEmail(firestore, input) {
177
+ var _ref, _message_to_map_find, _ref1, _ref2;
178
+ const { orgId, org, message, domain } = input;
179
+ const plain = (message.text.trim() ? message.text : htmlToPlainText(message.html)).replace(/\r\n?/g, '\n');
180
+ const forwarded = forwardedSection(plain);
181
+ const { sender, senderIsMember, candidates } = crmInboundCandidates({
182
+ from: message.from,
183
+ to: [
184
+ ...message.to,
185
+ ...message.receivedFor
186
+ ],
187
+ cc: [
188
+ ...message.cc,
189
+ ...message.bcc
190
+ ],
191
+ forwardedFrom: (_ref = forwarded == null ? void 0 : forwarded.from) != null ? _ref : undefined,
192
+ domain,
193
+ members: input.members
194
+ });
195
+ const senderDomain = emailDomainOf(message.from);
196
+ if (!candidates.length) return {
197
+ outcome: 'unmatched',
198
+ senderDomain
199
+ };
200
+ const match = await matchCrmInboundCorrespondent(firestore, {
201
+ orgId,
202
+ org,
203
+ candidates,
204
+ hostIds: input.hostIds
205
+ });
206
+ if (!match) return {
207
+ outcome: 'unmatched',
208
+ senderDomain
209
+ };
210
+ const key = crmCapturedEmailKey(message.messageId, message.id);
211
+ const ref = key ? crmCapturedEmailActivityRef(firestore, orgId, key) : newCrmActivityRef(firestore, orgId);
212
+ if (key) {
213
+ const existing = await ref.get();
214
+ if (existing.exists) return {
215
+ outcome: 'duplicate',
216
+ activityId: ref.id
217
+ };
218
+ }
219
+ const orgRef = firestore.collection('orgs').doc(orgId);
220
+ if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, match.link))) {
221
+ return {
222
+ outcome: 'ceiling',
223
+ match
224
+ };
225
+ }
226
+ // The member the From is — by sign-in address, or by an alias exactly one
227
+ // member confirmed. A shared alias names the team and no one person.
228
+ const author = senderIsMember ? findMemberByEmailAddress(input.members, sender) : null;
229
+ // The address the message was written to, when it is worth showing: the
230
+ // correspondent for a copied send, else the first recipient that is not
231
+ // the capture address.
232
+ const to = match.direction === 'outbound' ? match.email : (_message_to_map_find = message.to.map((value)=>emailAddressOf(value)).find((value)=>value && !isCrmInboundAddress(value, domain))) != null ? _message_to_map_find : null;
233
+ const activity = buildCrmCapturedEmailActivity({
234
+ direction: match.direction,
235
+ subject: message.subject,
236
+ excerpt: emailExcerpt(message.text, message.html),
237
+ from: sender != null ? sender : message.from,
238
+ to,
239
+ messageId: message.messageId || message.id,
240
+ inReplyTo: message.inReplyTo,
241
+ atMs: message.receivedAtMs,
242
+ byUid: (_ref1 = author == null ? void 0 : author.uid) != null ? _ref1 : '',
243
+ byName: (_ref2 = author == null ? void 0 : author.name) != null ? _ref2 : null,
244
+ link: match.link,
245
+ hostId: match.hostId,
246
+ visibleTo: match.visibleTo
247
+ });
248
+ const created = await createCrmEmailActivity(ref, activity);
249
+ return created === 'created' ? {
250
+ outcome: 'filed',
251
+ activityId: ref.id,
252
+ match
253
+ } : {
254
+ outcome: 'duplicate',
255
+ activityId: ref.id
256
+ };
257
+ }
258
+
259
+ //# sourceMappingURL=crm-inbound-email.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/crm-inbound-email.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 {\n consentGroupForHost,\n contactCaptureHostIds,\n crmActivityLogHasRoom,\n type CrmActivityLink,\n type CrmEmailDirection,\n crmScopeTokens,\n emailAddressOf,\n emailDomainOf,\n emailExcerpt,\n findMemberByEmailAddress,\n forwardedSection,\n htmlToPlainText,\n personKey,\n readContactFacet,\n type RosterMember,\n} from '@aglyn/aglyn/server'\nimport type { ReceivedEmail } from '@aglyn/shared-util-email'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\nimport {\n createCrmEmailActivity,\n crmCapturedEmailActivityRef,\n newCrmActivityRef,\n} from '@aglyn/tenant-data-admin/server/crm-email-activity'\nimport { countCrmActivitiesForRecord } from '@aglyn/tenant-data-admin/server/crm-records'\nimport {\n buildCrmCapturedEmailActivity,\n crmCapturedEmailKey,\n type CrmInboundCandidate,\n crmInboundCandidates,\n isCrmInboundAddress,\n isCrmInboundToken,\n mintCrmInboundToken,\n} from '../model/crm-inbound'\n\n/**\n * EMAIL CAPTURE (AGL-2657): the half with Firestore.\n *\n * The org's capture token — minted once, replaced on a rotation, found\n * again from a message's recipient — and the filing of one received\n * message: which record it was with, whether it has been filed before, and\n * the row it becomes. The pure rules are `@aglyn/aglyn`'s\n * `crm-inbound.ts`; this module is the reads and the one write.\n *\n * Firestore is a parameter throughout, as in `crm-records.ts` and\n * `crm-email-activity.ts`: the routes hold one, and a module that reached\n * for the Admin app itself would drag it into every spec that only wants\n * the matching. The roster is read through that same store by\n * `loadMemberAddressRoster`, and the caller writes the org's feed line, so a\n * spec of this module needs a store and nothing else.\n */\n\n/**\n * The workspace's email capture address, as the org document carries it\n * under `crmInbound` (AGL-2657): `crm+<token>@<capture domain>`.\n *\n * One token per organization, minted lazily by `crm/inbound-address` the\n * first time a member asks for the address, and replaced by the same route\n * on a rotation. Every site of the organization shares it: a captured\n * message is filed on the record that matches its correspondent, and the\n * record says which site it belongs to, so the address need not.\n *\n * The CRM's field, not the platform's: the organization type does not name\n * it, and this module is its only reader and writer. Server-owned and\n * member-readable: the token IS the address, so a client that could write\n * it could point another workspace's mail here.\n */\nexport interface OrgCrmInbound {\n /** The url-safe secret in the local part; `crm+<token>@…`. */\n token: string\n /** When the first token was minted, epoch ms. */\n createdAtMs: number\n /** When the token was last replaced, epoch ms; absent until it has been. */\n rotatedAtMs?: number\n}\n\n/** The most candidate addresses one message is matched by. */\nexport const CRM_INBOUND_CANDIDATE_MAX = 6\n\n/** The most sites whose leads are consulted for one message. */\nexport const CRM_INBOUND_LEAD_HOST_MAX = 50\n\nexport interface CrmInboundTokenResult {\n token: string\n createdAtMs: number\n rotatedAtMs?: number\n /** True when this call replaced a token that existed. */\n rotated: boolean\n}\n\n/**\n * The org's capture token, minted the first time anybody asks and kept\n * thereafter; with `rotate`, replaced. A transaction, so two members\n * opening the settings page at once mint one token and not two, and a\n * rotation lands on the token the reader saw.\n */\nexport async function ensureCrmInboundToken(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n options: { rotate?: boolean; nowMs?: number } = {},\n): Promise<CrmInboundTokenResult> {\n const rotate = options.rotate === true\n const nowMs = options.nowMs ?? Date.now()\n const orgRef = firestore.collection('orgs').doc(orgId)\n return firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(orgRef)\n if (!snapshot.exists) throw new Error(`[crm-inbound] unknown org ${orgId}`)\n const current = (snapshot.get('crmInbound') ?? null) as Partial<OrgCrmInbound> | null\n const held = isCrmInboundToken(current?.token)\n if (held && !rotate) {\n const createdAtMs = Number(current?.createdAtMs)\n return {\n token: String(current?.token),\n createdAtMs: Number.isFinite(createdAtMs) && createdAtMs > 0 ? createdAtMs : nowMs,\n ...(typeof current?.rotatedAtMs === 'number' ? { rotatedAtMs: current.rotatedAtMs } : {}),\n rotated: false,\n }\n }\n const createdAtMs =\n held && Number.isFinite(Number(current?.createdAtMs)) && Number(current?.createdAtMs) > 0\n ? Number(current?.createdAtMs)\n : nowMs\n const next: OrgCrmInbound = {\n token: mintCrmInboundToken(),\n createdAtMs,\n ...(held ? { rotatedAtMs: nowMs } : {}),\n }\n tx.update(orgRef, { crmInbound: next })\n return { ...next, rotated: held }\n })\n}\n\n/**\n * The org a capture token names, or `null`. One equality on\n * `crmInbound.token`, which Firestore indexes on its own; a token that is\n * not one by shape is refused before the query, so a malformed local part\n * costs no read.\n */\nexport async function findOrgByCrmInboundToken(\n firestore: FirebaseFirestore.Firestore,\n token: unknown,\n): Promise<{ orgId: string; org: Record<string, unknown> } | null> {\n if (!isCrmInboundToken(token)) return null\n const page = await firestore\n .collection('orgs')\n .where('crmInbound.token', '==', token)\n .limit(1)\n .get()\n const doc = page.docs[0]\n if (!doc) return null\n return { orgId: doc.id, org: { $id: doc.id, ...(doc.data() ?? {}) } }\n}\n\n/**\n * Every site of the organization, by id, up to the ceiling — the set a\n * message's lead lookup sweeps. One equality on `hosts.orgId`.\n */\nexport async function crmInboundHostIds(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<string[]> {\n const page = await firestore\n .collection('hosts')\n .where('orgId', '==', orgId)\n .limit(CRM_INBOUND_LEAD_HOST_MAX)\n .get()\n return page.docs.map((doc) => String(doc.id))\n}\n\n/** The record one of a message's addresses resolved to. */\nexport interface CrmInboundMatch {\n email: string\n direction: CrmEmailDirection\n kind: 'contact' | 'lead'\n /** Where the row is filed. */\n link: CrmActivityLink\n /** The site the row is stamped with — the record's own. */\n hostId: string\n /** Who may list the row — the record's own tokens. */\n visibleTo: string[]\n}\n\n/**\n * The first candidate that is a contact of the org — through the address\n * index, so a merged record's alternate address answers — or a lead on any\n * of its sites. Candidates are tried in the order the rule gave them and\n * the first hit wins; a contact is looked for before a lead at each\n * address, because a converted lead's address is its contact's.\n */\nexport async function matchCrmInboundCorrespondent(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n org: Record<string, unknown>\n candidates: readonly CrmInboundCandidate[]\n hostIds: readonly string[]\n },\n): Promise<CrmInboundMatch | null> {\n const { orgId, org } = input\n const orgRef = firestore.collection('orgs').doc(orgId)\n const contacts = orgRef.collection('contacts')\n const tokensOf = (record: Record<string, unknown>): string[] => {\n const raw = record['visibleTo']\n return Array.isArray(raw) ? raw.map((token) => String(token)) : []\n }\n for (const candidate of input.candidates.slice(0, CRM_INBOUND_CANDIDATE_MAX)) {\n const contact = await findContactByEmail(contacts, candidate.email)\n if (contact) {\n const record = (contact.data() ?? {}) as Record<string, unknown>\n const hostId =\n String(record['hostId'] ?? '').trim() || contactCaptureHostIds(record)[0] || ''\n const link: CrmActivityLink = { contactId: contact.id }\n if (hostId) {\n // The company the capturing site files this person under, so the\n // row shows on the company's log as a sent email's does.\n const group = consentGroupForHost(org, hostId)\n const facet = readContactFacet(record, group.groupId)\n if (facet.companyId) link.companyId = facet.companyId\n }\n return {\n email: candidate.email,\n direction: candidate.direction,\n kind: 'contact',\n link,\n hostId,\n visibleTo: tokensOf(record),\n }\n }\n const key = personKey(candidate.email)\n if (!key) continue\n /*\n * ONE ROW, then which of the named sites may see it (AGL-3275).\n *\n * This used to read a lead per site because there was one per site. There\n * is one now, so the read is single and the LOOP is what it always really\n * was: deciding whose mailbox this reply belongs to. A site that cannot\n * see the row is not that site, which is what `visibleToHost` answers —\n * and skipping it is what keeps an agency's clients apart on a shared row.\n */\n const lead = await orgRef.collection('leads').doc(key).get()\n for (const hostId of input.hostIds.slice(0, CRM_INBOUND_LEAD_HOST_MAX)) {\n if (!lead.exists) break\n if (!visibleToHost(lead.get('visibleTo') as string[] | undefined, hostId)) continue\n const link: CrmActivityLink = { leadId: lead.id }\n // A converted lead's mail belongs on the contact it became as well.\n const converted = String(lead.get('convertedContactId') ?? '').trim()\n if (converted) link.contactId = converted\n return {\n email: candidate.email,\n direction: candidate.direction,\n kind: 'lead',\n link,\n hostId,\n // A lead carries no tokens of its own: the site's scope, as every\n // record created from that site is stamped.\n visibleTo: crmScopeTokens(org, consentGroupForHost(org, hostId)),\n }\n }\n }\n return null\n}\n\nexport type CrmInboundFileOutcome =\n /** A row was written. */\n | { outcome: 'filed'; activityId: string; match: CrmInboundMatch }\n /** The message had been filed before; nothing written. */\n | { outcome: 'duplicate'; activityId: string }\n /** No address on the message is a record of the org; nothing written. */\n | { outcome: 'unmatched'; senderDomain: string | null }\n /** The record's activity log is at its ceiling; nothing written. */\n | { outcome: 'ceiling'; match: CrmInboundMatch }\n\n/**\n * Files one received message on the record it was with (AGL-2657).\n *\n * The order is the cost: the candidate rule is arithmetic; the match is\n * the reads that decide everything after it; a message filed before is\n * answered off one read of the row it would write, before the ceiling's\n * aggregate; the ceiling comes last and refuses rather than dropping a row\n * on a full log. The write is a `create`, so two deliveries racing past\n * the read still yield one row. The BODY never leaves this function except\n * as the bounded excerpt in the row.\n */\nexport async function fileCrmInboundEmail(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n org: Record<string, unknown>\n message: ReceivedEmail\n /** The capture domain, so the capture address is never the correspondent. */\n domain: string\n /** The roster, from `loadMemberAddressRoster`: sign-in addresses and confirmed aliases. */\n members: readonly RosterMember[]\n hostIds: readonly string[]\n },\n): Promise<CrmInboundFileOutcome> {\n const { orgId, org, message, domain } = input\n const plain = (message.text.trim() ? message.text : htmlToPlainText(message.html)).replace(\n /\\r\\n?/g,\n '\\n',\n )\n const forwarded = forwardedSection(plain)\n const { sender, senderIsMember, candidates } = crmInboundCandidates({\n from: message.from,\n to: [...message.to, ...message.receivedFor],\n cc: [...message.cc, ...message.bcc],\n forwardedFrom: forwarded?.from ?? undefined,\n domain,\n members: input.members,\n })\n const senderDomain = emailDomainOf(message.from)\n if (!candidates.length) return { outcome: 'unmatched', senderDomain }\n\n const match = await matchCrmInboundCorrespondent(firestore, {\n orgId,\n org,\n candidates,\n hostIds: input.hostIds,\n })\n if (!match) return { outcome: 'unmatched', senderDomain }\n\n const key = crmCapturedEmailKey(message.messageId, message.id)\n const ref = key\n ? crmCapturedEmailActivityRef(firestore, orgId, key)\n : newCrmActivityRef(firestore, orgId)\n if (key) {\n const existing = await ref.get()\n if (existing.exists) return { outcome: 'duplicate', activityId: ref.id }\n }\n\n const orgRef = firestore.collection('orgs').doc(orgId)\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, match.link))) {\n return { outcome: 'ceiling', match }\n }\n\n // The member the From is — by sign-in address, or by an alias exactly one\n // member confirmed. A shared alias names the team and no one person.\n const author = senderIsMember ? findMemberByEmailAddress(input.members, sender) : null\n // The address the message was written to, when it is worth showing: the\n // correspondent for a copied send, else the first recipient that is not\n // the capture address.\n const to =\n match.direction === 'outbound'\n ? match.email\n : (message.to\n .map((value) => emailAddressOf(value))\n .find((value) => value && !isCrmInboundAddress(value, domain)) ?? null)\n const activity = buildCrmCapturedEmailActivity({\n direction: match.direction,\n subject: message.subject,\n excerpt: emailExcerpt(message.text, message.html),\n from: sender ?? message.from,\n to,\n messageId: message.messageId || message.id,\n inReplyTo: message.inReplyTo,\n atMs: message.receivedAtMs,\n byUid: author?.uid ?? '',\n byName: author?.name ?? null,\n link: match.link,\n hostId: match.hostId,\n visibleTo: match.visibleTo,\n })\n const created = await createCrmEmailActivity(ref, activity)\n return created === 'created'\n ? { outcome: 'filed', activityId: ref.id, match }\n : { outcome: 'duplicate', activityId: ref.id }\n}\n"],"names":["consentGroupForHost","contactCaptureHostIds","crmActivityLogHasRoom","crmScopeTokens","emailAddressOf","emailDomainOf","emailExcerpt","findMemberByEmailAddress","forwardedSection","htmlToPlainText","personKey","readContactFacet","visibleToHost","findContactByEmail","createCrmEmailActivity","crmCapturedEmailActivityRef","newCrmActivityRef","countCrmActivitiesForRecord","buildCrmCapturedEmailActivity","crmCapturedEmailKey","crmInboundCandidates","isCrmInboundAddress","isCrmInboundToken","mintCrmInboundToken","CRM_INBOUND_CANDIDATE_MAX","CRM_INBOUND_LEAD_HOST_MAX","ensureCrmInboundToken","firestore","orgId","options","rotate","nowMs","Date","now","orgRef","collection","doc","runTransaction","tx","snapshot","get","exists","Error","current","held","token","createdAtMs","Number","String","isFinite","rotatedAtMs","rotated","next","update","crmInbound","findOrgByCrmInboundToken","page","where","limit","docs","id","org","$id","data","crmInboundHostIds","map","matchCrmInboundCorrespondent","input","contacts","tokensOf","record","raw","Array","isArray","candidate","candidates","slice","contact","email","hostId","trim","link","contactId","group","facet","groupId","companyId","direction","kind","visibleTo","key","lead","hostIds","leadId","converted","fileCrmInboundEmail","message","domain","plain","text","html","replace","forwarded","sender","senderIsMember","from","to","receivedFor","cc","bcc","forwardedFrom","undefined","members","senderDomain","length","outcome","match","messageId","ref","existing","activityId","author","value","find","activity","subject","excerpt","inReplyTo","atMs","receivedAtMs","byUid","uid","byName","name","created"],"mappings":"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"}
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { PluginUsageMeterContext, PluginUsageMeterReading } from '@aglyn/aglyn/plugin-manager/plugin-usage-meters';
18
+ /**
19
+ * The CRM's month in the usage sweep: the records band (AGL-890, widened in
20
+ * AGL-2611) — contacts, companies and deals, counted together across the
21
+ * workspace and measured against the ONE band `contactsPerHost` names.
22
+ *
23
+ * ## A stock, read at the end of the month it bills (AGL-2399)
24
+ *
25
+ * Records are a LEVEL, not a total: the count read now describes now. The
26
+ * closed-month sweep runs after the month is over, so it bills the last
27
+ * reading taken INSIDE the month — the period-end stamp each in-progress
28
+ * sweep writes — and falls back to measuring only when no such reading
29
+ * exists. The in-progress sweep stamps its own reading as the period-end one,
30
+ * and a closed sweep never stamps, so every re-run of a closed month reads
31
+ * the same input and bills the same amount.
32
+ *
33
+ * The records trio is resolved as a unit, so `contactsCount + companiesCount
34
+ * + dealsCount === crmRecordsCount` on every row. A closed month with a
35
+ * contacts reading but no records reading was measured on contacts alone,
36
+ * before the band was widened, and bills the other two as nothing — the
37
+ * permissive direction, and the only one the period-end rule permits. The
38
+ * persisted names still say "contacts" because every reader of the rollup
39
+ * keys on them; the quantity behind them is the records band.
40
+ *
41
+ * ## Withheld while the CRM is dark (AGL-1604)
42
+ *
43
+ * `release_crm` gates the console CRM. Records still accrue while it is off —
44
+ * capture and `GET /v1/contacts` keep running — and nobody may be charged for
45
+ * what they cannot reach, so the overage is withheld rather than billed, and
46
+ * recorded as withheld so the month is legible. The COUNT is written always:
47
+ * it is an entitlement input, and only the figure that reaches the invoice
48
+ * moves with the flag.
49
+ */
50
+ export declare function measureCrmRecords(context: PluginUsageMeterContext): Promise<PluginUsageMeterReading>;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { checkCrmRecordsQuota } from "@aglyn/aglyn/app-utils/plan-entitlements";
17
+ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
18
+ /**
19
+ * The CRM's month in the usage sweep: the records band (AGL-890, widened in
20
+ * AGL-2611) — contacts, companies and deals, counted together across the
21
+ * workspace and measured against the ONE band `contactsPerHost` names.
22
+ *
23
+ * ## A stock, read at the end of the month it bills (AGL-2399)
24
+ *
25
+ * Records are a LEVEL, not a total: the count read now describes now. The
26
+ * closed-month sweep runs after the month is over, so it bills the last
27
+ * reading taken INSIDE the month — the period-end stamp each in-progress
28
+ * sweep writes — and falls back to measuring only when no such reading
29
+ * exists. The in-progress sweep stamps its own reading as the period-end one,
30
+ * and a closed sweep never stamps, so every re-run of a closed month reads
31
+ * the same input and bills the same amount.
32
+ *
33
+ * The records trio is resolved as a unit, so `contactsCount + companiesCount
34
+ * + dealsCount === crmRecordsCount` on every row. A closed month with a
35
+ * contacts reading but no records reading was measured on contacts alone,
36
+ * before the band was widened, and bills the other two as nothing — the
37
+ * permissive direction, and the only one the period-end rule permits. The
38
+ * persisted names still say "contacts" because every reader of the rollup
39
+ * keys on them; the quantity behind them is the records band.
40
+ *
41
+ * ## Withheld while the CRM is dark (AGL-1604)
42
+ *
43
+ * `release_crm` gates the console CRM. Records still accrue while it is off —
44
+ * capture and `GET /v1/contacts` keep running — and nobody may be charged for
45
+ * what they cannot reach, so the overage is withheld rather than billed, and
46
+ * recorded as withheld so the month is legible. The COUNT is written always:
47
+ * it is an entitlement input, and only the figure that reaches the invoice
48
+ * moves with the flag.
49
+ */ export async function measureCrmRecords(context) {
50
+ var _contactsSnap_data_count, _companiesSnap_data_count, _dealsSnap_data_count;
51
+ const orgRef = firebaseAdmin.app().firestore().collection('orgs').doc(context.orgId);
52
+ // Three aggregate counts, one per collection the band counts. Tasks and
53
+ // activities are not counted, deliberately.
54
+ const [contactsSnap, companiesSnap, dealsSnap] = await Promise.all([
55
+ orgRef.collection('contacts').count().get(),
56
+ orgRef.collection('companies').count().get(),
57
+ orgRef.collection('deals').count().get()
58
+ ]);
59
+ const contactsCountAtSweep = Number((_contactsSnap_data_count = contactsSnap.data().count) != null ? _contactsSnap_data_count : 0);
60
+ const companiesCountAtSweep = Number((_companiesSnap_data_count = companiesSnap.data().count) != null ? _companiesSnap_data_count : 0);
61
+ const dealsCountAtSweep = Number((_dealsSnap_data_count = dealsSnap.data().count) != null ? _dealsSnap_data_count : 0);
62
+ // The band's own figure: the three summed, measured at the same instant, so
63
+ // the total and its parts describe one moment.
64
+ const crmRecordsCountAtSweep = contactsCountAtSweep + companiesCountAtSweep + dealsCountAtSweep;
65
+ /**
66
+ * A reading an earlier sweep stamped, or `null` when there is none to
67
+ * trust. A missing field, a NaN or a negative falls back to measuring and
68
+ * never bills as zero: billing zero on a malformed field would forfeit the
69
+ * overage silently and look exactly like a customer inside the band.
70
+ */ const stamped = (field)=>{
71
+ const value = Number(context.previous[field]);
72
+ return Number.isFinite(value) && value >= 0 ? value : null;
73
+ };
74
+ const contactsAtPeriodEnd = stamped('contactsCountAtPeriodEnd');
75
+ const crmRecordsAtPeriodEnd = stamped('crmRecordsCountAtPeriodEnd');
76
+ const companiesAtPeriodEnd = stamped('companiesCountAtPeriodEnd');
77
+ const dealsAtPeriodEnd = stamped('dealsCountAtPeriodEnd');
78
+ const { closed } = context;
79
+ const contactsCount = closed && contactsAtPeriodEnd !== null ? contactsAtPeriodEnd : contactsCountAtSweep;
80
+ const [crmRecordsCount, companiesCount, dealsCount] = closed && crmRecordsAtPeriodEnd !== null ? [
81
+ crmRecordsAtPeriodEnd,
82
+ companiesAtPeriodEnd != null ? companiesAtPeriodEnd : 0,
83
+ dealsAtPeriodEnd != null ? dealsAtPeriodEnd : 0
84
+ ] : closed && contactsAtPeriodEnd !== null ? [
85
+ contactsAtPeriodEnd,
86
+ 0,
87
+ 0
88
+ ] : [
89
+ crmRecordsCountAtSweep,
90
+ companiesCountAtSweep,
91
+ dealsCountAtSweep
92
+ ];
93
+ // The band's verdict on the SUM, unchanged by the flag — the count and the
94
+ // quota are what entitlement resolution reads, and only the figure that
95
+ // reaches the invoice moves.
96
+ const quota = checkCrmRecordsQuota(context.org, crmRecordsCount);
97
+ const billed = context.releaseFlagOn('release_crm');
98
+ const overageUsd = billed ? quota.overageMonthlyUsd : 0;
99
+ return {
100
+ fields: {
101
+ contactsCount,
102
+ crmRecordsCount,
103
+ companiesCount,
104
+ dealsCount,
105
+ // What this run measured, beside the billed basis: the difference is
106
+ // how much a month moved by being measured inside it.
107
+ contactsCountAtSweep,
108
+ crmRecordsCountAtSweep,
109
+ contactsOverageUsd: overageUsd,
110
+ contactsOverageBilled: billed,
111
+ contactsOverageWithheldUsd: billed ? 0 : quota.overageMonthlyUsd
112
+ },
113
+ // Stamped together, so a closed sweep reads the total and its parts back
114
+ // as one unit taken on one day.
115
+ periodEndFields: {
116
+ contactsCountAtPeriodEnd: contactsCountAtSweep,
117
+ crmRecordsCountAtPeriodEnd: crmRecordsCountAtSweep,
118
+ companiesCountAtPeriodEnd: companiesCountAtSweep,
119
+ dealsCountAtPeriodEnd: dealsCountAtSweep
120
+ },
121
+ billedUsd: overageUsd,
122
+ periodEndBasis: closed && (contactsAtPeriodEnd !== null || crmRecordsAtPeriodEnd !== null)
123
+ };
124
+ }
125
+
126
+ //# sourceMappingURL=crm-records-meter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/crm-records-meter.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 { checkCrmRecordsQuota } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport type {\n PluginUsageMeterContext,\n PluginUsageMeterReading,\n} from '@aglyn/aglyn/plugin-manager/plugin-usage-meters'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\n\n/**\n * The CRM's month in the usage sweep: the records band (AGL-890, widened in\n * AGL-2611) — contacts, companies and deals, counted together across the\n * workspace and measured against the ONE band `contactsPerHost` names.\n *\n * ## A stock, read at the end of the month it bills (AGL-2399)\n *\n * Records are a LEVEL, not a total: the count read now describes now. The\n * closed-month sweep runs after the month is over, so it bills the last\n * reading taken INSIDE the month — the period-end stamp each in-progress\n * sweep writes — and falls back to measuring only when no such reading\n * exists. The in-progress sweep stamps its own reading as the period-end one,\n * and a closed sweep never stamps, so every re-run of a closed month reads\n * the same input and bills the same amount.\n *\n * The records trio is resolved as a unit, so `contactsCount + companiesCount\n * + dealsCount === crmRecordsCount` on every row. A closed month with a\n * contacts reading but no records reading was measured on contacts alone,\n * before the band was widened, and bills the other two as nothing — the\n * permissive direction, and the only one the period-end rule permits. The\n * persisted names still say \"contacts\" because every reader of the rollup\n * keys on them; the quantity behind them is the records band.\n *\n * ## Withheld while the CRM is dark (AGL-1604)\n *\n * `release_crm` gates the console CRM. Records still accrue while it is off —\n * capture and `GET /v1/contacts` keep running — and nobody may be charged for\n * what they cannot reach, so the overage is withheld rather than billed, and\n * recorded as withheld so the month is legible. The COUNT is written always:\n * it is an entitlement input, and only the figure that reaches the invoice\n * moves with the flag.\n */\nexport async function measureCrmRecords(\n context: PluginUsageMeterContext,\n): Promise<PluginUsageMeterReading> {\n const orgRef = firebaseAdmin.app().firestore().collection('orgs').doc(context.orgId)\n // Three aggregate counts, one per collection the band counts. Tasks and\n // activities are not counted, deliberately.\n const [contactsSnap, companiesSnap, dealsSnap] = await Promise.all([\n orgRef.collection('contacts').count().get(),\n orgRef.collection('companies').count().get(),\n orgRef.collection('deals').count().get(),\n ])\n const contactsCountAtSweep = Number(contactsSnap.data().count ?? 0)\n const companiesCountAtSweep = Number(companiesSnap.data().count ?? 0)\n const dealsCountAtSweep = Number(dealsSnap.data().count ?? 0)\n // The band's own figure: the three summed, measured at the same instant, so\n // the total and its parts describe one moment.\n const crmRecordsCountAtSweep =\n contactsCountAtSweep + companiesCountAtSweep + dealsCountAtSweep\n\n /**\n * A reading an earlier sweep stamped, or `null` when there is none to\n * trust. A missing field, a NaN or a negative falls back to measuring and\n * never bills as zero: billing zero on a malformed field would forfeit the\n * overage silently and look exactly like a customer inside the band.\n */\n const stamped = (field: string): number | null => {\n const value = Number(context.previous[field])\n return Number.isFinite(value) && value >= 0 ? value : null\n }\n const contactsAtPeriodEnd = stamped('contactsCountAtPeriodEnd')\n const crmRecordsAtPeriodEnd = stamped('crmRecordsCountAtPeriodEnd')\n const companiesAtPeriodEnd = stamped('companiesCountAtPeriodEnd')\n const dealsAtPeriodEnd = stamped('dealsCountAtPeriodEnd')\n\n const { closed } = context\n const contactsCount =\n closed && contactsAtPeriodEnd !== null ? contactsAtPeriodEnd : contactsCountAtSweep\n const [crmRecordsCount, companiesCount, dealsCount] =\n closed && crmRecordsAtPeriodEnd !== null\n ? [crmRecordsAtPeriodEnd, companiesAtPeriodEnd ?? 0, dealsAtPeriodEnd ?? 0]\n : closed && contactsAtPeriodEnd !== null\n ? [contactsAtPeriodEnd, 0, 0]\n : [crmRecordsCountAtSweep, companiesCountAtSweep, dealsCountAtSweep]\n\n // The band's verdict on the SUM, unchanged by the flag — the count and the\n // quota are what entitlement resolution reads, and only the figure that\n // reaches the invoice moves.\n const quota = checkCrmRecordsQuota(context.org as never, crmRecordsCount)\n const billed = context.releaseFlagOn('release_crm')\n const overageUsd = billed ? quota.overageMonthlyUsd : 0\n\n return {\n fields: {\n contactsCount,\n crmRecordsCount,\n companiesCount,\n dealsCount,\n // What this run measured, beside the billed basis: the difference is\n // how much a month moved by being measured inside it.\n contactsCountAtSweep,\n crmRecordsCountAtSweep,\n contactsOverageUsd: overageUsd,\n contactsOverageBilled: billed,\n contactsOverageWithheldUsd: billed ? 0 : quota.overageMonthlyUsd,\n },\n // Stamped together, so a closed sweep reads the total and its parts back\n // as one unit taken on one day.\n periodEndFields: {\n contactsCountAtPeriodEnd: contactsCountAtSweep,\n crmRecordsCountAtPeriodEnd: crmRecordsCountAtSweep,\n companiesCountAtPeriodEnd: companiesCountAtSweep,\n dealsCountAtPeriodEnd: dealsCountAtSweep,\n },\n billedUsd: overageUsd,\n periodEndBasis:\n closed && (contactsAtPeriodEnd !== null || crmRecordsAtPeriodEnd !== null),\n }\n}\n"],"names":["checkCrmRecordsQuota","firebaseAdmin","measureCrmRecords","context","contactsSnap","companiesSnap","dealsSnap","orgRef","app","firestore","collection","doc","orgId","Promise","all","count","get","contactsCountAtSweep","Number","data","companiesCountAtSweep","dealsCountAtSweep","crmRecordsCountAtSweep","stamped","field","value","previous","isFinite","contactsAtPeriodEnd","crmRecordsAtPeriodEnd","companiesAtPeriodEnd","dealsAtPeriodEnd","closed","contactsCount","crmRecordsCount","companiesCount","dealsCount","quota","org","billed","releaseFlagOn","overageUsd","overageMonthlyUsd","fields","contactsOverageUsd","contactsOverageBilled","contactsOverageWithheldUsd","periodEndFields","contactsCountAtPeriodEnd","crmRecordsCountAtPeriodEnd","companiesCountAtPeriodEnd","dealsCountAtPeriodEnd","billedUsd","periodEndBasis"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,oBAAoB,QAAQ,2CAA0C;AAK/E,SAASC,aAAa,QAAQ,2BAA0B;AAExD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+BC,GACD,OAAO,eAAeC,kBACpBC,OAAgC;QAUIC,0BACCC,2BACJC;IAVjC,MAAMC,SAASN,cAAcO,GAAG,GAAGC,SAAS,GAAGC,UAAU,CAAC,QAAQC,GAAG,CAACR,QAAQS,KAAK;IACnF,wEAAwE;IACxE,4CAA4C;IAC5C,MAAM,CAACR,cAAcC,eAAeC,UAAU,GAAG,MAAMO,QAAQC,GAAG,CAAC;QACjEP,OAAOG,UAAU,CAAC,YAAYK,KAAK,GAAGC,GAAG;QACzCT,OAAOG,UAAU,CAAC,aAAaK,KAAK,GAAGC,GAAG;QAC1CT,OAAOG,UAAU,CAAC,SAASK,KAAK,GAAGC,GAAG;KACvC;IACD,MAAMC,uBAAuBC,QAAOd,2BAAAA,aAAae,IAAI,GAAGJ,KAAK,YAAzBX,2BAA6B;IACjE,MAAMgB,wBAAwBF,QAAOb,4BAAAA,cAAcc,IAAI,GAAGJ,KAAK,YAA1BV,4BAA8B;IACnE,MAAMgB,oBAAoBH,QAAOZ,wBAAAA,UAAUa,IAAI,GAAGJ,KAAK,YAAtBT,wBAA0B;IAC3D,4EAA4E;IAC5E,+CAA+C;IAC/C,MAAMgB,yBACJL,uBAAuBG,wBAAwBC;IAEjD;;;;;GAKC,GACD,MAAME,UAAU,CAACC;QACf,MAAMC,QAAQP,OAAOf,QAAQuB,QAAQ,CAACF,MAAM;QAC5C,OAAON,OAAOS,QAAQ,CAACF,UAAUA,SAAS,IAAIA,QAAQ;IACxD;IACA,MAAMG,sBAAsBL,QAAQ;IACpC,MAAMM,wBAAwBN,QAAQ;IACtC,MAAMO,uBAAuBP,QAAQ;IACrC,MAAMQ,mBAAmBR,QAAQ;IAEjC,MAAM,EAAES,MAAM,EAAE,GAAG7B;IACnB,MAAM8B,gBACJD,UAAUJ,wBAAwB,OAAOA,sBAAsBX;IACjE,MAAM,CAACiB,iBAAiBC,gBAAgBC,WAAW,GACjDJ,UAAUH,0BAA0B,OAChC;QAACA;QAAuBC,+BAAAA,uBAAwB;QAAGC,2BAAAA,mBAAoB;KAAE,GACzEC,UAAUJ,wBAAwB,OAChC;QAACA;QAAqB;QAAG;KAAE,GAC3B;QAACN;QAAwBF;QAAuBC;KAAkB;IAE1E,2EAA2E;IAC3E,wEAAwE;IACxE,6BAA6B;IAC7B,MAAMgB,QAAQrC,qBAAqBG,QAAQmC,GAAG,EAAWJ;IACzD,MAAMK,SAASpC,QAAQqC,aAAa,CAAC;IACrC,MAAMC,aAAaF,SAASF,MAAMK,iBAAiB,GAAG;IAEtD,OAAO;QACLC,QAAQ;YACNV;YACAC;YACAC;YACAC;YACA,qEAAqE;YACrE,sDAAsD;YACtDnB;YACAK;YACAsB,oBAAoBH;YACpBI,uBAAuBN;YACvBO,4BAA4BP,SAAS,IAAIF,MAAMK,iBAAiB;QAClE;QACA,yEAAyE;QACzE,gCAAgC;QAChCK,iBAAiB;YACfC,0BAA0B/B;YAC1BgC,4BAA4B3B;YAC5B4B,2BAA2B9B;YAC3B+B,uBAAuB9B;QACzB;QACA+B,WAAWX;QACXY,gBACErB,UAAWJ,CAAAA,wBAAwB,QAAQC,0BAA0B,IAAG;IAC5E;AACF"}
@@ -14,6 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
+ import { digestTimeZone } from '../model/crm-digest';
17
18
  /**
18
19
  * The daily CRM digest (AGL-2619): `POST /api/crm/daily-digest`.
19
20
  *
@@ -96,12 +97,5 @@ export declare const CRM_DIGEST_MARKER_COLLECTION = "crmDigests";
96
97
  * line for the same reason.
97
98
  */
98
99
  export declare const CRM_DIGEST_EMAIL_MAX_UID_LOOKUPS = 5;
99
- /**
100
- * The zone the digest's "today" is drawn in.
101
- *
102
- * `CRM_DIGEST_TIME_ZONE` overrides the default for a self-hosted console
103
- * whose team wakes up elsewhere; a value `Intl` does not know falls back to
104
- * the default with a warning rather than throwing the whole run away.
105
- */
106
- export declare function digestTimeZone(env?: NodeJS.ProcessEnv): string;
100
+ export { digestTimeZone };
107
101
  export declare function crmDailyDigestRoute(request: Request): Promise<Response>;
@@ -15,7 +15,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */ import { buildRoute, CRM_COLLECTIONS, DIGEST_PREFS_FIELD, digestEnabled, hostRoleFor, isReleaseFlagOnForOrg, notificationMuted, parseOrgReleaseFlagOverrides, pluginRequestFromWeb, resolveBrandingProfile, resolveEffectivePlan, resolveOrgEntitlements, Route } from "@aglyn/aglyn/server";
18
- import { buildMemberDigests, composeCrmDigestEmailText, composeCrmDigestSubject, composeCrmDigestSummary, CRM_DIGEST_DEFAULT_TIME_ZONE, CRM_DIGEST_LEAD_AGE_MS, CRM_DIGEST_LEAD_WINDOW, CRM_DIGEST_TASK_CEILING, crmDigestCounts, crmDigestEntitled, crmDigestWindow, isUnworkedLead, utcDayKey } from "../model/crm-digest.js";
18
+ import { buildMemberDigests, composeCrmDigestEmailText, composeCrmDigestSubject, composeCrmDigestSummary, CRM_DIGEST_LEAD_AGE_MS, CRM_DIGEST_LEAD_WINDOW, CRM_DIGEST_TASK_CEILING, crmDigestCounts, crmDigestEntitled, crmDigestWindow, digestTimeZone, isNurturingLead, isUnworkedLead, utcDayKey } from "../model/crm-digest.js";
19
19
  import { rateLimitedRetryAtMs, sendEmail } from "@aglyn/shared-util-email";
20
20
  import { withoutMailWithheldAccounts } from "@aglyn/tenant-data-admin/server/account-mail";
21
21
  import { findUserByUidAcrossPools, firebaseAdmin, getServerReleaseFlagValues, listOrgMembers, memberHasOrgPermission, meterPlatformEmail, notifyUsers } from "@aglyn/tenant-data-admin";
@@ -119,26 +119,9 @@ import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
119
119
  * denormalized copy is normally present, and `orgAdminEmails` draws the same
120
120
  * line for the same reason.
121
121
  */ export const CRM_DIGEST_EMAIL_MAX_UID_LOOKUPS = 5;
122
- /**
123
- * The zone the digest's "today" is drawn in.
124
- *
125
- * `CRM_DIGEST_TIME_ZONE` overrides the default for a self-hosted console
126
- * whose team wakes up elsewhere; a value `Intl` does not know falls back to
127
- * the default with a warning rather than throwing the whole run away.
128
- */ export function digestTimeZone(env = process.env) {
129
- var _env_CRM_DIGEST_TIME_ZONE;
130
- const configured = String((_env_CRM_DIGEST_TIME_ZONE = env['CRM_DIGEST_TIME_ZONE']) != null ? _env_CRM_DIGEST_TIME_ZONE : '').trim();
131
- if (!configured) return CRM_DIGEST_DEFAULT_TIME_ZONE;
132
- try {
133
- new Intl.DateTimeFormat('en-US', {
134
- timeZone: configured
135
- });
136
- return configured;
137
- } catch (unused) {
138
- console.warn(`crm-daily-digest: CRM_DIGEST_TIME_ZONE "${configured}" is not a zone — ` + `using ${CRM_DIGEST_DEFAULT_TIME_ZONE}`);
139
- return CRM_DIGEST_DEFAULT_TIME_ZONE;
140
- }
141
- }
122
+ // The zone lives with the digest's other pure judgments, where the task
123
+ // routes read it too; re-exported for the callers that name it here.
124
+ export { digestTimeZone };
142
125
  /**
143
126
  * Every host owned by an org, paged by document id — the shape
144
127
  * `usage-alerts` reads hosts with, at a lower ceiling because each host here
@@ -174,7 +157,7 @@ import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
174
157
  return hostId ? `/${hostId}/crm/${section}` : `/org/crm/${section}`;
175
158
  }
176
159
  async function digestOrg(ctx, orgDoc) {
177
- var _orgDoc_data, _marker_get, _org_slug;
160
+ var _orgDoc_data, _marker_get, _org_slug, _org_name;
178
161
  const { firestore, nowMs, day, timeZone, dryRun } = ctx;
179
162
  const orgId = orgDoc.id;
180
163
  const org = (_orgDoc_data = orgDoc.data()) != null ? _orgDoc_data : {};
@@ -225,6 +208,8 @@ async function digestOrg(ctx, orgDoc) {
225
208
  ]));
226
209
  const leadCutoff = nowMs - CRM_DIGEST_LEAD_AGE_MS;
227
210
  const leads = [];
211
+ // Read from the same window, and counted, not listed (AGL-3446).
212
+ const nurturing = [];
228
213
  for (const host of hosts.docs){
229
214
  /*
230
215
  * Still per site, but over the ORG collection narrowed to what that site
@@ -236,8 +221,10 @@ async function digestOrg(ctx, orgDoc) {
236
221
  for (const doc of page.docs){
237
222
  var _data_email, _data_name, _data_ownerUid;
238
223
  const data = doc.data();
239
- if (!isUnworkedLead(data, nowMs)) continue;
240
- leads.push({
224
+ const fields = data;
225
+ const into = isUnworkedLead(fields, nowMs) ? leads : isNurturingLead(fields, nowMs) ? nurturing : null;
226
+ if (!into) continue;
227
+ into.push({
241
228
  id: doc.id,
242
229
  hostId: host.id,
243
230
  email: String((_data_email = data['email']) != null ? _data_email : ''),
@@ -262,6 +249,7 @@ async function digestOrg(ctx, orgDoc) {
262
249
  const digests = buildMemberDigests({
263
250
  tasks,
264
251
  leads,
252
+ nurturing,
265
253
  window,
266
254
  members: members.map((member)=>({
267
255
  uid: member.$id,
@@ -286,6 +274,7 @@ async function digestOrg(ctx, orgDoc) {
286
274
  ]));
287
275
  const branding = resolveBrandingProfile(org);
288
276
  const orgSlug = String((_org_slug = org['slug']) != null ? _org_slug : '');
277
+ const workspaceName = String((_org_name = org['name']) != null ? _org_name : '').trim() || orgSlug;
289
278
  const hostName = (hostId)=>{
290
279
  const host = hostById.get(hostId);
291
280
  return String((host == null ? void 0 : host.get('name')) || (host == null ? void 0 : host.get('subdomain')) || hostId);
@@ -387,6 +376,7 @@ async function digestOrg(ctx, orgDoc) {
387
376
  nowMs,
388
377
  timeZone,
389
378
  productName: branding.productName,
379
+ workspaceName,
390
380
  tasksUrl: hub ? `${hub}/tasks` : ctx.origin,
391
381
  leadsUrl: (hostId)=>{
392
382
  var _hubUrl;
@@ -396,7 +386,7 @@ async function digestOrg(ctx, orgDoc) {
396
386
  hostName,
397
387
  supportLine
398
388
  };
399
- const subject = composeCrmDigestSubject(counts);
389
+ const subject = composeCrmDigestSubject(counts, workspaceName);
400
390
  // The `crm-daily-digest` system email (AGL-3367), in the org's brand,
401
391
  // read once per org and rendered per member.
402
392
  digestEmail != null ? digestEmail : digestEmail = loadSystemEmail('crm-daily-digest').catch(()=>null);