@aglyn/plugins-crm 1.0.0-beta.229 → 1.0.0-beta.231

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/package.json +14 -13
  2. package/src/lib/components/crm-next-activity-column.d.ts +1 -13
  3. package/src/lib/components/crm-next-activity-column.js +1 -19
  4. package/src/lib/components/crm-next-activity-column.js.map +1 -1
  5. package/src/lib/components/lead-status-options.d.ts +2 -14
  6. package/src/lib/components/lead-status-options.js +1 -13
  7. package/src/lib/components/lead-status-options.js.map +1 -1
  8. package/src/lib/components/lead-unqualify-dialog.d.ts +1 -2
  9. package/src/lib/components/lead-unqualify-dialog.js +2 -1
  10. package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
  11. package/src/lib/components/settings-section.d.ts +18 -0
  12. package/src/lib/components/settings-section.js +97 -1
  13. package/src/lib/components/settings-section.js.map +1 -1
  14. package/src/lib/constants/company-filters.d.ts +2 -2
  15. package/src/lib/constants/company-filters.js +1 -1
  16. package/src/lib/constants/company-filters.js.map +1 -1
  17. package/src/lib/constants/contact-filters.d.ts +3 -3
  18. package/src/lib/constants/contact-filters.js +2 -1
  19. package/src/lib/constants/contact-filters.js.map +1 -1
  20. package/src/lib/constants/deal-filters.d.ts +3 -3
  21. package/src/lib/constants/deal-filters.js +1 -1
  22. package/src/lib/constants/deal-filters.js.map +1 -1
  23. package/src/lib/constants/field-list-query.d.ts +1 -1
  24. package/src/lib/constants/field-list-query.js +1 -1
  25. package/src/lib/constants/field-list-query.js.map +1 -1
  26. package/src/lib/declarations.server.js +9 -0
  27. package/src/lib/declarations.server.js.map +1 -1
  28. package/src/lib/model/contacts-list-seed.d.ts +1 -1
  29. package/src/lib/model/contacts-list-seed.js.map +1 -1
  30. package/src/lib/model/crm-list-query.d.ts +4 -4
  31. package/src/lib/model/crm-list-query.js +2 -2
  32. package/src/lib/model/crm-list-query.js.map +1 -1
  33. package/src/lib/model/crm-next-activity.d.ts +13 -0
  34. package/src/lib/model/crm-next-activity.js +19 -0
  35. package/src/lib/model/crm-next-activity.js.map +1 -1
  36. package/src/lib/model/lead-filters.d.ts +6 -5
  37. package/src/lib/model/lead-filters.js +3 -2
  38. package/src/lib/model/lead-filters.js.map +1 -1
  39. package/src/lib/model/lead-status-choices.d.ts +32 -0
  40. package/src/lib/model/lead-status-choices.js +31 -0
  41. package/src/lib/model/lead-status-choices.js.map +1 -0
  42. package/src/lib/model/task-views.d.ts +2 -2
  43. package/src/lib/model/task-views.js.map +1 -1
  44. package/src/lib/server/deal-figures.d.ts +47 -0
  45. package/src/lib/server/deal-figures.js +275 -0
  46. package/src/lib/server/deal-figures.js.map +1 -0
  47. package/src/lib/server/person-records.d.ts +27 -0
  48. package/src/lib/server/person-records.js +127 -4
  49. package/src/lib/server/person-records.js.map +1 -1
  50. package/src/lib/server.d.ts +0 -38
  51. package/src/lib/server.js +5 -1
  52. package/src/lib/server.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/person-records.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 containerMembershipField,\n contactContainerFieldPath,\n normalizeContainerIds,\n} from '@aglyn/aglyn/app-utils/container-membership'\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport {\n CRM_COLLECTIONS,\n crmLeadStatus,\n crmViewIsListed,\n isCrmLeadOpen,\n normalizeCrmViewFilters,\n} from '@aglyn/aglyn/app-utils/crm'\nimport { dynamicListDimensionsForCrmView } from '@aglyn/aglyn/app-utils/dynamic-list-rule'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport {\n scopeTokensForHost,\n seenOnlyThroughGrant,\n visibleToHost,\n} from '@aglyn/aglyn/app-utils/scope-tokens'\nimport type {\n PluginPersonFileRequest,\n PluginPersonFindRequest,\n PluginPersonReadRequest,\n PluginPersonRecord,\n PluginPersonRecordRef,\n PluginPersonRecords,\n PluginPersonViewPeople,\n PluginPersonViewRequest,\n PluginPersonWroteInRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-person-records'\nimport {\n consentGroupForSite,\n firebaseAdmin,\n resolveOrgIdForHost,\n} from '@aglyn/tenant-data-admin'\n// The leaves, not the barrel: this plugin's specs substitute the barrel\n// wholesale, and the lookup and the restamp must reach the real logic.\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\nimport { restampCrmListFieldsAt } from '@aglyn/tenant-data-admin/server/crm-records'\nimport { collectDynamicListCandidates } from '@aglyn/tenant-data-admin/server/dynamic-list-materialize'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { recordPersonRefund } from './person-refund'\n\n/**\n * THE CRM AS THE PLUGIN THAT KEEPS PEOPLE, asked by the plugins that do not\n * (AGL-3080) — the CRM's side of `plugin-person-records`.\n *\n * Two kinds of record are a person here, in the words `plugin-record-routes`\n * addresses them by: a `contact`, the person once they are known, filed at\n * `orgs/{orgId}/contacts/{id}`; and a `lead`, the person before anybody has\n * qualified them, at `orgs/{orgId}/leads/{personKey}` (AGL-3275). One person\n * is one record (AGL-3232), so a lookup by address answers the contact when\n * there is one and the lead only for a caller that asked for any kind.\n *\n * The record's `data` is the stored document. Beside the fields the platform\n * defines (`marketingConsent…`, `emailState`, `visibleTo`), a contact keeps\n * what each consent group knows of the person in `facets.{groupId}` — the\n * sources, stage, owner, company, address and tags — and a lead keeps the\n * same facts at its top level.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** The person kinds, as `plugin-record-routes` addresses them. */\nexport const CRM_PERSON_KINDS = { contact: 'contact', lead: 'lead' } as const\n\n/** The collection each person kind is stored in, under the organization. */\nconst STORED_IN: Readonly<Record<string, string>> = {\n [CRM_PERSON_KINDS.contact]: 'contacts',\n [CRM_PERSON_KINDS.lead]: 'leads',\n}\n\n/** `getAll` takes this many references per call. */\nconst GET_ALL_CHUNK = 300\n\n/**\n * The most leads a saved Leads view is taken from: the site's most recently\n * seen, the Leads list's own window (AGL-3234).\n */\nexport const CRM_LEADS_VIEW_WINDOW = 200\n\nexport interface CrmPersonRecordsDeps {\n firestore(): Firestore\n /** The organization a site belongs to, or `null` for one with none. */\n orgIdForHost(hostId: string): Promise<string | null>\n /** The consent group a site files its facet under. */\n groupIdForHost(hostId: string): Promise<string>\n /**\n * The addresses a saved Contacts view selects among one site's contacts,\n * read the way the dynamic-list sweep reads one, and whether the read\n * reached the whole view before its budget.\n */\n contactViewEmails(input: { hostId: string; viewId: string }): Promise<{ emails: string[]; complete: boolean }>\n}\n\nexport function defaultCrmPersonRecordsDeps(): CrmPersonRecordsDeps {\n return {\n firestore: () => firebaseAdmin.app().firestore(),\n orgIdForHost: async (hostId) => (await resolveOrgIdForHost(hostId)) ?? null,\n groupIdForHost: async (hostId) => (await consentGroupForSite(hostId)).groupId,\n contactViewEmails: async ({ hostId, viewId }) => {\n const scan = await collectDynamicListCandidates({\n hostId,\n rule: { sources: ['contacts'], viewId },\n })\n return { emails: scan.candidates.map((candidate) => candidate.email), complete: scan.complete }\n },\n }\n}\n\nfunction personOf(kind: string, snapshot: FirebaseFirestore.DocumentSnapshot): PluginPersonRecord {\n const data = (snapshot.data() ?? {}) as Record<string, unknown>\n return { kind, id: snapshot.id, email: normalizeContactEmail(data['email']), data }\n}\n\n/**\n * The organization a request names, or the one its site belongs to. A site\n * with no organization THROWS, as the org-data lookup every caller used to\n * make threw: each caller has already decided which way a failed read falls.\n */\nasync function orgOf(\n deps: CrmPersonRecordsDeps,\n request: { orgId?: string | null; hostId?: string | null },\n): Promise<string> {\n const named = String(request.orgId ?? '').trim()\n if (named) return named\n const hostId = String(request.hostId ?? '').trim()\n const resolved = hostId ? await deps.orgIdForHost(hostId) : null\n if (!resolved) throw new Error(`[crm] no organization to find a person in for host ${hostId || '(none)'}`)\n return resolved\n}\n\nexport function createCrmPersonRecords(deps: CrmPersonRecordsDeps): PluginPersonRecords {\n return {\n /**\n * The contact first, through the org's address index (AGL-2633) so an\n * address a merge folded into another record still names the person who\n * now holds it; then, for a caller that asked for any kind, the lead the\n * address is keyed as. Narrowed to the site when asked, on the document\n * the address names: a record the site cannot see is `null`.\n */\n async find(request: PluginPersonFindRequest) {\n const email = normalizeContactEmail(request.email)\n if (!email) return null\n const orgId = await orgOf(deps, request)\n const org = deps.firestore().collection('orgs').doc(orgId)\n const hostId = String(request.hostId ?? '').trim()\n const narrowTo = request.onlyVisibleToSite && hostId ? hostId : null\n const contact = await findContactByEmail(\n org.collection(STORED_IN[CRM_PERSON_KINDS.contact]),\n email,\n narrowTo ? { hostId: narrowTo } : {},\n )\n if (contact) return personOf(CRM_PERSON_KINDS.contact, contact)\n if (!request.anyKind) return null\n const key = personKey(email)\n if (!key) return null\n const lead = await org.collection(STORED_IN[CRM_PERSON_KINDS.lead]).doc(key).get()\n if (!lead.exists) return null\n if (narrowTo && !visibleToHost(lead.get('visibleTo') as string[] | undefined, narrowTo)) {\n return null\n }\n return personOf(CRM_PERSON_KINDS.lead, lead)\n },\n\n /** Every record named, read at once; a kind the CRM does not keep is `null`. */\n async read(request: PluginPersonReadRequest) {\n const firestore = deps.firestore()\n const org = firestore.collection('orgs').doc(request.orgId)\n const refs = request.records.map((record) => {\n const collection = STORED_IN[record.kind]\n const id = String(record.id ?? '').trim()\n return collection && id ? org.collection(collection).doc(id) : null\n })\n const wanted = refs.filter((ref): ref is FirebaseFirestore.DocumentReference => ref !== null)\n const snapshots: FirebaseFirestore.DocumentSnapshot[] = []\n for (let start = 0; start < wanted.length; start += GET_ALL_CHUNK) {\n snapshots.push(...(await firestore.getAll(...wanted.slice(start, start + GET_ALL_CHUNK))))\n }\n let at = 0\n return request.records.map((record, index) => {\n if (!refs[index]) return null\n const snapshot = snapshots[at++]\n return snapshot?.exists ? personOf(record.kind, snapshot) : null\n })\n },\n\n /**\n * Files the person under the containers as the site holds them: on a\n * contact, inside the site's consent-group facet — which campaigns a\n * merchant filed somebody under is that merchant's record, and written at\n * the top it would be readable by every site of an agency's account; on a\n * lead, in the lead's own membership field, whose list fields are then\n * restamped so the Leads list's filter finds it (AGL-3321).\n *\n * `update`, never a merge-set: a dotted path in `set` is a literal field\n * name, and the record must exist. A record the site cannot see is not\n * filed.\n */\n async fileUnder(request: PluginPersonFileRequest) {\n const ids = normalizeContainerIds(request.ids)\n const collection = STORED_IN[request.record.kind]\n const id = String(request.record.id ?? '').trim()\n if (!ids.length || !collection || !id) return { filed: false }\n const orgId = await orgOf(deps, request)\n const ref = deps.firestore().collection('orgs').doc(orgId).collection(collection).doc(id)\n const snapshot = await ref.get()\n if (!snapshot.exists) return { filed: false }\n if (!visibleToHost(snapshot.get('visibleTo') as string[] | undefined, request.hostId)) {\n return { filed: false }\n }\n if (request.record.kind === CRM_PERSON_KINDS.lead) {\n await ref.update({\n [containerMembershipField(request.containerKind)]: FieldValue.arrayUnion(...ids),\n updatedAt: FieldValue.serverTimestamp(),\n })\n await restampCrmListFieldsAt(ref, 'leads')\n return { filed: true }\n }\n const groupId = await deps.groupIdForHost(request.hostId)\n await ref.update({\n [contactContainerFieldPath(groupId, request.containerKind)]: FieldValue.arrayUnion(...ids),\n updatedAt: FieldValue.serverTimestamp(),\n })\n return { filed: true }\n },\n\n recordRefund: (request) => recordPersonRefund(request),\n\n /**\n * The people a saved view selects for a site (AGL-3234). A colleague's\n * private view is theirs: not listed for this member, so it does not\n * exist for them here either.\n *\n * A Leads view takes the site's most recently seen leads —\n * {@link CRM_LEADS_VIEW_WINDOW}, the Leads list's own window — narrowed by\n * the view's status clause the way the list narrows it: open leads when\n * the view names no status, one status when it does, everything for\n * `all`. Held, not merely seen: a lead another site only SHARED with this\n * one (AGL-3336) is not this site's to take.\n *\n * A Contacts view is read the way the dynamic-list sweep reads one, and\n * each address it selects is resolved to the contact the site may see. A\n * view filtering on something that reading cannot apply is refused whole:\n * dropping the filter would select more people than the view shows.\n */\n async peopleInView(request: PluginPersonViewRequest): Promise<PluginPersonViewPeople> {\n const firestore = deps.firestore()\n const org = firestore.collection('orgs').doc(request.orgId)\n const view = await org.collection(CRM_COLLECTIONS.views).doc(request.viewId).get()\n const data = view.exists ? ((view.data() ?? {}) as Record<string, unknown>) : null\n if (\n !data ||\n !crmViewIsListed(\n { shared: data['shared'] === true, ownerUid: String(data['ownerUid'] ?? '') },\n request.viewerUid,\n )\n ) {\n return { ok: false, reason: 'not-found' }\n }\n const filters = normalizeCrmViewFilters(data['filters'])\n if (data['section'] === 'leads') {\n const statusClause = filters.find((clause) => clause.field === 'status' && clause.op === 'equals')\n const wanted = String(statusClause?.value ?? 'open')\n // Narrowed to what the site may see before it is ordered (AGL-3275):\n // the collection is org-wide, and an agency's sequence must not be\n // offered another client's people.\n const window = await org\n .collection(STORED_IN[CRM_PERSON_KINDS.lead])\n .where('visibleTo', 'array-contains-any', scopeTokensForHost(request.hostId))\n .orderBy('lastSeenAtMs', 'desc')\n .limit(CRM_LEADS_VIEW_WINDOW + 1)\n .get()\n const matching = window.docs.slice(0, CRM_LEADS_VIEW_WINDOW).filter((doc) => {\n const lead = (doc.data() ?? {}) as Record<string, unknown>\n if (seenOnlyThroughGrant(lead, request.hostId)) return false\n if (wanted === 'all') return true\n if (wanted === 'open') return isCrmLeadOpen(lead as never) && !lead['convertedContactId']\n return crmLeadStatus(lead as never) === wanted\n })\n return {\n ok: true,\n people: matching.slice(0, request.limit).map((doc) => ({ kind: CRM_PERSON_KINDS.lead, id: doc.id })),\n total: matching.length,\n truncated: matching.length > request.limit || window.docs.length > CRM_LEADS_VIEW_WINDOW,\n }\n }\n if (data['section'] !== 'contacts') return { ok: false, reason: 'not-people' }\n const { unsupported } = dynamicListDimensionsForCrmView(filters)\n if (unsupported.length) {\n return {\n ok: false,\n reason: 'unsupported',\n unsupported: unsupported.map((clause) => clause.label || clause.field),\n }\n }\n const { emails, complete } = await deps.contactViewEmails({\n hostId: request.hostId,\n viewId: request.viewId,\n })\n const ordered = [...new Set(emails)].sort()\n const contacts = org.collection(STORED_IN[CRM_PERSON_KINDS.contact])\n const found = await Promise.all(\n ordered\n .slice(0, request.limit)\n .map((email) => findContactByEmail(contacts, email, { hostId: request.hostId })),\n )\n const ids = [...new Set(found.filter((snapshot) => snapshot).map((snapshot) => String(snapshot?.id)))]\n return {\n ok: true,\n people: ids.map((id): PluginPersonRecordRef => ({ kind: CRM_PERSON_KINDS.contact, id })),\n total: ordered.length,\n truncated: ordered.length > request.limit || !complete,\n }\n },\n\n /**\n * Whether each person has ever written in: an inbound email on their\n * timeline, filed on the contact, or on the lead while they were one\n * (AGL-3234). One keyed query each; a failed one is unknown.\n */\n async wroteIn(request: PluginPersonWroteInRequest) {\n const activities = deps\n .firestore()\n .collection('orgs')\n .doc(request.orgId)\n .collection(CRM_COLLECTIONS.activities)\n return Promise.all(\n request.records.map(async (record) => {\n const field =\n record.kind === CRM_PERSON_KINDS.contact\n ? 'contactId'\n : record.kind === CRM_PERSON_KINDS.lead\n ? 'leadId'\n : null\n const id = String(record.id ?? '').trim()\n if (!field || !id) return null\n try {\n const snapshot = await activities\n .where(field, '==', id)\n .where('direction', '==', 'inbound')\n .limit(1)\n .get()\n return !snapshot.empty\n } catch (error) {\n console.error('[crm] inbound email lookup failed; reading as unknown', error)\n return null\n }\n }),\n )\n },\n }\n}\n\n/** The service as the CRM registers it, its storage the admin app's. */\nexport const crmPersonRecords: Required<PluginPersonRecords> = {\n find: (request) => createCrmPersonRecords(defaultCrmPersonRecordsDeps()).find(request),\n read: (request) => createCrmPersonRecords(defaultCrmPersonRecordsDeps()).read(request),\n fileUnder: (request) => createCrmPersonRecords(defaultCrmPersonRecordsDeps()).fileUnder!(request),\n recordRefund: (request) => recordPersonRefund(request),\n peopleInView: (request) => createCrmPersonRecords(defaultCrmPersonRecordsDeps()).peopleInView!(request),\n wroteIn: (request) => createCrmPersonRecords(defaultCrmPersonRecordsDeps()).wroteIn!(request),\n}\n"],"names":["containerMembershipField","contactContainerFieldPath","normalizeContainerIds","normalizeContactEmail","CRM_COLLECTIONS","crmLeadStatus","crmViewIsListed","isCrmLeadOpen","normalizeCrmViewFilters","dynamicListDimensionsForCrmView","personKey","scopeTokensForHost","seenOnlyThroughGrant","visibleToHost","consentGroupForSite","firebaseAdmin","resolveOrgIdForHost","findContactByEmail","restampCrmListFieldsAt","collectDynamicListCandidates","FieldValue","recordPersonRefund","CRM_PERSON_KINDS","contact","lead","STORED_IN","GET_ALL_CHUNK","CRM_LEADS_VIEW_WINDOW","defaultCrmPersonRecordsDeps","firestore","app","orgIdForHost","hostId","groupIdForHost","groupId","contactViewEmails","viewId","scan","rule","sources","emails","candidates","map","candidate","email","complete","personOf","kind","snapshot","data","id","orgOf","deps","request","named","String","orgId","trim","resolved","Error","createCrmPersonRecords","find","org","collection","doc","narrowTo","onlyVisibleToSite","anyKind","key","get","exists","read","refs","records","record","wanted","filter","ref","snapshots","start","length","push","getAll","slice","at","index","fileUnder","ids","filed","update","containerKind","arrayUnion","updatedAt","serverTimestamp","recordRefund","peopleInView","view","views","shared","ownerUid","viewerUid","ok","reason","filters","statusClause","clause","field","op","value","window","where","orderBy","limit","matching","docs","people","total","truncated","unsupported","label","ordered","Set","sort","contacts","found","Promise","all","wroteIn","activities","empty","error","console","crmPersonRecords"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/person-records.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 containerMembershipField,\n contactContainerFieldPath,\n normalizeContainerIds,\n} from '@aglyn/aglyn/app-utils/container-membership'\nimport { normalizeContactEmail, readContactFacet } from '@aglyn/aglyn/app-utils/contacts'\nimport {\n CRM_COLLECTIONS,\n CRM_SCOPED_SEARCH_JOIN,\n CRM_SCOPED_SEARCH_TOKENS_FIELD,\n crmLeadStatus,\n crmViewIsListed,\n isCrmLeadOpen,\n normalizeCrmViewFilters,\n} from '@aglyn/aglyn/app-utils/crm'\nimport { dynamicListDimensionsForCrmView } from '@aglyn/aglyn/app-utils/dynamic-list-rule'\nimport { NAME_TOKEN_MAX_PREFIX, nameSearchKey } from '@aglyn/aglyn/app-utils/name-search'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport {\n scopeTokensForHost,\n seenOnlyThroughGrant,\n visibleToHost,\n} from '@aglyn/aglyn/app-utils/scope-tokens'\nimport type {\n PluginPersonChange,\n PluginPersonChangesPage,\n PluginPersonChangesRequest,\n PluginPersonFileRequest,\n PluginPersonFindRequest,\n PluginPersonReadRequest,\n PluginPersonRecord,\n PluginPersonRecordRef,\n PluginPersonRecords,\n PluginPersonSearchRequest,\n PluginPersonViewPeople,\n PluginPersonViewRequest,\n PluginPersonWroteInRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-person-records'\nimport {\n consentGroupForSite,\n firebaseAdmin,\n resolveOrgIdForHost,\n} from '@aglyn/tenant-data-admin'\n// The leaves, not the barrel: this plugin's specs substitute the barrel\n// wholesale, and the lookup and the restamp must reach the real logic.\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\nimport { restampCrmListFieldsAt } from '@aglyn/tenant-data-admin/server/crm-records'\nimport { collectDynamicListCandidates } from '@aglyn/tenant-data-admin/server/dynamic-list-materialize'\nimport { FieldPath, FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport { recordPersonRefund } from './person-refund'\n\n/**\n * THE CRM AS THE PLUGIN THAT KEEPS PEOPLE, asked by the plugins that do not\n * (AGL-3080) — the CRM's side of `plugin-person-records`.\n *\n * Two kinds of record are a person here, in the words `plugin-record-routes`\n * addresses them by: a `contact`, the person once they are known, filed at\n * `orgs/{orgId}/contacts/{id}`; and a `lead`, the person before anybody has\n * qualified them, at `orgs/{orgId}/leads/{personKey}` (AGL-3275). One person\n * is one record (AGL-3232), so a lookup by address answers the contact when\n * there is one and the lead only for a caller that asked for any kind.\n *\n * The record's `data` is the stored document. Beside the fields the platform\n * defines (`marketingConsent…`, `emailState`, `visibleTo`), a contact keeps\n * what each consent group knows of the person in `facets.{groupId}` — the\n * sources, stage, owner, company, address and tags — and a lead keeps the\n * same facts at its top level.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** The person kinds, as `plugin-record-routes` addresses them. */\nexport const CRM_PERSON_KINDS = { contact: 'contact', lead: 'lead' } as const\n\n/** The collection each person kind is stored in, under the organization. */\nconst STORED_IN: Readonly<Record<string, string>> = {\n [CRM_PERSON_KINDS.contact]: 'contacts',\n [CRM_PERSON_KINDS.lead]: 'leads',\n}\n\n/** `getAll` takes this many references per call. */\nconst GET_ALL_CHUNK = 300\n\n/**\n * The most leads a saved Leads view is taken from: the site's most recently\n * seen, the Leads list's own window (AGL-3234).\n */\nexport const CRM_LEADS_VIEW_WINDOW = 200\n\nexport interface CrmPersonRecordsDeps {\n firestore(): Firestore\n /** The organization a site belongs to, or `null` for one with none. */\n orgIdForHost(hostId: string): Promise<string | null>\n /** The consent group a site files its facet under. */\n groupIdForHost(hostId: string): Promise<string>\n /**\n * The addresses a saved Contacts view selects among one site's contacts,\n * read the way the dynamic-list sweep reads one, and whether the read\n * reached the whole view before its budget.\n */\n contactViewEmails(input: { hostId: string; viewId: string }): Promise<{ emails: string[]; complete: boolean }>\n}\n\nexport function defaultCrmPersonRecordsDeps(): CrmPersonRecordsDeps {\n return {\n firestore: () => firebaseAdmin.app().firestore(),\n orgIdForHost: async (hostId) => (await resolveOrgIdForHost(hostId)) ?? null,\n groupIdForHost: async (hostId) => (await consentGroupForSite(hostId)).groupId,\n contactViewEmails: async ({ hostId, viewId }) => {\n const scan = await collectDynamicListCandidates({\n hostId,\n rule: { sources: ['contacts'], viewId },\n })\n return { emails: scan.candidates.map((candidate) => candidate.email), complete: scan.complete }\n },\n }\n}\n\nfunction personOf(kind: string, snapshot: FirebaseFirestore.DocumentSnapshot): PluginPersonRecord {\n const data = (snapshot.data() ?? {}) as Record<string, unknown>\n return { kind, id: snapshot.id, email: normalizeContactEmail(data['email']), data }\n}\n\n/**\n * The organization a request names, or the one its site belongs to. A site\n * with no organization THROWS, as the org-data lookup every caller used to\n * make threw: each caller has already decided which way a failed read falls.\n */\nasync function orgOf(\n deps: CrmPersonRecordsDeps,\n request: { orgId?: string | null; hostId?: string | null },\n): Promise<string> {\n const named = String(request.orgId ?? '').trim()\n if (named) return named\n const hostId = String(request.hostId ?? '').trim()\n const resolved = hostId ? await deps.orgIdForHost(hostId) : null\n if (!resolved) throw new Error(`[crm] no organization to find a person in for host ${hostId || '(none)'}`)\n return resolved\n}\n\n/** The most people one search answers, whatever the caller asks for. */\nexport const CRM_PERSON_SEARCH_MAX = 25\n\n/**\n * The one token a typed search asks the contacts' search index for\n * (AGL-3609), or `''` for nothing searchable.\n *\n * A run of digits and phone punctuation is a phone number, read as its\n * digits: `crmPhoneSearchWords` stores the whole number, the number without\n * a country code, its last seven and its last four, so `(555) 123-4567`,\n * `5551234567` and `4567` all find it. Anything else is a name or an\n * address, and the LONGEST word asks — it is the most selective one, and a\n * stored token is a prefix of a word, so `dana@acme.com` and `dana` both\n * reach the contact. Capped at the twelve characters a token keeps.\n */\nexport function crmPersonSearchWord(text: unknown): string {\n const raw = typeof text === 'string' ? text.trim() : ''\n if (!raw) return ''\n if (/^[\\d\\s()+.-]+$/.test(raw)) {\n const digits = raw.replace(/\\D/g, '')\n return digits.length >= 4 ? digits.slice(0, NAME_TOKEN_MAX_PREFIX) : ''\n }\n const words = nameSearchKey(raw).split(' ').filter(Boolean)\n const longest = words.reduce((best, word) => (word.length > best.length ? word : best), '')\n return longest.slice(0, NAME_TOKEN_MAX_PREFIX)\n}\n\n/**\n * A contacts walk's cursor (AGL-3639): the last contact's `updatedAt` to the\n * nanosecond, and its id for the tie. Opaque to every caller; a millisecond\n * alone would answer a contact written later in the same millisecond twice.\n */\nexport function encodeContactChangesCursor(updatedAt: unknown, id: string): string | null {\n const stamp = updatedAt as { seconds?: unknown; nanoseconds?: unknown } | null\n const seconds = Number(stamp?.seconds)\n const nanoseconds = Number(stamp?.nanoseconds ?? 0)\n if (!Number.isFinite(seconds) || !Number.isFinite(nanoseconds) || !id) return null\n return `${Math.trunc(seconds)}.${Math.trunc(nanoseconds)}.${id}`\n}\n\n/** Reads a cursor this module handed out, or `null` for anything else. */\nexport function decodeContactChangesCursor(\n cursor: string | null | undefined,\n): { seconds: number; nanoseconds: number; id: string } | null {\n const match = /^(\\d{1,12})\\.(\\d{1,9})\\.([A-Za-z0-9_-]{1,200})$/.exec(String(cursor ?? ''))\n if (!match) return null\n return { seconds: Number(match[1]), nanoseconds: Number(match[2]), id: match[3] }\n}\n\nconst millisOf = (value: unknown): number => {\n const stamp = value as { toMillis?: () => number } | null\n return typeof stamp?.toMillis === 'function' ? stamp.toMillis() : 0\n}\n\nconst integerOrNull = (value: unknown): number | null =>\n typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : null\n\nexport function createCrmPersonRecords(deps: CrmPersonRecordsDeps): PluginPersonRecords {\n return {\n /**\n * The contact first, through the org's address index (AGL-2633) so an\n * address a merge folded into another record still names the person who\n * now holds it; then, for a caller that asked for any kind, the lead the\n * address is keyed as. Narrowed to the site when asked, on the document\n * the address names: a record the site cannot see is `null`.\n */\n async find(request: PluginPersonFindRequest) {\n const email = normalizeContactEmail(request.email)\n if (!email) return null\n const orgId = await orgOf(deps, request)\n const org = deps.firestore().collection('orgs').doc(orgId)\n const hostId = String(request.hostId ?? '').trim()\n const narrowTo = request.onlyVisibleToSite && hostId ? hostId : null\n const contact = await findContactByEmail(\n org.collection(STORED_IN[CRM_PERSON_KINDS.contact]),\n email,\n narrowTo ? { hostId: narrowTo } : {},\n )\n if (contact) return personOf(CRM_PERSON_KINDS.contact, contact)\n if (!request.anyKind) return null\n const key = personKey(email)\n if (!key) return null\n const lead = await org.collection(STORED_IN[CRM_PERSON_KINDS.lead]).doc(key).get()\n if (!lead.exists) return null\n if (narrowTo && !visibleToHost(lead.get('visibleTo') as string[] | undefined, narrowTo)) {\n return null\n }\n return personOf(CRM_PERSON_KINDS.lead, lead)\n },\n\n /** Every record named, read at once; a kind the CRM does not keep is `null`. */\n async read(request: PluginPersonReadRequest) {\n const firestore = deps.firestore()\n const org = firestore.collection('orgs').doc(request.orgId)\n const refs = request.records.map((record) => {\n const collection = STORED_IN[record.kind]\n const id = String(record.id ?? '').trim()\n return collection && id ? org.collection(collection).doc(id) : null\n })\n const wanted = refs.filter((ref): ref is FirebaseFirestore.DocumentReference => ref !== null)\n const snapshots: FirebaseFirestore.DocumentSnapshot[] = []\n for (let start = 0; start < wanted.length; start += GET_ALL_CHUNK) {\n snapshots.push(...(await firestore.getAll(...wanted.slice(start, start + GET_ALL_CHUNK))))\n }\n let at = 0\n return request.records.map((record, index) => {\n if (!refs[index]) return null\n const snapshot = snapshots[at++]\n return snapshot?.exists ? personOf(record.kind, snapshot) : null\n })\n },\n\n /**\n * Files the person under the containers as the site holds them: on a\n * contact, inside the site's consent-group facet — which campaigns a\n * merchant filed somebody under is that merchant's record, and written at\n * the top it would be readable by every site of an agency's account; on a\n * lead, in the lead's own membership field, whose list fields are then\n * restamped so the Leads list's filter finds it (AGL-3321).\n *\n * `update`, never a merge-set: a dotted path in `set` is a literal field\n * name, and the record must exist. A record the site cannot see is not\n * filed.\n */\n async fileUnder(request: PluginPersonFileRequest) {\n const ids = normalizeContainerIds(request.ids)\n const collection = STORED_IN[request.record.kind]\n const id = String(request.record.id ?? '').trim()\n if (!ids.length || !collection || !id) return { filed: false }\n const orgId = await orgOf(deps, request)\n const ref = deps.firestore().collection('orgs').doc(orgId).collection(collection).doc(id)\n const snapshot = await ref.get()\n if (!snapshot.exists) return { filed: false }\n if (!visibleToHost(snapshot.get('visibleTo') as string[] | undefined, request.hostId)) {\n return { filed: false }\n }\n if (request.record.kind === CRM_PERSON_KINDS.lead) {\n await ref.update({\n [containerMembershipField(request.containerKind)]: FieldValue.arrayUnion(...ids),\n updatedAt: FieldValue.serverTimestamp(),\n })\n await restampCrmListFieldsAt(ref, 'leads')\n return { filed: true }\n }\n const groupId = await deps.groupIdForHost(request.hostId)\n await ref.update({\n [contactContainerFieldPath(groupId, request.containerKind)]: FieldValue.arrayUnion(...ids),\n updatedAt: FieldValue.serverTimestamp(),\n })\n return { filed: true }\n },\n\n recordRefund: (request) => recordPersonRefund(request),\n\n /**\n * The people a saved view selects for a site (AGL-3234). A colleague's\n * private view is theirs: not listed for this member, so it does not\n * exist for them here either.\n *\n * A Leads view takes the site's most recently seen leads —\n * {@link CRM_LEADS_VIEW_WINDOW}, the Leads list's own window — narrowed by\n * the view's status clause the way the list narrows it: open leads when\n * the view names no status, one status when it does, everything for\n * `all`. Held, not merely seen: a lead another site only SHARED with this\n * one (AGL-3336) is not this site's to take.\n *\n * A Contacts view is read the way the dynamic-list sweep reads one, and\n * each address it selects is resolved to the contact the site may see. A\n * view filtering on something that reading cannot apply is refused whole:\n * dropping the filter would select more people than the view shows.\n */\n async peopleInView(request: PluginPersonViewRequest): Promise<PluginPersonViewPeople> {\n const firestore = deps.firestore()\n const org = firestore.collection('orgs').doc(request.orgId)\n const view = await org.collection(CRM_COLLECTIONS.views).doc(request.viewId).get()\n const data = view.exists ? ((view.data() ?? {}) as Record<string, unknown>) : null\n if (\n !data ||\n !crmViewIsListed(\n { shared: data['shared'] === true, ownerUid: String(data['ownerUid'] ?? '') },\n request.viewerUid,\n )\n ) {\n return { ok: false, reason: 'not-found' }\n }\n const filters = normalizeCrmViewFilters(data['filters'])\n if (data['section'] === 'leads') {\n const statusClause = filters.find((clause) => clause.field === 'status' && clause.op === 'equals')\n const wanted = String(statusClause?.value ?? 'open')\n // Narrowed to what the site may see before it is ordered (AGL-3275):\n // the collection is org-wide, and an agency's sequence must not be\n // offered another client's people.\n const window = await org\n .collection(STORED_IN[CRM_PERSON_KINDS.lead])\n .where('visibleTo', 'array-contains-any', scopeTokensForHost(request.hostId))\n .orderBy('lastSeenAtMs', 'desc')\n .limit(CRM_LEADS_VIEW_WINDOW + 1)\n .get()\n const matching = window.docs.slice(0, CRM_LEADS_VIEW_WINDOW).filter((doc) => {\n const lead = (doc.data() ?? {}) as Record<string, unknown>\n if (seenOnlyThroughGrant(lead, request.hostId)) return false\n if (wanted === 'all') return true\n if (wanted === 'open') return isCrmLeadOpen(lead as never) && !lead['convertedContactId']\n return crmLeadStatus(lead as never) === wanted\n })\n return {\n ok: true,\n people: matching.slice(0, request.limit).map((doc) => ({ kind: CRM_PERSON_KINDS.lead, id: doc.id })),\n total: matching.length,\n truncated: matching.length > request.limit || window.docs.length > CRM_LEADS_VIEW_WINDOW,\n }\n }\n if (data['section'] !== 'contacts') return { ok: false, reason: 'not-people' }\n const { unsupported } = dynamicListDimensionsForCrmView(filters)\n if (unsupported.length) {\n return {\n ok: false,\n reason: 'unsupported',\n unsupported: unsupported.map((clause) => clause.label || clause.field),\n }\n }\n const { emails, complete } = await deps.contactViewEmails({\n hostId: request.hostId,\n viewId: request.viewId,\n })\n const ordered = [...new Set(emails)].sort()\n const contacts = org.collection(STORED_IN[CRM_PERSON_KINDS.contact])\n const found = await Promise.all(\n ordered\n .slice(0, request.limit)\n .map((email) => findContactByEmail(contacts, email, { hostId: request.hostId })),\n )\n const ids = [...new Set(found.filter((snapshot) => snapshot).map((snapshot) => String(snapshot?.id)))]\n return {\n ok: true,\n people: ids.map((id): PluginPersonRecordRef => ({ kind: CRM_PERSON_KINDS.contact, id })),\n total: ordered.length,\n truncated: ordered.length > request.limit || !complete,\n }\n },\n\n /**\n * The contacts a site may see, oldest change first (AGL-3639): the walk a\n * connector copies people out by. On the Contacts list's own index\n * (`visibleTo` array-contains-any, `updatedAt` ascending), with the id as\n * the tie-break. A contact the site sees only through a sharing grant is\n * read and stepped over — another holder's person is not this site's to\n * export — and the cursor still moves past it. The profile is the site's\n * consent group's facet, never another holder's.\n */\n async changedSince(request: PluginPersonChangesRequest): Promise<PluginPersonChangesPage> {\n const hostId = String(request.hostId ?? '').trim()\n if (!hostId) return { people: [], next: null }\n const orgId = await orgOf(deps, request)\n const groupId = await deps.groupIdForHost(hostId)\n const after = decodeContactChangesCursor(request.after)\n let query = deps\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(STORED_IN[CRM_PERSON_KINDS.contact])\n .where('visibleTo', 'array-contains-any', scopeTokensForHost(hostId))\n .orderBy('updatedAt', 'asc')\n .orderBy(FieldPath.documentId(), 'asc')\n if (after) {\n query = query.startAfter(new Timestamp(after.seconds, after.nanoseconds), after.id)\n }\n const snapshot = await query.limit(Math.max(1, Math.min(500, request.limit))).get()\n const people: PluginPersonChange[] = []\n let next: string | null = null\n for (const doc of snapshot.docs) {\n const data = (doc.data() ?? {}) as Record<string, unknown>\n next = encodeContactChangesCursor(data['updatedAt'], doc.id) ?? next\n if (seenOnlyThroughGrant(data, hostId)) continue\n const email = normalizeContactEmail(data['email'])\n if (!email) continue\n const facet = readContactFacet(data, groupId)\n people.push({\n kind: CRM_PERSON_KINDS.contact,\n id: doc.id,\n email,\n data,\n changedAtMs: millisOf(data['updatedAt']),\n profile: {\n name: typeof facet.name === 'string' && facet.name.trim() ? facet.name.trim() : null,\n phone: typeof facet.phone === 'string' && facet.phone.trim() ? facet.phone.trim() : null,\n tags: Array.isArray(facet.tags) ? facet.tags.filter((tag) => typeof tag === 'string') : [],\n lifetimeValueCents: integerOrNull(facet.ltvCents),\n ordersCount: integerOrNull(facet.ordersCount),\n },\n })\n }\n return { people, next }\n },\n\n /**\n * Whether each person has ever written in: an inbound email on their\n * timeline, filed on the contact, or on the lead while they were one\n * (AGL-3234). One keyed query each; a failed one is unknown.\n */\n async wroteIn(request: PluginPersonWroteInRequest) {\n const activities = deps\n .firestore()\n .collection('orgs')\n .doc(request.orgId)\n .collection(CRM_COLLECTIONS.activities)\n return Promise.all(\n request.records.map(async (record) => {\n const field =\n record.kind === CRM_PERSON_KINDS.contact\n ? 'contactId'\n : record.kind === CRM_PERSON_KINDS.lead\n ? 'leadId'\n : null\n const id = String(record.id ?? '').trim()\n if (!field || !id) return null\n try {\n const snapshot = await activities\n .where(field, '==', id)\n .where('direction', '==', 'inbound')\n .limit(1)\n .get()\n return !snapshot.empty\n } catch (error) {\n console.error('[crm] inbound email lookup failed; reading as unknown', error)\n return null\n }\n }),\n )\n },\n\n /**\n * Contacts the site may see whose name, address, company or phone\n * starts with the typed word (AGL-3609): ONE query on the scoped search\n * tokens every contact writer stamps, `array-contains-any` over the\n * site's scope tokens joined to the word — the same clause the Contacts\n * list runs under a site, so a record the site cannot see is never\n * answered and no index beyond the field's own is needed.\n */\n async search(request: PluginPersonSearchRequest) {\n const word = crmPersonSearchWord(request.text)\n const hostId = String(request.hostId ?? '').trim()\n const limit = Math.min(CRM_PERSON_SEARCH_MAX, Math.max(0, Math.floor(Number(request.limit) || 0)))\n if (!word || !hostId || !limit) return []\n const orgId = await orgOf(deps, request)\n const tokens = scopeTokensForHost(hostId).map((scope) => `${scope}${CRM_SCOPED_SEARCH_JOIN}${word}`)\n const snapshot = await deps\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(STORED_IN[CRM_PERSON_KINDS.contact])\n .where(CRM_SCOPED_SEARCH_TOKENS_FIELD, 'array-contains-any', tokens)\n .limit(limit)\n .get()\n return snapshot.docs.map((doc) => personOf(CRM_PERSON_KINDS.contact, doc))\n },\n }\n}\n\n/** The service as the CRM registers it, its storage the admin app's. */\nexport const crmPersonRecords: Required<PluginPersonRecords> = {\n find: (request) => createCrmPersonRecords(defaultCrmPersonRecordsDeps()).find(request),\n read: (request) => createCrmPersonRecords(defaultCrmPersonRecordsDeps()).read(request),\n fileUnder: (request) => createCrmPersonRecords(defaultCrmPersonRecordsDeps()).fileUnder!(request),\n recordRefund: (request) => recordPersonRefund(request),\n peopleInView: (request) => createCrmPersonRecords(defaultCrmPersonRecordsDeps()).peopleInView!(request),\n wroteIn: (request) => createCrmPersonRecords(defaultCrmPersonRecordsDeps()).wroteIn!(request),\n search: (request) => createCrmPersonRecords(defaultCrmPersonRecordsDeps()).search!(request),\n changedSince: (request) => createCrmPersonRecords(defaultCrmPersonRecordsDeps()).changedSince!(request),\n}\n"],"names":["containerMembershipField","contactContainerFieldPath","normalizeContainerIds","normalizeContactEmail","readContactFacet","CRM_COLLECTIONS","CRM_SCOPED_SEARCH_JOIN","CRM_SCOPED_SEARCH_TOKENS_FIELD","crmLeadStatus","crmViewIsListed","isCrmLeadOpen","normalizeCrmViewFilters","dynamicListDimensionsForCrmView","NAME_TOKEN_MAX_PREFIX","nameSearchKey","personKey","scopeTokensForHost","seenOnlyThroughGrant","visibleToHost","consentGroupForSite","firebaseAdmin","resolveOrgIdForHost","findContactByEmail","restampCrmListFieldsAt","collectDynamicListCandidates","FieldPath","FieldValue","Timestamp","recordPersonRefund","CRM_PERSON_KINDS","contact","lead","STORED_IN","GET_ALL_CHUNK","CRM_LEADS_VIEW_WINDOW","defaultCrmPersonRecordsDeps","firestore","app","orgIdForHost","hostId","groupIdForHost","groupId","contactViewEmails","viewId","scan","rule","sources","emails","candidates","map","candidate","email","complete","personOf","kind","snapshot","data","id","orgOf","deps","request","named","String","orgId","trim","resolved","Error","CRM_PERSON_SEARCH_MAX","crmPersonSearchWord","text","raw","test","digits","replace","length","slice","words","split","filter","Boolean","longest","reduce","best","word","encodeContactChangesCursor","updatedAt","stamp","seconds","Number","nanoseconds","isFinite","Math","trunc","decodeContactChangesCursor","cursor","match","exec","millisOf","value","toMillis","integerOrNull","round","createCrmPersonRecords","find","org","collection","doc","narrowTo","onlyVisibleToSite","anyKind","key","get","exists","read","refs","records","record","wanted","ref","snapshots","start","push","getAll","at","index","fileUnder","ids","filed","update","containerKind","arrayUnion","serverTimestamp","recordRefund","peopleInView","view","views","shared","ownerUid","viewerUid","ok","reason","filters","statusClause","clause","field","op","window","where","orderBy","limit","matching","docs","people","total","truncated","unsupported","label","ordered","Set","sort","contacts","found","Promise","all","changedSince","next","after","query","documentId","startAfter","max","min","facet","changedAtMs","profile","name","phone","tags","Array","isArray","tag","lifetimeValueCents","ltvCents","ordersCount","wroteIn","activities","empty","error","console","search","floor","tokens","scope","crmPersonRecords"],"mappings":"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"}
@@ -14,44 +14,6 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- /**
18
- * The Contacts plugin's SERVER half (AGL-2595).
19
- *
20
- * Contacts CRM v1 had none: every console write was client-direct against
21
- * the Firestore rules, and the one server path that creates a contact —
22
- * `upsertHostContact` — belongs to the capture doors, not to this plugin.
23
- * v2 needs a server for the things a browser must not do alone: an import
24
- * that dedupes forty thousand rows, a bulk action over a selection, an
25
- * auto-association of contacts to companies by domain. Each of those lands
26
- * as a `crm/<route>` handler registered here.
27
- *
28
- * `crm/ping` exists so the wiring is PROVEN rather than assumed:
29
- * `plugins.config.json` names this module's register function and the
30
- * `crm` API prefix, the generated server manifest loads this file, and the
31
- * console's `/api/[...pluginApi]` dispatcher reaches the handler. A plugin
32
- * whose first real route also had to be its first wiring test would have
33
- * two things to debug at once.
34
- *
35
- * The real routes are the writes with a side effect outside the document:
36
- * `crm/contact-stage` (AGL-2605) exists for the EVENT — `contactStageChanged`
37
- * can only be emitted by a server path that performed the write, so a record
38
- * page that moved the stage client-direct would move the person and tell no
39
- * automation; the task routes (AGL-2599) carry an assignee's notification and
40
- * the `taskCompleted` event. Every CRM file is imported through the
41
- * transfer framework (AGL-3527, AGL-3528, `transfer/`), whose writes go
42
- * through the same doors every other server path uses.
43
- * `crm/deal-stage` (AGL-2598) is the one writer of a deal's stage, won and lost,
44
- * because a stage change is what automations listen for (`server-deal-stage.ts`).
45
- * `crm/contacts-create` (AGL-2596) is a person typed into the console by a
46
- * member of the team — a server route because the dedupe against every
47
- * holder's rows and the audience band are judgments the browser cannot make.
48
- * `crm/contact-update` (AGL-2804) is every other write to a contact's facets:
49
- * the rules cannot tell one field of a holder's facet from another, so they
50
- * leave a client nothing there, and the plan is asked here about the fields
51
- * a save carries. `crm/contact-remove` (AGL-3338) is a holder letting a
52
- * contact go: the refusals the document holds are kept, which a client
53
- * write could not be trusted to do.
54
- */
55
17
  import { type PluginApiHandler } from '@aglyn/aglyn/server';
56
18
  /**
57
19
  * `GET /api/crm/ping` → `{ ok: true, plugin: 'crm' }`.
package/src/lib/server.js CHANGED
@@ -51,7 +51,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
51
51
  * a save carries. `crm/contact-remove` (AGL-3338) is a holder letting a
52
52
  * contact go: the refusals the document holds are kept, which a client
53
53
  * write could not be trusted to do.
54
- */ import { CONTACT_ERASED_MESSAGE, contactFacetPath, composeContactName, CRM_COLLECTIONS, CRM_SALUTATION_PICKLIST, crmReadTokens, isContactLifecycleStage, isOrgWideMember, judgeCrmPicklistValue, normalizeAddress, normalizeContactEmail, normalizePhone, readContactFacet, registerPluginApiRoute, visibleToHost } from "@aglyn/aglyn/server";
54
+ */ import { registerDealFigureReaders } from "./server/deal-figures.js";
55
+ import { CONTACT_ERASED_MESSAGE, contactFacetPath, composeContactName, CRM_COLLECTIONS, CRM_SALUTATION_PICKLIST, crmReadTokens, isContactLifecycleStage, isOrgWideMember, judgeCrmPicklistValue, normalizeAddress, normalizeContactEmail, normalizePhone, readContactFacet, registerPluginApiRoute, visibleToHost } from "@aglyn/aglyn/server";
55
56
  import { consentGroupForSite, firebaseAdmin, getOrgForHost, logHostActivity, memberHasOrgPermission, orgDataCollectionForHost, resolveOrgMembership, restampCrmListFieldsAt } from "@aglyn/tenant-data-admin";
56
57
  import { isRefusedIdToken } from "@aglyn/tenant-data-admin/server/id-token-refusal";
57
58
  import { emitHostEvent } from "@aglyn/tenant-runtime";
@@ -600,6 +601,9 @@ import { CRM_RECIPE_INSTALL_ROUTE, CRM_RECIPE_STATUS_ROUTE, crmRecipeInstallHand
600
601
  }
601
602
  };
602
603
  /** Console API registration, named in `plugins.config.json` as `consoleApi`. */ export function registerCrmConsoleApi() {
604
+ // The pipeline and the deals closed, as figure tables (AGL-3603), for the
605
+ // AI plugin's insights to read by id; the console runs insight jobs.
606
+ registerDealFigureReaders(()=>firebaseAdmin.app().firestore());
603
607
  /*
604
608
  * The contact-capture writer, again (AGL-3080). Both apps run it at boot
605
609
  * from their generated server-declarations manifest, and this is the
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/crm/src/lib/server.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 * The Contacts plugin's SERVER half (AGL-2595).\n *\n * Contacts CRM v1 had none: every console write was client-direct against\n * the Firestore rules, and the one server path that creates a contact —\n * `upsertHostContact` — belongs to the capture doors, not to this plugin.\n * v2 needs a server for the things a browser must not do alone: an import\n * that dedupes forty thousand rows, a bulk action over a selection, an\n * auto-association of contacts to companies by domain. Each of those lands\n * as a `crm/<route>` handler registered here.\n *\n * `crm/ping` exists so the wiring is PROVEN rather than assumed:\n * `plugins.config.json` names this module's register function and the\n * `crm` API prefix, the generated server manifest loads this file, and the\n * console's `/api/[...pluginApi]` dispatcher reaches the handler. A plugin\n * whose first real route also had to be its first wiring test would have\n * two things to debug at once.\n *\n * The real routes are the writes with a side effect outside the document:\n * `crm/contact-stage` (AGL-2605) exists for the EVENT — `contactStageChanged`\n * can only be emitted by a server path that performed the write, so a record\n * page that moved the stage client-direct would move the person and tell no\n * automation; the task routes (AGL-2599) carry an assignee's notification and\n * the `taskCompleted` event. Every CRM file is imported through the\n * transfer framework (AGL-3527, AGL-3528, `transfer/`), whose writes go\n * through the same doors every other server path uses.\n * `crm/deal-stage` (AGL-2598) is the one writer of a deal's stage, won and lost,\n * because a stage change is what automations listen for (`server-deal-stage.ts`).\n * `crm/contacts-create` (AGL-2596) is a person typed into the console by a\n * member of the team — a server route because the dedupe against every\n * holder's rows and the audience band are judgments the browser cannot make.\n * `crm/contact-update` (AGL-2804) is every other write to a contact's facets:\n * the rules cannot tell one field of a holder's facet from another, so they\n * leave a client nothing there, and the plan is asked here about the fields\n * a save carries. `crm/contact-remove` (AGL-3338) is a holder letting a\n * contact go: the refusals the document holds are kept, which a client\n * write could not be trusted to do.\n */\n\nimport {\n CONTACT_ERASED_MESSAGE,\n contactFacetPath,\n composeContactName,\n CRM_COLLECTIONS,\n CRM_SALUTATION_PICKLIST,\n crmReadTokens,\n isContactLifecycleStage,\n isOrgWideMember,\n judgeCrmPicklistValue,\n normalizeAddress,\n normalizeContactEmail,\n normalizePhone,\n type AglynPostalAddress,\n type PluginApiHandler,\n type PluginApiRequest,\n readContactFacet,\n registerPluginApiRoute,\n visibleToHost,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n memberHasOrgPermission,\n orgDataCollectionForHost,\n resolveOrgMembership,\n restampCrmListFieldsAt,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { emitHostEvent } from '@aglyn/tenant-runtime'\nimport { captureHostContact } from './server/capture-host-contact'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { CRM_API_ROUTES } from './constants/api-routes'\nimport { registerCrmServerDeclarations } from './declarations.server'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { CRM_NEXT_ACTIVITY_ROUTE } from './model/next-activity'\nimport { CRM_LIST_FIELDS_ROUTE } from './model/list-fields'\nimport { CRM_LEAD_SOURCE_VALUES_ROUTE, CRM_PICKLIST_VALUES_ROUTE } from './model/picklist-values'\nimport { CRM_TASK_ROUTES } from './model/task-routes'\nimport { crmNextActivityHandler } from './server/next-activity-routes'\nimport { crmListFieldsHandler } from './server/list-fields-routes'\nimport { crmLeadSourceValuesHandler, crmPicklistValuesHandler } from './server/picklist-values'\nimport { registerCrmRecordEmailStateWriter } from './server/record-email-state'\nimport { registerCrmRecordTimelineWriter } from './server/record-timeline'\nimport { crmTaskCompleteHandler, crmTaskSaveHandler } from './server/task-routes'\nimport { crmDealStageHandler } from './server-deal-stage'\nimport { crmEmailSendHandler } from './server/email-send'\nimport { CRM_EMAIL_CHECK_ROUTE, crmEmailCheckHandler } from './server/email-check'\nimport { leadConvertHandler } from './server/lead-convert'\nimport { leadCreateHandler } from './server/lead-create'\nimport {\n CONTACT_EMAIL_HISTORY_ROUTE,\n contactEmailHistoryHandler,\n} from './server/contact-email-history'\nimport { CONTACTS_MERGE_ROUTE, contactsMergeHandler } from './server/contacts-merge'\nimport { CRM_ERASE_PERSON_ROUTE, crmErasePersonHandler } from './server/erase-person'\nimport { CRM_ORG_ACTIVITY_ROUTE, crmOrgActivityHandler } from './server/org-activity'\nimport { CRM_INBOUND_ADDRESS_ROUTE, crmInboundAddressHandler } from './server/inbound-address'\nimport { crmCompanyDeleteHandler } from './server/company-delete'\nimport { crmSharingHandler } from './server/crm-sharing'\nimport { CRM_SHARING_ROUTE } from './model/crm-sharing'\nimport { CONTACT_PHONE_REFUSAL, normalizeTags, typed } from './server/contact-profile'\nimport { crmContactUpdateHandler, readContactCreateProfile } from './server/contact-update'\nimport { readCrmPicklist } from './server/read-picklist'\nimport { crmContactRemoveHandler } from './server/contact-remove'\nimport {\n CRM_EMAIL_TEMPLATE_DUPLICATE_ROUTE,\n crmEmailTemplateDuplicateHandler,\n} from './server/email-template-duplicate'\nimport { registerCrmRecordFactsReaders } from './server/record-facts'\nimport { crmSuiteRefusal } from './server/suite-gate'\nimport {\n CRM_RECIPE_INSTALL_ROUTE,\n CRM_RECIPE_STATUS_ROUTE,\n crmRecipeInstallHandler,\n crmRecipeStatusHandler,\n} from './server/recipe-routes'\n\n/**\n * `GET /api/crm/ping` → `{ ok: true, plugin: 'crm' }`.\n *\n * No auth, no host, no data: it answers whether the plugin's server bundle\n * was loaded and its routes registered, which is a fact about the process\n * and not about any org. Anything that reads a document goes behind the\n * same session and role checks the other plugins' console routes use.\n */\nexport const crmPingHandler: PluginApiHandler = (req, res) => {\n if (req.method !== 'GET') {\n res.setHeader('Allow', 'GET')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n res.status(200).json({ ok: true, plugin: BUNDLE_ID })\n}\n\ntype Refusal = { ok: false; status: number; error: string }\n\n/**\n * Who is calling, and whether they may edit this site's CRM.\n *\n * The same check the inbox, bookings and email console routes make: a\n * bearer ID token, verified, and an `admin` or `editor` role on the host\n * document. A contact is a site's business record, so the site's own role\n * map is the right authority; org-wide reach is not required, because a\n * site editor who can open the record in the console can already write\n * every other field on it client-direct.\n */\nasync function authorizeSiteEditor(\n req: PluginApiRequest,\n hostId: string,\n): Promise<{ ok: true; uid: string; email: string | null } | Refusal> {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return { ok: false, status: 401, error: 'Unauthenticated' }\n let uid: string\n let email: string | null\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n uid = decoded.uid\n email = typeof decoded.email === 'string' ? decoded.email : null\n } catch (error) {\n // A refused credential is the caller's 401; a failure to check one is\n // ours and keeps a 5xx (AGL-2852).\n if (isRefusedIdToken(error)) return { ok: false, status: 401, error: 'Unauthenticated' }\n console.error('[crm] the site editor could not be verified', error)\n return { ok: false, status: 500, error: 'The sign-in could not be checked. Try again.' }\n }\n const hostSnapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .get()\n if (!hostSnapshot.exists) {\n return { ok: false, status: 404, error: 'Unknown site' }\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return { ok: false, status: 403, error: 'Not a site admin or editor' }\n }\n return { ok: true, uid, email }\n}\n\n/**\n * `POST /api/crm/contact-stage` with `{ hostId, contactId, lifecycleStage }`.\n *\n * Writes the stage into THIS site's facet on the contact — a dotted\n * `update()`, never a top-level field, because the row is shared by every\n * site in the org and a stage is one holder's reading of the person — and\n * then announces `contactStageChanged` with the stage it replaced.\n *\n * A stage set to what it already is writes nothing and announces nothing:\n * the response says `changed: false`, and no automation listening for a\n * change hears one that did not happen.\n *\n * `lifecycleStage: null` CLEARS the stage (AGL-2804). A facet is the\n * server's to write, so \"not placed yet\" comes through the route that owns\n * the stage rather than as a client-direct delete, and it is refused to a\n * plan without the suite the way a move is. There is no event for \"no\n * stage\", so a clear announces nothing; an empty string names no stage and\n * is refused like any other the list does not have.\n *\n * The contact is looked up by id and then checked against `visibleTo`,\n * because the Admin SDK evaluates no rules: a caller who can edit site A\n * must not be able to restage a contact only site B holds by guessing its\n * id, and the scoped query the list uses is not available to a `doc()` read.\n */\nexport const contactStageHandler: 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 hostId = String(req.body?.hostId ?? '').trim()\n const contactId = String(req.body?.contactId ?? '').trim()\n const requested: unknown = req.body?.lifecycleStage\n if (!hostId || !contactId) {\n res.status(400).json({ error: 'Missing hostId or contactId' })\n return\n }\n // `null` clears the stage; anything else has to be one of the list's.\n const clearing = requested === null\n const lifecycleStage = clearing\n ? ''\n : isContactLifecycleStage(requested)\n ? requested\n : null\n if (lifecycleStage === null) {\n res.status(400).json({ error: 'Pick a lifecycle stage' })\n return\n }\n const caller = await authorizeSiteEditor(req, hostId)\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n try {\n // A lifecycle stage is the suite's (AGL-2787) — see `suite-gate.ts`.\n const owner = await getOrgForHost(hostId)\n if (!owner) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const suite = crmSuiteRefusal(owner.org, \"Moving a contact's lifecycle stage\")\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n const contactsRef = await orgDataCollectionForHost(hostId, 'contacts')\n const snapshot = await contactsRef.doc(contactId).get()\n if (!snapshot.exists || !visibleToHost(snapshot.get('visibleTo'), hostId)) {\n res.status(404).json({ error: 'Unknown contact' })\n return\n }\n const group = await consentGroupForSite(hostId)\n const facet = readContactFacet(\n snapshot.data() as Record<string, unknown>,\n group.groupId,\n )\n const previousStage = facet.lifecycleStage ?? ''\n if (previousStage === lifecycleStage) {\n res\n .status(200)\n .json({ ok: true, changed: false, lifecycleStage, previousStage })\n return\n }\n await snapshot.ref.update({\n [contactFacetPath(group.groupId, 'lifecycleStage')]: clearing\n ? FieldValue.delete()\n : lifecycleStage,\n updatedAt: FieldValue.serverTimestamp(),\n })\n // The stage is what the Contacts list filters by (AGL-3321).\n await restampCrmListFieldsAt(snapshot.ref, 'contacts')\n // Awaited, not floated: a serverless response ending cancels in-flight\n // work, and the event is the reason this route exists. A clear has none.\n if (!clearing) {\n await emitHostEvent(\n hostId,\n 'contactStageChanged',\n {\n contactId,\n email: String(snapshot.get('email') ?? ''),\n lifecycleStage,\n previousStage,\n },\n { actor: { kind: 'member', uid: caller.uid, email: caller.email } },\n )\n }\n res\n .status(200)\n .json({ ok: true, changed: true, lifecycleStage, previousStage })\n } catch (error) {\n console.error('[crm] contact-stage failed', hostId, contactId, error)\n res.status(500).json({ error: 'The stage could not be changed' })\n }\n}\n\n/**\n * What the create route says when the band refused (AGL-2596).\n *\n * The contacts list's own alert, in the past tense: the list says new\n * visitors are \"no longer captured\", and this says the one contact the\n * reader just tried to add was not. The remedy is the same sentence in both\n * places, so a reader who sees one and then the other is told one thing.\n */\nexport const CONTACT_BAND_FULL_MESSAGE =\n 'Contact limit reached — this contact was not added. Upgrade in Billing ' +\n 'to keep collecting.'\n\n/**\n * `POST /api/crm/contacts-create` — a person added by hand (AGL-2596).\n *\n * Body: `{ hostId, email, name?, phone?, jobTitle?, companyName?,\n * companyId?, address?, ownerUid?, lifecycleStage?, tags?,\n * marketingConsent?, disclosedConsentGroup? }`, plus any of Salesforce's\n * standard contact fields `crm/contact-update` reads but the reports-to\n * (`CONTACT_CREATE_PROFILE_FIELDS`, AGL-3515) — read by the same rules, the\n * salutation judged against the org's list, and the holder's name composed\n * from a first and last name when either is given. Answers\n * `{ contactId, created }`: `created` is\n * false when the address already belonged to somebody, in which case what\n * was typed MERGES into the existing row — the dedupe the shared address\n * book exists for, and the reason a second \"create\" of one person is not\n * an error.\n *\n * ## Who may call it\n *\n * The caller's ID token, then `data.manage` on the site's org — the same key\n * the console surface itself is gated on, resolved through the same\n * three-layer permission read the members route uses. A scoped member is\n * admitted only for a site they reach: the rules would refuse a browser\n * write outside their tokens, and a server route that admitted it would be\n * a way round the rules rather than a service in front of them.\n *\n * ## What it refuses, and how\n *\n * A malformed email or phone number is a 400 with a sentence the drawer can\n * show under the field. The band is a 409 carrying the list's own wording:\n * it is not a bad request and not a server fault, it is the plan saying no,\n * and the drawer relays the sentence rather than inventing one.\n *\n * ## The company (AGL-2613)\n *\n * `companyId` is the picker's choice, and it is checked before anything is\n * written: the company has to exist and be visible to the capturing site's\n * scope, or a caller could file a person under a record they cannot open —\n * the same refusal the lead conversion makes. It reaches the upsert as\n * `facet.companyId`, which is where the link is kept in step with its mirror\n * and the company's contacts count; a merge onto somebody already at another\n * company is a MOVE there, not a second link. The stored company's own name\n * is what is echoed, over whatever the client sent, because the name on the\n * record is the company's and not the form's.\n *\n * ## What it writes beyond the upsert\n *\n * `upsertHostContact` takes the profile — phone, title, address, owner,\n * stage, company — and writes it into the capturing group's facet. The two\n * things it does not take are written here by DOTTED path afterwards: the\n * tags, which union into whatever the person already carried, and the\n * company name — the picked company's, or free text for a person filed\n * under a name no company record carries yet. Dotted paths because this is\n * an `update()`, and only an update reads a dot as a path — a nested object\n * here would replace the facet map and take every other holder's records\n * with it.\n */\nexport const crmContactsCreateHandler: 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\n const body = (req.body ?? {}) as Record<string, unknown>\n const hostId = typed(body['hostId'], 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 const rawPhone = typed(body['phone'], 40)\n const phone = rawPhone ? normalizePhone(rawPhone) : null\n if (rawPhone && !phone) {\n res.status(400).json({ error: CONTACT_PHONE_REFUSAL })\n return\n }\n const rawStage = typed(body['lifecycleStage'], 40)\n if (rawStage && !isContactLifecycleStage(rawStage)) {\n res.status(400).json({ error: 'Unknown lifecycle stage.' })\n return\n }\n const profile = readContactCreateProfile(body)\n if (profile.ok === false) {\n res.status(400).json({ error: profile.error })\n return\n }\n const extras = profile.fields\n const name = typed(body['name'], 120)\n const jobTitle = typed(body['jobTitle'], 120)\n let companyName = typed(body['companyName'], 120)\n const companyId = typed(body['companyId'], 128)\n const ownerUid = typed(body['ownerUid'], 128)\n const address =\n body['address'] && typeof body['address'] === 'object'\n ? normalizeAddress(body['address'] as AglynPostalAddress)\n : null\n const tags = normalizeTags(body['tags'])\n const marketingConsent = body['marketingConsent'] === true\n // The key of the consent-group sentence the drawer showed beside the box\n // (AGL-3320): the opt-in covers the site's group only while it is the\n // group's current one, and the capturing site alone otherwise.\n const disclosedConsentGroup = typed(body['disclosedConsentGroup'], 64)\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\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const resolved = await getOrgForHost(hostId)\n if (!resolved) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const isStaff = decoded['staff'] === true\n if (!isStaff) {\n const membership = await resolveOrgMembership(decoded.uid, resolved.orgId)\n const member = membership?.member ?? null\n const reaches =\n isOrgWideMember(member) || Boolean(member?.hostAccess?.[hostId])\n const allowed =\n member &&\n reaches &&\n (await memberHasOrgPermission(resolved.orgId, member, 'data.manage'))\n if (!allowed) {\n res.status(403).json({\n error: 'Adding contacts requires the data.manage permission on this site',\n })\n return\n }\n }\n\n // A person typed in by the team is the suite's; the capture doors that\n // fill a Free workspace's contacts do not come through here (AGL-2787).\n const suite = crmSuiteRefusal(resolved.org, 'Adding a contact by hand')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n // The salutation, judged against the org's list (AGL-3515).\n let salutation = ''\n if (extras.salutation) {\n const judged = judgeCrmPicklistValue(\n CRM_SALUTATION_PICKLIST,\n await readCrmPicklist(\n firebaseAdmin.app().firestore(),\n resolved.orgId,\n CRM_SALUTATION_PICKLIST,\n ),\n extras.salutation,\n )\n if (judged.ok === false) {\n res.status(400).json({ error: judged.error })\n return\n }\n salutation = judged.value ?? ''\n }\n\n if (companyId) {\n const group = await consentGroupForSite(\n hostId,\n resolved.org as Record<string, unknown>,\n )\n const readable = new Set<string>(crmReadTokens(group))\n const companySnapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(resolved.orgId)\n .collection(CRM_COLLECTIONS.companies)\n .doc(companyId)\n .get()\n const tokens: unknown = companySnapshot.exists\n ? companySnapshot.get('visibleTo')\n : undefined\n const visible =\n Array.isArray(tokens) && tokens.some((token) => readable.has(String(token)))\n if (!visible) {\n res.status(404).json({ error: 'Unknown company' })\n return\n }\n companyName = typed(companySnapshot.get('name'), 120) || companyName\n }\n\n const result = await captureHostContact({\n hostId,\n email,\n ...(name ? { name } : {}),\n source: 'manual',\n interaction: { summary: 'Added by hand' },\n actor: {\n kind: 'member',\n uid: decoded.uid,\n email: typeof decoded.email === 'string' ? decoded.email : null,\n },\n marketingConsent,\n ...(marketingConsent && disclosedConsentGroup\n ? { disclosedConsentGroup }\n : {}),\n facet: {\n ...(phone ? { phone } : {}),\n ...(jobTitle ? { jobTitle } : {}),\n // Salesforce's standard fields (AGL-3515); the capture composes the\n // holder's name from the first and last names.\n ...Object.fromEntries(\n Object.entries({ ...extras, salutation }).filter(\n ([key, value]) =>\n key !== 'doNotCall' && key !== 'otherAddress' && typeof value === 'string' && value,\n ),\n ),\n ...(extras.otherAddress ? { otherAddress: extras.otherAddress } : {}),\n ...(extras.doNotCall ? { doNotCall: true } : {}),\n ...(companyId ? { companyId } : {}),\n ...(address ? { address } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(rawStage && isContactLifecycleStage(rawStage)\n ? { lifecycleStage: rawStage }\n : {}),\n },\n })\n\n if ('refused' in result) {\n if (result.refused === 'band') {\n res.status(409).json({ error: CONTACT_BAND_FULL_MESSAGE, reason: 'band' })\n return\n }\n if (result.refused === 'invalid-email') {\n res.status(400).json({ error: 'Enter a valid email address.' })\n return\n }\n // The person was erased from this workspace (AGL-2623). A conflict,\n // like the band: the request was well-formed and the answer is a\n // standing fact about the address, not a fault in the call.\n if (result.refused === 'erased') {\n res.status(409).json({ error: CONTACT_ERASED_MESSAGE, reason: 'erased' })\n return\n }\n res.status(500).json({ error: 'The contact could not be saved.' })\n return\n }\n\n if (tags.length || companyName) {\n const group = await consentGroupForSite(\n hostId,\n resolved.org as Record<string, unknown>,\n )\n const contacts = await orgDataCollectionForHost(hostId, 'contacts')\n await contacts.doc(result.contactId).update({\n ...(tags.length\n ? {\n [contactFacetPath(group.groupId, 'tags')]: FieldValue.arrayUnion(\n ...tags,\n ),\n }\n : {}),\n ...(companyName\n ? {\n [contactFacetPath(group.groupId, 'companyName')]: companyName,\n // The search echo — see `HostContact.companyName`.\n companyName,\n }\n : {}),\n updatedAt: FieldValue.serverTimestamp(),\n })\n // The tags are what the Contacts list filters by (AGL-3321).\n await restampCrmListFieldsAt(contacts.doc(result.contactId), 'contacts')\n }\n\n /*\n * The audit line, written HERE rather than by the console (AGL-2622):\n * a route that verified the caller and performed the write is the one\n * writer that cannot record an act that did not happen, which is the\n * reason `check-activity-coverage.mjs` counts this file. A merge into\n * a person the org already held is said to be one — the row was\n * updated, not added — so the feed cannot claim two people for one.\n */\n await logHostActivity(\n hostId,\n { uid: decoded.uid, email: decoded.email ?? null },\n result.created ? 'Added contact' : 'Updated contact',\n {\n type: 'contact',\n id: result.contactId,\n name: name || composeContactName(extras.firstName, extras.lastName) || email,\n },\n )\n\n res\n .status(result.created ? 201 : 200)\n .json({ contactId: result.contactId, created: result.created })\n } catch (error) {\n console.error('[crm] contact create failed', error)\n res.status(500).json({ error: 'The contact could not be saved.' })\n }\n}\n\n/** Console API registration, named in `plugins.config.json` as `consoleApi`. */\nexport function registerCrmConsoleApi(): void {\n /*\n * The contact-capture writer, again (AGL-3080). Both apps run it at boot\n * from their generated server-declarations manifest, and this is the\n * backstop for a process whose boot did not: the first CRM door to load\n * registers it. Registering twice replaces in place.\n */\n registerCrmServerDeclarations()\n registerPluginApiRoute(CRM_API_ROUTES.ping, crmPingHandler)\n registerPluginApiRoute(CRM_API_ROUTES.contactStage, contactStageHandler)\n // Every other console write to a contact's facets (AGL-2804): the rules\n // cannot tell one facet field from another, so the plan is asked here.\n registerPluginApiRoute(CRM_API_ROUTES.contactUpdate, crmContactUpdateHandler)\n registerPluginApiRoute(CRM_API_ROUTES.contactRemove, crmContactRemoveHandler)\n // A company's contacts unlinked, then the company (AGL-2804): the unlink\n // clears a facet, which is the server's to write.\n registerPluginApiRoute(CRM_API_ROUTES.companyDelete, crmCompanyDeleteHandler)\n // Tasks (AGL-2599): the two writes with a side effect outside the document\n // — an assignee's notification, and the `taskCompleted` host event.\n registerPluginApiRoute(CRM_TASK_ROUTES.save, crmTaskSaveHandler)\n registerPluginApiRoute(CRM_TASK_ROUTES.complete, crmTaskCompleteHandler)\n // A client-direct task write's door to `nextTaskAtMs` (AGL-2661).\n registerPluginApiRoute(CRM_NEXT_ACTIVITY_ROUTE, crmNextActivityHandler)\n // What a client-direct write the browser cannot follow up asks for: the\n // fields the CRM lists query, restamped from the record (AGL-3321).\n registerPluginApiRoute(CRM_LIST_FIELDS_ROUTE, crmListFieldsHandler)\n // Sharing records with other sites (AGL-3336): a manager's share and\n // unshare, the org's sharing rules and their recompute, and the rules'\n // re-evaluation a client-direct write owes.\n registerPluginApiRoute(CRM_SHARING_ROUTE, crmSharingHandler)\n // A picklist value renamed or deleted (AGL-3298, AGL-3510): the list and\n // every record holding the old label, in one request. The lead source's\n // own address stays registered for a request that names no picklist.\n registerPluginApiRoute(CRM_PICKLIST_VALUES_ROUTE, crmPicklistValuesHandler)\n registerPluginApiRoute(CRM_LEAD_SOURCE_VALUES_ROUTE, crmLeadSourceValuesHandler)\n // The one writer of a deal's stage, won and lost (AGL-2598): the browser\n // could write the field, but only a server can emit the event an\n // automation listens for.\n registerPluginApiRoute('crm/deal-stage', crmDealStageHandler)\n // A person typed into the console (AGL-2596): the capture doors' own\n // function behind a session check, so the dedupe and the band are judged\n // where every other door judges them.\n registerPluginApiRoute('crm/contacts-create', crmContactsCreateHandler)\n // A lead becomes a contact, a company and a deal (AGL-2608) — the one CRM\n // write a browser cannot make alone, because only the server may create a\n // contact through the dedupe-and-meter door.\n registerPluginApiRoute('crm/lead-convert', leadConvertHandler)\n // A lead entered by hand (AGL-3231): the New lead drawer's route.\n registerPluginApiRoute('crm/leads-create', leadCreateHandler)\n // The one READ behind a route (AGL-2616): the per-recipient delivery log\n // is closed to clients, so a contact's campaign mail is projected here.\n registerPluginApiRoute(CONTACT_EMAIL_HISTORY_ROUTE, contactEmailHistoryHandler)\n // One email to one person from their record (AGL-2615): the recipient is\n // read off the record, the daily cap and both suppression lists are\n // judged, and the message leaves on the site's sending identity.\n registerPluginApiRoute(CRM_API_ROUTES.emailSend, crmEmailSendHandler)\n // What the platform knows about one address before somebody writes to it\n // (AGL-3328): the gateway chip on a record page, and the composer's\n // warning about a domain with no mail server or a gateway holding this\n // site's mail. DNS and the ledger are the server's.\n registerPluginApiRoute(CRM_EMAIL_CHECK_ROUTE, crmEmailCheckHandler)\n // Two records for one person become one (AGL-2625): the repoint of every\n // row naming the merged record and the transaction over both documents\n // are the server's, and the address index it writes is closed to clients.\n registerPluginApiRoute(CONTACTS_MERGE_ROUTE, contactsMergeHandler)\n // Files a person's privacy erasure for the daily job (AGL-2623);\n // workspace admins only.\n registerPluginApiRoute(CRM_ERASE_PERSON_ROUTE, crmErasePersonHandler)\n // One line in the organization's activity feed for an act the org-level\n // hub performed client-direct (AGL-2634): the feed is closed to clients,\n // so the bulk bars' lines come through here.\n registerPluginApiRoute(CRM_ORG_ACTIVITY_ROUTE, crmOrgActivityHandler)\n // A recipe's action written into one site's automations from the org\n // hub (AGL-2639), stamped and deduplicated by the server, and the stamps\n // read back per site so the hub can say which sites carry which recipe.\n registerPluginApiRoute(CRM_RECIPE_INSTALL_ROUTE, crmRecipeInstallHandler)\n registerPluginApiRoute(CRM_RECIPE_STATUS_ROUTE, crmRecipeStatusHandler)\n // The workspace's email capture address (AGL-2657): the token is minted\n // and rotated here, behind the CRM's own gate, and the org document that\n // carries it is closed to every client.\n registerPluginApiRoute(CRM_INBOUND_ADDRESS_ROUTE, crmInboundAddressHandler)\n /*\n * The three doors a MACHINE calls (AGL-3080), at the paths they have\n * always answered on: the scheduler's daily digest and hourly task\n * reminders, on the cron secret, and the mail provider's capture webhook,\n * on its signature. None names a site or a member, so each is registered\n * as a machine's route — the dispatcher leaves the plugin's release and\n * enablement gates and its write limit to it — and each judges the CRM's\n * plan and flag for every organization it resolves. Loaded with the\n * first call, not with the surface.\n */\n registerPluginApiRoute(\n CRM_API_ROUTES.dailyDigest,\n {\n web: async (request) =>\n (await import('./server/daily-digest-route')).crmDailyDigestRoute(request),\n },\n { machine: true },\n )\n registerPluginApiRoute(\n CRM_API_ROUTES.taskReminders,\n {\n web: async (request) =>\n (await import('./server/task-reminders-route')).crmTaskRemindersRoute(request),\n },\n { machine: true },\n )\n registerPluginApiRoute(\n CRM_API_ROUTES.inbound,\n {\n web: async (request) => (await import('./server/inbound-route')).crmInboundRoute(request),\n },\n { machine: true },\n )\n registerPluginApiRoute(\n CRM_EMAIL_TEMPLATE_DUPLICATE_ROUTE,\n crmEmailTemplateDuplicateHandler,\n )\n // The facts of a contact, company, deal or lead, and an import's field\n // catalog, for another plugin in this process (AGL-2917): read on the core's\n // record-facts seam under the rules every CRM door applies, and written\n // nowhere.\n registerCrmRecordFactsReaders()\n // The CRM as the workspace's record system on the core's record-timeline\n // seam (AGL-2981): another plugin files an email, a reply or a task on a\n // record through it, under the CRM's own scope, dedupe and ceiling.\n registerCrmRecordTimelineWriter()\n // And on the record email-state seam (AGL-3245): a sender that filed a\n // verdict on an address — a bounce, a complaint, an unsubscribe, a\n // do-not-contact mark — stamps the lead and the contact through it.\n registerCrmRecordEmailStateWriter()\n}\n\n// Type-only (AGL-3080): the plugin's entitlement keys, declared by module\n// augmentation, for every program that loads this entry point.\nexport type { crmPlanEntitlements } from './plan-entitlements'\n"],"names":["CONTACT_ERASED_MESSAGE","contactFacetPath","composeContactName","CRM_COLLECTIONS","CRM_SALUTATION_PICKLIST","crmReadTokens","isContactLifecycleStage","isOrgWideMember","judgeCrmPicklistValue","normalizeAddress","normalizeContactEmail","normalizePhone","readContactFacet","registerPluginApiRoute","visibleToHost","consentGroupForSite","firebaseAdmin","getOrgForHost","logHostActivity","memberHasOrgPermission","orgDataCollectionForHost","resolveOrgMembership","restampCrmListFieldsAt","isRefusedIdToken","emitHostEvent","captureHostContact","FieldValue","CRM_API_ROUTES","registerCrmServerDeclarations","BUNDLE_ID","CRM_NEXT_ACTIVITY_ROUTE","CRM_LIST_FIELDS_ROUTE","CRM_LEAD_SOURCE_VALUES_ROUTE","CRM_PICKLIST_VALUES_ROUTE","CRM_TASK_ROUTES","crmNextActivityHandler","crmListFieldsHandler","crmLeadSourceValuesHandler","crmPicklistValuesHandler","registerCrmRecordEmailStateWriter","registerCrmRecordTimelineWriter","crmTaskCompleteHandler","crmTaskSaveHandler","crmDealStageHandler","crmEmailSendHandler","CRM_EMAIL_CHECK_ROUTE","crmEmailCheckHandler","leadConvertHandler","leadCreateHandler","CONTACT_EMAIL_HISTORY_ROUTE","contactEmailHistoryHandler","CONTACTS_MERGE_ROUTE","contactsMergeHandler","CRM_ERASE_PERSON_ROUTE","crmErasePersonHandler","CRM_ORG_ACTIVITY_ROUTE","crmOrgActivityHandler","CRM_INBOUND_ADDRESS_ROUTE","crmInboundAddressHandler","crmCompanyDeleteHandler","crmSharingHandler","CRM_SHARING_ROUTE","CONTACT_PHONE_REFUSAL","normalizeTags","typed","crmContactUpdateHandler","readContactCreateProfile","readCrmPicklist","crmContactRemoveHandler","CRM_EMAIL_TEMPLATE_DUPLICATE_ROUTE","crmEmailTemplateDuplicateHandler","registerCrmRecordFactsReaders","crmSuiteRefusal","CRM_RECIPE_INSTALL_ROUTE","CRM_RECIPE_STATUS_ROUTE","crmRecipeInstallHandler","crmRecipeStatusHandler","crmPingHandler","req","res","method","setHeader","status","json","error","ok","plugin","authorizeSiteEditor","hostId","hostSnapshot","authorization","String","headers","idToken","startsWith","slice","length","undefined","uid","email","decoded","app","auth","verifyIdToken","console","firestore","collection","doc","get","exists","memberRole","contactStageHandler","body","trim","contactId","requested","lifecycleStage","clearing","caller","facet","owner","suite","org","contactsRef","snapshot","group","data","groupId","previousStage","changed","ref","update","delete","updatedAt","serverTimestamp","actor","kind","CONTACT_BAND_FULL_MESSAGE","crmContactsCreateHandler","rawPhone","phone","rawStage","profile","extras","fields","name","jobTitle","companyName","companyId","ownerUid","address","tags","marketingConsent","disclosedConsentGroup","resolved","isStaff","member","membership","orgId","reaches","Boolean","hostAccess","allowed","salutation","judged","value","readable","Set","companySnapshot","companies","tokens","visible","Array","isArray","some","token","has","result","source","interaction","summary","Object","fromEntries","entries","filter","key","otherAddress","doNotCall","refused","reason","contacts","arrayUnion","created","type","id","firstName","lastName","registerCrmConsoleApi","ping","contactStage","contactUpdate","contactRemove","companyDelete","save","complete","emailSend","dailyDigest","web","request","crmDailyDigestRoute","machine","taskReminders","crmTaskRemindersRoute","inbound","crmInboundRoute"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/crm/src/lib/server.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 * The Contacts plugin's SERVER half (AGL-2595).\n *\n * Contacts CRM v1 had none: every console write was client-direct against\n * the Firestore rules, and the one server path that creates a contact —\n * `upsertHostContact` — belongs to the capture doors, not to this plugin.\n * v2 needs a server for the things a browser must not do alone: an import\n * that dedupes forty thousand rows, a bulk action over a selection, an\n * auto-association of contacts to companies by domain. Each of those lands\n * as a `crm/<route>` handler registered here.\n *\n * `crm/ping` exists so the wiring is PROVEN rather than assumed:\n * `plugins.config.json` names this module's register function and the\n * `crm` API prefix, the generated server manifest loads this file, and the\n * console's `/api/[...pluginApi]` dispatcher reaches the handler. A plugin\n * whose first real route also had to be its first wiring test would have\n * two things to debug at once.\n *\n * The real routes are the writes with a side effect outside the document:\n * `crm/contact-stage` (AGL-2605) exists for the EVENT — `contactStageChanged`\n * can only be emitted by a server path that performed the write, so a record\n * page that moved the stage client-direct would move the person and tell no\n * automation; the task routes (AGL-2599) carry an assignee's notification and\n * the `taskCompleted` event. Every CRM file is imported through the\n * transfer framework (AGL-3527, AGL-3528, `transfer/`), whose writes go\n * through the same doors every other server path uses.\n * `crm/deal-stage` (AGL-2598) is the one writer of a deal's stage, won and lost,\n * because a stage change is what automations listen for (`server-deal-stage.ts`).\n * `crm/contacts-create` (AGL-2596) is a person typed into the console by a\n * member of the team — a server route because the dedupe against every\n * holder's rows and the audience band are judgments the browser cannot make.\n * `crm/contact-update` (AGL-2804) is every other write to a contact's facets:\n * the rules cannot tell one field of a holder's facet from another, so they\n * leave a client nothing there, and the plan is asked here about the fields\n * a save carries. `crm/contact-remove` (AGL-3338) is a holder letting a\n * contact go: the refusals the document holds are kept, which a client\n * write could not be trusted to do.\n */\n\nimport { registerDealFigureReaders } from './server/deal-figures'\nimport {\n CONTACT_ERASED_MESSAGE,\n contactFacetPath,\n composeContactName,\n CRM_COLLECTIONS,\n CRM_SALUTATION_PICKLIST,\n crmReadTokens,\n isContactLifecycleStage,\n isOrgWideMember,\n judgeCrmPicklistValue,\n normalizeAddress,\n normalizeContactEmail,\n normalizePhone,\n type AglynPostalAddress,\n type PluginApiHandler,\n type PluginApiRequest,\n readContactFacet,\n registerPluginApiRoute,\n visibleToHost,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n memberHasOrgPermission,\n orgDataCollectionForHost,\n resolveOrgMembership,\n restampCrmListFieldsAt,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { emitHostEvent } from '@aglyn/tenant-runtime'\nimport { captureHostContact } from './server/capture-host-contact'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { CRM_API_ROUTES } from './constants/api-routes'\nimport { registerCrmServerDeclarations } from './declarations.server'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { CRM_NEXT_ACTIVITY_ROUTE } from './model/next-activity'\nimport { CRM_LIST_FIELDS_ROUTE } from './model/list-fields'\nimport { CRM_LEAD_SOURCE_VALUES_ROUTE, CRM_PICKLIST_VALUES_ROUTE } from './model/picklist-values'\nimport { CRM_TASK_ROUTES } from './model/task-routes'\nimport { crmNextActivityHandler } from './server/next-activity-routes'\nimport { crmListFieldsHandler } from './server/list-fields-routes'\nimport { crmLeadSourceValuesHandler, crmPicklistValuesHandler } from './server/picklist-values'\nimport { registerCrmRecordEmailStateWriter } from './server/record-email-state'\nimport { registerCrmRecordTimelineWriter } from './server/record-timeline'\nimport { crmTaskCompleteHandler, crmTaskSaveHandler } from './server/task-routes'\nimport { crmDealStageHandler } from './server-deal-stage'\nimport { crmEmailSendHandler } from './server/email-send'\nimport { CRM_EMAIL_CHECK_ROUTE, crmEmailCheckHandler } from './server/email-check'\nimport { leadConvertHandler } from './server/lead-convert'\nimport { leadCreateHandler } from './server/lead-create'\nimport {\n CONTACT_EMAIL_HISTORY_ROUTE,\n contactEmailHistoryHandler,\n} from './server/contact-email-history'\nimport { CONTACTS_MERGE_ROUTE, contactsMergeHandler } from './server/contacts-merge'\nimport { CRM_ERASE_PERSON_ROUTE, crmErasePersonHandler } from './server/erase-person'\nimport { CRM_ORG_ACTIVITY_ROUTE, crmOrgActivityHandler } from './server/org-activity'\nimport { CRM_INBOUND_ADDRESS_ROUTE, crmInboundAddressHandler } from './server/inbound-address'\nimport { crmCompanyDeleteHandler } from './server/company-delete'\nimport { crmSharingHandler } from './server/crm-sharing'\nimport { CRM_SHARING_ROUTE } from './model/crm-sharing'\nimport { CONTACT_PHONE_REFUSAL, normalizeTags, typed } from './server/contact-profile'\nimport { crmContactUpdateHandler, readContactCreateProfile } from './server/contact-update'\nimport { readCrmPicklist } from './server/read-picklist'\nimport { crmContactRemoveHandler } from './server/contact-remove'\nimport {\n CRM_EMAIL_TEMPLATE_DUPLICATE_ROUTE,\n crmEmailTemplateDuplicateHandler,\n} from './server/email-template-duplicate'\nimport { registerCrmRecordFactsReaders } from './server/record-facts'\nimport { crmSuiteRefusal } from './server/suite-gate'\nimport {\n CRM_RECIPE_INSTALL_ROUTE,\n CRM_RECIPE_STATUS_ROUTE,\n crmRecipeInstallHandler,\n crmRecipeStatusHandler,\n} from './server/recipe-routes'\n\n/**\n * `GET /api/crm/ping` → `{ ok: true, plugin: 'crm' }`.\n *\n * No auth, no host, no data: it answers whether the plugin's server bundle\n * was loaded and its routes registered, which is a fact about the process\n * and not about any org. Anything that reads a document goes behind the\n * same session and role checks the other plugins' console routes use.\n */\nexport const crmPingHandler: PluginApiHandler = (req, res) => {\n if (req.method !== 'GET') {\n res.setHeader('Allow', 'GET')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n res.status(200).json({ ok: true, plugin: BUNDLE_ID })\n}\n\ntype Refusal = { ok: false; status: number; error: string }\n\n/**\n * Who is calling, and whether they may edit this site's CRM.\n *\n * The same check the inbox, bookings and email console routes make: a\n * bearer ID token, verified, and an `admin` or `editor` role on the host\n * document. A contact is a site's business record, so the site's own role\n * map is the right authority; org-wide reach is not required, because a\n * site editor who can open the record in the console can already write\n * every other field on it client-direct.\n */\nasync function authorizeSiteEditor(\n req: PluginApiRequest,\n hostId: string,\n): Promise<{ ok: true; uid: string; email: string | null } | Refusal> {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return { ok: false, status: 401, error: 'Unauthenticated' }\n let uid: string\n let email: string | null\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n uid = decoded.uid\n email = typeof decoded.email === 'string' ? decoded.email : null\n } catch (error) {\n // A refused credential is the caller's 401; a failure to check one is\n // ours and keeps a 5xx (AGL-2852).\n if (isRefusedIdToken(error)) return { ok: false, status: 401, error: 'Unauthenticated' }\n console.error('[crm] the site editor could not be verified', error)\n return { ok: false, status: 500, error: 'The sign-in could not be checked. Try again.' }\n }\n const hostSnapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .get()\n if (!hostSnapshot.exists) {\n return { ok: false, status: 404, error: 'Unknown site' }\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return { ok: false, status: 403, error: 'Not a site admin or editor' }\n }\n return { ok: true, uid, email }\n}\n\n/**\n * `POST /api/crm/contact-stage` with `{ hostId, contactId, lifecycleStage }`.\n *\n * Writes the stage into THIS site's facet on the contact — a dotted\n * `update()`, never a top-level field, because the row is shared by every\n * site in the org and a stage is one holder's reading of the person — and\n * then announces `contactStageChanged` with the stage it replaced.\n *\n * A stage set to what it already is writes nothing and announces nothing:\n * the response says `changed: false`, and no automation listening for a\n * change hears one that did not happen.\n *\n * `lifecycleStage: null` CLEARS the stage (AGL-2804). A facet is the\n * server's to write, so \"not placed yet\" comes through the route that owns\n * the stage rather than as a client-direct delete, and it is refused to a\n * plan without the suite the way a move is. There is no event for \"no\n * stage\", so a clear announces nothing; an empty string names no stage and\n * is refused like any other the list does not have.\n *\n * The contact is looked up by id and then checked against `visibleTo`,\n * because the Admin SDK evaluates no rules: a caller who can edit site A\n * must not be able to restage a contact only site B holds by guessing its\n * id, and the scoped query the list uses is not available to a `doc()` read.\n */\nexport const contactStageHandler: 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 hostId = String(req.body?.hostId ?? '').trim()\n const contactId = String(req.body?.contactId ?? '').trim()\n const requested: unknown = req.body?.lifecycleStage\n if (!hostId || !contactId) {\n res.status(400).json({ error: 'Missing hostId or contactId' })\n return\n }\n // `null` clears the stage; anything else has to be one of the list's.\n const clearing = requested === null\n const lifecycleStage = clearing\n ? ''\n : isContactLifecycleStage(requested)\n ? requested\n : null\n if (lifecycleStage === null) {\n res.status(400).json({ error: 'Pick a lifecycle stage' })\n return\n }\n const caller = await authorizeSiteEditor(req, hostId)\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n try {\n // A lifecycle stage is the suite's (AGL-2787) — see `suite-gate.ts`.\n const owner = await getOrgForHost(hostId)\n if (!owner) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const suite = crmSuiteRefusal(owner.org, \"Moving a contact's lifecycle stage\")\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n const contactsRef = await orgDataCollectionForHost(hostId, 'contacts')\n const snapshot = await contactsRef.doc(contactId).get()\n if (!snapshot.exists || !visibleToHost(snapshot.get('visibleTo'), hostId)) {\n res.status(404).json({ error: 'Unknown contact' })\n return\n }\n const group = await consentGroupForSite(hostId)\n const facet = readContactFacet(\n snapshot.data() as Record<string, unknown>,\n group.groupId,\n )\n const previousStage = facet.lifecycleStage ?? ''\n if (previousStage === lifecycleStage) {\n res\n .status(200)\n .json({ ok: true, changed: false, lifecycleStage, previousStage })\n return\n }\n await snapshot.ref.update({\n [contactFacetPath(group.groupId, 'lifecycleStage')]: clearing\n ? FieldValue.delete()\n : lifecycleStage,\n updatedAt: FieldValue.serverTimestamp(),\n })\n // The stage is what the Contacts list filters by (AGL-3321).\n await restampCrmListFieldsAt(snapshot.ref, 'contacts')\n // Awaited, not floated: a serverless response ending cancels in-flight\n // work, and the event is the reason this route exists. A clear has none.\n if (!clearing) {\n await emitHostEvent(\n hostId,\n 'contactStageChanged',\n {\n contactId,\n email: String(snapshot.get('email') ?? ''),\n lifecycleStage,\n previousStage,\n },\n { actor: { kind: 'member', uid: caller.uid, email: caller.email } },\n )\n }\n res\n .status(200)\n .json({ ok: true, changed: true, lifecycleStage, previousStage })\n } catch (error) {\n console.error('[crm] contact-stage failed', hostId, contactId, error)\n res.status(500).json({ error: 'The stage could not be changed' })\n }\n}\n\n/**\n * What the create route says when the band refused (AGL-2596).\n *\n * The contacts list's own alert, in the past tense: the list says new\n * visitors are \"no longer captured\", and this says the one contact the\n * reader just tried to add was not. The remedy is the same sentence in both\n * places, so a reader who sees one and then the other is told one thing.\n */\nexport const CONTACT_BAND_FULL_MESSAGE =\n 'Contact limit reached — this contact was not added. Upgrade in Billing ' +\n 'to keep collecting.'\n\n/**\n * `POST /api/crm/contacts-create` — a person added by hand (AGL-2596).\n *\n * Body: `{ hostId, email, name?, phone?, jobTitle?, companyName?,\n * companyId?, address?, ownerUid?, lifecycleStage?, tags?,\n * marketingConsent?, disclosedConsentGroup? }`, plus any of Salesforce's\n * standard contact fields `crm/contact-update` reads but the reports-to\n * (`CONTACT_CREATE_PROFILE_FIELDS`, AGL-3515) — read by the same rules, the\n * salutation judged against the org's list, and the holder's name composed\n * from a first and last name when either is given. Answers\n * `{ contactId, created }`: `created` is\n * false when the address already belonged to somebody, in which case what\n * was typed MERGES into the existing row — the dedupe the shared address\n * book exists for, and the reason a second \"create\" of one person is not\n * an error.\n *\n * ## Who may call it\n *\n * The caller's ID token, then `data.manage` on the site's org — the same key\n * the console surface itself is gated on, resolved through the same\n * three-layer permission read the members route uses. A scoped member is\n * admitted only for a site they reach: the rules would refuse a browser\n * write outside their tokens, and a server route that admitted it would be\n * a way round the rules rather than a service in front of them.\n *\n * ## What it refuses, and how\n *\n * A malformed email or phone number is a 400 with a sentence the drawer can\n * show under the field. The band is a 409 carrying the list's own wording:\n * it is not a bad request and not a server fault, it is the plan saying no,\n * and the drawer relays the sentence rather than inventing one.\n *\n * ## The company (AGL-2613)\n *\n * `companyId` is the picker's choice, and it is checked before anything is\n * written: the company has to exist and be visible to the capturing site's\n * scope, or a caller could file a person under a record they cannot open —\n * the same refusal the lead conversion makes. It reaches the upsert as\n * `facet.companyId`, which is where the link is kept in step with its mirror\n * and the company's contacts count; a merge onto somebody already at another\n * company is a MOVE there, not a second link. The stored company's own name\n * is what is echoed, over whatever the client sent, because the name on the\n * record is the company's and not the form's.\n *\n * ## What it writes beyond the upsert\n *\n * `upsertHostContact` takes the profile — phone, title, address, owner,\n * stage, company — and writes it into the capturing group's facet. The two\n * things it does not take are written here by DOTTED path afterwards: the\n * tags, which union into whatever the person already carried, and the\n * company name — the picked company's, or free text for a person filed\n * under a name no company record carries yet. Dotted paths because this is\n * an `update()`, and only an update reads a dot as a path — a nested object\n * here would replace the facet map and take every other holder's records\n * with it.\n */\nexport const crmContactsCreateHandler: 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\n const body = (req.body ?? {}) as Record<string, unknown>\n const hostId = typed(body['hostId'], 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 const rawPhone = typed(body['phone'], 40)\n const phone = rawPhone ? normalizePhone(rawPhone) : null\n if (rawPhone && !phone) {\n res.status(400).json({ error: CONTACT_PHONE_REFUSAL })\n return\n }\n const rawStage = typed(body['lifecycleStage'], 40)\n if (rawStage && !isContactLifecycleStage(rawStage)) {\n res.status(400).json({ error: 'Unknown lifecycle stage.' })\n return\n }\n const profile = readContactCreateProfile(body)\n if (profile.ok === false) {\n res.status(400).json({ error: profile.error })\n return\n }\n const extras = profile.fields\n const name = typed(body['name'], 120)\n const jobTitle = typed(body['jobTitle'], 120)\n let companyName = typed(body['companyName'], 120)\n const companyId = typed(body['companyId'], 128)\n const ownerUid = typed(body['ownerUid'], 128)\n const address =\n body['address'] && typeof body['address'] === 'object'\n ? normalizeAddress(body['address'] as AglynPostalAddress)\n : null\n const tags = normalizeTags(body['tags'])\n const marketingConsent = body['marketingConsent'] === true\n // The key of the consent-group sentence the drawer showed beside the box\n // (AGL-3320): the opt-in covers the site's group only while it is the\n // group's current one, and the capturing site alone otherwise.\n const disclosedConsentGroup = typed(body['disclosedConsentGroup'], 64)\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\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const resolved = await getOrgForHost(hostId)\n if (!resolved) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const isStaff = decoded['staff'] === true\n if (!isStaff) {\n const membership = await resolveOrgMembership(decoded.uid, resolved.orgId)\n const member = membership?.member ?? null\n const reaches =\n isOrgWideMember(member) || Boolean(member?.hostAccess?.[hostId])\n const allowed =\n member &&\n reaches &&\n (await memberHasOrgPermission(resolved.orgId, member, 'data.manage'))\n if (!allowed) {\n res.status(403).json({\n error: 'Adding contacts requires the data.manage permission on this site',\n })\n return\n }\n }\n\n // A person typed in by the team is the suite's; the capture doors that\n // fill a Free workspace's contacts do not come through here (AGL-2787).\n const suite = crmSuiteRefusal(resolved.org, 'Adding a contact by hand')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n // The salutation, judged against the org's list (AGL-3515).\n let salutation = ''\n if (extras.salutation) {\n const judged = judgeCrmPicklistValue(\n CRM_SALUTATION_PICKLIST,\n await readCrmPicklist(\n firebaseAdmin.app().firestore(),\n resolved.orgId,\n CRM_SALUTATION_PICKLIST,\n ),\n extras.salutation,\n )\n if (judged.ok === false) {\n res.status(400).json({ error: judged.error })\n return\n }\n salutation = judged.value ?? ''\n }\n\n if (companyId) {\n const group = await consentGroupForSite(\n hostId,\n resolved.org as Record<string, unknown>,\n )\n const readable = new Set<string>(crmReadTokens(group))\n const companySnapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(resolved.orgId)\n .collection(CRM_COLLECTIONS.companies)\n .doc(companyId)\n .get()\n const tokens: unknown = companySnapshot.exists\n ? companySnapshot.get('visibleTo')\n : undefined\n const visible =\n Array.isArray(tokens) && tokens.some((token) => readable.has(String(token)))\n if (!visible) {\n res.status(404).json({ error: 'Unknown company' })\n return\n }\n companyName = typed(companySnapshot.get('name'), 120) || companyName\n }\n\n const result = await captureHostContact({\n hostId,\n email,\n ...(name ? { name } : {}),\n source: 'manual',\n interaction: { summary: 'Added by hand' },\n actor: {\n kind: 'member',\n uid: decoded.uid,\n email: typeof decoded.email === 'string' ? decoded.email : null,\n },\n marketingConsent,\n ...(marketingConsent && disclosedConsentGroup\n ? { disclosedConsentGroup }\n : {}),\n facet: {\n ...(phone ? { phone } : {}),\n ...(jobTitle ? { jobTitle } : {}),\n // Salesforce's standard fields (AGL-3515); the capture composes the\n // holder's name from the first and last names.\n ...Object.fromEntries(\n Object.entries({ ...extras, salutation }).filter(\n ([key, value]) =>\n key !== 'doNotCall' && key !== 'otherAddress' && typeof value === 'string' && value,\n ),\n ),\n ...(extras.otherAddress ? { otherAddress: extras.otherAddress } : {}),\n ...(extras.doNotCall ? { doNotCall: true } : {}),\n ...(companyId ? { companyId } : {}),\n ...(address ? { address } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(rawStage && isContactLifecycleStage(rawStage)\n ? { lifecycleStage: rawStage }\n : {}),\n },\n })\n\n if ('refused' in result) {\n if (result.refused === 'band') {\n res.status(409).json({ error: CONTACT_BAND_FULL_MESSAGE, reason: 'band' })\n return\n }\n if (result.refused === 'invalid-email') {\n res.status(400).json({ error: 'Enter a valid email address.' })\n return\n }\n // The person was erased from this workspace (AGL-2623). A conflict,\n // like the band: the request was well-formed and the answer is a\n // standing fact about the address, not a fault in the call.\n if (result.refused === 'erased') {\n res.status(409).json({ error: CONTACT_ERASED_MESSAGE, reason: 'erased' })\n return\n }\n res.status(500).json({ error: 'The contact could not be saved.' })\n return\n }\n\n if (tags.length || companyName) {\n const group = await consentGroupForSite(\n hostId,\n resolved.org as Record<string, unknown>,\n )\n const contacts = await orgDataCollectionForHost(hostId, 'contacts')\n await contacts.doc(result.contactId).update({\n ...(tags.length\n ? {\n [contactFacetPath(group.groupId, 'tags')]: FieldValue.arrayUnion(\n ...tags,\n ),\n }\n : {}),\n ...(companyName\n ? {\n [contactFacetPath(group.groupId, 'companyName')]: companyName,\n // The search echo — see `HostContact.companyName`.\n companyName,\n }\n : {}),\n updatedAt: FieldValue.serverTimestamp(),\n })\n // The tags are what the Contacts list filters by (AGL-3321).\n await restampCrmListFieldsAt(contacts.doc(result.contactId), 'contacts')\n }\n\n /*\n * The audit line, written HERE rather than by the console (AGL-2622):\n * a route that verified the caller and performed the write is the one\n * writer that cannot record an act that did not happen, which is the\n * reason `check-activity-coverage.mjs` counts this file. A merge into\n * a person the org already held is said to be one — the row was\n * updated, not added — so the feed cannot claim two people for one.\n */\n await logHostActivity(\n hostId,\n { uid: decoded.uid, email: decoded.email ?? null },\n result.created ? 'Added contact' : 'Updated contact',\n {\n type: 'contact',\n id: result.contactId,\n name: name || composeContactName(extras.firstName, extras.lastName) || email,\n },\n )\n\n res\n .status(result.created ? 201 : 200)\n .json({ contactId: result.contactId, created: result.created })\n } catch (error) {\n console.error('[crm] contact create failed', error)\n res.status(500).json({ error: 'The contact could not be saved.' })\n }\n}\n\n/** Console API registration, named in `plugins.config.json` as `consoleApi`. */\nexport function registerCrmConsoleApi(): void {\n // The pipeline and the deals closed, as figure tables (AGL-3603), for the\n // AI plugin's insights to read by id; the console runs insight jobs.\n registerDealFigureReaders(() => firebaseAdmin.app().firestore())\n /*\n * The contact-capture writer, again (AGL-3080). Both apps run it at boot\n * from their generated server-declarations manifest, and this is the\n * backstop for a process whose boot did not: the first CRM door to load\n * registers it. Registering twice replaces in place.\n */\n registerCrmServerDeclarations()\n registerPluginApiRoute(CRM_API_ROUTES.ping, crmPingHandler)\n registerPluginApiRoute(CRM_API_ROUTES.contactStage, contactStageHandler)\n // Every other console write to a contact's facets (AGL-2804): the rules\n // cannot tell one facet field from another, so the plan is asked here.\n registerPluginApiRoute(CRM_API_ROUTES.contactUpdate, crmContactUpdateHandler)\n registerPluginApiRoute(CRM_API_ROUTES.contactRemove, crmContactRemoveHandler)\n // A company's contacts unlinked, then the company (AGL-2804): the unlink\n // clears a facet, which is the server's to write.\n registerPluginApiRoute(CRM_API_ROUTES.companyDelete, crmCompanyDeleteHandler)\n // Tasks (AGL-2599): the two writes with a side effect outside the document\n // — an assignee's notification, and the `taskCompleted` host event.\n registerPluginApiRoute(CRM_TASK_ROUTES.save, crmTaskSaveHandler)\n registerPluginApiRoute(CRM_TASK_ROUTES.complete, crmTaskCompleteHandler)\n // A client-direct task write's door to `nextTaskAtMs` (AGL-2661).\n registerPluginApiRoute(CRM_NEXT_ACTIVITY_ROUTE, crmNextActivityHandler)\n // What a client-direct write the browser cannot follow up asks for: the\n // fields the CRM lists query, restamped from the record (AGL-3321).\n registerPluginApiRoute(CRM_LIST_FIELDS_ROUTE, crmListFieldsHandler)\n // Sharing records with other sites (AGL-3336): a manager's share and\n // unshare, the org's sharing rules and their recompute, and the rules'\n // re-evaluation a client-direct write owes.\n registerPluginApiRoute(CRM_SHARING_ROUTE, crmSharingHandler)\n // A picklist value renamed or deleted (AGL-3298, AGL-3510): the list and\n // every record holding the old label, in one request. The lead source's\n // own address stays registered for a request that names no picklist.\n registerPluginApiRoute(CRM_PICKLIST_VALUES_ROUTE, crmPicklistValuesHandler)\n registerPluginApiRoute(CRM_LEAD_SOURCE_VALUES_ROUTE, crmLeadSourceValuesHandler)\n // The one writer of a deal's stage, won and lost (AGL-2598): the browser\n // could write the field, but only a server can emit the event an\n // automation listens for.\n registerPluginApiRoute('crm/deal-stage', crmDealStageHandler)\n // A person typed into the console (AGL-2596): the capture doors' own\n // function behind a session check, so the dedupe and the band are judged\n // where every other door judges them.\n registerPluginApiRoute('crm/contacts-create', crmContactsCreateHandler)\n // A lead becomes a contact, a company and a deal (AGL-2608) — the one CRM\n // write a browser cannot make alone, because only the server may create a\n // contact through the dedupe-and-meter door.\n registerPluginApiRoute('crm/lead-convert', leadConvertHandler)\n // A lead entered by hand (AGL-3231): the New lead drawer's route.\n registerPluginApiRoute('crm/leads-create', leadCreateHandler)\n // The one READ behind a route (AGL-2616): the per-recipient delivery log\n // is closed to clients, so a contact's campaign mail is projected here.\n registerPluginApiRoute(CONTACT_EMAIL_HISTORY_ROUTE, contactEmailHistoryHandler)\n // One email to one person from their record (AGL-2615): the recipient is\n // read off the record, the daily cap and both suppression lists are\n // judged, and the message leaves on the site's sending identity.\n registerPluginApiRoute(CRM_API_ROUTES.emailSend, crmEmailSendHandler)\n // What the platform knows about one address before somebody writes to it\n // (AGL-3328): the gateway chip on a record page, and the composer's\n // warning about a domain with no mail server or a gateway holding this\n // site's mail. DNS and the ledger are the server's.\n registerPluginApiRoute(CRM_EMAIL_CHECK_ROUTE, crmEmailCheckHandler)\n // Two records for one person become one (AGL-2625): the repoint of every\n // row naming the merged record and the transaction over both documents\n // are the server's, and the address index it writes is closed to clients.\n registerPluginApiRoute(CONTACTS_MERGE_ROUTE, contactsMergeHandler)\n // Files a person's privacy erasure for the daily job (AGL-2623);\n // workspace admins only.\n registerPluginApiRoute(CRM_ERASE_PERSON_ROUTE, crmErasePersonHandler)\n // One line in the organization's activity feed for an act the org-level\n // hub performed client-direct (AGL-2634): the feed is closed to clients,\n // so the bulk bars' lines come through here.\n registerPluginApiRoute(CRM_ORG_ACTIVITY_ROUTE, crmOrgActivityHandler)\n // A recipe's action written into one site's automations from the org\n // hub (AGL-2639), stamped and deduplicated by the server, and the stamps\n // read back per site so the hub can say which sites carry which recipe.\n registerPluginApiRoute(CRM_RECIPE_INSTALL_ROUTE, crmRecipeInstallHandler)\n registerPluginApiRoute(CRM_RECIPE_STATUS_ROUTE, crmRecipeStatusHandler)\n // The workspace's email capture address (AGL-2657): the token is minted\n // and rotated here, behind the CRM's own gate, and the org document that\n // carries it is closed to every client.\n registerPluginApiRoute(CRM_INBOUND_ADDRESS_ROUTE, crmInboundAddressHandler)\n /*\n * The three doors a MACHINE calls (AGL-3080), at the paths they have\n * always answered on: the scheduler's daily digest and hourly task\n * reminders, on the cron secret, and the mail provider's capture webhook,\n * on its signature. None names a site or a member, so each is registered\n * as a machine's route — the dispatcher leaves the plugin's release and\n * enablement gates and its write limit to it — and each judges the CRM's\n * plan and flag for every organization it resolves. Loaded with the\n * first call, not with the surface.\n */\n registerPluginApiRoute(\n CRM_API_ROUTES.dailyDigest,\n {\n web: async (request) =>\n (await import('./server/daily-digest-route')).crmDailyDigestRoute(request),\n },\n { machine: true },\n )\n registerPluginApiRoute(\n CRM_API_ROUTES.taskReminders,\n {\n web: async (request) =>\n (await import('./server/task-reminders-route')).crmTaskRemindersRoute(request),\n },\n { machine: true },\n )\n registerPluginApiRoute(\n CRM_API_ROUTES.inbound,\n {\n web: async (request) => (await import('./server/inbound-route')).crmInboundRoute(request),\n },\n { machine: true },\n )\n registerPluginApiRoute(\n CRM_EMAIL_TEMPLATE_DUPLICATE_ROUTE,\n crmEmailTemplateDuplicateHandler,\n )\n // The facts of a contact, company, deal or lead, and an import's field\n // catalog, for another plugin in this process (AGL-2917): read on the core's\n // record-facts seam under the rules every CRM door applies, and written\n // nowhere.\n registerCrmRecordFactsReaders()\n // The CRM as the workspace's record system on the core's record-timeline\n // seam (AGL-2981): another plugin files an email, a reply or a task on a\n // record through it, under the CRM's own scope, dedupe and ceiling.\n registerCrmRecordTimelineWriter()\n // And on the record email-state seam (AGL-3245): a sender that filed a\n // verdict on an address — a bounce, a complaint, an unsubscribe, a\n // do-not-contact mark — stamps the lead and the contact through it.\n registerCrmRecordEmailStateWriter()\n}\n\n// Type-only (AGL-3080): the plugin's entitlement keys, declared by module\n// augmentation, for every program that loads this entry point.\nexport type { crmPlanEntitlements } from './plan-entitlements'\n"],"names":["registerDealFigureReaders","CONTACT_ERASED_MESSAGE","contactFacetPath","composeContactName","CRM_COLLECTIONS","CRM_SALUTATION_PICKLIST","crmReadTokens","isContactLifecycleStage","isOrgWideMember","judgeCrmPicklistValue","normalizeAddress","normalizeContactEmail","normalizePhone","readContactFacet","registerPluginApiRoute","visibleToHost","consentGroupForSite","firebaseAdmin","getOrgForHost","logHostActivity","memberHasOrgPermission","orgDataCollectionForHost","resolveOrgMembership","restampCrmListFieldsAt","isRefusedIdToken","emitHostEvent","captureHostContact","FieldValue","CRM_API_ROUTES","registerCrmServerDeclarations","BUNDLE_ID","CRM_NEXT_ACTIVITY_ROUTE","CRM_LIST_FIELDS_ROUTE","CRM_LEAD_SOURCE_VALUES_ROUTE","CRM_PICKLIST_VALUES_ROUTE","CRM_TASK_ROUTES","crmNextActivityHandler","crmListFieldsHandler","crmLeadSourceValuesHandler","crmPicklistValuesHandler","registerCrmRecordEmailStateWriter","registerCrmRecordTimelineWriter","crmTaskCompleteHandler","crmTaskSaveHandler","crmDealStageHandler","crmEmailSendHandler","CRM_EMAIL_CHECK_ROUTE","crmEmailCheckHandler","leadConvertHandler","leadCreateHandler","CONTACT_EMAIL_HISTORY_ROUTE","contactEmailHistoryHandler","CONTACTS_MERGE_ROUTE","contactsMergeHandler","CRM_ERASE_PERSON_ROUTE","crmErasePersonHandler","CRM_ORG_ACTIVITY_ROUTE","crmOrgActivityHandler","CRM_INBOUND_ADDRESS_ROUTE","crmInboundAddressHandler","crmCompanyDeleteHandler","crmSharingHandler","CRM_SHARING_ROUTE","CONTACT_PHONE_REFUSAL","normalizeTags","typed","crmContactUpdateHandler","readContactCreateProfile","readCrmPicklist","crmContactRemoveHandler","CRM_EMAIL_TEMPLATE_DUPLICATE_ROUTE","crmEmailTemplateDuplicateHandler","registerCrmRecordFactsReaders","crmSuiteRefusal","CRM_RECIPE_INSTALL_ROUTE","CRM_RECIPE_STATUS_ROUTE","crmRecipeInstallHandler","crmRecipeStatusHandler","crmPingHandler","req","res","method","setHeader","status","json","error","ok","plugin","authorizeSiteEditor","hostId","hostSnapshot","authorization","String","headers","idToken","startsWith","slice","length","undefined","uid","email","decoded","app","auth","verifyIdToken","console","firestore","collection","doc","get","exists","memberRole","contactStageHandler","body","trim","contactId","requested","lifecycleStage","clearing","caller","facet","owner","suite","org","contactsRef","snapshot","group","data","groupId","previousStage","changed","ref","update","delete","updatedAt","serverTimestamp","actor","kind","CONTACT_BAND_FULL_MESSAGE","crmContactsCreateHandler","rawPhone","phone","rawStage","profile","extras","fields","name","jobTitle","companyName","companyId","ownerUid","address","tags","marketingConsent","disclosedConsentGroup","resolved","isStaff","member","membership","orgId","reaches","Boolean","hostAccess","allowed","salutation","judged","value","readable","Set","companySnapshot","companies","tokens","visible","Array","isArray","some","token","has","result","source","interaction","summary","Object","fromEntries","entries","filter","key","otherAddress","doNotCall","refused","reason","contacts","arrayUnion","created","type","id","firstName","lastName","registerCrmConsoleApi","ping","contactStage","contactUpdate","contactRemove","companyDelete","save","complete","emailSend","dailyDigest","web","request","crmDailyDigestRoute","machine","taskReminders","crmTaskRemindersRoute","inbound","crmInboundRoute"],"mappings":"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"}