@aglyn/plugins-crm 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -14
- package/src/lib/components/activity-link-fields.d.ts +34 -0
- package/src/lib/components/activity-link-fields.js +141 -0
- package/src/lib/components/activity-link-fields.js.map +1 -0
- package/src/lib/components/activity-list.d.ts +8 -0
- package/src/lib/components/activity-list.js +4 -2
- package/src/lib/components/activity-list.js.map +1 -1
- package/src/lib/components/activity-queries.d.ts +7 -2
- package/src/lib/components/activity-queries.js +7 -3
- package/src/lib/components/activity-queries.js.map +1 -1
- package/src/lib/components/company-contact-roles-card.d.ts +42 -0
- package/src/lib/components/company-contact-roles-card.js +135 -0
- package/src/lib/components/company-contact-roles-card.js.map +1 -0
- package/src/lib/components/company-detail-page.js +12 -1
- package/src/lib/components/company-detail-page.js.map +1 -1
- package/src/lib/components/company-edit-drawer.js +9 -6
- package/src/lib/components/company-edit-drawer.js.map +1 -1
- package/src/lib/components/company-picker.js +4 -2
- package/src/lib/components/company-picker.js.map +1 -1
- package/src/lib/components/contact-associations-card.js +15 -1
- package/src/lib/components/contact-associations-card.js.map +1 -1
- package/src/lib/components/contact-detail-page.js +13 -14
- package/src/lib/components/contact-detail-page.js.map +1 -1
- package/src/lib/components/contact-list-columns.js +5 -4
- package/src/lib/components/contact-list-columns.js.map +1 -1
- package/src/lib/components/contact-properties-card.js +41 -20
- package/src/lib/components/contact-properties-card.js.map +1 -1
- package/src/lib/components/contact-roles-editor.d.ts +54 -0
- package/src/lib/components/contact-roles-editor.js +270 -0
- package/src/lib/components/contact-roles-editor.js.map +1 -0
- package/src/lib/components/contacts-section.js +6 -4
- package/src/lib/components/contacts-section.js.map +1 -1
- package/src/lib/components/crm-console-sections.js +1 -1
- package/src/lib/components/crm-console-sections.js.map +1 -1
- package/src/lib/components/crm-record-picker.d.ts +19 -0
- package/src/lib/components/crm-record-picker.js +40 -11
- package/src/lib/components/crm-record-picker.js.map +1 -1
- package/src/lib/components/crm-related-to-picker.d.ts +33 -0
- package/src/lib/components/crm-related-to-picker.js +135 -0
- package/src/lib/components/crm-related-to-picker.js.map +1 -0
- package/src/lib/components/crm-site-picker.d.ts +13 -1
- package/src/lib/components/crm-site-picker.js +15 -6
- package/src/lib/components/crm-site-picker.js.map +1 -1
- package/src/lib/components/crm-transfer-buttons.d.ts +5 -0
- package/src/lib/components/crm-transfer-buttons.js +9 -6
- package/src/lib/components/crm-transfer-buttons.js.map +1 -1
- package/src/lib/components/deal-contact-roles-card.js +21 -239
- package/src/lib/components/deal-contact-roles-card.js.map +1 -1
- package/src/lib/components/deal-detail-page.js +2 -1
- package/src/lib/components/deal-detail-page.js.map +1 -1
- package/src/lib/components/deal-edit-drawer.js +11 -7
- package/src/lib/components/deal-edit-drawer.js.map +1 -1
- package/src/lib/components/deals-section.js +5 -4
- package/src/lib/components/deals-section.js.map +1 -1
- package/src/lib/components/log-activity-dialog.d.ts +27 -9
- package/src/lib/components/log-activity-dialog.js +96 -32
- package/src/lib/components/log-activity-dialog.js.map +1 -1
- package/src/lib/components/new-contact-drawer.d.ts +7 -3
- package/src/lib/components/new-contact-drawer.js +27 -15
- package/src/lib/components/new-contact-drawer.js.map +1 -1
- package/src/lib/components/pipeline-stages-dialog.js +5 -1
- package/src/lib/components/pipeline-stages-dialog.js.map +1 -1
- package/src/lib/components/record-activity-card.d.ts +11 -2
- package/src/lib/components/record-activity-card.js +90 -5
- package/src/lib/components/record-activity-card.js.map +1 -1
- package/src/lib/components/settings-section.d.ts +4 -2
- package/src/lib/components/settings-section.js +7 -5
- package/src/lib/components/settings-section.js.map +1 -1
- package/src/lib/hooks/crm-activity-client-reads.d.ts +8 -0
- package/src/lib/hooks/crm-activity-client-reads.js +50 -0
- package/src/lib/hooks/crm-activity-client-reads.js.map +1 -0
- package/src/lib/hooks/use-campaign-filing-log.d.ts +2 -1
- package/src/lib/hooks/use-campaign-filing-log.js +10 -3
- package/src/lib/hooks/use-campaign-filing-log.js.map +1 -1
- package/src/lib/hooks/use-crm-org-mount.d.ts +8 -6
- package/src/lib/hooks/use-crm-org-mount.js +2 -1
- package/src/lib/hooks/use-crm-org-mount.js.map +1 -1
- package/src/lib/hooks/use-crm-scope.d.ts +17 -5
- package/src/lib/hooks/use-crm-scope.js +12 -5
- package/src/lib/hooks/use-crm-scope.js.map +1 -1
- package/src/lib/model/contact-holder.d.ts +12 -0
- package/src/lib/model/contact-holder.js +20 -1
- package/src/lib/model/contact-holder.js.map +1 -1
- package/src/lib/model/contact-record.d.ts +5 -0
- package/src/lib/model/contact-record.js +7 -1
- package/src/lib/model/contact-record.js.map +1 -1
- package/src/lib/model/contact-update.d.ts +6 -0
- package/src/lib/model/contact-update.js +1 -0
- package/src/lib/model/contact-update.js.map +1 -1
- package/src/lib/model/crm-activity-link.d.ts +117 -0
- package/src/lib/model/crm-activity-link.js +113 -0
- package/src/lib/model/crm-activity-link.js.map +1 -0
- package/src/lib/model/crm-api.d.ts +4 -1
- package/src/lib/model/crm-api.js +9 -5
- package/src/lib/model/crm-api.js.map +1 -1
- package/src/lib/model/deal-board-model.js +5 -3
- package/src/lib/model/deal-board-model.js.map +1 -1
- package/src/lib/model/deal-contact-roles.d.ts +15 -1
- package/src/lib/model/deal-contact-roles.js +17 -3
- package/src/lib/model/deal-contact-roles.js.map +1 -1
- package/src/lib/model/settle-stage-names.d.ts +25 -0
- package/src/lib/model/settle-stage-names.js +29 -0
- package/src/lib/model/settle-stage-names.js.map +1 -0
- package/src/lib/native-nav.js +1 -1
- package/src/lib/native-nav.js.map +1 -1
- package/src/lib/server/add-contact-by-hand.d.ts +84 -0
- package/src/lib/server/add-contact-by-hand.js +212 -0
- package/src/lib/server/add-contact-by-hand.js.map +1 -0
- package/src/lib/server/api-v1/contacts.js +61 -23
- package/src/lib/server/api-v1/contacts.js.map +1 -1
- package/src/lib/server/api-v1/crm-activities.js +13 -3
- package/src/lib/server/api-v1/crm-activities.js.map +1 -1
- package/src/lib/server/api-v1/crm-pipelines.d.ts +1 -1
- package/src/lib/server/api-v1/crm-pipelines.js +8 -2
- package/src/lib/server/api-v1/crm-pipelines.js.map +1 -1
- package/src/lib/server/api-v1/crm-shared.d.ts +16 -9
- package/src/lib/server/api-v1/crm-shared.js +23 -11
- package/src/lib/server/api-v1/crm-shared.js.map +1 -1
- package/src/lib/server/api-v1/openapi.js +3 -3
- package/src/lib/server/api-v1/openapi.js.map +1 -1
- package/src/lib/server/automation-steps.js +11 -9
- package/src/lib/server/automation-steps.js.map +1 -1
- package/src/lib/server/campaign-filing-activity.js +7 -2
- package/src/lib/server/campaign-filing-activity.js.map +1 -1
- package/src/lib/server/contact-update.d.ts +4 -0
- package/src/lib/server/contact-update.js +71 -10
- package/src/lib/server/contact-update.js.map +1 -1
- package/src/lib/server/convert-host-lead.js +13 -2
- package/src/lib/server/convert-host-lead.js.map +1 -1
- package/src/lib/server/convert-open-lead.js +8 -1
- package/src/lib/server/convert-open-lead.js.map +1 -1
- package/src/lib/server/crm-activity-company.d.ts +25 -0
- package/src/lib/server/crm-activity-company.js +34 -0
- package/src/lib/server/crm-activity-company.js.map +1 -0
- package/src/lib/server/crm-inbound-email.js +21 -15
- package/src/lib/server/crm-inbound-email.js.map +1 -1
- package/src/lib/server/crm-sharing.js +9 -2
- package/src/lib/server/crm-sharing.js.map +1 -1
- package/src/lib/server/email-send.js +10 -8
- package/src/lib/server/email-send.js.map +1 -1
- package/src/lib/server/merge-contacts.js +6 -2
- package/src/lib/server/merge-contacts.js.map +1 -1
- package/src/lib/server/org-caller.d.ts +8 -0
- package/src/lib/server/org-caller.js +10 -0
- package/src/lib/server/org-caller.js.map +1 -1
- package/src/lib/server/record-timeline.d.ts +3 -1
- package/src/lib/server/record-timeline.js +11 -3
- package/src/lib/server/record-timeline.js.map +1 -1
- package/src/lib/server.js +152 -43
- package/src/lib/server.js.map +1 -1
- package/src/lib/transfer/contacts.js +83 -48
- package/src/lib/transfer/contacts.js.map +1 -1
- package/src/lib/transfer/fields.js +1 -2
- package/src/lib/transfer/fields.js.map +1 -1
|
@@ -23,6 +23,7 @@ import { createHash } from "crypto";
|
|
|
23
23
|
import { FieldValue } from "firebase-admin/firestore";
|
|
24
24
|
import { BUNDLE_ID } from "../constants/bundle-common.js";
|
|
25
25
|
import { buildCampaignFilingActivity } from "../model/campaign-filing-activity.js";
|
|
26
|
+
import { crmActivityLinkWithCompany } from "./crm-activity-company.js";
|
|
26
27
|
/** The document id a keyed entry files under: the CRM's namespace, never a raw key. */ export function campaignFilingActivityId(key) {
|
|
27
28
|
return `plg_${createHash('sha256').update(`${BUNDLE_ID}:${key}`).digest('hex').slice(0, 28)}`;
|
|
28
29
|
}
|
|
@@ -49,11 +50,15 @@ import { buildCampaignFilingActivity } from "../model/campaign-filing-activity.j
|
|
|
49
50
|
var _ref, _input_org;
|
|
50
51
|
const orgRef = firestore.collection('orgs').doc(input.orgId);
|
|
51
52
|
const org = (_ref = (_input_org = input.org) != null ? _input_org : (await orgRef.get()).data()) != null ? _ref : null;
|
|
52
|
-
const
|
|
53
|
+
const group = consentGroupForHost(org, input.hostId);
|
|
54
|
+
const visibleTo = crmScopeTokens(org, group);
|
|
53
55
|
if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, input.link))) {
|
|
54
56
|
console.warn('[crm] the filing was not written on the timeline: the record’s log is full', input.link);
|
|
55
57
|
return 0;
|
|
56
58
|
}
|
|
59
|
+
const link = await crmActivityLinkWithCompany(orgRef, input.link, {
|
|
60
|
+
groupId: group.groupId
|
|
61
|
+
});
|
|
57
62
|
const activities = orgRef.collection(CRM_COLLECTIONS.activities);
|
|
58
63
|
let written = 0;
|
|
59
64
|
for (const campaign of input.campaigns){
|
|
@@ -61,7 +66,7 @@ import { buildCampaignFilingActivity } from "../model/campaign-filing-activity.j
|
|
|
61
66
|
const entry = _extends({}, buildCampaignFilingActivity({
|
|
62
67
|
action: input.action,
|
|
63
68
|
campaign,
|
|
64
|
-
link
|
|
69
|
+
link,
|
|
65
70
|
hostId: input.hostId,
|
|
66
71
|
visibleTo,
|
|
67
72
|
atMs: input.atMs,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/campaign-filing-activity.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 { consentGroupForHost } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport {\n CRM_COLLECTIONS,\n crmActivityLogHasRoom,\n crmScopeTokens,\n type CrmActivityLink,\n} from '@aglyn/aglyn/app-utils/crm'\nimport { countCrmActivitiesForRecord } from '@aglyn/tenant-data-admin/server/crm-records'\nimport { readOrgContainers } from '@aglyn/tenant-data-admin/server/org-containers'\nimport { createHash } from 'crypto'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport {\n buildCampaignFilingActivity,\n type CampaignFilingAction,\n type CampaignFilingRef,\n} from '../model/campaign-filing-activity'\n\n/**\n * FILES A FILING ON THE RECORD'S TIMELINE (AGL-3274), from the server.\n *\n * The two doors that file a lead under a campaign on the server — the\n * `leads-create` route behind the New lead drawer, and the conversion's\n * carry of a lead's campaigns onto the contact — write the entry through\n * this, with the scope a record made on the site carries and the CRM's\n * own id as the filing plugin, exactly as the console's hook writes one.\n *\n * ## Once, when the caller says so\n *\n * A member's act is an act each time, and the route's entries are named\n * by `createResourceUid()`, the id every console resource carries. The\n * conversion carry runs from a seam every converting\n * door reaches, and a door that runs twice must not file twice — so a\n * caller that hands in a `dedupeKey` gets a keyed `create()`, and the\n * second run finds its own entry and writes nothing. The key is hashed\n * under the CRM's id the way the timeline seam hashes a plugin's, so no\n * caller's raw key becomes a document id.\n *\n * ## Never throws\n *\n * The membership has landed by the time this runs; an entry that could\n * not be filed is logged, and the record's page shows the campaigns from\n * the document either way. A record at its activity ceiling is refused\n * before anything is written, in the same breath.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CampaignFilingActivitiesInput {\n orgId: string\n /**\n * The organization document, when the caller already holds it — the\n * scope tokens read `defaultResourceScope` and the consent groups off\n * it. Read here otherwise.\n */\n org?: Record<string, unknown> | null\n /** The site the entry carries as provenance. */\n hostId: string\n link: CrmActivityLink\n action: CampaignFilingAction\n campaigns: readonly CampaignFilingRef[]\n atMs: number\n /** The member who did it, or `''` for the conversion. */\n byUid: string\n byName?: string | null\n /**\n * A key that files each campaign's entry once across runs — the carry's\n * — or nothing for a member's act, which is new each time.\n */\n dedupeKey?: string | null\n}\n\n/** The document id a keyed entry files under: the CRM's namespace, never a raw key. */\nexport function campaignFilingActivityId(key: string): string {\n return `plg_${createHash('sha256').update(`${BUNDLE_ID}:${key}`).digest('hex').slice(0, 28)}`\n}\n\n/** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */\nconst ALREADY_EXISTS = 6\n\n/**\n * The names of an org's campaigns — the `campaign` container kind — by id,\n * for the entries that name them. A container that is gone answers its id,\n * so the entry still says which.\n */\nexport async function orgCampaignRefs(\n firestore: Firestore,\n orgId: string,\n campaignIds: readonly string[],\n): Promise<CampaignFilingRef[]> {\n if (!campaignIds.length) return []\n if (!orgId) return campaignIds.map((id) => ({ id, name: id }))\n const found = await readOrgContainers(firestore, 'campaign', orgId, campaignIds)\n return found.map((container) => ({ id: container.id, name: container.name || container.id }))\n}\n\n/** Files one entry per campaign; answers how many were written. */\nexport async function fileCampaignFilingActivities(\n firestore: Firestore,\n input: CampaignFilingActivitiesInput,\n): Promise<number> {\n if (!input.campaigns.length) return 0\n try {\n const orgRef = firestore.collection('orgs').doc(input.orgId)\n const org =\n input.org ?? ((await orgRef.get()).data() as Record<string, unknown> | undefined) ?? null\n const
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/campaign-filing-activity.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 { consentGroupForHost } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport {\n CRM_COLLECTIONS,\n crmActivityLogHasRoom,\n crmScopeTokens,\n type CrmActivityLink,\n} from '@aglyn/aglyn/app-utils/crm'\nimport { countCrmActivitiesForRecord } from '@aglyn/tenant-data-admin/server/crm-records'\nimport { readOrgContainers } from '@aglyn/tenant-data-admin/server/org-containers'\nimport { createHash } from 'crypto'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport {\n buildCampaignFilingActivity,\n type CampaignFilingAction,\n type CampaignFilingRef,\n} from '../model/campaign-filing-activity'\nimport { crmActivityLinkWithCompany } from './crm-activity-company'\n\n/**\n * FILES A FILING ON THE RECORD'S TIMELINE (AGL-3274), from the server.\n *\n * The two doors that file a lead under a campaign on the server — the\n * `leads-create` route behind the New lead drawer, and the conversion's\n * carry of a lead's campaigns onto the contact — write the entry through\n * this, with the scope a record made on the site carries and the CRM's\n * own id as the filing plugin, exactly as the console's hook writes one.\n *\n * ## Once, when the caller says so\n *\n * A member's act is an act each time, and the route's entries are named\n * by `createResourceUid()`, the id every console resource carries. The\n * conversion carry runs from a seam every converting\n * door reaches, and a door that runs twice must not file twice — so a\n * caller that hands in a `dedupeKey` gets a keyed `create()`, and the\n * second run finds its own entry and writes nothing. The key is hashed\n * under the CRM's id the way the timeline seam hashes a plugin's, so no\n * caller's raw key becomes a document id.\n *\n * ## Never throws\n *\n * The membership has landed by the time this runs; an entry that could\n * not be filed is logged, and the record's page shows the campaigns from\n * the document either way. A record at its activity ceiling is refused\n * before anything is written, in the same breath.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CampaignFilingActivitiesInput {\n orgId: string\n /**\n * The organization document, when the caller already holds it — the\n * scope tokens read `defaultResourceScope` and the consent groups off\n * it. Read here otherwise.\n */\n org?: Record<string, unknown> | null\n /** The site the entry carries as provenance. */\n hostId: string\n link: CrmActivityLink\n action: CampaignFilingAction\n campaigns: readonly CampaignFilingRef[]\n atMs: number\n /** The member who did it, or `''` for the conversion. */\n byUid: string\n byName?: string | null\n /**\n * A key that files each campaign's entry once across runs — the carry's\n * — or nothing for a member's act, which is new each time.\n */\n dedupeKey?: string | null\n}\n\n/** The document id a keyed entry files under: the CRM's namespace, never a raw key. */\nexport function campaignFilingActivityId(key: string): string {\n return `plg_${createHash('sha256').update(`${BUNDLE_ID}:${key}`).digest('hex').slice(0, 28)}`\n}\n\n/** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */\nconst ALREADY_EXISTS = 6\n\n/**\n * The names of an org's campaigns — the `campaign` container kind — by id,\n * for the entries that name them. A container that is gone answers its id,\n * so the entry still says which.\n */\nexport async function orgCampaignRefs(\n firestore: Firestore,\n orgId: string,\n campaignIds: readonly string[],\n): Promise<CampaignFilingRef[]> {\n if (!campaignIds.length) return []\n if (!orgId) return campaignIds.map((id) => ({ id, name: id }))\n const found = await readOrgContainers(firestore, 'campaign', orgId, campaignIds)\n return found.map((container) => ({ id: container.id, name: container.name || container.id }))\n}\n\n/** Files one entry per campaign; answers how many were written. */\nexport async function fileCampaignFilingActivities(\n firestore: Firestore,\n input: CampaignFilingActivitiesInput,\n): Promise<number> {\n if (!input.campaigns.length) return 0\n try {\n const orgRef = firestore.collection('orgs').doc(input.orgId)\n const org =\n input.org ?? ((await orgRef.get()).data() as Record<string, unknown> | undefined) ?? null\n const group = consentGroupForHost(org, input.hostId)\n const visibleTo = crmScopeTokens(org, group)\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, input.link))) {\n console.warn('[crm] the filing was not written on the timeline: the record’s log is full', input.link)\n return 0\n }\n const link = await crmActivityLinkWithCompany(orgRef, input.link, { groupId: group.groupId })\n const activities = orgRef.collection(CRM_COLLECTIONS.activities)\n let written = 0\n for (const campaign of input.campaigns) {\n const entry = {\n ...buildCampaignFilingActivity({\n action: input.action,\n campaign,\n link,\n hostId: input.hostId,\n visibleTo,\n atMs: input.atMs,\n byUid: input.byUid,\n byName: input.byName ?? null,\n }),\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n if (input.dedupeKey) {\n try {\n await activities.doc(campaignFilingActivityId(`${input.dedupeKey}:${campaign.id}`)).create(entry)\n written += 1\n } catch (error) {\n if ((error as { code?: unknown })?.code !== ALREADY_EXISTS) throw error\n }\n } else {\n // A member's act, new each time, named by the platform's own id.\n await activities.doc(createResourceUid()).create(entry)\n written += 1\n }\n }\n return written\n } catch (error) {\n console.error('[crm] the filing could not be written on the timeline', input.link, error)\n return 0\n }\n}\n"],"names":["consentGroupForHost","createResourceUid","CRM_COLLECTIONS","crmActivityLogHasRoom","crmScopeTokens","countCrmActivitiesForRecord","readOrgContainers","createHash","FieldValue","BUNDLE_ID","buildCampaignFilingActivity","crmActivityLinkWithCompany","campaignFilingActivityId","key","update","digest","slice","ALREADY_EXISTS","orgCampaignRefs","firestore","orgId","campaignIds","length","map","id","name","found","container","fileCampaignFilingActivities","input","campaigns","orgRef","collection","doc","org","get","data","group","hostId","visibleTo","link","console","warn","groupId","activities","written","campaign","entry","action","atMs","byUid","byName","createdAt","serverTimestamp","updatedAt","dedupeKey","create","error","code"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,mBAAmB,QAAQ,wCAAuC;AAC3E,SAASC,iBAAiB,QAAQ,6CAA4C;AAC9E,SACEC,eAAe,EACfC,qBAAqB,EACrBC,cAAc,QAET,6BAA4B;AACnC,SAASC,2BAA2B,QAAQ,8CAA6C;AACzF,SAASC,iBAAiB,QAAQ,iDAAgD;AAClF,SAASC,UAAU,QAAQ,SAAQ;AACnC,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,SAAS,QAAQ,gCAA4B;AACtD,SACEC,2BAA2B,QAGtB,uCAAmC;AAC1C,SAASC,0BAA0B,QAAQ,4BAAwB;AAwDnE,qFAAqF,GACrF,OAAO,SAASC,yBAAyBC,GAAW;IAClD,OAAO,CAAC,IAAI,EAAEN,WAAW,UAAUO,MAAM,CAAC,GAAGL,UAAU,CAAC,EAAEI,KAAK,EAAEE,MAAM,CAAC,OAAOC,KAAK,CAAC,GAAG,KAAK;AAC/F;AAEA,qFAAqF,GACrF,MAAMC,iBAAiB;AAEvB;;;;CAIC,GACD,OAAO,eAAeC,gBACpBC,SAAoB,EACpBC,KAAa,EACbC,WAA8B;IAE9B,IAAI,CAACA,YAAYC,MAAM,EAAE,OAAO,EAAE;IAClC,IAAI,CAACF,OAAO,OAAOC,YAAYE,GAAG,CAAC,CAACC,KAAQ,CAAA;YAAEA;YAAIC,MAAMD;QAAG,CAAA;IAC3D,MAAME,QAAQ,MAAMpB,kBAAkBa,WAAW,YAAYC,OAAOC;IACpE,OAAOK,MAAMH,GAAG,CAAC,CAACI,YAAe,CAAA;YAAEH,IAAIG,UAAUH,EAAE;YAAEC,MAAME,UAAUF,IAAI,IAAIE,UAAUH,EAAE;QAAC,CAAA;AAC5F;AAEA,iEAAiE,GACjE,OAAO,eAAeI,6BACpBT,SAAoB,EACpBU,KAAoC;IAEpC,IAAI,CAACA,MAAMC,SAAS,CAACR,MAAM,EAAE,OAAO;IACpC,IAAI;YAGAO,MAAAA;QAFF,MAAME,SAASZ,UAAUa,UAAU,CAAC,QAAQC,GAAG,CAACJ,MAAMT,KAAK;QAC3D,MAAMc,OACJL,QAAAA,aAAAA,MAAMK,GAAG,YAATL,aAAc,AAAC,CAAA,MAAME,OAAOI,GAAG,EAAC,EAAGC,IAAI,cAAvCP,OAAqF;QACvF,MAAMQ,QAAQrC,oBAAoBkC,KAAKL,MAAMS,MAAM;QACnD,MAAMC,YAAYnC,eAAe8B,KAAKG;QACtC,IAAI,CAAClC,sBAAsB,MAAME,4BAA4B0B,QAAQF,MAAMW,IAAI,IAAI;YACjFC,QAAQC,IAAI,CAAC,8EAA8Eb,MAAMW,IAAI;YACrG,OAAO;QACT;QACA,MAAMA,OAAO,MAAM7B,2BAA2BoB,QAAQF,MAAMW,IAAI,EAAE;YAAEG,SAASN,MAAMM,OAAO;QAAC;QAC3F,MAAMC,aAAab,OAAOC,UAAU,CAAC9B,gBAAgB0C,UAAU;QAC/D,IAAIC,UAAU;QACd,KAAK,MAAMC,YAAYjB,MAAMC,SAAS,CAAE;gBAU1BD;YATZ,MAAMkB,QAAQ,aACTrC,4BAA4B;gBAC7BsC,QAAQnB,MAAMmB,MAAM;gBACpBF;gBACAN;gBACAF,QAAQT,MAAMS,MAAM;gBACpBC;gBACAU,MAAMpB,MAAMoB,IAAI;gBAChBC,OAAOrB,MAAMqB,KAAK;gBAClBC,MAAM,GAAEtB,gBAAAA,MAAMsB,MAAM,YAAZtB,gBAAgB;YAC1B;gBACAuB,WAAW5C,WAAW6C,eAAe;gBACrCC,WAAW9C,WAAW6C,eAAe;;YAEvC,IAAIxB,MAAM0B,SAAS,EAAE;gBACnB,IAAI;oBACF,MAAMX,WAAWX,GAAG,CAACrB,yBAAyB,GAAGiB,MAAM0B,SAAS,CAAC,CAAC,EAAET,SAAStB,EAAE,EAAE,GAAGgC,MAAM,CAACT;oBAC3FF,WAAW;gBACb,EAAE,OAAOY,OAAO;oBACd,IAAI,CAACA,yBAAD,AAACA,MAA8BC,IAAI,MAAKzC,gBAAgB,MAAMwC;gBACpE;YACF,OAAO;gBACL,iEAAiE;gBACjE,MAAMb,WAAWX,GAAG,CAAChC,qBAAqBuD,MAAM,CAACT;gBACjDF,WAAW;YACb;QACF;QACA,OAAOA;IACT,EAAE,OAAOY,OAAO;QACdhB,QAAQgB,KAAK,CAAC,yDAAyD5B,MAAMW,IAAI,EAAEiB;QACnF,OAAO;IACT;AACF"}
|
|
@@ -73,6 +73,10 @@ import { type ContactCompanyLinkPlan, type ContactCustomValue, type PluginApiHan
|
|
|
73
73
|
import { type ContactUpdateFields } from '../model/contact-update';
|
|
74
74
|
/** The sentence a `name` that contradicts the holder's first and last names is refused with. */
|
|
75
75
|
export declare const CONTACT_COMPOSED_NAME_REFUSAL = "This person's name is made of their first and last name \u2014 change those instead.";
|
|
76
|
+
/** The sentence an address another contact already has is refused with. */
|
|
77
|
+
export declare const CONTACT_EMAIL_TAKEN_REFUSAL = "Another contact already has that email. Open them and merge the two.";
|
|
78
|
+
/** The sentence a change to a shared address is refused with below the whole workspace. */
|
|
79
|
+
export declare const CONTACT_EMAIL_CHANGE_REFUSAL = "Every site shares this email, so changing it needs access to the whole workspace.";
|
|
76
80
|
/**
|
|
77
81
|
* The act a request is refused for on a plan without the CRM: the first
|
|
78
82
|
* field it carries, in the order the route reads them. Every field is the
|
|
@@ -68,8 +68,11 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
68
68
|
* link: summed per company and settled once the contacts are written, so a
|
|
69
69
|
* company deleted in between cannot fail the save of the people at it — the
|
|
70
70
|
* company page's live aggregate is what corrects a count that did not move.
|
|
71
|
-
*/ import { composeContactName, CONTACT_BIRTHDATE_REFUSAL, CONTACT_EXTRA_PHONE_FIELDS, CONTACT_PHONE_FIELD_LABELS, consentGroupForHost, CONTACT_FIELDS_MAX_PER_ORG, containerMembershipValue, contactContainerFieldPath, contactFacetPath, CRM_COLLECTIONS, CRM_LEAD_SOURCE_PICKLIST, CRM_MEDIA_IDS_FIELD, CRM_SALUTATION_PICKLIST, crmReadTokens, effectiveCrmLeadSourcePicklist, isOrgWideMember, judgeCrmLeadSource, judgeCrmPicklistValue, normalizeAddress, normalizeContactBirthdate, normalizeCrmMediaIds, normalizeCrmPicklistLabel, normalizePhone, planContactCompanyLink, readContactCompanyLink, readContactFacet, readCrmCustomInput, visibleToTokens } from "@aglyn/aglyn/server";
|
|
71
|
+
*/ import { composeContactName, CONTACT_BIRTHDATE_REFUSAL, CONTACT_EXTRA_PHONE_FIELDS, CONTACT_PHONE_FIELD_LABELS, consentGroupForHost, CONTACT_FIELDS_MAX_PER_ORG, ORG_CONTACT_HOLDER_ID, containerMembershipValue, contactContainerFieldPath, contactFacetPath, CRM_COLLECTIONS, CRM_LEAD_SOURCE_PICKLIST, CRM_MEDIA_IDS_FIELD, CRM_SALUTATION_PICKLIST, crmReadTokens, effectiveCrmLeadSourcePicklist, isOrgWideMember, judgeCrmLeadSource, judgeCrmPicklistValue, normalizeAddress, normalizeContactBirthdate, normalizeContactEmail, normalizeCrmMediaIds, normalizeCrmPicklistLabel, normalizePhone, planContactCompanyLink, readContactCompanyLink, readContactFacet, readCrmCustomInput, visibleToTokens } from "@aglyn/aglyn/server";
|
|
72
72
|
import { companyContactsCountFields, contactCompanyLinkFields, firebaseAdmin, resolveOrgMembership, restampCrmListFieldsOf } from "@aglyn/tenant-data-admin";
|
|
73
|
+
import { personKey } from "@aglyn/aglyn/app-utils/person-key";
|
|
74
|
+
import { scheduleCapturedEmailCheck } from "@aglyn/tenant-data-admin/server/capture-email-check";
|
|
75
|
+
import { emailIndexBeside, findContactByEmail, writeContactEmailIndex } from "@aglyn/tenant-data-admin/server/contact-email-index";
|
|
73
76
|
import { FieldValue } from "firebase-admin/firestore";
|
|
74
77
|
import { CONTACT_UPDATE_FIELDS, CRM_CONTACT_UPDATE_MAX } from "../model/contact-update.js";
|
|
75
78
|
import { CONTACT_NOTES_MAX, CONTACT_PHONE_REFUSAL, CONTACT_TAGS_MAX, normalizeTags, readBulkTag, typed } from "./contact-profile.js";
|
|
@@ -84,6 +87,7 @@ import { readCrmPicklist } from "./read-picklist.js";
|
|
|
84
87
|
salutation: "Editing a contact's profile",
|
|
85
88
|
firstName: "Editing a contact's profile",
|
|
86
89
|
lastName: "Editing a contact's profile",
|
|
90
|
+
email: "Editing a contact's profile",
|
|
87
91
|
phone: "Editing a contact's profile",
|
|
88
92
|
mobilePhone: "Editing a contact's profile",
|
|
89
93
|
homePhone: "Editing a contact's profile",
|
|
@@ -111,7 +115,8 @@ import { readCrmPicklist } from "./read-picklist.js";
|
|
|
111
115
|
mediaIds: 'Attaching files to a contact'
|
|
112
116
|
};
|
|
113
117
|
/** The sentence a `name` that contradicts the holder's first and last names is refused with. */ export const CONTACT_COMPOSED_NAME_REFUSAL = "This person's name is made of their first and last name — change those instead.";
|
|
114
|
-
/**
|
|
118
|
+
/** The sentence an address another contact already has is refused with. */ export const CONTACT_EMAIL_TAKEN_REFUSAL = 'Another contact already has that email. Open them and merge the two.';
|
|
119
|
+
/** The sentence a change to a shared address is refused with below the whole workspace. */ export const CONTACT_EMAIL_CHANGE_REFUSAL = 'Every site shares this email, so changing it needs access to the whole workspace.';
|
|
115
120
|
/**
|
|
116
121
|
* The act a request is refused for on a plan without the CRM: the first
|
|
117
122
|
* field it carries, in the order the route reads them. Every field is the
|
|
@@ -141,6 +146,14 @@ import { readCrmPicklist } from "./read-picklist.js";
|
|
|
141
146
|
};
|
|
142
147
|
}
|
|
143
148
|
const fields = {};
|
|
149
|
+
if ('email' in raw) {
|
|
150
|
+
const email = normalizeContactEmail(raw['email']);
|
|
151
|
+
if (!email) return {
|
|
152
|
+
ok: false,
|
|
153
|
+
error: 'Enter a valid email address.'
|
|
154
|
+
};
|
|
155
|
+
fields.email = email;
|
|
156
|
+
}
|
|
144
157
|
if ('name' in raw) fields.name = typed(raw['name'], 120);
|
|
145
158
|
for (const key of [
|
|
146
159
|
'phone',
|
|
@@ -531,6 +544,12 @@ export const crmContactUpdateHandler = async (req, res)=>{
|
|
|
531
544
|
return;
|
|
532
545
|
}
|
|
533
546
|
const { fields } = read;
|
|
547
|
+
if (fields.email && contactIds.length !== 1) {
|
|
548
|
+
res.status(400).json({
|
|
549
|
+
error: 'An email address belongs to one contact at a time.'
|
|
550
|
+
});
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
534
553
|
try {
|
|
535
554
|
const writer = await authorizeCrmWriter(req, scope, {
|
|
536
555
|
suiteAct: null
|
|
@@ -617,6 +636,8 @@ export const crmContactUpdateHandler = async (req, res)=>{
|
|
|
617
636
|
const snapshots = await firestore.getAll(...contactIds.map((id)=>contacts.doc(id)));
|
|
618
637
|
const outcomes = new Map();
|
|
619
638
|
const writes = [];
|
|
639
|
+
/** What the one contact answered to before an address change. */ let previousEmail = null;
|
|
640
|
+
/** Whether the row passed and its address really moves. */ let emailMoved = false;
|
|
620
641
|
const rows = [];
|
|
621
642
|
snapshots.forEach((snapshot, index)=>{
|
|
622
643
|
var _snapshot_data;
|
|
@@ -635,16 +656,13 @@ export const crmContactUpdateHandler = async (req, res)=>{
|
|
|
635
656
|
outcomes.set(contactId, refused(contactId, 'That contact is not visible to this site.'));
|
|
636
657
|
return;
|
|
637
658
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
outcomes.set(contactId, refused(contactId, NO_HOLDER_REFUSAL));
|
|
641
|
-
return;
|
|
642
|
-
}
|
|
659
|
+
// A person no site holds has a profile too, kept for the org (AGL-4122).
|
|
660
|
+
const groupId = (siteGroup != null ? siteGroup : contactPrimaryGroup(data, writer.org)).groupId || ORG_CONTACT_HOLDER_ID;
|
|
643
661
|
rows.push({
|
|
644
662
|
contactId,
|
|
645
663
|
snapshot,
|
|
646
664
|
data,
|
|
647
|
-
groupId
|
|
665
|
+
groupId
|
|
648
666
|
});
|
|
649
667
|
});
|
|
650
668
|
for (const { contactId, snapshot, data, groupId } of rows){
|
|
@@ -686,20 +704,63 @@ export const crmContactUpdateHandler = async (req, res)=>{
|
|
|
686
704
|
outcomes.set(contactId, refused(contactId, patch.refused));
|
|
687
705
|
continue;
|
|
688
706
|
}
|
|
707
|
+
/*
|
|
708
|
+
* THE ADDRESS, the one identity every site shares (AGL-4122). Filling in
|
|
709
|
+
* a blank is anybody's who can edit this person. Replacing one changes
|
|
710
|
+
* who the record is for every site, so it needs the whole workspace.
|
|
711
|
+
* Another contact on the address is the owner's to merge, never a write
|
|
712
|
+
* here, which would leave two records answering to one person.
|
|
713
|
+
*/ if (fields.email) {
|
|
714
|
+
previousEmail = normalizeContactEmail(data['email']);
|
|
715
|
+
const replacing = previousEmail && previousEmail !== fields.email;
|
|
716
|
+
if (replacing && scope.level === 'site' && !isOrgWideMember(writer.member)) {
|
|
717
|
+
outcomes.set(contactId, refused(contactId, CONTACT_EMAIL_CHANGE_REFUSAL));
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
const holder = await findContactByEmail(contacts, fields.email);
|
|
721
|
+
if (holder && holder.id !== contactId) {
|
|
722
|
+
outcomes.set(contactId, refused(contactId, CONTACT_EMAIL_TAKEN_REFUSAL));
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
689
726
|
outcomes.set(contactId, {
|
|
690
727
|
contactId,
|
|
691
728
|
ok: true
|
|
692
729
|
});
|
|
693
|
-
|
|
730
|
+
emailMoved = Boolean(fields.email) && fields.email !== previousEmail;
|
|
731
|
+
const update = emailMoved ? _extends({}, patch.update, {
|
|
732
|
+
email: fields.email,
|
|
733
|
+
// The verdict was on the old address, and the next check judges this one.
|
|
734
|
+
emailStatus: FieldValue.delete(),
|
|
735
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
736
|
+
}) : patch.update;
|
|
737
|
+
if (update) {
|
|
694
738
|
writes.push({
|
|
695
739
|
contactId,
|
|
696
740
|
ref: snapshot.ref,
|
|
697
|
-
update
|
|
741
|
+
update,
|
|
698
742
|
counts: patch.counts
|
|
699
743
|
});
|
|
700
744
|
}
|
|
701
745
|
}
|
|
702
746
|
const failed = await commitContactWrites(firestore, writes);
|
|
747
|
+
if (fields.email && emailMoved && !failed.has(contactIds[0])) {
|
|
748
|
+
const index = emailIndexBeside(contacts);
|
|
749
|
+
await writeContactEmailIndex(index, contactIds[0], [
|
|
750
|
+
fields.email
|
|
751
|
+
]);
|
|
752
|
+
// The old address stops resolving here: this person no longer answers to it.
|
|
753
|
+
const previousKey = previousEmail ? personKey(previousEmail) : null;
|
|
754
|
+
if (index && previousKey) {
|
|
755
|
+
await index.doc(previousKey).delete().catch((error)=>{
|
|
756
|
+
console.error('[crm] contact-update could not retire an address', error);
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
scheduleCapturedEmailCheck({
|
|
760
|
+
orgId: writer.orgId,
|
|
761
|
+
email: fields.email
|
|
762
|
+
});
|
|
763
|
+
}
|
|
703
764
|
for (const contactId of failed){
|
|
704
765
|
outcomes.set(contactId, refused(contactId, 'The contact could not be saved.'));
|
|
705
766
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contact-update.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/contact-update` — one holder's profile fields on one or\n * more contacts (AGL-2804).\n *\n * Body: `{ hostId, contactIds, set }` under a site, or `{ orgId, hostId?,\n * contactIds, set }` at the organization level, where `set` is\n * `ContactUpdateFields`. Answers `{ ok, results }`: one outcome per contact,\n * in the order the ids were sent.\n *\n * ## Why every facet write comes here\n *\n * A contact's fields live in the facet of the holder that keeps them —\n * `facets.{groupId}.*` — and the Firestore rules cannot tell one field of a\n * facet from another: the holder is a map key a rule cannot address, and a\n * write's affected keys name only `facets`. So the rules let a client update\n * change nothing in a facet beyond letting a holder go, and the console's\n * edits arrive here, where the plan is asked about the fields the request\n * actually carries.\n *\n * ## Who may call it\n *\n * The CRM writer the task routes admit (`authorizeCrmWriter`): under a site,\n * a member whose role writes org data, who holds `data.manage` and whose\n * access reaches the site; at the organization level, an org-wide member\n * with the permission. Each contact must then be one the caller may read —\n * the rules' `canReadScoped()` — and, under a site, one the site's hub lists.\n *\n * ## The plan\n *\n * Every field is the CRM's (AGL-2790), and a plan without the CRM has none\n * of it (AGL-2851), so it edits no contact: not the owner, company, custom\n * values or files, and not the profile a capture writes (name, phone, job\n * title, address), the tags, the notes or the campaign filing either. A\n * request carrying any field is refused to such a plan once the caller is\n * known, and before a contact is read — staff included, because the plan is\n * a fact about the workspace. Erasing a person is another route, open on\n * every plan.\n *\n * ## One holder, by dotted path\n *\n * Under a site the facet is the site's consent group's; at the organization\n * level it is each contact's own primary holder, the facet the record page\n * shows there. Every field is one dotted path — a nested `facets` object\n * would replace every other holder's map — and a text field sent empty is\n * deleted rather than stored blank, so \"no phone\" has one shape.\n *\n * ## Many contacts, one commit\n *\n * The contacts are read together and written in one batch. A batch that\n * fails is retried contact by contact, so the one that could not be written\n * is named and the rest still land. A company's contacts count follows the\n * link: summed per company and settled once the contacts are written, so a\n * company deleted in between cannot fail the save of the people at it — the\n * company page's live aggregate is what corrects a count that did not move.\n */\n\nimport {\n type AglynPostalAddress,\n composeContactName,\n CONTACT_BIRTHDATE_REFUSAL,\n CONTACT_EXTRA_PHONE_FIELDS,\n CONTACT_PHONE_FIELD_LABELS,\n consentGroupForHost,\n CONTACT_FIELDS_MAX_PER_ORG,\n type ContactCompanyLinkPlan,\n type ContactCustomValue,\n type ContactFieldDefinition,\n containerMembershipValue,\n contactContainerFieldPath,\n contactFacetPath,\n CRM_COLLECTIONS,\n CRM_LEAD_SOURCE_PICKLIST,\n CRM_MEDIA_IDS_FIELD,\n CRM_SALUTATION_PICKLIST,\n crmReadTokens,\n effectiveCrmLeadSourcePicklist,\n isOrgWideMember,\n judgeCrmLeadSource,\n judgeCrmPicklistValue,\n normalizeAddress,\n normalizeContactBirthdate,\n normalizeCrmMediaIds,\n normalizeCrmPicklistLabel,\n normalizePhone,\n type PluginApiHandler,\n planContactCompanyLink,\n readContactCompanyLink,\n readContactFacet,\n readCrmCustomInput,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n companyContactsCountFields,\n contactCompanyLinkFields,\n firebaseAdmin,\n resolveOrgMembership,\n restampCrmListFieldsOf,\n} from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n CONTACT_UPDATE_FIELDS,\n type ContactUpdateFields,\n type ContactUpdateOutcome,\n type ContactUpdateResponse,\n CRM_CONTACT_UPDATE_MAX,\n} from '../model/contact-update'\nimport {\n CONTACT_NOTES_MAX,\n CONTACT_PHONE_REFUSAL,\n CONTACT_TAGS_MAX,\n normalizeTags,\n readBulkTag,\n typed,\n} from './contact-profile'\nimport { readCrmRouteScope } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\nimport { authorizeCrmWriter, canReach, type Writer } from './task-routes'\nimport { contactPrimaryGroup } from '../model/contact-holder'\nimport {\n cachedContactReader,\n CONTACT_REPORTS_TO_LOOP_REFUSAL,\n CONTACT_REPORTS_TO_SELF_REFUSAL,\n contactReportsToLoops,\n} from './contact-reports-to'\nimport { readCrmPicklist } from './read-picklist'\n\n/** What the suite gate names for each field a request can carry. */\nconst SUITE_ACTS: Record<keyof ContactUpdateFields, string> = {\n name: \"Editing a contact's profile\",\n salutation: \"Editing a contact's profile\",\n firstName: \"Editing a contact's profile\",\n lastName: \"Editing a contact's profile\",\n phone: \"Editing a contact's profile\",\n mobilePhone: \"Editing a contact's profile\",\n homePhone: \"Editing a contact's profile\",\n otherPhone: \"Editing a contact's profile\",\n fax: \"Editing a contact's profile\",\n jobTitle: \"Editing a contact's profile\",\n department: \"Editing a contact's profile\",\n birthdate: \"Editing a contact's profile\",\n assistantName: \"Editing a contact's profile\",\n assistantPhone: \"Editing a contact's profile\",\n reportsToContactId: \"Editing a contact's profile\",\n leadSource: \"Editing a contact's profile\",\n address: \"Editing a contact's profile\",\n otherAddress: \"Editing a contact's profile\",\n doNotCall: \"Editing a contact's profile\",\n notes: \"Editing a contact's notes\",\n tags: 'Tagging a contact',\n addTag: 'Tagging a contact',\n removeTag: 'Tagging a contact',\n campaignIds: 'Filing a contact under a campaign',\n ownerUid: \"Assigning a contact's owner\",\n companyId: 'Filing a contact under a company',\n companyName: 'Filing a contact under a company',\n custom: \"Editing a contact's custom fields\",\n mediaIds: 'Attaching files to a contact',\n}\n\n/** The sentence a `name` that contradicts the holder's first and last names is refused with. */\nexport const CONTACT_COMPOSED_NAME_REFUSAL =\n \"This person's name is made of their first and last name — change those instead.\"\n\n/** A contact read at the organization level that no site has captured. */\nconst NO_HOLDER_REFUSAL = 'No site holds this contact yet, so it has no profile to edit.'\n\n/**\n * The act a request is refused for on a plan without the CRM: the first\n * field it carries, in the order the route reads them. Every field is the\n * CRM's, so whichever field a request names is the act its refusal names.\n */\nexport function contactUpdateSuiteAct(fields: ContactUpdateFields): string {\n const field = CONTACT_UPDATE_FIELDS.find((key) => fields[key] !== undefined)\n return field ? SUITE_ACTS[field] : \"Editing a contact's profile\"\n}\n\n/**\n * A body's `set`, read into the fields it names, or the sentence refusing\n * it. A key the route does not save is refused rather than dropped: a save\n * that silently lost a field would read as a save that kept it.\n */\nexport function readContactUpdateFields(\n input: unknown,\n): { ok: true; fields: ContactUpdateFields } | { ok: false; error: string } {\n if (!input || typeof input !== 'object' || Array.isArray(input)) {\n return { ok: false, error: 'Name the fields to save.' }\n }\n const raw = input as Record<string, unknown>\n const unknownKey = Object.keys(raw).find(\n (key) => !(CONTACT_UPDATE_FIELDS as readonly string[]).includes(key),\n )\n if (unknownKey) {\n return { ok: false, error: `\"${unknownKey}\" is not a field a contact's profile saves.` }\n }\n const fields: ContactUpdateFields = {}\n if ('name' in raw) fields.name = typed(raw['name'], 120)\n for (const key of ['phone', ...CONTACT_EXTRA_PHONE_FIELDS] as const) {\n if (!(key in raw)) continue\n const text = typed(raw[key], 40)\n const phone = text ? normalizePhone(text) : ''\n if (phone === null) {\n return {\n ok: false,\n error:\n key === 'phone'\n ? CONTACT_PHONE_REFUSAL\n : `${CONTACT_PHONE_FIELD_LABELS[key]}: ${CONTACT_PHONE_REFUSAL}`,\n }\n }\n fields[key] = phone\n }\n if ('jobTitle' in raw) fields.jobTitle = typed(raw['jobTitle'], 120)\n // Held to the label's shape here and judged against the org's list by the\n // handler, like the lead source (AGL-3515).\n if ('salutation' in raw) fields.salutation = normalizeCrmPicklistLabel(raw['salutation'])\n if ('firstName' in raw) fields.firstName = composeContactName(raw['firstName'], '')\n if ('lastName' in raw) fields.lastName = composeContactName('', raw['lastName'])\n if ('department' in raw) fields.department = typed(raw['department'], 120)\n if ('assistantName' in raw) fields.assistantName = typed(raw['assistantName'], 120)\n if ('birthdate' in raw) {\n const birthdate = normalizeContactBirthdate(raw['birthdate'])\n if (birthdate === null) return { ok: false, error: CONTACT_BIRTHDATE_REFUSAL }\n fields.birthdate = birthdate\n }\n if ('reportsToContactId' in raw) {\n const reportsTo =\n raw['reportsToContactId'] === null ? '' : typed(raw['reportsToContactId'], 200)\n if (reportsTo.includes('/')) {\n return { ok: false, error: 'The contact this person reports to could not be read.' }\n }\n fields.reportsToContactId = reportsTo || null\n }\n if ('doNotCall' in raw) {\n if (typeof raw['doNotCall'] !== 'boolean') {\n return { ok: false, error: 'Do not call must be true or false.' }\n }\n fields.doNotCall = raw['doNotCall']\n }\n // Held to the label's shape here; judged against the org's list by the\n // handler, which alone can read it (AGL-3298).\n if ('leadSource' in raw) fields.leadSource = normalizeCrmPicklistLabel(raw['leadSource'])\n for (const key of ['address', 'otherAddress'] as const) {\n if (!(key in raw)) continue\n const address = raw[key]\n fields[key] =\n address && typeof address === 'object' && !Array.isArray(address)\n ? normalizeAddress(address as AglynPostalAddress)\n : null\n }\n if ('notes' in raw) fields.notes = String(raw['notes'] ?? '').slice(0, CONTACT_NOTES_MAX)\n // One write may carry one transform per field, so the tags are replaced,\n // added to or taken from — never two of those at once.\n if (['tags', 'addTag', 'removeTag'].filter((key) => key in raw).length > 1) {\n return { ok: false, error: 'Send the tags, a tag to add or a tag to remove — one of them.' }\n }\n if ('tags' in raw) {\n if (!Array.isArray(raw['tags'])) return { ok: false, error: 'Tags must be a list.' }\n fields.tags = normalizeTags(raw['tags'])\n }\n for (const key of ['addTag', 'removeTag'] as const) {\n if (!(key in raw)) continue\n const tag = readBulkTag(raw[key])\n if (!tag) return { ok: false, error: 'Name the tag.' }\n fields[key] = tag\n }\n if ('campaignIds' in raw) {\n const ids = raw['campaignIds']\n if (!Array.isArray(ids)) return { ok: false, error: 'Campaigns must be a list.' }\n fields.campaignIds = containerMembershipValue(ids.map((id) => String(id ?? '')))\n }\n if ('ownerUid' in raw) fields.ownerUid = typed(raw['ownerUid'], 128)\n if ('companyId' in raw) {\n const companyId = raw['companyId'] === null ? null : typed(raw['companyId'], 128)\n if (companyId !== null && (!companyId || companyId.includes('/'))) {\n return { ok: false, error: 'The company could not be read.' }\n }\n fields.companyId = companyId\n }\n if ('companyName' in raw) fields.companyName = typed(raw['companyName'], 120)\n if ('custom' in raw) {\n const custom = raw['custom']\n if (!custom || typeof custom !== 'object' || Array.isArray(custom)) {\n return { ok: false, error: 'Custom fields must be values keyed by field key.' }\n }\n fields.custom = custom as Record<string, ContactCustomValue>\n }\n if ('mediaIds' in raw) {\n if (!Array.isArray(raw['mediaIds'])) {\n return { ok: false, error: 'Files must be a list of ids.' }\n }\n fields.mediaIds = normalizeCrmMediaIds(raw['mediaIds'])\n }\n if (!Object.keys(fields).length) return { ok: false, error: 'Name the fields to save.' }\n return { ok: true, fields }\n}\n\n/**\n * Salesforce's standard contact fields (AGL-3515) a create may carry beside\n * the identity — every one `upsertHostContact` writes into the capturing\n * facet. The reports-to is not one: it names another record, which only\n * this route checks the holder can see.\n */\nexport const CONTACT_CREATE_PROFILE_FIELDS = [\n 'salutation',\n 'firstName',\n 'lastName',\n 'department',\n 'mobilePhone',\n 'homePhone',\n 'otherPhone',\n 'fax',\n 'birthdate',\n 'assistantName',\n 'assistantPhone',\n 'otherAddress',\n 'doNotCall',\n] as const satisfies ReadonlyArray<keyof ContactUpdateFields>\n\n/**\n * Those fields of a create's body, read by the same rules an update reads\n * them with — or the sentence refusing one. A body naming none of them\n * reads as no fields.\n */\nexport function readContactCreateProfile(\n body: Record<string, unknown>,\n): { ok: true; fields: ContactUpdateFields } | { ok: false; error: string } {\n const given = Object.fromEntries(\n CONTACT_CREATE_PROFILE_FIELDS.filter((key) => body[key] !== undefined).map((key) => [\n key,\n body[key],\n ]),\n )\n if (!Object.keys(given).length) return { ok: true, fields: {} }\n return readContactUpdateFields(given)\n}\n\n/** The ids a body names, deduplicated — or `null` when any is unreadable or there are too many. */\nfunction readContactIds(value: unknown): string[] | null {\n if (!Array.isArray(value) || !value.length || value.length > CRM_CONTACT_UPDATE_MAX) {\n return null\n }\n const ids = value.map((id) => typed(id, 200))\n if (ids.some((id) => !id || id.includes('/'))) return null\n return [...new Set(ids)]\n}\n\n/**\n * Under a site, whether the caller's access reaches it. The rules could not\n * ask this of a facet write — they cannot see which holder a write is for —\n * and a collaborator on one site must not write another site's notes on a\n * person both of them hold.\n */\nfunction reachesSite(writer: Writer, hostId: string): boolean {\n return (\n writer.staff ||\n isOrgWideMember(writer.member) ||\n Boolean((writer.member?.hostAccess as Record<string, unknown> | undefined)?.[hostId])\n )\n}\n\nconst refused = (contactId: string, error: string): ContactUpdateOutcome => ({\n contactId,\n ok: false,\n error,\n})\n\n/** One contact's planned write, and the company counts it moves. */\nexport interface ContactWrite {\n contactId: string\n ref: FirebaseFirestore.DocumentReference\n update: Record<string, unknown>\n counts: ContactCompanyLinkPlan['counts']\n}\n\n/**\n * One contact's update for one holder, or the sentence refusing the row.\n * `update` is `null` when the document already says everything asked — a tag\n * the row already carries, a link already made.\n */\nexport function contactPatch(\n contact: Record<string, unknown>,\n groupId: string,\n fields: ContactUpdateFields,\n custom: Record<string, ContactCustomValue> | undefined,\n linkedCompanyName: string,\n):\n | { update: Record<string, unknown> | null; counts: ContactCompanyLinkPlan['counts'] }\n | { refused: string } {\n const path = (field: string) => contactFacetPath(groupId, field)\n /** A text field as stored: the value, or the field's absence. */\n const text = (value: string) => value || FieldValue.delete()\n const update: Record<string, unknown> = {}\n let counts: ContactCompanyLinkPlan['counts'] = []\n const stored = readContactFacet(contact, groupId)\n\n /*\n * THE NAME FOLLOWS THE FIRST AND LAST NAMES (AGL-3515). While the holder\n * keeps either, the facet's `name` is their composition, written whenever\n * either moves; a `name` sent beside them must agree, because a save that\n * stored one and showed the other would read as a save that kept both.\n * Both cleared leaves the name as it stands, unless one was sent.\n */\n const namesSent = fields.firstName !== undefined || fields.lastName !== undefined\n const composed = composeContactName(\n fields.firstName ?? stored.firstName,\n fields.lastName ?? stored.lastName,\n )\n if (composed && fields.name !== undefined && fields.name !== composed) {\n return { refused: CONTACT_COMPOSED_NAME_REFUSAL }\n }\n if (fields.firstName !== undefined) update[path('firstName')] = text(fields.firstName)\n if (fields.lastName !== undefined) update[path('lastName')] = text(fields.lastName)\n if (namesSent && composed) update[path('name')] = composed\n else if (fields.name !== undefined && !composed) update[path('name')] = text(fields.name)\n\n if (fields.salutation !== undefined) update[path('salutation')] = text(fields.salutation)\n if (fields.phone !== undefined) {\n update[path('phone')] = text(fields.phone)\n // The search echo — see `HostContact.phone`.\n update['phone'] = text(fields.phone)\n }\n for (const key of CONTACT_EXTRA_PHONE_FIELDS) {\n if (fields[key] !== undefined) update[path(key)] = text(fields[key] ?? '')\n }\n if (fields.jobTitle !== undefined) update[path('jobTitle')] = text(fields.jobTitle)\n if (fields.department !== undefined) update[path('department')] = text(fields.department)\n if (fields.birthdate !== undefined) update[path('birthdate')] = text(fields.birthdate)\n if (fields.assistantName !== undefined) {\n update[path('assistantName')] = text(fields.assistantName)\n }\n if (fields.reportsToContactId !== undefined) {\n update[path('reportsToContactId')] = text(fields.reportsToContactId ?? '')\n }\n if (fields.doNotCall !== undefined) {\n // Stored only as `true` — see `ContactFacet.doNotCall`.\n update[path('doNotCall')] = fields.doNotCall ? true : FieldValue.delete()\n }\n if (fields.leadSource !== undefined) update[path('leadSource')] = text(fields.leadSource)\n if (fields.address !== undefined) {\n update[path('address')] = fields.address ?? FieldValue.delete()\n }\n if (fields.otherAddress !== undefined) {\n update[path('otherAddress')] = fields.otherAddress ?? FieldValue.delete()\n }\n if (fields.notes !== undefined) update[path('notes')] = fields.notes\n if (fields.tags !== undefined) update[path('tags')] = fields.tags\n\n const held = (stored.tags ?? []).map((tag) =>\n String(tag).toLowerCase(),\n )\n if (fields.addTag && !held.includes(fields.addTag)) {\n // The record page's cap, which a selection must not slip past.\n if (held.length >= CONTACT_TAGS_MAX) {\n return { refused: `already has ${CONTACT_TAGS_MAX} tags` }\n }\n update[path('tags')] = FieldValue.arrayUnion(fields.addTag)\n }\n if (fields.removeTag && held.includes(fields.removeTag)) {\n update[path('tags')] = FieldValue.arrayRemove(fields.removeTag)\n }\n if (fields.campaignIds !== undefined) {\n update[contactContainerFieldPath(groupId, 'campaign')] = fields.campaignIds\n }\n if (fields.ownerUid !== undefined) update[path('ownerUid')] = text(fields.ownerUid)\n\n if (fields.companyId !== undefined) {\n const plan = planContactCompanyLink(\n readContactCompanyLink(contact, groupId),\n fields.companyId,\n )\n if (plan) {\n Object.assign(update, contactCompanyLinkFields(plan, groupId))\n counts = plan.counts\n if (fields.companyName === undefined) {\n // The label follows the link: the company's own name, or nothing.\n const label = plan.companyId ? linkedCompanyName : ''\n update[path('companyName')] = text(label)\n // The search echo — see `HostContact.companyName`.\n update['companyName'] = text(label)\n }\n }\n }\n if (fields.companyName !== undefined) {\n update[path('companyName')] = text(fields.companyName)\n update['companyName'] = text(fields.companyName)\n }\n for (const [key, value] of Object.entries(custom ?? {})) {\n update[path(`custom.${key}`)] = value\n }\n if (fields.mediaIds !== undefined) update[path(CRM_MEDIA_IDS_FIELD)] = fields.mediaIds\n\n if (!Object.keys(update).length) return { update: null, counts }\n update['updatedAt'] = FieldValue.serverTimestamp()\n return { update, counts }\n}\n\n/**\n * Every write in one batch, and — when the batch is refused — each on its\n * own, so the contact that could not be written is the only one reported.\n * Answers the ids that were not written.\n */\nasync function commitContactWrites(\n firestore: FirebaseFirestore.Firestore,\n writes: readonly ContactWrite[],\n): Promise<Set<string>> {\n const failed = new Set<string>()\n if (!writes.length) return failed\n const batch = firestore.batch()\n for (const write of writes) batch.update(write.ref, write.update)\n let committed = false\n try {\n await batch.commit()\n committed = true\n } catch {\n // A batch names no document; the pass below finds the one it was.\n }\n if (!committed) {\n for (const write of writes) {\n try {\n await write.ref.update(write.update)\n } catch (error) {\n console.error('[crm] contact-update could not write a contact', write.contactId, error)\n failed.add(write.contactId)\n }\n }\n }\n // The owner, tags, company and custom values are what the Contacts list\n // filters by through `facetKeys` (AGL-3321): restamped from each row.\n await restampCrmListFieldsOf(\n writes.filter((write) => !failed.has(write.contactId)).map((write) => write.ref),\n 'contacts',\n )\n return failed\n}\n\n/** Each company's contacts count moved once, by the sum of what the written links moved. */\nexport async function settleCompanyCounts(\n companies: FirebaseFirestore.CollectionReference,\n writes: readonly ContactWrite[],\n): Promise<void> {\n const deltas = new Map<string, number>()\n for (const write of writes) {\n for (const count of write.counts) {\n deltas.set(count.companyId, (deltas.get(count.companyId) ?? 0) + count.delta)\n }\n }\n await Promise.all(\n [...deltas]\n .filter(([, delta]) => delta !== 0)\n .map(([companyId, delta]) =>\n companies\n .doc(companyId)\n .update(companyContactsCountFields(delta))\n .catch((error: unknown) => {\n console.error('[crm] company contacts count could not move', companyId, delta, error)\n }),\n ),\n )\n}\n\nexport const crmContactUpdateHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const body = (req.body ?? {}) as Record<string, unknown>\n const scope = readCrmRouteScope(body)\n if (!scope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const contactIds = readContactIds(body['contactIds'])\n if (!contactIds) {\n res.status(400).json({ error: `Name between 1 and ${CRM_CONTACT_UPDATE_MAX} contacts.` })\n return\n }\n const read = readContactUpdateFields(body['set'])\n if (read.ok === false) {\n res.status(400).json({ error: read.error })\n return\n }\n const { fields } = read\n\n try {\n const writer = await authorizeCrmWriter(req, scope, { suiteAct: null })\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n if (scope.level === 'site' && !reachesSite(writer, scope.hostId)) {\n res.status(403).json({ error: 'Your access to this organization does not reach this site.' })\n return\n }\n // The plan after the person, and before any contact is read (AGL-2787).\n const suite = crmSuiteRefusal(writer.org, contactUpdateSuiteAct(fields))\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(writer.orgId)\n const siteGroup =\n scope.level === 'site' ? consentGroupForHost(writer.org, scope.hostId) : null\n const siteTokens = siteGroup ? crmReadTokens(siteGroup) : null\n\n let custom: Record<string, ContactCustomValue> | undefined\n if (fields.custom) {\n const definitions = await orgRef\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(CONTACT_FIELDS_MAX_PER_ORG)\n .get()\n const judged = readCrmCustomInput(\n fields.custom,\n definitions.docs.map((doc) => doc.data() as ContactFieldDefinition),\n 'contact',\n )\n if ('errors' in judged) {\n res.status(400).json({\n error: Object.values(judged.errors)[0] ?? 'A custom value could not be saved.',\n })\n return\n }\n custom = judged.values\n }\n\n /*\n * THE ORG'S LEAD SOURCES (AGL-3298), read only when the body names one.\n * Judged per contact below, because a contact keeps the value it\n * already holds even when the list has since deactivated it.\n */\n const leadSources = fields.leadSource\n ? effectiveCrmLeadSourcePicklist(\n (\n await orgRef.collection(CRM_COLLECTIONS.picklists).doc(CRM_LEAD_SOURCE_PICKLIST).get()\n ).data(),\n )\n : null\n\n // The org's salutations (AGL-3515), judged per contact like the lead source.\n const salutations = fields.salutation\n ? await readCrmPicklist(firestore, writer.orgId, CRM_SALUTATION_PICKLIST)\n : null\n\n if (fields.ownerUid) {\n const owner = await resolveOrgMembership(fields.ownerUid, writer.orgId).catch(() => null)\n if (!owner?.member) {\n res.status(400).json({ error: 'The owner is not a member of this organization.' })\n return\n }\n }\n\n // The company a link names has to exist and be visible to the caller and\n // the site, or a person could be filed under a record nobody here can open.\n let linkedCompanyName = ''\n if (fields.companyId) {\n const company = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .doc(fields.companyId)\n .get()\n const tokens = company.exists ? (company.get('visibleTo') as string[] | undefined) : undefined\n const visible =\n company.exists &&\n canReach(writer, tokens) &&\n (!siteTokens || visibleToTokens(tokens, siteTokens))\n if (!visible) {\n res.status(404).json({ error: 'Unknown company' })\n return\n }\n linkedCompanyName = typed(company.get('name'), 120)\n }\n\n const contacts = orgRef.collection('contacts')\n const readContact = cachedContactReader(contacts)\n\n // The manager a reports-to names has to be a contact the caller and the\n // site can see, for the company link's reason (AGL-3515).\n if (fields.reportsToContactId) {\n const manager = await contacts.doc(fields.reportsToContactId).get()\n const tokens = manager.exists ? (manager.get('visibleTo') as string[] | undefined) : undefined\n const visible =\n manager.exists &&\n canReach(writer, tokens) &&\n (!siteTokens || visibleToTokens(tokens, siteTokens))\n if (!visible) {\n res.status(404).json({ error: 'Unknown contact to report to' })\n return\n }\n }\n\n const snapshots = await firestore.getAll(...contactIds.map((id) => contacts.doc(id)))\n const outcomes = new Map<string, ContactUpdateOutcome>()\n const writes: ContactWrite[] = []\n const rows: Array<{\n contactId: string\n snapshot: FirebaseFirestore.DocumentSnapshot\n data: Record<string, unknown>\n groupId: string\n }> = []\n snapshots.forEach((snapshot, index) => {\n const contactId = contactIds[index]\n if (!snapshot.exists) {\n outcomes.set(contactId, refused(contactId, 'That contact no longer exists.'))\n return\n }\n const data = (snapshot.data() ?? {}) as Record<string, unknown>\n const visibleTo = data['visibleTo'] as string[] | undefined\n if (!canReach(writer, visibleTo)) {\n outcomes.set(contactId, refused(contactId, 'That contact is not visible to you.'))\n return\n }\n if (siteTokens && !visibleToTokens(visibleTo, siteTokens)) {\n outcomes.set(contactId, refused(contactId, 'That contact is not visible to this site.'))\n return\n }\n const group = siteGroup ?? contactPrimaryGroup(data, writer.org)\n if (!group.groupId) {\n outcomes.set(contactId, refused(contactId, NO_HOLDER_REFUSAL))\n return\n }\n rows.push({ contactId, snapshot, data, groupId: group.groupId })\n })\n\n for (const { contactId, snapshot, data, groupId } of rows) {\n if (fields.reportsToContactId) {\n if (fields.reportsToContactId === contactId) {\n outcomes.set(contactId, refused(contactId, CONTACT_REPORTS_TO_SELF_REFUSAL))\n continue\n }\n if (await contactReportsToLoops(readContact, contactId, fields.reportsToContactId, groupId)) {\n outcomes.set(contactId, refused(contactId, CONTACT_REPORTS_TO_LOOP_REFUSAL))\n continue\n }\n }\n let rowFields = fields\n if (salutations && fields.salutation) {\n const judged = judgeCrmPicklistValue(\n CRM_SALUTATION_PICKLIST,\n salutations,\n fields.salutation,\n readContactFacet(data, groupId).salutation,\n )\n if (judged.ok === false) {\n outcomes.set(contactId, refused(contactId, judged.error))\n continue\n }\n rowFields = { ...rowFields, salutation: judged.value ?? '' }\n }\n if (leadSources && fields.leadSource) {\n const judged = judgeCrmLeadSource(\n leadSources,\n fields.leadSource,\n readContactFacet(data, groupId).leadSource,\n )\n if (judged.ok === false) {\n outcomes.set(contactId, refused(contactId, judged.error))\n continue\n }\n rowFields = { ...rowFields, leadSource: judged.value ?? '' }\n }\n const patch = contactPatch(data, groupId, rowFields, custom, linkedCompanyName)\n if ('refused' in patch) {\n outcomes.set(contactId, refused(contactId, patch.refused))\n continue\n }\n outcomes.set(contactId, { contactId, ok: true })\n if (patch.update) {\n writes.push({ contactId, ref: snapshot.ref, update: patch.update, counts: patch.counts })\n }\n }\n\n const failed = await commitContactWrites(firestore, writes)\n for (const contactId of failed) {\n outcomes.set(contactId, refused(contactId, 'The contact could not be saved.'))\n }\n await settleCompanyCounts(\n orgRef.collection(CRM_COLLECTIONS.companies),\n writes.filter((write) => !failed.has(write.contactId)),\n )\n\n const answer: ContactUpdateResponse = {\n ok: true,\n results: contactIds.map(\n (contactId) => outcomes.get(contactId) ?? refused(contactId, 'The contact could not be saved.'),\n ),\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] contact-update failed', error)\n res.status(500).json({ error: 'The contact could not be saved.' })\n }\n}\n"],"names":["composeContactName","CONTACT_BIRTHDATE_REFUSAL","CONTACT_EXTRA_PHONE_FIELDS","CONTACT_PHONE_FIELD_LABELS","consentGroupForHost","CONTACT_FIELDS_MAX_PER_ORG","containerMembershipValue","contactContainerFieldPath","contactFacetPath","CRM_COLLECTIONS","CRM_LEAD_SOURCE_PICKLIST","CRM_MEDIA_IDS_FIELD","CRM_SALUTATION_PICKLIST","crmReadTokens","effectiveCrmLeadSourcePicklist","isOrgWideMember","judgeCrmLeadSource","judgeCrmPicklistValue","normalizeAddress","normalizeContactBirthdate","normalizeCrmMediaIds","normalizeCrmPicklistLabel","normalizePhone","planContactCompanyLink","readContactCompanyLink","readContactFacet","readCrmCustomInput","visibleToTokens","companyContactsCountFields","contactCompanyLinkFields","firebaseAdmin","resolveOrgMembership","restampCrmListFieldsOf","FieldValue","CONTACT_UPDATE_FIELDS","CRM_CONTACT_UPDATE_MAX","CONTACT_NOTES_MAX","CONTACT_PHONE_REFUSAL","CONTACT_TAGS_MAX","normalizeTags","readBulkTag","typed","readCrmRouteScope","crmSuiteRefusal","authorizeCrmWriter","canReach","contactPrimaryGroup","cachedContactReader","CONTACT_REPORTS_TO_LOOP_REFUSAL","CONTACT_REPORTS_TO_SELF_REFUSAL","contactReportsToLoops","readCrmPicklist","SUITE_ACTS","name","salutation","firstName","lastName","phone","mobilePhone","homePhone","otherPhone","fax","jobTitle","department","birthdate","assistantName","assistantPhone","reportsToContactId","leadSource","address","otherAddress","doNotCall","notes","tags","addTag","removeTag","campaignIds","ownerUid","companyId","companyName","custom","mediaIds","CONTACT_COMPOSED_NAME_REFUSAL","NO_HOLDER_REFUSAL","contactUpdateSuiteAct","fields","field","find","key","undefined","readContactUpdateFields","input","raw","Array","isArray","ok","error","unknownKey","Object","keys","includes","text","reportsTo","String","slice","filter","length","tag","ids","map","id","CONTACT_CREATE_PROFILE_FIELDS","readContactCreateProfile","body","given","fromEntries","readContactIds","value","some","Set","reachesSite","writer","hostId","staff","member","Boolean","hostAccess","refused","contactId","contactPatch","contact","groupId","linkedCompanyName","stored","path","delete","update","counts","namesSent","composed","held","toLowerCase","arrayUnion","arrayRemove","plan","assign","label","entries","serverTimestamp","commitContactWrites","firestore","writes","failed","batch","write","ref","committed","commit","console","add","has","settleCompanyCounts","companies","deltas","Map","count","set","get","delta","Promise","all","doc","catch","crmContactUpdateHandler","req","res","method","setHeader","status","json","scope","contactIds","read","suiteAct","level","suite","org","app","orgRef","collection","orgId","siteGroup","siteTokens","definitions","contactFields","limit","judged","docs","data","values","errors","leadSources","picklists","salutations","owner","company","tokens","exists","visible","contacts","readContact","manager","snapshots","getAll","outcomes","rows","forEach","snapshot","index","visibleTo","group","push","rowFields","patch","answer","results"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contact-update.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/contact-update` — one holder's profile fields on one or\n * more contacts (AGL-2804).\n *\n * Body: `{ hostId, contactIds, set }` under a site, or `{ orgId, hostId?,\n * contactIds, set }` at the organization level, where `set` is\n * `ContactUpdateFields`. Answers `{ ok, results }`: one outcome per contact,\n * in the order the ids were sent.\n *\n * ## Why every facet write comes here\n *\n * A contact's fields live in the facet of the holder that keeps them —\n * `facets.{groupId}.*` — and the Firestore rules cannot tell one field of a\n * facet from another: the holder is a map key a rule cannot address, and a\n * write's affected keys name only `facets`. So the rules let a client update\n * change nothing in a facet beyond letting a holder go, and the console's\n * edits arrive here, where the plan is asked about the fields the request\n * actually carries.\n *\n * ## Who may call it\n *\n * The CRM writer the task routes admit (`authorizeCrmWriter`): under a site,\n * a member whose role writes org data, who holds `data.manage` and whose\n * access reaches the site; at the organization level, an org-wide member\n * with the permission. Each contact must then be one the caller may read —\n * the rules' `canReadScoped()` — and, under a site, one the site's hub lists.\n *\n * ## The plan\n *\n * Every field is the CRM's (AGL-2790), and a plan without the CRM has none\n * of it (AGL-2851), so it edits no contact: not the owner, company, custom\n * values or files, and not the profile a capture writes (name, phone, job\n * title, address), the tags, the notes or the campaign filing either. A\n * request carrying any field is refused to such a plan once the caller is\n * known, and before a contact is read — staff included, because the plan is\n * a fact about the workspace. Erasing a person is another route, open on\n * every plan.\n *\n * ## One holder, by dotted path\n *\n * Under a site the facet is the site's consent group's; at the organization\n * level it is each contact's own primary holder, the facet the record page\n * shows there. Every field is one dotted path — a nested `facets` object\n * would replace every other holder's map — and a text field sent empty is\n * deleted rather than stored blank, so \"no phone\" has one shape.\n *\n * ## Many contacts, one commit\n *\n * The contacts are read together and written in one batch. A batch that\n * fails is retried contact by contact, so the one that could not be written\n * is named and the rest still land. A company's contacts count follows the\n * link: summed per company and settled once the contacts are written, so a\n * company deleted in between cannot fail the save of the people at it — the\n * company page's live aggregate is what corrects a count that did not move.\n */\n\nimport {\n type AglynPostalAddress,\n composeContactName,\n CONTACT_BIRTHDATE_REFUSAL,\n CONTACT_EXTRA_PHONE_FIELDS,\n CONTACT_PHONE_FIELD_LABELS,\n consentGroupForHost,\n CONTACT_FIELDS_MAX_PER_ORG,\n type ContactCompanyLinkPlan,\n type ContactCustomValue,\n type ContactFieldDefinition,\n ORG_CONTACT_HOLDER_ID,\n containerMembershipValue,\n contactContainerFieldPath,\n contactFacetPath,\n CRM_COLLECTIONS,\n CRM_LEAD_SOURCE_PICKLIST,\n CRM_MEDIA_IDS_FIELD,\n CRM_SALUTATION_PICKLIST,\n crmReadTokens,\n effectiveCrmLeadSourcePicklist,\n isOrgWideMember,\n judgeCrmLeadSource,\n judgeCrmPicklistValue,\n normalizeAddress,\n normalizeContactBirthdate,\n normalizeContactEmail,\n normalizeCrmMediaIds,\n normalizeCrmPicklistLabel,\n normalizePhone,\n type PluginApiHandler,\n planContactCompanyLink,\n readContactCompanyLink,\n readContactFacet,\n readCrmCustomInput,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n companyContactsCountFields,\n contactCompanyLinkFields,\n firebaseAdmin,\n resolveOrgMembership,\n restampCrmListFieldsOf,\n} from '@aglyn/tenant-data-admin'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport { scheduleCapturedEmailCheck } from '@aglyn/tenant-data-admin/server/capture-email-check'\nimport {\n emailIndexBeside,\n findContactByEmail,\n writeContactEmailIndex,\n} from '@aglyn/tenant-data-admin/server/contact-email-index'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n CONTACT_UPDATE_FIELDS,\n type ContactUpdateFields,\n type ContactUpdateOutcome,\n type ContactUpdateResponse,\n CRM_CONTACT_UPDATE_MAX,\n} from '../model/contact-update'\nimport {\n CONTACT_NOTES_MAX,\n CONTACT_PHONE_REFUSAL,\n CONTACT_TAGS_MAX,\n normalizeTags,\n readBulkTag,\n typed,\n} from './contact-profile'\nimport { readCrmRouteScope } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\nimport { authorizeCrmWriter, canReach, type Writer } from './task-routes'\nimport { contactPrimaryGroup } from '../model/contact-holder'\nimport {\n cachedContactReader,\n CONTACT_REPORTS_TO_LOOP_REFUSAL,\n CONTACT_REPORTS_TO_SELF_REFUSAL,\n contactReportsToLoops,\n} from './contact-reports-to'\nimport { readCrmPicklist } from './read-picklist'\n\n/** What the suite gate names for each field a request can carry. */\nconst SUITE_ACTS: Record<keyof ContactUpdateFields, string> = {\n name: \"Editing a contact's profile\",\n salutation: \"Editing a contact's profile\",\n firstName: \"Editing a contact's profile\",\n lastName: \"Editing a contact's profile\",\n email: \"Editing a contact's profile\",\n phone: \"Editing a contact's profile\",\n mobilePhone: \"Editing a contact's profile\",\n homePhone: \"Editing a contact's profile\",\n otherPhone: \"Editing a contact's profile\",\n fax: \"Editing a contact's profile\",\n jobTitle: \"Editing a contact's profile\",\n department: \"Editing a contact's profile\",\n birthdate: \"Editing a contact's profile\",\n assistantName: \"Editing a contact's profile\",\n assistantPhone: \"Editing a contact's profile\",\n reportsToContactId: \"Editing a contact's profile\",\n leadSource: \"Editing a contact's profile\",\n address: \"Editing a contact's profile\",\n otherAddress: \"Editing a contact's profile\",\n doNotCall: \"Editing a contact's profile\",\n notes: \"Editing a contact's notes\",\n tags: 'Tagging a contact',\n addTag: 'Tagging a contact',\n removeTag: 'Tagging a contact',\n campaignIds: 'Filing a contact under a campaign',\n ownerUid: \"Assigning a contact's owner\",\n companyId: 'Filing a contact under a company',\n companyName: 'Filing a contact under a company',\n custom: \"Editing a contact's custom fields\",\n mediaIds: 'Attaching files to a contact',\n}\n\n/** The sentence a `name` that contradicts the holder's first and last names is refused with. */\nexport const CONTACT_COMPOSED_NAME_REFUSAL =\n \"This person's name is made of their first and last name — change those instead.\"\n\n/** The sentence an address another contact already has is refused with. */\nexport const CONTACT_EMAIL_TAKEN_REFUSAL =\n 'Another contact already has that email. Open them and merge the two.'\n\n/** The sentence a change to a shared address is refused with below the whole workspace. */\nexport const CONTACT_EMAIL_CHANGE_REFUSAL =\n 'Every site shares this email, so changing it needs access to the whole workspace.'\n\n\n/**\n * The act a request is refused for on a plan without the CRM: the first\n * field it carries, in the order the route reads them. Every field is the\n * CRM's, so whichever field a request names is the act its refusal names.\n */\nexport function contactUpdateSuiteAct(fields: ContactUpdateFields): string {\n const field = CONTACT_UPDATE_FIELDS.find((key) => fields[key] !== undefined)\n return field ? SUITE_ACTS[field] : \"Editing a contact's profile\"\n}\n\n/**\n * A body's `set`, read into the fields it names, or the sentence refusing\n * it. A key the route does not save is refused rather than dropped: a save\n * that silently lost a field would read as a save that kept it.\n */\nexport function readContactUpdateFields(\n input: unknown,\n): { ok: true; fields: ContactUpdateFields } | { ok: false; error: string } {\n if (!input || typeof input !== 'object' || Array.isArray(input)) {\n return { ok: false, error: 'Name the fields to save.' }\n }\n const raw = input as Record<string, unknown>\n const unknownKey = Object.keys(raw).find(\n (key) => !(CONTACT_UPDATE_FIELDS as readonly string[]).includes(key),\n )\n if (unknownKey) {\n return { ok: false, error: `\"${unknownKey}\" is not a field a contact's profile saves.` }\n }\n const fields: ContactUpdateFields = {}\n if ('email' in raw) {\n const email = normalizeContactEmail(raw['email'])\n if (!email) return { ok: false, error: 'Enter a valid email address.' }\n fields.email = email\n }\n if ('name' in raw) fields.name = typed(raw['name'], 120)\n for (const key of ['phone', ...CONTACT_EXTRA_PHONE_FIELDS] as const) {\n if (!(key in raw)) continue\n const text = typed(raw[key], 40)\n const phone = text ? normalizePhone(text) : ''\n if (phone === null) {\n return {\n ok: false,\n error:\n key === 'phone'\n ? CONTACT_PHONE_REFUSAL\n : `${CONTACT_PHONE_FIELD_LABELS[key]}: ${CONTACT_PHONE_REFUSAL}`,\n }\n }\n fields[key] = phone\n }\n if ('jobTitle' in raw) fields.jobTitle = typed(raw['jobTitle'], 120)\n // Held to the label's shape here and judged against the org's list by the\n // handler, like the lead source (AGL-3515).\n if ('salutation' in raw) fields.salutation = normalizeCrmPicklistLabel(raw['salutation'])\n if ('firstName' in raw) fields.firstName = composeContactName(raw['firstName'], '')\n if ('lastName' in raw) fields.lastName = composeContactName('', raw['lastName'])\n if ('department' in raw) fields.department = typed(raw['department'], 120)\n if ('assistantName' in raw) fields.assistantName = typed(raw['assistantName'], 120)\n if ('birthdate' in raw) {\n const birthdate = normalizeContactBirthdate(raw['birthdate'])\n if (birthdate === null) return { ok: false, error: CONTACT_BIRTHDATE_REFUSAL }\n fields.birthdate = birthdate\n }\n if ('reportsToContactId' in raw) {\n const reportsTo =\n raw['reportsToContactId'] === null ? '' : typed(raw['reportsToContactId'], 200)\n if (reportsTo.includes('/')) {\n return { ok: false, error: 'The contact this person reports to could not be read.' }\n }\n fields.reportsToContactId = reportsTo || null\n }\n if ('doNotCall' in raw) {\n if (typeof raw['doNotCall'] !== 'boolean') {\n return { ok: false, error: 'Do not call must be true or false.' }\n }\n fields.doNotCall = raw['doNotCall']\n }\n // Held to the label's shape here; judged against the org's list by the\n // handler, which alone can read it (AGL-3298).\n if ('leadSource' in raw) fields.leadSource = normalizeCrmPicklistLabel(raw['leadSource'])\n for (const key of ['address', 'otherAddress'] as const) {\n if (!(key in raw)) continue\n const address = raw[key]\n fields[key] =\n address && typeof address === 'object' && !Array.isArray(address)\n ? normalizeAddress(address as AglynPostalAddress)\n : null\n }\n if ('notes' in raw) fields.notes = String(raw['notes'] ?? '').slice(0, CONTACT_NOTES_MAX)\n // One write may carry one transform per field, so the tags are replaced,\n // added to or taken from — never two of those at once.\n if (['tags', 'addTag', 'removeTag'].filter((key) => key in raw).length > 1) {\n return { ok: false, error: 'Send the tags, a tag to add or a tag to remove — one of them.' }\n }\n if ('tags' in raw) {\n if (!Array.isArray(raw['tags'])) return { ok: false, error: 'Tags must be a list.' }\n fields.tags = normalizeTags(raw['tags'])\n }\n for (const key of ['addTag', 'removeTag'] as const) {\n if (!(key in raw)) continue\n const tag = readBulkTag(raw[key])\n if (!tag) return { ok: false, error: 'Name the tag.' }\n fields[key] = tag\n }\n if ('campaignIds' in raw) {\n const ids = raw['campaignIds']\n if (!Array.isArray(ids)) return { ok: false, error: 'Campaigns must be a list.' }\n fields.campaignIds = containerMembershipValue(ids.map((id) => String(id ?? '')))\n }\n if ('ownerUid' in raw) fields.ownerUid = typed(raw['ownerUid'], 128)\n if ('companyId' in raw) {\n const companyId = raw['companyId'] === null ? null : typed(raw['companyId'], 128)\n if (companyId !== null && (!companyId || companyId.includes('/'))) {\n return { ok: false, error: 'The company could not be read.' }\n }\n fields.companyId = companyId\n }\n if ('companyName' in raw) fields.companyName = typed(raw['companyName'], 120)\n if ('custom' in raw) {\n const custom = raw['custom']\n if (!custom || typeof custom !== 'object' || Array.isArray(custom)) {\n return { ok: false, error: 'Custom fields must be values keyed by field key.' }\n }\n fields.custom = custom as Record<string, ContactCustomValue>\n }\n if ('mediaIds' in raw) {\n if (!Array.isArray(raw['mediaIds'])) {\n return { ok: false, error: 'Files must be a list of ids.' }\n }\n fields.mediaIds = normalizeCrmMediaIds(raw['mediaIds'])\n }\n if (!Object.keys(fields).length) return { ok: false, error: 'Name the fields to save.' }\n return { ok: true, fields }\n}\n\n/**\n * Salesforce's standard contact fields (AGL-3515) a create may carry beside\n * the identity — every one `upsertHostContact` writes into the capturing\n * facet. The reports-to is not one: it names another record, which only\n * this route checks the holder can see.\n */\nexport const CONTACT_CREATE_PROFILE_FIELDS = [\n 'salutation',\n 'firstName',\n 'lastName',\n 'department',\n 'mobilePhone',\n 'homePhone',\n 'otherPhone',\n 'fax',\n 'birthdate',\n 'assistantName',\n 'assistantPhone',\n 'otherAddress',\n 'doNotCall',\n] as const satisfies ReadonlyArray<keyof ContactUpdateFields>\n\n/**\n * Those fields of a create's body, read by the same rules an update reads\n * them with — or the sentence refusing one. A body naming none of them\n * reads as no fields.\n */\nexport function readContactCreateProfile(\n body: Record<string, unknown>,\n): { ok: true; fields: ContactUpdateFields } | { ok: false; error: string } {\n const given = Object.fromEntries(\n CONTACT_CREATE_PROFILE_FIELDS.filter((key) => body[key] !== undefined).map((key) => [\n key,\n body[key],\n ]),\n )\n if (!Object.keys(given).length) return { ok: true, fields: {} }\n return readContactUpdateFields(given)\n}\n\n/** The ids a body names, deduplicated — or `null` when any is unreadable or there are too many. */\nfunction readContactIds(value: unknown): string[] | null {\n if (!Array.isArray(value) || !value.length || value.length > CRM_CONTACT_UPDATE_MAX) {\n return null\n }\n const ids = value.map((id) => typed(id, 200))\n if (ids.some((id) => !id || id.includes('/'))) return null\n return [...new Set(ids)]\n}\n\n/**\n * Under a site, whether the caller's access reaches it. The rules could not\n * ask this of a facet write — they cannot see which holder a write is for —\n * and a collaborator on one site must not write another site's notes on a\n * person both of them hold.\n */\nfunction reachesSite(writer: Writer, hostId: string): boolean {\n return (\n writer.staff ||\n isOrgWideMember(writer.member) ||\n Boolean((writer.member?.hostAccess as Record<string, unknown> | undefined)?.[hostId])\n )\n}\n\nconst refused = (contactId: string, error: string): ContactUpdateOutcome => ({\n contactId,\n ok: false,\n error,\n})\n\n/** One contact's planned write, and the company counts it moves. */\nexport interface ContactWrite {\n contactId: string\n ref: FirebaseFirestore.DocumentReference\n update: Record<string, unknown>\n counts: ContactCompanyLinkPlan['counts']\n}\n\n/**\n * One contact's update for one holder, or the sentence refusing the row.\n * `update` is `null` when the document already says everything asked — a tag\n * the row already carries, a link already made.\n */\nexport function contactPatch(\n contact: Record<string, unknown>,\n groupId: string,\n fields: ContactUpdateFields,\n custom: Record<string, ContactCustomValue> | undefined,\n linkedCompanyName: string,\n):\n | { update: Record<string, unknown> | null; counts: ContactCompanyLinkPlan['counts'] }\n | { refused: string } {\n const path = (field: string) => contactFacetPath(groupId, field)\n /** A text field as stored: the value, or the field's absence. */\n const text = (value: string) => value || FieldValue.delete()\n const update: Record<string, unknown> = {}\n let counts: ContactCompanyLinkPlan['counts'] = []\n const stored = readContactFacet(contact, groupId)\n\n /*\n * THE NAME FOLLOWS THE FIRST AND LAST NAMES (AGL-3515). While the holder\n * keeps either, the facet's `name` is their composition, written whenever\n * either moves; a `name` sent beside them must agree, because a save that\n * stored one and showed the other would read as a save that kept both.\n * Both cleared leaves the name as it stands, unless one was sent.\n */\n const namesSent = fields.firstName !== undefined || fields.lastName !== undefined\n const composed = composeContactName(\n fields.firstName ?? stored.firstName,\n fields.lastName ?? stored.lastName,\n )\n if (composed && fields.name !== undefined && fields.name !== composed) {\n return { refused: CONTACT_COMPOSED_NAME_REFUSAL }\n }\n if (fields.firstName !== undefined) update[path('firstName')] = text(fields.firstName)\n if (fields.lastName !== undefined) update[path('lastName')] = text(fields.lastName)\n if (namesSent && composed) update[path('name')] = composed\n else if (fields.name !== undefined && !composed) update[path('name')] = text(fields.name)\n\n if (fields.salutation !== undefined) update[path('salutation')] = text(fields.salutation)\n if (fields.phone !== undefined) {\n update[path('phone')] = text(fields.phone)\n // The search echo — see `HostContact.phone`.\n update['phone'] = text(fields.phone)\n }\n for (const key of CONTACT_EXTRA_PHONE_FIELDS) {\n if (fields[key] !== undefined) update[path(key)] = text(fields[key] ?? '')\n }\n if (fields.jobTitle !== undefined) update[path('jobTitle')] = text(fields.jobTitle)\n if (fields.department !== undefined) update[path('department')] = text(fields.department)\n if (fields.birthdate !== undefined) update[path('birthdate')] = text(fields.birthdate)\n if (fields.assistantName !== undefined) {\n update[path('assistantName')] = text(fields.assistantName)\n }\n if (fields.reportsToContactId !== undefined) {\n update[path('reportsToContactId')] = text(fields.reportsToContactId ?? '')\n }\n if (fields.doNotCall !== undefined) {\n // Stored only as `true` — see `ContactFacet.doNotCall`.\n update[path('doNotCall')] = fields.doNotCall ? true : FieldValue.delete()\n }\n if (fields.leadSource !== undefined) update[path('leadSource')] = text(fields.leadSource)\n if (fields.address !== undefined) {\n update[path('address')] = fields.address ?? FieldValue.delete()\n }\n if (fields.otherAddress !== undefined) {\n update[path('otherAddress')] = fields.otherAddress ?? FieldValue.delete()\n }\n if (fields.notes !== undefined) update[path('notes')] = fields.notes\n if (fields.tags !== undefined) update[path('tags')] = fields.tags\n\n const held = (stored.tags ?? []).map((tag) =>\n String(tag).toLowerCase(),\n )\n if (fields.addTag && !held.includes(fields.addTag)) {\n // The record page's cap, which a selection must not slip past.\n if (held.length >= CONTACT_TAGS_MAX) {\n return { refused: `already has ${CONTACT_TAGS_MAX} tags` }\n }\n update[path('tags')] = FieldValue.arrayUnion(fields.addTag)\n }\n if (fields.removeTag && held.includes(fields.removeTag)) {\n update[path('tags')] = FieldValue.arrayRemove(fields.removeTag)\n }\n if (fields.campaignIds !== undefined) {\n update[contactContainerFieldPath(groupId, 'campaign')] = fields.campaignIds\n }\n if (fields.ownerUid !== undefined) update[path('ownerUid')] = text(fields.ownerUid)\n\n if (fields.companyId !== undefined) {\n const plan = planContactCompanyLink(\n readContactCompanyLink(contact, groupId),\n fields.companyId,\n )\n if (plan) {\n Object.assign(update, contactCompanyLinkFields(plan, groupId))\n counts = plan.counts\n if (fields.companyName === undefined) {\n // The label follows the link: the company's own name, or nothing.\n const label = plan.companyId ? linkedCompanyName : ''\n update[path('companyName')] = text(label)\n // The search echo — see `HostContact.companyName`.\n update['companyName'] = text(label)\n }\n }\n }\n if (fields.companyName !== undefined) {\n update[path('companyName')] = text(fields.companyName)\n update['companyName'] = text(fields.companyName)\n }\n for (const [key, value] of Object.entries(custom ?? {})) {\n update[path(`custom.${key}`)] = value\n }\n if (fields.mediaIds !== undefined) update[path(CRM_MEDIA_IDS_FIELD)] = fields.mediaIds\n\n if (!Object.keys(update).length) return { update: null, counts }\n update['updatedAt'] = FieldValue.serverTimestamp()\n return { update, counts }\n}\n\n/**\n * Every write in one batch, and — when the batch is refused — each on its\n * own, so the contact that could not be written is the only one reported.\n * Answers the ids that were not written.\n */\nasync function commitContactWrites(\n firestore: FirebaseFirestore.Firestore,\n writes: readonly ContactWrite[],\n): Promise<Set<string>> {\n const failed = new Set<string>()\n if (!writes.length) return failed\n const batch = firestore.batch()\n for (const write of writes) batch.update(write.ref, write.update)\n let committed = false\n try {\n await batch.commit()\n committed = true\n } catch {\n // A batch names no document; the pass below finds the one it was.\n }\n if (!committed) {\n for (const write of writes) {\n try {\n await write.ref.update(write.update)\n } catch (error) {\n console.error('[crm] contact-update could not write a contact', write.contactId, error)\n failed.add(write.contactId)\n }\n }\n }\n // The owner, tags, company and custom values are what the Contacts list\n // filters by through `facetKeys` (AGL-3321): restamped from each row.\n await restampCrmListFieldsOf(\n writes.filter((write) => !failed.has(write.contactId)).map((write) => write.ref),\n 'contacts',\n )\n return failed\n}\n\n/** Each company's contacts count moved once, by the sum of what the written links moved. */\nexport async function settleCompanyCounts(\n companies: FirebaseFirestore.CollectionReference,\n writes: readonly ContactWrite[],\n): Promise<void> {\n const deltas = new Map<string, number>()\n for (const write of writes) {\n for (const count of write.counts) {\n deltas.set(count.companyId, (deltas.get(count.companyId) ?? 0) + count.delta)\n }\n }\n await Promise.all(\n [...deltas]\n .filter(([, delta]) => delta !== 0)\n .map(([companyId, delta]) =>\n companies\n .doc(companyId)\n .update(companyContactsCountFields(delta))\n .catch((error: unknown) => {\n console.error('[crm] company contacts count could not move', companyId, delta, error)\n }),\n ),\n )\n}\n\nexport const crmContactUpdateHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const body = (req.body ?? {}) as Record<string, unknown>\n const scope = readCrmRouteScope(body)\n if (!scope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const contactIds = readContactIds(body['contactIds'])\n if (!contactIds) {\n res.status(400).json({ error: `Name between 1 and ${CRM_CONTACT_UPDATE_MAX} contacts.` })\n return\n }\n const read = readContactUpdateFields(body['set'])\n if (read.ok === false) {\n res.status(400).json({ error: read.error })\n return\n }\n const { fields } = read\n if (fields.email && contactIds.length !== 1) {\n res.status(400).json({ error: 'An email address belongs to one contact at a time.' })\n return\n }\n\n try {\n const writer = await authorizeCrmWriter(req, scope, { suiteAct: null })\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n if (scope.level === 'site' && !reachesSite(writer, scope.hostId)) {\n res.status(403).json({ error: 'Your access to this organization does not reach this site.' })\n return\n }\n // The plan after the person, and before any contact is read (AGL-2787).\n const suite = crmSuiteRefusal(writer.org, contactUpdateSuiteAct(fields))\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(writer.orgId)\n const siteGroup =\n scope.level === 'site' ? consentGroupForHost(writer.org, scope.hostId) : null\n const siteTokens = siteGroup ? crmReadTokens(siteGroup) : null\n\n let custom: Record<string, ContactCustomValue> | undefined\n if (fields.custom) {\n const definitions = await orgRef\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(CONTACT_FIELDS_MAX_PER_ORG)\n .get()\n const judged = readCrmCustomInput(\n fields.custom,\n definitions.docs.map((doc) => doc.data() as ContactFieldDefinition),\n 'contact',\n )\n if ('errors' in judged) {\n res.status(400).json({\n error: Object.values(judged.errors)[0] ?? 'A custom value could not be saved.',\n })\n return\n }\n custom = judged.values\n }\n\n /*\n * THE ORG'S LEAD SOURCES (AGL-3298), read only when the body names one.\n * Judged per contact below, because a contact keeps the value it\n * already holds even when the list has since deactivated it.\n */\n const leadSources = fields.leadSource\n ? effectiveCrmLeadSourcePicklist(\n (\n await orgRef.collection(CRM_COLLECTIONS.picklists).doc(CRM_LEAD_SOURCE_PICKLIST).get()\n ).data(),\n )\n : null\n\n // The org's salutations (AGL-3515), judged per contact like the lead source.\n const salutations = fields.salutation\n ? await readCrmPicklist(firestore, writer.orgId, CRM_SALUTATION_PICKLIST)\n : null\n\n if (fields.ownerUid) {\n const owner = await resolveOrgMembership(fields.ownerUid, writer.orgId).catch(() => null)\n if (!owner?.member) {\n res.status(400).json({ error: 'The owner is not a member of this organization.' })\n return\n }\n }\n\n // The company a link names has to exist and be visible to the caller and\n // the site, or a person could be filed under a record nobody here can open.\n let linkedCompanyName = ''\n if (fields.companyId) {\n const company = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .doc(fields.companyId)\n .get()\n const tokens = company.exists ? (company.get('visibleTo') as string[] | undefined) : undefined\n const visible =\n company.exists &&\n canReach(writer, tokens) &&\n (!siteTokens || visibleToTokens(tokens, siteTokens))\n if (!visible) {\n res.status(404).json({ error: 'Unknown company' })\n return\n }\n linkedCompanyName = typed(company.get('name'), 120)\n }\n\n const contacts = orgRef.collection('contacts')\n const readContact = cachedContactReader(contacts)\n\n // The manager a reports-to names has to be a contact the caller and the\n // site can see, for the company link's reason (AGL-3515).\n if (fields.reportsToContactId) {\n const manager = await contacts.doc(fields.reportsToContactId).get()\n const tokens = manager.exists ? (manager.get('visibleTo') as string[] | undefined) : undefined\n const visible =\n manager.exists &&\n canReach(writer, tokens) &&\n (!siteTokens || visibleToTokens(tokens, siteTokens))\n if (!visible) {\n res.status(404).json({ error: 'Unknown contact to report to' })\n return\n }\n }\n\n const snapshots = await firestore.getAll(...contactIds.map((id) => contacts.doc(id)))\n const outcomes = new Map<string, ContactUpdateOutcome>()\n const writes: ContactWrite[] = []\n /** What the one contact answered to before an address change. */\n let previousEmail: string | null = null\n /** Whether the row passed and its address really moves. */\n let emailMoved = false\n const rows: Array<{\n contactId: string\n snapshot: FirebaseFirestore.DocumentSnapshot\n data: Record<string, unknown>\n groupId: string\n }> = []\n snapshots.forEach((snapshot, index) => {\n const contactId = contactIds[index]\n if (!snapshot.exists) {\n outcomes.set(contactId, refused(contactId, 'That contact no longer exists.'))\n return\n }\n const data = (snapshot.data() ?? {}) as Record<string, unknown>\n const visibleTo = data['visibleTo'] as string[] | undefined\n if (!canReach(writer, visibleTo)) {\n outcomes.set(contactId, refused(contactId, 'That contact is not visible to you.'))\n return\n }\n if (siteTokens && !visibleToTokens(visibleTo, siteTokens)) {\n outcomes.set(contactId, refused(contactId, 'That contact is not visible to this site.'))\n return\n }\n // A person no site holds has a profile too, kept for the org (AGL-4122).\n const groupId =\n (siteGroup ?? contactPrimaryGroup(data, writer.org)).groupId || ORG_CONTACT_HOLDER_ID\n rows.push({ contactId, snapshot, data, groupId })\n })\n\n for (const { contactId, snapshot, data, groupId } of rows) {\n if (fields.reportsToContactId) {\n if (fields.reportsToContactId === contactId) {\n outcomes.set(contactId, refused(contactId, CONTACT_REPORTS_TO_SELF_REFUSAL))\n continue\n }\n if (await contactReportsToLoops(readContact, contactId, fields.reportsToContactId, groupId)) {\n outcomes.set(contactId, refused(contactId, CONTACT_REPORTS_TO_LOOP_REFUSAL))\n continue\n }\n }\n let rowFields = fields\n if (salutations && fields.salutation) {\n const judged = judgeCrmPicklistValue(\n CRM_SALUTATION_PICKLIST,\n salutations,\n fields.salutation,\n readContactFacet(data, groupId).salutation,\n )\n if (judged.ok === false) {\n outcomes.set(contactId, refused(contactId, judged.error))\n continue\n }\n rowFields = { ...rowFields, salutation: judged.value ?? '' }\n }\n if (leadSources && fields.leadSource) {\n const judged = judgeCrmLeadSource(\n leadSources,\n fields.leadSource,\n readContactFacet(data, groupId).leadSource,\n )\n if (judged.ok === false) {\n outcomes.set(contactId, refused(contactId, judged.error))\n continue\n }\n rowFields = { ...rowFields, leadSource: judged.value ?? '' }\n }\n const patch = contactPatch(data, groupId, rowFields, custom, linkedCompanyName)\n if ('refused' in patch) {\n outcomes.set(contactId, refused(contactId, patch.refused))\n continue\n }\n /*\n * THE ADDRESS, the one identity every site shares (AGL-4122). Filling in\n * a blank is anybody's who can edit this person. Replacing one changes\n * who the record is for every site, so it needs the whole workspace.\n * Another contact on the address is the owner's to merge, never a write\n * here, which would leave two records answering to one person.\n */\n if (fields.email) {\n previousEmail = normalizeContactEmail(data['email'])\n const replacing = previousEmail && previousEmail !== fields.email\n if (replacing && scope.level === 'site' && !isOrgWideMember(writer.member)) {\n outcomes.set(contactId, refused(contactId, CONTACT_EMAIL_CHANGE_REFUSAL))\n continue\n }\n const holder = await findContactByEmail(contacts, fields.email)\n if (holder && holder.id !== contactId) {\n outcomes.set(contactId, refused(contactId, CONTACT_EMAIL_TAKEN_REFUSAL))\n continue\n }\n }\n outcomes.set(contactId, { contactId, ok: true })\n emailMoved = Boolean(fields.email) && fields.email !== previousEmail\n const update =\n emailMoved\n ? {\n ...patch.update,\n email: fields.email,\n // The verdict was on the old address, and the next check judges this one.\n emailStatus: FieldValue.delete(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n : patch.update\n if (update) {\n writes.push({ contactId, ref: snapshot.ref, update, counts: patch.counts })\n }\n }\n\n const failed = await commitContactWrites(firestore, writes)\n if (fields.email && emailMoved && !failed.has(contactIds[0])) {\n const index = emailIndexBeside(contacts)\n await writeContactEmailIndex(index, contactIds[0], [fields.email])\n // The old address stops resolving here: this person no longer answers to it.\n const previousKey = previousEmail ? personKey(previousEmail) : null\n if (index && previousKey) {\n await index\n .doc(previousKey)\n .delete()\n .catch((error: unknown) => {\n console.error('[crm] contact-update could not retire an address', error)\n })\n }\n scheduleCapturedEmailCheck({ orgId: writer.orgId, email: fields.email })\n }\n for (const contactId of failed) {\n outcomes.set(contactId, refused(contactId, 'The contact could not be saved.'))\n }\n await settleCompanyCounts(\n orgRef.collection(CRM_COLLECTIONS.companies),\n writes.filter((write) => !failed.has(write.contactId)),\n )\n\n const answer: ContactUpdateResponse = {\n ok: true,\n results: contactIds.map(\n (contactId) => outcomes.get(contactId) ?? refused(contactId, 'The contact could not be saved.'),\n ),\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] contact-update failed', error)\n res.status(500).json({ error: 'The contact could not be saved.' })\n }\n}\n"],"names":["composeContactName","CONTACT_BIRTHDATE_REFUSAL","CONTACT_EXTRA_PHONE_FIELDS","CONTACT_PHONE_FIELD_LABELS","consentGroupForHost","CONTACT_FIELDS_MAX_PER_ORG","ORG_CONTACT_HOLDER_ID","containerMembershipValue","contactContainerFieldPath","contactFacetPath","CRM_COLLECTIONS","CRM_LEAD_SOURCE_PICKLIST","CRM_MEDIA_IDS_FIELD","CRM_SALUTATION_PICKLIST","crmReadTokens","effectiveCrmLeadSourcePicklist","isOrgWideMember","judgeCrmLeadSource","judgeCrmPicklistValue","normalizeAddress","normalizeContactBirthdate","normalizeContactEmail","normalizeCrmMediaIds","normalizeCrmPicklistLabel","normalizePhone","planContactCompanyLink","readContactCompanyLink","readContactFacet","readCrmCustomInput","visibleToTokens","companyContactsCountFields","contactCompanyLinkFields","firebaseAdmin","resolveOrgMembership","restampCrmListFieldsOf","personKey","scheduleCapturedEmailCheck","emailIndexBeside","findContactByEmail","writeContactEmailIndex","FieldValue","CONTACT_UPDATE_FIELDS","CRM_CONTACT_UPDATE_MAX","CONTACT_NOTES_MAX","CONTACT_PHONE_REFUSAL","CONTACT_TAGS_MAX","normalizeTags","readBulkTag","typed","readCrmRouteScope","crmSuiteRefusal","authorizeCrmWriter","canReach","contactPrimaryGroup","cachedContactReader","CONTACT_REPORTS_TO_LOOP_REFUSAL","CONTACT_REPORTS_TO_SELF_REFUSAL","contactReportsToLoops","readCrmPicklist","SUITE_ACTS","name","salutation","firstName","lastName","email","phone","mobilePhone","homePhone","otherPhone","fax","jobTitle","department","birthdate","assistantName","assistantPhone","reportsToContactId","leadSource","address","otherAddress","doNotCall","notes","tags","addTag","removeTag","campaignIds","ownerUid","companyId","companyName","custom","mediaIds","CONTACT_COMPOSED_NAME_REFUSAL","CONTACT_EMAIL_TAKEN_REFUSAL","CONTACT_EMAIL_CHANGE_REFUSAL","contactUpdateSuiteAct","fields","field","find","key","undefined","readContactUpdateFields","input","raw","Array","isArray","ok","error","unknownKey","Object","keys","includes","text","reportsTo","String","slice","filter","length","tag","ids","map","id","CONTACT_CREATE_PROFILE_FIELDS","readContactCreateProfile","body","given","fromEntries","readContactIds","value","some","Set","reachesSite","writer","hostId","staff","member","Boolean","hostAccess","refused","contactId","contactPatch","contact","groupId","linkedCompanyName","stored","path","delete","update","counts","namesSent","composed","held","toLowerCase","arrayUnion","arrayRemove","plan","assign","label","entries","serverTimestamp","commitContactWrites","firestore","writes","failed","batch","write","ref","committed","commit","console","add","has","settleCompanyCounts","companies","deltas","Map","count","set","get","delta","Promise","all","doc","catch","crmContactUpdateHandler","req","res","method","setHeader","status","json","scope","contactIds","read","suiteAct","level","suite","org","app","orgRef","collection","orgId","siteGroup","siteTokens","definitions","contactFields","limit","judged","docs","data","values","errors","leadSources","picklists","salutations","owner","company","tokens","exists","visible","contacts","readContact","manager","snapshots","getAll","outcomes","previousEmail","emailMoved","rows","forEach","snapshot","index","visibleTo","push","rowFields","patch","replacing","holder","emailStatus","updatedAt","previousKey","answer","results"],"mappings":"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"}
|
|
@@ -99,6 +99,7 @@ import { assignOwnerForCapture, notifyRecordAssigned } from "./assign-contact-ow
|
|
|
99
99
|
import { captureHostContact } from "./capture-host-contact.js";
|
|
100
100
|
import { readCrmPicklist } from "./read-picklist.js";
|
|
101
101
|
import { handOffLeadRecords } from "@aglyn/tenant-runtime/hand-off-lead";
|
|
102
|
+
import { crmActivityLinkWithCompany } from "./crm-activity-company.js";
|
|
102
103
|
import { readContainerIds } from "@aglyn/aglyn/app-utils/container-membership";
|
|
103
104
|
/**
|
|
104
105
|
* The stage a new deal opens in.
|
|
@@ -635,13 +636,23 @@ import { readContainerIds } from "@aglyn/aglyn/app-utils/container-membership";
|
|
|
635
636
|
* contact, and every plugin with a listener — Sequences, for an
|
|
636
637
|
* enrollment naming the lead — re-points its own records. After the
|
|
637
638
|
* stamp, so a listener reading the lead finds the conversion on it; it
|
|
638
|
-
* never throws.
|
|
639
|
-
|
|
639
|
+
* never throws. The activities roll up to the company this conversion
|
|
640
|
+
* linked, else the one the contact was already filed under (AGL-4123).
|
|
641
|
+
*/ const rollup = await crmActivityLinkWithCompany(orgRef, _extends({
|
|
642
|
+
contactId
|
|
643
|
+
}, companyId ? {
|
|
644
|
+
companyId
|
|
645
|
+
} : {}), {
|
|
646
|
+
groupId: group.groupId,
|
|
647
|
+
org
|
|
648
|
+
});
|
|
649
|
+
await handOffLeadRecords({
|
|
640
650
|
firestore,
|
|
641
651
|
orgId,
|
|
642
652
|
hostId,
|
|
643
653
|
leadId,
|
|
644
654
|
contactId,
|
|
655
|
+
companyId: rollup.companyId,
|
|
645
656
|
email,
|
|
646
657
|
by: actor.kind === 'member' ? 'member' : 'api'
|
|
647
658
|
});
|