@aglyn/plugins-crm 1.0.0-beta.199 → 1.0.0-beta.200
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/companies-section.d.ts +6 -6
- package/src/lib/components/companies-section.js +54 -84
- package/src/lib/components/companies-section.js.map +1 -1
- package/src/lib/components/company-edit-drawer.js +17 -4
- package/src/lib/components/company-edit-drawer.js.map +1 -1
- package/src/lib/components/company-picker.js +6 -2
- package/src/lib/components/company-picker.js.map +1 -1
- package/src/lib/components/contact-detail-page.js +14 -2
- package/src/lib/components/contact-detail-page.js.map +1 -1
- package/src/lib/components/contact-list-columns.d.ts +3 -5
- package/src/lib/components/contact-list-columns.js +6 -8
- package/src/lib/components/contact-list-columns.js.map +1 -1
- package/src/lib/components/contacts-bulk-bar.js +10 -1
- package/src/lib/components/contacts-bulk-bar.js.map +1 -1
- package/src/lib/components/contacts-section.js +115 -163
- package/src/lib/components/contacts-section.js.map +1 -1
- package/src/lib/components/crm-next-activity-column.d.ts +4 -2
- package/src/lib/components/crm-next-activity-column.js +6 -3
- package/src/lib/components/crm-next-activity-column.js.map +1 -1
- package/src/lib/components/deal-edit-drawer.js +8 -4
- package/src/lib/components/deal-edit-drawer.js.map +1 -1
- package/src/lib/components/deals-section.d.ts +2 -2
- package/src/lib/components/deals-section.js +59 -84
- package/src/lib/components/deals-section.js.map +1 -1
- package/src/lib/components/lead-campaigns-card.js +11 -3
- package/src/lib/components/lead-campaigns-card.js.map +1 -1
- package/src/lib/components/lead-properties-card.js +9 -1
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/leads-bulk-bar.js +16 -3
- package/src/lib/components/leads-bulk-bar.js.map +1 -1
- package/src/lib/components/leads-section.d.ts +2 -1
- package/src/lib/components/leads-section.js +110 -148
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/tasks-section.js +128 -121
- package/src/lib/components/tasks-section.js.map +1 -1
- package/src/lib/constants/company-filters.d.ts +13 -0
- package/src/lib/constants/company-filters.js +47 -17
- package/src/lib/constants/company-filters.js.map +1 -1
- package/src/lib/constants/contact-filters.d.ts +61 -10
- package/src/lib/constants/contact-filters.js +373 -141
- package/src/lib/constants/contact-filters.js.map +1 -1
- package/src/lib/constants/deal-filters.d.ts +44 -0
- package/src/lib/constants/deal-filters.js +78 -0
- package/src/lib/constants/deal-filters.js.map +1 -0
- package/src/lib/hooks/use-crm-list-query.d.ts +68 -0
- package/src/lib/hooks/use-crm-list-query.js +182 -0
- package/src/lib/hooks/use-crm-list-query.js.map +1 -0
- package/src/lib/hooks/use-crm-tasks.d.ts +23 -6
- package/src/lib/hooks/use-crm-tasks.js +44 -42
- package/src/lib/hooks/use-crm-tasks.js.map +1 -1
- package/src/lib/hooks/use-deals.d.ts +2 -34
- package/src/lib/hooks/use-deals.js +3 -62
- package/src/lib/hooks/use-deals.js.map +1 -1
- package/src/lib/model/crm-list-query.d.ts +91 -0
- package/src/lib/model/crm-list-query.js +182 -0
- package/src/lib/model/crm-list-query.js.map +1 -0
- package/src/lib/model/lead-filters.d.ts +49 -43
- package/src/lib/model/lead-filters.js +194 -88
- package/src/lib/model/lead-filters.js.map +1 -1
- package/src/lib/model/list-fields-api.d.ts +26 -0
- package/src/lib/model/list-fields-api.js +47 -0
- package/src/lib/model/list-fields-api.js.map +1 -0
- package/src/lib/model/list-fields.d.ts +47 -0
- package/src/lib/model/list-fields.js +39 -0
- package/src/lib/model/list-fields.js.map +1 -0
- package/src/lib/model/task-views.d.ts +15 -10
- package/src/lib/model/task-views.js +68 -9
- package/src/lib/model/task-views.js.map +1 -1
- package/src/lib/server/companies-import.js +3 -3
- package/src/lib/server/companies-import.js.map +1 -1
- package/src/lib/server/company-delete.js +3 -1
- package/src/lib/server/company-delete.js.map +1 -1
- package/src/lib/server/consent-group-participant.js +4 -0
- package/src/lib/server/consent-group-participant.js.map +1 -1
- package/src/lib/server/contact-update.js +14 -8
- package/src/lib/server/contact-update.js.map +1 -1
- package/src/lib/server/contacts-import.js +4 -2
- package/src/lib/server/contacts-import.js.map +1 -1
- package/src/lib/server/deals-import.js +4 -2
- package/src/lib/server/deals-import.js.map +1 -1
- package/src/lib/server/erase-person.js +2 -1
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/lead-create.js +4 -1
- package/src/lib/server/lead-create.js.map +1 -1
- package/src/lib/server/lead-source-values.js +9 -5
- package/src/lib/server/lead-source-values.js.map +1 -1
- package/src/lib/server/leads-import.js +4 -1
- package/src/lib/server/leads-import.js.map +1 -1
- package/src/lib/server/list-fields-routes.d.ts +29 -0
- package/src/lib/server/list-fields-routes.js +85 -0
- package/src/lib/server/list-fields-routes.js.map +1 -0
- package/src/lib/server/record-email-state.js +5 -2
- package/src/lib/server/record-email-state.js.map +1 -1
- package/src/lib/server/record-timeline.js +3 -2
- package/src/lib/server/record-timeline.js.map +1 -1
- package/src/lib/server/task-routes.js +6 -3
- package/src/lib/server/task-routes.js.map +1 -1
- package/src/lib/server/tasks-import.js +5 -2
- package/src/lib/server/tasks-import.js.map +1 -1
- package/src/lib/server.js +10 -1
- package/src/lib/server.js.map +1 -1
- package/src/lib/hooks/use-org-leads.d.ts +0 -50
- package/src/lib/hooks/use-org-leads.js +0 -104
- package/src/lib/hooks/use-org-leads.js.map +0 -1
|
@@ -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/** 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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"}
|
|
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 crmNewRecordListFields,\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 // What the Companies list searches and filters by (AGL-3321).\n ...crmNewRecordListFields('companies', { ...fields, visibleTo: context.scopeTokens }),\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","crmNewRecordListFields","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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"}
|
|
@@ -45,7 +45,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
45
45
|
* A deal is a CRM record and meets the records band (AGL-2611), judged
|
|
46
46
|
* the way the companies import judges it: one aggregate at the first
|
|
47
47
|
* create, re-judged locally as the request creates more.
|
|
48
|
-
*/ import { checkCrmRecordsQuota, CRM_COLLECTIONS, isPipelineArchived, nameSearchKey, visibleToTokens } from "@aglyn/aglyn/server";
|
|
48
|
+
*/ import { checkCrmRecordsQuota, CRM_COLLECTIONS, crmNewRecordListFields, isPipelineArchived, nameSearchKey, visibleToTokens } from "@aglyn/aglyn/server";
|
|
49
49
|
import { DEAL_IMPORT_CHUNK_SIZE, DEAL_IMPORT_MAX_BODY_BYTES, dealImportNameKey, normalizeDealImportRow } from "../model/crm-deal-import.js";
|
|
50
50
|
import { crmRecordsQuotaForOrg, firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
51
51
|
import { FieldValue } from "firebase-admin/firestore";
|
|
@@ -211,7 +211,9 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
211
211
|
expectedCloseAtMs: row.expectedCloseAtMs
|
|
212
212
|
} : {}, row.notes ? {
|
|
213
213
|
notes: row.notes
|
|
214
|
-
} : {}, stamp
|
|
214
|
+
} : {}, stamp, crmNewRecordListFields('deals', _extends({
|
|
215
|
+
title: row.title
|
|
216
|
+
}, stamp))));
|
|
215
217
|
createdHere += 1;
|
|
216
218
|
created += 1;
|
|
217
219
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/deals-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/deals-import` — one chunk of a deals file, written\n * (AGL-2662).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link DEAL_IMPORT_CHUNK_SIZE} raw\n * rows in the vocabulary `crm-deal-import.ts` defines. This route judges\n * each one through the same normalizer, resolves the two names a deal is\n * filed under, and writes it with the stamp every CRM creator writes.\n *\n * ## The pipeline and the stage are looked up ONCE per request\n *\n * The org's pipelines are read once — the active ones the caller's scope\n * can see — and every row resolves against that list by name. A row\n * naming no pipeline lands in the default one (or the only one); a row\n * naming no stage lands in its pipeline's first open stage. A name that\n * matches nothing refuses the row by name, before any write, so the\n * operator's skipped file says exactly which cell to fix.\n *\n * ## A deal's status is its stage's kind\n *\n * Landing in a `won` or `lost` stage closes the deal on creation, stamped\n * with the same clock as `stageChangedAtMs`, the way the REST create does.\n * No `dealWon` event fires for an imported win: the deal was won before it\n * was filed here, and an automation on the event is for what happens next.\n *\n * ## The band is counted once per request\n *\n * A deal is a CRM record and meets the records band (AGL-2611), judged\n * the way the companies import judges it: one aggregate at the first\n * create, re-judged locally as the request creates more.\n */\n\nimport {\n checkCrmRecordsQuota,\n CRM_COLLECTIONS,\n type CrmDealStage,\n type CrmPipeline,\n isPipelineArchived,\n nameSearchKey,\n type PluginApiHandler,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n DEAL_IMPORT_CHUNK_SIZE,\n DEAL_IMPORT_MAX_BODY_BYTES,\n type DealImportChunkResult,\n type DealImportRawRow,\n type DealImportRow,\n type DealImportSkippedRow,\n dealImportNameKey,\n normalizeDealImportRow,\n} from '../model/crm-deal-import'\nimport { crmRecordsQuotaForOrg, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n type ImportContext,\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\n\n/** The default currency a deal carries when the file names none. */\nconst DEFAULT_CURRENCY = 'usd'\n\n/** One pipeline the request can file into, with its stages by name. */\ninterface PipelineChoice {\n id: string\n pipeline: CrmPipeline\n stages: CrmDealStage[]\n}\n\n/** The active pipelines the caller's scope can see, in read order. */\nasync function readPipelines(\n orgRef: FirebaseFirestore.DocumentReference,\n readTokens: readonly string[],\n): Promise<PipelineChoice[]> {\n const snapshot = await orgRef.collection(CRM_COLLECTIONS.pipelines).get()\n const choices: PipelineChoice[] = []\n for (const doc of snapshot.docs) {\n const pipeline = doc.data() as CrmPipeline\n if (isPipelineArchived(pipeline)) continue\n if (!visibleToTokens(pipeline.visibleTo, readTokens)) continue\n const stages = [...(pipeline.stages ?? [])].sort((a, b) => a.order - b.order)\n choices.push({ id: doc.id, pipeline, stages })\n }\n return choices\n}\n\n/**\n * Where one row lands: its pipeline and its stage, or which of the two\n * names the org does not have.\n */\nexport function placeDealImportRow(\n choices: readonly PipelineChoice[],\n row: Pick<DealImportRow, 'pipeline' | 'stage'>,\n):\n | { ok: true; pipeline: PipelineChoice; stage: CrmDealStage }\n | { ok: false; reason: 'unknown-pipeline' | 'unknown-stage' } {\n let pipeline: PipelineChoice | undefined\n if (row.pipeline) {\n const wanted = dealImportNameKey(row.pipeline)\n pipeline = choices.find((choice) => dealImportNameKey(choice.pipeline.name ?? '') === wanted)\n } else {\n pipeline = choices.find((choice) => choice.pipeline.isDefault) ?? (choices.length === 1 ? choices[0] : undefined)\n }\n if (!pipeline) return { ok: false, reason: 'unknown-pipeline' }\n let stage: CrmDealStage | undefined\n if (row.stage) {\n const wanted = dealImportNameKey(row.stage)\n stage = pipeline.stages.find((candidate) => dealImportNameKey(candidate.name ?? '') === wanted)\n } else {\n stage = pipeline.stages.find((candidate) => candidate.kind === 'open') ?? pipeline.stages[0]\n }\n if (!stage) return { ok: false, reason: 'unknown-stage' }\n return { ok: true, pipeline, stage }\n}\n\n/**\n * `POST crm/deals-import` — `{ hostId, rows }` → a {@link DealImportChunkResult}.\n *\n * Rows are written one after another so the band's local count stays\n * honest across the request.\n */\nexport const crmDealsImportHandler: 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<DealImportRawRow>(req, {\n maxBodyBytes: DEAL_IMPORT_MAX_BODY_BYTES,\n chunkSize: DEAL_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: DealImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: DealImportRow }[] = []\n read.rows.forEach((raw, index) => {\n const verdict = normalizeDealImportRow(raw)\n if (verdict.ok === false) {\n skipped.push({ index, title: verdict.input, reason: verdict.reason })\n return\n }\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 orgRef = firebaseAdmin.app().firestore().collection('orgs').doc(context.orgId)\n const [owners, pipelines] = await Promise.all([\n ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n ),\n normalized.length ? readPipelines(orgRef, context.readTokens) : Promise.resolve([]),\n ])\n const ownersUnresolved = new Set<string>()\n const deals = orgRef.collection(CRM_COLLECTIONS.deals)\n const stamp = creationStamp(context)\n let counted: { crmRecordsCount: number } | null = null\n let createdHere = 0\n let created = 0\n\n for (const { index, row } of normalized) {\n const placed = placeDealImportRow(pipelines, row)\n if (placed.ok === false) {\n skipped.push({ index, title: row.title, reason: placed.reason })\n continue\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 if (!counted) counted = await crmRecordsQuotaForOrg(context.org as never, orgRef)\n const room = checkCrmRecordsQuota(\n context.org as never,\n counted.crmRecordsCount + createdHere,\n )\n if (!room.allowed) {\n skipped.push({ index, title: row.title, reason: 'records-band' })\n continue\n }\n const nowMs = Date.now()\n const { stage } = placed\n const status = stage.kind === 'won' ? 'won' : stage.kind === 'lost' ? 'lost' : 'open'\n await deals.add({\n title: row.title,\n titleLower: nameSearchKey(row.title),\n pipelineId: placed.pipeline.id,\n stageId: stage.id,\n status,\n stageChangedAtMs: nowMs,\n closedAtMs: status === 'open' ? null : nowMs,\n currency: row.currency ?? DEFAULT_CURRENCY,\n ...(typeof row.amountCents === 'number' ? { amountCents: row.amountCents } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(typeof row.expectedCloseAtMs === 'number'\n ? { expectedCloseAtMs: row.expectedCloseAtMs }\n : {}),\n ...(row.notes ? { notes: row.notes } : {}),\n ...stamp,\n })\n createdHere += 1\n created += 1\n }\n\n const result: DealImportChunkResult = {\n received: read.rows.length,\n created,\n merged: 0,\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/deals-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n\n/** The scope, the site, the importer and the clocks every created deal carries. */\nfunction creationStamp(\n context: Extract<ImportContext, { ok: true }>,\n): Record<string, unknown> {\n return {\n hostId: context.hostId,\n visibleTo: context.scopeTokens,\n createdByUid: context.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n"],"names":["checkCrmRecordsQuota","CRM_COLLECTIONS","isPipelineArchived","nameSearchKey","visibleToTokens","DEAL_IMPORT_CHUNK_SIZE","DEAL_IMPORT_MAX_BODY_BYTES","dealImportNameKey","normalizeDealImportRow","crmRecordsQuotaForOrg","firebaseAdmin","FieldValue","ownerDirectory","readImportRows","resolveImportContext","DEFAULT_CURRENCY","readPipelines","orgRef","readTokens","snapshot","collection","pipelines","get","choices","doc","docs","pipeline","data","visibleTo","stages","sort","a","b","order","push","id","placeDealImportRow","row","wanted","find","choice","name","isDefault","length","undefined","ok","reason","stage","candidate","kind","crmDealsImportHandler","req","res","method","setHeader","status","json","error","read","maxBodyBytes","chunkSize","context","body","skipped","dropped","normalized","rows","forEach","raw","index","verdict","title","input","entry","field","app","firestore","orgId","owners","Promise","all","map","resolve","ownersUnresolved","Set","deals","stamp","creationStamp","counted","createdHere","created","placed","ownerUid","ownerEmail","add","org","room","crmRecordsCount","allowed","nowMs","Date","now","titleLower","pipelineId","stageId","stageChangedAtMs","closedAtMs","currency","amountCents","expectedCloseAtMs","notes","result","received","merged","console","hostId","scopeTokens","createdByUid","uid","createdAt","serverTimestamp","updatedAt"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/deals-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/deals-import` — one chunk of a deals file, written\n * (AGL-2662).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link DEAL_IMPORT_CHUNK_SIZE} raw\n * rows in the vocabulary `crm-deal-import.ts` defines. This route judges\n * each one through the same normalizer, resolves the two names a deal is\n * filed under, and writes it with the stamp every CRM creator writes.\n *\n * ## The pipeline and the stage are looked up ONCE per request\n *\n * The org's pipelines are read once — the active ones the caller's scope\n * can see — and every row resolves against that list by name. A row\n * naming no pipeline lands in the default one (or the only one); a row\n * naming no stage lands in its pipeline's first open stage. A name that\n * matches nothing refuses the row by name, before any write, so the\n * operator's skipped file says exactly which cell to fix.\n *\n * ## A deal's status is its stage's kind\n *\n * Landing in a `won` or `lost` stage closes the deal on creation, stamped\n * with the same clock as `stageChangedAtMs`, the way the REST create does.\n * No `dealWon` event fires for an imported win: the deal was won before it\n * was filed here, and an automation on the event is for what happens next.\n *\n * ## The band is counted once per request\n *\n * A deal is a CRM record and meets the records band (AGL-2611), judged\n * the way the companies import judges it: one aggregate at the first\n * create, re-judged locally as the request creates more.\n */\n\nimport {\n checkCrmRecordsQuota,\n CRM_COLLECTIONS,\n type CrmDealStage,\n crmNewRecordListFields,\n type CrmPipeline,\n isPipelineArchived,\n nameSearchKey,\n type PluginApiHandler,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n DEAL_IMPORT_CHUNK_SIZE,\n DEAL_IMPORT_MAX_BODY_BYTES,\n type DealImportChunkResult,\n type DealImportRawRow,\n type DealImportRow,\n type DealImportSkippedRow,\n dealImportNameKey,\n normalizeDealImportRow,\n} from '../model/crm-deal-import'\nimport { crmRecordsQuotaForOrg, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n type ImportContext,\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\n\n/** The default currency a deal carries when the file names none. */\nconst DEFAULT_CURRENCY = 'usd'\n\n/** One pipeline the request can file into, with its stages by name. */\ninterface PipelineChoice {\n id: string\n pipeline: CrmPipeline\n stages: CrmDealStage[]\n}\n\n/** The active pipelines the caller's scope can see, in read order. */\nasync function readPipelines(\n orgRef: FirebaseFirestore.DocumentReference,\n readTokens: readonly string[],\n): Promise<PipelineChoice[]> {\n const snapshot = await orgRef.collection(CRM_COLLECTIONS.pipelines).get()\n const choices: PipelineChoice[] = []\n for (const doc of snapshot.docs) {\n const pipeline = doc.data() as CrmPipeline\n if (isPipelineArchived(pipeline)) continue\n if (!visibleToTokens(pipeline.visibleTo, readTokens)) continue\n const stages = [...(pipeline.stages ?? [])].sort((a, b) => a.order - b.order)\n choices.push({ id: doc.id, pipeline, stages })\n }\n return choices\n}\n\n/**\n * Where one row lands: its pipeline and its stage, or which of the two\n * names the org does not have.\n */\nexport function placeDealImportRow(\n choices: readonly PipelineChoice[],\n row: Pick<DealImportRow, 'pipeline' | 'stage'>,\n):\n | { ok: true; pipeline: PipelineChoice; stage: CrmDealStage }\n | { ok: false; reason: 'unknown-pipeline' | 'unknown-stage' } {\n let pipeline: PipelineChoice | undefined\n if (row.pipeline) {\n const wanted = dealImportNameKey(row.pipeline)\n pipeline = choices.find((choice) => dealImportNameKey(choice.pipeline.name ?? '') === wanted)\n } else {\n pipeline = choices.find((choice) => choice.pipeline.isDefault) ?? (choices.length === 1 ? choices[0] : undefined)\n }\n if (!pipeline) return { ok: false, reason: 'unknown-pipeline' }\n let stage: CrmDealStage | undefined\n if (row.stage) {\n const wanted = dealImportNameKey(row.stage)\n stage = pipeline.stages.find((candidate) => dealImportNameKey(candidate.name ?? '') === wanted)\n } else {\n stage = pipeline.stages.find((candidate) => candidate.kind === 'open') ?? pipeline.stages[0]\n }\n if (!stage) return { ok: false, reason: 'unknown-stage' }\n return { ok: true, pipeline, stage }\n}\n\n/**\n * `POST crm/deals-import` — `{ hostId, rows }` → a {@link DealImportChunkResult}.\n *\n * Rows are written one after another so the band's local count stays\n * honest across the request.\n */\nexport const crmDealsImportHandler: 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<DealImportRawRow>(req, {\n maxBodyBytes: DEAL_IMPORT_MAX_BODY_BYTES,\n chunkSize: DEAL_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: DealImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: DealImportRow }[] = []\n read.rows.forEach((raw, index) => {\n const verdict = normalizeDealImportRow(raw)\n if (verdict.ok === false) {\n skipped.push({ index, title: verdict.input, reason: verdict.reason })\n return\n }\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 orgRef = firebaseAdmin.app().firestore().collection('orgs').doc(context.orgId)\n const [owners, pipelines] = await Promise.all([\n ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n ),\n normalized.length ? readPipelines(orgRef, context.readTokens) : Promise.resolve([]),\n ])\n const ownersUnresolved = new Set<string>()\n const deals = orgRef.collection(CRM_COLLECTIONS.deals)\n const stamp = creationStamp(context)\n let counted: { crmRecordsCount: number } | null = null\n let createdHere = 0\n let created = 0\n\n for (const { index, row } of normalized) {\n const placed = placeDealImportRow(pipelines, row)\n if (placed.ok === false) {\n skipped.push({ index, title: row.title, reason: placed.reason })\n continue\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 if (!counted) counted = await crmRecordsQuotaForOrg(context.org as never, orgRef)\n const room = checkCrmRecordsQuota(\n context.org as never,\n counted.crmRecordsCount + createdHere,\n )\n if (!room.allowed) {\n skipped.push({ index, title: row.title, reason: 'records-band' })\n continue\n }\n const nowMs = Date.now()\n const { stage } = placed\n const status = stage.kind === 'won' ? 'won' : stage.kind === 'lost' ? 'lost' : 'open'\n await deals.add({\n title: row.title,\n titleLower: nameSearchKey(row.title),\n pipelineId: placed.pipeline.id,\n stageId: stage.id,\n status,\n stageChangedAtMs: nowMs,\n closedAtMs: status === 'open' ? null : nowMs,\n currency: row.currency ?? DEFAULT_CURRENCY,\n ...(typeof row.amountCents === 'number' ? { amountCents: row.amountCents } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(typeof row.expectedCloseAtMs === 'number'\n ? { expectedCloseAtMs: row.expectedCloseAtMs }\n : {}),\n ...(row.notes ? { notes: row.notes } : {}),\n ...stamp,\n // What the Deals list searches and filters by (AGL-3321).\n ...crmNewRecordListFields('deals', { title: row.title, ...stamp }),\n })\n createdHere += 1\n created += 1\n }\n\n const result: DealImportChunkResult = {\n received: read.rows.length,\n created,\n merged: 0,\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/deals-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n\n/** The scope, the site, the importer and the clocks every created deal carries. */\nfunction creationStamp(\n context: Extract<ImportContext, { ok: true }>,\n): Record<string, unknown> {\n return {\n hostId: context.hostId,\n visibleTo: context.scopeTokens,\n createdByUid: context.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n"],"names":["checkCrmRecordsQuota","CRM_COLLECTIONS","crmNewRecordListFields","isPipelineArchived","nameSearchKey","visibleToTokens","DEAL_IMPORT_CHUNK_SIZE","DEAL_IMPORT_MAX_BODY_BYTES","dealImportNameKey","normalizeDealImportRow","crmRecordsQuotaForOrg","firebaseAdmin","FieldValue","ownerDirectory","readImportRows","resolveImportContext","DEFAULT_CURRENCY","readPipelines","orgRef","readTokens","snapshot","collection","pipelines","get","choices","doc","docs","pipeline","data","visibleTo","stages","sort","a","b","order","push","id","placeDealImportRow","row","wanted","find","choice","name","isDefault","length","undefined","ok","reason","stage","candidate","kind","crmDealsImportHandler","req","res","method","setHeader","status","json","error","read","maxBodyBytes","chunkSize","context","body","skipped","dropped","normalized","rows","forEach","raw","index","verdict","title","input","entry","field","app","firestore","orgId","owners","Promise","all","map","resolve","ownersUnresolved","Set","deals","stamp","creationStamp","counted","createdHere","created","placed","ownerUid","ownerEmail","add","org","room","crmRecordsCount","allowed","nowMs","Date","now","titleLower","pipelineId","stageId","stageChangedAtMs","closedAtMs","currency","amountCents","expectedCloseAtMs","notes","result","received","merged","console","hostId","scopeTokens","createdByUid","uid","createdAt","serverTimestamp","updatedAt"],"mappings":"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"}
|
|
@@ -20,6 +20,7 @@ import { firebaseAdmin, getOrgForHost, logHostActivity, logOrgActivity, orgDataC
|
|
|
20
20
|
import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
|
|
21
21
|
import { FieldValue } from "firebase-admin/firestore";
|
|
22
22
|
import { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from "./org-caller.js";
|
|
23
|
+
import { addAdminAudit } from "@aglyn/tenant-data-admin/server/admin-audit-write";
|
|
23
24
|
/** The route key, as `registerCrmConsoleApi` registers it. */ export const CRM_ERASE_PERSON_ROUTE = 'crm/erase-person';
|
|
24
25
|
/**
|
|
25
26
|
* `POST /api/crm/erase-person` — file a privacy erasure for one person
|
|
@@ -289,7 +290,7 @@ import { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from "./org-caller.
|
|
|
289
290
|
} else {
|
|
290
291
|
await logHostActivity(hostId, actor, 'Requested privacy erasure', target).catch(()=>undefined);
|
|
291
292
|
}
|
|
292
|
-
await firestore
|
|
293
|
+
await addAdminAudit(firestore, {
|
|
293
294
|
actorUid: actor.uid,
|
|
294
295
|
action: 'person.erasure-requested',
|
|
295
296
|
target: `orgs/${orgId}/people/${key}`,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/erase-person.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 canManageOrg,\n CONTACT_ERASURE_REQUESTED_FIELD,\n normalizeContactEmail,\n PERSON_ERASURES_COLLECTION,\n personErasureConfirmationMatches,\n personErasureId,\n type PersonErasureRequest,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport {\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n logOrgActivity,\n orgDataCollectionForHost,\n readLeadForHost,\n suppressEmailForHostErasure,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from './org-caller'\n\n/** The route key, as `registerCrmConsoleApi` registers it. */\nexport const CRM_ERASE_PERSON_ROUTE = 'crm/erase-person'\n\nexport interface ErasePersonRequestBody {\n /**\n * The site the request is filed from. At the organization level\n * (AGL-2634) the record's own site, or absent for a contact no site has\n * captured; a LEAD always names one, because a lead lives under its site.\n */\n hostId?: string\n /** The organization, at the organization level (AGL-2634). */\n orgId?: string\n /**\n * At most one of these names the record the request is filed from. Under\n * a site exactly one does; at the organization level neither may, which\n * files the request by `email` alone (AGL-2839).\n */\n contactId?: string\n leadId?: string\n /**\n * The address as the admin typed it — the confirmation of a record's\n * address, or, filed by address alone, the person the request is about.\n */\n email: string\n /** Filed by address alone: the address typed a second time. */\n confirmEmail?: string\n}\n\nexport interface ErasePersonResponse {\n ok: true\n requestId: string\n /** When the request entered the queue. */\n pendingSinceMs: number\n /** True when a request for this person was already waiting; nothing was re-filed. */\n alreadyPending: boolean\n}\n\n/**\n * `POST /api/crm/erase-person` — file a privacy erasure for one person\n * (AGL-2623).\n *\n * ## Who may\n *\n * A workspace admin or owner — `canManageOrg` on the caller's org role —\n * and nobody else. Not a site editor with the data permission, who can\n * detach a contact from their own site but has no standing to remove the\n * person from every site in the workspace; and not staff acting alone,\n * because the workspace is the controller of this data and the instruction\n * has to come from it. Staff who are also admins of the workspace pass on\n * that role, as anyone would.\n *\n * ## What it does, and what it does not\n *\n * It files the request and closes the doors; it deletes nothing. The daily\n * erasure job executes the sweep, so that a workspace's person erasures and\n * its own erasure run through one job, one audit trail and one queue a\n * staff member can watch. What happens here, in order:\n *\n * 1. The record named by the body is read and its address is what the\n * request is about — the body's address must match it, typed, so an\n * admin who opened the wrong record is stopped by the confirmation\n * rather than by luck.\n * 2. The request document is written, or found already pending, in\n * which case nothing is re-filed and the caller is told so.\n * 3. Every site of the workspace gets its suppression row NOW, not when\n * the job runs: from this moment a capture cannot rebuild the person.\n * 4. The contact document and each site's lead are stamped with the\n * request time, so their pages can say \"erasure pending\" off the\n * document they already read.\n * 5. The site's activity feed and the platform audit log each get a row\n * that names the record by id and the person by hash, never by\n * address.\n *\n * ## The organization variant (AGL-2634)\n *\n * `{ orgId, hostId?, contactId | leadId, email }` from the org-level hub:\n * the same workspace admin, authorized by the org rather than through a\n * site they name, so a contact no site captured can be erased from the\n * page that lists it. The sweep is what it always was — every site of the\n * org, found by `orgId` — and the row goes to the org's feed. A lead still\n * names its site, because that is where a lead lives.\n *\n * ## By address alone (AGL-2839)\n *\n * `{ orgId, email, confirmEmail }` with no record, from the workspace's\n * Privacy settings: the same workspace admin, for any person the workspace\n * may hold — including one no page shows, since the CRM that lists records\n * is included from Starter and a Free workspace has none of it. Nothing is\n * read to file it: the sweep is keyed by the address, the markers are found\n * by it, and the typed address must match its second typing, which is the\n * confirmation a record's address gives the other variants. No plan is\n * asked on any variant: erasure is an obligation, not a CRM feature.\n */\nexport const crmErasePersonHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n const body: Partial<ErasePersonRequestBody> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const routeScope = readCrmRouteScope(body as Record<string, unknown>)\n const contactId = String(body.contactId ?? '').trim()\n const leadId = String(body.leadId ?? '').trim()\n const byAddress = routeScope?.level === 'org' && !contactId && !leadId\n if (!routeScope || (!byAddress && !contactId && !leadId) || (contactId && leadId)) {\n res.status(400).json({ error: 'Name the site and exactly one contact or lead' })\n return\n }\n const { hostId } = routeScope\n if (leadId && !hostId) {\n res.status(400).json({ error: 'Name the site the lead lives under' })\n return\n }\n const refusal = 'Only a workspace admin can erase a person from the workspace'\n\n try {\n let orgId: string\n let actor: { uid: string; email: string | null }\n if (routeScope.level === 'org') {\n const caller = await authorizeOrgCaller(req, routeScope.orgId, {\n needs: 'manage-org',\n refusal,\n })\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n orgId = caller.orgId\n actor = { uid: caller.uid, email: caller.email }\n } else {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (!canManageOrg(membership.role)) {\n res.status(403).json({ error: refusal })\n return\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved || resolved.orgId !== membership.orgId) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n orgId = resolved.orgId\n actor = { uid: decoded.uid, email: decoded.email ?? null }\n }\n const firestore = firebaseAdmin.app().firestore()\n\n let recordEmail: string | null = null\n if (byAddress) {\n recordEmail = normalizeContactEmail(body.email)\n } else if (contactId) {\n const contactsRef =\n routeScope.level === 'org'\n ? firestore.collection('orgs').doc(orgId).collection('contacts')\n : await orgDataCollectionForHost(hostId, 'contacts')\n const contact = await contactsRef.doc(contactId).get()\n if (!contact.exists) {\n res.status(404).json({ error: 'Unknown contact' })\n return\n }\n recordEmail = normalizeContactEmail(contact.get('email'))\n } else {\n const lead = await readLeadForHost(hostId, leadId)\n if (!lead) {\n res.status(404).json({ error: 'Unknown lead' })\n return\n }\n recordEmail = normalizeContactEmail(lead.get('email'))\n }\n const key = recordEmail ? personKey(recordEmail) : null\n if (!recordEmail || !key) {\n res.status(byAddress ? 400 : 422).json({\n error: byAddress\n ? 'Enter the email address of the person to erase'\n : 'This record has no usable email address to erase by',\n })\n return\n }\n if (byAddress && !personErasureConfirmationMatches(body.confirmEmail, recordEmail)) {\n res.status(400).json({\n error: 'Type the email address a second time, exactly, to confirm the erasure',\n })\n return\n }\n if (!byAddress && !personErasureConfirmationMatches(body.email, recordEmail)) {\n res.status(400).json({\n error: 'Type the record’s email address exactly to confirm the erasure',\n })\n return\n }\n\n const requestId = personErasureId(orgId, key)\n const requestRef = firestore.collection(PERSON_ERASURES_COLLECTION).doc(requestId)\n const existing = await requestRef.get()\n if (existing.exists && existing.get('status') === 'pending') {\n const answer: ErasePersonResponse = {\n ok: true,\n requestId,\n pendingSinceMs: Number(existing.get('pendingSinceMs') ?? 0),\n alreadyPending: true,\n }\n res.status(200).json(answer)\n return\n }\n\n const now = Date.now()\n const request: PersonErasureRequest = {\n orgId,\n personKey: key,\n status: 'pending',\n email: recordEmail,\n requestedAtMs: now,\n requestedByUid: actor.uid,\n ...(hostId ? { hostId } : {}),\n ...(contactId ? { contactId } : {}),\n ...(leadId ? { leadId } : {}),\n pendingSinceMs: now,\n }\n await requestRef.set(\n {\n ...request,\n // A request re-filed after a failed or completed run starts clean.\n erasedAtMs: FieldValue.delete(),\n result: FieldValue.delete(),\n failedAtMs: FieldValue.delete(),\n lastError: FieldValue.delete(),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n const hostIds = await orgHostIds(firestore, orgId)\n /*\n * Suppression stays PER SITE — it is that site's sending list — while the\n * lead marker below is written once, because the lead is one org row now\n * (AGL-3275) rather than one row per site.\n */\n for (const siteId of hostIds) {\n await suppressEmailForHostErasure({ hostId: siteId, email: recordEmail }).catch(\n (error: unknown) => {\n console.error('[crm] erase-person suppression write failed', siteId, error)\n },\n )\n }\n /*\n * THE ORG ROW. One document for the person, since AGL-3276 emptied the\n * host path and AGL-3277 removed it — the per-site sweep this used to do\n * beside it had nothing left to find.\n */\n const eraseLeadAt = async (\n ref: FirebaseFirestore.DocumentReference,\n where: string,\n ) => {\n await ref\n .get()\n .then((snapshot) =>\n snapshot.exists\n ? ref.update({ [CONTACT_ERASURE_REQUESTED_FIELD]: now })\n : undefined,\n )\n .catch((error: unknown) => {\n console.error('[crm] erase-person lead marker failed', where, error)\n })\n }\n await eraseLeadAt(firestore.collection('orgs').doc(orgId).collection('leads').doc(key), orgId)\n try {\n const contacts = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('contacts')\n .where('email', '==', recordEmail)\n .get()\n for (const contact of contacts.docs) {\n await contact.ref.update({ [CONTACT_ERASURE_REQUESTED_FIELD]: now })\n }\n } catch (error) {\n console.error('[crm] erase-person contact marker failed', orgId, error)\n }\n\n // The feed the act was performed in: the site's under a site, the\n // org's at the organization level.\n const target = { type: contactId ? 'contact' : 'lead', id: contactId || leadId } as const\n if (routeScope.level === 'org') {\n // Filed by address, the line names no record — and never the address.\n await logOrgActivity(\n orgId,\n actor,\n 'Requested privacy erasure',\n byAddress ? { type: 'org' } : target,\n ).catch(() => undefined)\n } else {\n await logHostActivity(hostId, actor, 'Requested privacy erasure', target).catch(\n () => undefined,\n )\n }\n await firestore\n .collection('adminAudit')\n .add({\n actorUid: actor.uid,\n action: 'person.erasure-requested',\n target: `orgs/${orgId}/people/${key}`,\n before: null,\n after: {\n hostId: hostId || null,\n hosts: hostIds.length,\n from: byAddress ? 'address' : contactId ? 'contact' : 'lead',\n },\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n\n const answer: ErasePersonResponse = {\n ok: true,\n requestId,\n pendingSinceMs: now,\n alreadyPending: false,\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] erase-person failed', routeScope, error)\n res.status(500).json({ error: 'The erasure could not be filed' })\n }\n}\n"],"names":["canManageOrg","CONTACT_ERASURE_REQUESTED_FIELD","normalizeContactEmail","PERSON_ERASURES_COLLECTION","personErasureConfirmationMatches","personErasureId","personKey","firebaseAdmin","getOrgForHost","logHostActivity","logOrgActivity","orgDataCollectionForHost","readLeadForHost","suppressEmailForHostErasure","resolveOrgPermissions","FieldValue","authorizeOrgCaller","orgHostIds","readCrmRouteScope","CRM_ERASE_PERSON_ROUTE","crmErasePersonHandler","req","res","body","method","setHeader","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","routeScope","contactId","trim","leadId","byAddress","level","hostId","refusal","orgId","actor","caller","needs","ok","uid","email","decoded","app","auth","verifyIdToken","membership","role","resolved","firestore","recordEmail","contactsRef","collection","doc","contact","get","exists","lead","key","confirmEmail","requestId","requestRef","existing","answer","pendingSinceMs","Number","alreadyPending","now","Date","request","requestedAtMs","requestedByUid","set","erasedAtMs","delete","result","failedAtMs","lastError","updatedAt","serverTimestamp","merge","hostIds","siteId","catch","console","eraseLeadAt","ref","where","then","snapshot","update","contacts","docs","target","type","id","add","actorUid","action","before","after","hosts","from","at"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/erase-person.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 canManageOrg,\n CONTACT_ERASURE_REQUESTED_FIELD,\n normalizeContactEmail,\n PERSON_ERASURES_COLLECTION,\n personErasureConfirmationMatches,\n personErasureId,\n type PersonErasureRequest,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport {\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n logOrgActivity,\n orgDataCollectionForHost,\n readLeadForHost,\n suppressEmailForHostErasure,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from './org-caller'\nimport { addAdminAudit } from '@aglyn/tenant-data-admin/server/admin-audit-write'\n\n/** The route key, as `registerCrmConsoleApi` registers it. */\nexport const CRM_ERASE_PERSON_ROUTE = 'crm/erase-person'\n\nexport interface ErasePersonRequestBody {\n /**\n * The site the request is filed from. At the organization level\n * (AGL-2634) the record's own site, or absent for a contact no site has\n * captured; a LEAD always names one, because a lead lives under its site.\n */\n hostId?: string\n /** The organization, at the organization level (AGL-2634). */\n orgId?: string\n /**\n * At most one of these names the record the request is filed from. Under\n * a site exactly one does; at the organization level neither may, which\n * files the request by `email` alone (AGL-2839).\n */\n contactId?: string\n leadId?: string\n /**\n * The address as the admin typed it — the confirmation of a record's\n * address, or, filed by address alone, the person the request is about.\n */\n email: string\n /** Filed by address alone: the address typed a second time. */\n confirmEmail?: string\n}\n\nexport interface ErasePersonResponse {\n ok: true\n requestId: string\n /** When the request entered the queue. */\n pendingSinceMs: number\n /** True when a request for this person was already waiting; nothing was re-filed. */\n alreadyPending: boolean\n}\n\n/**\n * `POST /api/crm/erase-person` — file a privacy erasure for one person\n * (AGL-2623).\n *\n * ## Who may\n *\n * A workspace admin or owner — `canManageOrg` on the caller's org role —\n * and nobody else. Not a site editor with the data permission, who can\n * detach a contact from their own site but has no standing to remove the\n * person from every site in the workspace; and not staff acting alone,\n * because the workspace is the controller of this data and the instruction\n * has to come from it. Staff who are also admins of the workspace pass on\n * that role, as anyone would.\n *\n * ## What it does, and what it does not\n *\n * It files the request and closes the doors; it deletes nothing. The daily\n * erasure job executes the sweep, so that a workspace's person erasures and\n * its own erasure run through one job, one audit trail and one queue a\n * staff member can watch. What happens here, in order:\n *\n * 1. The record named by the body is read and its address is what the\n * request is about — the body's address must match it, typed, so an\n * admin who opened the wrong record is stopped by the confirmation\n * rather than by luck.\n * 2. The request document is written, or found already pending, in\n * which case nothing is re-filed and the caller is told so.\n * 3. Every site of the workspace gets its suppression row NOW, not when\n * the job runs: from this moment a capture cannot rebuild the person.\n * 4. The contact document and each site's lead are stamped with the\n * request time, so their pages can say \"erasure pending\" off the\n * document they already read.\n * 5. The site's activity feed and the platform audit log each get a row\n * that names the record by id and the person by hash, never by\n * address.\n *\n * ## The organization variant (AGL-2634)\n *\n * `{ orgId, hostId?, contactId | leadId, email }` from the org-level hub:\n * the same workspace admin, authorized by the org rather than through a\n * site they name, so a contact no site captured can be erased from the\n * page that lists it. The sweep is what it always was — every site of the\n * org, found by `orgId` — and the row goes to the org's feed. A lead still\n * names its site, because that is where a lead lives.\n *\n * ## By address alone (AGL-2839)\n *\n * `{ orgId, email, confirmEmail }` with no record, from the workspace's\n * Privacy settings: the same workspace admin, for any person the workspace\n * may hold — including one no page shows, since the CRM that lists records\n * is included from Starter and a Free workspace has none of it. Nothing is\n * read to file it: the sweep is keyed by the address, the markers are found\n * by it, and the typed address must match its second typing, which is the\n * confirmation a record's address gives the other variants. No plan is\n * asked on any variant: erasure is an obligation, not a CRM feature.\n */\nexport const crmErasePersonHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n const body: Partial<ErasePersonRequestBody> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const routeScope = readCrmRouteScope(body as Record<string, unknown>)\n const contactId = String(body.contactId ?? '').trim()\n const leadId = String(body.leadId ?? '').trim()\n const byAddress = routeScope?.level === 'org' && !contactId && !leadId\n if (!routeScope || (!byAddress && !contactId && !leadId) || (contactId && leadId)) {\n res.status(400).json({ error: 'Name the site and exactly one contact or lead' })\n return\n }\n const { hostId } = routeScope\n if (leadId && !hostId) {\n res.status(400).json({ error: 'Name the site the lead lives under' })\n return\n }\n const refusal = 'Only a workspace admin can erase a person from the workspace'\n\n try {\n let orgId: string\n let actor: { uid: string; email: string | null }\n if (routeScope.level === 'org') {\n const caller = await authorizeOrgCaller(req, routeScope.orgId, {\n needs: 'manage-org',\n refusal,\n })\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n orgId = caller.orgId\n actor = { uid: caller.uid, email: caller.email }\n } else {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (!canManageOrg(membership.role)) {\n res.status(403).json({ error: refusal })\n return\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved || resolved.orgId !== membership.orgId) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n orgId = resolved.orgId\n actor = { uid: decoded.uid, email: decoded.email ?? null }\n }\n const firestore = firebaseAdmin.app().firestore()\n\n let recordEmail: string | null = null\n if (byAddress) {\n recordEmail = normalizeContactEmail(body.email)\n } else if (contactId) {\n const contactsRef =\n routeScope.level === 'org'\n ? firestore.collection('orgs').doc(orgId).collection('contacts')\n : await orgDataCollectionForHost(hostId, 'contacts')\n const contact = await contactsRef.doc(contactId).get()\n if (!contact.exists) {\n res.status(404).json({ error: 'Unknown contact' })\n return\n }\n recordEmail = normalizeContactEmail(contact.get('email'))\n } else {\n const lead = await readLeadForHost(hostId, leadId)\n if (!lead) {\n res.status(404).json({ error: 'Unknown lead' })\n return\n }\n recordEmail = normalizeContactEmail(lead.get('email'))\n }\n const key = recordEmail ? personKey(recordEmail) : null\n if (!recordEmail || !key) {\n res.status(byAddress ? 400 : 422).json({\n error: byAddress\n ? 'Enter the email address of the person to erase'\n : 'This record has no usable email address to erase by',\n })\n return\n }\n if (byAddress && !personErasureConfirmationMatches(body.confirmEmail, recordEmail)) {\n res.status(400).json({\n error: 'Type the email address a second time, exactly, to confirm the erasure',\n })\n return\n }\n if (!byAddress && !personErasureConfirmationMatches(body.email, recordEmail)) {\n res.status(400).json({\n error: 'Type the record’s email address exactly to confirm the erasure',\n })\n return\n }\n\n const requestId = personErasureId(orgId, key)\n const requestRef = firestore.collection(PERSON_ERASURES_COLLECTION).doc(requestId)\n const existing = await requestRef.get()\n if (existing.exists && existing.get('status') === 'pending') {\n const answer: ErasePersonResponse = {\n ok: true,\n requestId,\n pendingSinceMs: Number(existing.get('pendingSinceMs') ?? 0),\n alreadyPending: true,\n }\n res.status(200).json(answer)\n return\n }\n\n const now = Date.now()\n const request: PersonErasureRequest = {\n orgId,\n personKey: key,\n status: 'pending',\n email: recordEmail,\n requestedAtMs: now,\n requestedByUid: actor.uid,\n ...(hostId ? { hostId } : {}),\n ...(contactId ? { contactId } : {}),\n ...(leadId ? { leadId } : {}),\n pendingSinceMs: now,\n }\n await requestRef.set(\n {\n ...request,\n // A request re-filed after a failed or completed run starts clean.\n erasedAtMs: FieldValue.delete(),\n result: FieldValue.delete(),\n failedAtMs: FieldValue.delete(),\n lastError: FieldValue.delete(),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n const hostIds = await orgHostIds(firestore, orgId)\n /*\n * Suppression stays PER SITE — it is that site's sending list — while the\n * lead marker below is written once, because the lead is one org row now\n * (AGL-3275) rather than one row per site.\n */\n for (const siteId of hostIds) {\n await suppressEmailForHostErasure({ hostId: siteId, email: recordEmail }).catch(\n (error: unknown) => {\n console.error('[crm] erase-person suppression write failed', siteId, error)\n },\n )\n }\n /*\n * THE ORG ROW. One document for the person, since AGL-3276 emptied the\n * host path and AGL-3277 removed it — the per-site sweep this used to do\n * beside it had nothing left to find.\n */\n const eraseLeadAt = async (\n ref: FirebaseFirestore.DocumentReference,\n where: string,\n ) => {\n await ref\n .get()\n .then((snapshot) =>\n snapshot.exists\n ? ref.update({ [CONTACT_ERASURE_REQUESTED_FIELD]: now })\n : undefined,\n )\n .catch((error: unknown) => {\n console.error('[crm] erase-person lead marker failed', where, error)\n })\n }\n await eraseLeadAt(firestore.collection('orgs').doc(orgId).collection('leads').doc(key), orgId)\n try {\n const contacts = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('contacts')\n .where('email', '==', recordEmail)\n .get()\n for (const contact of contacts.docs) {\n await contact.ref.update({ [CONTACT_ERASURE_REQUESTED_FIELD]: now })\n }\n } catch (error) {\n console.error('[crm] erase-person contact marker failed', orgId, error)\n }\n\n // The feed the act was performed in: the site's under a site, the\n // org's at the organization level.\n const target = { type: contactId ? 'contact' : 'lead', id: contactId || leadId } as const\n if (routeScope.level === 'org') {\n // Filed by address, the line names no record — and never the address.\n await logOrgActivity(\n orgId,\n actor,\n 'Requested privacy erasure',\n byAddress ? { type: 'org' } : target,\n ).catch(() => undefined)\n } else {\n await logHostActivity(hostId, actor, 'Requested privacy erasure', target).catch(\n () => undefined,\n )\n }\n await addAdminAudit(firestore, {\n actorUid: actor.uid,\n action: 'person.erasure-requested',\n target: `orgs/${orgId}/people/${key}`,\n before: null,\n after: {\n hostId: hostId || null,\n hosts: hostIds.length,\n from: byAddress ? 'address' : contactId ? 'contact' : 'lead',\n },\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n\n const answer: ErasePersonResponse = {\n ok: true,\n requestId,\n pendingSinceMs: now,\n alreadyPending: false,\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] erase-person failed', routeScope, error)\n res.status(500).json({ error: 'The erasure could not be filed' })\n }\n}\n"],"names":["canManageOrg","CONTACT_ERASURE_REQUESTED_FIELD","normalizeContactEmail","PERSON_ERASURES_COLLECTION","personErasureConfirmationMatches","personErasureId","personKey","firebaseAdmin","getOrgForHost","logHostActivity","logOrgActivity","orgDataCollectionForHost","readLeadForHost","suppressEmailForHostErasure","resolveOrgPermissions","FieldValue","authorizeOrgCaller","orgHostIds","readCrmRouteScope","addAdminAudit","CRM_ERASE_PERSON_ROUTE","crmErasePersonHandler","req","res","body","method","setHeader","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","routeScope","contactId","trim","leadId","byAddress","level","hostId","refusal","orgId","actor","caller","needs","ok","uid","email","decoded","app","auth","verifyIdToken","membership","role","resolved","firestore","recordEmail","contactsRef","collection","doc","contact","get","exists","lead","key","confirmEmail","requestId","requestRef","existing","answer","pendingSinceMs","Number","alreadyPending","now","Date","request","requestedAtMs","requestedByUid","set","erasedAtMs","delete","result","failedAtMs","lastError","updatedAt","serverTimestamp","merge","hostIds","siteId","catch","console","eraseLeadAt","ref","where","then","snapshot","update","contacts","docs","target","type","id","actorUid","action","before","after","hosts","from","at"],"mappings":"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"}
|
|
@@ -45,7 +45,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
45
45
|
* because a lead entered by hand is suite work: the capture doors that fill
|
|
46
46
|
* a Free workspace's leads do not come through here.
|
|
47
47
|
*/ import { checkVisitorRecordCeiling, CONTACT_FIELDS_MAX_PER_ORG, CRM_COLLECTIONS, LEADS_MAX_PER_HOST, normalizeContactEmail, normalizeCrmLeadProfile, personKey, readCrmCustomInput } from "@aglyn/aglyn/server";
|
|
48
|
-
import { addHostLead, firebaseAdmin, getOrgForHost, logHostActivity, orgLeadsForHost, readLeadForHost, scopedToHost } from "@aglyn/tenant-data-admin";
|
|
48
|
+
import { addHostLead, firebaseAdmin, getOrgForHost, logHostActivity, orgLeadsForHost, restampCrmListFieldsAt, readLeadForHost, scopedToHost } from "@aglyn/tenant-data-admin";
|
|
49
49
|
import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
|
|
50
50
|
import { normalizeCampaignIds, readCampaignIds } from "@aglyn/aglyn/app-utils/campaign-membership";
|
|
51
51
|
import { FieldValue } from "firebase-admin/firestore";
|
|
@@ -288,6 +288,9 @@ export const leadCreateHandler = async (req, res)=>{
|
|
|
288
288
|
}), {
|
|
289
289
|
merge: true
|
|
290
290
|
});
|
|
291
|
+
// The profile, tags and lead source are what the Leads list searches
|
|
292
|
+
// and filters by (AGL-3321): restamped from the merged lead.
|
|
293
|
+
await restampCrmListFieldsAt(leadsRef.doc(leadId), 'leads');
|
|
291
294
|
}
|
|
292
295
|
/*
|
|
293
296
|
* The filing on the lead's Activity (AGL-3274): one "Filed under" per
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-create.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/leads-create` — a lead typed in by hand (AGL-3231).\n *\n * Salesforce's New Lead: a person the team has heard of and has not yet\n * qualified, entered with what is known about them — the address, the\n * name, the company as text, a title, a phone, a website, an address, tags,\n * where they came from — and nothing else brought into being. No contact,\n * no company record: those are the conversion's to make, once the lead is\n * real. Until then the lead is the whole record, which is what lets the\n * Leads section be worked without a second row for every person in it.\n *\n * ## Through the one door\n *\n * `addHostLead` is the single writer of `hosts/{hostId}/leads` — the\n * capture doors, the import and the REST create all reach it — and it is\n * the writer here. That is what keys the lead on the person, so a second\n * \"new lead\" for one address updates the first rather than filing twice;\n * counts it against the platform ceiling inside the transaction that\n * writes; and records NO marketing basis, because a person typed in by the\n * team is not a person who ticked a box. The profile and the working state\n * are the CRM's own annotations and land in one merge write after the\n * door's, exactly as the import writes them.\n *\n * ## Who may call it\n *\n * The same two gates the convert route asks: `data.manage` in the org, and\n * a role on THIS site — a lead is the site's record, so an org member scoped\n * to a sibling site holds the key but not the host. Then the CRM suite gate,\n * because a lead entered by hand is suite work: the capture doors that fill\n * a Free workspace's leads do not come through here.\n */\n\nimport {\n checkVisitorRecordCeiling,\n CONTACT_FIELDS_MAX_PER_ORG,\n type ContactFieldDefinition,\n CRM_COLLECTIONS,\n type CrmCustomValue,\n type CrmLeadProfilePatch,\n type CrmLeadStatus,\n LEADS_MAX_PER_HOST,\n normalizeContactEmail,\n normalizeCrmLeadProfile,\n personKey,\n type PluginApiHandler,\n readCrmCustomInput,\n} from '@aglyn/aglyn/server'\nimport {\n addHostLead,\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n orgLeadsForHost,\n readLeadForHost,\n scopedToHost,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { normalizeCampaignIds, readCampaignIds } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport type { CampaignFilingRef } from '../model/campaign-filing-activity'\nimport { fileCampaignFilingActivities } from './campaign-filing-activity'\nimport { readLeadSourcePicklist, resolveLeadSourceWrite } from './lead-source-picklist'\nimport { holdsDataManage } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/** The statuses a lead may be entered in — everything but the converted state. */\nconst CREATE_STATUSES: readonly CrmLeadStatus[] = ['new', 'working']\n\nconst NAME_MAX = 120\nconst NOTES_MAX = 4000\n\n/** The surface a hand-entered lead names, beside `signup`, `booking`, `form:{id}` and `import`. */\nexport const LEAD_MANUAL_SOURCE = 'manual'\n\n/** What the New lead drawer posts. */\nexport interface LeadCreateRequest {\n hostId: string\n email: string\n name?: string\n company?: string\n jobTitle?: string\n phone?: string\n website?: string\n address?: Record<string, unknown> | null\n tags?: string[]\n leadSource?: string\n status?: CrmLeadStatus\n ownerUid?: string\n notes?: string\n /** The org's campaigns to file the lead under (AGL-3254), by container id. */\n campaignIds?: string[]\n /**\n * The org's custom LEAD fields (AGL-3272), keyed by each definition's\n * `key`. Judged against the definitions whose `object` is `lead`, so a\n * contact field of the same name cannot be written through this door.\n */\n custom?: Record<string, CrmCustomValue>\n}\n\n/** The sentence a campaign that is not the org's live one is refused with, under the field. */\nexport const LEAD_CAMPAIGN_REFUSAL =\n \"One of the campaigns picked doesn't exist any more. Pick it again.\"\n\n/**\n * The campaigns a request may file a lead under: the organization's own\n * live containers (`orgs/{orgId}/emailCampaigns`), and nothing else. A\n * request can claim any id, and a lead filed under another org's campaign\n * — or one the console soft-deleted — would sit on a page nobody in this\n * org can open. A lead is an org record, so any live campaign of the org\n * qualifies, whichever sites it is placed on. Answered with the names as\n * they stand, for the timeline entries the filing writes (AGL-3274).\n *\n * @returns the clean ids with their names, or `null` when one is not the org's.\n */\nexport async function orgCampaigns(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n raw: unknown,\n): Promise<CampaignFilingRef[] | null> {\n const ids = normalizeCampaignIds(raw)\n if (!ids.length) return []\n if (!orgId || ids.some((id) => id.includes('/'))) return null\n const containers = firestore.collection('orgs').doc(orgId).collection('emailCampaigns')\n const found = await firestore.getAll(...ids.map((id) => containers.doc(id)))\n const live = found.every((snapshot) => snapshot.exists && !snapshot.get('deletedAt'))\n return live\n ? ids.map((id, index) => ({ id, name: String(found[index]?.get('name') ?? '').trim() || id }))\n : null\n}\n\n/** The ids alone — see {@link orgCampaigns}. */\nexport async function orgCampaignIds(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n raw: unknown,\n): Promise<string[] | null> {\n const campaigns = await orgCampaigns(firestore, orgId, raw)\n return campaigns ? campaigns.map((campaign) => campaign.id) : null\n}\n\n/** What the route answers on success. */\nexport interface LeadCreateResponse {\n leadId: string\n /** False when the site already held a lead for the address — it was updated. */\n created: boolean\n}\n\n/** The lead's own fields as one write, `null` in the patch meaning a clear. */\nexport function leadProfileWrite(patch: CrmLeadProfilePatch): Record<string, unknown> {\n const write: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(patch)) {\n if (value === undefined) continue\n write[key] = value === null ? FieldValue.delete() : value\n }\n return write\n}\n\nexport const leadCreateHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n const body: Partial<LeadCreateRequest> & Record<string, unknown> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n .trim()\n .slice(0, 128)\n if (!hostId) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const email = normalizeContactEmail(body.email)\n if (!email) {\n res.status(400).json({ error: 'Enter a valid email address.' })\n return\n }\n /*\n * The profile, refused BEFORE any read: a phone or a website the record\n * cannot hold is answered under its field, the sentence the drawer shows\n * beneath the input, and nothing was written to retry against.\n */\n const { patch, errors } = normalizeCrmLeadProfile(body)\n const [firstError] = Object.entries(errors)\n if (firstError) {\n res.status(400).json({ error: firstError[1], field: firstError[0] })\n return\n }\n const rawStatus = String(body.status ?? '').trim()\n if (rawStatus && !CREATE_STATUSES.includes(rawStatus as CrmLeadStatus)) {\n res.status(400).json({ error: 'A new lead is New or Working.' })\n return\n }\n const name = String(body.name ?? '')\n .trim()\n .replace(/\\s+/g, ' ')\n .slice(0, NAME_MAX)\n const ownerUid = String(body.ownerUid ?? '')\n .trim()\n .slice(0, 128)\n const notes = String(body.notes ?? '')\n .trim()\n .slice(0, NOTES_MAX)\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (\n !staff &&\n !(membership.hostRole && (await holdsDataManage(membership.orgId, decoded.uid)))\n ) {\n res\n .status(403)\n .json({ error: 'Adding a lead requires the data permission on this site' })\n return\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const suite = crmSuiteRefusal(resolved.org, 'Adding a lead by hand')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const leadsRef = await orgLeadsForHost(hostId)\n /*\n * The org's own lead fields (AGL-3272), judged before any write and\n * only when the body carried a map — a body without `custom` pays for\n * no definition read. The refusal is the definition reader's own\n * sentence, so a client learns which key it spelled wrong rather than\n * that the lead could not be saved.\n */\n let custom: Record<string, CrmCustomValue> | undefined\n if (body.custom !== undefined) {\n const definitions = await firestore\n .collection('orgs')\n .doc(resolved.orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(CONTACT_FIELDS_MAX_PER_ORG)\n .get()\n const judged = readCrmCustomInput(\n body.custom,\n definitions.docs.map((entry) => entry.data() as ContactFieldDefinition),\n 'lead',\n )\n if ('errors' in judged) {\n const [field, message] = Object.entries(judged.errors)[0] ?? []\n res.status(400).json({\n error: message ?? 'A custom value could not be saved.',\n ...(field ? { field } : {}),\n })\n return\n }\n custom = judged.values\n }\n // The campaigns, judged before any write, so a refused pick leaves\n // nothing to retry against (AGL-3254).\n const campaigns = await orgCampaigns(firestore, resolved.orgId, body.campaignIds)\n if (campaigns === null) {\n res.status(400).json({ error: LEAD_CAMPAIGN_REFUSAL, field: 'campaignIds' })\n return\n }\n const campaignIds = campaigns.map((campaign) => campaign.id)\n // Non-null by construction: the normalizer refused every address this\n // derivation could not key.\n const leadId = personKey(email) as string\n const before = await readLeadForHost(hostId, leadId)\n const created = !before\n /*\n * THE ORG'S LEAD SOURCES (AGL-3298), judged before any write: a value\n * outside the list is refused under the field naming what the list\n * allows, a lead the site already held keeps the value it has, and a\n * new lead that named none starts from the list's default.\n */\n const leadSource = resolveLeadSourceWrite(\n await readLeadSourcePicklist(firestore, resolved.orgId),\n patch.leadSource,\n { current: before?.get('leadSource'), created },\n )\n if (leadSource.ok === false) {\n res.status(400).json({ error: leadSource.error, field: 'leadSource' })\n return\n }\n if (leadSource.write === undefined) delete patch.leadSource\n else patch.leadSource = leadSource.write\n /*\n * The platform ceiling, judged here so the drawer can say WHY rather\n * than only that the lead could not be saved; the door re-judges it\n * inside its own transaction, so a race is still refused there.\n */\n if (created) {\n // What THIS site may see, not the whole org's collection (AGL-3275) —\n // the same narrowing `addHostLead` re-applies inside its transaction.\n const used = (await scopedToHost(leadsRef, hostId).count().get()).data().count\n if (checkVisitorRecordCeiling(used, LEADS_MAX_PER_HOST).exceeded) {\n res.status(409).json({\n error:\n 'This site is at the platform lead limit, so the lead was not ' +\n 'added. Remove some leads, or contact support if this is real ' +\n 'traffic.',\n reason: 'lead-ceiling',\n })\n return\n }\n }\n const stored = await addHostLead({\n hostRef,\n hostId,\n lead: {\n email,\n ...(name ? { name } : {}),\n source: LEAD_MANUAL_SOURCE,\n },\n })\n if (!stored) {\n res.status(500).json({ error: 'The lead could not be saved.' })\n return\n }\n const working: Record<string, unknown> = {\n ...leadProfileWrite(patch),\n ...(rawStatus ? { status: rawStatus } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(notes ? { notes } : {}),\n // Added to what a lead the site already held carries, never in place\n // of it: a second \"new lead\" for an address files it under one more\n // campaign, as the bulk bar would.\n ...(campaignIds.length ? { campaignIds: FieldValue.arrayUnion(...campaignIds) } : {}),\n /*\n * The map as a map, because the write below is a `set` with `merge`\n * and a merge is a DEEP one: the keys this body named land, and a\n * second \"new lead\" for an address the site already holds leaves\n * every other key's value where it was. A dotted path would not do\n * it — only `update` reads dots as paths, and `set` would file a\n * field literally called `custom.tier`.\n */\n ...(custom && Object.keys(custom).length ? { custom } : {}),\n }\n if (Object.keys(working).length) {\n await leadsRef\n .doc(leadId)\n .set({ ...working, updatedAt: FieldValue.serverTimestamp() }, { merge: true })\n }\n /*\n * The filing on the lead's Activity (AGL-3274): one \"Filed under\" per\n * campaign the lead was not already in, by the member who added it.\n * After the write and never before, and never failing it — the\n * membership is on the document; the entry records the act.\n */\n const already = readCampaignIds(before ? (before.data() as Record<string, unknown>) : null)\n await fileCampaignFilingActivities(firestore, {\n orgId: resolved.orgId,\n org: resolved.org as Record<string, unknown>,\n hostId,\n link: { leadId },\n action: 'filed',\n campaigns: campaigns.filter((campaign) => !already.includes(campaign.id)),\n atMs: Date.now(),\n byUid: decoded.uid,\n byName: String(decoded['name'] ?? decoded.email ?? '').trim() || null,\n })\n /*\n * The audit line, written by the route that verified the caller and\n * performed the write — the one writer that cannot record an add that\n * did not happen. An update of a lead the site already held says so.\n */\n await logHostActivity(\n hostId,\n { uid: decoded.uid, email: decoded.email ?? null },\n created ? 'Added lead' : 'Updated lead',\n { type: 'lead', id: leadId, name: name || email },\n )\n const answer: LeadCreateResponse = { leadId, created }\n res.status(created ? 201 : 200).json(answer)\n } catch (error) {\n console.error('leads-create failed', error)\n res.status(500).json({ error: 'The lead could not be saved.' })\n }\n}\n\nexport default leadCreateHandler\n"],"names":["checkVisitorRecordCeiling","CONTACT_FIELDS_MAX_PER_ORG","CRM_COLLECTIONS","LEADS_MAX_PER_HOST","normalizeContactEmail","normalizeCrmLeadProfile","personKey","readCrmCustomInput","addHostLead","firebaseAdmin","getOrgForHost","logHostActivity","orgLeadsForHost","readLeadForHost","scopedToHost","resolveOrgPermissions","normalizeCampaignIds","readCampaignIds","FieldValue","fileCampaignFilingActivities","readLeadSourcePicklist","resolveLeadSourceWrite","holdsDataManage","crmSuiteRefusal","CREATE_STATUSES","NAME_MAX","NOTES_MAX","LEAD_MANUAL_SOURCE","LEAD_CAMPAIGN_REFUSAL","orgCampaigns","firestore","orgId","raw","ids","length","some","id","includes","containers","collection","doc","found","getAll","map","live","every","snapshot","exists","get","index","name","String","trim","orgCampaignIds","campaigns","campaign","leadProfileWrite","patch","write","key","value","Object","entries","undefined","delete","leadCreateHandler","req","res","body","method","setHeader","status","json","error","authorization","headers","idToken","startsWith","slice","JSON","parse","hostId","email","errors","firstError","field","rawStatus","replace","ownerUid","notes","decoded","app","auth","verifyIdToken","staff","membership","uid","hostRole","resolved","suite","org","hostRef","leadsRef","custom","definitions","contactFields","limit","judged","docs","entry","data","message","values","campaignIds","leadId","before","created","leadSource","current","ok","used","count","exceeded","reason","stored","lead","source","working","arrayUnion","keys","set","updatedAt","serverTimestamp","merge","already","link","action","filter","atMs","Date","now","byUid","byName","type","answer","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-create.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/leads-create` — a lead typed in by hand (AGL-3231).\n *\n * Salesforce's New Lead: a person the team has heard of and has not yet\n * qualified, entered with what is known about them — the address, the\n * name, the company as text, a title, a phone, a website, an address, tags,\n * where they came from — and nothing else brought into being. No contact,\n * no company record: those are the conversion's to make, once the lead is\n * real. Until then the lead is the whole record, which is what lets the\n * Leads section be worked without a second row for every person in it.\n *\n * ## Through the one door\n *\n * `addHostLead` is the single writer of `hosts/{hostId}/leads` — the\n * capture doors, the import and the REST create all reach it — and it is\n * the writer here. That is what keys the lead on the person, so a second\n * \"new lead\" for one address updates the first rather than filing twice;\n * counts it against the platform ceiling inside the transaction that\n * writes; and records NO marketing basis, because a person typed in by the\n * team is not a person who ticked a box. The profile and the working state\n * are the CRM's own annotations and land in one merge write after the\n * door's, exactly as the import writes them.\n *\n * ## Who may call it\n *\n * The same two gates the convert route asks: `data.manage` in the org, and\n * a role on THIS site — a lead is the site's record, so an org member scoped\n * to a sibling site holds the key but not the host. Then the CRM suite gate,\n * because a lead entered by hand is suite work: the capture doors that fill\n * a Free workspace's leads do not come through here.\n */\n\nimport {\n checkVisitorRecordCeiling,\n CONTACT_FIELDS_MAX_PER_ORG,\n type ContactFieldDefinition,\n CRM_COLLECTIONS,\n type CrmCustomValue,\n type CrmLeadProfilePatch,\n type CrmLeadStatus,\n LEADS_MAX_PER_HOST,\n normalizeContactEmail,\n normalizeCrmLeadProfile,\n personKey,\n type PluginApiHandler,\n readCrmCustomInput,\n} from '@aglyn/aglyn/server'\nimport {\n addHostLead,\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n orgLeadsForHost,\n restampCrmListFieldsAt,\n readLeadForHost,\n scopedToHost,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { normalizeCampaignIds, readCampaignIds } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport type { CampaignFilingRef } from '../model/campaign-filing-activity'\nimport { fileCampaignFilingActivities } from './campaign-filing-activity'\nimport { readLeadSourcePicklist, resolveLeadSourceWrite } from './lead-source-picklist'\nimport { holdsDataManage } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/** The statuses a lead may be entered in — everything but the converted state. */\nconst CREATE_STATUSES: readonly CrmLeadStatus[] = ['new', 'working']\n\nconst NAME_MAX = 120\nconst NOTES_MAX = 4000\n\n/** The surface a hand-entered lead names, beside `signup`, `booking`, `form:{id}` and `import`. */\nexport const LEAD_MANUAL_SOURCE = 'manual'\n\n/** What the New lead drawer posts. */\nexport interface LeadCreateRequest {\n hostId: string\n email: string\n name?: string\n company?: string\n jobTitle?: string\n phone?: string\n website?: string\n address?: Record<string, unknown> | null\n tags?: string[]\n leadSource?: string\n status?: CrmLeadStatus\n ownerUid?: string\n notes?: string\n /** The org's campaigns to file the lead under (AGL-3254), by container id. */\n campaignIds?: string[]\n /**\n * The org's custom LEAD fields (AGL-3272), keyed by each definition's\n * `key`. Judged against the definitions whose `object` is `lead`, so a\n * contact field of the same name cannot be written through this door.\n */\n custom?: Record<string, CrmCustomValue>\n}\n\n/** The sentence a campaign that is not the org's live one is refused with, under the field. */\nexport const LEAD_CAMPAIGN_REFUSAL =\n \"One of the campaigns picked doesn't exist any more. Pick it again.\"\n\n/**\n * The campaigns a request may file a lead under: the organization's own\n * live containers (`orgs/{orgId}/emailCampaigns`), and nothing else. A\n * request can claim any id, and a lead filed under another org's campaign\n * — or one the console soft-deleted — would sit on a page nobody in this\n * org can open. A lead is an org record, so any live campaign of the org\n * qualifies, whichever sites it is placed on. Answered with the names as\n * they stand, for the timeline entries the filing writes (AGL-3274).\n *\n * @returns the clean ids with their names, or `null` when one is not the org's.\n */\nexport async function orgCampaigns(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n raw: unknown,\n): Promise<CampaignFilingRef[] | null> {\n const ids = normalizeCampaignIds(raw)\n if (!ids.length) return []\n if (!orgId || ids.some((id) => id.includes('/'))) return null\n const containers = firestore.collection('orgs').doc(orgId).collection('emailCampaigns')\n const found = await firestore.getAll(...ids.map((id) => containers.doc(id)))\n const live = found.every((snapshot) => snapshot.exists && !snapshot.get('deletedAt'))\n return live\n ? ids.map((id, index) => ({ id, name: String(found[index]?.get('name') ?? '').trim() || id }))\n : null\n}\n\n/** The ids alone — see {@link orgCampaigns}. */\nexport async function orgCampaignIds(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n raw: unknown,\n): Promise<string[] | null> {\n const campaigns = await orgCampaigns(firestore, orgId, raw)\n return campaigns ? campaigns.map((campaign) => campaign.id) : null\n}\n\n/** What the route answers on success. */\nexport interface LeadCreateResponse {\n leadId: string\n /** False when the site already held a lead for the address — it was updated. */\n created: boolean\n}\n\n/** The lead's own fields as one write, `null` in the patch meaning a clear. */\nexport function leadProfileWrite(patch: CrmLeadProfilePatch): Record<string, unknown> {\n const write: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(patch)) {\n if (value === undefined) continue\n write[key] = value === null ? FieldValue.delete() : value\n }\n return write\n}\n\nexport const leadCreateHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n const body: Partial<LeadCreateRequest> & Record<string, unknown> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n .trim()\n .slice(0, 128)\n if (!hostId) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const email = normalizeContactEmail(body.email)\n if (!email) {\n res.status(400).json({ error: 'Enter a valid email address.' })\n return\n }\n /*\n * The profile, refused BEFORE any read: a phone or a website the record\n * cannot hold is answered under its field, the sentence the drawer shows\n * beneath the input, and nothing was written to retry against.\n */\n const { patch, errors } = normalizeCrmLeadProfile(body)\n const [firstError] = Object.entries(errors)\n if (firstError) {\n res.status(400).json({ error: firstError[1], field: firstError[0] })\n return\n }\n const rawStatus = String(body.status ?? '').trim()\n if (rawStatus && !CREATE_STATUSES.includes(rawStatus as CrmLeadStatus)) {\n res.status(400).json({ error: 'A new lead is New or Working.' })\n return\n }\n const name = String(body.name ?? '')\n .trim()\n .replace(/\\s+/g, ' ')\n .slice(0, NAME_MAX)\n const ownerUid = String(body.ownerUid ?? '')\n .trim()\n .slice(0, 128)\n const notes = String(body.notes ?? '')\n .trim()\n .slice(0, NOTES_MAX)\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (\n !staff &&\n !(membership.hostRole && (await holdsDataManage(membership.orgId, decoded.uid)))\n ) {\n res\n .status(403)\n .json({ error: 'Adding a lead requires the data permission on this site' })\n return\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const suite = crmSuiteRefusal(resolved.org, 'Adding a lead by hand')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const leadsRef = await orgLeadsForHost(hostId)\n /*\n * The org's own lead fields (AGL-3272), judged before any write and\n * only when the body carried a map — a body without `custom` pays for\n * no definition read. The refusal is the definition reader's own\n * sentence, so a client learns which key it spelled wrong rather than\n * that the lead could not be saved.\n */\n let custom: Record<string, CrmCustomValue> | undefined\n if (body.custom !== undefined) {\n const definitions = await firestore\n .collection('orgs')\n .doc(resolved.orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(CONTACT_FIELDS_MAX_PER_ORG)\n .get()\n const judged = readCrmCustomInput(\n body.custom,\n definitions.docs.map((entry) => entry.data() as ContactFieldDefinition),\n 'lead',\n )\n if ('errors' in judged) {\n const [field, message] = Object.entries(judged.errors)[0] ?? []\n res.status(400).json({\n error: message ?? 'A custom value could not be saved.',\n ...(field ? { field } : {}),\n })\n return\n }\n custom = judged.values\n }\n // The campaigns, judged before any write, so a refused pick leaves\n // nothing to retry against (AGL-3254).\n const campaigns = await orgCampaigns(firestore, resolved.orgId, body.campaignIds)\n if (campaigns === null) {\n res.status(400).json({ error: LEAD_CAMPAIGN_REFUSAL, field: 'campaignIds' })\n return\n }\n const campaignIds = campaigns.map((campaign) => campaign.id)\n // Non-null by construction: the normalizer refused every address this\n // derivation could not key.\n const leadId = personKey(email) as string\n const before = await readLeadForHost(hostId, leadId)\n const created = !before\n /*\n * THE ORG'S LEAD SOURCES (AGL-3298), judged before any write: a value\n * outside the list is refused under the field naming what the list\n * allows, a lead the site already held keeps the value it has, and a\n * new lead that named none starts from the list's default.\n */\n const leadSource = resolveLeadSourceWrite(\n await readLeadSourcePicklist(firestore, resolved.orgId),\n patch.leadSource,\n { current: before?.get('leadSource'), created },\n )\n if (leadSource.ok === false) {\n res.status(400).json({ error: leadSource.error, field: 'leadSource' })\n return\n }\n if (leadSource.write === undefined) delete patch.leadSource\n else patch.leadSource = leadSource.write\n /*\n * The platform ceiling, judged here so the drawer can say WHY rather\n * than only that the lead could not be saved; the door re-judges it\n * inside its own transaction, so a race is still refused there.\n */\n if (created) {\n // What THIS site may see, not the whole org's collection (AGL-3275) —\n // the same narrowing `addHostLead` re-applies inside its transaction.\n const used = (await scopedToHost(leadsRef, hostId).count().get()).data().count\n if (checkVisitorRecordCeiling(used, LEADS_MAX_PER_HOST).exceeded) {\n res.status(409).json({\n error:\n 'This site is at the platform lead limit, so the lead was not ' +\n 'added. Remove some leads, or contact support if this is real ' +\n 'traffic.',\n reason: 'lead-ceiling',\n })\n return\n }\n }\n const stored = await addHostLead({\n hostRef,\n hostId,\n lead: {\n email,\n ...(name ? { name } : {}),\n source: LEAD_MANUAL_SOURCE,\n },\n })\n if (!stored) {\n res.status(500).json({ error: 'The lead could not be saved.' })\n return\n }\n const working: Record<string, unknown> = {\n ...leadProfileWrite(patch),\n ...(rawStatus ? { status: rawStatus } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(notes ? { notes } : {}),\n // Added to what a lead the site already held carries, never in place\n // of it: a second \"new lead\" for an address files it under one more\n // campaign, as the bulk bar would.\n ...(campaignIds.length ? { campaignIds: FieldValue.arrayUnion(...campaignIds) } : {}),\n /*\n * The map as a map, because the write below is a `set` with `merge`\n * and a merge is a DEEP one: the keys this body named land, and a\n * second \"new lead\" for an address the site already holds leaves\n * every other key's value where it was. A dotted path would not do\n * it — only `update` reads dots as paths, and `set` would file a\n * field literally called `custom.tier`.\n */\n ...(custom && Object.keys(custom).length ? { custom } : {}),\n }\n if (Object.keys(working).length) {\n await leadsRef\n .doc(leadId)\n .set({ ...working, updatedAt: FieldValue.serverTimestamp() }, { merge: true })\n // The profile, tags and lead source are what the Leads list searches\n // and filters by (AGL-3321): restamped from the merged lead.\n await restampCrmListFieldsAt(leadsRef.doc(leadId), 'leads')\n }\n /*\n * The filing on the lead's Activity (AGL-3274): one \"Filed under\" per\n * campaign the lead was not already in, by the member who added it.\n * After the write and never before, and never failing it — the\n * membership is on the document; the entry records the act.\n */\n const already = readCampaignIds(before ? (before.data() as Record<string, unknown>) : null)\n await fileCampaignFilingActivities(firestore, {\n orgId: resolved.orgId,\n org: resolved.org as Record<string, unknown>,\n hostId,\n link: { leadId },\n action: 'filed',\n campaigns: campaigns.filter((campaign) => !already.includes(campaign.id)),\n atMs: Date.now(),\n byUid: decoded.uid,\n byName: String(decoded['name'] ?? decoded.email ?? '').trim() || null,\n })\n /*\n * The audit line, written by the route that verified the caller and\n * performed the write — the one writer that cannot record an add that\n * did not happen. An update of a lead the site already held says so.\n */\n await logHostActivity(\n hostId,\n { uid: decoded.uid, email: decoded.email ?? null },\n created ? 'Added lead' : 'Updated lead',\n { type: 'lead', id: leadId, name: name || email },\n )\n const answer: LeadCreateResponse = { leadId, created }\n res.status(created ? 201 : 200).json(answer)\n } catch (error) {\n console.error('leads-create failed', error)\n res.status(500).json({ error: 'The lead could not be saved.' })\n }\n}\n\nexport default leadCreateHandler\n"],"names":["checkVisitorRecordCeiling","CONTACT_FIELDS_MAX_PER_ORG","CRM_COLLECTIONS","LEADS_MAX_PER_HOST","normalizeContactEmail","normalizeCrmLeadProfile","personKey","readCrmCustomInput","addHostLead","firebaseAdmin","getOrgForHost","logHostActivity","orgLeadsForHost","restampCrmListFieldsAt","readLeadForHost","scopedToHost","resolveOrgPermissions","normalizeCampaignIds","readCampaignIds","FieldValue","fileCampaignFilingActivities","readLeadSourcePicklist","resolveLeadSourceWrite","holdsDataManage","crmSuiteRefusal","CREATE_STATUSES","NAME_MAX","NOTES_MAX","LEAD_MANUAL_SOURCE","LEAD_CAMPAIGN_REFUSAL","orgCampaigns","firestore","orgId","raw","ids","length","some","id","includes","containers","collection","doc","found","getAll","map","live","every","snapshot","exists","get","index","name","String","trim","orgCampaignIds","campaigns","campaign","leadProfileWrite","patch","write","key","value","Object","entries","undefined","delete","leadCreateHandler","req","res","body","method","setHeader","status","json","error","authorization","headers","idToken","startsWith","slice","JSON","parse","hostId","email","errors","firstError","field","rawStatus","replace","ownerUid","notes","decoded","app","auth","verifyIdToken","staff","membership","uid","hostRole","resolved","suite","org","hostRef","leadsRef","custom","definitions","contactFields","limit","judged","docs","entry","data","message","values","campaignIds","leadId","before","created","leadSource","current","ok","used","count","exceeded","reason","stored","lead","source","working","arrayUnion","keys","set","updatedAt","serverTimestamp","merge","already","link","action","filter","atMs","Date","now","byUid","byName","type","answer","console"],"mappings":"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"}
|
|
@@ -14,7 +14,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
14
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
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";
|
|
17
|
+
*/ import { consentGroupForHost, CONTACT_FACETS_FIELD, CRM_COLLECTIONS, CRM_LEAD_SOURCE_KEY_FIELD, CRM_LEAD_SOURCE_PICKLIST, crmLeadSourceKey, effectiveCrmLeadSourcePicklist, newResourceScopeFields, normalizeCrmPicklist, ORG_SCOPE_TOKEN } from "@aglyn/aglyn/server";
|
|
18
18
|
import { firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
19
19
|
import { FieldPath, FieldValue } from "firebase-admin/firestore";
|
|
20
20
|
import { deletePicklistValue, renamePicklistValue } from "../model/lead-source-values.js";
|
|
@@ -24,19 +24,20 @@ import { authorizeCrmWriter } from "./task-routes.js";
|
|
|
24
24
|
/** Writes per batch — under Firestore's 500, with room to spare. */ const BATCH_SIZE = 400;
|
|
25
25
|
/**
|
|
26
26
|
* Set `field` to `to` (or remove it, for `null`) on every document `query`
|
|
27
|
-
* finds holding `from`, a batch at a time
|
|
27
|
+
* finds holding `from`, a batch at a time, with `beside` written alongside.
|
|
28
|
+
* Answers how many were written.
|
|
28
29
|
*
|
|
29
30
|
* The query is re-run after each batch rather than paged, because each
|
|
30
31
|
* batch moves its documents OUT of the query's answer — the next run finds
|
|
31
32
|
* only what is left, and a run that finds nothing is the end.
|
|
32
|
-
*/ async function replaceEverywhere(firestore, query, field, to) {
|
|
33
|
+
*/ async function replaceEverywhere(firestore, query, field, to, beside = {}) {
|
|
33
34
|
let written = 0;
|
|
34
35
|
for(;;){
|
|
35
36
|
const page = await query.limit(BATCH_SIZE).get();
|
|
36
37
|
if (page.empty) return written;
|
|
37
38
|
const batch = firestore.batch();
|
|
38
39
|
for (const snapshot of page.docs){
|
|
39
|
-
batch.update(snapshot.ref, field, to != null ? to : FieldValue.delete(), 'updatedAt', FieldValue.serverTimestamp());
|
|
40
|
+
batch.update(snapshot.ref, field, to != null ? to : FieldValue.delete(), 'updatedAt', FieldValue.serverTimestamp(), ...Object.entries(beside).flat());
|
|
40
41
|
}
|
|
41
42
|
await batch.commit();
|
|
42
43
|
written += page.size;
|
|
@@ -160,7 +161,10 @@ import { authorizeCrmWriter } from "./task-routes.js";
|
|
|
160
161
|
*/ let leads = 0;
|
|
161
162
|
let contacts = 0;
|
|
162
163
|
if (moved.from !== moved.to) {
|
|
163
|
-
leads = await replaceEverywhere(firestore, orgRef.collection('leads').where('leadSource', '==', moved.from), 'leadSource', moved.to)
|
|
164
|
+
leads = await replaceEverywhere(firestore, orgRef.collection('leads').where('leadSource', '==', moved.from), 'leadSource', moved.to, // The key the Leads list filters by moves with the label (AGL-3321).
|
|
165
|
+
{
|
|
166
|
+
[CRM_LEAD_SOURCE_KEY_FIELD]: crmLeadSourceKey(moved.to)
|
|
167
|
+
});
|
|
164
168
|
const hostIds = await orgHostIds(firestore, writer.orgId);
|
|
165
169
|
const groupIds = [
|
|
166
170
|
...new Set(hostIds.map((hostId)=>consentGroupForHost(writer.org, hostId).groupId))
|