@aglyn/plugins-crm 1.0.0-beta.229 → 1.0.0-beta.231
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/package.json +14 -13
- package/src/lib/components/crm-next-activity-column.d.ts +1 -13
- package/src/lib/components/crm-next-activity-column.js +1 -19
- package/src/lib/components/crm-next-activity-column.js.map +1 -1
- package/src/lib/components/lead-status-options.d.ts +2 -14
- package/src/lib/components/lead-status-options.js +1 -13
- package/src/lib/components/lead-status-options.js.map +1 -1
- package/src/lib/components/lead-unqualify-dialog.d.ts +1 -2
- package/src/lib/components/lead-unqualify-dialog.js +2 -1
- package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
- package/src/lib/components/settings-section.d.ts +18 -0
- package/src/lib/components/settings-section.js +97 -1
- package/src/lib/components/settings-section.js.map +1 -1
- package/src/lib/constants/company-filters.d.ts +2 -2
- package/src/lib/constants/company-filters.js +1 -1
- package/src/lib/constants/company-filters.js.map +1 -1
- package/src/lib/constants/contact-filters.d.ts +3 -3
- package/src/lib/constants/contact-filters.js +2 -1
- package/src/lib/constants/contact-filters.js.map +1 -1
- package/src/lib/constants/deal-filters.d.ts +3 -3
- package/src/lib/constants/deal-filters.js +1 -1
- package/src/lib/constants/deal-filters.js.map +1 -1
- package/src/lib/constants/field-list-query.d.ts +1 -1
- package/src/lib/constants/field-list-query.js +1 -1
- package/src/lib/constants/field-list-query.js.map +1 -1
- package/src/lib/declarations.server.js +9 -0
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/model/contacts-list-seed.d.ts +1 -1
- package/src/lib/model/contacts-list-seed.js.map +1 -1
- package/src/lib/model/crm-list-query.d.ts +4 -4
- package/src/lib/model/crm-list-query.js +2 -2
- package/src/lib/model/crm-list-query.js.map +1 -1
- package/src/lib/model/crm-next-activity.d.ts +13 -0
- package/src/lib/model/crm-next-activity.js +19 -0
- package/src/lib/model/crm-next-activity.js.map +1 -1
- package/src/lib/model/lead-filters.d.ts +6 -5
- package/src/lib/model/lead-filters.js +3 -2
- package/src/lib/model/lead-filters.js.map +1 -1
- package/src/lib/model/lead-status-choices.d.ts +32 -0
- package/src/lib/model/lead-status-choices.js +31 -0
- package/src/lib/model/lead-status-choices.js.map +1 -0
- package/src/lib/model/task-views.d.ts +2 -2
- package/src/lib/model/task-views.js.map +1 -1
- package/src/lib/server/deal-figures.d.ts +47 -0
- package/src/lib/server/deal-figures.js +275 -0
- package/src/lib/server/deal-figures.js.map +1 -0
- package/src/lib/server/person-records.d.ts +27 -0
- package/src/lib/server/person-records.js +127 -4
- package/src/lib/server/person-records.js.map +1 -1
- package/src/lib/server.d.ts +0 -38
- package/src/lib/server.js +5 -1
- package/src/lib/server.js.map +1 -1
|
@@ -0,0 +1,275 @@
|
|
|
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 { isPipelineArchived } from "@aglyn/aglyn/app-utils/crm";
|
|
17
|
+
import { PLUGIN_FIGURE_MAX_ROWS, pluginFigureChange, pluginFigureWindows, registerPluginFigureReader } from "@aglyn/aglyn/plugin-manager/plugin-figures";
|
|
18
|
+
import { scopedToHost } from "@aglyn/tenant-data-admin/server/organizations";
|
|
19
|
+
import { BUNDLE_ID } from "../constants/bundle-common.js";
|
|
20
|
+
import { currencyOfDeals, pipelineTotals } from "../model/crm-reports.js";
|
|
21
|
+
/** Open deals the pipeline reads; a larger pipeline is told its figures read low. */ export const DEAL_FIGURES_OPEN_CEILING = 1000;
|
|
22
|
+
/** Deals one window reads of each outcome. */ export const DEAL_FIGURES_CLOSED_CEILING = 1000;
|
|
23
|
+
/** Pipelines read. */ export const DEAL_FIGURES_PIPELINE_CEILING = 20;
|
|
24
|
+
const WINDOWS = [
|
|
25
|
+
7,
|
|
26
|
+
14,
|
|
27
|
+
30,
|
|
28
|
+
90
|
|
29
|
+
];
|
|
30
|
+
const major = (cents)=>Math.round(cents) / 100;
|
|
31
|
+
function deals(firestore, orgId, hostId) {
|
|
32
|
+
const collection = firestore.collection('orgs').doc(orgId).collection('deals');
|
|
33
|
+
return hostId ? scopedToHost(collection, hostId) : collection;
|
|
34
|
+
}
|
|
35
|
+
async function readOpenDeals(firestore, orgId, hostId) {
|
|
36
|
+
const snapshot = await deals(firestore, orgId, hostId).where('status', '==', 'open').orderBy('updatedAt', 'desc').select('status', 'stageId', 'pipelineId', 'amountCents', 'currency', 'probability').limit(DEAL_FIGURES_OPEN_CEILING + 1).get();
|
|
37
|
+
return {
|
|
38
|
+
rows: snapshot.docs.slice(0, DEAL_FIGURES_OPEN_CEILING).map((doc)=>{
|
|
39
|
+
var _doc_data;
|
|
40
|
+
return (_doc_data = doc.data()) != null ? _doc_data : {};
|
|
41
|
+
}),
|
|
42
|
+
truncated: snapshot.docs.length > DEAL_FIGURES_OPEN_CEILING
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
async function readPipelines(firestore, orgId, hostId) {
|
|
46
|
+
const collection = firestore.collection('orgs').doc(orgId).collection('pipelines');
|
|
47
|
+
const snapshot = await (hostId ? scopedToHost(collection, hostId) : collection).limit(DEAL_FIGURES_PIPELINE_CEILING).get();
|
|
48
|
+
return snapshot.docs.map((doc)=>{
|
|
49
|
+
var _doc_data;
|
|
50
|
+
return {
|
|
51
|
+
id: doc.id,
|
|
52
|
+
pipeline: (_doc_data = doc.data()) != null ? _doc_data : {}
|
|
53
|
+
};
|
|
54
|
+
}).filter(({ pipeline })=>!isPipelineArchived(pipeline));
|
|
55
|
+
}
|
|
56
|
+
export function crmPipelineFigureReader(firestore) {
|
|
57
|
+
return {
|
|
58
|
+
id: 'crm.pipeline',
|
|
59
|
+
label: 'Deal pipeline',
|
|
60
|
+
description: 'The open deals as they stand, by pipeline and stage: how many, what they are worth, and what they are worth at each stage’s odds. Current totals, with no window.',
|
|
61
|
+
scope: 'org',
|
|
62
|
+
windows: [],
|
|
63
|
+
feature: 'crm',
|
|
64
|
+
read: async (request)=>{
|
|
65
|
+
const db = firestore();
|
|
66
|
+
const [open, pipelines] = await Promise.all([
|
|
67
|
+
readOpenDeals(db, request.orgId, request.hostId),
|
|
68
|
+
readPipelines(db, request.orgId, request.hostId)
|
|
69
|
+
]);
|
|
70
|
+
const { currency, mixed } = currencyOfDeals(open.rows);
|
|
71
|
+
const code = currency.toUpperCase();
|
|
72
|
+
const rows = [];
|
|
73
|
+
let count = 0;
|
|
74
|
+
let amount = 0;
|
|
75
|
+
let weighted = 0;
|
|
76
|
+
let placed = 0;
|
|
77
|
+
for (const { id, pipeline } of pipelines){
|
|
78
|
+
const totals = pipelineTotals(open.rows.filter((deal)=>deal.pipelineId === id), pipeline);
|
|
79
|
+
count += totals.count;
|
|
80
|
+
amount += totals.amountCents;
|
|
81
|
+
weighted += totals.weightedCents;
|
|
82
|
+
for (const stage of totals.stages){
|
|
83
|
+
placed += 1;
|
|
84
|
+
rows.push({
|
|
85
|
+
pipeline: pipeline.name || 'Untitled pipeline',
|
|
86
|
+
stage: stage.stage.name || 'Untitled stage',
|
|
87
|
+
deals: stage.count,
|
|
88
|
+
value: major(stage.amountCents),
|
|
89
|
+
weighted: major(stage.weightedCents)
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const kept = rows.slice(0, PLUGIN_FIGURE_MAX_ROWS - 1);
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
table: {
|
|
97
|
+
title: 'Deal pipeline',
|
|
98
|
+
source: {
|
|
99
|
+
label: 'CRM reports',
|
|
100
|
+
path: 'crm/reports'
|
|
101
|
+
},
|
|
102
|
+
period: null,
|
|
103
|
+
columns: [
|
|
104
|
+
{
|
|
105
|
+
key: 'pipeline',
|
|
106
|
+
label: 'Pipeline',
|
|
107
|
+
kind: 'text'
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
key: 'stage',
|
|
111
|
+
label: 'Stage',
|
|
112
|
+
kind: 'text'
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
key: 'deals',
|
|
116
|
+
label: 'Open deals',
|
|
117
|
+
kind: 'count'
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
key: 'value',
|
|
121
|
+
label: 'Value',
|
|
122
|
+
kind: 'money',
|
|
123
|
+
currency: code
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
key: 'weighted',
|
|
127
|
+
label: 'Weighted value',
|
|
128
|
+
kind: 'money',
|
|
129
|
+
currency: code
|
|
130
|
+
}
|
|
131
|
+
],
|
|
132
|
+
rows: [
|
|
133
|
+
{
|
|
134
|
+
pipeline: 'All pipelines',
|
|
135
|
+
stage: 'All open stages',
|
|
136
|
+
deals: count,
|
|
137
|
+
value: major(amount),
|
|
138
|
+
weighted: major(weighted)
|
|
139
|
+
},
|
|
140
|
+
...kept
|
|
141
|
+
],
|
|
142
|
+
omitted: Math.max(0, placed - kept.length),
|
|
143
|
+
notes: [
|
|
144
|
+
'Only open deals: a won deal is revenue and a lost one is history.',
|
|
145
|
+
...mixed ? [
|
|
146
|
+
`Deals are in more than one currency; every amount is added as ${code}.`
|
|
147
|
+
] : [],
|
|
148
|
+
...open.truncated ? [
|
|
149
|
+
`More than ${DEAL_FIGURES_OPEN_CEILING.toLocaleString('en-US')} deals are open, so the figures count the most recently updated ${DEAL_FIGURES_OPEN_CEILING.toLocaleString('en-US')} and read low.`
|
|
150
|
+
] : []
|
|
151
|
+
].slice(0, 3)
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
async function readClosed(firestore, orgId, hostId, status, startMs, endMs) {
|
|
158
|
+
const snapshot = await deals(firestore, orgId, hostId).where('status', '==', status).where('closedAtMs', '>=', startMs).where('closedAtMs', '<', endMs).orderBy('closedAtMs', 'asc').select('amountCents', 'currency').limit(DEAL_FIGURES_CLOSED_CEILING + 1).get();
|
|
159
|
+
const docs = snapshot.docs.slice(0, DEAL_FIGURES_CLOSED_CEILING).map((doc)=>{
|
|
160
|
+
var _doc_data;
|
|
161
|
+
return (_doc_data = doc.data()) != null ? _doc_data : {};
|
|
162
|
+
});
|
|
163
|
+
return {
|
|
164
|
+
count: docs.length,
|
|
165
|
+
amountCents: docs.reduce((sum, deal)=>{
|
|
166
|
+
var _deal_amountCents;
|
|
167
|
+
return sum + Math.max(0, Math.round(Number((_deal_amountCents = deal.amountCents) != null ? _deal_amountCents : 0) || 0));
|
|
168
|
+
}, 0),
|
|
169
|
+
currencies: docs,
|
|
170
|
+
truncated: snapshot.docs.length > DEAL_FIGURES_CLOSED_CEILING
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
export function crmClosedDealsFigureReader(firestore) {
|
|
174
|
+
return {
|
|
175
|
+
id: 'crm.closed',
|
|
176
|
+
label: 'Deals won and lost',
|
|
177
|
+
description: 'Deals closed over the window: how many were won and lost, what was won, the win rate, and the change in deals won from the window before.',
|
|
178
|
+
scope: 'org',
|
|
179
|
+
windows: WINDOWS,
|
|
180
|
+
feature: 'crm',
|
|
181
|
+
read: async (request)=>{
|
|
182
|
+
if (!WINDOWS.includes(request.days)) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
status: 400,
|
|
186
|
+
error: 'That window is not one closed deals are read over'
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const db = firestore();
|
|
190
|
+
const { current, previous } = pluginFigureWindows(request.now, request.days);
|
|
191
|
+
const [won, lost, wonBefore] = await Promise.all([
|
|
192
|
+
readClosed(db, request.orgId, request.hostId, 'won', current.startMs, current.endMs),
|
|
193
|
+
readClosed(db, request.orgId, request.hostId, 'lost', current.startMs, current.endMs),
|
|
194
|
+
readClosed(db, request.orgId, request.hostId, 'won', previous.startMs, previous.endMs)
|
|
195
|
+
]);
|
|
196
|
+
const { currency, mixed } = currencyOfDeals(won.currencies);
|
|
197
|
+
const code = currency.toUpperCase();
|
|
198
|
+
const closed = won.count + lost.count;
|
|
199
|
+
return {
|
|
200
|
+
ok: true,
|
|
201
|
+
table: {
|
|
202
|
+
title: 'Deals won and lost',
|
|
203
|
+
source: {
|
|
204
|
+
label: 'CRM reports',
|
|
205
|
+
path: 'crm/reports'
|
|
206
|
+
},
|
|
207
|
+
period: {
|
|
208
|
+
from: current.from,
|
|
209
|
+
to: current.to,
|
|
210
|
+
days: request.days
|
|
211
|
+
},
|
|
212
|
+
columns: [
|
|
213
|
+
{
|
|
214
|
+
key: 'won',
|
|
215
|
+
label: 'Won',
|
|
216
|
+
kind: 'count'
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
key: 'lost',
|
|
220
|
+
label: 'Lost',
|
|
221
|
+
kind: 'count'
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
key: 'wonValue',
|
|
225
|
+
label: 'Value won',
|
|
226
|
+
kind: 'money',
|
|
227
|
+
currency: code
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
key: 'winRate',
|
|
231
|
+
label: 'Win rate',
|
|
232
|
+
kind: 'percent'
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
key: 'change',
|
|
236
|
+
label: 'Change in deals won',
|
|
237
|
+
kind: 'change'
|
|
238
|
+
}
|
|
239
|
+
],
|
|
240
|
+
rows: [
|
|
241
|
+
{
|
|
242
|
+
won: won.count,
|
|
243
|
+
lost: lost.count,
|
|
244
|
+
wonValue: major(won.amountCents),
|
|
245
|
+
winRate: closed > 0 ? Math.round(won.count / closed * 1000) / 10 : null,
|
|
246
|
+
change: pluginFigureChange(won.count, wonBefore.count)
|
|
247
|
+
}
|
|
248
|
+
],
|
|
249
|
+
omitted: 0,
|
|
250
|
+
notes: [
|
|
251
|
+
'A deal is counted on the day it closed.',
|
|
252
|
+
...mixed ? [
|
|
253
|
+
`Deals won are in more than one currency; every amount is added as ${code}.`
|
|
254
|
+
] : [],
|
|
255
|
+
...won.truncated || lost.truncated || wonBefore.truncated ? [
|
|
256
|
+
`More than ${DEAL_FIGURES_CLOSED_CEILING.toLocaleString('en-US')} deals closed one way in a window, so the figures count ${DEAL_FIGURES_CLOSED_CEILING.toLocaleString('en-US')} and read low.`
|
|
257
|
+
] : []
|
|
258
|
+
].slice(0, 3)
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
/** Registers the deal readers from the console surface, where insight jobs run. */ export function registerDealFigureReaders(firestore) {
|
|
265
|
+
for (const reader of [
|
|
266
|
+
crmPipelineFigureReader(firestore),
|
|
267
|
+
crmClosedDealsFigureReader(firestore)
|
|
268
|
+
]){
|
|
269
|
+
registerPluginFigureReader(reader, {
|
|
270
|
+
pluginId: BUNDLE_ID
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
//# sourceMappingURL=deal-figures.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/deal-figures.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 { isPipelineArchived, type CrmDeal, type CrmPipeline } from '@aglyn/aglyn/app-utils/crm'\nimport {\n PLUGIN_FIGURE_MAX_ROWS,\n pluginFigureChange,\n pluginFigureWindows,\n registerPluginFigureReader,\n type PluginFigureReader,\n type PluginFigureRow,\n} from '@aglyn/aglyn/plugin-manager/plugin-figures'\nimport { scopedToHost } from '@aglyn/tenant-data-admin/server/organizations'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { currencyOfDeals, pipelineTotals } from '../model/crm-reports'\n\n/**\n * The organization's deals as figure tables another plugin reads by id\n * (AGL-3603) — the AI plugin's insights answer \"how much is in the pipeline?\"\n * and \"what did we win this month?\" from them.\n *\n * - `crm.pipeline`: the open pipeline as it stands, by pipeline and stage —\n * how many deals, what they are worth, and what they are worth at their\n * stage's odds — with `pipelineTotals`, the Reports page's own arithmetic.\n * - `crm.closed`: deals won and lost over the window, what was won, the win\n * rate, and the change in won deals from the window before.\n *\n * Organization readers: with a site, only the deals shared with that site —\n * `scopedToHost`, the predicate the rules evaluate — and with none, the\n * workspace's. Each read is a Firestore query on `status` (and `closedAtMs`\n * for the window), served by the `deals` composites the index file already\n * carries, projected to the fields counted and capped; a deal's title, its\n * contact and its company never reach a table.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** Open deals the pipeline reads; a larger pipeline is told its figures read low. */\nexport const DEAL_FIGURES_OPEN_CEILING = 1_000\n\n/** Deals one window reads of each outcome. */\nexport const DEAL_FIGURES_CLOSED_CEILING = 1_000\n\n/** Pipelines read. */\nexport const DEAL_FIGURES_PIPELINE_CEILING = 20\n\nconst WINDOWS: readonly number[] = [7, 14, 30, 90]\n\ntype DealRow = Pick<CrmDeal, 'status' | 'stageId' | 'pipelineId' | 'amountCents' | 'currency' | 'probability'>\n\nconst major = (cents: number): number => Math.round(cents) / 100\n\nfunction deals(firestore: Firestore, orgId: string, hostId: string | null): FirebaseFirestore.Query {\n const collection = firestore.collection('orgs').doc(orgId).collection('deals')\n return hostId ? scopedToHost(collection, hostId) : collection\n}\n\nasync function readOpenDeals(\n firestore: Firestore,\n orgId: string,\n hostId: string | null,\n): Promise<{ rows: DealRow[]; truncated: boolean }> {\n const snapshot = await deals(firestore, orgId, hostId)\n .where('status', '==', 'open')\n .orderBy('updatedAt', 'desc')\n .select('status', 'stageId', 'pipelineId', 'amountCents', 'currency', 'probability')\n .limit(DEAL_FIGURES_OPEN_CEILING + 1)\n .get()\n return {\n rows: snapshot.docs.slice(0, DEAL_FIGURES_OPEN_CEILING).map((doc) => (doc.data() ?? {}) as DealRow),\n truncated: snapshot.docs.length > DEAL_FIGURES_OPEN_CEILING,\n }\n}\n\nasync function readPipelines(\n firestore: Firestore,\n orgId: string,\n hostId: string | null,\n): Promise<Array<{ id: string; pipeline: CrmPipeline }>> {\n const collection = firestore.collection('orgs').doc(orgId).collection('pipelines')\n const snapshot = await (hostId ? scopedToHost(collection, hostId) : collection)\n .limit(DEAL_FIGURES_PIPELINE_CEILING)\n .get()\n return snapshot.docs\n .map((doc) => ({ id: doc.id, pipeline: (doc.data() ?? {}) as CrmPipeline }))\n .filter(({ pipeline }) => !isPipelineArchived(pipeline))\n}\n\nexport function crmPipelineFigureReader(firestore: () => Firestore): PluginFigureReader {\n return {\n id: 'crm.pipeline',\n label: 'Deal pipeline',\n description:\n 'The open deals as they stand, by pipeline and stage: how many, what they are worth, and what they are worth at each stage’s odds. Current totals, with no window.',\n scope: 'org',\n windows: [],\n feature: 'crm',\n read: async (request) => {\n const db = firestore()\n const [open, pipelines] = await Promise.all([\n readOpenDeals(db, request.orgId, request.hostId),\n readPipelines(db, request.orgId, request.hostId),\n ])\n const { currency, mixed } = currencyOfDeals(open.rows)\n const code = currency.toUpperCase()\n const rows: PluginFigureRow[] = []\n let count = 0\n let amount = 0\n let weighted = 0\n let placed = 0\n for (const { id, pipeline } of pipelines) {\n const totals = pipelineTotals(\n open.rows.filter((deal) => deal.pipelineId === id),\n pipeline,\n )\n count += totals.count\n amount += totals.amountCents\n weighted += totals.weightedCents\n for (const stage of totals.stages) {\n placed += 1\n rows.push({\n pipeline: pipeline.name || 'Untitled pipeline',\n stage: stage.stage.name || 'Untitled stage',\n deals: stage.count,\n value: major(stage.amountCents),\n weighted: major(stage.weightedCents),\n })\n }\n }\n const kept = rows.slice(0, PLUGIN_FIGURE_MAX_ROWS - 1)\n return {\n ok: true,\n table: {\n title: 'Deal pipeline',\n source: { label: 'CRM reports', path: 'crm/reports' },\n period: null,\n columns: [\n { key: 'pipeline', label: 'Pipeline', kind: 'text' },\n { key: 'stage', label: 'Stage', kind: 'text' },\n { key: 'deals', label: 'Open deals', kind: 'count' },\n { key: 'value', label: 'Value', kind: 'money', currency: code },\n { key: 'weighted', label: 'Weighted value', kind: 'money', currency: code },\n ],\n rows: [\n { pipeline: 'All pipelines', stage: 'All open stages', deals: count, value: major(amount), weighted: major(weighted) },\n ...kept,\n ],\n omitted: Math.max(0, placed - kept.length),\n notes: [\n 'Only open deals: a won deal is revenue and a lost one is history.',\n ...(mixed ? [`Deals are in more than one currency; every amount is added as ${code}.`] : []),\n ...(open.truncated\n ? [`More than ${DEAL_FIGURES_OPEN_CEILING.toLocaleString('en-US')} deals are open, so the figures count the most recently updated ${DEAL_FIGURES_OPEN_CEILING.toLocaleString('en-US')} and read low.`]\n : []),\n ].slice(0, 3),\n },\n }\n },\n }\n}\n\ninterface ClosedRead {\n count: number\n amountCents: number\n currencies: Array<Pick<CrmDeal, 'currency'>>\n truncated: boolean\n}\n\nasync function readClosed(\n firestore: Firestore,\n orgId: string,\n hostId: string | null,\n status: 'won' | 'lost',\n startMs: number,\n endMs: number,\n): Promise<ClosedRead> {\n const snapshot = await deals(firestore, orgId, hostId)\n .where('status', '==', status)\n .where('closedAtMs', '>=', startMs)\n .where('closedAtMs', '<', endMs)\n .orderBy('closedAtMs', 'asc')\n .select('amountCents', 'currency')\n .limit(DEAL_FIGURES_CLOSED_CEILING + 1)\n .get()\n const docs = snapshot.docs.slice(0, DEAL_FIGURES_CLOSED_CEILING).map((doc) => (doc.data() ?? {}) as DealRow)\n return {\n count: docs.length,\n amountCents: docs.reduce((sum, deal) => sum + Math.max(0, Math.round(Number(deal.amountCents ?? 0) || 0)), 0),\n currencies: docs,\n truncated: snapshot.docs.length > DEAL_FIGURES_CLOSED_CEILING,\n }\n}\n\nexport function crmClosedDealsFigureReader(firestore: () => Firestore): PluginFigureReader {\n return {\n id: 'crm.closed',\n label: 'Deals won and lost',\n description:\n 'Deals closed over the window: how many were won and lost, what was won, the win rate, and the change in deals won from the window before.',\n scope: 'org',\n windows: WINDOWS,\n feature: 'crm',\n read: async (request) => {\n if (!WINDOWS.includes(request.days)) {\n return { ok: false, status: 400, error: 'That window is not one closed deals are read over' }\n }\n const db = firestore()\n const { current, previous } = pluginFigureWindows(request.now, request.days)\n const [won, lost, wonBefore] = await Promise.all([\n readClosed(db, request.orgId, request.hostId, 'won', current.startMs, current.endMs),\n readClosed(db, request.orgId, request.hostId, 'lost', current.startMs, current.endMs),\n readClosed(db, request.orgId, request.hostId, 'won', previous.startMs, previous.endMs),\n ])\n const { currency, mixed } = currencyOfDeals(won.currencies)\n const code = currency.toUpperCase()\n const closed = won.count + lost.count\n return {\n ok: true,\n table: {\n title: 'Deals won and lost',\n source: { label: 'CRM reports', path: 'crm/reports' },\n period: { from: current.from, to: current.to, days: request.days },\n columns: [\n { key: 'won', label: 'Won', kind: 'count' },\n { key: 'lost', label: 'Lost', kind: 'count' },\n { key: 'wonValue', label: 'Value won', kind: 'money', currency: code },\n { key: 'winRate', label: 'Win rate', kind: 'percent' },\n { key: 'change', label: 'Change in deals won', kind: 'change' },\n ],\n rows: [\n {\n won: won.count,\n lost: lost.count,\n wonValue: major(won.amountCents),\n winRate: closed > 0 ? Math.round((won.count / closed) * 1_000) / 10 : null,\n change: pluginFigureChange(won.count, wonBefore.count),\n },\n ],\n omitted: 0,\n notes: [\n 'A deal is counted on the day it closed.',\n ...(mixed ? [`Deals won are in more than one currency; every amount is added as ${code}.`] : []),\n ...(won.truncated || lost.truncated || wonBefore.truncated\n ? [`More than ${DEAL_FIGURES_CLOSED_CEILING.toLocaleString('en-US')} deals closed one way in a window, so the figures count ${DEAL_FIGURES_CLOSED_CEILING.toLocaleString('en-US')} and read low.`]\n : []),\n ].slice(0, 3),\n },\n }\n },\n }\n}\n\n/** Registers the deal readers from the console surface, where insight jobs run. */\nexport function registerDealFigureReaders(firestore: () => Firestore): void {\n for (const reader of [crmPipelineFigureReader(firestore), crmClosedDealsFigureReader(firestore)]) {\n registerPluginFigureReader(reader, { pluginId: BUNDLE_ID })\n }\n}\n"],"names":["isPipelineArchived","PLUGIN_FIGURE_MAX_ROWS","pluginFigureChange","pluginFigureWindows","registerPluginFigureReader","scopedToHost","BUNDLE_ID","currencyOfDeals","pipelineTotals","DEAL_FIGURES_OPEN_CEILING","DEAL_FIGURES_CLOSED_CEILING","DEAL_FIGURES_PIPELINE_CEILING","WINDOWS","major","cents","Math","round","deals","firestore","orgId","hostId","collection","doc","readOpenDeals","snapshot","where","orderBy","select","limit","get","rows","docs","slice","map","data","truncated","length","readPipelines","id","pipeline","filter","crmPipelineFigureReader","label","description","scope","windows","feature","read","request","db","open","pipelines","Promise","all","currency","mixed","code","toUpperCase","count","amount","weighted","placed","totals","deal","pipelineId","amountCents","weightedCents","stage","stages","push","name","value","kept","ok","table","title","source","path","period","columns","key","kind","omitted","max","notes","toLocaleString","readClosed","status","startMs","endMs","reduce","sum","Number","currencies","crmClosedDealsFigureReader","includes","days","error","current","previous","now","won","lost","wonBefore","closed","from","to","wonValue","winRate","change","registerDealFigureReaders","reader","pluginId"],"mappings":"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"}
|
|
@@ -63,6 +63,33 @@ export interface CrmPersonRecordsDeps {
|
|
|
63
63
|
}>;
|
|
64
64
|
}
|
|
65
65
|
export declare function defaultCrmPersonRecordsDeps(): CrmPersonRecordsDeps;
|
|
66
|
+
/** The most people one search answers, whatever the caller asks for. */
|
|
67
|
+
export declare const CRM_PERSON_SEARCH_MAX = 25;
|
|
68
|
+
/**
|
|
69
|
+
* The one token a typed search asks the contacts' search index for
|
|
70
|
+
* (AGL-3609), or `''` for nothing searchable.
|
|
71
|
+
*
|
|
72
|
+
* A run of digits and phone punctuation is a phone number, read as its
|
|
73
|
+
* digits: `crmPhoneSearchWords` stores the whole number, the number without
|
|
74
|
+
* a country code, its last seven and its last four, so `(555) 123-4567`,
|
|
75
|
+
* `5551234567` and `4567` all find it. Anything else is a name or an
|
|
76
|
+
* address, and the LONGEST word asks — it is the most selective one, and a
|
|
77
|
+
* stored token is a prefix of a word, so `dana@acme.com` and `dana` both
|
|
78
|
+
* reach the contact. Capped at the twelve characters a token keeps.
|
|
79
|
+
*/
|
|
80
|
+
export declare function crmPersonSearchWord(text: unknown): string;
|
|
81
|
+
/**
|
|
82
|
+
* A contacts walk's cursor (AGL-3639): the last contact's `updatedAt` to the
|
|
83
|
+
* nanosecond, and its id for the tie. Opaque to every caller; a millisecond
|
|
84
|
+
* alone would answer a contact written later in the same millisecond twice.
|
|
85
|
+
*/
|
|
86
|
+
export declare function encodeContactChangesCursor(updatedAt: unknown, id: string): string | null;
|
|
87
|
+
/** Reads a cursor this module handed out, or `null` for anything else. */
|
|
88
|
+
export declare function decodeContactChangesCursor(cursor: string | null | undefined): {
|
|
89
|
+
seconds: number;
|
|
90
|
+
nanoseconds: number;
|
|
91
|
+
id: string;
|
|
92
|
+
} | null;
|
|
66
93
|
export declare function createCrmPersonRecords(deps: CrmPersonRecordsDeps): PluginPersonRecords;
|
|
67
94
|
/** The service as the CRM registers it, its storage the admin app's. */
|
|
68
95
|
export declare const crmPersonRecords: Required<PluginPersonRecords>;
|
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ import { containerMembershipField, contactContainerFieldPath, normalizeContainerIds } from "@aglyn/aglyn/app-utils/container-membership";
|
|
17
|
-
import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
|
|
18
|
-
import { CRM_COLLECTIONS, crmLeadStatus, crmViewIsListed, isCrmLeadOpen, normalizeCrmViewFilters } from "@aglyn/aglyn/app-utils/crm";
|
|
17
|
+
import { normalizeContactEmail, readContactFacet } from "@aglyn/aglyn/app-utils/contacts";
|
|
18
|
+
import { CRM_COLLECTIONS, CRM_SCOPED_SEARCH_JOIN, CRM_SCOPED_SEARCH_TOKENS_FIELD, crmLeadStatus, crmViewIsListed, isCrmLeadOpen, normalizeCrmViewFilters } from "@aglyn/aglyn/app-utils/crm";
|
|
19
19
|
import { dynamicListDimensionsForCrmView } from "@aglyn/aglyn/app-utils/dynamic-list-rule";
|
|
20
|
+
import { NAME_TOKEN_MAX_PREFIX, nameSearchKey } from "@aglyn/aglyn/app-utils/name-search";
|
|
20
21
|
import { personKey } from "@aglyn/aglyn/app-utils/person-key";
|
|
21
22
|
import { scopeTokensForHost, seenOnlyThroughGrant, visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
22
23
|
import { consentGroupForSite, firebaseAdmin, resolveOrgIdForHost } from "@aglyn/tenant-data-admin";
|
|
@@ -25,7 +26,7 @@ import { consentGroupForSite, firebaseAdmin, resolveOrgIdForHost } from "@aglyn/
|
|
|
25
26
|
import { findContactByEmail } from "@aglyn/tenant-data-admin/server/contact-email-index";
|
|
26
27
|
import { restampCrmListFieldsAt } from "@aglyn/tenant-data-admin/server/crm-records";
|
|
27
28
|
import { collectDynamicListCandidates } from "@aglyn/tenant-data-admin/server/dynamic-list-materialize";
|
|
28
|
-
import { FieldValue } from "firebase-admin/firestore";
|
|
29
|
+
import { FieldPath, FieldValue, Timestamp } from "firebase-admin/firestore";
|
|
29
30
|
import { recordPersonRefund } from "./person-refund.js";
|
|
30
31
|
/** The person kinds, as `plugin-record-routes` addresses them. */ export const CRM_PERSON_KINDS = {
|
|
31
32
|
contact: 'contact',
|
|
@@ -88,6 +89,55 @@ function personOf(kind, snapshot) {
|
|
|
88
89
|
if (!resolved) throw new Error(`[crm] no organization to find a person in for host ${hostId || '(none)'}`);
|
|
89
90
|
return resolved;
|
|
90
91
|
}
|
|
92
|
+
/** The most people one search answers, whatever the caller asks for. */ export const CRM_PERSON_SEARCH_MAX = 25;
|
|
93
|
+
/**
|
|
94
|
+
* The one token a typed search asks the contacts' search index for
|
|
95
|
+
* (AGL-3609), or `''` for nothing searchable.
|
|
96
|
+
*
|
|
97
|
+
* A run of digits and phone punctuation is a phone number, read as its
|
|
98
|
+
* digits: `crmPhoneSearchWords` stores the whole number, the number without
|
|
99
|
+
* a country code, its last seven and its last four, so `(555) 123-4567`,
|
|
100
|
+
* `5551234567` and `4567` all find it. Anything else is a name or an
|
|
101
|
+
* address, and the LONGEST word asks — it is the most selective one, and a
|
|
102
|
+
* stored token is a prefix of a word, so `dana@acme.com` and `dana` both
|
|
103
|
+
* reach the contact. Capped at the twelve characters a token keeps.
|
|
104
|
+
*/ export function crmPersonSearchWord(text) {
|
|
105
|
+
const raw = typeof text === 'string' ? text.trim() : '';
|
|
106
|
+
if (!raw) return '';
|
|
107
|
+
if (/^[\d\s()+.-]+$/.test(raw)) {
|
|
108
|
+
const digits = raw.replace(/\D/g, '');
|
|
109
|
+
return digits.length >= 4 ? digits.slice(0, NAME_TOKEN_MAX_PREFIX) : '';
|
|
110
|
+
}
|
|
111
|
+
const words = nameSearchKey(raw).split(' ').filter(Boolean);
|
|
112
|
+
const longest = words.reduce((best, word)=>word.length > best.length ? word : best, '');
|
|
113
|
+
return longest.slice(0, NAME_TOKEN_MAX_PREFIX);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* A contacts walk's cursor (AGL-3639): the last contact's `updatedAt` to the
|
|
117
|
+
* nanosecond, and its id for the tie. Opaque to every caller; a millisecond
|
|
118
|
+
* alone would answer a contact written later in the same millisecond twice.
|
|
119
|
+
*/ export function encodeContactChangesCursor(updatedAt, id) {
|
|
120
|
+
var _ref;
|
|
121
|
+
const stamp = updatedAt;
|
|
122
|
+
const seconds = Number(stamp == null ? void 0 : stamp.seconds);
|
|
123
|
+
const nanoseconds = Number((_ref = stamp == null ? void 0 : stamp.nanoseconds) != null ? _ref : 0);
|
|
124
|
+
if (!Number.isFinite(seconds) || !Number.isFinite(nanoseconds) || !id) return null;
|
|
125
|
+
return `${Math.trunc(seconds)}.${Math.trunc(nanoseconds)}.${id}`;
|
|
126
|
+
}
|
|
127
|
+
/** Reads a cursor this module handed out, or `null` for anything else. */ export function decodeContactChangesCursor(cursor) {
|
|
128
|
+
const match = /^(\d{1,12})\.(\d{1,9})\.([A-Za-z0-9_-]{1,200})$/.exec(String(cursor != null ? cursor : ''));
|
|
129
|
+
if (!match) return null;
|
|
130
|
+
return {
|
|
131
|
+
seconds: Number(match[1]),
|
|
132
|
+
nanoseconds: Number(match[2]),
|
|
133
|
+
id: match[3]
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const millisOf = (value)=>{
|
|
137
|
+
const stamp = value;
|
|
138
|
+
return typeof (stamp == null ? void 0 : stamp.toMillis) === 'function' ? stamp.toMillis() : 0;
|
|
139
|
+
};
|
|
140
|
+
const integerOrNull = (value)=>typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : null;
|
|
91
141
|
export function createCrmPersonRecords(deps) {
|
|
92
142
|
return {
|
|
93
143
|
/**
|
|
@@ -284,6 +334,59 @@ export function createCrmPersonRecords(deps) {
|
|
|
284
334
|
};
|
|
285
335
|
},
|
|
286
336
|
/**
|
|
337
|
+
* The contacts a site may see, oldest change first (AGL-3639): the walk a
|
|
338
|
+
* connector copies people out by. On the Contacts list's own index
|
|
339
|
+
* (`visibleTo` array-contains-any, `updatedAt` ascending), with the id as
|
|
340
|
+
* the tie-break. A contact the site sees only through a sharing grant is
|
|
341
|
+
* read and stepped over — another holder's person is not this site's to
|
|
342
|
+
* export — and the cursor still moves past it. The profile is the site's
|
|
343
|
+
* consent group's facet, never another holder's.
|
|
344
|
+
*/ async changedSince (request) {
|
|
345
|
+
var _request_hostId;
|
|
346
|
+
const hostId = String((_request_hostId = request.hostId) != null ? _request_hostId : '').trim();
|
|
347
|
+
if (!hostId) return {
|
|
348
|
+
people: [],
|
|
349
|
+
next: null
|
|
350
|
+
};
|
|
351
|
+
const orgId = await orgOf(deps, request);
|
|
352
|
+
const groupId = await deps.groupIdForHost(hostId);
|
|
353
|
+
const after = decodeContactChangesCursor(request.after);
|
|
354
|
+
let query = deps.firestore().collection('orgs').doc(orgId).collection(STORED_IN[CRM_PERSON_KINDS.contact]).where('visibleTo', 'array-contains-any', scopeTokensForHost(hostId)).orderBy('updatedAt', 'asc').orderBy(FieldPath.documentId(), 'asc');
|
|
355
|
+
if (after) {
|
|
356
|
+
query = query.startAfter(new Timestamp(after.seconds, after.nanoseconds), after.id);
|
|
357
|
+
}
|
|
358
|
+
const snapshot = await query.limit(Math.max(1, Math.min(500, request.limit))).get();
|
|
359
|
+
const people = [];
|
|
360
|
+
let next = null;
|
|
361
|
+
for (const doc of snapshot.docs){
|
|
362
|
+
var _doc_data, _encodeContactChangesCursor;
|
|
363
|
+
const data = (_doc_data = doc.data()) != null ? _doc_data : {};
|
|
364
|
+
next = (_encodeContactChangesCursor = encodeContactChangesCursor(data['updatedAt'], doc.id)) != null ? _encodeContactChangesCursor : next;
|
|
365
|
+
if (seenOnlyThroughGrant(data, hostId)) continue;
|
|
366
|
+
const email = normalizeContactEmail(data['email']);
|
|
367
|
+
if (!email) continue;
|
|
368
|
+
const facet = readContactFacet(data, groupId);
|
|
369
|
+
people.push({
|
|
370
|
+
kind: CRM_PERSON_KINDS.contact,
|
|
371
|
+
id: doc.id,
|
|
372
|
+
email,
|
|
373
|
+
data,
|
|
374
|
+
changedAtMs: millisOf(data['updatedAt']),
|
|
375
|
+
profile: {
|
|
376
|
+
name: typeof facet.name === 'string' && facet.name.trim() ? facet.name.trim() : null,
|
|
377
|
+
phone: typeof facet.phone === 'string' && facet.phone.trim() ? facet.phone.trim() : null,
|
|
378
|
+
tags: Array.isArray(facet.tags) ? facet.tags.filter((tag)=>typeof tag === 'string') : [],
|
|
379
|
+
lifetimeValueCents: integerOrNull(facet.ltvCents),
|
|
380
|
+
ordersCount: integerOrNull(facet.ordersCount)
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
return {
|
|
385
|
+
people,
|
|
386
|
+
next
|
|
387
|
+
};
|
|
388
|
+
},
|
|
389
|
+
/**
|
|
287
390
|
* Whether each person has ever written in: an inbound email on their
|
|
288
391
|
* timeline, filed on the contact, or on the lead while they were one
|
|
289
392
|
* (AGL-3234). One keyed query each; a failed one is unknown.
|
|
@@ -302,6 +405,24 @@ export function createCrmPersonRecords(deps) {
|
|
|
302
405
|
return null;
|
|
303
406
|
}
|
|
304
407
|
}));
|
|
408
|
+
},
|
|
409
|
+
/**
|
|
410
|
+
* Contacts the site may see whose name, address, company or phone
|
|
411
|
+
* starts with the typed word (AGL-3609): ONE query on the scoped search
|
|
412
|
+
* tokens every contact writer stamps, `array-contains-any` over the
|
|
413
|
+
* site's scope tokens joined to the word — the same clause the Contacts
|
|
414
|
+
* list runs under a site, so a record the site cannot see is never
|
|
415
|
+
* answered and no index beyond the field's own is needed.
|
|
416
|
+
*/ async search (request) {
|
|
417
|
+
var _request_hostId;
|
|
418
|
+
const word = crmPersonSearchWord(request.text);
|
|
419
|
+
const hostId = String((_request_hostId = request.hostId) != null ? _request_hostId : '').trim();
|
|
420
|
+
const limit = Math.min(CRM_PERSON_SEARCH_MAX, Math.max(0, Math.floor(Number(request.limit) || 0)));
|
|
421
|
+
if (!word || !hostId || !limit) return [];
|
|
422
|
+
const orgId = await orgOf(deps, request);
|
|
423
|
+
const tokens = scopeTokensForHost(hostId).map((scope)=>`${scope}${CRM_SCOPED_SEARCH_JOIN}${word}`);
|
|
424
|
+
const snapshot = await deps.firestore().collection('orgs').doc(orgId).collection(STORED_IN[CRM_PERSON_KINDS.contact]).where(CRM_SCOPED_SEARCH_TOKENS_FIELD, 'array-contains-any', tokens).limit(limit).get();
|
|
425
|
+
return snapshot.docs.map((doc)=>personOf(CRM_PERSON_KINDS.contact, doc));
|
|
305
426
|
}
|
|
306
427
|
};
|
|
307
428
|
}
|
|
@@ -311,7 +432,9 @@ export function createCrmPersonRecords(deps) {
|
|
|
311
432
|
fileUnder: (request)=>createCrmPersonRecords(defaultCrmPersonRecordsDeps()).fileUnder(request),
|
|
312
433
|
recordRefund: (request)=>recordPersonRefund(request),
|
|
313
434
|
peopleInView: (request)=>createCrmPersonRecords(defaultCrmPersonRecordsDeps()).peopleInView(request),
|
|
314
|
-
wroteIn: (request)=>createCrmPersonRecords(defaultCrmPersonRecordsDeps()).wroteIn(request)
|
|
435
|
+
wroteIn: (request)=>createCrmPersonRecords(defaultCrmPersonRecordsDeps()).wroteIn(request),
|
|
436
|
+
search: (request)=>createCrmPersonRecords(defaultCrmPersonRecordsDeps()).search(request),
|
|
437
|
+
changedSince: (request)=>createCrmPersonRecords(defaultCrmPersonRecordsDeps()).changedSince(request)
|
|
315
438
|
};
|
|
316
439
|
|
|
317
440
|
//# sourceMappingURL=person-records.js.map
|