@aglyn/plugins-crm 1.0.0-beta.152 → 1.0.0-beta.153
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 +13 -13
- package/src/lib/components/lead-convert-dialog.js +4 -2
- package/src/lib/components/lead-convert-dialog.js.map +1 -1
- package/src/lib/components/lead-history-card.js +3 -0
- package/src/lib/components/lead-history-card.js.map +1 -1
- package/src/lib/components/lead-properties-card.js +209 -1
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/lead-surfaces-note.d.ts +7 -3
- package/src/lib/components/lead-surfaces-note.js +6 -2
- package/src/lib/components/lead-surfaces-note.js.map +1 -1
- package/src/lib/components/leads-section.js +114 -2
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/new-lead-drawer.d.ts +56 -0
- package/src/lib/components/new-lead-drawer.js +327 -0
- package/src/lib/components/new-lead-drawer.js.map +1 -0
- package/src/lib/components/use-crm-api.d.ts +1 -1
- package/src/lib/components/use-crm-api.js.map +1 -1
- package/src/lib/model/crm-lead-import.d.ts +19 -3
- package/src/lib/model/crm-lead-import.js +159 -6
- package/src/lib/model/crm-lead-import.js.map +1 -1
- package/src/lib/model/lead-company-suggestion.d.ts +18 -3
- package/src/lib/model/lead-company-suggestion.js +41 -11
- package/src/lib/model/lead-company-suggestion.js.map +1 -1
- package/src/lib/server/capture-contact.d.ts +28 -0
- package/src/lib/server/capture-contact.js +139 -0
- package/src/lib/server/capture-contact.js.map +1 -1
- package/src/lib/server/convert-open-lead.d.ts +32 -0
- package/src/lib/server/convert-open-lead.js +91 -0
- package/src/lib/server/convert-open-lead.js.map +1 -0
- package/src/lib/server/lead-create.d.ts +76 -0
- package/src/lib/server/lead-create.js +227 -0
- package/src/lib/server/lead-create.js.map +1 -0
- package/src/lib/server/leads-import.js +7 -2
- package/src/lib/server/leads-import.js.map +1 -1
- package/src/lib/server/record-timeline.js +4 -2
- package/src/lib/server/record-timeline.js.map +1 -1
- package/src/lib/server.js +3 -0
- package/src/lib/server.js.map +1 -1
|
@@ -0,0 +1,227 @@
|
|
|
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
|
+
*/ /**
|
|
18
|
+
* `POST /api/crm/leads-create` — a lead typed in by hand (AGL-3231).
|
|
19
|
+
*
|
|
20
|
+
* Salesforce's New Lead: a person the team has heard of and has not yet
|
|
21
|
+
* qualified, entered with what is known about them — the address, the
|
|
22
|
+
* name, the company as text, a title, a phone, a website, an address, tags,
|
|
23
|
+
* where they came from — and nothing else brought into being. No contact,
|
|
24
|
+
* no company record: those are the conversion's to make, once the lead is
|
|
25
|
+
* real. Until then the lead is the whole record, which is what lets the
|
|
26
|
+
* Leads section be worked without a second row for every person in it.
|
|
27
|
+
*
|
|
28
|
+
* ## Through the one door
|
|
29
|
+
*
|
|
30
|
+
* `addHostLead` is the single writer of `hosts/{hostId}/leads` — the
|
|
31
|
+
* capture doors, the import and the REST create all reach it — and it is
|
|
32
|
+
* the writer here. That is what keys the lead on the person, so a second
|
|
33
|
+
* "new lead" for one address updates the first rather than filing twice;
|
|
34
|
+
* counts it against the platform ceiling inside the transaction that
|
|
35
|
+
* writes; and records NO marketing basis, because a person typed in by the
|
|
36
|
+
* team is not a person who ticked a box. The profile and the working state
|
|
37
|
+
* are the CRM's own annotations and land in one merge write after the
|
|
38
|
+
* door's, exactly as the import writes them.
|
|
39
|
+
*
|
|
40
|
+
* ## Who may call it
|
|
41
|
+
*
|
|
42
|
+
* The same two gates the convert route asks: `data.manage` in the org, and
|
|
43
|
+
* a role on THIS site — a lead is the site's record, so an org member scoped
|
|
44
|
+
* to a sibling site holds the key but not the host. Then the CRM suite gate,
|
|
45
|
+
* because a lead entered by hand is suite work: the capture doors that fill
|
|
46
|
+
* a Free workspace's leads do not come through here.
|
|
47
|
+
*/ import { checkVisitorRecordCeiling, LEADS_MAX_PER_HOST, normalizeContactEmail, normalizeCrmLeadProfile, personKey } from "@aglyn/aglyn/server";
|
|
48
|
+
import { addHostLead, firebaseAdmin, getOrgForHost, logHostActivity } from "@aglyn/tenant-data-admin";
|
|
49
|
+
import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
|
|
50
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
51
|
+
import { holdsDataManage } from "./org-caller.js";
|
|
52
|
+
import { crmSuiteRefusal } from "./suite-gate.js";
|
|
53
|
+
/** The statuses a lead may be entered in — everything but the converted state. */ const CREATE_STATUSES = [
|
|
54
|
+
'new',
|
|
55
|
+
'working'
|
|
56
|
+
];
|
|
57
|
+
const NAME_MAX = 120;
|
|
58
|
+
const NOTES_MAX = 4000;
|
|
59
|
+
/** The surface a hand-entered lead names, beside `signup`, `booking`, `form:{id}` and `import`. */ export const LEAD_MANUAL_SOURCE = 'manual';
|
|
60
|
+
/** The lead's own fields as one write, `null` in the patch meaning a clear. */ export function leadProfileWrite(patch) {
|
|
61
|
+
const write = {};
|
|
62
|
+
for (const [key, value] of Object.entries(patch)){
|
|
63
|
+
if (value === undefined) continue;
|
|
64
|
+
write[key] = value === null ? FieldValue.delete() : value;
|
|
65
|
+
}
|
|
66
|
+
return write;
|
|
67
|
+
}
|
|
68
|
+
export const leadCreateHandler = async (req, res)=>{
|
|
69
|
+
var _req_headers_authorization, _req_body, _body_hostId, _body_status, _body_name, _body_ownerUid, _body_notes;
|
|
70
|
+
if (req.method !== 'POST') {
|
|
71
|
+
res.setHeader('Allow', 'POST');
|
|
72
|
+
res.status(405).json({
|
|
73
|
+
error: 'Method not allowed'
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
|
|
78
|
+
const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
|
|
79
|
+
if (!idToken) {
|
|
80
|
+
res.status(401).json({
|
|
81
|
+
error: 'Unauthenticated'
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const body = typeof req.body === 'string' ? JSON.parse(req.body) : (_req_body = req.body) != null ? _req_body : {};
|
|
86
|
+
const hostId = String((_body_hostId = body.hostId) != null ? _body_hostId : '').trim().slice(0, 128);
|
|
87
|
+
if (!hostId) {
|
|
88
|
+
res.status(400).json({
|
|
89
|
+
error: 'Missing hostId'
|
|
90
|
+
});
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const email = normalizeContactEmail(body.email);
|
|
94
|
+
if (!email) {
|
|
95
|
+
res.status(400).json({
|
|
96
|
+
error: 'Enter a valid email address.'
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
/*
|
|
101
|
+
* The profile, refused BEFORE any read: a phone or a website the record
|
|
102
|
+
* cannot hold is answered under its field, the sentence the drawer shows
|
|
103
|
+
* beneath the input, and nothing was written to retry against.
|
|
104
|
+
*/ const { patch, errors } = normalizeCrmLeadProfile(body);
|
|
105
|
+
const [firstError] = Object.entries(errors);
|
|
106
|
+
if (firstError) {
|
|
107
|
+
res.status(400).json({
|
|
108
|
+
error: firstError[1],
|
|
109
|
+
field: firstError[0]
|
|
110
|
+
});
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const rawStatus = String((_body_status = body.status) != null ? _body_status : '').trim();
|
|
114
|
+
if (rawStatus && !CREATE_STATUSES.includes(rawStatus)) {
|
|
115
|
+
res.status(400).json({
|
|
116
|
+
error: 'A new lead is New or Working.'
|
|
117
|
+
});
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const name = String((_body_name = body.name) != null ? _body_name : '').trim().replace(/\s+/g, ' ').slice(0, NAME_MAX);
|
|
121
|
+
const ownerUid = String((_body_ownerUid = body.ownerUid) != null ? _body_ownerUid : '').trim().slice(0, 128);
|
|
122
|
+
const notes = String((_body_notes = body.notes) != null ? _body_notes : '').trim().slice(0, NOTES_MAX);
|
|
123
|
+
try {
|
|
124
|
+
var _decoded_email;
|
|
125
|
+
const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
|
|
126
|
+
const staff = decoded['staff'] === true;
|
|
127
|
+
const membership = await resolveOrgPermissions(decoded.uid, {
|
|
128
|
+
hostId
|
|
129
|
+
});
|
|
130
|
+
if (!staff && !(membership.hostRole && await holdsDataManage(membership.orgId, decoded.uid))) {
|
|
131
|
+
res.status(403).json({
|
|
132
|
+
error: 'Adding a lead requires the data permission on this site'
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const resolved = await getOrgForHost(hostId);
|
|
137
|
+
if (!resolved) {
|
|
138
|
+
res.status(404).json({
|
|
139
|
+
error: 'Unknown site'
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const suite = crmSuiteRefusal(resolved.org, 'Adding a lead by hand');
|
|
144
|
+
if (suite) {
|
|
145
|
+
res.status(suite.status).json(suite.body);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
149
|
+
const hostRef = firestore.collection('hosts').doc(hostId);
|
|
150
|
+
const leadsRef = hostRef.collection('leads');
|
|
151
|
+
// Non-null by construction: the normalizer refused every address this
|
|
152
|
+
// derivation could not key.
|
|
153
|
+
const leadId = personKey(email);
|
|
154
|
+
const before = await leadsRef.doc(leadId).get();
|
|
155
|
+
const created = !before.exists;
|
|
156
|
+
/*
|
|
157
|
+
* The platform ceiling, judged here so the drawer can say WHY rather
|
|
158
|
+
* than only that the lead could not be saved; the door re-judges it
|
|
159
|
+
* inside its own transaction, so a race is still refused there.
|
|
160
|
+
*/ if (created) {
|
|
161
|
+
const used = (await leadsRef.count().get()).data().count;
|
|
162
|
+
if (checkVisitorRecordCeiling(used, LEADS_MAX_PER_HOST).exceeded) {
|
|
163
|
+
res.status(409).json({
|
|
164
|
+
error: 'This site is at the platform lead limit, so the lead was not ' + 'added. Remove some leads, or contact support if this is real ' + 'traffic.',
|
|
165
|
+
reason: 'lead-ceiling'
|
|
166
|
+
});
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const stored = await addHostLead({
|
|
171
|
+
hostRef,
|
|
172
|
+
hostId,
|
|
173
|
+
lead: _extends({
|
|
174
|
+
email
|
|
175
|
+
}, name ? {
|
|
176
|
+
name
|
|
177
|
+
} : {}, {
|
|
178
|
+
source: LEAD_MANUAL_SOURCE
|
|
179
|
+
})
|
|
180
|
+
});
|
|
181
|
+
if (!stored) {
|
|
182
|
+
res.status(500).json({
|
|
183
|
+
error: 'The lead could not be saved.'
|
|
184
|
+
});
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const working = _extends({}, leadProfileWrite(patch), rawStatus ? {
|
|
188
|
+
status: rawStatus
|
|
189
|
+
} : {}, ownerUid ? {
|
|
190
|
+
ownerUid
|
|
191
|
+
} : {}, notes ? {
|
|
192
|
+
notes
|
|
193
|
+
} : {});
|
|
194
|
+
if (Object.keys(working).length) {
|
|
195
|
+
await leadsRef.doc(leadId).set(_extends({}, working, {
|
|
196
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
197
|
+
}), {
|
|
198
|
+
merge: true
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
/*
|
|
202
|
+
* The audit line, written by the route that verified the caller and
|
|
203
|
+
* performed the write — the one writer that cannot record an add that
|
|
204
|
+
* did not happen. An update of a lead the site already held says so.
|
|
205
|
+
*/ await logHostActivity(hostId, {
|
|
206
|
+
uid: decoded.uid,
|
|
207
|
+
email: (_decoded_email = decoded.email) != null ? _decoded_email : null
|
|
208
|
+
}, created ? 'Added lead' : 'Updated lead', {
|
|
209
|
+
type: 'lead',
|
|
210
|
+
id: leadId,
|
|
211
|
+
name: name || email
|
|
212
|
+
});
|
|
213
|
+
const answer = {
|
|
214
|
+
leadId,
|
|
215
|
+
created
|
|
216
|
+
};
|
|
217
|
+
res.status(created ? 201 : 200).json(answer);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
console.error('leads-create failed', error);
|
|
220
|
+
res.status(500).json({
|
|
221
|
+
error: 'The lead could not be saved.'
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
export default leadCreateHandler;
|
|
226
|
+
|
|
227
|
+
//# sourceMappingURL=lead-create.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-create.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/leads-create` — a lead typed in by hand (AGL-3231).\n *\n * Salesforce's New Lead: a person the team has heard of and has not yet\n * qualified, entered with what is known about them — the address, the\n * name, the company as text, a title, a phone, a website, an address, tags,\n * where they came from — and nothing else brought into being. No contact,\n * no company record: those are the conversion's to make, once the lead is\n * real. Until then the lead is the whole record, which is what lets the\n * Leads section be worked without a second row for every person in it.\n *\n * ## Through the one door\n *\n * `addHostLead` is the single writer of `hosts/{hostId}/leads` — the\n * capture doors, the import and the REST create all reach it — and it is\n * the writer here. That is what keys the lead on the person, so a second\n * \"new lead\" for one address updates the first rather than filing twice;\n * counts it against the platform ceiling inside the transaction that\n * writes; and records NO marketing basis, because a person typed in by the\n * team is not a person who ticked a box. The profile and the working state\n * are the CRM's own annotations and land in one merge write after the\n * door's, exactly as the import writes them.\n *\n * ## Who may call it\n *\n * The same two gates the convert route asks: `data.manage` in the org, and\n * a role on THIS site — a lead is the site's record, so an org member scoped\n * to a sibling site holds the key but not the host. Then the CRM suite gate,\n * because a lead entered by hand is suite work: the capture doors that fill\n * a Free workspace's leads do not come through here.\n */\n\nimport {\n checkVisitorRecordCeiling,\n type CrmLeadProfilePatch,\n type CrmLeadStatus,\n LEADS_MAX_PER_HOST,\n normalizeContactEmail,\n normalizeCrmLeadProfile,\n personKey,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n addHostLead,\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { holdsDataManage } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/** The statuses a lead may be entered in — everything but the converted state. */\nconst CREATE_STATUSES: readonly CrmLeadStatus[] = ['new', 'working']\n\nconst NAME_MAX = 120\nconst NOTES_MAX = 4000\n\n/** The surface a hand-entered lead names, beside `signup`, `booking`, `form:{id}` and `import`. */\nexport const LEAD_MANUAL_SOURCE = 'manual'\n\n/** What the New lead drawer posts. */\nexport interface LeadCreateRequest {\n hostId: string\n email: string\n name?: string\n company?: string\n jobTitle?: string\n phone?: string\n website?: string\n address?: Record<string, unknown> | null\n tags?: string[]\n leadSource?: string\n status?: CrmLeadStatus\n ownerUid?: string\n notes?: string\n}\n\n/** What the route answers on success. */\nexport interface LeadCreateResponse {\n leadId: string\n /** False when the site already held a lead for the address — it was updated. */\n created: boolean\n}\n\n/** The lead's own fields as one write, `null` in the patch meaning a clear. */\nexport function leadProfileWrite(patch: CrmLeadProfilePatch): Record<string, unknown> {\n const write: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(patch)) {\n if (value === undefined) continue\n write[key] = value === null ? FieldValue.delete() : value\n }\n return write\n}\n\nexport const leadCreateHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n const body: Partial<LeadCreateRequest> & Record<string, unknown> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n .trim()\n .slice(0, 128)\n if (!hostId) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const email = normalizeContactEmail(body.email)\n if (!email) {\n res.status(400).json({ error: 'Enter a valid email address.' })\n return\n }\n /*\n * The profile, refused BEFORE any read: a phone or a website the record\n * cannot hold is answered under its field, the sentence the drawer shows\n * beneath the input, and nothing was written to retry against.\n */\n const { patch, errors } = normalizeCrmLeadProfile(body)\n const [firstError] = Object.entries(errors)\n if (firstError) {\n res.status(400).json({ error: firstError[1], field: firstError[0] })\n return\n }\n const rawStatus = String(body.status ?? '').trim()\n if (rawStatus && !CREATE_STATUSES.includes(rawStatus as CrmLeadStatus)) {\n res.status(400).json({ error: 'A new lead is New or Working.' })\n return\n }\n const name = String(body.name ?? '')\n .trim()\n .replace(/\\s+/g, ' ')\n .slice(0, NAME_MAX)\n const ownerUid = String(body.ownerUid ?? '')\n .trim()\n .slice(0, 128)\n const notes = String(body.notes ?? '')\n .trim()\n .slice(0, NOTES_MAX)\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (\n !staff &&\n !(membership.hostRole && (await holdsDataManage(membership.orgId, decoded.uid)))\n ) {\n res\n .status(403)\n .json({ error: 'Adding a lead requires the data permission on this site' })\n return\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const suite = crmSuiteRefusal(resolved.org, 'Adding a lead by hand')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const leadsRef = hostRef.collection('leads')\n // Non-null by construction: the normalizer refused every address this\n // derivation could not key.\n const leadId = personKey(email) as string\n const before = await leadsRef.doc(leadId).get()\n const created = !before.exists\n /*\n * The platform ceiling, judged here so the drawer can say WHY rather\n * than only that the lead could not be saved; the door re-judges it\n * inside its own transaction, so a race is still refused there.\n */\n if (created) {\n const used = (await leadsRef.count().get()).data().count\n if (checkVisitorRecordCeiling(used, LEADS_MAX_PER_HOST).exceeded) {\n res.status(409).json({\n error:\n 'This site is at the platform lead limit, so the lead was not ' +\n 'added. Remove some leads, or contact support if this is real ' +\n 'traffic.',\n reason: 'lead-ceiling',\n })\n return\n }\n }\n const stored = await addHostLead({\n hostRef,\n hostId,\n lead: {\n email,\n ...(name ? { name } : {}),\n source: LEAD_MANUAL_SOURCE,\n },\n })\n if (!stored) {\n res.status(500).json({ error: 'The lead could not be saved.' })\n return\n }\n const working: Record<string, unknown> = {\n ...leadProfileWrite(patch),\n ...(rawStatus ? { status: rawStatus } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(notes ? { notes } : {}),\n }\n if (Object.keys(working).length) {\n await leadsRef\n .doc(leadId)\n .set({ ...working, updatedAt: FieldValue.serverTimestamp() }, { merge: true })\n }\n /*\n * The audit line, written by the route that verified the caller and\n * performed the write — the one writer that cannot record an add that\n * did not happen. An update of a lead the site already held says so.\n */\n await logHostActivity(\n hostId,\n { uid: decoded.uid, email: decoded.email ?? null },\n created ? 'Added lead' : 'Updated lead',\n { type: 'lead', id: leadId, name: name || email },\n )\n const answer: LeadCreateResponse = { leadId, created }\n res.status(created ? 201 : 200).json(answer)\n } catch (error) {\n console.error('leads-create failed', error)\n res.status(500).json({ error: 'The lead could not be saved.' })\n }\n}\n\nexport default leadCreateHandler\n"],"names":["checkVisitorRecordCeiling","LEADS_MAX_PER_HOST","normalizeContactEmail","normalizeCrmLeadProfile","personKey","addHostLead","firebaseAdmin","getOrgForHost","logHostActivity","resolveOrgPermissions","FieldValue","holdsDataManage","crmSuiteRefusal","CREATE_STATUSES","NAME_MAX","NOTES_MAX","LEAD_MANUAL_SOURCE","leadProfileWrite","patch","write","key","value","Object","entries","undefined","delete","leadCreateHandler","req","res","body","method","setHeader","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","JSON","parse","hostId","trim","email","errors","firstError","field","rawStatus","includes","name","replace","ownerUid","notes","decoded","app","auth","verifyIdToken","staff","membership","uid","hostRole","orgId","resolved","suite","org","firestore","hostRef","collection","doc","leadsRef","leadId","before","get","created","exists","used","count","data","exceeded","reason","stored","lead","source","working","keys","set","updatedAt","serverTimestamp","merge","type","id","answer","console"],"mappings":"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"}
|
|
@@ -88,7 +88,12 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
88
88
|
* can be held to the same vocabulary the source filters and the labels
|
|
89
89
|
* read. One person brought in by one file reads the same on both records.
|
|
90
90
|
*/ const LEAD_IMPORT_SOURCE = 'import';
|
|
91
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* The team's annotations on one row and the lead's own profile
|
|
93
|
+
* (AGL-3231), or nothing when the file named none of either. A profile
|
|
94
|
+
* value lands as the record's card would write it; the file never clears
|
|
95
|
+
* one, because a blank cell is a cell nobody filled.
|
|
96
|
+
*/ function workingState(row, ownerUid) {
|
|
92
97
|
const fields = _extends({}, row.status ? {
|
|
93
98
|
status: row.status
|
|
94
99
|
} : {}, ownerUid ? {
|
|
@@ -97,7 +102,7 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
97
102
|
unqualifiedReason: row.unqualifiedReason
|
|
98
103
|
} : {}, row.notes ? {
|
|
99
104
|
notes: row.notes
|
|
100
|
-
} : {});
|
|
105
|
+
} : {}, row.profile);
|
|
101
106
|
return Object.keys(fields).length ? fields : null;
|
|
102
107
|
}
|
|
103
108
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/leads-import.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/leads-import` — one chunk of a leads file, written\n * (AGL-2701).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link LEAD_IMPORT_CHUNK_SIZE} raw\n * rows in the vocabulary `crm-lead-import.ts` defines. This route judges\n * each one through the same normalizer, resolves the owner against the\n * org's roster, and writes it in two halves.\n *\n * ## The capture half goes through the door every capture goes through\n *\n * `addHostLead` — the ONE writer of `hosts/{hostId}/leads` for the sign-up\n * handler and both booking paths — is the writer here too. That is what\n * makes an imported row key on `personKey` the way a second form\n * submission does, land under the site by path with `capturedByHostIds`\n * naming it, count against `LEADS_MAX_PER_HOST` inside the transaction\n * that writes, and record NO marketing basis. A bulk path with its own\n * `add()` would grow an unkeyed, unbounded copy of the site's leads under\n * a second set of rules.\n *\n * ## A lead is host-scoped by path, so nothing here stamps `visibleTo`\n *\n * The five org collections carry `visibleTo` because they sit under\n * `orgs/{orgId}` and the rules decide per document which sites may read\n * them. A lead does not: `hosts/{hostId}/leads` is private to the site by\n * path, the rules admit the site's own members, and the list therefore\n * reads it with no scope clause. An import that stamped scope tokens onto\n * a lead would be writing a field nothing reads, and would suggest a\n * sharing decision the collection does not have. The site is the whole\n * scope, and the drawer's picker is where it is chosen.\n *\n * ## No file may hand a lead a consent it did not give\n *\n * A capture writes a marketing basis only when the visitor ticked a box in\n * front of them. A CSV row has no such event behind it, so the lead\n * vocabulary has no consent column and this route passes no\n * `marketingConsent`: an imported lead is mailable only if some earlier\n * capture on this site already recorded a basis, which `addHostLead`\n * carries forward untouched. The contacts import has a consent column\n * because its own export writes one and the merchant is asserting a basis\n * they hold per person; the leads export writes none, and a column the\n * file cannot fill is a column that cannot be misread.\n *\n * ## The working half is written the way the list writes it\n *\n * A status, an owner, a reason and notes are the CRM's annotations, not\n * the capture's, and the leads list already writes them client-direct onto\n * the same document. A row that carries any of them gets one merge write\n * after the door's, stamped `updatedAt` exactly as the list stamps it. A\n * row that carries none costs nothing extra.\n *\n * ## Created or merged is read once for the chunk\n *\n * The door reports only whether the lead was STORED, and the result panel\n * has to say how many people were added and how many updated. One\n * `getAll` over the chunk's document ids answers that for every row in a\n * single round trip, before any of them is written — which is also the\n * only moment the answer is still true.\n */\n\nimport {\n checkVisitorRecordCeiling,\n type ContactSource,\n LEADS_MAX_PER_HOST,\n personKey,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n LEAD_IMPORT_CHUNK_SIZE,\n LEAD_IMPORT_MAX_BODY_BYTES,\n type LeadImportChunkResult,\n type LeadImportRawRow,\n type LeadImportRow,\n type LeadImportSkippedRow,\n normalizeLeadImportRow,\n} from '../model/crm-lead-import'\nimport { addHostLead, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\n\n/**\n * The surface an imported lead names, beside `signup`, `booking` and\n * `form:{formId}`.\n *\n * Typed as the capture-source union rather than a bare string, which is\n * what the contacts import gets for free from the door it calls: a lead's\n * `source` is plain text on the way in, so this is the only place the word\n * can be held to the same vocabulary the source filters and the labels\n * read. One person brought in by one file reads the same on both records.\n */\nconst LEAD_IMPORT_SOURCE: ContactSource = 'import'\n\n/** The team's annotations on one row, or nothing when the file named none. */\nfunction workingState(\n row: LeadImportRow,\n ownerUid: string | undefined,\n): Record<string, unknown> | null {\n const fields: Record<string, unknown> = {\n ...(row.status ? { status: row.status } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.unqualifiedReason ? { unqualifiedReason: row.unqualifiedReason } : {}),\n ...(row.notes ? { notes: row.notes } : {}),\n }\n return Object.keys(fields).length ? fields : null\n}\n\n/**\n * `POST crm/leads-import` — `{ hostId, rows }` → a {@link LeadImportChunkResult}.\n *\n * Rows are written one after another so the ceiling's local count stays\n * honest across the request.\n */\nexport const crmLeadsImportHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const read = readImportRows<LeadImportRawRow>(req, {\n maxBodyBytes: LEAD_IMPORT_MAX_BODY_BYTES,\n chunkSize: LEAD_IMPORT_CHUNK_SIZE,\n })\n if ('error' in read) return res.status(read.status).json({ error: read.error })\n try {\n const context = await resolveImportContext(req)\n if (context.ok === false) return res.status(context.status).json(context.body)\n\n const skipped: LeadImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: LeadImportRow; key: string }[] = []\n /*\n * A duplicate WITHIN the request is skipped rather than merged, for\n * the reason the contacts import gives: the second row would merge\n * onto the first's document and the tally would read one created and\n * one merged for one person in one file. Across requests the door's\n * own dedupe answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeLeadImportRow(raw)\n if (verdict.ok === false) {\n skipped.push({ index, email: verdict.input, reason: verdict.reason })\n return\n }\n // Non-null by construction: the normalizer refused every address\n // this derivation could not key.\n const key = personKey(verdict.row.email) as string\n if (seen.has(key)) {\n skipped.push({ index, email: verdict.row.email, reason: 'duplicate' })\n return\n }\n seen.add(key)\n for (const entry of verdict.row.dropped) {\n dropped[entry.field] = (dropped[entry.field] ?? 0) + 1\n }\n normalized.push({ index, row: verdict.row, key })\n })\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(context.hostId)\n const leadsRef = hostRef.collection('leads')\n const refs = normalized.map((entry) => leadsRef.doc(entry.key))\n const [owners, before] = await Promise.all([\n ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n ),\n refs.length ? firestore.getAll(...refs) : Promise.resolve([]),\n ])\n const held = new Set(\n before.filter((snapshot) => snapshot.exists).map((snapshot) => snapshot.id),\n )\n const ownersUnresolved = new Set<string>()\n let counted: number | null = null\n let createdHere = 0\n let created = 0\n let merged = 0\n\n for (const { index, row, key } of normalized) {\n const isNew = !held.has(key)\n /*\n * The platform lead ceiling, judged the way the deals import judges\n * the records band: one count at the first create, re-judged locally\n * as the request creates more. The door re-judges it authoritatively\n * inside its own transaction, so a race is still refused there — this\n * is what lets the operator's skipped file say WHY rather than only\n * that the row could not be saved.\n */\n if (isNew) {\n if (counted === null) {\n counted = (await leadsRef.count().get()).data().count\n }\n if (\n checkVisitorRecordCeiling(counted + createdHere, LEADS_MAX_PER_HOST)\n .exceeded\n ) {\n skipped.push({ index, email: row.email, reason: 'lead-ceiling' })\n continue\n }\n }\n let ownerUid: string | undefined\n if (row.ownerEmail) {\n ownerUid = owners.get(row.ownerEmail)\n if (!ownerUid) ownersUnresolved.add(row.ownerEmail)\n }\n const stored = await addHostLead({\n hostRef,\n hostId: context.hostId,\n lead: {\n email: row.email,\n ...(row.name ? { name: row.name } : {}),\n source: LEAD_IMPORT_SOURCE,\n },\n })\n if (!stored) {\n skipped.push({ index, email: row.email, reason: 'write-failed' })\n continue\n }\n const working = workingState(row, ownerUid)\n if (working) {\n await leadsRef\n .doc(key)\n .set(\n { ...working, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n }\n if (isNew) {\n created += 1\n createdHere += 1\n } else {\n merged += 1\n }\n }\n\n const result: LeadImportChunkResult = {\n received: read.rows.length,\n created,\n merged,\n skipped: skipped.sort((a, b) => a.index - b.index),\n dropped,\n ownersUnresolved: [...ownersUnresolved],\n }\n return res.status(200).json(result)\n } catch (error) {\n console.error('crm/leads-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n"],"names":["checkVisitorRecordCeiling","LEADS_MAX_PER_HOST","personKey","LEAD_IMPORT_CHUNK_SIZE","LEAD_IMPORT_MAX_BODY_BYTES","normalizeLeadImportRow","addHostLead","firebaseAdmin","FieldValue","ownerDirectory","readImportRows","resolveImportContext","LEAD_IMPORT_SOURCE","workingState","row","ownerUid","fields","status","unqualifiedReason","notes","Object","keys","length","crmLeadsImportHandler","req","res","method","setHeader","json","error","read","maxBodyBytes","chunkSize","context","ok","body","skipped","dropped","normalized","seen","Set","rows","forEach","raw","index","verdict","push","email","input","reason","key","has","add","entry","field","firestore","app","hostRef","collection","doc","hostId","leadsRef","refs","map","owners","before","Promise","all","orgId","getAll","resolve","held","filter","snapshot","exists","id","ownersUnresolved","counted","createdHere","created","merged","isNew","count","get","data","exceeded","ownerEmail","stored","lead","name","source","working","set","updatedAt","serverTimestamp","merge","result","received","sort","a","b","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/leads-import.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/leads-import` — one chunk of a leads file, written\n * (AGL-2701).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link LEAD_IMPORT_CHUNK_SIZE} raw\n * rows in the vocabulary `crm-lead-import.ts` defines. This route judges\n * each one through the same normalizer, resolves the owner against the\n * org's roster, and writes it in two halves.\n *\n * ## The capture half goes through the door every capture goes through\n *\n * `addHostLead` — the ONE writer of `hosts/{hostId}/leads` for the sign-up\n * handler and both booking paths — is the writer here too. That is what\n * makes an imported row key on `personKey` the way a second form\n * submission does, land under the site by path with `capturedByHostIds`\n * naming it, count against `LEADS_MAX_PER_HOST` inside the transaction\n * that writes, and record NO marketing basis. A bulk path with its own\n * `add()` would grow an unkeyed, unbounded copy of the site's leads under\n * a second set of rules.\n *\n * ## A lead is host-scoped by path, so nothing here stamps `visibleTo`\n *\n * The five org collections carry `visibleTo` because they sit under\n * `orgs/{orgId}` and the rules decide per document which sites may read\n * them. A lead does not: `hosts/{hostId}/leads` is private to the site by\n * path, the rules admit the site's own members, and the list therefore\n * reads it with no scope clause. An import that stamped scope tokens onto\n * a lead would be writing a field nothing reads, and would suggest a\n * sharing decision the collection does not have. The site is the whole\n * scope, and the drawer's picker is where it is chosen.\n *\n * ## No file may hand a lead a consent it did not give\n *\n * A capture writes a marketing basis only when the visitor ticked a box in\n * front of them. A CSV row has no such event behind it, so the lead\n * vocabulary has no consent column and this route passes no\n * `marketingConsent`: an imported lead is mailable only if some earlier\n * capture on this site already recorded a basis, which `addHostLead`\n * carries forward untouched. The contacts import has a consent column\n * because its own export writes one and the merchant is asserting a basis\n * they hold per person; the leads export writes none, and a column the\n * file cannot fill is a column that cannot be misread.\n *\n * ## The working half is written the way the list writes it\n *\n * A status, an owner, a reason and notes are the CRM's annotations, not\n * the capture's, and the leads list already writes them client-direct onto\n * the same document. A row that carries any of them gets one merge write\n * after the door's, stamped `updatedAt` exactly as the list stamps it. A\n * row that carries none costs nothing extra.\n *\n * ## Created or merged is read once for the chunk\n *\n * The door reports only whether the lead was STORED, and the result panel\n * has to say how many people were added and how many updated. One\n * `getAll` over the chunk's document ids answers that for every row in a\n * single round trip, before any of them is written — which is also the\n * only moment the answer is still true.\n */\n\nimport {\n checkVisitorRecordCeiling,\n type ContactSource,\n LEADS_MAX_PER_HOST,\n personKey,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n LEAD_IMPORT_CHUNK_SIZE,\n LEAD_IMPORT_MAX_BODY_BYTES,\n type LeadImportChunkResult,\n type LeadImportRawRow,\n type LeadImportRow,\n type LeadImportSkippedRow,\n normalizeLeadImportRow,\n} from '../model/crm-lead-import'\nimport { addHostLead, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\n\n/**\n * The surface an imported lead names, beside `signup`, `booking` and\n * `form:{formId}`.\n *\n * Typed as the capture-source union rather than a bare string, which is\n * what the contacts import gets for free from the door it calls: a lead's\n * `source` is plain text on the way in, so this is the only place the word\n * can be held to the same vocabulary the source filters and the labels\n * read. One person brought in by one file reads the same on both records.\n */\nconst LEAD_IMPORT_SOURCE: ContactSource = 'import'\n\n/**\n * The team's annotations on one row and the lead's own profile\n * (AGL-3231), or nothing when the file named none of either. A profile\n * value lands as the record's card would write it; the file never clears\n * one, because a blank cell is a cell nobody filled.\n */\nfunction workingState(\n row: LeadImportRow,\n ownerUid: string | undefined,\n): Record<string, unknown> | null {\n const fields: Record<string, unknown> = {\n ...(row.status ? { status: row.status } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.unqualifiedReason ? { unqualifiedReason: row.unqualifiedReason } : {}),\n ...(row.notes ? { notes: row.notes } : {}),\n ...row.profile,\n }\n return Object.keys(fields).length ? fields : null\n}\n\n/**\n * `POST crm/leads-import` — `{ hostId, rows }` → a {@link LeadImportChunkResult}.\n *\n * Rows are written one after another so the ceiling's local count stays\n * honest across the request.\n */\nexport const crmLeadsImportHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const read = readImportRows<LeadImportRawRow>(req, {\n maxBodyBytes: LEAD_IMPORT_MAX_BODY_BYTES,\n chunkSize: LEAD_IMPORT_CHUNK_SIZE,\n })\n if ('error' in read) return res.status(read.status).json({ error: read.error })\n try {\n const context = await resolveImportContext(req)\n if (context.ok === false) return res.status(context.status).json(context.body)\n\n const skipped: LeadImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: LeadImportRow; key: string }[] = []\n /*\n * A duplicate WITHIN the request is skipped rather than merged, for\n * the reason the contacts import gives: the second row would merge\n * onto the first's document and the tally would read one created and\n * one merged for one person in one file. Across requests the door's\n * own dedupe answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeLeadImportRow(raw)\n if (verdict.ok === false) {\n skipped.push({ index, email: verdict.input, reason: verdict.reason })\n return\n }\n // Non-null by construction: the normalizer refused every address\n // this derivation could not key.\n const key = personKey(verdict.row.email) as string\n if (seen.has(key)) {\n skipped.push({ index, email: verdict.row.email, reason: 'duplicate' })\n return\n }\n seen.add(key)\n for (const entry of verdict.row.dropped) {\n dropped[entry.field] = (dropped[entry.field] ?? 0) + 1\n }\n normalized.push({ index, row: verdict.row, key })\n })\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(context.hostId)\n const leadsRef = hostRef.collection('leads')\n const refs = normalized.map((entry) => leadsRef.doc(entry.key))\n const [owners, before] = await Promise.all([\n ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n ),\n refs.length ? firestore.getAll(...refs) : Promise.resolve([]),\n ])\n const held = new Set(\n before.filter((snapshot) => snapshot.exists).map((snapshot) => snapshot.id),\n )\n const ownersUnresolved = new Set<string>()\n let counted: number | null = null\n let createdHere = 0\n let created = 0\n let merged = 0\n\n for (const { index, row, key } of normalized) {\n const isNew = !held.has(key)\n /*\n * The platform lead ceiling, judged the way the deals import judges\n * the records band: one count at the first create, re-judged locally\n * as the request creates more. The door re-judges it authoritatively\n * inside its own transaction, so a race is still refused there — this\n * is what lets the operator's skipped file say WHY rather than only\n * that the row could not be saved.\n */\n if (isNew) {\n if (counted === null) {\n counted = (await leadsRef.count().get()).data().count\n }\n if (\n checkVisitorRecordCeiling(counted + createdHere, LEADS_MAX_PER_HOST)\n .exceeded\n ) {\n skipped.push({ index, email: row.email, reason: 'lead-ceiling' })\n continue\n }\n }\n let ownerUid: string | undefined\n if (row.ownerEmail) {\n ownerUid = owners.get(row.ownerEmail)\n if (!ownerUid) ownersUnresolved.add(row.ownerEmail)\n }\n const stored = await addHostLead({\n hostRef,\n hostId: context.hostId,\n lead: {\n email: row.email,\n ...(row.name ? { name: row.name } : {}),\n source: LEAD_IMPORT_SOURCE,\n },\n })\n if (!stored) {\n skipped.push({ index, email: row.email, reason: 'write-failed' })\n continue\n }\n const working = workingState(row, ownerUid)\n if (working) {\n await leadsRef\n .doc(key)\n .set(\n { ...working, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n }\n if (isNew) {\n created += 1\n createdHere += 1\n } else {\n merged += 1\n }\n }\n\n const result: LeadImportChunkResult = {\n received: read.rows.length,\n created,\n merged,\n skipped: skipped.sort((a, b) => a.index - b.index),\n dropped,\n ownersUnresolved: [...ownersUnresolved],\n }\n return res.status(200).json(result)\n } catch (error) {\n console.error('crm/leads-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n"],"names":["checkVisitorRecordCeiling","LEADS_MAX_PER_HOST","personKey","LEAD_IMPORT_CHUNK_SIZE","LEAD_IMPORT_MAX_BODY_BYTES","normalizeLeadImportRow","addHostLead","firebaseAdmin","FieldValue","ownerDirectory","readImportRows","resolveImportContext","LEAD_IMPORT_SOURCE","workingState","row","ownerUid","fields","status","unqualifiedReason","notes","profile","Object","keys","length","crmLeadsImportHandler","req","res","method","setHeader","json","error","read","maxBodyBytes","chunkSize","context","ok","body","skipped","dropped","normalized","seen","Set","rows","forEach","raw","index","verdict","push","email","input","reason","key","has","add","entry","field","firestore","app","hostRef","collection","doc","hostId","leadsRef","refs","map","owners","before","Promise","all","orgId","getAll","resolve","held","filter","snapshot","exists","id","ownersUnresolved","counted","createdHere","created","merged","isNew","count","get","data","exceeded","ownerEmail","stored","lead","name","source","working","set","updatedAt","serverTimestamp","merge","result","received","sort","a","b","console"],"mappings":"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"}
|
|
@@ -36,6 +36,8 @@ const refuse = (status, error)=>({
|
|
|
36
36
|
companyId: String(link.companyId)
|
|
37
37
|
} : {}, (link == null ? void 0 : link.dealId) ? {
|
|
38
38
|
dealId: String(link.dealId)
|
|
39
|
+
} : {}, (link == null ? void 0 : link.leadId) ? {
|
|
40
|
+
leadId: String(link.leadId)
|
|
39
41
|
} : {});
|
|
40
42
|
}
|
|
41
43
|
/** The document id a caller's key files under: its own namespace, never a raw key. */ function keyedId(sourcePluginId, key) {
|
|
@@ -50,8 +52,8 @@ export function createCrmRecordTimelineWriter(deps) {
|
|
|
50
52
|
if (!orgId || !hostId || !String((_context_sourcePluginId = context.sourcePluginId) != null ? _context_sourcePluginId : '').trim()) {
|
|
51
53
|
return refuse(400, 'An entry names its organization, its site and the plugin filing it.');
|
|
52
54
|
}
|
|
53
|
-
if (!link.contactId && !link.companyId && !link.dealId) {
|
|
54
|
-
return refuse(400, 'An entry is filed on a contact, a company or a
|
|
55
|
+
if (!link.contactId && !link.companyId && !link.dealId && !link.leadId) {
|
|
56
|
+
return refuse(400, 'An entry is filed on a contact, a company, a deal or a lead.');
|
|
55
57
|
}
|
|
56
58
|
const firestore = deps.firestore();
|
|
57
59
|
const orgRef = firestore.collection('orgs').doc(orgId);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/record-timeline.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 registerPluginRecordTimelineWriter,\n type PluginRecordActivityRequest,\n type PluginRecordEntryContext,\n type PluginRecordTaskRequest,\n type PluginRecordTimelineWriter,\n type PluginRecordWrite,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport {\n buildCrmCapturedEmailActivity,\n checkEntitlement,\n consentGroupForHost,\n CRM_ACTIVITY_LOG_FULL_MESSAGE,\n CRM_COLLECTIONS,\n crmActivityLogHasRoom,\n crmCapturedEmailKey,\n crmScopeTokens,\n crmTaskReminderAfterEdit,\n isCrmActivityKind,\n isCrmTaskKind,\n type CrmActivity,\n type CrmActivityLink,\n type CrmTask,\n} from '@aglyn/aglyn/server'\nimport {\n countCrmActivitiesForRecord,\n createCrmEmailActivity,\n crmActivityRef,\n crmCapturedEmailActivityRef,\n firebaseAdmin,\n recomputeCrmNextTaskAt,\n} from '@aglyn/tenant-data-admin'\nimport { createHash } from 'crypto'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { CRM_SUITE_FEATURE } from './suite-gate'\n\n/**\n * THE CRM'S WRITER ON THE CORE'S RECORD-TIMELINE SEAM (AGL-2981).\n *\n * Another plugin files an activity or a task on a contact, company or deal —\n * an email a sequence sent, the reply it read, the call it asks for — and\n * this writes it as the CRM writes its own:\n *\n * 1. the workspace's plan carries the CRM (`features.crm`), as at every\n * CRM door; a Free workspace keeps no records to file on;\n * 2. the entry carries the scope a record made on that site carries\n * (`crmScopeTokens`), so exactly the people who see the record see it;\n * 3. an EMAIL is filed under the id the capture address files a copy of the\n * same message by (`cap_` + the digest of its `Message-ID`), and written\n * with `create()`: the send, a copy the rep forwarded to the capture\n * address, and a second run of the caller are one row;\n * 4. anything else — a note, a task — is filed under the caller's own key,\n * hashed with the caller's plugin id, and written once the same way;\n * 5. a record at its activity ceiling is refused with the sentence every\n * CRM writer answers with, before anything is written.\n *\n * Each entry names the plugin that filed it (`sourcePluginId`), and a task's\n * `nextTaskAtMs` is recomputed on the records it names, as every server\n * writer of a task does.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CrmRecordTimelineDeps {\n firestore(): Firestore\n}\n\n/** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */\nconst ALREADY_EXISTS = 6\n\n/** The longest task title the CRM keeps, as its own forms do. */\nconst TASK_TITLE_MAX = 200\n/** The longest note body or task note an entry filed here keeps. */\nconst NOTE_MAX = 2_000\n\nconst refuse = (status: 400 | 403 | 404 | 409, error: string): PluginRecordWrite => ({\n ok: false,\n status,\n error,\n})\n\n/** The record fields of a link, only the ones it names. */\nfunction linkFields(link: PluginRecordEntryContext['link']): CrmActivityLink {\n return {\n ...(link?.contactId ? { contactId: String(link.contactId) } : {}),\n ...(link?.companyId ? { companyId: String(link.companyId) } : {}),\n ...(link?.dealId ? { dealId: String(link.dealId) } : {}),\n }\n}\n\n/** The document id a caller's key files under: its own namespace, never a raw key. */\nfunction keyedId(sourcePluginId: string, key: string): string {\n return `plg_${createHash('sha256').update(`${sourcePluginId}:${key}`).digest('hex').slice(0, 28)}`\n}\n\nexport function createCrmRecordTimelineWriter(deps: CrmRecordTimelineDeps): PluginRecordTimelineWriter {\n /** The org, its plan, the link and the scope an entry is stamped with. */\n async function admit(\n context: PluginRecordEntryContext,\n ): Promise<\n | PluginRecordWrite\n | { firestore: Firestore; orgRef: FirebaseFirestore.DocumentReference; link: CrmActivityLink; visibleTo: string[] }\n > {\n const orgId = String(context.orgId ?? '').trim()\n const hostId = String(context.hostId ?? '').trim()\n const link = linkFields(context.link)\n if (!orgId || !hostId || !String(context.sourcePluginId ?? '').trim()) {\n return refuse(400, 'An entry names its organization, its site and the plugin filing it.')\n }\n if (!link.contactId && !link.companyId && !link.dealId) {\n return refuse(400, 'An entry is filed on a contact, a company or a deal.')\n }\n const firestore = deps.firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n const snapshot = await orgRef.get()\n if (!snapshot.exists) return refuse(404, 'That organization no longer exists.')\n const org = (snapshot.data() ?? {}) as Record<string, unknown>\n if (!checkEntitlement(org, CRM_SUITE_FEATURE)) {\n return refuse(403, \"This workspace's plan doesn't include the CRM.\")\n }\n return {\n firestore,\n orgRef,\n link,\n visibleTo: crmScopeTokens(org, consentGroupForHost(org, hostId)),\n }\n }\n\n /** Creates a keyed entry once: `created: false` for one already filed. */\n async function createOnce(\n ref: FirebaseFirestore.DocumentReference,\n data: Record<string, unknown>,\n ): Promise<boolean> {\n try {\n await ref.create({\n ...data,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n return true\n } catch (error) {\n if ((error as { code?: unknown })?.code === ALREADY_EXISTS) return false\n throw error\n }\n }\n\n return {\n async logActivity(request: PluginRecordActivityRequest): Promise<PluginRecordWrite> {\n if (!isCrmActivityKind(request.kind)) return refuse(400, `\"${String(request.kind)}\" isn't an activity.`)\n const admitted = await admit(request)\n if ('ok' in admitted) return admitted\n const { firestore, orgRef, link, visibleTo } = admitted\n const orgId = orgRef.id\n\n let ref: FirebaseFirestore.DocumentReference\n let activity: CrmActivity\n if (request.kind === 'email') {\n const email = request.email\n const key = email ? crmCapturedEmailKey(email.messageId, null) : null\n if (!email || !key) return refuse(400, 'An email is filed by its Message-ID.')\n ref = crmCapturedEmailActivityRef(firestore, orgId, key)\n activity = buildCrmCapturedEmailActivity({\n direction: email.direction === 'inbound' ? 'inbound' : 'outbound',\n subject: email.subject,\n excerpt: request.body,\n from: email.from,\n to: email.to,\n messageId: email.messageId,\n inReplyTo: email.inReplyTo ?? null,\n atMs: request.atMs,\n byUid: String(request.byUid ?? ''),\n byName: request.byName ?? null,\n link,\n hostId: request.hostId,\n visibleTo,\n })\n } else {\n const key = String(request.dedupeKey ?? '').trim()\n if (!key) return refuse(400, 'An entry that is not an email names its own key.')\n ref = crmActivityRef(firestore, orgId, keyedId(request.sourcePluginId, key))\n const byName = String(request.byName ?? '').trim()\n activity = {\n kind: request.kind,\n body: String(request.body ?? '').slice(0, NOTE_MAX),\n atMs: request.atMs,\n byUid: String(request.byUid ?? ''),\n ...(byName ? { byName } : {}),\n ...link,\n hostId: request.hostId,\n visibleTo,\n }\n }\n // Filed before: answered off the one read, ahead of the ceiling's count.\n if ((await ref.get()).exists) return { ok: true, id: ref.id, created: false }\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, link))) {\n return refuse(409, CRM_ACTIVITY_LOG_FULL_MESSAGE)\n }\n const stamped = { ...activity, sourcePluginId: request.sourcePluginId }\n const created =\n request.kind === 'email'\n ? (await createCrmEmailActivity(ref, stamped)) === 'created'\n : await createOnce(ref, stamped as unknown as Record<string, unknown>)\n return { ok: true, id: ref.id, created }\n },\n\n async createTask(request: PluginRecordTaskRequest): Promise<PluginRecordWrite> {\n const title = String(request.title ?? '').replace(/\\s+/g, ' ').trim().slice(0, TASK_TITLE_MAX)\n const key = String(request.dedupeKey ?? '').trim()\n if (!title || !key) return refuse(400, 'A task has a title and the caller’s own key.')\n if (!isCrmTaskKind(request.kind)) return refuse(400, `\"${String(request.kind)}\" isn't a task.`)\n if (!Number.isFinite(request.dueAtMs)) return refuse(400, 'A task has a due time.')\n const admitted = await admit(request)\n if ('ok' in admitted) return admitted\n const { firestore, orgRef, link, visibleTo } = admitted\n const ref = orgRef.collection(CRM_COLLECTIONS.tasks).doc(keyedId(request.sourcePluginId, key))\n const notes = String(request.notes ?? '').trim().slice(0, NOTE_MAX)\n const assigneeUid = String(request.assigneeUid ?? '').trim()\n const task: CrmTask = {\n title,\n ...(notes ? { notes } : {}),\n kind: request.kind,\n priority: 'normal',\n status: 'open',\n dueAtMs: request.dueAtMs,\n // The reminder a person's task gets (AGL-2659): the due time.\n remindAtMs: crmTaskReminderAfterEdit({ dueAtMs: request.dueAtMs, previous: null }),\n ...(assigneeUid ? { assigneeUid } : {}),\n createdByUid: String(request.createdByUid ?? ''),\n sourcePluginId: request.sourcePluginId,\n ...link,\n hostId: request.hostId,\n visibleTo,\n }\n const created = await createOnce(ref, task as unknown as Record<string, unknown>)\n if (created) {\n // The records the task names carry `nextTaskAtMs` (AGL-2661); a figure\n // that could not move is the Fields section's recompute's.\n await recomputeCrmNextTaskAt(firestore, orgRef.id, [link]).catch((error: unknown) => {\n console.error('[crm] next activity could not be recomputed', orgRef.id, error)\n })\n }\n return { ok: true, id: ref.id, created }\n },\n }\n}\n\n/** The platform's own dependencies. Specs build their own. */\nexport function defaultCrmRecordTimelineDeps(): CrmRecordTimelineDeps {\n return { firestore: () => firebaseAdmin.app().firestore() }\n}\n\n/** Registers the CRM as the workspace's record system on the timeline seam. */\nexport function registerCrmRecordTimelineWriter(\n deps: CrmRecordTimelineDeps = defaultCrmRecordTimelineDeps(),\n): void {\n registerPluginRecordTimelineWriter(createCrmRecordTimelineWriter(deps), { pluginId: BUNDLE_ID })\n}\n"],"names":["registerPluginRecordTimelineWriter","buildCrmCapturedEmailActivity","checkEntitlement","consentGroupForHost","CRM_ACTIVITY_LOG_FULL_MESSAGE","CRM_COLLECTIONS","crmActivityLogHasRoom","crmCapturedEmailKey","crmScopeTokens","crmTaskReminderAfterEdit","isCrmActivityKind","isCrmTaskKind","countCrmActivitiesForRecord","createCrmEmailActivity","crmActivityRef","crmCapturedEmailActivityRef","firebaseAdmin","recomputeCrmNextTaskAt","createHash","FieldValue","BUNDLE_ID","CRM_SUITE_FEATURE","ALREADY_EXISTS","TASK_TITLE_MAX","NOTE_MAX","refuse","status","error","ok","linkFields","link","contactId","String","companyId","dealId","keyedId","sourcePluginId","key","update","digest","slice","createCrmRecordTimelineWriter","deps","admit","context","snapshot","orgId","trim","hostId","firestore","orgRef","collection","doc","get","exists","org","data","visibleTo","createOnce","ref","create","createdAt","serverTimestamp","updatedAt","code","logActivity","request","kind","admitted","id","activity","email","messageId","direction","subject","excerpt","body","from","to","inReplyTo","atMs","byUid","byName","dedupeKey","created","stamped","createTask","title","replace","Number","isFinite","dueAtMs","tasks","notes","assigneeUid","task","priority","remindAtMs","previous","createdByUid","catch","console","defaultCrmRecordTimelineDeps","app","registerCrmRecordTimelineWriter","pluginId"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/record-timeline.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 registerPluginRecordTimelineWriter,\n type PluginRecordActivityRequest,\n type PluginRecordEntryContext,\n type PluginRecordTaskRequest,\n type PluginRecordTimelineWriter,\n type PluginRecordWrite,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport {\n buildCrmCapturedEmailActivity,\n checkEntitlement,\n consentGroupForHost,\n CRM_ACTIVITY_LOG_FULL_MESSAGE,\n CRM_COLLECTIONS,\n crmActivityLogHasRoom,\n crmCapturedEmailKey,\n crmScopeTokens,\n crmTaskReminderAfterEdit,\n isCrmActivityKind,\n isCrmTaskKind,\n type CrmActivity,\n type CrmActivityLink,\n type CrmTask,\n} from '@aglyn/aglyn/server'\nimport {\n countCrmActivitiesForRecord,\n createCrmEmailActivity,\n crmActivityRef,\n crmCapturedEmailActivityRef,\n firebaseAdmin,\n recomputeCrmNextTaskAt,\n} from '@aglyn/tenant-data-admin'\nimport { createHash } from 'crypto'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { CRM_SUITE_FEATURE } from './suite-gate'\n\n/**\n * THE CRM'S WRITER ON THE CORE'S RECORD-TIMELINE SEAM (AGL-2981).\n *\n * Another plugin files an activity or a task on a contact, company or deal —\n * an email a sequence sent, the reply it read, the call it asks for — and\n * this writes it as the CRM writes its own:\n *\n * 1. the workspace's plan carries the CRM (`features.crm`), as at every\n * CRM door; a Free workspace keeps no records to file on;\n * 2. the entry carries the scope a record made on that site carries\n * (`crmScopeTokens`), so exactly the people who see the record see it;\n * 3. an EMAIL is filed under the id the capture address files a copy of the\n * same message by (`cap_` + the digest of its `Message-ID`), and written\n * with `create()`: the send, a copy the rep forwarded to the capture\n * address, and a second run of the caller are one row;\n * 4. anything else — a note, a task — is filed under the caller's own key,\n * hashed with the caller's plugin id, and written once the same way;\n * 5. a record at its activity ceiling is refused with the sentence every\n * CRM writer answers with, before anything is written.\n *\n * Each entry names the plugin that filed it (`sourcePluginId`), and a task's\n * `nextTaskAtMs` is recomputed on the records it names, as every server\n * writer of a task does.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CrmRecordTimelineDeps {\n firestore(): Firestore\n}\n\n/** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */\nconst ALREADY_EXISTS = 6\n\n/** The longest task title the CRM keeps, as its own forms do. */\nconst TASK_TITLE_MAX = 200\n/** The longest note body or task note an entry filed here keeps. */\nconst NOTE_MAX = 2_000\n\nconst refuse = (status: 400 | 403 | 404 | 409, error: string): PluginRecordWrite => ({\n ok: false,\n status,\n error,\n})\n\n/** The record fields of a link, only the ones it names. */\nfunction linkFields(link: PluginRecordEntryContext['link']): CrmActivityLink {\n return {\n ...(link?.contactId ? { contactId: String(link.contactId) } : {}),\n ...(link?.companyId ? { companyId: String(link.companyId) } : {}),\n ...(link?.dealId ? { dealId: String(link.dealId) } : {}),\n // A lead (AGL-3234): host-scoped by path, so the entry carries the\n // site's scope like any record created from that site.\n ...(link?.leadId ? { leadId: String(link.leadId) } : {}),\n }\n}\n\n/** The document id a caller's key files under: its own namespace, never a raw key. */\nfunction keyedId(sourcePluginId: string, key: string): string {\n return `plg_${createHash('sha256').update(`${sourcePluginId}:${key}`).digest('hex').slice(0, 28)}`\n}\n\nexport function createCrmRecordTimelineWriter(deps: CrmRecordTimelineDeps): PluginRecordTimelineWriter {\n /** The org, its plan, the link and the scope an entry is stamped with. */\n async function admit(\n context: PluginRecordEntryContext,\n ): Promise<\n | PluginRecordWrite\n | { firestore: Firestore; orgRef: FirebaseFirestore.DocumentReference; link: CrmActivityLink; visibleTo: string[] }\n > {\n const orgId = String(context.orgId ?? '').trim()\n const hostId = String(context.hostId ?? '').trim()\n const link = linkFields(context.link)\n if (!orgId || !hostId || !String(context.sourcePluginId ?? '').trim()) {\n return refuse(400, 'An entry names its organization, its site and the plugin filing it.')\n }\n if (!link.contactId && !link.companyId && !link.dealId && !link.leadId) {\n return refuse(400, 'An entry is filed on a contact, a company, a deal or a lead.')\n }\n const firestore = deps.firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n const snapshot = await orgRef.get()\n if (!snapshot.exists) return refuse(404, 'That organization no longer exists.')\n const org = (snapshot.data() ?? {}) as Record<string, unknown>\n if (!checkEntitlement(org, CRM_SUITE_FEATURE)) {\n return refuse(403, \"This workspace's plan doesn't include the CRM.\")\n }\n return {\n firestore,\n orgRef,\n link,\n visibleTo: crmScopeTokens(org, consentGroupForHost(org, hostId)),\n }\n }\n\n /** Creates a keyed entry once: `created: false` for one already filed. */\n async function createOnce(\n ref: FirebaseFirestore.DocumentReference,\n data: Record<string, unknown>,\n ): Promise<boolean> {\n try {\n await ref.create({\n ...data,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n return true\n } catch (error) {\n if ((error as { code?: unknown })?.code === ALREADY_EXISTS) return false\n throw error\n }\n }\n\n return {\n async logActivity(request: PluginRecordActivityRequest): Promise<PluginRecordWrite> {\n if (!isCrmActivityKind(request.kind)) return refuse(400, `\"${String(request.kind)}\" isn't an activity.`)\n const admitted = await admit(request)\n if ('ok' in admitted) return admitted\n const { firestore, orgRef, link, visibleTo } = admitted\n const orgId = orgRef.id\n\n let ref: FirebaseFirestore.DocumentReference\n let activity: CrmActivity\n if (request.kind === 'email') {\n const email = request.email\n const key = email ? crmCapturedEmailKey(email.messageId, null) : null\n if (!email || !key) return refuse(400, 'An email is filed by its Message-ID.')\n ref = crmCapturedEmailActivityRef(firestore, orgId, key)\n activity = buildCrmCapturedEmailActivity({\n direction: email.direction === 'inbound' ? 'inbound' : 'outbound',\n subject: email.subject,\n excerpt: request.body,\n from: email.from,\n to: email.to,\n messageId: email.messageId,\n inReplyTo: email.inReplyTo ?? null,\n atMs: request.atMs,\n byUid: String(request.byUid ?? ''),\n byName: request.byName ?? null,\n link,\n hostId: request.hostId,\n visibleTo,\n })\n } else {\n const key = String(request.dedupeKey ?? '').trim()\n if (!key) return refuse(400, 'An entry that is not an email names its own key.')\n ref = crmActivityRef(firestore, orgId, keyedId(request.sourcePluginId, key))\n const byName = String(request.byName ?? '').trim()\n activity = {\n kind: request.kind,\n body: String(request.body ?? '').slice(0, NOTE_MAX),\n atMs: request.atMs,\n byUid: String(request.byUid ?? ''),\n ...(byName ? { byName } : {}),\n ...link,\n hostId: request.hostId,\n visibleTo,\n }\n }\n // Filed before: answered off the one read, ahead of the ceiling's count.\n if ((await ref.get()).exists) return { ok: true, id: ref.id, created: false }\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, link))) {\n return refuse(409, CRM_ACTIVITY_LOG_FULL_MESSAGE)\n }\n const stamped = { ...activity, sourcePluginId: request.sourcePluginId }\n const created =\n request.kind === 'email'\n ? (await createCrmEmailActivity(ref, stamped)) === 'created'\n : await createOnce(ref, stamped as unknown as Record<string, unknown>)\n return { ok: true, id: ref.id, created }\n },\n\n async createTask(request: PluginRecordTaskRequest): Promise<PluginRecordWrite> {\n const title = String(request.title ?? '').replace(/\\s+/g, ' ').trim().slice(0, TASK_TITLE_MAX)\n const key = String(request.dedupeKey ?? '').trim()\n if (!title || !key) return refuse(400, 'A task has a title and the caller’s own key.')\n if (!isCrmTaskKind(request.kind)) return refuse(400, `\"${String(request.kind)}\" isn't a task.`)\n if (!Number.isFinite(request.dueAtMs)) return refuse(400, 'A task has a due time.')\n const admitted = await admit(request)\n if ('ok' in admitted) return admitted\n const { firestore, orgRef, link, visibleTo } = admitted\n const ref = orgRef.collection(CRM_COLLECTIONS.tasks).doc(keyedId(request.sourcePluginId, key))\n const notes = String(request.notes ?? '').trim().slice(0, NOTE_MAX)\n const assigneeUid = String(request.assigneeUid ?? '').trim()\n const task: CrmTask = {\n title,\n ...(notes ? { notes } : {}),\n kind: request.kind,\n priority: 'normal',\n status: 'open',\n dueAtMs: request.dueAtMs,\n // The reminder a person's task gets (AGL-2659): the due time.\n remindAtMs: crmTaskReminderAfterEdit({ dueAtMs: request.dueAtMs, previous: null }),\n ...(assigneeUid ? { assigneeUid } : {}),\n createdByUid: String(request.createdByUid ?? ''),\n sourcePluginId: request.sourcePluginId,\n ...link,\n hostId: request.hostId,\n visibleTo,\n }\n const created = await createOnce(ref, task as unknown as Record<string, unknown>)\n if (created) {\n // The records the task names carry `nextTaskAtMs` (AGL-2661); a figure\n // that could not move is the Fields section's recompute's.\n await recomputeCrmNextTaskAt(firestore, orgRef.id, [link]).catch((error: unknown) => {\n console.error('[crm] next activity could not be recomputed', orgRef.id, error)\n })\n }\n return { ok: true, id: ref.id, created }\n },\n }\n}\n\n/** The platform's own dependencies. Specs build their own. */\nexport function defaultCrmRecordTimelineDeps(): CrmRecordTimelineDeps {\n return { firestore: () => firebaseAdmin.app().firestore() }\n}\n\n/** Registers the CRM as the workspace's record system on the timeline seam. */\nexport function registerCrmRecordTimelineWriter(\n deps: CrmRecordTimelineDeps = defaultCrmRecordTimelineDeps(),\n): void {\n registerPluginRecordTimelineWriter(createCrmRecordTimelineWriter(deps), { pluginId: BUNDLE_ID })\n}\n"],"names":["registerPluginRecordTimelineWriter","buildCrmCapturedEmailActivity","checkEntitlement","consentGroupForHost","CRM_ACTIVITY_LOG_FULL_MESSAGE","CRM_COLLECTIONS","crmActivityLogHasRoom","crmCapturedEmailKey","crmScopeTokens","crmTaskReminderAfterEdit","isCrmActivityKind","isCrmTaskKind","countCrmActivitiesForRecord","createCrmEmailActivity","crmActivityRef","crmCapturedEmailActivityRef","firebaseAdmin","recomputeCrmNextTaskAt","createHash","FieldValue","BUNDLE_ID","CRM_SUITE_FEATURE","ALREADY_EXISTS","TASK_TITLE_MAX","NOTE_MAX","refuse","status","error","ok","linkFields","link","contactId","String","companyId","dealId","leadId","keyedId","sourcePluginId","key","update","digest","slice","createCrmRecordTimelineWriter","deps","admit","context","snapshot","orgId","trim","hostId","firestore","orgRef","collection","doc","get","exists","org","data","visibleTo","createOnce","ref","create","createdAt","serverTimestamp","updatedAt","code","logActivity","request","kind","admitted","id","activity","email","messageId","direction","subject","excerpt","body","from","to","inReplyTo","atMs","byUid","byName","dedupeKey","created","stamped","createTask","title","replace","Number","isFinite","dueAtMs","tasks","notes","assigneeUid","task","priority","remindAtMs","previous","createdByUid","catch","console","defaultCrmRecordTimelineDeps","app","registerCrmRecordTimelineWriter","pluginId"],"mappings":"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"}
|
package/src/lib/server.js
CHANGED
|
@@ -69,6 +69,7 @@ import { crmTasksImportHandler } from "./server/tasks-import.js";
|
|
|
69
69
|
import { crmDealStageHandler } from "./server-deal-stage.js";
|
|
70
70
|
import { crmEmailSendHandler } from "./server/email-send.js";
|
|
71
71
|
import { leadConvertHandler } from "./server/lead-convert.js";
|
|
72
|
+
import { leadCreateHandler } from "./server/lead-create.js";
|
|
72
73
|
import { CONTACT_EMAIL_HISTORY_ROUTE, contactEmailHistoryHandler } from "./server/contact-email-history.js";
|
|
73
74
|
import { CONTACTS_MERGE_ROUTE, contactsMergeHandler } from "./server/contacts-merge.js";
|
|
74
75
|
import { CRM_ERASE_PERSON_ROUTE, crmErasePersonHandler } from "./server/erase-person.js";
|
|
@@ -580,6 +581,8 @@ import { CRM_RECIPE_INSTALL_ROUTE, CRM_RECIPE_STATUS_ROUTE, crmRecipeInstallHand
|
|
|
580
581
|
// write a browser cannot make alone, because only the server may create a
|
|
581
582
|
// contact through the dedupe-and-meter door.
|
|
582
583
|
registerPluginApiRoute('crm/lead-convert', leadConvertHandler);
|
|
584
|
+
// A lead entered by hand (AGL-3231): the New lead drawer's route.
|
|
585
|
+
registerPluginApiRoute('crm/leads-create', leadCreateHandler);
|
|
583
586
|
// The one READ behind a route (AGL-2616): the per-recipient delivery log
|
|
584
587
|
// is closed to clients, so a contact's campaign mail is projected here.
|
|
585
588
|
registerPluginApiRoute(CONTACT_EMAIL_HISTORY_ROUTE, contactEmailHistoryHandler);
|