@aglyn/plugins-crm 1.0.0-beta.178 → 1.0.0-beta.179
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/contact-detail-page.js +1 -0
- package/src/lib/components/contact-detail-page.js.map +1 -1
- package/src/lib/components/contact-properties-card.d.ts +6 -0
- package/src/lib/components/contact-properties-card.js +25 -2
- package/src/lib/components/contact-properties-card.js.map +1 -1
- package/src/lib/components/csv-import-drawer.d.ts +7 -0
- package/src/lib/components/csv-import-drawer.js +7 -0
- package/src/lib/components/csv-import-drawer.js.map +1 -1
- package/src/lib/components/fields-section.js +6 -0
- package/src/lib/components/fields-section.js.map +1 -1
- package/src/lib/components/lead-import-drawer.d.ts +3 -0
- package/src/lib/components/lead-import-drawer.js +29 -6
- package/src/lib/components/lead-import-drawer.js.map +1 -1
- package/src/lib/components/lead-properties-card.js +10 -12
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/lead-source-select.d.ts +30 -0
- package/src/lib/components/lead-source-select.js +96 -0
- package/src/lib/components/lead-source-select.js.map +1 -0
- package/src/lib/components/lead-source-values-card.d.ts +27 -0
- package/src/lib/components/lead-source-values-card.js +606 -0
- package/src/lib/components/lead-source-values-card.js.map +1 -0
- package/src/lib/components/leads-section.js +100 -4
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/new-lead-drawer.js +27 -11
- package/src/lib/components/new-lead-drawer.js.map +1 -1
- package/src/lib/hooks/use-lead-source-picklist.d.ts +23 -0
- package/src/lib/hooks/use-lead-source-picklist.js +50 -0
- package/src/lib/hooks/use-lead-source-picklist.js.map +1 -0
- package/src/lib/model/contact-record.d.ts +2 -0
- package/src/lib/model/contact-record.js +2 -1
- package/src/lib/model/contact-record.js.map +1 -1
- package/src/lib/model/contact-update.d.ts +6 -0
- package/src/lib/model/contact-update.js +1 -0
- package/src/lib/model/contact-update.js.map +1 -1
- package/src/lib/model/crm-lead-import.d.ts +10 -2
- package/src/lib/model/crm-lead-import.js +19 -1
- package/src/lib/model/crm-lead-import.js.map +1 -1
- package/src/lib/model/lead-filters.d.ts +13 -0
- package/src/lib/model/lead-filters.js +17 -1
- package/src/lib/model/lead-filters.js.map +1 -1
- package/src/lib/model/lead-source-values.d.ts +82 -0
- package/src/lib/model/lead-source-values.js +149 -0
- package/src/lib/model/lead-source-values.js.map +1 -0
- package/src/lib/server/contact-update.js +25 -2
- package/src/lib/server/contact-update.js.map +1 -1
- package/src/lib/server/lead-create.js +19 -0
- package/src/lib/server/lead-create.js.map +1 -1
- package/src/lib/server/lead-source-picklist.d.ts +42 -0
- package/src/lib/server/lead-source-picklist.js +52 -0
- package/src/lib/server/lead-source-picklist.js.map +1 -0
- package/src/lib/server/lead-source-values.d.ts +50 -0
- package/src/lib/server/lead-source-values.js +187 -0
- package/src/lib/server/lead-source-values.js.map +1 -0
- package/src/lib/server/leads-import.js +31 -2
- package/src/lib/server/leads-import.js.map +1 -1
- package/src/lib/server.js +5 -0
- package/src/lib/server.js.map +1 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { crmPicklistValueByLabel, crmPicklistValueId, normalizeCrmPicklistLabel } from "@aglyn/aglyn/app-utils/crm";
|
|
18
|
+
/**
|
|
19
|
+
* THE LEAD SOURCE LIST'S EDITS (AGL-3298), as pure moves over a picklist.
|
|
20
|
+
*
|
|
21
|
+
* The Fields page applies the ones that touch only the list — add, reorder,
|
|
22
|
+
* sort, activate, deactivate, default — as one client write of the whole
|
|
23
|
+
* document. The two that change what RECORDS hold — a rename and a delete
|
|
24
|
+
* with its replacement — go to `crm/lead-source-values`, which rewrites the
|
|
25
|
+
* list and every lead and contact holding the old label in one request;
|
|
26
|
+
* this module is where both sides agree what each move does.
|
|
27
|
+
*/ /** The route the two record-changing moves are posted to. */ export const CRM_LEAD_SOURCE_VALUES_ROUTE = 'crm/lead-source-values';
|
|
28
|
+
export const crmLeadSourceValuesRouteUrl = ()=>`/api/${CRM_LEAD_SOURCE_VALUES_ROUTE}`;
|
|
29
|
+
const DUPLICATE = (label)=>`“${label}” is already in the list.`;
|
|
30
|
+
/** Append a value. */ export function addPicklistValue(picklist, rawLabel) {
|
|
31
|
+
const label = normalizeCrmPicklistLabel(rawLabel);
|
|
32
|
+
if (!label) return {
|
|
33
|
+
ok: false,
|
|
34
|
+
error: 'Enter a name for the value.'
|
|
35
|
+
};
|
|
36
|
+
const existing = crmPicklistValueByLabel(picklist, label);
|
|
37
|
+
if (existing) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
error: existing.active ? DUPLICATE(existing.label) : `“${existing.label}” is in the list, inactive — activate it instead.`
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const value = {
|
|
44
|
+
id: crmPicklistValueId(label, picklist.values.map((entry)=>entry.id)),
|
|
45
|
+
label,
|
|
46
|
+
active: true
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
ok: true,
|
|
50
|
+
picklist: _extends({}, picklist, {
|
|
51
|
+
values: [
|
|
52
|
+
...picklist.values,
|
|
53
|
+
value
|
|
54
|
+
]
|
|
55
|
+
})
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Rename a value, keeping its id — the list half of the route's rename. */ export function renamePicklistValue(picklist, valueId, rawLabel) {
|
|
59
|
+
const label = normalizeCrmPicklistLabel(rawLabel);
|
|
60
|
+
if (!label) return {
|
|
61
|
+
ok: false,
|
|
62
|
+
error: 'Enter a name for the value.'
|
|
63
|
+
};
|
|
64
|
+
if (!picklist.values.some((value)=>value.id === valueId)) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
error: 'That value is no longer in the list.'
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const clash = crmPicklistValueByLabel(picklist, label);
|
|
71
|
+
if (clash && clash.id !== valueId) return {
|
|
72
|
+
ok: false,
|
|
73
|
+
error: DUPLICATE(clash.label)
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
ok: true,
|
|
77
|
+
picklist: _extends({}, picklist, {
|
|
78
|
+
values: picklist.values.map((value)=>value.id === valueId ? _extends({}, value, {
|
|
79
|
+
label
|
|
80
|
+
}) : value)
|
|
81
|
+
})
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Remove a value — the list half of the route's delete. The replacement,
|
|
86
|
+
* when there is one, must be another ACTIVE value: records are not moved
|
|
87
|
+
* onto a value the pickers no longer offer. A default that named the
|
|
88
|
+
* deleted value is cleared.
|
|
89
|
+
*/ export function deletePicklistValue(picklist, valueId, replaceWith) {
|
|
90
|
+
if (!picklist.values.some((value)=>value.id === valueId)) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
error: 'That value is no longer in the list.'
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (replaceWith !== null) {
|
|
97
|
+
const target = crmPicklistValueByLabel(picklist, replaceWith);
|
|
98
|
+
if (!target || target.id === valueId || !target.active) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
error: 'Pick an active value to move its records to, or none.'
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
picklist: {
|
|
108
|
+
values: picklist.values.filter((value)=>value.id !== valueId),
|
|
109
|
+
defaultValueId: picklist.defaultValueId === valueId ? null : picklist.defaultValueId
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** Turn a value on or off. Deactivating the default clears the default. */ export function setPicklistValueActive(picklist, valueId, active) {
|
|
114
|
+
return {
|
|
115
|
+
values: picklist.values.map((value)=>value.id === valueId ? _extends({}, value, {
|
|
116
|
+
active
|
|
117
|
+
}) : value),
|
|
118
|
+
defaultValueId: !active && picklist.defaultValueId === valueId ? null : picklist.defaultValueId
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** Make a value the default for new records, or clear the default with `null`. */ export function setPicklistDefault(picklist, valueId) {
|
|
122
|
+
const value = picklist.values.find((entry)=>entry.id === valueId);
|
|
123
|
+
return _extends({}, picklist, {
|
|
124
|
+
defaultValueId: (value == null ? void 0 : value.active) ? value.id : null
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/** Move the value at `from` to `to`, the drag and the arrows alike. */ export function movePicklistValue(picklist, from, to) {
|
|
128
|
+
const size = picklist.values.length;
|
|
129
|
+
if (from === to || from < 0 || to < 0 || from >= size || to >= size) return picklist;
|
|
130
|
+
const values = [
|
|
131
|
+
...picklist.values
|
|
132
|
+
];
|
|
133
|
+
const [moved] = values.splice(from, 1);
|
|
134
|
+
values.splice(to, 0, moved);
|
|
135
|
+
return _extends({}, picklist, {
|
|
136
|
+
values
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/** Every value in A–Z order by label, as Salesforce's "Sort alphabetically" does. */ export function sortPicklistValues(picklist) {
|
|
140
|
+
return _extends({}, picklist, {
|
|
141
|
+
values: [
|
|
142
|
+
...picklist.values
|
|
143
|
+
].sort((a, b)=>a.label.localeCompare(b.label, undefined, {
|
|
144
|
+
sensitivity: 'base'
|
|
145
|
+
}))
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
//# sourceMappingURL=lead-source-values.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/lead-source-values.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 type CrmPicklist,\n type CrmPicklistValue,\n crmPicklistValueByLabel,\n crmPicklistValueId,\n normalizeCrmPicklistLabel,\n} from '@aglyn/aglyn/app-utils/crm'\n\n/**\n * THE LEAD SOURCE LIST'S EDITS (AGL-3298), as pure moves over a picklist.\n *\n * The Fields page applies the ones that touch only the list — add, reorder,\n * sort, activate, deactivate, default — as one client write of the whole\n * document. The two that change what RECORDS hold — a rename and a delete\n * with its replacement — go to `crm/lead-source-values`, which rewrites the\n * list and every lead and contact holding the old label in one request;\n * this module is where both sides agree what each move does.\n */\n\n/** The route the two record-changing moves are posted to. */\nexport const CRM_LEAD_SOURCE_VALUES_ROUTE = 'crm/lead-source-values' as const\n\nexport const crmLeadSourceValuesRouteUrl = (): string => `/api/${CRM_LEAD_SOURCE_VALUES_ROUTE}`\n\n/** What the route is asked. `hostId` or `orgId` names the org, as every CRM route's scope does. */\nexport type LeadSourceValuesRequest = { hostId?: string | null; orgId?: string } & (\n | { action: 'rename'; valueId: string; label: string }\n | {\n action: 'delete'\n valueId: string\n /** An active value's label the records move to, or `null` to clear them. */\n replaceWith: string | null\n }\n)\n\n/** What the route answers. */\nexport interface LeadSourceValuesResponse {\n ok: true\n /** How many leads now hold the new label (or none). */\n leads: number\n /**\n * How many contact FACETS were rewritten — one per holder that held the\n * old label, so a person two sites both filed under it counts twice.\n */\n contacts: number\n}\n\n/** Why a move is refused, in the sentence the page shows. */\nexport type PicklistMove = { ok: true; picklist: CrmPicklist } | { ok: false; error: string }\n\nconst DUPLICATE = (label: string) => `“${label}” is already in the list.`\n\n/** Append a value. */\nexport function addPicklistValue(picklist: CrmPicklist, rawLabel: string): PicklistMove {\n const label = normalizeCrmPicklistLabel(rawLabel)\n if (!label) return { ok: false, error: 'Enter a name for the value.' }\n const existing = crmPicklistValueByLabel(picklist, label)\n if (existing) {\n return {\n ok: false,\n error: existing.active\n ? DUPLICATE(existing.label)\n : `“${existing.label}” is in the list, inactive — activate it instead.`,\n }\n }\n const value: CrmPicklistValue = {\n id: crmPicklistValueId(\n label,\n picklist.values.map((entry) => entry.id),\n ),\n label,\n active: true,\n }\n return { ok: true, picklist: { ...picklist, values: [...picklist.values, value] } }\n}\n\n/** Rename a value, keeping its id — the list half of the route's rename. */\nexport function renamePicklistValue(\n picklist: CrmPicklist,\n valueId: string,\n rawLabel: string,\n): PicklistMove {\n const label = normalizeCrmPicklistLabel(rawLabel)\n if (!label) return { ok: false, error: 'Enter a name for the value.' }\n if (!picklist.values.some((value) => value.id === valueId)) {\n return { ok: false, error: 'That value is no longer in the list.' }\n }\n const clash = crmPicklistValueByLabel(picklist, label)\n if (clash && clash.id !== valueId) return { ok: false, error: DUPLICATE(clash.label) }\n return {\n ok: true,\n picklist: {\n ...picklist,\n values: picklist.values.map((value) => (value.id === valueId ? { ...value, label } : value)),\n },\n }\n}\n\n/**\n * Remove a value — the list half of the route's delete. The replacement,\n * when there is one, must be another ACTIVE value: records are not moved\n * onto a value the pickers no longer offer. A default that named the\n * deleted value is cleared.\n */\nexport function deletePicklistValue(\n picklist: CrmPicklist,\n valueId: string,\n replaceWith: string | null,\n): PicklistMove {\n if (!picklist.values.some((value) => value.id === valueId)) {\n return { ok: false, error: 'That value is no longer in the list.' }\n }\n if (replaceWith !== null) {\n const target = crmPicklistValueByLabel(picklist, replaceWith)\n if (!target || target.id === valueId || !target.active) {\n return { ok: false, error: 'Pick an active value to move its records to, or none.' }\n }\n }\n return {\n ok: true,\n picklist: {\n values: picklist.values.filter((value) => value.id !== valueId),\n defaultValueId: picklist.defaultValueId === valueId ? null : picklist.defaultValueId,\n },\n }\n}\n\n/** Turn a value on or off. Deactivating the default clears the default. */\nexport function setPicklistValueActive(\n picklist: CrmPicklist,\n valueId: string,\n active: boolean,\n): CrmPicklist {\n return {\n values: picklist.values.map((value) => (value.id === valueId ? { ...value, active } : value)),\n defaultValueId:\n !active && picklist.defaultValueId === valueId ? null : picklist.defaultValueId,\n }\n}\n\n/** Make a value the default for new records, or clear the default with `null`. */\nexport function setPicklistDefault(picklist: CrmPicklist, valueId: string | null): CrmPicklist {\n const value = picklist.values.find((entry) => entry.id === valueId)\n return { ...picklist, defaultValueId: value?.active ? value.id : null }\n}\n\n/** Move the value at `from` to `to`, the drag and the arrows alike. */\nexport function movePicklistValue(picklist: CrmPicklist, from: number, to: number): CrmPicklist {\n const size = picklist.values.length\n if (from === to || from < 0 || to < 0 || from >= size || to >= size) return picklist\n const values = [...picklist.values]\n const [moved] = values.splice(from, 1)\n values.splice(to, 0, moved)\n return { ...picklist, values }\n}\n\n/** Every value in A–Z order by label, as Salesforce's \"Sort alphabetically\" does. */\nexport function sortPicklistValues(picklist: CrmPicklist): CrmPicklist {\n return {\n ...picklist,\n values: [...picklist.values].sort((a, b) =>\n a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }),\n ),\n }\n}\n"],"names":["crmPicklistValueByLabel","crmPicklistValueId","normalizeCrmPicklistLabel","CRM_LEAD_SOURCE_VALUES_ROUTE","crmLeadSourceValuesRouteUrl","DUPLICATE","label","addPicklistValue","picklist","rawLabel","ok","error","existing","active","value","id","values","map","entry","renamePicklistValue","valueId","some","clash","deletePicklistValue","replaceWith","target","filter","defaultValueId","setPicklistValueActive","setPicklistDefault","find","movePicklistValue","from","to","size","length","moved","splice","sortPicklistValues","sort","a","b","localeCompare","undefined","sensitivity"],"mappings":"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"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
1
2
|
/**
|
|
2
3
|
* @license
|
|
3
4
|
* Copyright 2026 Aglyn LLC
|
|
@@ -67,7 +68,7 @@
|
|
|
67
68
|
* link: summed per company and settled once the contacts are written, so a
|
|
68
69
|
* company deleted in between cannot fail the save of the people at it — the
|
|
69
70
|
* company page's live aggregate is what corrects a count that did not move.
|
|
70
|
-
*/ import { consentGroupForHost, CONTACT_FIELDS_MAX_PER_ORG, campaignMembershipValue, contactCampaignFieldPath, contactFacetPath, contactPrimaryGroup, CRM_COLLECTIONS, CRM_MEDIA_IDS_FIELD, crmReadTokens, isOrgWideMember, normalizeAddress, normalizeCrmMediaIds, normalizePhone, planContactCompanyLink, readContactCompanyLink, readContactFacet, readCrmCustomInput, visibleToTokens } from "@aglyn/aglyn/server";
|
|
71
|
+
*/ import { consentGroupForHost, CONTACT_FIELDS_MAX_PER_ORG, campaignMembershipValue, contactCampaignFieldPath, contactFacetPath, contactPrimaryGroup, CRM_COLLECTIONS, CRM_LEAD_SOURCE_PICKLIST, CRM_MEDIA_IDS_FIELD, crmReadTokens, effectiveCrmLeadSourcePicklist, isOrgWideMember, judgeCrmLeadSource, normalizeAddress, normalizeCrmMediaIds, normalizeCrmPicklistLabel, normalizePhone, planContactCompanyLink, readContactCompanyLink, readContactFacet, readCrmCustomInput, visibleToTokens } from "@aglyn/aglyn/server";
|
|
71
72
|
import { companyContactsCountFields, contactCompanyLinkFields, firebaseAdmin, resolveOrgMembership } from "@aglyn/tenant-data-admin";
|
|
72
73
|
import { FieldValue } from "firebase-admin/firestore";
|
|
73
74
|
import { CONTACT_UPDATE_FIELDS, CRM_CONTACT_UPDATE_MAX } from "../model/contact-update.js";
|
|
@@ -79,6 +80,7 @@ import { authorizeCrmWriter, canReach } from "./task-routes.js";
|
|
|
79
80
|
name: "Editing a contact's profile",
|
|
80
81
|
phone: "Editing a contact's profile",
|
|
81
82
|
jobTitle: "Editing a contact's profile",
|
|
83
|
+
leadSource: "Editing a contact's profile",
|
|
82
84
|
address: "Editing a contact's profile",
|
|
83
85
|
notes: "Editing a contact's notes",
|
|
84
86
|
tags: 'Tagging a contact',
|
|
@@ -132,6 +134,9 @@ import { authorizeCrmWriter, canReach } from "./task-routes.js";
|
|
|
132
134
|
fields.phone = phone;
|
|
133
135
|
}
|
|
134
136
|
if ('jobTitle' in raw) fields.jobTitle = typed(raw['jobTitle'], 120);
|
|
137
|
+
// Held to the label's shape here; judged against the org's list by the
|
|
138
|
+
// handler, which alone can read it (AGL-3298).
|
|
139
|
+
if ('leadSource' in raw) fields.leadSource = normalizeCrmPicklistLabel(raw['leadSource']);
|
|
135
140
|
if ('address' in raw) {
|
|
136
141
|
const address = raw['address'];
|
|
137
142
|
fields.address = address && typeof address === 'object' && !Array.isArray(address) ? normalizeAddress(address) : null;
|
|
@@ -257,6 +262,7 @@ const refused = (contactId, error)=>({
|
|
|
257
262
|
update['phone'] = text(fields.phone);
|
|
258
263
|
}
|
|
259
264
|
if (fields.jobTitle !== undefined) update[path('jobTitle')] = text(fields.jobTitle);
|
|
265
|
+
if (fields.leadSource !== undefined) update[path('leadSource')] = text(fields.leadSource);
|
|
260
266
|
if (fields.address !== undefined) {
|
|
261
267
|
var _fields_address;
|
|
262
268
|
update[path('address')] = (_fields_address = fields.address) != null ? _fields_address : FieldValue.delete();
|
|
@@ -420,6 +426,11 @@ export const crmContactUpdateHandler = async (req, res)=>{
|
|
|
420
426
|
}
|
|
421
427
|
custom = judged.values;
|
|
422
428
|
}
|
|
429
|
+
/*
|
|
430
|
+
* THE ORG'S LEAD SOURCES (AGL-3298), read only when the body names one.
|
|
431
|
+
* Judged per contact below, because a contact keeps the value it
|
|
432
|
+
* already holds even when the list has since deactivated it.
|
|
433
|
+
*/ const leadSources = fields.leadSource ? effectiveCrmLeadSourcePicklist((await orgRef.collection(CRM_COLLECTIONS.picklists).doc(CRM_LEAD_SOURCE_PICKLIST).get()).data()) : null;
|
|
423
434
|
if (fields.ownerUid) {
|
|
424
435
|
const owner = await resolveOrgMembership(fields.ownerUid, writer.orgId).catch(()=>null);
|
|
425
436
|
if (!(owner == null ? void 0 : owner.member)) {
|
|
@@ -470,7 +481,19 @@ export const crmContactUpdateHandler = async (req, res)=>{
|
|
|
470
481
|
outcomes.set(contactId, refused(contactId, NO_HOLDER_REFUSAL));
|
|
471
482
|
return;
|
|
472
483
|
}
|
|
473
|
-
|
|
484
|
+
let rowFields = fields;
|
|
485
|
+
if (leadSources && fields.leadSource) {
|
|
486
|
+
var _judged_value;
|
|
487
|
+
const judged = judgeCrmLeadSource(leadSources, fields.leadSource, readContactFacet(data, group.groupId).leadSource);
|
|
488
|
+
if (judged.ok === false) {
|
|
489
|
+
outcomes.set(contactId, refused(contactId, judged.error));
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
rowFields = _extends({}, fields, {
|
|
493
|
+
leadSource: (_judged_value = judged.value) != null ? _judged_value : ''
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
const patch = contactPatch(data, group.groupId, rowFields, custom, linkedCompanyName);
|
|
474
497
|
if ('refused' in patch) {
|
|
475
498
|
outcomes.set(contactId, refused(contactId, patch.refused));
|
|
476
499
|
return;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contact-update.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/contact-update` — one holder's profile fields on one or\n * more contacts (AGL-2804).\n *\n * Body: `{ hostId, contactIds, set }` under a site, or `{ orgId, hostId?,\n * contactIds, set }` at the organization level, where `set` is\n * `ContactUpdateFields`. Answers `{ ok, results }`: one outcome per contact,\n * in the order the ids were sent.\n *\n * ## Why every facet write comes here\n *\n * A contact's fields live in the facet of the holder that keeps them —\n * `facets.{groupId}.*` — and the Firestore rules cannot tell one field of a\n * facet from another: the holder is a map key a rule cannot address, and a\n * write's affected keys name only `facets`. So the rules let a client update\n * change nothing in a facet beyond letting a holder go, and the console's\n * edits arrive here, where the plan is asked about the fields the request\n * actually carries.\n *\n * ## Who may call it\n *\n * The CRM writer the task routes admit (`authorizeCrmWriter`): under a site,\n * a member whose role writes org data, who holds `data.manage` and whose\n * access reaches the site; at the organization level, an org-wide member\n * with the permission. Each contact must then be one the caller may read —\n * the rules' `canReadScoped()` — and, under a site, one the site's hub lists.\n *\n * ## The plan\n *\n * Every field is the CRM's (AGL-2790), and a plan without the CRM has none\n * of it (AGL-2851), so it edits no contact: not the owner, company, custom\n * values or files, and not the profile a capture writes (name, phone, job\n * title, address), the tags, the notes or the campaign filing either. A\n * request carrying any field is refused to such a plan once the caller is\n * known, and before a contact is read — staff included, because the plan is\n * a fact about the workspace. Erasing a person is another route, open on\n * every plan.\n *\n * ## One holder, by dotted path\n *\n * Under a site the facet is the site's consent group's; at the organization\n * level it is each contact's own primary holder, the facet the record page\n * shows there. Every field is one dotted path — a nested `facets` object\n * would replace every other holder's map — and a text field sent empty is\n * deleted rather than stored blank, so \"no phone\" has one shape.\n *\n * ## Many contacts, one commit\n *\n * The contacts are read together and written in one batch. A batch that\n * fails is retried contact by contact, so the one that could not be written\n * is named and the rest still land. A company's contacts count follows the\n * link: summed per company and settled once the contacts are written, so a\n * company deleted in between cannot fail the save of the people at it — the\n * company page's live aggregate is what corrects a count that did not move.\n */\n\nimport {\n type AglynPostalAddress,\n consentGroupForHost,\n CONTACT_FIELDS_MAX_PER_ORG,\n type ContactCompanyLinkPlan,\n type ContactCustomValue,\n type ContactFieldDefinition,\n campaignMembershipValue,\n contactCampaignFieldPath,\n contactFacetPath,\n contactPrimaryGroup,\n CRM_COLLECTIONS,\n CRM_MEDIA_IDS_FIELD,\n crmReadTokens,\n isOrgWideMember,\n normalizeAddress,\n normalizeCrmMediaIds,\n normalizePhone,\n type PluginApiHandler,\n planContactCompanyLink,\n readContactCompanyLink,\n readContactFacet,\n readCrmCustomInput,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n companyContactsCountFields,\n contactCompanyLinkFields,\n firebaseAdmin,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n CONTACT_UPDATE_FIELDS,\n type ContactUpdateFields,\n type ContactUpdateOutcome,\n type ContactUpdateResponse,\n CRM_CONTACT_UPDATE_MAX,\n} from '../model/contact-update'\nimport {\n CONTACT_NOTES_MAX,\n CONTACT_PHONE_REFUSAL,\n CONTACT_TAGS_MAX,\n normalizeTags,\n readBulkTag,\n typed,\n} from './contact-profile'\nimport { readCrmRouteScope } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\nimport { authorizeCrmWriter, canReach, type Writer } from './task-routes'\n\n/** What the suite gate names for each field a request can carry. */\nconst SUITE_ACTS: Record<keyof ContactUpdateFields, string> = {\n name: \"Editing a contact's profile\",\n phone: \"Editing a contact's profile\",\n jobTitle: \"Editing a contact's profile\",\n address: \"Editing a contact's profile\",\n notes: \"Editing a contact's notes\",\n tags: 'Tagging a contact',\n addTag: 'Tagging a contact',\n removeTag: 'Tagging a contact',\n campaignIds: 'Filing a contact under a campaign',\n ownerUid: \"Assigning a contact's owner\",\n companyId: 'Filing a contact under a company',\n companyName: 'Filing a contact under a company',\n custom: \"Editing a contact's custom fields\",\n mediaIds: 'Attaching files to a contact',\n}\n\n/** A contact read at the organization level that no site has captured. */\nconst NO_HOLDER_REFUSAL = 'No site holds this contact yet, so it has no profile to edit.'\n\n/**\n * The act a request is refused for on a plan without the CRM: the first\n * field it carries, in the order the route reads them. Every field is the\n * CRM's, so whichever field a request names is the act its refusal names.\n */\nexport function contactUpdateSuiteAct(fields: ContactUpdateFields): string {\n const field = CONTACT_UPDATE_FIELDS.find((key) => fields[key] !== undefined)\n return field ? SUITE_ACTS[field] : \"Editing a contact's profile\"\n}\n\n/**\n * A body's `set`, read into the fields it names, or the sentence refusing\n * it. A key the route does not save is refused rather than dropped: a save\n * that silently lost a field would read as a save that kept it.\n */\nexport function readContactUpdateFields(\n input: unknown,\n): { ok: true; fields: ContactUpdateFields } | { ok: false; error: string } {\n if (!input || typeof input !== 'object' || Array.isArray(input)) {\n return { ok: false, error: 'Name the fields to save.' }\n }\n const raw = input as Record<string, unknown>\n const unknownKey = Object.keys(raw).find(\n (key) => !(CONTACT_UPDATE_FIELDS as readonly string[]).includes(key),\n )\n if (unknownKey) {\n return { ok: false, error: `\"${unknownKey}\" is not a field a contact's profile saves.` }\n }\n const fields: ContactUpdateFields = {}\n if ('name' in raw) fields.name = typed(raw['name'], 120)\n if ('phone' in raw) {\n const text = typed(raw['phone'], 40)\n const phone = text ? normalizePhone(text) : ''\n if (phone === null) return { ok: false, error: CONTACT_PHONE_REFUSAL }\n fields.phone = phone\n }\n if ('jobTitle' in raw) fields.jobTitle = typed(raw['jobTitle'], 120)\n if ('address' in raw) {\n const address = raw['address']\n fields.address =\n address && typeof address === 'object' && !Array.isArray(address)\n ? normalizeAddress(address as AglynPostalAddress)\n : null\n }\n if ('notes' in raw) fields.notes = String(raw['notes'] ?? '').slice(0, CONTACT_NOTES_MAX)\n // One write may carry one transform per field, so the tags are replaced,\n // added to or taken from — never two of those at once.\n if (['tags', 'addTag', 'removeTag'].filter((key) => key in raw).length > 1) {\n return { ok: false, error: 'Send the tags, a tag to add or a tag to remove — one of them.' }\n }\n if ('tags' in raw) {\n if (!Array.isArray(raw['tags'])) return { ok: false, error: 'Tags must be a list.' }\n fields.tags = normalizeTags(raw['tags'])\n }\n for (const key of ['addTag', 'removeTag'] as const) {\n if (!(key in raw)) continue\n const tag = readBulkTag(raw[key])\n if (!tag) return { ok: false, error: 'Name the tag.' }\n fields[key] = tag\n }\n if ('campaignIds' in raw) {\n const ids = raw['campaignIds']\n if (!Array.isArray(ids)) return { ok: false, error: 'Campaigns must be a list.' }\n fields.campaignIds = campaignMembershipValue(ids.map((id) => String(id ?? '')))\n }\n if ('ownerUid' in raw) fields.ownerUid = typed(raw['ownerUid'], 128)\n if ('companyId' in raw) {\n const companyId = raw['companyId'] === null ? null : typed(raw['companyId'], 128)\n if (companyId !== null && (!companyId || companyId.includes('/'))) {\n return { ok: false, error: 'The company could not be read.' }\n }\n fields.companyId = companyId\n }\n if ('companyName' in raw) fields.companyName = typed(raw['companyName'], 120)\n if ('custom' in raw) {\n const custom = raw['custom']\n if (!custom || typeof custom !== 'object' || Array.isArray(custom)) {\n return { ok: false, error: 'Custom fields must be values keyed by field key.' }\n }\n fields.custom = custom as Record<string, ContactCustomValue>\n }\n if ('mediaIds' in raw) {\n if (!Array.isArray(raw['mediaIds'])) {\n return { ok: false, error: 'Files must be a list of ids.' }\n }\n fields.mediaIds = normalizeCrmMediaIds(raw['mediaIds'])\n }\n if (!Object.keys(fields).length) return { ok: false, error: 'Name the fields to save.' }\n return { ok: true, fields }\n}\n\n/** The ids a body names, deduplicated — or `null` when any is unreadable or there are too many. */\nfunction readContactIds(value: unknown): string[] | null {\n if (!Array.isArray(value) || !value.length || value.length > CRM_CONTACT_UPDATE_MAX) {\n return null\n }\n const ids = value.map((id) => typed(id, 200))\n if (ids.some((id) => !id || id.includes('/'))) return null\n return [...new Set(ids)]\n}\n\n/**\n * Under a site, whether the caller's access reaches it. The rules could not\n * ask this of a facet write — they cannot see which holder a write is for —\n * and a collaborator on one site must not write another site's notes on a\n * person both of them hold.\n */\nfunction reachesSite(writer: Writer, hostId: string): boolean {\n return (\n writer.staff ||\n isOrgWideMember(writer.member) ||\n Boolean((writer.member?.hostAccess as Record<string, unknown> | undefined)?.[hostId])\n )\n}\n\nconst refused = (contactId: string, error: string): ContactUpdateOutcome => ({\n contactId,\n ok: false,\n error,\n})\n\ninterface ContactWrite {\n contactId: string\n ref: FirebaseFirestore.DocumentReference\n update: Record<string, unknown>\n counts: ContactCompanyLinkPlan['counts']\n}\n\n/**\n * One contact's update for one holder, or the sentence refusing the row.\n * `update` is `null` when the document already says everything asked — a tag\n * the row already carries, a link already made.\n */\nfunction contactPatch(\n contact: Record<string, unknown>,\n groupId: string,\n fields: ContactUpdateFields,\n custom: Record<string, ContactCustomValue> | undefined,\n linkedCompanyName: string,\n):\n | { update: Record<string, unknown> | null; counts: ContactCompanyLinkPlan['counts'] }\n | { refused: string } {\n const path = (field: string) => contactFacetPath(groupId, field)\n /** A text field as stored: the value, or the field's absence. */\n const text = (value: string) => value || FieldValue.delete()\n const update: Record<string, unknown> = {}\n let counts: ContactCompanyLinkPlan['counts'] = []\n\n if (fields.name !== undefined) update[path('name')] = text(fields.name)\n if (fields.phone !== undefined) {\n update[path('phone')] = text(fields.phone)\n // The search echo — see `HostContact.phone`.\n update['phone'] = text(fields.phone)\n }\n if (fields.jobTitle !== undefined) update[path('jobTitle')] = text(fields.jobTitle)\n if (fields.address !== undefined) {\n update[path('address')] = fields.address ?? FieldValue.delete()\n }\n if (fields.notes !== undefined) update[path('notes')] = fields.notes\n if (fields.tags !== undefined) update[path('tags')] = fields.tags\n\n const held = (readContactFacet(contact, groupId).tags ?? []).map((tag) =>\n String(tag).toLowerCase(),\n )\n if (fields.addTag && !held.includes(fields.addTag)) {\n // The record page's cap, which a selection must not slip past.\n if (held.length >= CONTACT_TAGS_MAX) {\n return { refused: `already has ${CONTACT_TAGS_MAX} tags` }\n }\n update[path('tags')] = FieldValue.arrayUnion(fields.addTag)\n }\n if (fields.removeTag && held.includes(fields.removeTag)) {\n update[path('tags')] = FieldValue.arrayRemove(fields.removeTag)\n }\n if (fields.campaignIds !== undefined) {\n update[contactCampaignFieldPath(groupId)] = fields.campaignIds\n }\n if (fields.ownerUid !== undefined) update[path('ownerUid')] = text(fields.ownerUid)\n\n if (fields.companyId !== undefined) {\n const plan = planContactCompanyLink(\n readContactCompanyLink(contact, groupId),\n fields.companyId,\n )\n if (plan) {\n Object.assign(update, contactCompanyLinkFields(plan, groupId))\n counts = plan.counts\n if (fields.companyName === undefined) {\n // The label follows the link: the company's own name, or nothing.\n const label = plan.companyId ? linkedCompanyName : ''\n update[path('companyName')] = text(label)\n // The search echo — see `HostContact.companyName`.\n update['companyName'] = text(label)\n }\n }\n }\n if (fields.companyName !== undefined) {\n update[path('companyName')] = text(fields.companyName)\n update['companyName'] = text(fields.companyName)\n }\n for (const [key, value] of Object.entries(custom ?? {})) {\n update[path(`custom.${key}`)] = value\n }\n if (fields.mediaIds !== undefined) update[path(CRM_MEDIA_IDS_FIELD)] = fields.mediaIds\n\n if (!Object.keys(update).length) return { update: null, counts }\n update['updatedAt'] = FieldValue.serverTimestamp()\n return { update, counts }\n}\n\n/**\n * Every write in one batch, and — when the batch is refused — each on its\n * own, so the contact that could not be written is the only one reported.\n * Answers the ids that were not written.\n */\nasync function commitContactWrites(\n firestore: FirebaseFirestore.Firestore,\n writes: readonly ContactWrite[],\n): Promise<Set<string>> {\n const failed = new Set<string>()\n if (!writes.length) return failed\n const batch = firestore.batch()\n for (const write of writes) batch.update(write.ref, write.update)\n try {\n await batch.commit()\n return failed\n } catch {\n // A batch names no document; the pass below finds the one it was.\n }\n for (const write of writes) {\n try {\n await write.ref.update(write.update)\n } catch (error) {\n console.error('[crm] contact-update could not write a contact', write.contactId, error)\n failed.add(write.contactId)\n }\n }\n return failed\n}\n\n/** Each company's contacts count moved once, by the sum of what the written links moved. */\nasync function settleCompanyCounts(\n companies: FirebaseFirestore.CollectionReference,\n writes: readonly ContactWrite[],\n): Promise<void> {\n const deltas = new Map<string, number>()\n for (const write of writes) {\n for (const count of write.counts) {\n deltas.set(count.companyId, (deltas.get(count.companyId) ?? 0) + count.delta)\n }\n }\n await Promise.all(\n [...deltas]\n .filter(([, delta]) => delta !== 0)\n .map(([companyId, delta]) =>\n companies\n .doc(companyId)\n .update(companyContactsCountFields(delta))\n .catch((error: unknown) => {\n console.error('[crm] company contacts count could not move', companyId, delta, error)\n }),\n ),\n )\n}\n\nexport const crmContactUpdateHandler: 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 body = (req.body ?? {}) as Record<string, unknown>\n const scope = readCrmRouteScope(body)\n if (!scope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const contactIds = readContactIds(body['contactIds'])\n if (!contactIds) {\n res.status(400).json({ error: `Name between 1 and ${CRM_CONTACT_UPDATE_MAX} contacts.` })\n return\n }\n const read = readContactUpdateFields(body['set'])\n if (read.ok === false) {\n res.status(400).json({ error: read.error })\n return\n }\n const { fields } = read\n\n try {\n const writer = await authorizeCrmWriter(req, scope, { suiteAct: null })\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n if (scope.level === 'site' && !reachesSite(writer, scope.hostId)) {\n res.status(403).json({ error: 'Your access to this organization does not reach this site.' })\n return\n }\n // The plan after the person, and before any contact is read (AGL-2787).\n const suite = crmSuiteRefusal(writer.org, contactUpdateSuiteAct(fields))\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(writer.orgId)\n const siteGroup =\n scope.level === 'site' ? consentGroupForHost(writer.org, scope.hostId) : null\n const siteTokens = siteGroup ? crmReadTokens(siteGroup) : null\n\n let custom: Record<string, ContactCustomValue> | undefined\n if (fields.custom) {\n const definitions = await orgRef\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(CONTACT_FIELDS_MAX_PER_ORG)\n .get()\n const judged = readCrmCustomInput(\n fields.custom,\n definitions.docs.map((doc) => doc.data() as ContactFieldDefinition),\n 'contact',\n )\n if ('errors' in judged) {\n res.status(400).json({\n error: Object.values(judged.errors)[0] ?? 'A custom value could not be saved.',\n })\n return\n }\n custom = judged.values\n }\n\n if (fields.ownerUid) {\n const owner = await resolveOrgMembership(fields.ownerUid, writer.orgId).catch(() => null)\n if (!owner?.member) {\n res.status(400).json({ error: 'The owner is not a member of this organization.' })\n return\n }\n }\n\n // The company a link names has to exist and be visible to the caller and\n // the site, or a person could be filed under a record nobody here can open.\n let linkedCompanyName = ''\n if (fields.companyId) {\n const company = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .doc(fields.companyId)\n .get()\n const tokens = company.exists ? (company.get('visibleTo') as string[] | undefined) : undefined\n const visible =\n company.exists &&\n canReach(writer, tokens) &&\n (!siteTokens || visibleToTokens(tokens, siteTokens))\n if (!visible) {\n res.status(404).json({ error: 'Unknown company' })\n return\n }\n linkedCompanyName = typed(company.get('name'), 120)\n }\n\n const contacts = orgRef.collection('contacts')\n const snapshots = await firestore.getAll(...contactIds.map((id) => contacts.doc(id)))\n const outcomes = new Map<string, ContactUpdateOutcome>()\n const writes: ContactWrite[] = []\n snapshots.forEach((snapshot, index) => {\n const contactId = contactIds[index]\n if (!snapshot.exists) {\n outcomes.set(contactId, refused(contactId, 'That contact no longer exists.'))\n return\n }\n const data = (snapshot.data() ?? {}) as Record<string, unknown>\n const visibleTo = data['visibleTo'] as string[] | undefined\n if (!canReach(writer, visibleTo)) {\n outcomes.set(contactId, refused(contactId, 'That contact is not visible to you.'))\n return\n }\n if (siteTokens && !visibleToTokens(visibleTo, siteTokens)) {\n outcomes.set(contactId, refused(contactId, 'That contact is not visible to this site.'))\n return\n }\n const group = siteGroup ?? contactPrimaryGroup(data, writer.org)\n if (!group.groupId) {\n outcomes.set(contactId, refused(contactId, NO_HOLDER_REFUSAL))\n return\n }\n const patch = contactPatch(data, group.groupId, fields, custom, linkedCompanyName)\n if ('refused' in patch) {\n outcomes.set(contactId, refused(contactId, patch.refused))\n return\n }\n outcomes.set(contactId, { contactId, ok: true })\n if (patch.update) {\n writes.push({ contactId, ref: snapshot.ref, update: patch.update, counts: patch.counts })\n }\n })\n\n const failed = await commitContactWrites(firestore, writes)\n for (const contactId of failed) {\n outcomes.set(contactId, refused(contactId, 'The contact could not be saved.'))\n }\n await settleCompanyCounts(\n orgRef.collection(CRM_COLLECTIONS.companies),\n writes.filter((write) => !failed.has(write.contactId)),\n )\n\n const answer: ContactUpdateResponse = {\n ok: true,\n results: contactIds.map(\n (contactId) => outcomes.get(contactId) ?? refused(contactId, 'The contact could not be saved.'),\n ),\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] contact-update failed', error)\n res.status(500).json({ error: 'The contact could not be saved.' })\n }\n}\n"],"names":["consentGroupForHost","CONTACT_FIELDS_MAX_PER_ORG","campaignMembershipValue","contactCampaignFieldPath","contactFacetPath","contactPrimaryGroup","CRM_COLLECTIONS","CRM_MEDIA_IDS_FIELD","crmReadTokens","isOrgWideMember","normalizeAddress","normalizeCrmMediaIds","normalizePhone","planContactCompanyLink","readContactCompanyLink","readContactFacet","readCrmCustomInput","visibleToTokens","companyContactsCountFields","contactCompanyLinkFields","firebaseAdmin","resolveOrgMembership","FieldValue","CONTACT_UPDATE_FIELDS","CRM_CONTACT_UPDATE_MAX","CONTACT_NOTES_MAX","CONTACT_PHONE_REFUSAL","CONTACT_TAGS_MAX","normalizeTags","readBulkTag","typed","readCrmRouteScope","crmSuiteRefusal","authorizeCrmWriter","canReach","SUITE_ACTS","name","phone","jobTitle","address","notes","tags","addTag","removeTag","campaignIds","ownerUid","companyId","companyName","custom","mediaIds","NO_HOLDER_REFUSAL","contactUpdateSuiteAct","fields","field","find","key","undefined","readContactUpdateFields","input","raw","Array","isArray","ok","error","unknownKey","Object","keys","includes","text","String","slice","filter","length","tag","ids","map","id","readContactIds","value","some","Set","reachesSite","writer","hostId","staff","member","Boolean","hostAccess","refused","contactId","contactPatch","contact","groupId","linkedCompanyName","path","delete","update","counts","held","toLowerCase","arrayUnion","arrayRemove","plan","assign","label","entries","serverTimestamp","commitContactWrites","firestore","writes","failed","batch","write","ref","commit","console","add","settleCompanyCounts","companies","deltas","Map","count","set","get","delta","Promise","all","doc","catch","crmContactUpdateHandler","req","res","method","setHeader","status","json","body","scope","contactIds","read","suiteAct","level","suite","org","app","orgRef","collection","orgId","siteGroup","siteTokens","definitions","contactFields","limit","judged","docs","data","values","errors","owner","company","tokens","exists","visible","contacts","snapshots","getAll","outcomes","forEach","snapshot","index","visibleTo","group","patch","push","has","answer","results"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contact-update.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/contact-update` — one holder's profile fields on one or\n * more contacts (AGL-2804).\n *\n * Body: `{ hostId, contactIds, set }` under a site, or `{ orgId, hostId?,\n * contactIds, set }` at the organization level, where `set` is\n * `ContactUpdateFields`. Answers `{ ok, results }`: one outcome per contact,\n * in the order the ids were sent.\n *\n * ## Why every facet write comes here\n *\n * A contact's fields live in the facet of the holder that keeps them —\n * `facets.{groupId}.*` — and the Firestore rules cannot tell one field of a\n * facet from another: the holder is a map key a rule cannot address, and a\n * write's affected keys name only `facets`. So the rules let a client update\n * change nothing in a facet beyond letting a holder go, and the console's\n * edits arrive here, where the plan is asked about the fields the request\n * actually carries.\n *\n * ## Who may call it\n *\n * The CRM writer the task routes admit (`authorizeCrmWriter`): under a site,\n * a member whose role writes org data, who holds `data.manage` and whose\n * access reaches the site; at the organization level, an org-wide member\n * with the permission. Each contact must then be one the caller may read —\n * the rules' `canReadScoped()` — and, under a site, one the site's hub lists.\n *\n * ## The plan\n *\n * Every field is the CRM's (AGL-2790), and a plan without the CRM has none\n * of it (AGL-2851), so it edits no contact: not the owner, company, custom\n * values or files, and not the profile a capture writes (name, phone, job\n * title, address), the tags, the notes or the campaign filing either. A\n * request carrying any field is refused to such a plan once the caller is\n * known, and before a contact is read — staff included, because the plan is\n * a fact about the workspace. Erasing a person is another route, open on\n * every plan.\n *\n * ## One holder, by dotted path\n *\n * Under a site the facet is the site's consent group's; at the organization\n * level it is each contact's own primary holder, the facet the record page\n * shows there. Every field is one dotted path — a nested `facets` object\n * would replace every other holder's map — and a text field sent empty is\n * deleted rather than stored blank, so \"no phone\" has one shape.\n *\n * ## Many contacts, one commit\n *\n * The contacts are read together and written in one batch. A batch that\n * fails is retried contact by contact, so the one that could not be written\n * is named and the rest still land. A company's contacts count follows the\n * link: summed per company and settled once the contacts are written, so a\n * company deleted in between cannot fail the save of the people at it — the\n * company page's live aggregate is what corrects a count that did not move.\n */\n\nimport {\n type AglynPostalAddress,\n consentGroupForHost,\n CONTACT_FIELDS_MAX_PER_ORG,\n type ContactCompanyLinkPlan,\n type ContactCustomValue,\n type ContactFieldDefinition,\n campaignMembershipValue,\n contactCampaignFieldPath,\n contactFacetPath,\n contactPrimaryGroup,\n CRM_COLLECTIONS,\n CRM_LEAD_SOURCE_PICKLIST,\n CRM_MEDIA_IDS_FIELD,\n crmReadTokens,\n effectiveCrmLeadSourcePicklist,\n isOrgWideMember,\n judgeCrmLeadSource,\n normalizeAddress,\n normalizeCrmMediaIds,\n normalizeCrmPicklistLabel,\n normalizePhone,\n type PluginApiHandler,\n planContactCompanyLink,\n readContactCompanyLink,\n readContactFacet,\n readCrmCustomInput,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n companyContactsCountFields,\n contactCompanyLinkFields,\n firebaseAdmin,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n CONTACT_UPDATE_FIELDS,\n type ContactUpdateFields,\n type ContactUpdateOutcome,\n type ContactUpdateResponse,\n CRM_CONTACT_UPDATE_MAX,\n} from '../model/contact-update'\nimport {\n CONTACT_NOTES_MAX,\n CONTACT_PHONE_REFUSAL,\n CONTACT_TAGS_MAX,\n normalizeTags,\n readBulkTag,\n typed,\n} from './contact-profile'\nimport { readCrmRouteScope } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\nimport { authorizeCrmWriter, canReach, type Writer } from './task-routes'\n\n/** What the suite gate names for each field a request can carry. */\nconst SUITE_ACTS: Record<keyof ContactUpdateFields, string> = {\n name: \"Editing a contact's profile\",\n phone: \"Editing a contact's profile\",\n jobTitle: \"Editing a contact's profile\",\n leadSource: \"Editing a contact's profile\",\n address: \"Editing a contact's profile\",\n notes: \"Editing a contact's notes\",\n tags: 'Tagging a contact',\n addTag: 'Tagging a contact',\n removeTag: 'Tagging a contact',\n campaignIds: 'Filing a contact under a campaign',\n ownerUid: \"Assigning a contact's owner\",\n companyId: 'Filing a contact under a company',\n companyName: 'Filing a contact under a company',\n custom: \"Editing a contact's custom fields\",\n mediaIds: 'Attaching files to a contact',\n}\n\n/** A contact read at the organization level that no site has captured. */\nconst NO_HOLDER_REFUSAL = 'No site holds this contact yet, so it has no profile to edit.'\n\n/**\n * The act a request is refused for on a plan without the CRM: the first\n * field it carries, in the order the route reads them. Every field is the\n * CRM's, so whichever field a request names is the act its refusal names.\n */\nexport function contactUpdateSuiteAct(fields: ContactUpdateFields): string {\n const field = CONTACT_UPDATE_FIELDS.find((key) => fields[key] !== undefined)\n return field ? SUITE_ACTS[field] : \"Editing a contact's profile\"\n}\n\n/**\n * A body's `set`, read into the fields it names, or the sentence refusing\n * it. A key the route does not save is refused rather than dropped: a save\n * that silently lost a field would read as a save that kept it.\n */\nexport function readContactUpdateFields(\n input: unknown,\n): { ok: true; fields: ContactUpdateFields } | { ok: false; error: string } {\n if (!input || typeof input !== 'object' || Array.isArray(input)) {\n return { ok: false, error: 'Name the fields to save.' }\n }\n const raw = input as Record<string, unknown>\n const unknownKey = Object.keys(raw).find(\n (key) => !(CONTACT_UPDATE_FIELDS as readonly string[]).includes(key),\n )\n if (unknownKey) {\n return { ok: false, error: `\"${unknownKey}\" is not a field a contact's profile saves.` }\n }\n const fields: ContactUpdateFields = {}\n if ('name' in raw) fields.name = typed(raw['name'], 120)\n if ('phone' in raw) {\n const text = typed(raw['phone'], 40)\n const phone = text ? normalizePhone(text) : ''\n if (phone === null) return { ok: false, error: CONTACT_PHONE_REFUSAL }\n fields.phone = phone\n }\n if ('jobTitle' in raw) fields.jobTitle = typed(raw['jobTitle'], 120)\n // Held to the label's shape here; judged against the org's list by the\n // handler, which alone can read it (AGL-3298).\n if ('leadSource' in raw) fields.leadSource = normalizeCrmPicklistLabel(raw['leadSource'])\n if ('address' in raw) {\n const address = raw['address']\n fields.address =\n address && typeof address === 'object' && !Array.isArray(address)\n ? normalizeAddress(address as AglynPostalAddress)\n : null\n }\n if ('notes' in raw) fields.notes = String(raw['notes'] ?? '').slice(0, CONTACT_NOTES_MAX)\n // One write may carry one transform per field, so the tags are replaced,\n // added to or taken from — never two of those at once.\n if (['tags', 'addTag', 'removeTag'].filter((key) => key in raw).length > 1) {\n return { ok: false, error: 'Send the tags, a tag to add or a tag to remove — one of them.' }\n }\n if ('tags' in raw) {\n if (!Array.isArray(raw['tags'])) return { ok: false, error: 'Tags must be a list.' }\n fields.tags = normalizeTags(raw['tags'])\n }\n for (const key of ['addTag', 'removeTag'] as const) {\n if (!(key in raw)) continue\n const tag = readBulkTag(raw[key])\n if (!tag) return { ok: false, error: 'Name the tag.' }\n fields[key] = tag\n }\n if ('campaignIds' in raw) {\n const ids = raw['campaignIds']\n if (!Array.isArray(ids)) return { ok: false, error: 'Campaigns must be a list.' }\n fields.campaignIds = campaignMembershipValue(ids.map((id) => String(id ?? '')))\n }\n if ('ownerUid' in raw) fields.ownerUid = typed(raw['ownerUid'], 128)\n if ('companyId' in raw) {\n const companyId = raw['companyId'] === null ? null : typed(raw['companyId'], 128)\n if (companyId !== null && (!companyId || companyId.includes('/'))) {\n return { ok: false, error: 'The company could not be read.' }\n }\n fields.companyId = companyId\n }\n if ('companyName' in raw) fields.companyName = typed(raw['companyName'], 120)\n if ('custom' in raw) {\n const custom = raw['custom']\n if (!custom || typeof custom !== 'object' || Array.isArray(custom)) {\n return { ok: false, error: 'Custom fields must be values keyed by field key.' }\n }\n fields.custom = custom as Record<string, ContactCustomValue>\n }\n if ('mediaIds' in raw) {\n if (!Array.isArray(raw['mediaIds'])) {\n return { ok: false, error: 'Files must be a list of ids.' }\n }\n fields.mediaIds = normalizeCrmMediaIds(raw['mediaIds'])\n }\n if (!Object.keys(fields).length) return { ok: false, error: 'Name the fields to save.' }\n return { ok: true, fields }\n}\n\n/** The ids a body names, deduplicated — or `null` when any is unreadable or there are too many. */\nfunction readContactIds(value: unknown): string[] | null {\n if (!Array.isArray(value) || !value.length || value.length > CRM_CONTACT_UPDATE_MAX) {\n return null\n }\n const ids = value.map((id) => typed(id, 200))\n if (ids.some((id) => !id || id.includes('/'))) return null\n return [...new Set(ids)]\n}\n\n/**\n * Under a site, whether the caller's access reaches it. The rules could not\n * ask this of a facet write — they cannot see which holder a write is for —\n * and a collaborator on one site must not write another site's notes on a\n * person both of them hold.\n */\nfunction reachesSite(writer: Writer, hostId: string): boolean {\n return (\n writer.staff ||\n isOrgWideMember(writer.member) ||\n Boolean((writer.member?.hostAccess as Record<string, unknown> | undefined)?.[hostId])\n )\n}\n\nconst refused = (contactId: string, error: string): ContactUpdateOutcome => ({\n contactId,\n ok: false,\n error,\n})\n\ninterface ContactWrite {\n contactId: string\n ref: FirebaseFirestore.DocumentReference\n update: Record<string, unknown>\n counts: ContactCompanyLinkPlan['counts']\n}\n\n/**\n * One contact's update for one holder, or the sentence refusing the row.\n * `update` is `null` when the document already says everything asked — a tag\n * the row already carries, a link already made.\n */\nfunction contactPatch(\n contact: Record<string, unknown>,\n groupId: string,\n fields: ContactUpdateFields,\n custom: Record<string, ContactCustomValue> | undefined,\n linkedCompanyName: string,\n):\n | { update: Record<string, unknown> | null; counts: ContactCompanyLinkPlan['counts'] }\n | { refused: string } {\n const path = (field: string) => contactFacetPath(groupId, field)\n /** A text field as stored: the value, or the field's absence. */\n const text = (value: string) => value || FieldValue.delete()\n const update: Record<string, unknown> = {}\n let counts: ContactCompanyLinkPlan['counts'] = []\n\n if (fields.name !== undefined) update[path('name')] = text(fields.name)\n if (fields.phone !== undefined) {\n update[path('phone')] = text(fields.phone)\n // The search echo — see `HostContact.phone`.\n update['phone'] = text(fields.phone)\n }\n if (fields.jobTitle !== undefined) update[path('jobTitle')] = text(fields.jobTitle)\n if (fields.leadSource !== undefined) update[path('leadSource')] = text(fields.leadSource)\n if (fields.address !== undefined) {\n update[path('address')] = fields.address ?? FieldValue.delete()\n }\n if (fields.notes !== undefined) update[path('notes')] = fields.notes\n if (fields.tags !== undefined) update[path('tags')] = fields.tags\n\n const held = (readContactFacet(contact, groupId).tags ?? []).map((tag) =>\n String(tag).toLowerCase(),\n )\n if (fields.addTag && !held.includes(fields.addTag)) {\n // The record page's cap, which a selection must not slip past.\n if (held.length >= CONTACT_TAGS_MAX) {\n return { refused: `already has ${CONTACT_TAGS_MAX} tags` }\n }\n update[path('tags')] = FieldValue.arrayUnion(fields.addTag)\n }\n if (fields.removeTag && held.includes(fields.removeTag)) {\n update[path('tags')] = FieldValue.arrayRemove(fields.removeTag)\n }\n if (fields.campaignIds !== undefined) {\n update[contactCampaignFieldPath(groupId)] = fields.campaignIds\n }\n if (fields.ownerUid !== undefined) update[path('ownerUid')] = text(fields.ownerUid)\n\n if (fields.companyId !== undefined) {\n const plan = planContactCompanyLink(\n readContactCompanyLink(contact, groupId),\n fields.companyId,\n )\n if (plan) {\n Object.assign(update, contactCompanyLinkFields(plan, groupId))\n counts = plan.counts\n if (fields.companyName === undefined) {\n // The label follows the link: the company's own name, or nothing.\n const label = plan.companyId ? linkedCompanyName : ''\n update[path('companyName')] = text(label)\n // The search echo — see `HostContact.companyName`.\n update['companyName'] = text(label)\n }\n }\n }\n if (fields.companyName !== undefined) {\n update[path('companyName')] = text(fields.companyName)\n update['companyName'] = text(fields.companyName)\n }\n for (const [key, value] of Object.entries(custom ?? {})) {\n update[path(`custom.${key}`)] = value\n }\n if (fields.mediaIds !== undefined) update[path(CRM_MEDIA_IDS_FIELD)] = fields.mediaIds\n\n if (!Object.keys(update).length) return { update: null, counts }\n update['updatedAt'] = FieldValue.serverTimestamp()\n return { update, counts }\n}\n\n/**\n * Every write in one batch, and — when the batch is refused — each on its\n * own, so the contact that could not be written is the only one reported.\n * Answers the ids that were not written.\n */\nasync function commitContactWrites(\n firestore: FirebaseFirestore.Firestore,\n writes: readonly ContactWrite[],\n): Promise<Set<string>> {\n const failed = new Set<string>()\n if (!writes.length) return failed\n const batch = firestore.batch()\n for (const write of writes) batch.update(write.ref, write.update)\n try {\n await batch.commit()\n return failed\n } catch {\n // A batch names no document; the pass below finds the one it was.\n }\n for (const write of writes) {\n try {\n await write.ref.update(write.update)\n } catch (error) {\n console.error('[crm] contact-update could not write a contact', write.contactId, error)\n failed.add(write.contactId)\n }\n }\n return failed\n}\n\n/** Each company's contacts count moved once, by the sum of what the written links moved. */\nasync function settleCompanyCounts(\n companies: FirebaseFirestore.CollectionReference,\n writes: readonly ContactWrite[],\n): Promise<void> {\n const deltas = new Map<string, number>()\n for (const write of writes) {\n for (const count of write.counts) {\n deltas.set(count.companyId, (deltas.get(count.companyId) ?? 0) + count.delta)\n }\n }\n await Promise.all(\n [...deltas]\n .filter(([, delta]) => delta !== 0)\n .map(([companyId, delta]) =>\n companies\n .doc(companyId)\n .update(companyContactsCountFields(delta))\n .catch((error: unknown) => {\n console.error('[crm] company contacts count could not move', companyId, delta, error)\n }),\n ),\n )\n}\n\nexport const crmContactUpdateHandler: 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 body = (req.body ?? {}) as Record<string, unknown>\n const scope = readCrmRouteScope(body)\n if (!scope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const contactIds = readContactIds(body['contactIds'])\n if (!contactIds) {\n res.status(400).json({ error: `Name between 1 and ${CRM_CONTACT_UPDATE_MAX} contacts.` })\n return\n }\n const read = readContactUpdateFields(body['set'])\n if (read.ok === false) {\n res.status(400).json({ error: read.error })\n return\n }\n const { fields } = read\n\n try {\n const writer = await authorizeCrmWriter(req, scope, { suiteAct: null })\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n if (scope.level === 'site' && !reachesSite(writer, scope.hostId)) {\n res.status(403).json({ error: 'Your access to this organization does not reach this site.' })\n return\n }\n // The plan after the person, and before any contact is read (AGL-2787).\n const suite = crmSuiteRefusal(writer.org, contactUpdateSuiteAct(fields))\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(writer.orgId)\n const siteGroup =\n scope.level === 'site' ? consentGroupForHost(writer.org, scope.hostId) : null\n const siteTokens = siteGroup ? crmReadTokens(siteGroup) : null\n\n let custom: Record<string, ContactCustomValue> | undefined\n if (fields.custom) {\n const definitions = await orgRef\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(CONTACT_FIELDS_MAX_PER_ORG)\n .get()\n const judged = readCrmCustomInput(\n fields.custom,\n definitions.docs.map((doc) => doc.data() as ContactFieldDefinition),\n 'contact',\n )\n if ('errors' in judged) {\n res.status(400).json({\n error: Object.values(judged.errors)[0] ?? 'A custom value could not be saved.',\n })\n return\n }\n custom = judged.values\n }\n\n /*\n * THE ORG'S LEAD SOURCES (AGL-3298), read only when the body names one.\n * Judged per contact below, because a contact keeps the value it\n * already holds even when the list has since deactivated it.\n */\n const leadSources = fields.leadSource\n ? effectiveCrmLeadSourcePicklist(\n (\n await orgRef.collection(CRM_COLLECTIONS.picklists).doc(CRM_LEAD_SOURCE_PICKLIST).get()\n ).data(),\n )\n : null\n\n if (fields.ownerUid) {\n const owner = await resolveOrgMembership(fields.ownerUid, writer.orgId).catch(() => null)\n if (!owner?.member) {\n res.status(400).json({ error: 'The owner is not a member of this organization.' })\n return\n }\n }\n\n // The company a link names has to exist and be visible to the caller and\n // the site, or a person could be filed under a record nobody here can open.\n let linkedCompanyName = ''\n if (fields.companyId) {\n const company = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .doc(fields.companyId)\n .get()\n const tokens = company.exists ? (company.get('visibleTo') as string[] | undefined) : undefined\n const visible =\n company.exists &&\n canReach(writer, tokens) &&\n (!siteTokens || visibleToTokens(tokens, siteTokens))\n if (!visible) {\n res.status(404).json({ error: 'Unknown company' })\n return\n }\n linkedCompanyName = typed(company.get('name'), 120)\n }\n\n const contacts = orgRef.collection('contacts')\n const snapshots = await firestore.getAll(...contactIds.map((id) => contacts.doc(id)))\n const outcomes = new Map<string, ContactUpdateOutcome>()\n const writes: ContactWrite[] = []\n snapshots.forEach((snapshot, index) => {\n const contactId = contactIds[index]\n if (!snapshot.exists) {\n outcomes.set(contactId, refused(contactId, 'That contact no longer exists.'))\n return\n }\n const data = (snapshot.data() ?? {}) as Record<string, unknown>\n const visibleTo = data['visibleTo'] as string[] | undefined\n if (!canReach(writer, visibleTo)) {\n outcomes.set(contactId, refused(contactId, 'That contact is not visible to you.'))\n return\n }\n if (siteTokens && !visibleToTokens(visibleTo, siteTokens)) {\n outcomes.set(contactId, refused(contactId, 'That contact is not visible to this site.'))\n return\n }\n const group = siteGroup ?? contactPrimaryGroup(data, writer.org)\n if (!group.groupId) {\n outcomes.set(contactId, refused(contactId, NO_HOLDER_REFUSAL))\n return\n }\n let rowFields = fields\n if (leadSources && fields.leadSource) {\n const judged = judgeCrmLeadSource(\n leadSources,\n fields.leadSource,\n readContactFacet(data, group.groupId).leadSource,\n )\n if (judged.ok === false) {\n outcomes.set(contactId, refused(contactId, judged.error))\n return\n }\n rowFields = { ...fields, leadSource: judged.value ?? '' }\n }\n const patch = contactPatch(data, group.groupId, rowFields, custom, linkedCompanyName)\n if ('refused' in patch) {\n outcomes.set(contactId, refused(contactId, patch.refused))\n return\n }\n outcomes.set(contactId, { contactId, ok: true })\n if (patch.update) {\n writes.push({ contactId, ref: snapshot.ref, update: patch.update, counts: patch.counts })\n }\n })\n\n const failed = await commitContactWrites(firestore, writes)\n for (const contactId of failed) {\n outcomes.set(contactId, refused(contactId, 'The contact could not be saved.'))\n }\n await settleCompanyCounts(\n orgRef.collection(CRM_COLLECTIONS.companies),\n writes.filter((write) => !failed.has(write.contactId)),\n )\n\n const answer: ContactUpdateResponse = {\n ok: true,\n results: contactIds.map(\n (contactId) => outcomes.get(contactId) ?? refused(contactId, 'The contact could not be saved.'),\n ),\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] contact-update failed', error)\n res.status(500).json({ error: 'The contact could not be saved.' })\n }\n}\n"],"names":["consentGroupForHost","CONTACT_FIELDS_MAX_PER_ORG","campaignMembershipValue","contactCampaignFieldPath","contactFacetPath","contactPrimaryGroup","CRM_COLLECTIONS","CRM_LEAD_SOURCE_PICKLIST","CRM_MEDIA_IDS_FIELD","crmReadTokens","effectiveCrmLeadSourcePicklist","isOrgWideMember","judgeCrmLeadSource","normalizeAddress","normalizeCrmMediaIds","normalizeCrmPicklistLabel","normalizePhone","planContactCompanyLink","readContactCompanyLink","readContactFacet","readCrmCustomInput","visibleToTokens","companyContactsCountFields","contactCompanyLinkFields","firebaseAdmin","resolveOrgMembership","FieldValue","CONTACT_UPDATE_FIELDS","CRM_CONTACT_UPDATE_MAX","CONTACT_NOTES_MAX","CONTACT_PHONE_REFUSAL","CONTACT_TAGS_MAX","normalizeTags","readBulkTag","typed","readCrmRouteScope","crmSuiteRefusal","authorizeCrmWriter","canReach","SUITE_ACTS","name","phone","jobTitle","leadSource","address","notes","tags","addTag","removeTag","campaignIds","ownerUid","companyId","companyName","custom","mediaIds","NO_HOLDER_REFUSAL","contactUpdateSuiteAct","fields","field","find","key","undefined","readContactUpdateFields","input","raw","Array","isArray","ok","error","unknownKey","Object","keys","includes","text","String","slice","filter","length","tag","ids","map","id","readContactIds","value","some","Set","reachesSite","writer","hostId","staff","member","Boolean","hostAccess","refused","contactId","contactPatch","contact","groupId","linkedCompanyName","path","delete","update","counts","held","toLowerCase","arrayUnion","arrayRemove","plan","assign","label","entries","serverTimestamp","commitContactWrites","firestore","writes","failed","batch","write","ref","commit","console","add","settleCompanyCounts","companies","deltas","Map","count","set","get","delta","Promise","all","doc","catch","crmContactUpdateHandler","req","res","method","setHeader","status","json","body","scope","contactIds","read","suiteAct","level","suite","org","app","orgRef","collection","orgId","siteGroup","siteTokens","definitions","contactFields","limit","judged","docs","data","values","errors","leadSources","picklists","owner","company","tokens","exists","visible","contacts","snapshots","getAll","outcomes","forEach","snapshot","index","visibleTo","group","rowFields","patch","push","has","answer","results"],"mappings":"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"}
|
|
@@ -50,6 +50,7 @@ import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
|
|
|
50
50
|
import { normalizeCampaignIds, readCampaignIds } from "@aglyn/aglyn/app-utils/campaign-membership";
|
|
51
51
|
import { FieldValue } from "firebase-admin/firestore";
|
|
52
52
|
import { fileCampaignFilingActivities } from "./campaign-filing-activity.js";
|
|
53
|
+
import { readLeadSourcePicklist, resolveLeadSourceWrite } from "./lead-source-picklist.js";
|
|
53
54
|
import { holdsDataManage } from "./org-caller.js";
|
|
54
55
|
import { crmSuiteRefusal } from "./suite-gate.js";
|
|
55
56
|
/** The statuses a lead may be entered in — everything but the converted state. */ const CREATE_STATUSES = [
|
|
@@ -220,6 +221,24 @@ export const leadCreateHandler = async (req, res)=>{
|
|
|
220
221
|
const before = await readLeadForHost(hostId, leadId);
|
|
221
222
|
const created = !before;
|
|
222
223
|
/*
|
|
224
|
+
* THE ORG'S LEAD SOURCES (AGL-3298), judged before any write: a value
|
|
225
|
+
* outside the list is refused under the field naming what the list
|
|
226
|
+
* allows, a lead the site already held keeps the value it has, and a
|
|
227
|
+
* new lead that named none starts from the list's default.
|
|
228
|
+
*/ const leadSource = resolveLeadSourceWrite(await readLeadSourcePicklist(firestore, resolved.orgId), patch.leadSource, {
|
|
229
|
+
current: before == null ? void 0 : before.get('leadSource'),
|
|
230
|
+
created
|
|
231
|
+
});
|
|
232
|
+
if (leadSource.ok === false) {
|
|
233
|
+
res.status(400).json({
|
|
234
|
+
error: leadSource.error,
|
|
235
|
+
field: 'leadSource'
|
|
236
|
+
});
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (leadSource.write === undefined) delete patch.leadSource;
|
|
240
|
+
else patch.leadSource = leadSource.write;
|
|
241
|
+
/*
|
|
223
242
|
* The platform ceiling, judged here so the drawer can say WHY rather
|
|
224
243
|
* than only that the lead could not be saved; the door re-judges it
|
|
225
244
|
* inside its own transaction, so a race is still refused there.
|
|
@@ -1 +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 CONTACT_FIELDS_MAX_PER_ORG,\n type ContactFieldDefinition,\n CRM_COLLECTIONS,\n type CrmCustomValue,\n type CrmLeadProfilePatch,\n type CrmLeadStatus,\n LEADS_MAX_PER_HOST,\n normalizeContactEmail,\n normalizeCrmLeadProfile,\n personKey,\n type PluginApiHandler,\n readCrmCustomInput,\n} from '@aglyn/aglyn/server'\nimport {\n addHostLead,\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n orgLeadsForHost,\n readLeadForHost,\n scopedToHost,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { normalizeCampaignIds, readCampaignIds } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport type { CampaignFilingRef } from '../model/campaign-filing-activity'\nimport { fileCampaignFilingActivities } from './campaign-filing-activity'\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 /** The org's campaigns to file the lead under (AGL-3254), by container id. */\n campaignIds?: string[]\n /**\n * The org's custom LEAD fields (AGL-3272), keyed by each definition's\n * `key`. Judged against the definitions whose `object` is `lead`, so a\n * contact field of the same name cannot be written through this door.\n */\n custom?: Record<string, CrmCustomValue>\n}\n\n/** The sentence a campaign that is not the org's live one is refused with, under the field. */\nexport const LEAD_CAMPAIGN_REFUSAL =\n \"One of the campaigns picked doesn't exist any more. Pick it again.\"\n\n/**\n * The campaigns a request may file a lead under: the organization's own\n * live containers (`orgs/{orgId}/emailCampaigns`), and nothing else. A\n * request can claim any id, and a lead filed under another org's campaign\n * — or one the console soft-deleted — would sit on a page nobody in this\n * org can open. A lead is an org record, so any live campaign of the org\n * qualifies, whichever sites it is placed on. Answered with the names as\n * they stand, for the timeline entries the filing writes (AGL-3274).\n *\n * @returns the clean ids with their names, or `null` when one is not the org's.\n */\nexport async function orgCampaigns(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n raw: unknown,\n): Promise<CampaignFilingRef[] | null> {\n const ids = normalizeCampaignIds(raw)\n if (!ids.length) return []\n if (!orgId || ids.some((id) => id.includes('/'))) return null\n const containers = firestore.collection('orgs').doc(orgId).collection('emailCampaigns')\n const found = await firestore.getAll(...ids.map((id) => containers.doc(id)))\n const live = found.every((snapshot) => snapshot.exists && !snapshot.get('deletedAt'))\n return live\n ? ids.map((id, index) => ({ id, name: String(found[index]?.get('name') ?? '').trim() || id }))\n : null\n}\n\n/** The ids alone — see {@link orgCampaigns}. */\nexport async function orgCampaignIds(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n raw: unknown,\n): Promise<string[] | null> {\n const campaigns = await orgCampaigns(firestore, orgId, raw)\n return campaigns ? campaigns.map((campaign) => campaign.id) : null\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 = await orgLeadsForHost(hostId)\n /*\n * The org's own lead fields (AGL-3272), judged before any write and\n * only when the body carried a map — a body without `custom` pays for\n * no definition read. The refusal is the definition reader's own\n * sentence, so a client learns which key it spelled wrong rather than\n * that the lead could not be saved.\n */\n let custom: Record<string, CrmCustomValue> | undefined\n if (body.custom !== undefined) {\n const definitions = await firestore\n .collection('orgs')\n .doc(resolved.orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(CONTACT_FIELDS_MAX_PER_ORG)\n .get()\n const judged = readCrmCustomInput(\n body.custom,\n definitions.docs.map((entry) => entry.data() as ContactFieldDefinition),\n 'lead',\n )\n if ('errors' in judged) {\n const [field, message] = Object.entries(judged.errors)[0] ?? []\n res.status(400).json({\n error: message ?? 'A custom value could not be saved.',\n ...(field ? { field } : {}),\n })\n return\n }\n custom = judged.values\n }\n // The campaigns, judged before any write, so a refused pick leaves\n // nothing to retry against (AGL-3254).\n const campaigns = await orgCampaigns(firestore, resolved.orgId, body.campaignIds)\n if (campaigns === null) {\n res.status(400).json({ error: LEAD_CAMPAIGN_REFUSAL, field: 'campaignIds' })\n return\n }\n const campaignIds = campaigns.map((campaign) => campaign.id)\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 readLeadForHost(hostId, leadId)\n const created = !before\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 // What THIS site may see, not the whole org's collection (AGL-3275) —\n // the same narrowing `addHostLead` re-applies inside its transaction.\n const used = (await scopedToHost(leadsRef, hostId).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 // Added to what a lead the site already held carries, never in place\n // of it: a second \"new lead\" for an address files it under one more\n // campaign, as the bulk bar would.\n ...(campaignIds.length ? { campaignIds: FieldValue.arrayUnion(...campaignIds) } : {}),\n /*\n * The map as a map, because the write below is a `set` with `merge`\n * and a merge is a DEEP one: the keys this body named land, and a\n * second \"new lead\" for an address the site already holds leaves\n * every other key's value where it was. A dotted path would not do\n * it — only `update` reads dots as paths, and `set` would file a\n * field literally called `custom.tier`.\n */\n ...(custom && Object.keys(custom).length ? { custom } : {}),\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 filing on the lead's Activity (AGL-3274): one \"Filed under\" per\n * campaign the lead was not already in, by the member who added it.\n * After the write and never before, and never failing it — the\n * membership is on the document; the entry records the act.\n */\n const already = readCampaignIds(before ? (before.data() as Record<string, unknown>) : null)\n await fileCampaignFilingActivities(firestore, {\n orgId: resolved.orgId,\n org: resolved.org as Record<string, unknown>,\n hostId,\n link: { leadId },\n action: 'filed',\n campaigns: campaigns.filter((campaign) => !already.includes(campaign.id)),\n atMs: Date.now(),\n byUid: decoded.uid,\n byName: String(decoded['name'] ?? decoded.email ?? '').trim() || null,\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","CONTACT_FIELDS_MAX_PER_ORG","CRM_COLLECTIONS","LEADS_MAX_PER_HOST","normalizeContactEmail","normalizeCrmLeadProfile","personKey","readCrmCustomInput","addHostLead","firebaseAdmin","getOrgForHost","logHostActivity","orgLeadsForHost","readLeadForHost","scopedToHost","resolveOrgPermissions","normalizeCampaignIds","readCampaignIds","FieldValue","fileCampaignFilingActivities","holdsDataManage","crmSuiteRefusal","CREATE_STATUSES","NAME_MAX","NOTES_MAX","LEAD_MANUAL_SOURCE","LEAD_CAMPAIGN_REFUSAL","orgCampaigns","firestore","orgId","raw","ids","length","some","id","includes","containers","collection","doc","found","getAll","map","live","every","snapshot","exists","get","index","name","String","trim","orgCampaignIds","campaigns","campaign","leadProfileWrite","patch","write","key","value","Object","entries","undefined","delete","leadCreateHandler","req","res","body","method","setHeader","status","json","error","authorization","headers","idToken","startsWith","slice","JSON","parse","hostId","email","errors","firstError","field","rawStatus","replace","ownerUid","notes","decoded","app","auth","verifyIdToken","staff","membership","uid","hostRole","resolved","suite","org","hostRef","leadsRef","custom","definitions","contactFields","limit","judged","docs","entry","data","message","values","campaignIds","leadId","before","created","used","count","exceeded","reason","stored","lead","source","working","arrayUnion","keys","set","updatedAt","serverTimestamp","merge","already","link","action","filter","atMs","Date","now","byUid","byName","type","answer","console"],"mappings":"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"}
|
|
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 CONTACT_FIELDS_MAX_PER_ORG,\n type ContactFieldDefinition,\n CRM_COLLECTIONS,\n type CrmCustomValue,\n type CrmLeadProfilePatch,\n type CrmLeadStatus,\n LEADS_MAX_PER_HOST,\n normalizeContactEmail,\n normalizeCrmLeadProfile,\n personKey,\n type PluginApiHandler,\n readCrmCustomInput,\n} from '@aglyn/aglyn/server'\nimport {\n addHostLead,\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n orgLeadsForHost,\n readLeadForHost,\n scopedToHost,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { normalizeCampaignIds, readCampaignIds } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport type { CampaignFilingRef } from '../model/campaign-filing-activity'\nimport { fileCampaignFilingActivities } from './campaign-filing-activity'\nimport { readLeadSourcePicklist, resolveLeadSourceWrite } from './lead-source-picklist'\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 /** The org's campaigns to file the lead under (AGL-3254), by container id. */\n campaignIds?: string[]\n /**\n * The org's custom LEAD fields (AGL-3272), keyed by each definition's\n * `key`. Judged against the definitions whose `object` is `lead`, so a\n * contact field of the same name cannot be written through this door.\n */\n custom?: Record<string, CrmCustomValue>\n}\n\n/** The sentence a campaign that is not the org's live one is refused with, under the field. */\nexport const LEAD_CAMPAIGN_REFUSAL =\n \"One of the campaigns picked doesn't exist any more. Pick it again.\"\n\n/**\n * The campaigns a request may file a lead under: the organization's own\n * live containers (`orgs/{orgId}/emailCampaigns`), and nothing else. A\n * request can claim any id, and a lead filed under another org's campaign\n * — or one the console soft-deleted — would sit on a page nobody in this\n * org can open. A lead is an org record, so any live campaign of the org\n * qualifies, whichever sites it is placed on. Answered with the names as\n * they stand, for the timeline entries the filing writes (AGL-3274).\n *\n * @returns the clean ids with their names, or `null` when one is not the org's.\n */\nexport async function orgCampaigns(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n raw: unknown,\n): Promise<CampaignFilingRef[] | null> {\n const ids = normalizeCampaignIds(raw)\n if (!ids.length) return []\n if (!orgId || ids.some((id) => id.includes('/'))) return null\n const containers = firestore.collection('orgs').doc(orgId).collection('emailCampaigns')\n const found = await firestore.getAll(...ids.map((id) => containers.doc(id)))\n const live = found.every((snapshot) => snapshot.exists && !snapshot.get('deletedAt'))\n return live\n ? ids.map((id, index) => ({ id, name: String(found[index]?.get('name') ?? '').trim() || id }))\n : null\n}\n\n/** The ids alone — see {@link orgCampaigns}. */\nexport async function orgCampaignIds(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n raw: unknown,\n): Promise<string[] | null> {\n const campaigns = await orgCampaigns(firestore, orgId, raw)\n return campaigns ? campaigns.map((campaign) => campaign.id) : null\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 = await orgLeadsForHost(hostId)\n /*\n * The org's own lead fields (AGL-3272), judged before any write and\n * only when the body carried a map — a body without `custom` pays for\n * no definition read. The refusal is the definition reader's own\n * sentence, so a client learns which key it spelled wrong rather than\n * that the lead could not be saved.\n */\n let custom: Record<string, CrmCustomValue> | undefined\n if (body.custom !== undefined) {\n const definitions = await firestore\n .collection('orgs')\n .doc(resolved.orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(CONTACT_FIELDS_MAX_PER_ORG)\n .get()\n const judged = readCrmCustomInput(\n body.custom,\n definitions.docs.map((entry) => entry.data() as ContactFieldDefinition),\n 'lead',\n )\n if ('errors' in judged) {\n const [field, message] = Object.entries(judged.errors)[0] ?? []\n res.status(400).json({\n error: message ?? 'A custom value could not be saved.',\n ...(field ? { field } : {}),\n })\n return\n }\n custom = judged.values\n }\n // The campaigns, judged before any write, so a refused pick leaves\n // nothing to retry against (AGL-3254).\n const campaigns = await orgCampaigns(firestore, resolved.orgId, body.campaignIds)\n if (campaigns === null) {\n res.status(400).json({ error: LEAD_CAMPAIGN_REFUSAL, field: 'campaignIds' })\n return\n }\n const campaignIds = campaigns.map((campaign) => campaign.id)\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 readLeadForHost(hostId, leadId)\n const created = !before\n /*\n * THE ORG'S LEAD SOURCES (AGL-3298), judged before any write: a value\n * outside the list is refused under the field naming what the list\n * allows, a lead the site already held keeps the value it has, and a\n * new lead that named none starts from the list's default.\n */\n const leadSource = resolveLeadSourceWrite(\n await readLeadSourcePicklist(firestore, resolved.orgId),\n patch.leadSource,\n { current: before?.get('leadSource'), created },\n )\n if (leadSource.ok === false) {\n res.status(400).json({ error: leadSource.error, field: 'leadSource' })\n return\n }\n if (leadSource.write === undefined) delete patch.leadSource\n else patch.leadSource = leadSource.write\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 // What THIS site may see, not the whole org's collection (AGL-3275) —\n // the same narrowing `addHostLead` re-applies inside its transaction.\n const used = (await scopedToHost(leadsRef, hostId).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 // Added to what a lead the site already held carries, never in place\n // of it: a second \"new lead\" for an address files it under one more\n // campaign, as the bulk bar would.\n ...(campaignIds.length ? { campaignIds: FieldValue.arrayUnion(...campaignIds) } : {}),\n /*\n * The map as a map, because the write below is a `set` with `merge`\n * and a merge is a DEEP one: the keys this body named land, and a\n * second \"new lead\" for an address the site already holds leaves\n * every other key's value where it was. A dotted path would not do\n * it — only `update` reads dots as paths, and `set` would file a\n * field literally called `custom.tier`.\n */\n ...(custom && Object.keys(custom).length ? { custom } : {}),\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 filing on the lead's Activity (AGL-3274): one \"Filed under\" per\n * campaign the lead was not already in, by the member who added it.\n * After the write and never before, and never failing it — the\n * membership is on the document; the entry records the act.\n */\n const already = readCampaignIds(before ? (before.data() as Record<string, unknown>) : null)\n await fileCampaignFilingActivities(firestore, {\n orgId: resolved.orgId,\n org: resolved.org as Record<string, unknown>,\n hostId,\n link: { leadId },\n action: 'filed',\n campaigns: campaigns.filter((campaign) => !already.includes(campaign.id)),\n atMs: Date.now(),\n byUid: decoded.uid,\n byName: String(decoded['name'] ?? decoded.email ?? '').trim() || null,\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","CONTACT_FIELDS_MAX_PER_ORG","CRM_COLLECTIONS","LEADS_MAX_PER_HOST","normalizeContactEmail","normalizeCrmLeadProfile","personKey","readCrmCustomInput","addHostLead","firebaseAdmin","getOrgForHost","logHostActivity","orgLeadsForHost","readLeadForHost","scopedToHost","resolveOrgPermissions","normalizeCampaignIds","readCampaignIds","FieldValue","fileCampaignFilingActivities","readLeadSourcePicklist","resolveLeadSourceWrite","holdsDataManage","crmSuiteRefusal","CREATE_STATUSES","NAME_MAX","NOTES_MAX","LEAD_MANUAL_SOURCE","LEAD_CAMPAIGN_REFUSAL","orgCampaigns","firestore","orgId","raw","ids","length","some","id","includes","containers","collection","doc","found","getAll","map","live","every","snapshot","exists","get","index","name","String","trim","orgCampaignIds","campaigns","campaign","leadProfileWrite","patch","write","key","value","Object","entries","undefined","delete","leadCreateHandler","req","res","body","method","setHeader","status","json","error","authorization","headers","idToken","startsWith","slice","JSON","parse","hostId","email","errors","firstError","field","rawStatus","replace","ownerUid","notes","decoded","app","auth","verifyIdToken","staff","membership","uid","hostRole","resolved","suite","org","hostRef","leadsRef","custom","definitions","contactFields","limit","judged","docs","entry","data","message","values","campaignIds","leadId","before","created","leadSource","current","ok","used","count","exceeded","reason","stored","lead","source","working","arrayUnion","keys","set","updatedAt","serverTimestamp","merge","already","link","action","filter","atMs","Date","now","byUid","byName","type","answer","console"],"mappings":"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"}
|