@aglyn/plugins-crm 1.0.0-beta.177 → 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.
Files changed (58) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/contact-detail-page.js +1 -0
  3. package/src/lib/components/contact-detail-page.js.map +1 -1
  4. package/src/lib/components/contact-properties-card.d.ts +6 -0
  5. package/src/lib/components/contact-properties-card.js +25 -2
  6. package/src/lib/components/contact-properties-card.js.map +1 -1
  7. package/src/lib/components/csv-import-drawer.d.ts +7 -0
  8. package/src/lib/components/csv-import-drawer.js +7 -0
  9. package/src/lib/components/csv-import-drawer.js.map +1 -1
  10. package/src/lib/components/fields-section.js +6 -0
  11. package/src/lib/components/fields-section.js.map +1 -1
  12. package/src/lib/components/lead-import-drawer.d.ts +3 -0
  13. package/src/lib/components/lead-import-drawer.js +29 -6
  14. package/src/lib/components/lead-import-drawer.js.map +1 -1
  15. package/src/lib/components/lead-properties-card.js +10 -12
  16. package/src/lib/components/lead-properties-card.js.map +1 -1
  17. package/src/lib/components/lead-source-select.d.ts +30 -0
  18. package/src/lib/components/lead-source-select.js +96 -0
  19. package/src/lib/components/lead-source-select.js.map +1 -0
  20. package/src/lib/components/lead-source-values-card.d.ts +27 -0
  21. package/src/lib/components/lead-source-values-card.js +606 -0
  22. package/src/lib/components/lead-source-values-card.js.map +1 -0
  23. package/src/lib/components/leads-section.js +100 -4
  24. package/src/lib/components/leads-section.js.map +1 -1
  25. package/src/lib/components/new-lead-drawer.js +27 -11
  26. package/src/lib/components/new-lead-drawer.js.map +1 -1
  27. package/src/lib/hooks/use-lead-source-picklist.d.ts +23 -0
  28. package/src/lib/hooks/use-lead-source-picklist.js +50 -0
  29. package/src/lib/hooks/use-lead-source-picklist.js.map +1 -0
  30. package/src/lib/model/contact-record.d.ts +2 -0
  31. package/src/lib/model/contact-record.js +2 -1
  32. package/src/lib/model/contact-record.js.map +1 -1
  33. package/src/lib/model/contact-update.d.ts +6 -0
  34. package/src/lib/model/contact-update.js +1 -0
  35. package/src/lib/model/contact-update.js.map +1 -1
  36. package/src/lib/model/crm-lead-import.d.ts +10 -2
  37. package/src/lib/model/crm-lead-import.js +19 -1
  38. package/src/lib/model/crm-lead-import.js.map +1 -1
  39. package/src/lib/model/lead-filters.d.ts +13 -0
  40. package/src/lib/model/lead-filters.js +17 -1
  41. package/src/lib/model/lead-filters.js.map +1 -1
  42. package/src/lib/model/lead-source-values.d.ts +82 -0
  43. package/src/lib/model/lead-source-values.js +149 -0
  44. package/src/lib/model/lead-source-values.js.map +1 -0
  45. package/src/lib/server/contact-update.js +25 -2
  46. package/src/lib/server/contact-update.js.map +1 -1
  47. package/src/lib/server/lead-create.js +19 -0
  48. package/src/lib/server/lead-create.js.map +1 -1
  49. package/src/lib/server/lead-source-picklist.d.ts +42 -0
  50. package/src/lib/server/lead-source-picklist.js +52 -0
  51. package/src/lib/server/lead-source-picklist.js.map +1 -0
  52. package/src/lib/server/lead-source-values.d.ts +50 -0
  53. package/src/lib/server/lead-source-values.js +187 -0
  54. package/src/lib/server/lead-source-values.js.map +1 -0
  55. package/src/lib/server/leads-import.js +31 -2
  56. package/src/lib/server/leads-import.js.map +1 -1
  57. package/src/lib/server.js +5 -0
  58. package/src/lib/server.js.map +1 -1
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type CrmPicklist } from '@aglyn/aglyn/server';
18
+ /** The org's lead source list as every server door judges against it (AGL-3298). */
19
+ export declare function readLeadSourcePicklist(firestore: FirebaseFirestore.Firestore, orgId: string): Promise<CrmPicklist>;
20
+ /**
21
+ * What a lead write stores as its lead source (AGL-3298), from what the
22
+ * request said about it:
23
+ *
24
+ * - `undefined` (not named): nothing — except on a CREATE, which starts
25
+ * from the list's default when it has one, as Salesforce's does.
26
+ * - `null` (cleared): the clear.
27
+ * - a label: {@link judgeCrmLeadSource} — an active value's label, the
28
+ * record's own current value kept, anything else refused naming the
29
+ * values the list allows.
30
+ *
31
+ * `write` undefined means leave the field alone.
32
+ */
33
+ export declare function resolveLeadSourceWrite(picklist: CrmPicklist, requested: string | null | undefined, options: {
34
+ current?: unknown;
35
+ created: boolean;
36
+ }): {
37
+ ok: true;
38
+ write: string | null | undefined;
39
+ } | {
40
+ ok: false;
41
+ error: string;
42
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { CRM_COLLECTIONS, CRM_LEAD_SOURCE_PICKLIST, crmPicklistDefaultLabel, effectiveCrmLeadSourcePicklist, judgeCrmLeadSource } from "@aglyn/aglyn/server";
17
+ /** The org's lead source list as every server door judges against it (AGL-3298). */ export async function readLeadSourcePicklist(firestore, orgId) {
18
+ const snapshot = await firestore.collection('orgs').doc(orgId).collection(CRM_COLLECTIONS.picklists).doc(CRM_LEAD_SOURCE_PICKLIST).get();
19
+ return effectiveCrmLeadSourcePicklist(snapshot.data());
20
+ }
21
+ /**
22
+ * What a lead write stores as its lead source (AGL-3298), from what the
23
+ * request said about it:
24
+ *
25
+ * - `undefined` (not named): nothing — except on a CREATE, which starts
26
+ * from the list's default when it has one, as Salesforce's does.
27
+ * - `null` (cleared): the clear.
28
+ * - a label: {@link judgeCrmLeadSource} — an active value's label, the
29
+ * record's own current value kept, anything else refused naming the
30
+ * values the list allows.
31
+ *
32
+ * `write` undefined means leave the field alone.
33
+ */ export function resolveLeadSourceWrite(picklist, requested, options) {
34
+ if (requested === undefined) {
35
+ const fallback = options.created ? crmPicklistDefaultLabel(picklist) : null;
36
+ return {
37
+ ok: true,
38
+ write: fallback != null ? fallback : undefined
39
+ };
40
+ }
41
+ if (requested === null) return {
42
+ ok: true,
43
+ write: null
44
+ };
45
+ const judged = judgeCrmLeadSource(picklist, requested, options.current);
46
+ return judged.ok === false ? judged : {
47
+ ok: true,
48
+ write: judged.value
49
+ };
50
+ }
51
+
52
+ //# sourceMappingURL=lead-source-picklist.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-source-picklist.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 CRM_COLLECTIONS,\n CRM_LEAD_SOURCE_PICKLIST,\n type CrmPicklist,\n crmPicklistDefaultLabel,\n effectiveCrmLeadSourcePicklist,\n judgeCrmLeadSource,\n} from '@aglyn/aglyn/server'\n\n/** The org's lead source list as every server door judges against it (AGL-3298). */\nexport async function readLeadSourcePicklist(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<CrmPicklist> {\n const snapshot = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.picklists)\n .doc(CRM_LEAD_SOURCE_PICKLIST)\n .get()\n return effectiveCrmLeadSourcePicklist(snapshot.data())\n}\n\n/**\n * What a lead write stores as its lead source (AGL-3298), from what the\n * request said about it:\n *\n * - `undefined` (not named): nothing — except on a CREATE, which starts\n * from the list's default when it has one, as Salesforce's does.\n * - `null` (cleared): the clear.\n * - a label: {@link judgeCrmLeadSource} — an active value's label, the\n * record's own current value kept, anything else refused naming the\n * values the list allows.\n *\n * `write` undefined means leave the field alone.\n */\nexport function resolveLeadSourceWrite(\n picklist: CrmPicklist,\n requested: string | null | undefined,\n options: { current?: unknown; created: boolean },\n): { ok: true; write: string | null | undefined } | { ok: false; error: string } {\n if (requested === undefined) {\n const fallback = options.created ? crmPicklistDefaultLabel(picklist) : null\n return { ok: true, write: fallback ?? undefined }\n }\n if (requested === null) return { ok: true, write: null }\n const judged = judgeCrmLeadSource(picklist, requested, options.current)\n return judged.ok === false ? judged : { ok: true, write: judged.value }\n}\n"],"names":["CRM_COLLECTIONS","CRM_LEAD_SOURCE_PICKLIST","crmPicklistDefaultLabel","effectiveCrmLeadSourcePicklist","judgeCrmLeadSource","readLeadSourcePicklist","firestore","orgId","snapshot","collection","doc","picklists","get","data","resolveLeadSourceWrite","picklist","requested","options","undefined","fallback","created","ok","write","judged","current","value"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,eAAe,EACfC,wBAAwB,EAExBC,uBAAuB,EACvBC,8BAA8B,EAC9BC,kBAAkB,QACb,sBAAqB;AAE5B,kFAAkF,GAClF,OAAO,eAAeC,uBACpBC,SAAsC,EACtCC,KAAa;IAEb,MAAMC,WAAW,MAAMF,UACpBG,UAAU,CAAC,QACXC,GAAG,CAACH,OACJE,UAAU,CAACT,gBAAgBW,SAAS,EACpCD,GAAG,CAACT,0BACJW,GAAG;IACN,OAAOT,+BAA+BK,SAASK,IAAI;AACrD;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,uBACdC,QAAqB,EACrBC,SAAoC,EACpCC,OAAgD;IAEhD,IAAID,cAAcE,WAAW;QAC3B,MAAMC,WAAWF,QAAQG,OAAO,GAAGlB,wBAAwBa,YAAY;QACvE,OAAO;YAAEM,IAAI;YAAMC,KAAK,EAAEH,mBAAAA,WAAYD;QAAU;IAClD;IACA,IAAIF,cAAc,MAAM,OAAO;QAAEK,IAAI;QAAMC,OAAO;IAAK;IACvD,MAAMC,SAASnB,mBAAmBW,UAAUC,WAAWC,QAAQO,OAAO;IACtE,OAAOD,OAAOF,EAAE,KAAK,QAAQE,SAAS;QAAEF,IAAI;QAAMC,OAAOC,OAAOE,KAAK;IAAC;AACxE"}
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type PluginApiHandler } from '@aglyn/aglyn/server';
18
+ /**
19
+ * `POST crm/lead-source-values` — rename a lead source value, or delete one
20
+ * and move its records to another (AGL-3298).
21
+ *
22
+ * Body: `{ hostId | orgId, action: 'rename', valueId, label }` or
23
+ * `{ hostId | orgId, action: 'delete', valueId, replaceWith: label | null }`.
24
+ *
25
+ * Records store the value's LABEL (see the picklist block in `crm.ts`), so
26
+ * both moves change records as well as the list: every lead and every
27
+ * contact holding the old label is rewritten to the new one — or, for a
28
+ * delete with no replacement, cleared. That is Salesforce's Replace, done
29
+ * as part of the move, so a report grouped by lead source follows a rename
30
+ * instead of splitting into the old name and the new.
31
+ *
32
+ * ## The list first, then the records
33
+ *
34
+ * The list is written in a transaction before any record: from that moment
35
+ * no picker offers the old label and no import or API write accepts it, so
36
+ * the sweep that follows cannot be outrun by a new record taking the name
37
+ * it is removing. A sweep interrupted part-way leaves records holding a
38
+ * label the list no longer has, which they show as "(not in the list)"; the
39
+ * same request sent again finishes it, because the rename of a value to its
40
+ * own current label is a no-op on the list and still sweeps.
41
+ *
42
+ * ## Who may
43
+ *
44
+ * Whoever may write the CRM — `authorizeCrmWriter`, the question every CRM
45
+ * route asks — which is who the rules let edit the list itself. The sweep
46
+ * reaches every lead and contact in the org whatever the caller's scope:
47
+ * it changes one label to another that the same caller could already have
48
+ * picked, and reveals nothing about the records but their count.
49
+ */
50
+ export declare const crmLeadSourceValuesHandler: PluginApiHandler;
@@ -0,0 +1,187 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { consentGroupForHost, CONTACT_FACETS_FIELD, CRM_COLLECTIONS, CRM_LEAD_SOURCE_PICKLIST, effectiveCrmLeadSourcePicklist, newResourceScopeFields, normalizeCrmPicklist, ORG_SCOPE_TOKEN } from "@aglyn/aglyn/server";
18
+ import { firebaseAdmin } from "@aglyn/tenant-data-admin";
19
+ import { FieldPath, FieldValue } from "firebase-admin/firestore";
20
+ import { deletePicklistValue, renamePicklistValue } from "../model/lead-source-values.js";
21
+ import { orgHostIds, readCrmRouteScope } from "./org-caller.js";
22
+ import { authorizeCrmWriter } from "./task-routes.js";
23
+ /** What the suite gate names when a plan without the CRM asks. */ const SUITE_ACT = "Editing the organization's lead sources";
24
+ /** Writes per batch — under Firestore's 500, with room to spare. */ const BATCH_SIZE = 400;
25
+ /**
26
+ * Set `field` to `to` (or remove it, for `null`) on every document `query`
27
+ * finds holding `from`, a batch at a time. Answers how many were written.
28
+ *
29
+ * The query is re-run after each batch rather than paged, because each
30
+ * batch moves its documents OUT of the query's answer — the next run finds
31
+ * only what is left, and a run that finds nothing is the end.
32
+ */ async function replaceEverywhere(firestore, query, field, to) {
33
+ let written = 0;
34
+ for(;;){
35
+ const page = await query.limit(BATCH_SIZE).get();
36
+ if (page.empty) return written;
37
+ const batch = firestore.batch();
38
+ for (const snapshot of page.docs){
39
+ batch.update(snapshot.ref, field, to != null ? to : FieldValue.delete(), 'updatedAt', FieldValue.serverTimestamp());
40
+ }
41
+ await batch.commit();
42
+ written += page.size;
43
+ if (page.size < BATCH_SIZE) return written;
44
+ }
45
+ }
46
+ /**
47
+ * `POST crm/lead-source-values` — rename a lead source value, or delete one
48
+ * and move its records to another (AGL-3298).
49
+ *
50
+ * Body: `{ hostId | orgId, action: 'rename', valueId, label }` or
51
+ * `{ hostId | orgId, action: 'delete', valueId, replaceWith: label | null }`.
52
+ *
53
+ * Records store the value's LABEL (see the picklist block in `crm.ts`), so
54
+ * both moves change records as well as the list: every lead and every
55
+ * contact holding the old label is rewritten to the new one — or, for a
56
+ * delete with no replacement, cleared. That is Salesforce's Replace, done
57
+ * as part of the move, so a report grouped by lead source follows a rename
58
+ * instead of splitting into the old name and the new.
59
+ *
60
+ * ## The list first, then the records
61
+ *
62
+ * The list is written in a transaction before any record: from that moment
63
+ * no picker offers the old label and no import or API write accepts it, so
64
+ * the sweep that follows cannot be outrun by a new record taking the name
65
+ * it is removing. A sweep interrupted part-way leaves records holding a
66
+ * label the list no longer has, which they show as "(not in the list)"; the
67
+ * same request sent again finishes it, because the rename of a value to its
68
+ * own current label is a no-op on the list and still sweeps.
69
+ *
70
+ * ## Who may
71
+ *
72
+ * Whoever may write the CRM — `authorizeCrmWriter`, the question every CRM
73
+ * route asks — which is who the rules let edit the list itself. The sweep
74
+ * reaches every lead and contact in the org whatever the caller's scope:
75
+ * it changes one label to another that the same caller could already have
76
+ * picked, and reveals nothing about the records but their count.
77
+ */ export const crmLeadSourceValuesHandler = async (req, res)=>{
78
+ var _req_body, _body_valueId;
79
+ if (req.method !== 'POST') {
80
+ res.setHeader('Allow', 'POST');
81
+ res.status(405).json({
82
+ error: 'Method not allowed'
83
+ });
84
+ return;
85
+ }
86
+ const body = (_req_body = req.body) != null ? _req_body : {};
87
+ const scope = readCrmRouteScope(body);
88
+ if (!scope) {
89
+ res.status(400).json({
90
+ error: 'Missing hostId'
91
+ });
92
+ return;
93
+ }
94
+ const action = body['action'];
95
+ const valueId = String((_body_valueId = body['valueId']) != null ? _body_valueId : '').trim().slice(0, 64);
96
+ if (action !== 'rename' && action !== 'delete' || !valueId) {
97
+ res.status(400).json({
98
+ error: 'Name a value, and whether to rename or delete it.'
99
+ });
100
+ return;
101
+ }
102
+ const replaceWith = body['replaceWith'] === null || body['replaceWith'] === undefined ? null : String(body['replaceWith']);
103
+ try {
104
+ const writer = await authorizeCrmWriter(req, scope, {
105
+ suiteAct: SUITE_ACT
106
+ });
107
+ if (writer.ok === false) {
108
+ res.status(writer.status).json(writer.body);
109
+ return;
110
+ }
111
+ const firestore = firebaseAdmin.app().firestore();
112
+ const orgRef = firestore.collection('orgs').doc(writer.orgId);
113
+ const listRef = orgRef.collection(CRM_COLLECTIONS.picklists).doc(CRM_LEAD_SOURCE_PICKLIST);
114
+ /*
115
+ * THE LIST. Read and written in one transaction, so two admins editing
116
+ * at once cannot each write a list missing the other's change.
117
+ */ const moved = await firestore.runTransaction(async (transaction)=>{
118
+ var _body_label, _ref, _ref1;
119
+ var _move_picklist_values_find, _move_picklist_values_find1;
120
+ const snapshot = await transaction.get(listRef);
121
+ const before = effectiveCrmLeadSourcePicklist(snapshot.data());
122
+ const value = before.values.find((entry)=>entry.id === valueId);
123
+ if (!value) return {
124
+ ok: false,
125
+ error: 'That value is no longer in the list.'
126
+ };
127
+ const move = action === 'rename' ? renamePicklistValue(before, valueId, String((_body_label = body['label']) != null ? _body_label : '')) : deletePicklistValue(before, valueId, replaceWith);
128
+ if (move.ok === false) return move;
129
+ const created = normalizeCrmPicklist(snapshot.data()) === null;
130
+ transaction.set(listRef, _extends({
131
+ values: move.picklist.values,
132
+ defaultValueId: move.picklist.defaultValueId,
133
+ updatedAt: FieldValue.serverTimestamp()
134
+ }, created ? _extends({
135
+ hostId: scope.hostId || null,
136
+ createdAt: FieldValue.serverTimestamp()
137
+ }, newResourceScopeFields([
138
+ ORG_SCOPE_TOKEN
139
+ ])) : {}), {
140
+ merge: true
141
+ });
142
+ const to = action === 'rename' ? (_ref = (_move_picklist_values_find = move.picklist.values.find((entry)=>entry.id === valueId)) == null ? void 0 : _move_picklist_values_find.label) != null ? _ref : null : replaceWith === null ? null : (_ref1 = (_move_picklist_values_find1 = move.picklist.values.find((entry)=>entry.label.toLowerCase() === replaceWith.trim().toLowerCase())) == null ? void 0 : _move_picklist_values_find1.label) != null ? _ref1 : null;
143
+ return {
144
+ ok: true,
145
+ from: value.label,
146
+ to
147
+ };
148
+ });
149
+ if (moved.ok === false) {
150
+ res.status(400).json({
151
+ error: moved.error
152
+ });
153
+ return;
154
+ }
155
+ /*
156
+ * THE RECORDS. Leads by the field itself; contacts by each holder's
157
+ * facet, one query per consent group the org's sites belong to — a
158
+ * contact's lead source is the holder's own, like the rest of its
159
+ * profile. A rename to the same label moves nothing, and says so.
160
+ */ let leads = 0;
161
+ let contacts = 0;
162
+ if (moved.from !== moved.to) {
163
+ leads = await replaceEverywhere(firestore, orgRef.collection('leads').where('leadSource', '==', moved.from), 'leadSource', moved.to);
164
+ const hostIds = await orgHostIds(firestore, writer.orgId);
165
+ const groupIds = [
166
+ ...new Set(hostIds.map((hostId)=>consentGroupForHost(writer.org, hostId).groupId))
167
+ ].filter(Boolean);
168
+ for (const groupId of groupIds){
169
+ const path = new FieldPath(CONTACT_FACETS_FIELD, groupId, 'leadSource');
170
+ contacts += await replaceEverywhere(firestore, orgRef.collection('contacts').where(path, '==', moved.from), path, moved.to);
171
+ }
172
+ }
173
+ const answer = {
174
+ ok: true,
175
+ leads,
176
+ contacts
177
+ };
178
+ res.status(200).json(answer);
179
+ } catch (error) {
180
+ console.error('[crm] lead-source-values failed', error);
181
+ res.status(500).json({
182
+ error: 'The lead sources could not be saved.'
183
+ });
184
+ }
185
+ };
186
+
187
+ //# sourceMappingURL=lead-source-values.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/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 consentGroupForHost,\n CONTACT_FACETS_FIELD,\n CRM_COLLECTIONS,\n CRM_LEAD_SOURCE_PICKLIST,\n type CrmPicklist,\n effectiveCrmLeadSourcePicklist,\n newResourceScopeFields,\n normalizeCrmPicklist,\n ORG_SCOPE_TOKEN,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { FieldPath, FieldValue } from 'firebase-admin/firestore'\nimport {\n deletePicklistValue,\n type LeadSourceValuesResponse,\n type PicklistMove,\n renamePicklistValue,\n} from '../model/lead-source-values'\nimport { orgHostIds, readCrmRouteScope } from './org-caller'\nimport { authorizeCrmWriter } from './task-routes'\n\n/** What the suite gate names when a plan without the CRM asks. */\nconst SUITE_ACT = \"Editing the organization's lead sources\"\n\n/** Writes per batch — under Firestore's 500, with room to spare. */\nconst BATCH_SIZE = 400\n\n/**\n * Set `field` to `to` (or remove it, for `null`) on every document `query`\n * finds holding `from`, a batch at a time. Answers how many were written.\n *\n * The query is re-run after each batch rather than paged, because each\n * batch moves its documents OUT of the query's answer — the next run finds\n * only what is left, and a run that finds nothing is the end.\n */\nasync function replaceEverywhere(\n firestore: FirebaseFirestore.Firestore,\n query: FirebaseFirestore.Query,\n field: string | FirebaseFirestore.FieldPath,\n to: string | null,\n): Promise<number> {\n let written = 0\n for (;;) {\n const page = await query.limit(BATCH_SIZE).get()\n if (page.empty) return written\n const batch = firestore.batch()\n for (const snapshot of page.docs) {\n batch.update(snapshot.ref, field, to ?? FieldValue.delete(), 'updatedAt', FieldValue.serverTimestamp())\n }\n await batch.commit()\n written += page.size\n if (page.size < BATCH_SIZE) return written\n }\n}\n\n/**\n * `POST crm/lead-source-values` — rename a lead source value, or delete one\n * and move its records to another (AGL-3298).\n *\n * Body: `{ hostId | orgId, action: 'rename', valueId, label }` or\n * `{ hostId | orgId, action: 'delete', valueId, replaceWith: label | null }`.\n *\n * Records store the value's LABEL (see the picklist block in `crm.ts`), so\n * both moves change records as well as the list: every lead and every\n * contact holding the old label is rewritten to the new one — or, for a\n * delete with no replacement, cleared. That is Salesforce's Replace, done\n * as part of the move, so a report grouped by lead source follows a rename\n * instead of splitting into the old name and the new.\n *\n * ## The list first, then the records\n *\n * The list is written in a transaction before any record: from that moment\n * no picker offers the old label and no import or API write accepts it, so\n * the sweep that follows cannot be outrun by a new record taking the name\n * it is removing. A sweep interrupted part-way leaves records holding a\n * label the list no longer has, which they show as \"(not in the list)\"; the\n * same request sent again finishes it, because the rename of a value to its\n * own current label is a no-op on the list and still sweeps.\n *\n * ## Who may\n *\n * Whoever may write the CRM — `authorizeCrmWriter`, the question every CRM\n * route asks — which is who the rules let edit the list itself. The sweep\n * reaches every lead and contact in the org whatever the caller's scope:\n * it changes one label to another that the same caller could already have\n * picked, and reveals nothing about the records but their count.\n */\nexport const crmLeadSourceValuesHandler: 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 action = body['action']\n const valueId = String(body['valueId'] ?? '').trim().slice(0, 64)\n if ((action !== 'rename' && action !== 'delete') || !valueId) {\n res.status(400).json({ error: 'Name a value, and whether to rename or delete it.' })\n return\n }\n const replaceWith =\n body['replaceWith'] === null || body['replaceWith'] === undefined\n ? null\n : String(body['replaceWith'])\n\n try {\n const writer = await authorizeCrmWriter(req, scope, { suiteAct: SUITE_ACT })\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(writer.orgId)\n const listRef = orgRef.collection(CRM_COLLECTIONS.picklists).doc(CRM_LEAD_SOURCE_PICKLIST)\n\n /*\n * THE LIST. Read and written in one transaction, so two admins editing\n * at once cannot each write a list missing the other's change.\n */\n const moved = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(listRef)\n const before: CrmPicklist = effectiveCrmLeadSourcePicklist(snapshot.data())\n const value = before.values.find((entry) => entry.id === valueId)\n if (!value) return { ok: false as const, error: 'That value is no longer in the list.' }\n const move: PicklistMove =\n action === 'rename'\n ? renamePicklistValue(before, valueId, String(body['label'] ?? ''))\n : deletePicklistValue(before, valueId, replaceWith)\n if (move.ok === false) return move\n const created = normalizeCrmPicklist(snapshot.data()) === null\n transaction.set(\n listRef,\n {\n values: move.picklist.values,\n defaultValueId: move.picklist.defaultValueId,\n updatedAt: FieldValue.serverTimestamp(),\n // The stamp every org-wide CRM document carries (see the Fields\n // page), written when this move is what creates the document.\n ...(created\n ? {\n hostId: scope.hostId || null,\n createdAt: FieldValue.serverTimestamp(),\n ...newResourceScopeFields([ORG_SCOPE_TOKEN]),\n }\n : {}),\n },\n { merge: true },\n )\n const to =\n action === 'rename'\n ? (move.picklist.values.find((entry) => entry.id === valueId)?.label ?? null)\n : replaceWith === null\n ? null\n : (move.picklist.values.find(\n (entry) => entry.label.toLowerCase() === replaceWith.trim().toLowerCase(),\n )?.label ?? null)\n return { ok: true as const, from: value.label, to }\n })\n if (moved.ok === false) {\n res.status(400).json({ error: moved.error })\n return\n }\n\n /*\n * THE RECORDS. Leads by the field itself; contacts by each holder's\n * facet, one query per consent group the org's sites belong to — a\n * contact's lead source is the holder's own, like the rest of its\n * profile. A rename to the same label moves nothing, and says so.\n */\n let leads = 0\n let contacts = 0\n if (moved.from !== moved.to) {\n leads = await replaceEverywhere(\n firestore,\n orgRef.collection('leads').where('leadSource', '==', moved.from),\n 'leadSource',\n moved.to,\n )\n const hostIds = await orgHostIds(firestore, writer.orgId)\n const groupIds = [\n ...new Set(hostIds.map((hostId) => consentGroupForHost(writer.org, hostId).groupId)),\n ].filter(Boolean)\n for (const groupId of groupIds) {\n const path = new FieldPath(CONTACT_FACETS_FIELD, groupId, 'leadSource')\n contacts += await replaceEverywhere(\n firestore,\n orgRef.collection('contacts').where(path, '==', moved.from),\n path,\n moved.to,\n )\n }\n }\n const answer: LeadSourceValuesResponse = { ok: true, leads, contacts }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] lead-source-values failed', error)\n res.status(500).json({ error: 'The lead sources could not be saved.' })\n }\n}\n"],"names":["consentGroupForHost","CONTACT_FACETS_FIELD","CRM_COLLECTIONS","CRM_LEAD_SOURCE_PICKLIST","effectiveCrmLeadSourcePicklist","newResourceScopeFields","normalizeCrmPicklist","ORG_SCOPE_TOKEN","firebaseAdmin","FieldPath","FieldValue","deletePicklistValue","renamePicklistValue","orgHostIds","readCrmRouteScope","authorizeCrmWriter","SUITE_ACT","BATCH_SIZE","replaceEverywhere","firestore","query","field","to","written","page","limit","get","empty","batch","snapshot","docs","update","ref","delete","serverTimestamp","commit","size","crmLeadSourceValuesHandler","req","res","body","method","setHeader","status","json","error","scope","action","valueId","String","trim","slice","replaceWith","undefined","writer","suiteAct","ok","app","orgRef","collection","doc","orgId","listRef","picklists","moved","runTransaction","transaction","move","before","data","value","values","find","entry","id","created","set","picklist","defaultValueId","updatedAt","hostId","createdAt","merge","label","toLowerCase","from","leads","contacts","where","hostIds","groupIds","Set","map","org","groupId","filter","Boolean","path","answer","console"],"mappings":"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"}
@@ -79,6 +79,7 @@ import { addHostLead, firebaseAdmin, orgLeadsForHost } from "@aglyn/tenant-data-
79
79
  import { visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
80
80
  import { FieldValue } from "firebase-admin/firestore";
81
81
  import { ownerDirectory, readImportRows, resolveImportContext } from "./import-context.js";
82
+ import { readLeadSourcePicklist, resolveLeadSourceWrite } from "./lead-source-picklist.js";
82
83
  /**
83
84
  * The surface an imported lead names, beside `signup`, `booking` and
84
85
  * `form:{formId}`.
@@ -210,11 +211,18 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
210
211
  var _entry_row_campaigns;
211
212
  return (_entry_row_campaigns = entry.row.campaigns) == null ? void 0 : _entry_row_campaigns.length;
212
213
  });
213
- const [owners, before, campaigns] = await Promise.all([
214
+ const [owners, before, campaigns, leadSources] = await Promise.all([
214
215
  ownerDirectory(context.orgId, normalized.map((entry)=>entry.row)),
215
216
  refs.length ? firestore.getAll(...refs) : Promise.resolve([]),
216
- namesCampaigns ? campaignDirectory(firestore, context.orgId, context.hostId) : Promise.resolve(new Map())
217
+ namesCampaigns ? campaignDirectory(firestore, context.orgId, context.hostId) : Promise.resolve(new Map()),
218
+ // The org's lead source list (AGL-3298): one read per chunk, for the
219
+ // values rows name and the default a new lead starts from.
220
+ readLeadSourcePicklist(firestore, context.orgId)
217
221
  ]);
222
+ const beforeById = new Map(before.map((snapshot)=>[
223
+ snapshot.id,
224
+ snapshot
225
+ ]));
218
226
  const held = new Set(before.filter((snapshot)=>snapshot.exists).map((snapshot)=>snapshot.id));
219
227
  const ownersUnresolved = new Set();
220
228
  let counted = null;
@@ -223,6 +231,7 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
223
231
  let merged = 0;
224
232
  for (const { index, row, key } of normalized){
225
233
  var _row_campaigns;
234
+ var _beforeById_get;
226
235
  // The campaigns the row names, every one of them the org's, or the
227
236
  // row is refused whole and named (AGL-3254).
228
237
  const campaignIds = ((_row_campaigns = row.campaigns) != null ? _row_campaigns : []).map((name)=>{
@@ -239,6 +248,26 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
239
248
  }
240
249
  const isNew = !held.has(key);
241
250
  /*
251
+ * THE LEAD SOURCE, restricted to the org's list (AGL-3298). A value
252
+ * the list does not hold is refused whole and named, as a campaign
253
+ * the org does not have is — the drawer's notice has already listed
254
+ * the values the list allows — and a lead the site already held
255
+ * keeps the value it has. A new lead with no value starts from the
256
+ * list's default.
257
+ */ const leadSource = resolveLeadSourceWrite(leadSources, row.profile.leadSource, {
258
+ current: (_beforeById_get = beforeById.get(key)) == null ? void 0 : _beforeById_get.get('leadSource'),
259
+ created: isNew
260
+ });
261
+ if (leadSource.ok === false) {
262
+ skipped.push({
263
+ index,
264
+ email: row.email,
265
+ reason: 'lead-source-unknown'
266
+ });
267
+ continue;
268
+ }
269
+ if (leadSource.write) row.profile.leadSource = leadSource.write;
270
+ /*
242
271
  * The platform lead ceiling, judged the way the deals import judges
243
272
  * the records band: one count at the first create, re-judged locally
244
273
  * as the request creates more. The door re-judges it authoritatively
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/leads-import.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/leads-import` — one chunk of a leads file, written\n * (AGL-2701).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link LEAD_IMPORT_CHUNK_SIZE} raw\n * rows in the vocabulary `crm-lead-import.ts` defines. This route judges\n * each one through the same normalizer, resolves the owner against the\n * org's roster, and writes it in two halves.\n *\n * ## The capture half goes through the door every capture goes through\n *\n * `addHostLead` — the ONE writer of `hosts/{hostId}/leads` for the sign-up\n * handler and both booking paths — is the writer here too. That is what\n * makes an imported row key on `personKey` the way a second form\n * submission does, land under the site by path with `capturedByHostIds`\n * naming it, count against `LEADS_MAX_PER_HOST` inside the transaction\n * that writes, and record NO marketing basis. A bulk path with its own\n * `add()` would grow an unkeyed, unbounded copy of the site's leads under\n * a second set of rules.\n *\n * ## A lead is host-scoped by path, so nothing here stamps `visibleTo`\n *\n * The five org collections carry `visibleTo` because they sit under\n * `orgs/{orgId}` and the rules decide per document which sites may read\n * them. A lead does not: `hosts/{hostId}/leads` is private to the site by\n * path, the rules admit the site's own members, and the list therefore\n * reads it with no scope clause. An import that stamped scope tokens onto\n * a lead would be writing a field nothing reads, and would suggest a\n * sharing decision the collection does not have. The site is the whole\n * scope, and the drawer's picker is where it is chosen.\n *\n * ## No file may hand a lead a consent it did not give\n *\n * A capture writes a marketing basis only when the visitor ticked a box in\n * front of them. A CSV row has no such event behind it, so the lead\n * vocabulary has no consent column and this route passes no\n * `marketingConsent`: an imported lead is mailable only if some earlier\n * capture on this site already recorded a basis, which `addHostLead`\n * carries forward untouched. The contacts import has a consent column\n * because its own export writes one and the merchant is asserting a basis\n * they hold per person; the leads export writes none, and a column the\n * file cannot fill is a column that cannot be misread.\n *\n * ## The working half is written the way the list writes it\n *\n * A status, an owner, a reason and notes are the CRM's annotations, not\n * the capture's, and the leads list already writes them client-direct onto\n * the same document. A row that carries any of them gets one merge write\n * after the door's, stamped `updatedAt` exactly as the list stamps it. A\n * row that carries none costs nothing extra.\n *\n * ## Created or merged is read once for the chunk\n *\n * The door reports only whether the lead was STORED, and the result panel\n * has to say how many people were added and how many updated. One\n * `getAll` over the chunk's document ids answers that for every row in a\n * single round trip, before any of them is written — which is also the\n * only moment the answer is still true.\n */\n\nimport {\n checkVisitorRecordCeiling,\n type ContactSource,\n LEADS_MAX_PER_HOST,\n personKey,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n LEAD_IMPORT_CHUNK_SIZE,\n LEAD_IMPORT_MAX_BODY_BYTES,\n type LeadImportChunkResult,\n type LeadImportRawRow,\n type LeadImportRow,\n type LeadImportSkippedRow,\n normalizeLeadImportRow,\n} from '../model/crm-lead-import'\nimport { addHostLead, firebaseAdmin, orgLeadsForHost } from '@aglyn/tenant-data-admin'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\n\n/**\n * The surface an imported lead names, beside `signup`, `booking` and\n * `form:{formId}`.\n *\n * Typed as the capture-source union rather than a bare string, which is\n * what the contacts import gets for free from the door it calls: a lead's\n * `source` is plain text on the way in, so this is the only place the word\n * can be held to the same vocabulary the source filters and the labels\n * read. One person brought in by one file reads the same on both records.\n */\nconst LEAD_IMPORT_SOURCE: ContactSource = 'import'\n\n/**\n * How many of the org's campaigns one chunk reads to resolve the names a\n * file carries (AGL-3254). Well past what an org keeps — the picker offers\n * fifty — and read once per chunk rather than once per row.\n */\nconst CAMPAIGN_DIRECTORY_CEILING = 200\n\n/**\n * The org's live campaigns by NAME, lower-cased, for the `campaigns`\n * column. A lead is an org record, so every live container of the org\n * (`orgs/{orgId}/emailCampaigns`) may be named, whichever sites it is\n * placed on; where two share a name, the one placed on the importing site\n * wins, because that is the one its picker shows. A name the org does not\n * have is not guessed at: the row is refused whole and named, because a\n * lead filed under half its campaigns is a lead nobody asked for. Read\n * only when some row names one.\n */\nasync function campaignDirectory(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n hostId: string,\n): Promise<Map<string, string>> {\n const directory = new Map<string, string>()\n if (!orgId) return directory\n const containers = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('emailCampaigns')\n .limit(CAMPAIGN_DIRECTORY_CEILING)\n .get()\n const live = containers.docs.filter((container) => !container.get('deletedAt'))\n const placed = (container: FirebaseFirestore.QueryDocumentSnapshot) =>\n visibleToHost(container.get('visibleTo') as string[] | undefined, hostId)\n for (const container of [...live.filter(placed), ...live.filter((entry) => !placed(entry))]) {\n const name = String(container.get('name') ?? '').trim().toLowerCase()\n if (name && !directory.has(name)) directory.set(name, container.id)\n }\n return directory\n}\n\n/**\n * The team's annotations on one row and the lead's own profile\n * (AGL-3231), or nothing when the file named none of either. A profile\n * value lands as the record's card would write it; the file never clears\n * one, because a blank cell is a cell nobody filled.\n */\nfunction workingState(\n row: LeadImportRow,\n ownerUid: string | undefined,\n campaignIds: readonly string[],\n): Record<string, unknown> | null {\n const fields: Record<string, unknown> = {\n ...(row.status ? { status: row.status } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.unqualifiedReason ? { unqualifiedReason: row.unqualifiedReason } : {}),\n ...(row.notes ? { notes: row.notes } : {}),\n ...row.profile,\n // Added to what a lead the site already held carries (AGL-3254): a\n // file re-imported under a new campaign files the person under one\n // more, and never takes them out of the last.\n ...(campaignIds.length ? { campaignIds: FieldValue.arrayUnion(...campaignIds) } : {}),\n }\n return Object.keys(fields).length ? fields : null\n}\n\n/**\n * `POST crm/leads-import` — `{ hostId, rows }` → a {@link LeadImportChunkResult}.\n *\n * Rows are written one after another so the ceiling's local count stays\n * honest across the request.\n */\nexport const crmLeadsImportHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const read = readImportRows<LeadImportRawRow>(req, {\n maxBodyBytes: LEAD_IMPORT_MAX_BODY_BYTES,\n chunkSize: LEAD_IMPORT_CHUNK_SIZE,\n })\n if ('error' in read) return res.status(read.status).json({ error: read.error })\n try {\n const context = await resolveImportContext(req)\n if (context.ok === false) return res.status(context.status).json(context.body)\n\n const skipped: LeadImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: LeadImportRow; key: string }[] = []\n /*\n * A duplicate WITHIN the request is skipped rather than merged, for\n * the reason the contacts import gives: the second row would merge\n * onto the first's document and the tally would read one created and\n * one merged for one person in one file. Across requests the door's\n * own dedupe answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeLeadImportRow(raw)\n if (verdict.ok === false) {\n skipped.push({ index, email: verdict.input, reason: verdict.reason })\n return\n }\n // Non-null by construction: the normalizer refused every address\n // this derivation could not key.\n const key = personKey(verdict.row.email) as string\n if (seen.has(key)) {\n skipped.push({ index, email: verdict.row.email, reason: 'duplicate' })\n return\n }\n seen.add(key)\n for (const entry of verdict.row.dropped) {\n dropped[entry.field] = (dropped[entry.field] ?? 0) + 1\n }\n normalized.push({ index, row: verdict.row, key })\n })\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(context.hostId)\n const leadsRef = await orgLeadsForHost(context.hostId)\n const refs = normalized.map((entry) => leadsRef.doc(entry.key))\n const namesCampaigns = normalized.some((entry) => entry.row.campaigns?.length)\n const [owners, before, campaigns] = await Promise.all([\n ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n ),\n refs.length ? firestore.getAll(...refs) : Promise.resolve([]),\n namesCampaigns\n ? campaignDirectory(firestore, context.orgId, context.hostId)\n : Promise.resolve(new Map<string, string>()),\n ])\n const held = new Set(\n before.filter((snapshot) => snapshot.exists).map((snapshot) => snapshot.id),\n )\n const ownersUnresolved = new Set<string>()\n let counted: number | null = null\n let createdHere = 0\n let created = 0\n let merged = 0\n\n for (const { index, row, key } of normalized) {\n // The campaigns the row names, every one of them the org's, or the\n // row is refused whole and named (AGL-3254).\n const campaignIds = (row.campaigns ?? []).map((name) => campaigns.get(name.toLowerCase()) ?? '')\n if (campaignIds.some((id) => !id)) {\n skipped.push({ index, email: row.email, reason: 'campaign-unknown' })\n continue\n }\n const isNew = !held.has(key)\n /*\n * The platform lead ceiling, judged the way the deals import judges\n * the records band: one count at the first create, re-judged locally\n * as the request creates more. The door re-judges it authoritatively\n * inside its own transaction, so a race is still refused there — this\n * is what lets the operator's skipped file say WHY rather than only\n * that the row could not be saved.\n */\n if (isNew) {\n if (counted === null) {\n counted = (await leadsRef.count().get()).data().count\n }\n if (\n checkVisitorRecordCeiling(counted + createdHere, LEADS_MAX_PER_HOST)\n .exceeded\n ) {\n skipped.push({ index, email: row.email, reason: 'lead-ceiling' })\n continue\n }\n }\n let ownerUid: string | undefined\n if (row.ownerEmail) {\n ownerUid = owners.get(row.ownerEmail)\n if (!ownerUid) ownersUnresolved.add(row.ownerEmail)\n }\n const stored = await addHostLead({\n hostRef,\n hostId: context.hostId,\n lead: {\n email: row.email,\n ...(row.name ? { name: row.name } : {}),\n source: LEAD_IMPORT_SOURCE,\n },\n })\n if (!stored) {\n skipped.push({ index, email: row.email, reason: 'write-failed' })\n continue\n }\n const working = workingState(row, ownerUid, [...new Set(campaignIds)])\n if (working) {\n await leadsRef\n .doc(key)\n .set(\n { ...working, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n }\n if (isNew) {\n created += 1\n createdHere += 1\n } else {\n merged += 1\n }\n }\n\n const result: LeadImportChunkResult = {\n received: read.rows.length,\n created,\n merged,\n skipped: skipped.sort((a, b) => a.index - b.index),\n dropped,\n ownersUnresolved: [...ownersUnresolved],\n }\n return res.status(200).json(result)\n } catch (error) {\n console.error('crm/leads-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n"],"names":["checkVisitorRecordCeiling","LEADS_MAX_PER_HOST","personKey","LEAD_IMPORT_CHUNK_SIZE","LEAD_IMPORT_MAX_BODY_BYTES","normalizeLeadImportRow","addHostLead","firebaseAdmin","orgLeadsForHost","visibleToHost","FieldValue","ownerDirectory","readImportRows","resolveImportContext","LEAD_IMPORT_SOURCE","CAMPAIGN_DIRECTORY_CEILING","campaignDirectory","firestore","orgId","hostId","directory","Map","containers","collection","doc","limit","get","live","docs","filter","container","placed","entry","name","String","trim","toLowerCase","has","set","id","workingState","row","ownerUid","campaignIds","fields","status","unqualifiedReason","notes","profile","length","arrayUnion","Object","keys","crmLeadsImportHandler","req","res","method","setHeader","json","error","read","maxBodyBytes","chunkSize","context","ok","body","skipped","dropped","normalized","seen","Set","rows","forEach","raw","index","verdict","push","email","input","reason","key","add","field","app","hostRef","leadsRef","refs","map","namesCampaigns","some","campaigns","owners","before","Promise","all","getAll","resolve","held","snapshot","exists","ownersUnresolved","counted","createdHere","created","merged","isNew","count","data","exceeded","ownerEmail","stored","lead","source","working","updatedAt","serverTimestamp","merge","result","received","sort","a","b","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/leads-import.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/leads-import` — one chunk of a leads file, written\n * (AGL-2701).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link LEAD_IMPORT_CHUNK_SIZE} raw\n * rows in the vocabulary `crm-lead-import.ts` defines. This route judges\n * each one through the same normalizer, resolves the owner against the\n * org's roster, and writes it in two halves.\n *\n * ## The capture half goes through the door every capture goes through\n *\n * `addHostLead` — the ONE writer of `hosts/{hostId}/leads` for the sign-up\n * handler and both booking paths — is the writer here too. That is what\n * makes an imported row key on `personKey` the way a second form\n * submission does, land under the site by path with `capturedByHostIds`\n * naming it, count against `LEADS_MAX_PER_HOST` inside the transaction\n * that writes, and record NO marketing basis. A bulk path with its own\n * `add()` would grow an unkeyed, unbounded copy of the site's leads under\n * a second set of rules.\n *\n * ## A lead is host-scoped by path, so nothing here stamps `visibleTo`\n *\n * The five org collections carry `visibleTo` because they sit under\n * `orgs/{orgId}` and the rules decide per document which sites may read\n * them. A lead does not: `hosts/{hostId}/leads` is private to the site by\n * path, the rules admit the site's own members, and the list therefore\n * reads it with no scope clause. An import that stamped scope tokens onto\n * a lead would be writing a field nothing reads, and would suggest a\n * sharing decision the collection does not have. The site is the whole\n * scope, and the drawer's picker is where it is chosen.\n *\n * ## No file may hand a lead a consent it did not give\n *\n * A capture writes a marketing basis only when the visitor ticked a box in\n * front of them. A CSV row has no such event behind it, so the lead\n * vocabulary has no consent column and this route passes no\n * `marketingConsent`: an imported lead is mailable only if some earlier\n * capture on this site already recorded a basis, which `addHostLead`\n * carries forward untouched. The contacts import has a consent column\n * because its own export writes one and the merchant is asserting a basis\n * they hold per person; the leads export writes none, and a column the\n * file cannot fill is a column that cannot be misread.\n *\n * ## The working half is written the way the list writes it\n *\n * A status, an owner, a reason and notes are the CRM's annotations, not\n * the capture's, and the leads list already writes them client-direct onto\n * the same document. A row that carries any of them gets one merge write\n * after the door's, stamped `updatedAt` exactly as the list stamps it. A\n * row that carries none costs nothing extra.\n *\n * ## Created or merged is read once for the chunk\n *\n * The door reports only whether the lead was STORED, and the result panel\n * has to say how many people were added and how many updated. One\n * `getAll` over the chunk's document ids answers that for every row in a\n * single round trip, before any of them is written — which is also the\n * only moment the answer is still true.\n */\n\nimport {\n checkVisitorRecordCeiling,\n type ContactSource,\n LEADS_MAX_PER_HOST,\n personKey,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n LEAD_IMPORT_CHUNK_SIZE,\n LEAD_IMPORT_MAX_BODY_BYTES,\n type LeadImportChunkResult,\n type LeadImportRawRow,\n type LeadImportRow,\n type LeadImportSkippedRow,\n normalizeLeadImportRow,\n} from '../model/crm-lead-import'\nimport { addHostLead, firebaseAdmin, orgLeadsForHost } from '@aglyn/tenant-data-admin'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\nimport { readLeadSourcePicklist, resolveLeadSourceWrite } from './lead-source-picklist'\n\n/**\n * The surface an imported lead names, beside `signup`, `booking` and\n * `form:{formId}`.\n *\n * Typed as the capture-source union rather than a bare string, which is\n * what the contacts import gets for free from the door it calls: a lead's\n * `source` is plain text on the way in, so this is the only place the word\n * can be held to the same vocabulary the source filters and the labels\n * read. One person brought in by one file reads the same on both records.\n */\nconst LEAD_IMPORT_SOURCE: ContactSource = 'import'\n\n/**\n * How many of the org's campaigns one chunk reads to resolve the names a\n * file carries (AGL-3254). Well past what an org keeps — the picker offers\n * fifty — and read once per chunk rather than once per row.\n */\nconst CAMPAIGN_DIRECTORY_CEILING = 200\n\n/**\n * The org's live campaigns by NAME, lower-cased, for the `campaigns`\n * column. A lead is an org record, so every live container of the org\n * (`orgs/{orgId}/emailCampaigns`) may be named, whichever sites it is\n * placed on; where two share a name, the one placed on the importing site\n * wins, because that is the one its picker shows. A name the org does not\n * have is not guessed at: the row is refused whole and named, because a\n * lead filed under half its campaigns is a lead nobody asked for. Read\n * only when some row names one.\n */\nasync function campaignDirectory(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n hostId: string,\n): Promise<Map<string, string>> {\n const directory = new Map<string, string>()\n if (!orgId) return directory\n const containers = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('emailCampaigns')\n .limit(CAMPAIGN_DIRECTORY_CEILING)\n .get()\n const live = containers.docs.filter((container) => !container.get('deletedAt'))\n const placed = (container: FirebaseFirestore.QueryDocumentSnapshot) =>\n visibleToHost(container.get('visibleTo') as string[] | undefined, hostId)\n for (const container of [...live.filter(placed), ...live.filter((entry) => !placed(entry))]) {\n const name = String(container.get('name') ?? '').trim().toLowerCase()\n if (name && !directory.has(name)) directory.set(name, container.id)\n }\n return directory\n}\n\n/**\n * The team's annotations on one row and the lead's own profile\n * (AGL-3231), or nothing when the file named none of either. A profile\n * value lands as the record's card would write it; the file never clears\n * one, because a blank cell is a cell nobody filled.\n */\nfunction workingState(\n row: LeadImportRow,\n ownerUid: string | undefined,\n campaignIds: readonly string[],\n): Record<string, unknown> | null {\n const fields: Record<string, unknown> = {\n ...(row.status ? { status: row.status } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.unqualifiedReason ? { unqualifiedReason: row.unqualifiedReason } : {}),\n ...(row.notes ? { notes: row.notes } : {}),\n ...row.profile,\n // Added to what a lead the site already held carries (AGL-3254): a\n // file re-imported under a new campaign files the person under one\n // more, and never takes them out of the last.\n ...(campaignIds.length ? { campaignIds: FieldValue.arrayUnion(...campaignIds) } : {}),\n }\n return Object.keys(fields).length ? fields : null\n}\n\n/**\n * `POST crm/leads-import` — `{ hostId, rows }` → a {@link LeadImportChunkResult}.\n *\n * Rows are written one after another so the ceiling's local count stays\n * honest across the request.\n */\nexport const crmLeadsImportHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const read = readImportRows<LeadImportRawRow>(req, {\n maxBodyBytes: LEAD_IMPORT_MAX_BODY_BYTES,\n chunkSize: LEAD_IMPORT_CHUNK_SIZE,\n })\n if ('error' in read) return res.status(read.status).json({ error: read.error })\n try {\n const context = await resolveImportContext(req)\n if (context.ok === false) return res.status(context.status).json(context.body)\n\n const skipped: LeadImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: LeadImportRow; key: string }[] = []\n /*\n * A duplicate WITHIN the request is skipped rather than merged, for\n * the reason the contacts import gives: the second row would merge\n * onto the first's document and the tally would read one created and\n * one merged for one person in one file. Across requests the door's\n * own dedupe answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeLeadImportRow(raw)\n if (verdict.ok === false) {\n skipped.push({ index, email: verdict.input, reason: verdict.reason })\n return\n }\n // Non-null by construction: the normalizer refused every address\n // this derivation could not key.\n const key = personKey(verdict.row.email) as string\n if (seen.has(key)) {\n skipped.push({ index, email: verdict.row.email, reason: 'duplicate' })\n return\n }\n seen.add(key)\n for (const entry of verdict.row.dropped) {\n dropped[entry.field] = (dropped[entry.field] ?? 0) + 1\n }\n normalized.push({ index, row: verdict.row, key })\n })\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(context.hostId)\n const leadsRef = await orgLeadsForHost(context.hostId)\n const refs = normalized.map((entry) => leadsRef.doc(entry.key))\n const namesCampaigns = normalized.some((entry) => entry.row.campaigns?.length)\n const [owners, before, campaigns, leadSources] = await Promise.all([\n ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n ),\n refs.length ? firestore.getAll(...refs) : Promise.resolve([]),\n namesCampaigns\n ? campaignDirectory(firestore, context.orgId, context.hostId)\n : Promise.resolve(new Map<string, string>()),\n // The org's lead source list (AGL-3298): one read per chunk, for the\n // values rows name and the default a new lead starts from.\n readLeadSourcePicklist(firestore, context.orgId),\n ])\n const beforeById = new Map(before.map((snapshot) => [snapshot.id, snapshot]))\n const held = new Set(\n before.filter((snapshot) => snapshot.exists).map((snapshot) => snapshot.id),\n )\n const ownersUnresolved = new Set<string>()\n let counted: number | null = null\n let createdHere = 0\n let created = 0\n let merged = 0\n\n for (const { index, row, key } of normalized) {\n // The campaigns the row names, every one of them the org's, or the\n // row is refused whole and named (AGL-3254).\n const campaignIds = (row.campaigns ?? []).map((name) => campaigns.get(name.toLowerCase()) ?? '')\n if (campaignIds.some((id) => !id)) {\n skipped.push({ index, email: row.email, reason: 'campaign-unknown' })\n continue\n }\n const isNew = !held.has(key)\n /*\n * THE LEAD SOURCE, restricted to the org's list (AGL-3298). A value\n * the list does not hold is refused whole and named, as a campaign\n * the org does not have is — the drawer's notice has already listed\n * the values the list allows — and a lead the site already held\n * keeps the value it has. A new lead with no value starts from the\n * list's default.\n */\n const leadSource = resolveLeadSourceWrite(leadSources, row.profile.leadSource, {\n current: beforeById.get(key)?.get('leadSource'),\n created: isNew,\n })\n if (leadSource.ok === false) {\n skipped.push({ index, email: row.email, reason: 'lead-source-unknown' })\n continue\n }\n if (leadSource.write) row.profile.leadSource = leadSource.write\n /*\n * The platform lead ceiling, judged the way the deals import judges\n * the records band: one count at the first create, re-judged locally\n * as the request creates more. The door re-judges it authoritatively\n * inside its own transaction, so a race is still refused there — this\n * is what lets the operator's skipped file say WHY rather than only\n * that the row could not be saved.\n */\n if (isNew) {\n if (counted === null) {\n counted = (await leadsRef.count().get()).data().count\n }\n if (\n checkVisitorRecordCeiling(counted + createdHere, LEADS_MAX_PER_HOST)\n .exceeded\n ) {\n skipped.push({ index, email: row.email, reason: 'lead-ceiling' })\n continue\n }\n }\n let ownerUid: string | undefined\n if (row.ownerEmail) {\n ownerUid = owners.get(row.ownerEmail)\n if (!ownerUid) ownersUnresolved.add(row.ownerEmail)\n }\n const stored = await addHostLead({\n hostRef,\n hostId: context.hostId,\n lead: {\n email: row.email,\n ...(row.name ? { name: row.name } : {}),\n source: LEAD_IMPORT_SOURCE,\n },\n })\n if (!stored) {\n skipped.push({ index, email: row.email, reason: 'write-failed' })\n continue\n }\n const working = workingState(row, ownerUid, [...new Set(campaignIds)])\n if (working) {\n await leadsRef\n .doc(key)\n .set(\n { ...working, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n }\n if (isNew) {\n created += 1\n createdHere += 1\n } else {\n merged += 1\n }\n }\n\n const result: LeadImportChunkResult = {\n received: read.rows.length,\n created,\n merged,\n skipped: skipped.sort((a, b) => a.index - b.index),\n dropped,\n ownersUnresolved: [...ownersUnresolved],\n }\n return res.status(200).json(result)\n } catch (error) {\n console.error('crm/leads-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n"],"names":["checkVisitorRecordCeiling","LEADS_MAX_PER_HOST","personKey","LEAD_IMPORT_CHUNK_SIZE","LEAD_IMPORT_MAX_BODY_BYTES","normalizeLeadImportRow","addHostLead","firebaseAdmin","orgLeadsForHost","visibleToHost","FieldValue","ownerDirectory","readImportRows","resolveImportContext","readLeadSourcePicklist","resolveLeadSourceWrite","LEAD_IMPORT_SOURCE","CAMPAIGN_DIRECTORY_CEILING","campaignDirectory","firestore","orgId","hostId","directory","Map","containers","collection","doc","limit","get","live","docs","filter","container","placed","entry","name","String","trim","toLowerCase","has","set","id","workingState","row","ownerUid","campaignIds","fields","status","unqualifiedReason","notes","profile","length","arrayUnion","Object","keys","crmLeadsImportHandler","req","res","method","setHeader","json","error","read","maxBodyBytes","chunkSize","context","ok","body","skipped","dropped","normalized","seen","Set","rows","forEach","raw","index","verdict","push","email","input","reason","key","add","field","app","hostRef","leadsRef","refs","map","namesCampaigns","some","campaigns","owners","before","leadSources","Promise","all","getAll","resolve","beforeById","snapshot","held","exists","ownersUnresolved","counted","createdHere","created","merged","isNew","leadSource","current","write","count","data","exceeded","ownerEmail","stored","lead","source","working","updatedAt","serverTimestamp","merge","result","received","sort","a","b","console"],"mappings":"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"}
package/src/lib/server.js CHANGED
@@ -57,8 +57,10 @@ import { CRM_API_ROUTES } from "./constants/api-routes.js";
57
57
  import { registerCrmServerDeclarations } from "./declarations.server.js";
58
58
  import { BUNDLE_ID } from "./constants/bundle-common.js";
59
59
  import { CRM_NEXT_ACTIVITY_ROUTE } from "./model/next-activity.js";
60
+ import { CRM_LEAD_SOURCE_VALUES_ROUTE } from "./model/lead-source-values.js";
60
61
  import { CRM_TASK_ROUTES } from "./model/task-routes.js";
61
62
  import { crmNextActivityHandler } from "./server/next-activity-routes.js";
63
+ import { crmLeadSourceValuesHandler } from "./server/lead-source-values.js";
62
64
  import { registerCrmRecordEmailStateWriter } from "./server/record-email-state.js";
63
65
  import { registerCrmRecordTimelineWriter } from "./server/record-timeline.js";
64
66
  import { crmTaskCompleteHandler, crmTaskSaveHandler } from "./server/task-routes.js";
@@ -554,6 +556,9 @@ import { CRM_RECIPE_INSTALL_ROUTE, CRM_RECIPE_STATUS_ROUTE, crmRecipeInstallHand
554
556
  registerPluginApiRoute(CRM_TASK_ROUTES.complete, crmTaskCompleteHandler);
555
557
  // A client-direct task write's door to `nextTaskAtMs` (AGL-2661).
556
558
  registerPluginApiRoute(CRM_NEXT_ACTIVITY_ROUTE, crmNextActivityHandler);
559
+ // A lead source value renamed or deleted (AGL-3298): the list and every
560
+ // lead and contact holding the old label, in one request.
561
+ registerPluginApiRoute(CRM_LEAD_SOURCE_VALUES_ROUTE, crmLeadSourceValuesHandler);
557
562
  // One chunk of a contact file (AGL-2602), judged and written through the
558
563
  // same door every capture uses.
559
564
  registerPluginApiRoute('crm/contacts-import', crmContactsImportHandler);