@aglyn/plugins-crm 1.0.0-beta.162 → 1.0.0-beta.164
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/leads-bulk-bar.js +51 -5
- package/src/lib/components/leads-bulk-bar.js.map +1 -1
- package/src/lib/components/leads-section.js +93 -4
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/new-lead-drawer.d.ts +2 -0
- package/src/lib/components/new-lead-drawer.js +27 -1
- package/src/lib/components/new-lead-drawer.js.map +1 -1
- package/src/lib/declarations.server.js +15 -0
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/model/crm-lead-import.d.ts +11 -2
- package/src/lib/model/crm-lead-import.js +22 -2
- package/src/lib/model/crm-lead-import.js.map +1 -1
- package/src/lib/model/lead-filters.d.ts +8 -0
- package/src/lib/model/lead-filters.js +11 -1
- package/src/lib/model/lead-filters.js.map +1 -1
- package/src/lib/server/lead-campaign-carry.d.ts +50 -0
- package/src/lib/server/lead-campaign-carry.js +75 -0
- package/src/lib/server/lead-campaign-carry.js.map +1 -0
- package/src/lib/server/lead-create.d.ts +13 -0
- package/src/lib/server/lead-create.js +30 -0
- package/src/lib/server/lead-create.js.map +1 -1
- package/src/lib/server/leads-import.js +50 -5
- package/src/lib/server/leads-import.js.map +1 -1
|
@@ -88,12 +88,33 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
88
88
|
* can be held to the same vocabulary the source filters and the labels
|
|
89
89
|
* read. One person brought in by one file reads the same on both records.
|
|
90
90
|
*/ const LEAD_IMPORT_SOURCE = 'import';
|
|
91
|
+
/**
|
|
92
|
+
* How many of the site's campaigns one chunk reads to resolve the names a
|
|
93
|
+
* file carries (AGL-3254). Well past what a site keeps — the picker offers
|
|
94
|
+
* fifty — and read once per chunk rather than once per row.
|
|
95
|
+
*/ const CAMPAIGN_DIRECTORY_CEILING = 200;
|
|
96
|
+
/**
|
|
97
|
+
* The site's live campaigns by NAME, lower-cased, for the `campaigns`
|
|
98
|
+
* column. A name the site does not have is not guessed at: the row is
|
|
99
|
+
* refused whole and named, because a lead filed under half its campaigns
|
|
100
|
+
* is a lead nobody asked for. Read only when some row names one.
|
|
101
|
+
*/ async function campaignDirectory(hostRef) {
|
|
102
|
+
const directory = new Map();
|
|
103
|
+
const containers = await hostRef.collection('emailCampaigns').limit(CAMPAIGN_DIRECTORY_CEILING).get();
|
|
104
|
+
for (const container of containers.docs){
|
|
105
|
+
var _container_get;
|
|
106
|
+
if (container.get('deletedAt')) continue;
|
|
107
|
+
const name = String((_container_get = container.get('name')) != null ? _container_get : '').trim().toLowerCase();
|
|
108
|
+
if (name && !directory.has(name)) directory.set(name, container.id);
|
|
109
|
+
}
|
|
110
|
+
return directory;
|
|
111
|
+
}
|
|
91
112
|
/**
|
|
92
113
|
* The team's annotations on one row and the lead's own profile
|
|
93
114
|
* (AGL-3231), or nothing when the file named none of either. A profile
|
|
94
115
|
* value lands as the record's card would write it; the file never clears
|
|
95
116
|
* one, because a blank cell is a cell nobody filled.
|
|
96
|
-
*/ function workingState(row, ownerUid) {
|
|
117
|
+
*/ function workingState(row, ownerUid, campaignIds) {
|
|
97
118
|
const fields = _extends({}, row.status ? {
|
|
98
119
|
status: row.status
|
|
99
120
|
} : {}, ownerUid ? {
|
|
@@ -102,7 +123,9 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
102
123
|
unqualifiedReason: row.unqualifiedReason
|
|
103
124
|
} : {}, row.notes ? {
|
|
104
125
|
notes: row.notes
|
|
105
|
-
} : {}, row.profile
|
|
126
|
+
} : {}, row.profile, campaignIds.length ? {
|
|
127
|
+
campaignIds: FieldValue.arrayUnion(...campaignIds)
|
|
128
|
+
} : {});
|
|
106
129
|
return Object.keys(fields).length ? fields : null;
|
|
107
130
|
}
|
|
108
131
|
/**
|
|
@@ -173,9 +196,14 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
173
196
|
const hostRef = firestore.collection('hosts').doc(context.hostId);
|
|
174
197
|
const leadsRef = hostRef.collection('leads');
|
|
175
198
|
const refs = normalized.map((entry)=>leadsRef.doc(entry.key));
|
|
176
|
-
const
|
|
199
|
+
const namesCampaigns = normalized.some((entry)=>{
|
|
200
|
+
var _entry_row_campaigns;
|
|
201
|
+
return (_entry_row_campaigns = entry.row.campaigns) == null ? void 0 : _entry_row_campaigns.length;
|
|
202
|
+
});
|
|
203
|
+
const [owners, before, campaigns] = await Promise.all([
|
|
177
204
|
ownerDirectory(context.orgId, normalized.map((entry)=>entry.row)),
|
|
178
|
-
refs.length ? firestore.getAll(...refs) : Promise.resolve([])
|
|
205
|
+
refs.length ? firestore.getAll(...refs) : Promise.resolve([]),
|
|
206
|
+
namesCampaigns ? campaignDirectory(hostRef) : Promise.resolve(new Map())
|
|
179
207
|
]);
|
|
180
208
|
const held = new Set(before.filter((snapshot)=>snapshot.exists).map((snapshot)=>snapshot.id));
|
|
181
209
|
const ownersUnresolved = new Set();
|
|
@@ -184,6 +212,21 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
184
212
|
let created = 0;
|
|
185
213
|
let merged = 0;
|
|
186
214
|
for (const { index, row, key } of normalized){
|
|
215
|
+
var _row_campaigns;
|
|
216
|
+
// The campaigns the row names, every one of them the site's, or the
|
|
217
|
+
// row is refused whole and named (AGL-3254).
|
|
218
|
+
const campaignIds = ((_row_campaigns = row.campaigns) != null ? _row_campaigns : []).map((name)=>{
|
|
219
|
+
var _campaigns_get;
|
|
220
|
+
return (_campaigns_get = campaigns.get(name.toLowerCase())) != null ? _campaigns_get : '';
|
|
221
|
+
});
|
|
222
|
+
if (campaignIds.some((id)=>!id)) {
|
|
223
|
+
skipped.push({
|
|
224
|
+
index,
|
|
225
|
+
email: row.email,
|
|
226
|
+
reason: 'campaign-unknown'
|
|
227
|
+
});
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
187
230
|
const isNew = !held.has(key);
|
|
188
231
|
/*
|
|
189
232
|
* The platform lead ceiling, judged the way the deals import judges
|
|
@@ -229,7 +272,9 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
229
272
|
});
|
|
230
273
|
continue;
|
|
231
274
|
}
|
|
232
|
-
const working = workingState(row, ownerUid
|
|
275
|
+
const working = workingState(row, ownerUid, [
|
|
276
|
+
...new Set(campaignIds)
|
|
277
|
+
]);
|
|
233
278
|
if (working) {
|
|
234
279
|
await leadsRef.doc(key).set(_extends({}, working, {
|
|
235
280
|
updatedAt: FieldValue.serverTimestamp()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/leads-import.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/leads-import` — one chunk of a leads file, written\n * (AGL-2701).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link LEAD_IMPORT_CHUNK_SIZE} raw\n * rows in the vocabulary `crm-lead-import.ts` defines. This route judges\n * each one through the same normalizer, resolves the owner against the\n * org's roster, and writes it in two halves.\n *\n * ## The capture half goes through the door every capture goes through\n *\n * `addHostLead` — the ONE writer of `hosts/{hostId}/leads` for the sign-up\n * handler and both booking paths — is the writer here too. That is what\n * makes an imported row key on `personKey` the way a second form\n * submission does, land under the site by path with `capturedByHostIds`\n * naming it, count against `LEADS_MAX_PER_HOST` inside the transaction\n * that writes, and record NO marketing basis. A bulk path with its own\n * `add()` would grow an unkeyed, unbounded copy of the site's leads under\n * a second set of rules.\n *\n * ## A lead is host-scoped by path, so nothing here stamps `visibleTo`\n *\n * The five org collections carry `visibleTo` because they sit under\n * `orgs/{orgId}` and the rules decide per document which sites may read\n * them. A lead does not: `hosts/{hostId}/leads` is private to the site by\n * path, the rules admit the site's own members, and the list therefore\n * reads it with no scope clause. An import that stamped scope tokens onto\n * a lead would be writing a field nothing reads, and would suggest a\n * sharing decision the collection does not have. The site is the whole\n * scope, and the drawer's picker is where it is chosen.\n *\n * ## No file may hand a lead a consent it did not give\n *\n * A capture writes a marketing basis only when the visitor ticked a box in\n * front of them. A CSV row has no such event behind it, so the lead\n * vocabulary has no consent column and this route passes no\n * `marketingConsent`: an imported lead is mailable only if some earlier\n * capture on this site already recorded a basis, which `addHostLead`\n * carries forward untouched. The contacts import has a consent column\n * because its own export writes one and the merchant is asserting a basis\n * they hold per person; the leads export writes none, and a column the\n * file cannot fill is a column that cannot be misread.\n *\n * ## The working half is written the way the list writes it\n *\n * A status, an owner, a reason and notes are the CRM's annotations, not\n * the capture's, and the leads list already writes them client-direct onto\n * the same document. A row that carries any of them gets one merge write\n * after the door's, stamped `updatedAt` exactly as the list stamps it. A\n * row that carries none costs nothing extra.\n *\n * ## Created or merged is read once for the chunk\n *\n * The door reports only whether the lead was STORED, and the result panel\n * has to say how many people were added and how many updated. One\n * `getAll` over the chunk's document ids answers that for every row in a\n * single round trip, before any of them is written — which is also the\n * only moment the answer is still true.\n */\n\nimport {\n checkVisitorRecordCeiling,\n type ContactSource,\n LEADS_MAX_PER_HOST,\n personKey,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n LEAD_IMPORT_CHUNK_SIZE,\n LEAD_IMPORT_MAX_BODY_BYTES,\n type LeadImportChunkResult,\n type LeadImportRawRow,\n type LeadImportRow,\n type LeadImportSkippedRow,\n normalizeLeadImportRow,\n} from '../model/crm-lead-import'\nimport { addHostLead, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\n\n/**\n * The surface an imported lead names, beside `signup`, `booking` and\n * `form:{formId}`.\n *\n * Typed as the capture-source union rather than a bare string, which is\n * what the contacts import gets for free from the door it calls: a lead's\n * `source` is plain text on the way in, so this is the only place the word\n * can be held to the same vocabulary the source filters and the labels\n * read. One person brought in by one file reads the same on both records.\n */\nconst LEAD_IMPORT_SOURCE: ContactSource = 'import'\n\n/**\n * The team's annotations on one row and the lead's own profile\n * (AGL-3231), or nothing when the file named none of either. A profile\n * value lands as the record's card would write it; the file never clears\n * one, because a blank cell is a cell nobody filled.\n */\nfunction workingState(\n row: LeadImportRow,\n ownerUid: string | undefined,\n): Record<string, unknown> | null {\n const fields: Record<string, unknown> = {\n ...(row.status ? { status: row.status } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.unqualifiedReason ? { unqualifiedReason: row.unqualifiedReason } : {}),\n ...(row.notes ? { notes: row.notes } : {}),\n ...row.profile,\n }\n return Object.keys(fields).length ? fields : null\n}\n\n/**\n * `POST crm/leads-import` — `{ hostId, rows }` → a {@link LeadImportChunkResult}.\n *\n * Rows are written one after another so the ceiling's local count stays\n * honest across the request.\n */\nexport const crmLeadsImportHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const read = readImportRows<LeadImportRawRow>(req, {\n maxBodyBytes: LEAD_IMPORT_MAX_BODY_BYTES,\n chunkSize: LEAD_IMPORT_CHUNK_SIZE,\n })\n if ('error' in read) return res.status(read.status).json({ error: read.error })\n try {\n const context = await resolveImportContext(req)\n if (context.ok === false) return res.status(context.status).json(context.body)\n\n const skipped: LeadImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: LeadImportRow; key: string }[] = []\n /*\n * A duplicate WITHIN the request is skipped rather than merged, for\n * the reason the contacts import gives: the second row would merge\n * onto the first's document and the tally would read one created and\n * one merged for one person in one file. Across requests the door's\n * own dedupe answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeLeadImportRow(raw)\n if (verdict.ok === false) {\n skipped.push({ index, email: verdict.input, reason: verdict.reason })\n return\n }\n // Non-null by construction: the normalizer refused every address\n // this derivation could not key.\n const key = personKey(verdict.row.email) as string\n if (seen.has(key)) {\n skipped.push({ index, email: verdict.row.email, reason: 'duplicate' })\n return\n }\n seen.add(key)\n for (const entry of verdict.row.dropped) {\n dropped[entry.field] = (dropped[entry.field] ?? 0) + 1\n }\n normalized.push({ index, row: verdict.row, key })\n })\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(context.hostId)\n const leadsRef = hostRef.collection('leads')\n const refs = normalized.map((entry) => leadsRef.doc(entry.key))\n const [owners, before] = await Promise.all([\n ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n ),\n refs.length ? firestore.getAll(...refs) : Promise.resolve([]),\n ])\n const held = new Set(\n before.filter((snapshot) => snapshot.exists).map((snapshot) => snapshot.id),\n )\n const ownersUnresolved = new Set<string>()\n let counted: number | null = null\n let createdHere = 0\n let created = 0\n let merged = 0\n\n for (const { index, row, key } of normalized) {\n const isNew = !held.has(key)\n /*\n * The platform lead ceiling, judged the way the deals import judges\n * the records band: one count at the first create, re-judged locally\n * as the request creates more. The door re-judges it authoritatively\n * inside its own transaction, so a race is still refused there — this\n * is what lets the operator's skipped file say WHY rather than only\n * that the row could not be saved.\n */\n if (isNew) {\n if (counted === null) {\n counted = (await leadsRef.count().get()).data().count\n }\n if (\n checkVisitorRecordCeiling(counted + createdHere, LEADS_MAX_PER_HOST)\n .exceeded\n ) {\n skipped.push({ index, email: row.email, reason: 'lead-ceiling' })\n continue\n }\n }\n let ownerUid: string | undefined\n if (row.ownerEmail) {\n ownerUid = owners.get(row.ownerEmail)\n if (!ownerUid) ownersUnresolved.add(row.ownerEmail)\n }\n const stored = await addHostLead({\n hostRef,\n hostId: context.hostId,\n lead: {\n email: row.email,\n ...(row.name ? { name: row.name } : {}),\n source: LEAD_IMPORT_SOURCE,\n },\n })\n if (!stored) {\n skipped.push({ index, email: row.email, reason: 'write-failed' })\n continue\n }\n const working = workingState(row, ownerUid)\n if (working) {\n await leadsRef\n .doc(key)\n .set(\n { ...working, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n }\n if (isNew) {\n created += 1\n createdHere += 1\n } else {\n merged += 1\n }\n }\n\n const result: LeadImportChunkResult = {\n received: read.rows.length,\n created,\n merged,\n skipped: skipped.sort((a, b) => a.index - b.index),\n dropped,\n ownersUnresolved: [...ownersUnresolved],\n }\n return res.status(200).json(result)\n } catch (error) {\n console.error('crm/leads-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n"],"names":["checkVisitorRecordCeiling","LEADS_MAX_PER_HOST","personKey","LEAD_IMPORT_CHUNK_SIZE","LEAD_IMPORT_MAX_BODY_BYTES","normalizeLeadImportRow","addHostLead","firebaseAdmin","FieldValue","ownerDirectory","readImportRows","resolveImportContext","LEAD_IMPORT_SOURCE","workingState","row","ownerUid","fields","status","unqualifiedReason","notes","profile","Object","keys","length","crmLeadsImportHandler","req","res","method","setHeader","json","error","read","maxBodyBytes","chunkSize","context","ok","body","skipped","dropped","normalized","seen","Set","rows","forEach","raw","index","verdict","push","email","input","reason","key","has","add","entry","field","firestore","app","hostRef","collection","doc","hostId","leadsRef","refs","map","owners","before","Promise","all","orgId","getAll","resolve","held","filter","snapshot","exists","id","ownersUnresolved","counted","createdHere","created","merged","isNew","count","get","data","exceeded","ownerEmail","stored","lead","name","source","working","set","updatedAt","serverTimestamp","merge","result","received","sort","a","b","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/leads-import.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/leads-import` — one chunk of a leads file, written\n * (AGL-2701).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link LEAD_IMPORT_CHUNK_SIZE} raw\n * rows in the vocabulary `crm-lead-import.ts` defines. This route judges\n * each one through the same normalizer, resolves the owner against the\n * org's roster, and writes it in two halves.\n *\n * ## The capture half goes through the door every capture goes through\n *\n * `addHostLead` — the ONE writer of `hosts/{hostId}/leads` for the sign-up\n * handler and both booking paths — is the writer here too. That is what\n * makes an imported row key on `personKey` the way a second form\n * submission does, land under the site by path with `capturedByHostIds`\n * naming it, count against `LEADS_MAX_PER_HOST` inside the transaction\n * that writes, and record NO marketing basis. A bulk path with its own\n * `add()` would grow an unkeyed, unbounded copy of the site's leads under\n * a second set of rules.\n *\n * ## A lead is host-scoped by path, so nothing here stamps `visibleTo`\n *\n * The five org collections carry `visibleTo` because they sit under\n * `orgs/{orgId}` and the rules decide per document which sites may read\n * them. A lead does not: `hosts/{hostId}/leads` is private to the site by\n * path, the rules admit the site's own members, and the list therefore\n * reads it with no scope clause. An import that stamped scope tokens onto\n * a lead would be writing a field nothing reads, and would suggest a\n * sharing decision the collection does not have. The site is the whole\n * scope, and the drawer's picker is where it is chosen.\n *\n * ## No file may hand a lead a consent it did not give\n *\n * A capture writes a marketing basis only when the visitor ticked a box in\n * front of them. A CSV row has no such event behind it, so the lead\n * vocabulary has no consent column and this route passes no\n * `marketingConsent`: an imported lead is mailable only if some earlier\n * capture on this site already recorded a basis, which `addHostLead`\n * carries forward untouched. The contacts import has a consent column\n * because its own export writes one and the merchant is asserting a basis\n * they hold per person; the leads export writes none, and a column the\n * file cannot fill is a column that cannot be misread.\n *\n * ## The working half is written the way the list writes it\n *\n * A status, an owner, a reason and notes are the CRM's annotations, not\n * the capture's, and the leads list already writes them client-direct onto\n * the same document. A row that carries any of them gets one merge write\n * after the door's, stamped `updatedAt` exactly as the list stamps it. A\n * row that carries none costs nothing extra.\n *\n * ## Created or merged is read once for the chunk\n *\n * The door reports only whether the lead was STORED, and the result panel\n * has to say how many people were added and how many updated. One\n * `getAll` over the chunk's document ids answers that for every row in a\n * single round trip, before any of them is written — which is also the\n * only moment the answer is still true.\n */\n\nimport {\n checkVisitorRecordCeiling,\n type ContactSource,\n LEADS_MAX_PER_HOST,\n personKey,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n LEAD_IMPORT_CHUNK_SIZE,\n LEAD_IMPORT_MAX_BODY_BYTES,\n type LeadImportChunkResult,\n type LeadImportRawRow,\n type LeadImportRow,\n type LeadImportSkippedRow,\n normalizeLeadImportRow,\n} from '../model/crm-lead-import'\nimport { addHostLead, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\n\n/**\n * The surface an imported lead names, beside `signup`, `booking` and\n * `form:{formId}`.\n *\n * Typed as the capture-source union rather than a bare string, which is\n * what the contacts import gets for free from the door it calls: a lead's\n * `source` is plain text on the way in, so this is the only place the word\n * can be held to the same vocabulary the source filters and the labels\n * read. One person brought in by one file reads the same on both records.\n */\nconst LEAD_IMPORT_SOURCE: ContactSource = 'import'\n\n/**\n * How many of the site's campaigns one chunk reads to resolve the names a\n * file carries (AGL-3254). Well past what a site 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 site's live campaigns by NAME, lower-cased, for the `campaigns`\n * column. A name the site does not have is not guessed at: the row is\n * refused whole and named, because a lead filed under half its campaigns\n * is a lead nobody asked for. Read only when some row names one.\n */\nasync function campaignDirectory(\n hostRef: FirebaseFirestore.DocumentReference,\n): Promise<Map<string, string>> {\n const directory = new Map<string, string>()\n const containers = await hostRef.collection('emailCampaigns').limit(CAMPAIGN_DIRECTORY_CEILING).get()\n for (const container of containers.docs) {\n if (container.get('deletedAt')) continue\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 = hostRef.collection('leads')\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 ? campaignDirectory(hostRef) : 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 site'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","FieldValue","ownerDirectory","readImportRows","resolveImportContext","LEAD_IMPORT_SOURCE","CAMPAIGN_DIRECTORY_CEILING","campaignDirectory","hostRef","directory","Map","containers","collection","limit","get","container","docs","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","entry","field","firestore","app","doc","hostId","leadsRef","refs","map","namesCampaigns","some","campaigns","owners","before","Promise","all","orgId","getAll","resolve","held","filter","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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"}
|