@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
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
leads
|
|
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);
|