@aglyn/plugins-crm 1.0.0-beta.150 → 1.0.0-beta.152
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/plugins-crm",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.152",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,18 +25,18 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-ui-email-campaigns": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-ui-next": "1.0.0-beta.
|
|
34
|
-
"@aglyn/shared-ui-snackstack": "1.0.0-beta.
|
|
35
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
36
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
37
|
-
"@aglyn/tenant-data-admin": "1.0.0-beta.
|
|
38
|
-
"@aglyn/tenant-feature-instance": "1.0.0-beta.
|
|
39
|
-
"@aglyn/tenant-runtime": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.152",
|
|
29
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.152",
|
|
30
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.152",
|
|
31
|
+
"@aglyn/shared-ui-email-campaigns": "1.0.0-beta.152",
|
|
32
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.152",
|
|
33
|
+
"@aglyn/shared-ui-next": "1.0.0-beta.152",
|
|
34
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.152",
|
|
35
|
+
"@aglyn/shared-util-email": "1.0.0-beta.152",
|
|
36
|
+
"@aglyn/shared-util-http": "1.0.0-beta.152",
|
|
37
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.152",
|
|
38
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.152",
|
|
39
|
+
"@aglyn/tenant-runtime": "1.0.0-beta.152",
|
|
40
40
|
"@dnd-kit/core": "^6.3.1",
|
|
41
41
|
"@dnd-kit/utilities": "^3.2.2",
|
|
42
42
|
"@swc/helpers": "0.5.23",
|
|
@@ -86,7 +86,7 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
86
86
|
* see — two clients of one agency may each have an "Acme" and must each get
|
|
87
87
|
* their own. Created with the same scope stamp every CRM creator uses, so
|
|
88
88
|
* the company lands exactly where a contact captured on this site would.
|
|
89
|
-
*/ async function
|
|
89
|
+
*/ async function resolveCompany(context, name, cache, tally) {
|
|
90
90
|
const fields = nameSearchFields(name);
|
|
91
91
|
const cached = cache.get(fields.nameLower);
|
|
92
92
|
if (cached) return cached;
|
|
@@ -95,8 +95,15 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
95
95
|
const matches = await companies.where('nameLower', '==', fields.nameLower).limit(10).get();
|
|
96
96
|
const seen = matches.docs.find((doc)=>visibleToTokens(doc.get('visibleTo'), context.readTokens));
|
|
97
97
|
if (seen) {
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
var _seen_get;
|
|
99
|
+
// The name as the record spells it, not as this row typed it, so the
|
|
100
|
+
// contact's facet reads the same as the company page.
|
|
101
|
+
const found = {
|
|
102
|
+
id: seen.id,
|
|
103
|
+
name: String((_seen_get = seen.get('name')) != null ? _seen_get : name)
|
|
104
|
+
};
|
|
105
|
+
cache.set(fields.nameLower, found);
|
|
106
|
+
return found;
|
|
100
107
|
}
|
|
101
108
|
/*
|
|
102
109
|
* THE RECORDS BAND (AGL-2611). A company is a record of the same band
|
|
@@ -108,7 +115,7 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
108
115
|
* rate never reaches this branch — a rate is what makes the band meter
|
|
109
116
|
* instead of refuse.
|
|
110
117
|
*/ const room = await crmRecordsQuotaForOrg(context.org, orgRef);
|
|
111
|
-
if (!room.allowed) return
|
|
118
|
+
if (!room.allowed) return null;
|
|
112
119
|
const created = await companies.add(_extends({}, fields, {
|
|
113
120
|
hostId: context.hostId,
|
|
114
121
|
visibleTo: context.scopeTokens,
|
|
@@ -117,8 +124,12 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
117
124
|
updatedAt: FieldValue.serverTimestamp()
|
|
118
125
|
}));
|
|
119
126
|
tally.created += 1;
|
|
120
|
-
|
|
121
|
-
|
|
127
|
+
const made = {
|
|
128
|
+
id: created.id,
|
|
129
|
+
name
|
|
130
|
+
};
|
|
131
|
+
cache.set(fields.nameLower, made);
|
|
132
|
+
return made;
|
|
122
133
|
}
|
|
123
134
|
/**
|
|
124
135
|
* `POST crm/contacts-import` — `{ hostId, rows }` → a {@link ContactImportChunkResult}.
|
|
@@ -197,7 +208,7 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
197
208
|
ownerUid = owners.get(row.ownerEmail);
|
|
198
209
|
if (!ownerUid) ownersUnresolved.add(row.ownerEmail);
|
|
199
210
|
}
|
|
200
|
-
const
|
|
211
|
+
const company = row.companyName ? await resolveCompany(context, row.companyName, companies, companyTally) : null;
|
|
201
212
|
const verdict = await captureHostContact(_extends({
|
|
202
213
|
hostId: context.hostId,
|
|
203
214
|
email: row.email
|
|
@@ -214,8 +225,9 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
214
225
|
phone: row.phone
|
|
215
226
|
} : {}, row.jobTitle ? {
|
|
216
227
|
jobTitle: row.jobTitle
|
|
217
|
-
} : {},
|
|
218
|
-
companyId
|
|
228
|
+
} : {}, company ? {
|
|
229
|
+
companyId: company.id,
|
|
230
|
+
companyName: company.name
|
|
219
231
|
} : {}, row.address ? {
|
|
220
232
|
address: row.address
|
|
221
233
|
} : {}, ownerUid ? {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contacts-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/contacts-import` — one chunk of a contact file, written\n * (AGL-2602).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link CONTACT_IMPORT_CHUNK_SIZE} raw\n * rows in the field vocabulary `crm-import.ts` defines. This route judges\n * each one and writes it, and it is the ONLY place either happens: the\n * drawer shows a preview from the same mapper but stores nothing, so a\n * stale tab cannot store something the server would have refused.\n *\n * ## Every row goes through the door every capture goes through\n *\n * `captureHostContact` — the runtime's wrapper over `upsertHostContact`,\n * the one writer of the contacts collection for every door that is not the\n * v1 API: forms, checkout, bookings, the newsletter box — is the writer\n * here too, so a row this file creates raises `contactCreated` like any\n * other capture (AGL-2605). That is what makes an import\n * dedupe on the address the way a second form submission does, land in the\n * capturing group's facet the way a form capture does, record consent\n * against the capturing site, and stop at a hard band where a form\n * capture stops. A bulk path with its own `add()` would be the fastest way\n * to grow an unscoped, unbanded, unconsented copy of the address book.\n *\n * What the door did not know how to write until now — a phone, a title, a\n * company, an owner, a stage, custom values — it now takes as `facet`, and\n * what it did not say until now — created or merged or refused — it now\n * returns as a verdict. Both changes are the door's, so the v1 API and the\n * manual add can reach them next.\n *\n * ## Two lookups paid once per request, not once per row\n *\n * An owner is named by email in the file and stored by uid on the record,\n * so the org's roster is read once and every row resolves against it. A\n * company is named by name and stored by id, so each distinct name is\n * looked up once, created once when missing, and remembered for the rest\n * of the request — two hundred rows at one company is one read and at most\n * one write, not two hundred of each.\n *\n * ## Who may call it\n *\n * `resolveImportContext` (`import-context.ts`) — the two gates every CSV\n * import route asks, shared with the companies import so one permission\n * has one spelling.\n */\n\nimport {\n type ContactFieldDefinition,\n CRM_COLLECTIONS,\n nameSearchFields,\n type PluginApiHandler,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n CONTACT_IMPORT_CHUNK_SIZE,\n CONTACT_IMPORT_MAX_BODY_BYTES,\n type ContactImportChunkResult,\n type ContactImportRawRow,\n type ContactImportRow,\n type ContactImportSkippedRow,\n normalizeContactImportRow,\n} from '../model/crm-import'\nimport { crmRecordsQuotaForOrg, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { captureHostContact } from '@aglyn/tenant-runtime'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n type ImportContext,\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\n\n/** The one sentence every imported contact's timeline opens with. */\nexport const CONTACT_IMPORT_INTERACTION_SUMMARY = 'Imported from CSV'\n\n/**\n * The holder's live custom-field definitions.\n *\n * Read in full and filtered in memory: a holder has a handful of fields,\n * and a `where` on `retiredAt` would need an index for a collection that\n * fits in one page. Definitions the caller's scope cannot see are left out\n * for the same reason a value under an undefined key is — a value written\n * under a field the holder's own form will never show is a value nobody\n * can edit.\n */\nasync function loadFieldDefinitions(\n orgId: string,\n readTokens: readonly string[],\n): Promise<ContactFieldDefinition[]> {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(200)\n .get()\n return snapshot.docs\n .map((doc) => doc.data() as ContactFieldDefinition)\n .filter(\n (field) =>\n !!field.key &&\n !field.retiredAt &&\n visibleToTokens(field.visibleTo, readTokens),\n )\n}\n\n/**\n * The company a name refers to in this scope, created when there is none.\n *\n * Looked up by `nameLower`, the search twin `nameSearchFields` writes on\n * every company, and narrowed in memory to the ones the caller's scope can\n * see — two clients of one agency may each have an \"Acme\" and must each get\n * their own. Created with the same scope stamp every CRM creator uses, so\n * the company lands exactly where a contact captured on this site would.\n */\nasync function resolveCompanyId(\n context: Extract<ImportContext, { ok: true }>,\n name: string,\n cache: Map<string, string>,\n tally: { created: number },\n): Promise<string> {\n const fields = nameSearchFields(name)\n const cached = cache.get(fields.nameLower)\n if (cached) return cached\n const orgRef = firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(context.orgId)\n const companies = orgRef.collection(CRM_COLLECTIONS.companies)\n const matches = await companies\n .where('nameLower', '==', fields.nameLower)\n .limit(10)\n .get()\n const seen = matches.docs.find((doc) =>\n visibleToTokens(doc.get('visibleTo'), context.readTokens),\n )\n if (seen) {\n cache.set(fields.nameLower, seen.id)\n return seen.id\n }\n /*\n * THE RECORDS BAND (AGL-2611). A company is a record of the same band\n * the contact behind this row will meet at its own door, so on an org\n * at a full hard band the row gets no company rather than a company it\n * was not allowed to hold. Nothing is reported here: the contact is\n * refused `audience-band` a few lines on and the row is listed skipped,\n * which is the one message the operator needs. A plan with an overage\n * rate never reaches this branch — a rate is what makes the band meter\n * instead of refuse.\n */\n const room = await crmRecordsQuotaForOrg(context.org as never, orgRef)\n if (!room.allowed) return ''\n const created = await companies.add({\n ...fields,\n hostId: context.hostId,\n visibleTo: context.scopeTokens,\n createdByUid: context.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n tally.created += 1\n cache.set(fields.nameLower, created.id)\n return created.id\n}\n\n/**\n * `POST crm/contacts-import` — `{ hostId, rows }` → a {@link ContactImportChunkResult}.\n *\n * Rows are written one after another rather than in parallel, because the\n * door's create path counts the collection before it adds: two hundred\n * creates racing one another would each count the same audience and each\n * pass a band the last of them should have hit.\n */\nexport const crmContactsImportHandler: 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<ContactImportRawRow>(req, {\n maxBodyBytes: CONTACT_IMPORT_MAX_BODY_BYTES,\n chunkSize: CONTACT_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 fields = await loadFieldDefinitions(context.orgId, context.readTokens)\n const skipped: ContactImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: ContactImportRow }[] = []\n /*\n * A duplicate WITHIN the request is skipped rather than merged: the\n * second row would merge onto the first's record and the count would\n * read one created and one merged for one person in one file, which is\n * a number the operator cannot reconcile against the rows they sent.\n * Across requests the door's own dedupe answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeContactImportRow(raw, fields)\n if (verdict.ok === false) {\n skipped.push({ index, email: verdict.input, reason: verdict.reason })\n return\n }\n if (seen.has(verdict.row.email)) {\n skipped.push({ index, email: verdict.row.email, reason: 'duplicate' })\n return\n }\n seen.add(verdict.row.email)\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 })\n })\n\n const owners = await ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n )\n const ownersUnresolved = new Set<string>()\n const companies = new Map<string, string>()\n const companyTally = { created: 0 }\n let created = 0\n let merged = 0\n\n for (const { index, row } of normalized) {\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 companyId = row.companyName\n ? await resolveCompanyId(context, row.companyName, companies, companyTally)\n : undefined\n const verdict = await captureHostContact({\n hostId: context.hostId,\n email: row.email,\n ...(row.name ? { name: row.name } : {}),\n source: 'import',\n interaction: { summary: CONTACT_IMPORT_INTERACTION_SUMMARY },\n marketingConsent: row.marketingConsent,\n tags: row.tags,\n facet: {\n ...(row.phone ? { phone: row.phone } : {}),\n ...(row.jobTitle ? { jobTitle: row.jobTitle } : {}),\n ...(companyId ? { companyId } : {}),\n ...(row.address ? { address: row.address } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.lifecycleStage ? { lifecycleStage: row.lifecycleStage } : {}),\n ...(Object.keys(row.custom).length ? { custom: row.custom } : {}),\n },\n // An import is the merchant's own act and files nobody under a\n // campaign; the picker on the profile is where that happens.\n campaignIds: [],\n })\n if ('refused' in verdict) {\n skipped.push({\n index,\n email: row.email,\n reason:\n verdict.refused === 'band'\n ? 'audience-band'\n : verdict.refused === 'invalid-email'\n ? 'invalid-email'\n : verdict.refused === 'erased'\n ? 'erased'\n : 'write-failed',\n })\n continue\n }\n if (verdict.created) created += 1\n else merged += 1\n }\n\n const result: ContactImportChunkResult = {\n received: read.rows.length,\n created,\n merged,\n skipped: skipped.sort((a, b) => a.index - b.index),\n dropped,\n companiesCreated: companyTally.created,\n ownersUnresolved: [...ownersUnresolved],\n }\n return res.status(200).json(result)\n } catch (error) {\n console.error('crm/contacts-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n"],"names":["CRM_COLLECTIONS","nameSearchFields","visibleToTokens","CONTACT_IMPORT_CHUNK_SIZE","CONTACT_IMPORT_MAX_BODY_BYTES","normalizeContactImportRow","crmRecordsQuotaForOrg","firebaseAdmin","captureHostContact","FieldValue","ownerDirectory","readImportRows","resolveImportContext","CONTACT_IMPORT_INTERACTION_SUMMARY","loadFieldDefinitions","orgId","readTokens","snapshot","app","firestore","collection","doc","contactFields","limit","get","docs","map","data","filter","field","key","retiredAt","visibleTo","resolveCompanyId","context","name","cache","tally","fields","cached","nameLower","orgRef","companies","matches","where","seen","find","set","id","room","org","allowed","created","add","hostId","scopeTokens","createdByUid","uid","createdAt","serverTimestamp","updatedAt","crmContactsImportHandler","req","res","method","setHeader","status","json","error","read","maxBodyBytes","chunkSize","ok","body","skipped","dropped","normalized","Set","rows","forEach","raw","index","verdict","push","email","input","reason","has","row","entry","owners","ownersUnresolved","Map","companyTally","merged","ownerUid","ownerEmail","companyId","companyName","undefined","source","interaction","summary","marketingConsent","tags","facet","phone","jobTitle","address","lifecycleStage","Object","keys","custom","length","campaignIds","refused","result","received","sort","a","b","companiesCreated","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4CC,GAED,SAEEA,eAAe,EACfC,gBAAgB,EAEhBC,eAAe,QACV,sBAAqB;AAC5B,SACEC,yBAAyB,EACzBC,6BAA6B,EAK7BC,yBAAyB,QACpB,yBAAqB;AAC5B,SAASC,qBAAqB,EAAEC,aAAa,QAAQ,2BAA0B;AAC/E,SAASC,kBAAkB,QAAQ,wBAAuB;AAC1D,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAEEC,cAAc,EACdC,cAAc,EACdC,oBAAoB,QACf,sBAAkB;AAEzB,mEAAmE,GACnE,OAAO,MAAMC,qCAAqC,oBAAmB;AAErE;;;;;;;;;CASC,GACD,eAAeC,qBACbC,KAAa,EACbC,UAA6B;IAE7B,MAAMC,WAAW,MAAMV,cACpBW,GAAG,GACHC,SAAS,GACTC,UAAU,CAAC,QACXC,GAAG,CAACN,OACJK,UAAU,CAACpB,gBAAgBsB,aAAa,EACxCC,KAAK,CAAC,KACNC,GAAG;IACN,OAAOP,SAASQ,IAAI,CACjBC,GAAG,CAAC,CAACL,MAAQA,IAAIM,IAAI,IACrBC,MAAM,CACL,CAACC,QACC,CAAC,CAACA,MAAMC,GAAG,IACX,CAACD,MAAME,SAAS,IAChB7B,gBAAgB2B,MAAMG,SAAS,EAAEhB;AAEzC;AAEA;;;;;;;;CAQC,GACD,eAAeiB,iBACbC,OAA6C,EAC7CC,IAAY,EACZC,KAA0B,EAC1BC,KAA0B;IAE1B,MAAMC,SAASrC,iBAAiBkC;IAChC,MAAMI,SAASH,MAAMZ,GAAG,CAACc,OAAOE,SAAS;IACzC,IAAID,QAAQ,OAAOA;IACnB,MAAME,SAASlC,cACZW,GAAG,GACHC,SAAS,GACTC,UAAU,CAAC,QACXC,GAAG,CAACa,QAAQnB,KAAK;IACpB,MAAM2B,YAAYD,OAAOrB,UAAU,CAACpB,gBAAgB0C,SAAS;IAC7D,MAAMC,UAAU,MAAMD,UACnBE,KAAK,CAAC,aAAa,MAAMN,OAAOE,SAAS,EACzCjB,KAAK,CAAC,IACNC,GAAG;IACN,MAAMqB,OAAOF,QAAQlB,IAAI,CAACqB,IAAI,CAAC,CAACzB,MAC9BnB,gBAAgBmB,IAAIG,GAAG,CAAC,cAAcU,QAAQlB,UAAU;IAE1D,IAAI6B,MAAM;QACRT,MAAMW,GAAG,CAACT,OAAOE,SAAS,EAAEK,KAAKG,EAAE;QACnC,OAAOH,KAAKG,EAAE;IAChB;IACA;;;;;;;;;GASC,GACD,MAAMC,OAAO,MAAM3C,sBAAsB4B,QAAQgB,GAAG,EAAWT;IAC/D,IAAI,CAACQ,KAAKE,OAAO,EAAE,OAAO;IAC1B,MAAMC,UAAU,MAAMV,UAAUW,GAAG,CAAC,aAC/Bf;QACHgB,QAAQpB,QAAQoB,MAAM;QACtBtB,WAAWE,QAAQqB,WAAW;QAC9BC,cAActB,QAAQuB,GAAG;QACzBC,WAAWjD,WAAWkD,eAAe;QACrCC,WAAWnD,WAAWkD,eAAe;;IAEvCtB,MAAMe,OAAO,IAAI;IACjBhB,MAAMW,GAAG,CAACT,OAAOE,SAAS,EAAEY,QAAQJ,EAAE;IACtC,OAAOI,QAAQJ,EAAE;AACnB;AAEA;;;;;;;CAOC,GACD,OAAO,MAAMa,2BAA6C,OAAOC,KAAKC;IACpE,IAAID,IAAIE,MAAM,KAAK,QAAQ;QACzBD,IAAIE,SAAS,CAAC,SAAS;QACvB,OAAOF,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;IACA,MAAMC,OAAO1D,eAAoCmD,KAAK;QACpDQ,cAAclE;QACdmE,WAAWpE;IACb;IACA,IAAI,WAAWkE,MAAM,OAAON,IAAIG,MAAM,CAACG,KAAKH,MAAM,EAAEC,IAAI,CAAC;QAAEC,OAAOC,KAAKD,KAAK;IAAC;IAC7E,IAAI;QACF,MAAMlC,UAAU,MAAMtB,qBAAqBkD;QAC3C,IAAI5B,QAAQsC,EAAE,KAAK,OAAO,OAAOT,IAAIG,MAAM,CAAChC,QAAQgC,MAAM,EAAEC,IAAI,CAACjC,QAAQuC,IAAI;QAE7E,MAAMnC,SAAS,MAAMxB,qBAAqBoB,QAAQnB,KAAK,EAAEmB,QAAQlB,UAAU;QAC3E,MAAM0D,UAAqC,EAAE;QAC7C,MAAMC,UAAkC,CAAC;QACzC,MAAMC,aAAyD,EAAE;QACjE;;;;;;KAMC,GACD,MAAM/B,OAAO,IAAIgC;QACjBR,KAAKS,IAAI,CAACC,OAAO,CAAC,CAACC,KAAKC;YACtB,MAAMC,UAAU7E,0BAA0B2E,KAAK1C;YAC/C,IAAI4C,QAAQV,EAAE,KAAK,OAAO;gBACxBE,QAAQS,IAAI,CAAC;oBAAEF;oBAAOG,OAAOF,QAAQG,KAAK;oBAAEC,QAAQJ,QAAQI,MAAM;gBAAC;gBACnE;YACF;YACA,IAAIzC,KAAK0C,GAAG,CAACL,QAAQM,GAAG,CAACJ,KAAK,GAAG;gBAC/BV,QAAQS,IAAI,CAAC;oBAAEF;oBAAOG,OAAOF,QAAQM,GAAG,CAACJ,KAAK;oBAAEE,QAAQ;gBAAY;gBACpE;YACF;YACAzC,KAAKQ,GAAG,CAAC6B,QAAQM,GAAG,CAACJ,KAAK;YAC1B,KAAK,MAAMK,SAASP,QAAQM,GAAG,CAACb,OAAO,CAAE;oBACfA;gBAAxBA,OAAO,CAACc,MAAM5D,KAAK,CAAC,GAAG,EAAC8C,uBAAAA,OAAO,CAACc,MAAM5D,KAAK,CAAC,YAApB8C,uBAAwB,KAAK;YACvD;YACAC,WAAWO,IAAI,CAAC;gBAAEF;gBAAOO,KAAKN,QAAQM,GAAG;YAAC;QAC5C;QAEA,MAAME,SAAS,MAAMhF,eACnBwB,QAAQnB,KAAK,EACb6D,WAAWlD,GAAG,CAAC,CAAC+D,QAAUA,MAAMD,GAAG;QAErC,MAAMG,mBAAmB,IAAId;QAC7B,MAAMnC,YAAY,IAAIkD;QACtB,MAAMC,eAAe;YAAEzC,SAAS;QAAE;QAClC,IAAIA,UAAU;QACd,IAAI0C,SAAS;QAEb,KAAK,MAAM,EAAEb,KAAK,EAAEO,GAAG,EAAE,IAAIZ,WAAY;YACvC,IAAImB;YACJ,IAAIP,IAAIQ,UAAU,EAAE;gBAClBD,WAAWL,OAAOlE,GAAG,CAACgE,IAAIQ,UAAU;gBACpC,IAAI,CAACD,UAAUJ,iBAAiBtC,GAAG,CAACmC,IAAIQ,UAAU;YACpD;YACA,MAAMC,YAAYT,IAAIU,WAAW,GAC7B,MAAMjE,iBAAiBC,SAASsD,IAAIU,WAAW,EAAExD,WAAWmD,gBAC5DM;YACJ,MAAMjB,UAAU,MAAM1E,mBAAmB;gBACvC8C,QAAQpB,QAAQoB,MAAM;gBACtB8B,OAAOI,IAAIJ,KAAK;eACZI,IAAIrD,IAAI,GAAG;gBAAEA,MAAMqD,IAAIrD,IAAI;YAAC,IAAI,CAAC;gBACrCiE,QAAQ;gBACRC,aAAa;oBAAEC,SAASzF;gBAAmC;gBAC3D0F,kBAAkBf,IAAIe,gBAAgB;gBACtCC,MAAMhB,IAAIgB,IAAI;gBACdC,OAAO,aACDjB,IAAIkB,KAAK,GAAG;oBAAEA,OAAOlB,IAAIkB,KAAK;gBAAC,IAAI,CAAC,GACpClB,IAAImB,QAAQ,GAAG;oBAAEA,UAAUnB,IAAImB,QAAQ;gBAAC,IAAI,CAAC,GAC7CV,YAAY;oBAAEA;gBAAU,IAAI,CAAC,GAC7BT,IAAIoB,OAAO,GAAG;oBAAEA,SAASpB,IAAIoB,OAAO;gBAAC,IAAI,CAAC,GAC1Cb,WAAW;oBAAEA;gBAAS,IAAI,CAAC,GAC3BP,IAAIqB,cAAc,GAAG;oBAAEA,gBAAgBrB,IAAIqB,cAAc;gBAAC,IAAI,CAAC,GAC/DC,OAAOC,IAAI,CAACvB,IAAIwB,MAAM,EAAEC,MAAM,GAAG;oBAAED,QAAQxB,IAAIwB,MAAM;gBAAC,IAAI,CAAC;gBAEjE,+DAA+D;gBAC/D,6DAA6D;gBAC7DE,aAAa,EAAE;;YAEjB,IAAI,aAAahC,SAAS;gBACxBR,QAAQS,IAAI,CAAC;oBACXF;oBACAG,OAAOI,IAAIJ,KAAK;oBAChBE,QACEJ,QAAQiC,OAAO,KAAK,SAChB,kBACAjC,QAAQiC,OAAO,KAAK,kBAClB,kBACAjC,QAAQiC,OAAO,KAAK,WAClB,WACA;gBACZ;gBACA;YACF;YACA,IAAIjC,QAAQ9B,OAAO,EAAEA,WAAW;iBAC3B0C,UAAU;QACjB;QAEA,MAAMsB,SAAmC;YACvCC,UAAUhD,KAAKS,IAAI,CAACmC,MAAM;YAC1B7D;YACA0C;YACApB,SAASA,QAAQ4C,IAAI,CAAC,CAACC,GAAGC,IAAMD,EAAEtC,KAAK,GAAGuC,EAAEvC,KAAK;YACjDN;YACA8C,kBAAkB5B,aAAazC,OAAO;YACtCuC,kBAAkB;mBAAIA;aAAiB;QACzC;QACA,OAAO5B,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAACiD;IAC9B,EAAE,OAAOhD,OAAO;QACdsD,QAAQtD,KAAK,CAAC,8BAA8BA;QAC5C,OAAOL,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAiC;IACxE;AACF,EAAC"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contacts-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/contacts-import` — one chunk of a contact file, written\n * (AGL-2602).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link CONTACT_IMPORT_CHUNK_SIZE} raw\n * rows in the field vocabulary `crm-import.ts` defines. This route judges\n * each one and writes it, and it is the ONLY place either happens: the\n * drawer shows a preview from the same mapper but stores nothing, so a\n * stale tab cannot store something the server would have refused.\n *\n * ## Every row goes through the door every capture goes through\n *\n * `captureHostContact` — the runtime's wrapper over `upsertHostContact`,\n * the one writer of the contacts collection for every door that is not the\n * v1 API: forms, checkout, bookings, the newsletter box — is the writer\n * here too, so a row this file creates raises `contactCreated` like any\n * other capture (AGL-2605). That is what makes an import\n * dedupe on the address the way a second form submission does, land in the\n * capturing group's facet the way a form capture does, record consent\n * against the capturing site, and stop at a hard band where a form\n * capture stops. A bulk path with its own `add()` would be the fastest way\n * to grow an unscoped, unbanded, unconsented copy of the address book.\n *\n * What the door did not know how to write until now — a phone, a title, a\n * company, an owner, a stage, custom values — it now takes as `facet`, and\n * what it did not say until now — created or merged or refused — it now\n * returns as a verdict. Both changes are the door's, so the v1 API and the\n * manual add can reach them next.\n *\n * ## Two lookups paid once per request, not once per row\n *\n * An owner is named by email in the file and stored by uid on the record,\n * so the org's roster is read once and every row resolves against it. A\n * company is named by name and stored by id, so each distinct name is\n * looked up once, created once when missing, and remembered for the rest\n * of the request — two hundred rows at one company is one read and at most\n * one write, not two hundred of each.\n *\n * ## Who may call it\n *\n * `resolveImportContext` (`import-context.ts`) — the two gates every CSV\n * import route asks, shared with the companies import so one permission\n * has one spelling.\n */\n\nimport {\n type ContactFieldDefinition,\n CRM_COLLECTIONS,\n nameSearchFields,\n type PluginApiHandler,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n CONTACT_IMPORT_CHUNK_SIZE,\n CONTACT_IMPORT_MAX_BODY_BYTES,\n type ContactImportChunkResult,\n type ContactImportRawRow,\n type ContactImportRow,\n type ContactImportSkippedRow,\n normalizeContactImportRow,\n} from '../model/crm-import'\nimport { crmRecordsQuotaForOrg, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { captureHostContact } from '@aglyn/tenant-runtime'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n type ImportContext,\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\n\n/** The one sentence every imported contact's timeline opens with. */\nexport const CONTACT_IMPORT_INTERACTION_SUMMARY = 'Imported from CSV'\n\n/**\n * The holder's live custom-field definitions.\n *\n * Read in full and filtered in memory: a holder has a handful of fields,\n * and a `where` on `retiredAt` would need an index for a collection that\n * fits in one page. Definitions the caller's scope cannot see are left out\n * for the same reason a value under an undefined key is — a value written\n * under a field the holder's own form will never show is a value nobody\n * can edit.\n */\nasync function loadFieldDefinitions(\n orgId: string,\n readTokens: readonly string[],\n): Promise<ContactFieldDefinition[]> {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(200)\n .get()\n return snapshot.docs\n .map((doc) => doc.data() as ContactFieldDefinition)\n .filter(\n (field) =>\n !!field.key &&\n !field.retiredAt &&\n visibleToTokens(field.visibleTo, readTokens),\n )\n}\n\n/** A company an import row named, as the CRM stores it. */\ninterface ImportCompany {\n id: string\n name: string\n}\n\n/**\n * The company a name refers to in this scope, created when there is none.\n *\n * Looked up by `nameLower`, the search twin `nameSearchFields` writes on\n * every company, and narrowed in memory to the ones the caller's scope can\n * see — two clients of one agency may each have an \"Acme\" and must each get\n * their own. Created with the same scope stamp every CRM creator uses, so\n * the company lands exactly where a contact captured on this site would.\n */\nasync function resolveCompany(\n context: Extract<ImportContext, { ok: true }>,\n name: string,\n cache: Map<string, ImportCompany>,\n tally: { created: number },\n): Promise<ImportCompany | null> {\n const fields = nameSearchFields(name)\n const cached = cache.get(fields.nameLower)\n if (cached) return cached\n const orgRef = firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(context.orgId)\n const companies = orgRef.collection(CRM_COLLECTIONS.companies)\n const matches = await companies\n .where('nameLower', '==', fields.nameLower)\n .limit(10)\n .get()\n const seen = matches.docs.find((doc) =>\n visibleToTokens(doc.get('visibleTo'), context.readTokens),\n )\n if (seen) {\n // The name as the record spells it, not as this row typed it, so the\n // contact's facet reads the same as the company page.\n const found = { id: seen.id, name: String(seen.get('name') ?? name) }\n cache.set(fields.nameLower, found)\n return found\n }\n /*\n * THE RECORDS BAND (AGL-2611). A company is a record of the same band\n * the contact behind this row will meet at its own door, so on an org\n * at a full hard band the row gets no company rather than a company it\n * was not allowed to hold. Nothing is reported here: the contact is\n * refused `audience-band` a few lines on and the row is listed skipped,\n * which is the one message the operator needs. A plan with an overage\n * rate never reaches this branch — a rate is what makes the band meter\n * instead of refuse.\n */\n const room = await crmRecordsQuotaForOrg(context.org as never, orgRef)\n if (!room.allowed) return null\n const created = await companies.add({\n ...fields,\n hostId: context.hostId,\n visibleTo: context.scopeTokens,\n createdByUid: context.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n tally.created += 1\n const made = { id: created.id, name }\n cache.set(fields.nameLower, made)\n return made\n}\n\n/**\n * `POST crm/contacts-import` — `{ hostId, rows }` → a {@link ContactImportChunkResult}.\n *\n * Rows are written one after another rather than in parallel, because the\n * door's create path counts the collection before it adds: two hundred\n * creates racing one another would each count the same audience and each\n * pass a band the last of them should have hit.\n */\nexport const crmContactsImportHandler: 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<ContactImportRawRow>(req, {\n maxBodyBytes: CONTACT_IMPORT_MAX_BODY_BYTES,\n chunkSize: CONTACT_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 fields = await loadFieldDefinitions(context.orgId, context.readTokens)\n const skipped: ContactImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: ContactImportRow }[] = []\n /*\n * A duplicate WITHIN the request is skipped rather than merged: the\n * second row would merge onto the first's record and the count would\n * read one created and one merged for one person in one file, which is\n * a number the operator cannot reconcile against the rows they sent.\n * Across requests the door's own dedupe answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeContactImportRow(raw, fields)\n if (verdict.ok === false) {\n skipped.push({ index, email: verdict.input, reason: verdict.reason })\n return\n }\n if (seen.has(verdict.row.email)) {\n skipped.push({ index, email: verdict.row.email, reason: 'duplicate' })\n return\n }\n seen.add(verdict.row.email)\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 })\n })\n\n const owners = await ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n )\n const ownersUnresolved = new Set<string>()\n const companies = new Map<string, ImportCompany>()\n const companyTally = { created: 0 }\n let created = 0\n let merged = 0\n\n for (const { index, row } of normalized) {\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 company = row.companyName\n ? await resolveCompany(context, row.companyName, companies, companyTally)\n : null\n const verdict = await captureHostContact({\n hostId: context.hostId,\n email: row.email,\n ...(row.name ? { name: row.name } : {}),\n source: 'import',\n interaction: { summary: CONTACT_IMPORT_INTERACTION_SUMMARY },\n marketingConsent: row.marketingConsent,\n tags: row.tags,\n facet: {\n ...(row.phone ? { phone: row.phone } : {}),\n ...(row.jobTitle ? { jobTitle: row.jobTitle } : {}),\n // The link AND the name: the merge fields read the facet's\n // `companyName`, and a link written alone renders as nothing.\n ...(company ? { companyId: company.id, companyName: company.name } : {}),\n ...(row.address ? { address: row.address } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.lifecycleStage ? { lifecycleStage: row.lifecycleStage } : {}),\n ...(Object.keys(row.custom).length ? { custom: row.custom } : {}),\n },\n // An import is the merchant's own act and files nobody under a\n // campaign; the picker on the profile is where that happens.\n campaignIds: [],\n })\n if ('refused' in verdict) {\n skipped.push({\n index,\n email: row.email,\n reason:\n verdict.refused === 'band'\n ? 'audience-band'\n : verdict.refused === 'invalid-email'\n ? 'invalid-email'\n : verdict.refused === 'erased'\n ? 'erased'\n : 'write-failed',\n })\n continue\n }\n if (verdict.created) created += 1\n else merged += 1\n }\n\n const result: ContactImportChunkResult = {\n received: read.rows.length,\n created,\n merged,\n skipped: skipped.sort((a, b) => a.index - b.index),\n dropped,\n companiesCreated: companyTally.created,\n ownersUnresolved: [...ownersUnresolved],\n }\n return res.status(200).json(result)\n } catch (error) {\n console.error('crm/contacts-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n"],"names":["CRM_COLLECTIONS","nameSearchFields","visibleToTokens","CONTACT_IMPORT_CHUNK_SIZE","CONTACT_IMPORT_MAX_BODY_BYTES","normalizeContactImportRow","crmRecordsQuotaForOrg","firebaseAdmin","captureHostContact","FieldValue","ownerDirectory","readImportRows","resolveImportContext","CONTACT_IMPORT_INTERACTION_SUMMARY","loadFieldDefinitions","orgId","readTokens","snapshot","app","firestore","collection","doc","contactFields","limit","get","docs","map","data","filter","field","key","retiredAt","visibleTo","resolveCompany","context","name","cache","tally","fields","cached","nameLower","orgRef","companies","matches","where","seen","find","found","id","String","set","room","org","allowed","created","add","hostId","scopeTokens","createdByUid","uid","createdAt","serverTimestamp","updatedAt","made","crmContactsImportHandler","req","res","method","setHeader","status","json","error","read","maxBodyBytes","chunkSize","ok","body","skipped","dropped","normalized","Set","rows","forEach","raw","index","verdict","push","email","input","reason","has","row","entry","owners","ownersUnresolved","Map","companyTally","merged","ownerUid","ownerEmail","company","companyName","source","interaction","summary","marketingConsent","tags","facet","phone","jobTitle","companyId","address","lifecycleStage","Object","keys","custom","length","campaignIds","refused","result","received","sort","a","b","companiesCreated","console"],"mappings":"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"}
|