@aglyn/plugins-crm 1.0.0 → 1.2.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 +137 -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-known-by-card.d.ts +2 -1
- package/src/lib/components/contact-known-by-card.js +16 -3
- package/src/lib/components/contact-known-by-card.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-record-names.d.ts +3 -2
- package/src/lib/hooks/use-crm-record-names.js +4 -4
- package/src/lib/hooks/use-crm-record-names.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 +25 -1
- package/src/lib/model/deal-contact-roles.js +30 -4
- package/src/lib/model/deal-contact-roles.js.map +1 -1
- package/src/lib/model/native-vocabulary.d.ts +9 -0
- package/src/lib/model/native-vocabulary.js +13 -0
- package/src/lib/model/native-vocabulary.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/deal-contact-roles.d.ts +2 -0
- package/src/lib/server/deal-contact-roles.js +38 -1
- package/src/lib/server/deal-contact-roles.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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/hooks/use-crm-record-names.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 { contactDisplayName, CRM_COLLECTIONS } from '@aglyn/aglyn'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { doc, type Firestore, getDoc } from 'firebase/firestore'\nimport { useCallback, useEffect, useState } from 'react'\nimport { contactPrimaryGroup } from '../model/contact-record'\n\nexport type CrmRecordKind = 'contact' | 'company' | 'deal'\n\n/** The collection each kind of record lives in, under `orgs/{orgId}/`. */\nexport const CRM_RECORD_COLLECTIONS: Record<CrmRecordKind, string> = {\n contact: 'contacts',\n company: CRM_COLLECTIONS.companies,\n deal: CRM_COLLECTIONS.deals,\n}\n\n/**\n * What a record is CALLED, from its document.\n *\n * A contact's name is read through the viewing group's facet, the way the\n * people list reads it, so one business renaming a person does not change\n * what an unrelated business's task list shows; the email stands in for a\n * contact nobody has named. A company has a name and a deal a title.\n */\nexport function crmRecordName(\n kind: CrmRecordKind,\n data: Record<string, unknown> | undefined,\n groupId: string,\n): string {\n if (!data) return ''\n switch (kind) {\n case 'contact':\n return contactDisplayName(data, groupId) || String(data['email'] ?? '')\n case 'company':\n return String(data['name'] ?? '')\n case 'deal':\n return String(data['title'] ?? '')\n }\n}\n\n/**\n * Names already read, keyed by `orgId/collection/id`, shared by every\n * mount for the life of the page.\n *\n * A task list names the same handful of records over and over — five tasks\n * about one deal are five rows and one name — and a person moves between the\n * tasks section, a contact's page and the dashboard without the records\n * changing underneath them. `null` records a document that does not exist,\n * so a task pointing at a deleted record costs one read rather than one per\n * render. A rename is picked up on the next page load, which is the right\n * staleness for a label beside a link to the record itself.\n */\nconst nameCache = new Map<string, string | null>()\nconst pending = new Map<string, Promise<void>>()\n\nconst cacheKey = (orgId: string, kind: CrmRecordKind, id: string) =>\n `${orgId}/${CRM_RECORD_COLLECTIONS[kind]}/${id}`\n\n/** Test seam: forget everything, so a spec's fixtures are what get read. */\nexport function resetCrmRecordNameCache(): void {\n nameCache.clear()\n pending.clear()\n}\n\n/**\n * The reads that fill the cache for `records` not already in it — each\n * record read once however many callers ask at the same time.\n */\nfunction startNameReads(\n firestore: Firestore,\n orgId: string,\n groupId: string | null,\n org: Record<string, unknown> | null | undefined,\n records: ReadonlyArray<{ kind: CrmRecordKind; id: string }>,\n): Promise<void>[] {\n const reads: Promise<void>[] = []\n for (const { kind, id } of records) {\n if (!id) continue\n const key = cacheKey(orgId, kind, id)\n if (nameCache.has(key)) continue\n let read = pending.get(key)\n if (!read) {\n read = getDoc(doc(firestore, 'orgs', orgId, CRM_RECORD_COLLECTIONS[kind], id))\n .then((snapshot) => {\n const data = snapshot.exists() ? (snapshot.data() as Record<string, unknown>) : undefined\n nameCache.set(\n key,\n data ? crmRecordName(kind, data, groupId ?? contactPrimaryGroup(data, org).groupId) : null,\n )\n })\n .catch(() => {\n // A refused or failed read is not cached: the cell keeps showing\n // the id, and the next mount tries again.\n })\n .finally(() => {\n pending.delete(key)\n })\n pending.set(key, read)\n }\n reads.push(read)\n }\n return reads\n}\n\n/**\n * The names of `records`, read now — for a one-off like a file export,\n * which needs every name at once rather than a listener's re-render\n * (AGL-3521). Shares {@link useCrmRecordNames}' cache, and answers the same\n * lookup: `undefined` for a record that could not be named.\n */\nexport async function readCrmRecordNames(\n firestore: Firestore,\n options: {\n orgId: string\n groupId: string | null\n org?: Record<string, unknown> | null\n records: ReadonlyArray<{ kind: CrmRecordKind; id: string }>\n },\n): Promise<(kind: CrmRecordKind, id: string) => string | undefined> {\n const { orgId, groupId, org, records } = options\n await Promise.all(startNameReads(firestore, orgId, groupId, org, records))\n return (kind, id) => nameCache.get(cacheKey(orgId, kind, id)) ?? undefined\n}\n\n/**\n * The names of the records a list of tasks points at, resolved once each.\n *\n * Returns a lookup rather than a map so the caller does not have to know the\n * cache's key shape. An unresolved record answers `undefined` — the cell\n * renders the link with the id until the name lands, which is one render\n * later on a warm cache and one round trip on a cold one.\n */\nexport function useCrmRecordNames(options: {\n orgId: string | null | undefined\n /**\n * The viewing group a contact is named through, or `null` at the\n * organization level (AGL-2630), where each contact is named through its\n * own primary holder, resolved from {@link org}.\n */\n groupId: string | null\n org?: Record<string, unknown> | null\n records: ReadonlyArray<{ kind: CrmRecordKind; id: string }>\n}): (kind: CrmRecordKind, id: string) => string | undefined {\n const { orgId, groupId, org, records } = options\n const firestore = useFirestore()\n // A counter rather than a copy of the cache: the cache is the source of\n // truth and this only exists to re-render when it grows.\n const [, setResolved] = useState(0)\n\n // The pairs as one string, so the effect runs when the SET changes and not\n // when the caller builds a new array of the same pairs on every render.\n const wanted = records\n .map((record) => `${record.kind}:${record.id}`)\n .filter((entry, index, all) => all.indexOf(entry) === index)\n .sort()\n .join('\\n')\n\n useEffect(() => {\n if (!orgId || !wanted) return\n let active = true\n const reads = startNameReads(\n firestore,\n orgId,\n groupId,\n org,\n wanted.split('\\n').map((entry) => {\n const [kind, id] = entry.split(':') as [CrmRecordKind, string]\n return { kind, id }\n }),\n )\n if (!reads.length) return\n void Promise.all(reads).then(() => {\n if (active) setResolved((count) => count + 1)\n })\n return () => {\n active = false\n }\n }, [firestore, orgId, groupId, wanted])\n\n return useCallback(\n (kind: CrmRecordKind, id: string) => {\n if (!orgId) return undefined\n const hit = nameCache.get(cacheKey(orgId, kind, id))\n return hit === null ? undefined : hit\n },\n [orgId],\n )\n}\n"],"names":["contactDisplayName","CRM_COLLECTIONS","useFirestore","doc","getDoc","useCallback","useEffect","useState","contactPrimaryGroup","CRM_RECORD_COLLECTIONS","contact","company","companies","deal","deals","crmRecordName","kind","data","groupId","String","nameCache","Map","pending","cacheKey","orgId","id","resetCrmRecordNameCache","clear","startNameReads","firestore","org","records","reads","key","has","read","get","then","snapshot","exists","undefined","set","catch","finally","delete","push","readCrmRecordNames","options","Promise","all","useCrmRecordNames","setResolved","wanted","map","record","filter","entry","index","indexOf","sort","join","active","split","length","count","hit"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/hooks/use-crm-record-names.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 { contactDisplayName, CRM_COLLECTIONS } from '@aglyn/aglyn'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { doc, type Firestore, getDoc } from 'firebase/firestore'\nimport { useCallback, useEffect, useState } from 'react'\nimport { contactPrimaryGroup } from '../model/contact-record'\n\nexport type CrmRecordKind = 'contact' | 'company' | 'deal'\n\n/** The collection each kind of record lives in, under `orgs/{orgId}/`. */\nexport const CRM_RECORD_COLLECTIONS: Record<CrmRecordKind, string> = {\n contact: 'contacts',\n company: CRM_COLLECTIONS.companies,\n deal: CRM_COLLECTIONS.deals,\n}\n\n/**\n * What a record is CALLED, from its document.\n *\n * A contact's name is read through the viewing group's facet, the way the\n * people list reads it, so one business renaming a person does not change\n * what an unrelated business's task list shows; the email, then the phone\n * number, stands in for a contact nobody has named. A company has a name and\n * a deal a title.\n */\nexport function crmRecordName(\n kind: CrmRecordKind,\n data: Record<string, unknown> | undefined,\n groupId: string,\n): string {\n if (!data) return ''\n switch (kind) {\n case 'contact':\n return (\n contactDisplayName(data, groupId) || String(data['email'] || data['phone'] || '')\n )\n case 'company':\n return String(data['name'] ?? '')\n case 'deal':\n return String(data['title'] ?? '')\n }\n}\n\n/**\n * Names already read, keyed by `orgId/collection/id`, shared by every\n * mount for the life of the page.\n *\n * A task list names the same handful of records over and over — five tasks\n * about one deal are five rows and one name — and a person moves between the\n * tasks section, a contact's page and the dashboard without the records\n * changing underneath them. `null` records a document that does not exist,\n * so a task pointing at a deleted record costs one read rather than one per\n * render. A rename is picked up on the next page load, which is the right\n * staleness for a label beside a link to the record itself.\n */\nconst nameCache = new Map<string, string | null>()\nconst pending = new Map<string, Promise<void>>()\n\nconst cacheKey = (orgId: string, kind: CrmRecordKind, id: string) =>\n `${orgId}/${CRM_RECORD_COLLECTIONS[kind]}/${id}`\n\n/** Test seam: forget everything, so a spec's fixtures are what get read. */\nexport function resetCrmRecordNameCache(): void {\n nameCache.clear()\n pending.clear()\n}\n\n/**\n * The reads that fill the cache for `records` not already in it — each\n * record read once however many callers ask at the same time.\n */\nfunction startNameReads(\n firestore: Firestore,\n orgId: string,\n groupId: string | null,\n org: Record<string, unknown> | null | undefined,\n records: ReadonlyArray<{ kind: CrmRecordKind; id: string }>,\n): Promise<void>[] {\n const reads: Promise<void>[] = []\n for (const { kind, id } of records) {\n if (!id) continue\n const key = cacheKey(orgId, kind, id)\n if (nameCache.has(key)) continue\n let read = pending.get(key)\n if (!read) {\n read = getDoc(doc(firestore, 'orgs', orgId, CRM_RECORD_COLLECTIONS[kind], id))\n .then((snapshot) => {\n const data = snapshot.exists() ? (snapshot.data() as Record<string, unknown>) : undefined\n nameCache.set(\n key,\n data ? crmRecordName(kind, data, groupId ?? contactPrimaryGroup(data, org).groupId) : null,\n )\n })\n .catch(() => {\n // A refused or failed read is not cached: the cell keeps showing\n // the id, and the next mount tries again.\n })\n .finally(() => {\n pending.delete(key)\n })\n pending.set(key, read)\n }\n reads.push(read)\n }\n return reads\n}\n\n/**\n * The names of `records`, read now — for a one-off like a file export,\n * which needs every name at once rather than a listener's re-render\n * (AGL-3521). Shares {@link useCrmRecordNames}' cache, and answers the same\n * lookup: `undefined` for a record that could not be named.\n */\nexport async function readCrmRecordNames(\n firestore: Firestore,\n options: {\n orgId: string\n groupId: string | null\n org?: Record<string, unknown> | null\n records: ReadonlyArray<{ kind: CrmRecordKind; id: string }>\n },\n): Promise<(kind: CrmRecordKind, id: string) => string | undefined> {\n const { orgId, groupId, org, records } = options\n await Promise.all(startNameReads(firestore, orgId, groupId, org, records))\n return (kind, id) => nameCache.get(cacheKey(orgId, kind, id)) ?? undefined\n}\n\n/**\n * The names of the records a list of tasks points at, resolved once each.\n *\n * Returns a lookup rather than a map so the caller does not have to know the\n * cache's key shape. An unresolved record answers `undefined` — the cell\n * renders the link with the id until the name lands, which is one render\n * later on a warm cache and one round trip on a cold one.\n */\nexport function useCrmRecordNames(options: {\n orgId: string | null | undefined\n /**\n * The viewing group a contact is named through, or `null` at the\n * organization level (AGL-2630), where each contact is named through its\n * own primary holder, resolved from {@link org}.\n */\n groupId: string | null\n org?: Record<string, unknown> | null\n records: ReadonlyArray<{ kind: CrmRecordKind; id: string }>\n}): (kind: CrmRecordKind, id: string) => string | undefined {\n const { orgId, groupId, org, records } = options\n const firestore = useFirestore()\n // A counter rather than a copy of the cache: the cache is the source of\n // truth and this only exists to re-render when it grows.\n const [, setResolved] = useState(0)\n\n // The pairs as one string, so the effect runs when the SET changes and not\n // when the caller builds a new array of the same pairs on every render.\n const wanted = records\n .map((record) => `${record.kind}:${record.id}`)\n .filter((entry, index, all) => all.indexOf(entry) === index)\n .sort()\n .join('\\n')\n\n useEffect(() => {\n if (!orgId || !wanted) return\n let active = true\n const reads = startNameReads(\n firestore,\n orgId,\n groupId,\n org,\n wanted.split('\\n').map((entry) => {\n const [kind, id] = entry.split(':') as [CrmRecordKind, string]\n return { kind, id }\n }),\n )\n if (!reads.length) return\n void Promise.all(reads).then(() => {\n if (active) setResolved((count) => count + 1)\n })\n return () => {\n active = false\n }\n }, [firestore, orgId, groupId, wanted])\n\n return useCallback(\n (kind: CrmRecordKind, id: string) => {\n if (!orgId) return undefined\n const hit = nameCache.get(cacheKey(orgId, kind, id))\n return hit === null ? undefined : hit\n },\n [orgId],\n )\n}\n"],"names":["contactDisplayName","CRM_COLLECTIONS","useFirestore","doc","getDoc","useCallback","useEffect","useState","contactPrimaryGroup","CRM_RECORD_COLLECTIONS","contact","company","companies","deal","deals","crmRecordName","kind","data","groupId","String","nameCache","Map","pending","cacheKey","orgId","id","resetCrmRecordNameCache","clear","startNameReads","firestore","org","records","reads","key","has","read","get","then","snapshot","exists","undefined","set","catch","finally","delete","push","readCrmRecordNames","options","Promise","all","useCrmRecordNames","setResolved","wanted","map","record","filter","entry","index","indexOf","sort","join","active","split","length","count","hit"],"mappings":"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"}
|
|
@@ -47,16 +47,28 @@ export interface CrmScope {
|
|
|
47
47
|
*/
|
|
48
48
|
visibleTo: readonly ScopeToken[] | null;
|
|
49
49
|
/**
|
|
50
|
-
* The site a record this viewer CREATES
|
|
51
|
-
*
|
|
52
|
-
* have). A create must hold until this is known — the tokens below are
|
|
53
|
-
* empty without it, and a record stamped with none is visible to nobody.
|
|
50
|
+
* The site a record this viewer CREATES came from: the mounted site, or at
|
|
51
|
+
* the org level the site the reader picked — `null` when they picked none.
|
|
54
52
|
*/
|
|
55
53
|
createHostId: string | null;
|
|
56
54
|
/** The consent group of {@link createHostId} — what a create records consent for. */
|
|
57
55
|
createGroup: ConsentGroup | null;
|
|
58
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* What a record this viewer CREATES is stamped with. With a site, what
|
|
58
|
+
* `crmScopeTokens` says for it; with none, the whole org when the org's
|
|
59
|
+
* records belong to every site, and EMPTY when the org keeps its sites
|
|
60
|
+
* apart (or its document has not loaded) — there a record must come from
|
|
61
|
+
* a site, and a create holds until one is picked, because a record stamped
|
|
62
|
+
* with no tokens is visible to nobody.
|
|
63
|
+
*/
|
|
59
64
|
createTokens: readonly ScopeToken[];
|
|
65
|
+
/**
|
|
66
|
+
* Whether a create must name a site: true when the org keeps each site's
|
|
67
|
+
* records apart, where the site decides who may see the record. False when
|
|
68
|
+
* every record belongs to the whole org, where a site is only the source it
|
|
69
|
+
* came from and may be left empty.
|
|
70
|
+
*/
|
|
71
|
+
createNeedsSite: boolean;
|
|
60
72
|
}
|
|
61
73
|
/**
|
|
62
74
|
* The scope facts every CRM surface needs, resolved once (AGL-2597, one
|
|
@@ -14,12 +14,15 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ 'use client';
|
|
17
|
-
import { consentGroupForHost, crmReadTokens, crmScopeTokens, soloConsentGroup } from "@aglyn/aglyn";
|
|
17
|
+
import { consentGroupForHost, crmDefaultScopeOf, crmReadTokens, crmScopeTokens, ORG_SCOPE_TOKEN, soloConsentGroup } from "@aglyn/aglyn";
|
|
18
18
|
import { useOrgDataScope, useScopeTokens } from "@aglyn/tenant-feature-instance";
|
|
19
19
|
import { where } from "firebase/firestore";
|
|
20
20
|
import { useMemo } from "react";
|
|
21
21
|
import { useCrmOrgMount } from "./use-crm-org-mount.js";
|
|
22
22
|
const NO_TOKENS = Object.freeze([]);
|
|
23
|
+
const ORG_TOKENS = Object.freeze([
|
|
24
|
+
ORG_SCOPE_TOKEN
|
|
25
|
+
]);
|
|
23
26
|
/**
|
|
24
27
|
* The scope facts every CRM surface needs, resolved once (AGL-2597, one
|
|
25
28
|
* hook since AGL-2614, two levels since AGL-2630).
|
|
@@ -109,9 +112,11 @@ const NO_TOKENS = Object.freeze([]);
|
|
|
109
112
|
org,
|
|
110
113
|
createHostId
|
|
111
114
|
]);
|
|
112
|
-
const
|
|
115
|
+
const sharesWithEverySite = crmDefaultScopeOf(org) === 'org';
|
|
116
|
+
const createTokens = useMemo(()=>createGroup ? crmScopeTokens(org, createGroup) : sharesWithEverySite ? ORG_TOKENS : NO_TOKENS, [
|
|
113
117
|
org,
|
|
114
|
-
createGroup
|
|
118
|
+
createGroup,
|
|
119
|
+
sharesWithEverySite
|
|
115
120
|
]);
|
|
116
121
|
return useMemo(()=>({
|
|
117
122
|
scope,
|
|
@@ -123,7 +128,8 @@ const NO_TOKENS = Object.freeze([]);
|
|
|
123
128
|
visibleTo,
|
|
124
129
|
createHostId,
|
|
125
130
|
createGroup,
|
|
126
|
-
createTokens
|
|
131
|
+
createTokens,
|
|
132
|
+
createNeedsSite: !sharesWithEverySite
|
|
127
133
|
}), [
|
|
128
134
|
scope,
|
|
129
135
|
orgId,
|
|
@@ -134,7 +140,8 @@ const NO_TOKENS = Object.freeze([]);
|
|
|
134
140
|
visibleTo,
|
|
135
141
|
createHostId,
|
|
136
142
|
createGroup,
|
|
137
|
-
createTokens
|
|
143
|
+
createTokens,
|
|
144
|
+
sharesWithEverySite
|
|
138
145
|
]);
|
|
139
146
|
}
|
|
140
147
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/hooks/use-crm-scope.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'use client'\n\nimport {\n type ConsentGroup,\n type ConsolePluginPageProps,\n consentGroupForHost,\n crmReadTokens,\n crmScopeTokens,\n type ScopeToken,\n soloConsentGroup,\n} from '@aglyn/aglyn'\nimport { useOrgDataScope, useScopeTokens } from '@aglyn/tenant-feature-instance'\nimport { type QueryConstraint, where } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useCrmOrgMount } from './use-crm-org-mount'\n\n/**\n * The org document as a CRM surface receives it: the shell's typed billing\n * projection on a page, or the plain record a card was handed. Read as a\n * record at the one seam below, so no component casts it itself.\n */\nexport type CrmOrgDoc =\n | ConsolePluginPageProps['org']\n | Record<string, unknown>\n | null\n | undefined\n\n/** Where a CRM surface is mounted: under one site, or over the whole org. */\nexport type CrmScopeLevel = 'site' | 'org'\n\nexport interface CrmScope {\n /** The org data root, or `null` until the org lookup settles or when there is none. */\n scope: readonly ['orgs', string] | null\n /** The owning org, or null while the lookup runs or when there is none. */\n orgId: string | null\n /** Whether the org lookup has settled — `orgId` null after this means no org. */\n ready: boolean\n /** Under a site, or at the organization level (AGL-2630). */\n level: CrmScopeLevel\n /** The mounted site, or `null` at the organization level. */\n hostId: string | null\n /**\n * The controller this surface is being viewed as, or `null` at the org\n * level, where there is no one viewing site: a cross-holder reader\n * resolves each contact's own facet instead (`contactPrimaryGroup`).\n */\n consentGroup: ConsentGroup | null\n /**\n * What this viewer may LIST — every listener's `array-contains-any`\n * predicate, capped at what the operator accepts — or `null` at the org\n * level, where an org-wide member's listeners carry NO scope clause: the\n * rules' `canReadScoped()` short-circuits on `isOrgWideMember()`, so the\n * clause would only ever narrow what the reader is already allowed. Hand\n * it to {@link crmVisibleToClause}, which spells both cases. Stable across\n * renders for the same org and site, so it can sit in a query's\n * dependency list.\n *\n * On a site in a declared consent group, a member who reaches only SOME of\n * its sites lists with the group's tokens narrowed to their own (AGL-3320):\n * the rules admit a row by `visibleTo.hasAny(memberTokens)`, so a query\n * naming a sibling the member cannot reach is one they cannot prove for a\n * row stamped with that sibling alone, and Firestore refuses the whole\n * list. Until the member's reach has loaded it is the SITE's own tokens,\n * which everybody who can open this site's CRM holds — never the group's,\n * which would be exactly that refused query, and never empty, which\n * Firestore refuses to build at all.\n */\n visibleTo: readonly ScopeToken[] | null\n /**\n * The site a record this viewer CREATES is captured by: the mounted site,\n * or at the org level the site the reader picked (`null` until they\n * have). A create must hold until this is known — the tokens below are\n * empty without it, and a record stamped with none is visible to nobody.\n */\n createHostId: string | null\n /** The consent group of {@link createHostId} — what a create records consent for. */\n createGroup: ConsentGroup | null\n /** What a record this viewer CREATES is stamped with; empty until {@link createHostId} is known. */\n createTokens: readonly ScopeToken[]\n}\n\nconst NO_TOKENS: readonly ScopeToken[] = Object.freeze([])\n\n/**\n * The scope facts every CRM surface needs, resolved once (AGL-2597, one\n * hook since AGL-2614, two levels since AGL-2630).\n *\n * A CRM surface — a list, a record page, a drawer, a card on somebody\n * else's page — resolves its consent group, builds the token list its\n * listener filters on, builds the tokens a create stamps, and looks up the\n * org root. Every section needs the same answers, and for a while the deals\n * had a hook of their own that answered them under different names\n * (`readTokens` for `visibleTo`), which is two chances to filter by one set\n * of tokens and stamp with another. This is the one place the expression\n * lives: a surface that spelled its own could omit the `'org'` token and\n * stop listing the org-wide records the rules would have let it read.\n *\n * ## Two levels, one call\n *\n * Under a site, `hostId` names it and everything follows from the org\n * document and that id, as it always has. At the ORGANIZATION level the\n * hub hands every surface `hostId: null`, and the org comes from the mount\n * the hub published (`useCrmOrgMount`): the org root is known at once,\n * reads are unscoped, and the site a create stamps from is the one the\n * reader picked. A surface written against `{ scope, visibleTo,\n * createTokens }` serves both levels without knowing which it is on — the\n * two differences it cannot ignore are that `visibleTo` and `consentGroup`\n * are `null` at the org level, and both are typed to say so.\n *\n * `visibleTo` and `createTokens` are different questions with different\n * answers. The first is the union of everything this viewer may see; the\n * second is where a new record lands, which `crmScopeTokens` decides from\n * the org's default and is narrower — a group of one site creates for that\n * site, and reads the org-wide rows beside its own.\n *\n * Pure over the org document the shell already passed, so it costs no read;\n * only the org lookup behind `useOrgDataScope` is asynchronous, and `ready`\n * is how a surface tells \"not yet\" from \"no org\".\n */\nexport function useCrmScope(props: {\n hostId: string | null | undefined\n org?: CrmOrgDoc\n}): CrmScope {\n const hostId = props.hostId || null\n const org = (props.org ?? null) as Record<string, unknown> | null\n const mount = useCrmOrgMount()\n // Under a site the org is looked up from the host index; at the org level\n // it is the mount's, known at once. A `null` host with no mount is a\n // surface mounted nowhere, and resolves to no org rather than guessing.\n const { scope, orgId, ready } = useOrgDataScope(\n hostId ? { hostId } : { orgId: mount?.orgId },\n )\n const level: CrmScopeLevel = hostId ? 'site' : 'org'\n const consentGroup = useMemo(\n () => (hostId ? consentGroupForHost(org, hostId) : null),\n [org, hostId],\n )\n /*\n * WHO IS READING, for a site that shares a consent group (AGL-3320).\n *\n * A site alone reads `['org', 'host:{id}']`, and every member who can open\n * that site's CRM holds both — so the member's own tokens could only ever\n * return the same list, and the read is skipped: `useScopeTokens` opens\n * nothing for an undefined org. A DECLARED group's tokens name its other\n * sites too, and a collaborator invited to one of them does not hold the\n * rest.\n */\n const readsSiblings = consentGroup?.declared === true\n const reach = useScopeTokens(readsSiblings && orgId ? orgId : undefined)\n const visibleTo = useMemo(() => {\n if (!consentGroup) return null\n const tokens = crmReadTokens(consentGroup)\n if (!readsSiblings) return tokens\n // The site's own, until the reader's reach answers: provable for anybody\n // on this site, and an org-wide reader's list widens when it does.\n if (!orgId || !reach.loaded) {\n return crmReadTokens(soloConsentGroup(consentGroup.hostId))\n }\n if (reach.orgWide) return tokens\n const held = new Set<string>(reach.tokens)\n return tokens.filter((token) => held.has(token))\n }, [consentGroup, readsSiblings, orgId, reach.loaded, reach.orgWide, reach.tokens])\n const createHostId = hostId ?? mount?.createHostId ?? null\n const createGroup = useMemo(\n () => (createHostId ? consentGroupForHost(org, createHostId) : null),\n [org, createHostId],\n )\n const createTokens = useMemo(\n () => (createGroup ? crmScopeTokens(org, createGroup) : NO_TOKENS),\n [org, createGroup],\n )\n return useMemo(\n () => ({\n scope,\n orgId,\n ready,\n level,\n hostId,\n consentGroup,\n visibleTo,\n createHostId,\n createGroup,\n createTokens,\n }),\n [\n scope,\n orgId,\n ready,\n level,\n hostId,\n consentGroup,\n visibleTo,\n createHostId,\n createGroup,\n createTokens,\n ],\n )\n}\n\n/**\n * The scope clause a CRM listener carries, spelled once for both levels\n * (AGL-2630).\n *\n * Under a site: `where('visibleTo', 'array-contains-any', tokens)`, the\n * predicate the rules evaluate with `hasAny`, which is what makes a\n * filtered query provable per document. At the org level: NOTHING — the\n * reader is an org-wide member, the rules admit them to every row, and a\n * query that kept the clause would need the org's whole site list on it\n * and still miss a site the list did not carry. Spread into the query, so\n * the two cases are one line at every listener: `...crmVisibleToClause(\n * scope.visibleTo)`.\n *\n * A query that drops an `array-contains-any` may need a composite index\n * the scoped form did not: the equality and range clauses beside it stay,\n * and Firestore composes them on their own. The org-level entries live in\n * `cloud/firebase-firestore.indexes.json` beside their scoped twins.\n */\nexport function crmVisibleToClause(\n visibleTo: readonly ScopeToken[] | readonly string[] | null | undefined,\n): QueryConstraint[] {\n if (!visibleTo) return []\n return [where('visibleTo', 'array-contains-any', [...visibleTo])]\n}\n\n/**\n * Whether a listener that filters on `visibleTo` has anything to ask for:\n * `true` at the org level (no clause is a complete question), and under a\n * site only when the token list is non-empty — an `array-contains-any`\n * over nothing is a query Firestore refuses.\n */\nexport function crmScopeListable(\n visibleTo: readonly ScopeToken[] | readonly string[] | null | undefined,\n): boolean {\n return visibleTo === null || visibleTo === undefined || visibleTo.length > 0\n}\n\nexport default useCrmScope\n"],"names":["consentGroupForHost","crmReadTokens","crmScopeTokens","soloConsentGroup","useOrgDataScope","useScopeTokens","where","useMemo","useCrmOrgMount","NO_TOKENS","Object","freeze","useCrmScope","props","hostId","org","mount","scope","orgId","ready","level","consentGroup","readsSiblings","declared","reach","undefined","visibleTo","tokens","loaded","orgWide","held","Set","filter","token","has","createHostId","createGroup","createTokens","crmVisibleToClause","crmScopeListable","length"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;AAEA,SAGEA,mBAAmB,EACnBC,aAAa,EACbC,cAAc,EAEdC,gBAAgB,QACX,eAAc;AACrB,SAASC,eAAe,EAAEC,cAAc,QAAQ,iCAAgC;AAChF,SAA+BC,KAAK,QAAQ,qBAAoB;AAChE,SAASC,OAAO,QAAQ,QAAO;AAC/B,SAASC,cAAc,QAAQ,yBAAqB;AAmEpD,MAAMC,YAAmCC,OAAOC,MAAM,CAAC,EAAE;AAEzD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmCC,GACD,OAAO,SAASC,YAAYC,KAG3B;QAEcA,YAsCQC;IAvCrB,MAAMA,SAASD,MAAMC,MAAM,IAAI;IAC/B,MAAMC,OAAOF,aAAAA,MAAME,GAAG,YAATF,aAAa;IAC1B,MAAMG,QAAQR;IACd,0EAA0E;IAC1E,qEAAqE;IACrE,wEAAwE;IACxE,MAAM,EAAES,KAAK,EAAEC,KAAK,EAAEC,KAAK,EAAE,GAAGf,gBAC9BU,SAAS;QAAEA;IAAO,IAAI;QAAEI,KAAK,EAAEF,yBAAAA,MAAOE,KAAK;IAAC;IAE9C,MAAME,QAAuBN,SAAS,SAAS;IAC/C,MAAMO,eAAed,QACnB,IAAOO,SAASd,oBAAoBe,KAAKD,UAAU,MACnD;QAACC;QAAKD;KAAO;IAEf;;;;;;;;;GASC,GACD,MAAMQ,gBAAgBD,CAAAA,gCAAAA,aAAcE,QAAQ,MAAK;IACjD,MAAMC,QAAQnB,eAAeiB,iBAAiBJ,QAAQA,QAAQO;IAC9D,MAAMC,YAAYnB,QAAQ;QACxB,IAAI,CAACc,cAAc,OAAO;QAC1B,MAAMM,SAAS1B,cAAcoB;QAC7B,IAAI,CAACC,eAAe,OAAOK;QAC3B,yEAAyE;QACzE,mEAAmE;QACnE,IAAI,CAACT,SAAS,CAACM,MAAMI,MAAM,EAAE;YAC3B,OAAO3B,cAAcE,iBAAiBkB,aAAaP,MAAM;QAC3D;QACA,IAAIU,MAAMK,OAAO,EAAE,OAAOF;QAC1B,MAAMG,OAAO,IAAIC,IAAYP,MAAMG,MAAM;QACzC,OAAOA,OAAOK,MAAM,CAAC,CAACC,QAAUH,KAAKI,GAAG,CAACD;IAC3C,GAAG;QAACZ;QAAcC;QAAeJ;QAAOM,MAAMI,MAAM;QAAEJ,MAAMK,OAAO;QAAEL,MAAMG,MAAM;KAAC;IAClF,MAAMQ,gBAAerB,OAAAA,iBAAAA,SAAUE,yBAAAA,MAAOmB,YAAY,YAA7BrB,OAAiC;IACtD,MAAMsB,cAAc7B,QAClB,IAAO4B,eAAenC,oBAAoBe,KAAKoB,gBAAgB,MAC/D;QAACpB;QAAKoB;KAAa;IAErB,MAAME,eAAe9B,QACnB,IAAO6B,cAAclC,eAAea,KAAKqB,eAAe3B,WACxD;QAACM;QAAKqB;KAAY;IAEpB,OAAO7B,QACL,IAAO,CAAA;YACLU;YACAC;YACAC;YACAC;YACAN;YACAO;YACAK;YACAS;YACAC;YACAC;QACF,CAAA,GACA;QACEpB;QACAC;QACAC;QACAC;QACAN;QACAO;QACAK;QACAS;QACAC;QACAC;KACD;AAEL;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASC,mBACdZ,SAAuE;IAEvE,IAAI,CAACA,WAAW,OAAO,EAAE;IACzB,OAAO;QAACpB,MAAM,aAAa,sBAAsB;eAAIoB;SAAU;KAAE;AACnE;AAEA;;;;;CAKC,GACD,OAAO,SAASa,iBACdb,SAAuE;IAEvE,OAAOA,cAAc,QAAQA,cAAcD,aAAaC,UAAUc,MAAM,GAAG;AAC7E;AAEA,eAAe5B,YAAW"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/hooks/use-crm-scope.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'use client'\n\nimport {\n type ConsentGroup,\n type ConsolePluginPageProps,\n consentGroupForHost,\n crmDefaultScopeOf,\n crmReadTokens,\n crmScopeTokens,\n ORG_SCOPE_TOKEN,\n type ScopeToken,\n soloConsentGroup,\n} from '@aglyn/aglyn'\nimport { useOrgDataScope, useScopeTokens } from '@aglyn/tenant-feature-instance'\nimport { type QueryConstraint, where } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useCrmOrgMount } from './use-crm-org-mount'\n\n/**\n * The org document as a CRM surface receives it: the shell's typed billing\n * projection on a page, or the plain record a card was handed. Read as a\n * record at the one seam below, so no component casts it itself.\n */\nexport type CrmOrgDoc =\n | ConsolePluginPageProps['org']\n | Record<string, unknown>\n | null\n | undefined\n\n/** Where a CRM surface is mounted: under one site, or over the whole org. */\nexport type CrmScopeLevel = 'site' | 'org'\n\nexport interface CrmScope {\n /** The org data root, or `null` until the org lookup settles or when there is none. */\n scope: readonly ['orgs', string] | null\n /** The owning org, or null while the lookup runs or when there is none. */\n orgId: string | null\n /** Whether the org lookup has settled — `orgId` null after this means no org. */\n ready: boolean\n /** Under a site, or at the organization level (AGL-2630). */\n level: CrmScopeLevel\n /** The mounted site, or `null` at the organization level. */\n hostId: string | null\n /**\n * The controller this surface is being viewed as, or `null` at the org\n * level, where there is no one viewing site: a cross-holder reader\n * resolves each contact's own facet instead (`contactPrimaryGroup`).\n */\n consentGroup: ConsentGroup | null\n /**\n * What this viewer may LIST — every listener's `array-contains-any`\n * predicate, capped at what the operator accepts — or `null` at the org\n * level, where an org-wide member's listeners carry NO scope clause: the\n * rules' `canReadScoped()` short-circuits on `isOrgWideMember()`, so the\n * clause would only ever narrow what the reader is already allowed. Hand\n * it to {@link crmVisibleToClause}, which spells both cases. Stable across\n * renders for the same org and site, so it can sit in a query's\n * dependency list.\n *\n * On a site in a declared consent group, a member who reaches only SOME of\n * its sites lists with the group's tokens narrowed to their own (AGL-3320):\n * the rules admit a row by `visibleTo.hasAny(memberTokens)`, so a query\n * naming a sibling the member cannot reach is one they cannot prove for a\n * row stamped with that sibling alone, and Firestore refuses the whole\n * list. Until the member's reach has loaded it is the SITE's own tokens,\n * which everybody who can open this site's CRM holds — never the group's,\n * which would be exactly that refused query, and never empty, which\n * Firestore refuses to build at all.\n */\n visibleTo: readonly ScopeToken[] | null\n /**\n * The site a record this viewer CREATES came from: the mounted site, or at\n * the org level the site the reader picked — `null` when they picked none.\n */\n createHostId: string | null\n /** The consent group of {@link createHostId} — what a create records consent for. */\n createGroup: ConsentGroup | null\n /**\n * What a record this viewer CREATES is stamped with. With a site, what\n * `crmScopeTokens` says for it; with none, the whole org when the org's\n * records belong to every site, and EMPTY when the org keeps its sites\n * apart (or its document has not loaded) — there a record must come from\n * a site, and a create holds until one is picked, because a record stamped\n * with no tokens is visible to nobody.\n */\n createTokens: readonly ScopeToken[]\n /**\n * Whether a create must name a site: true when the org keeps each site's\n * records apart, where the site decides who may see the record. False when\n * every record belongs to the whole org, where a site is only the source it\n * came from and may be left empty.\n */\n createNeedsSite: boolean\n}\n\nconst NO_TOKENS: readonly ScopeToken[] = Object.freeze([])\nconst ORG_TOKENS: readonly ScopeToken[] = Object.freeze([ORG_SCOPE_TOKEN])\n\n/**\n * The scope facts every CRM surface needs, resolved once (AGL-2597, one\n * hook since AGL-2614, two levels since AGL-2630).\n *\n * A CRM surface — a list, a record page, a drawer, a card on somebody\n * else's page — resolves its consent group, builds the token list its\n * listener filters on, builds the tokens a create stamps, and looks up the\n * org root. Every section needs the same answers, and for a while the deals\n * had a hook of their own that answered them under different names\n * (`readTokens` for `visibleTo`), which is two chances to filter by one set\n * of tokens and stamp with another. This is the one place the expression\n * lives: a surface that spelled its own could omit the `'org'` token and\n * stop listing the org-wide records the rules would have let it read.\n *\n * ## Two levels, one call\n *\n * Under a site, `hostId` names it and everything follows from the org\n * document and that id, as it always has. At the ORGANIZATION level the\n * hub hands every surface `hostId: null`, and the org comes from the mount\n * the hub published (`useCrmOrgMount`): the org root is known at once,\n * reads are unscoped, and the site a create stamps from is the one the\n * reader picked. A surface written against `{ scope, visibleTo,\n * createTokens }` serves both levels without knowing which it is on — the\n * two differences it cannot ignore are that `visibleTo` and `consentGroup`\n * are `null` at the org level, and both are typed to say so.\n *\n * `visibleTo` and `createTokens` are different questions with different\n * answers. The first is the union of everything this viewer may see; the\n * second is where a new record lands, which `crmScopeTokens` decides from\n * the org's default and is narrower — a group of one site creates for that\n * site, and reads the org-wide rows beside its own.\n *\n * Pure over the org document the shell already passed, so it costs no read;\n * only the org lookup behind `useOrgDataScope` is asynchronous, and `ready`\n * is how a surface tells \"not yet\" from \"no org\".\n */\nexport function useCrmScope(props: {\n hostId: string | null | undefined\n org?: CrmOrgDoc\n}): CrmScope {\n const hostId = props.hostId || null\n const org = (props.org ?? null) as Record<string, unknown> | null\n const mount = useCrmOrgMount()\n // Under a site the org is looked up from the host index; at the org level\n // it is the mount's, known at once. A `null` host with no mount is a\n // surface mounted nowhere, and resolves to no org rather than guessing.\n const { scope, orgId, ready } = useOrgDataScope(\n hostId ? { hostId } : { orgId: mount?.orgId },\n )\n const level: CrmScopeLevel = hostId ? 'site' : 'org'\n const consentGroup = useMemo(\n () => (hostId ? consentGroupForHost(org, hostId) : null),\n [org, hostId],\n )\n /*\n * WHO IS READING, for a site that shares a consent group (AGL-3320).\n *\n * A site alone reads `['org', 'host:{id}']`, and every member who can open\n * that site's CRM holds both — so the member's own tokens could only ever\n * return the same list, and the read is skipped: `useScopeTokens` opens\n * nothing for an undefined org. A DECLARED group's tokens name its other\n * sites too, and a collaborator invited to one of them does not hold the\n * rest.\n */\n const readsSiblings = consentGroup?.declared === true\n const reach = useScopeTokens(readsSiblings && orgId ? orgId : undefined)\n const visibleTo = useMemo(() => {\n if (!consentGroup) return null\n const tokens = crmReadTokens(consentGroup)\n if (!readsSiblings) return tokens\n // The site's own, until the reader's reach answers: provable for anybody\n // on this site, and an org-wide reader's list widens when it does.\n if (!orgId || !reach.loaded) {\n return crmReadTokens(soloConsentGroup(consentGroup.hostId))\n }\n if (reach.orgWide) return tokens\n const held = new Set<string>(reach.tokens)\n return tokens.filter((token) => held.has(token))\n }, [consentGroup, readsSiblings, orgId, reach.loaded, reach.orgWide, reach.tokens])\n const createHostId = hostId ?? mount?.createHostId ?? null\n const createGroup = useMemo(\n () => (createHostId ? consentGroupForHost(org, createHostId) : null),\n [org, createHostId],\n )\n const sharesWithEverySite = crmDefaultScopeOf(org) === 'org'\n const createTokens = useMemo(\n () =>\n createGroup\n ? crmScopeTokens(org, createGroup)\n : sharesWithEverySite\n ? ORG_TOKENS\n : NO_TOKENS,\n [org, createGroup, sharesWithEverySite],\n )\n return useMemo(\n () => ({\n scope,\n orgId,\n ready,\n level,\n hostId,\n consentGroup,\n visibleTo,\n createHostId,\n createGroup,\n createTokens,\n createNeedsSite: !sharesWithEverySite,\n }),\n [\n scope,\n orgId,\n ready,\n level,\n hostId,\n consentGroup,\n visibleTo,\n createHostId,\n createGroup,\n createTokens,\n sharesWithEverySite,\n ],\n )\n}\n\n/**\n * The scope clause a CRM listener carries, spelled once for both levels\n * (AGL-2630).\n *\n * Under a site: `where('visibleTo', 'array-contains-any', tokens)`, the\n * predicate the rules evaluate with `hasAny`, which is what makes a\n * filtered query provable per document. At the org level: NOTHING — the\n * reader is an org-wide member, the rules admit them to every row, and a\n * query that kept the clause would need the org's whole site list on it\n * and still miss a site the list did not carry. Spread into the query, so\n * the two cases are one line at every listener: `...crmVisibleToClause(\n * scope.visibleTo)`.\n *\n * A query that drops an `array-contains-any` may need a composite index\n * the scoped form did not: the equality and range clauses beside it stay,\n * and Firestore composes them on their own. The org-level entries live in\n * `cloud/firebase-firestore.indexes.json` beside their scoped twins.\n */\nexport function crmVisibleToClause(\n visibleTo: readonly ScopeToken[] | readonly string[] | null | undefined,\n): QueryConstraint[] {\n if (!visibleTo) return []\n return [where('visibleTo', 'array-contains-any', [...visibleTo])]\n}\n\n/**\n * Whether a listener that filters on `visibleTo` has anything to ask for:\n * `true` at the org level (no clause is a complete question), and under a\n * site only when the token list is non-empty — an `array-contains-any`\n * over nothing is a query Firestore refuses.\n */\nexport function crmScopeListable(\n visibleTo: readonly ScopeToken[] | readonly string[] | null | undefined,\n): boolean {\n return visibleTo === null || visibleTo === undefined || visibleTo.length > 0\n}\n\nexport default useCrmScope\n"],"names":["consentGroupForHost","crmDefaultScopeOf","crmReadTokens","crmScopeTokens","ORG_SCOPE_TOKEN","soloConsentGroup","useOrgDataScope","useScopeTokens","where","useMemo","useCrmOrgMount","NO_TOKENS","Object","freeze","ORG_TOKENS","useCrmScope","props","hostId","org","mount","scope","orgId","ready","level","consentGroup","readsSiblings","declared","reach","undefined","visibleTo","tokens","loaded","orgWide","held","Set","filter","token","has","createHostId","createGroup","sharesWithEverySite","createTokens","createNeedsSite","crmVisibleToClause","crmScopeListable","length"],"mappings":"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"}
|
|
@@ -28,6 +28,14 @@ import { type ConsentGroup } from '@aglyn/aglyn/app-utils/consent-groups';
|
|
|
28
28
|
* empty string.
|
|
29
29
|
*/
|
|
30
30
|
export declare const NO_HOLDER_GROUP: ConsentGroup;
|
|
31
|
+
/**
|
|
32
|
+
* The holder of a person the organization added itself, with no site behind
|
|
33
|
+
* them (AGL-4122). Like {@link NO_HOLDER_GROUP} it names no site, so `hostId`
|
|
34
|
+
* is empty and a surface asking "is there a site here at all" gets the same
|
|
35
|
+
* answer. Unlike it, it names a facet, so the record has a profile to show and
|
|
36
|
+
* an edit has somewhere to go.
|
|
37
|
+
*/
|
|
38
|
+
export declare const ORG_HOLDER_GROUP: ConsentGroup;
|
|
31
39
|
/**
|
|
32
40
|
* The group a LEAD is read and acted on through (AGL-3278).
|
|
33
41
|
*
|
|
@@ -59,6 +67,10 @@ export declare function leadPrimaryGroup(lead: Record<string, unknown> | null |
|
|
|
59
67
|
* facet yet (the facet is written on the first edit). A row nobody captured
|
|
60
68
|
* and nobody wrote a facet for reads through {@link NO_HOLDER_GROUP}.
|
|
61
69
|
*
|
|
70
|
+
* A person the organization added itself reads through {@link ORG_HOLDER_GROUP}
|
|
71
|
+
* first, whatever has captured them since: that facet is the record the team
|
|
72
|
+
* built, and a form fill on a site later adds a holder without replacing it.
|
|
73
|
+
*
|
|
62
74
|
* Capture order rather than sorted order, deliberately: `capturedByHostIds`
|
|
63
75
|
* is maintained by `arrayUnion`, so its first entry is the site that met
|
|
64
76
|
* the person first, and that site's profile is the one a person reading
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* In the library rather than in the CRM plugin because both of those
|
|
26
26
|
* readers need it and one of them is an app: an app may not import a
|
|
27
27
|
* feature plugin (AGL-2662).
|
|
28
|
-
*/ import { CONTACT_FACETS_FIELD } from "@aglyn/aglyn/app-utils/contacts";
|
|
28
|
+
*/ import { CONTACT_FACETS_FIELD, ORG_CONTACT_HOLDER_ID } from "@aglyn/aglyn/app-utils/contacts";
|
|
29
29
|
import { CAPTURED_BY_HOST_FIELD } from "@aglyn/aglyn/app-utils/marketing-consent";
|
|
30
30
|
import { consentGroupForHost, consentGroupsAwaitConfirmation, readConsentGroups, soloConsentGroup } from "@aglyn/aglyn/app-utils/consent-groups";
|
|
31
31
|
/**
|
|
@@ -47,6 +47,20 @@ import { consentGroupForHost, consentGroupsAwaitConfirmation, readConsentGroups,
|
|
|
47
47
|
declared: false,
|
|
48
48
|
awaitsConfirmation: false
|
|
49
49
|
});
|
|
50
|
+
/**
|
|
51
|
+
* The holder of a person the organization added itself, with no site behind
|
|
52
|
+
* them (AGL-4122). Like {@link NO_HOLDER_GROUP} it names no site, so `hostId`
|
|
53
|
+
* is empty and a surface asking "is there a site here at all" gets the same
|
|
54
|
+
* answer. Unlike it, it names a facet, so the record has a profile to show and
|
|
55
|
+
* an edit has somewhere to go.
|
|
56
|
+
*/ export const ORG_HOLDER_GROUP = Object.freeze({
|
|
57
|
+
hostId: '',
|
|
58
|
+
groupId: ORG_CONTACT_HOLDER_ID,
|
|
59
|
+
name: null,
|
|
60
|
+
hostIds: [],
|
|
61
|
+
declared: false,
|
|
62
|
+
awaitsConfirmation: false
|
|
63
|
+
});
|
|
50
64
|
/**
|
|
51
65
|
* The sites that captured this person, in CAPTURE order — the order
|
|
52
66
|
* `arrayUnion` left, so the first entry is the site that met them first.
|
|
@@ -92,6 +106,10 @@ import { consentGroupForHost, consentGroupsAwaitConfirmation, readConsentGroups,
|
|
|
92
106
|
* facet yet (the facet is written on the first edit). A row nobody captured
|
|
93
107
|
* and nobody wrote a facet for reads through {@link NO_HOLDER_GROUP}.
|
|
94
108
|
*
|
|
109
|
+
* A person the organization added itself reads through {@link ORG_HOLDER_GROUP}
|
|
110
|
+
* first, whatever has captured them since: that facet is the record the team
|
|
111
|
+
* built, and a form fill on a site later adds a holder without replacing it.
|
|
112
|
+
*
|
|
95
113
|
* Capture order rather than sorted order, deliberately: `capturedByHostIds`
|
|
96
114
|
* is maintained by `arrayUnion`, so its first entry is the site that met
|
|
97
115
|
* the person first, and that site's profile is the one a person reading
|
|
@@ -101,6 +119,7 @@ import { consentGroupForHost, consentGroupsAwaitConfirmation, readConsentGroups,
|
|
|
101
119
|
const record = row != null ? row : {};
|
|
102
120
|
const facets = record[CONTACT_FACETS_FIELD];
|
|
103
121
|
const facetKeys = facets && typeof facets === 'object' && !Array.isArray(facets) ? Object.keys(facets) : [];
|
|
122
|
+
if (facetKeys.includes(ORG_CONTACT_HOLDER_ID)) return ORG_HOLDER_GROUP;
|
|
104
123
|
const captured = captureOrder(record);
|
|
105
124
|
for (const hostId of captured){
|
|
106
125
|
const group = consentGroupForHost(org, hostId);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/contact-holder.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 * WHICH HOLDER A CROSS-SITE READER SEES A CONTACT THROUGH (AGL-2630).\n *\n * A contact document carries one facet per holder, and every field but the\n * shared identity lives in one of them. A surface standing on a site reads\n * that site's group; a surface standing at the organization — the org-level\n * Contacts list, and the whole-collection export that writes the same file\n * — has no site to read as and needs one answer per row.\n *\n * In the library rather than in the CRM plugin because both of those\n * readers need it and one of them is an app: an app may not import a\n * feature plugin (AGL-2662).\n */\n\nimport {
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/contact-holder.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 * WHICH HOLDER A CROSS-SITE READER SEES A CONTACT THROUGH (AGL-2630).\n *\n * A contact document carries one facet per holder, and every field but the\n * shared identity lives in one of them. A surface standing on a site reads\n * that site's group; a surface standing at the organization — the org-level\n * Contacts list, and the whole-collection export that writes the same file\n * — has no site to read as and needs one answer per row.\n *\n * In the library rather than in the CRM plugin because both of those\n * readers need it and one of them is an app: an app may not import a\n * feature plugin (AGL-2662).\n */\n\nimport {\n CONTACT_FACETS_FIELD,\n ORG_CONTACT_HOLDER_ID,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport { CAPTURED_BY_HOST_FIELD } from '@aglyn/aglyn/app-utils/marketing-consent'\nimport {\n type ConsentGroup,\n consentGroupForHost,\n consentGroupsAwaitConfirmation,\n readConsentGroups,\n soloConsentGroup,\n} from '@aglyn/aglyn/app-utils/consent-groups'\n\n/**\n * The holder of nothing: the group a contact is flattened through when no\n * site has captured it and no facet exists — a row written by a path that\n * predates attribution, read at the org level (AGL-2630).\n *\n * An empty `groupId` reads an empty facet (`readContactFacet` answers the\n * empty facet for a key the map lacks) and REFUSES a write\n * (`contactFacetPath` throws on an empty holder), which is the right pair:\n * the row still shows its shared identity, and nothing edits a facet\n * nobody holds. Exported so a surface can test for it rather than for an\n * empty string.\n */\nexport const NO_HOLDER_GROUP: ConsentGroup = Object.freeze({\n hostId: '',\n groupId: '',\n name: null,\n hostIds: [],\n declared: false,\n awaitsConfirmation: false,\n}) as ConsentGroup\n\n/**\n * The holder of a person the organization added itself, with no site behind\n * them (AGL-4122). Like {@link NO_HOLDER_GROUP} it names no site, so `hostId`\n * is empty and a surface asking \"is there a site here at all\" gets the same\n * answer. Unlike it, it names a facet, so the record has a profile to show and\n * an edit has somewhere to go.\n */\nexport const ORG_HOLDER_GROUP: ConsentGroup = Object.freeze({\n hostId: '',\n groupId: ORG_CONTACT_HOLDER_ID,\n name: null,\n hostIds: [],\n declared: false,\n awaitsConfirmation: false,\n}) as ConsentGroup\n\n/**\n * The sites that captured this person, in CAPTURE order — the order\n * `arrayUnion` left, so the first entry is the site that met them first.\n *\n * `contactCaptureHostIds` sorts the same array for RENDERING, where a\n * stable order matters more than a first. Both readers below want the\n * first, so neither may use the sorted one.\n */\nfunction captureOrder(\n record: Record<string, unknown> | null | undefined,\n): string[] {\n const raw = (record ?? {})[CAPTURED_BY_HOST_FIELD]\n if (!Array.isArray(raw)) return []\n return raw.map((id) => String(id ?? '').trim()).filter(Boolean)\n}\n\n/**\n * The group a LEAD is read and acted on through (AGL-3278).\n *\n * The same question {@link contactPrimaryGroup} answers, with a shorter\n * answer: a lead is one flat document — no facets, one profile, one\n * status — so there is nothing to choose between and the holder is simply\n * the site that captured the person first. What it is FOR is the same: at\n * the organization level the record page has no viewing site, and it needs\n * one to read the consent basis against, to file an activity under, to\n * offer a booking on and to convert as.\n *\n * A lead no site has captured — a row imported before attribution — reads\n * through {@link NO_HOLDER_GROUP}, whose empty `hostId` is how a surface\n * asks \"is there a site here at all\" without testing for an empty string.\n */\nexport function leadPrimaryGroup(\n lead: Record<string, unknown> | null | undefined,\n org: Record<string, unknown> | null | undefined,\n): ConsentGroup {\n const captured = captureOrder(lead)\n return captured.length ? consentGroupForHost(org, captured[0]) : NO_HOLDER_GROUP\n}\n\n/**\n * The group a CROSS-HOLDER reader flattens one contact through (AGL-2630).\n *\n * Under a site there is one answer for every row — the site's own group,\n * `consentGroupForHost` — and the reader never asks this. At the\n * organization level there is no viewing site, and a contact captured by\n * three sites has up to three facets; the org-wide member reading them all\n * needs ONE to show the row's profile, owner and stage under, and to write\n * an edit back to. This picks the person's PRIMARY holder: the first\n * capturing site, in capture order, whose group actually holds a facet;\n * then any facet at all, resolved back to its declared group or to the\n * site whose id it is keyed by; then the first capturing site even with no\n * facet yet (the facet is written on the first edit). A row nobody captured\n * and nobody wrote a facet for reads through {@link NO_HOLDER_GROUP}.\n *\n * A person the organization added itself reads through {@link ORG_HOLDER_GROUP}\n * first, whatever has captured them since: that facet is the record the team\n * built, and a form fill on a site later adds a holder without replacing it.\n *\n * Capture order rather than sorted order, deliberately: `capturedByHostIds`\n * is maintained by `arrayUnion`, so its first entry is the site that met\n * the person first, and that site's profile is the one a person reading\n * across brands would expect to see. `contactCaptureHostIds` sorts the same\n * array for RENDERING, where a stable order matters more than a first.\n */\nexport function contactPrimaryGroup(\n row: Record<string, unknown> | null | undefined,\n org: Record<string, unknown> | null | undefined,\n): ConsentGroup {\n const record = row ?? {}\n const facets = record[CONTACT_FACETS_FIELD]\n const facetKeys =\n facets && typeof facets === 'object' && !Array.isArray(facets)\n ? Object.keys(facets as Record<string, unknown>)\n : []\n if (facetKeys.includes(ORG_CONTACT_HOLDER_ID)) return ORG_HOLDER_GROUP\n const captured = captureOrder(record)\n for (const hostId of captured) {\n const group = consentGroupForHost(org, hostId)\n if (facetKeys.includes(group.groupId)) return group\n }\n for (const key of facetKeys) {\n const declared = readConsentGroups(org)[key]\n if (declared) {\n return {\n hostId: declared.hostIds[0],\n groupId: key,\n name: declared.name,\n hostIds: [...declared.hostIds],\n declared: true,\n awaitsConfirmation: consentGroupsAwaitConfirmation(org),\n }\n }\n return soloConsentGroup(key)\n }\n if (captured.length) return consentGroupForHost(org, captured[0])\n return NO_HOLDER_GROUP\n}"],"names":["CONTACT_FACETS_FIELD","ORG_CONTACT_HOLDER_ID","CAPTURED_BY_HOST_FIELD","consentGroupForHost","consentGroupsAwaitConfirmation","readConsentGroups","soloConsentGroup","NO_HOLDER_GROUP","Object","freeze","hostId","groupId","name","hostIds","declared","awaitsConfirmation","ORG_HOLDER_GROUP","captureOrder","record","raw","Array","isArray","map","id","String","trim","filter","Boolean","leadPrimaryGroup","lead","org","captured","length","contactPrimaryGroup","row","facets","facetKeys","keys","includes","group","key"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;CAYC,GAED,SACEA,oBAAoB,EACpBC,qBAAqB,QAChB,kCAAiC;AACxC,SAASC,sBAAsB,QAAQ,2CAA0C;AACjF,SAEEC,mBAAmB,EACnBC,8BAA8B,EAC9BC,iBAAiB,EACjBC,gBAAgB,QACX,wCAAuC;AAE9C;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,kBAAgCC,OAAOC,MAAM,CAAC;IACzDC,QAAQ;IACRC,SAAS;IACTC,MAAM;IACNC,SAAS,EAAE;IACXC,UAAU;IACVC,oBAAoB;AACtB,GAAkB;AAElB;;;;;;CAMC,GACD,OAAO,MAAMC,mBAAiCR,OAAOC,MAAM,CAAC;IAC1DC,QAAQ;IACRC,SAASV;IACTW,MAAM;IACNC,SAAS,EAAE;IACXC,UAAU;IACVC,oBAAoB;AACtB,GAAkB;AAElB;;;;;;;CAOC,GACD,SAASE,aACPC,MAAkD;IAElD,MAAMC,MAAM,CAACD,iBAAAA,SAAU,CAAC,EAAE,CAAChB,uBAAuB;IAClD,IAAI,CAACkB,MAAMC,OAAO,CAACF,MAAM,OAAO,EAAE;IAClC,OAAOA,IAAIG,GAAG,CAAC,CAACC,KAAOC,OAAOD,aAAAA,KAAM,IAAIE,IAAI,IAAIC,MAAM,CAACC;AACzD;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,iBACdC,IAAgD,EAChDC,GAA+C;IAE/C,MAAMC,WAAWd,aAAaY;IAC9B,OAAOE,SAASC,MAAM,GAAG7B,oBAAoB2B,KAAKC,QAAQ,CAAC,EAAE,IAAIxB;AACnE;AAEA;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GACD,OAAO,SAAS0B,oBACdC,GAA+C,EAC/CJ,GAA+C;IAE/C,MAAMZ,SAASgB,cAAAA,MAAO,CAAC;IACvB,MAAMC,SAASjB,MAAM,CAAClB,qBAAqB;IAC3C,MAAMoC,YACJD,UAAU,OAAOA,WAAW,YAAY,CAACf,MAAMC,OAAO,CAACc,UACnD3B,OAAO6B,IAAI,CAACF,UACZ,EAAE;IACR,IAAIC,UAAUE,QAAQ,CAACrC,wBAAwB,OAAOe;IACtD,MAAMe,WAAWd,aAAaC;IAC9B,KAAK,MAAMR,UAAUqB,SAAU;QAC7B,MAAMQ,QAAQpC,oBAAoB2B,KAAKpB;QACvC,IAAI0B,UAAUE,QAAQ,CAACC,MAAM5B,OAAO,GAAG,OAAO4B;IAChD;IACA,KAAK,MAAMC,OAAOJ,UAAW;QAC3B,MAAMtB,WAAWT,kBAAkByB,IAAI,CAACU,IAAI;QAC5C,IAAI1B,UAAU;YACZ,OAAO;gBACLJ,QAAQI,SAASD,OAAO,CAAC,EAAE;gBAC3BF,SAAS6B;gBACT5B,MAAME,SAASF,IAAI;gBACnBC,SAAS;uBAAIC,SAASD,OAAO;iBAAC;gBAC9BC,UAAU;gBACVC,oBAAoBX,+BAA+B0B;YACrD;QACF;QACA,OAAOxB,iBAAiBkC;IAC1B;IACA,IAAIT,SAASC,MAAM,EAAE,OAAO7B,oBAAoB2B,KAAKC,QAAQ,CAAC,EAAE;IAChE,OAAOxB;AACT"}
|
|
@@ -134,6 +134,11 @@ export interface ContactRecord {
|
|
|
134
134
|
}
|
|
135
135
|
export { contactPrimaryGroup, NO_HOLDER_GROUP } from './contact-holder';
|
|
136
136
|
/** A document off the wire, flattened through one group's facet. */
|
|
137
|
+
/**
|
|
138
|
+
* What to call a person: their name, then their address, then their number. A
|
|
139
|
+
* contact needs no email (AGL-4122), so no surface may assume one is there.
|
|
140
|
+
*/
|
|
141
|
+
export declare function contactRecordLabel(record: Pick<ContactRecord, 'name' | 'email' | 'phone' | 'mobilePhone'>): string;
|
|
137
142
|
export declare function contactRecordFromDoc(row: Record<string, any>, group: ConsentGroup): ContactRecord;
|
|
138
143
|
/**
|
|
139
144
|
* Tags as typed — comma-separated — into the shape the facet stores:
|
|
@@ -21,7 +21,13 @@ import { crmShareChipFor } from "./crm-sharing.js";
|
|
|
21
21
|
* `contact-holder.ts`, shared with the server's whole-collection export so
|
|
22
22
|
* the file and the organization-level list flatten a contact one way.
|
|
23
23
|
*/ export { contactPrimaryGroup, NO_HOLDER_GROUP } from "./contact-holder.js";
|
|
24
|
-
/** A document off the wire, flattened through one group's facet. */
|
|
24
|
+
/** A document off the wire, flattened through one group's facet. */ /**
|
|
25
|
+
* What to call a person: their name, then their address, then their number. A
|
|
26
|
+
* contact needs no email (AGL-4122), so no surface may assume one is there.
|
|
27
|
+
*/ export function contactRecordLabel(record) {
|
|
28
|
+
return record.name || record.email || record.phone || record.mobilePhone || 'Unnamed contact';
|
|
29
|
+
}
|
|
30
|
+
export function contactRecordFromDoc(row, group) {
|
|
25
31
|
var _row_$id, _facet_name, _facet_tags, _facet_notes, _facet_ltvCents, _facet_ordersCount, _facet_phone, _facet_jobTitle, _facet_leadSource, _facet_salutation, _facet_firstName, _facet_lastName, _facet_department, _facet_mobilePhone, _facet_homePhone, _facet_otherPhone, _facet_fax, _facet_birthdate, _facet_assistantName, _facet_assistantPhone, _facet_reportsToContactId, _facet_otherAddress, _facet_companyName, _facet_companyId, _facet_address, _facet_custom, _facet_mediaIds, _facet_ownerUid;
|
|
26
32
|
const facet = Aglyn.readContactFacet(row, group.groupId);
|
|
27
33
|
return {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/contact-record.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 * as Aglyn from '@aglyn/aglyn'\nimport { readNextTaskAtMs } from './crm-next-activity'\nimport { type CrmShareChip, crmShareChipFor } from './crm-sharing'\nimport type {\n AglynPostalAddress,\n ConsentGroup,\n ContactInteraction,\n ContactLifecycleStage,\n ContactSource,\n} from '@aglyn/aglyn'\n\n/**\n * One contact, as the VIEWING GROUP may see it (AGL-2596).\n *\n * A contact document is shared by every site in the org, and almost nothing\n * on it is legitimately shared: the notes, tags, timeline, profile and\n * commercial figures are the holder's own records and live in that holder's\n * facet. This is the row after the facet read — flat, so the list, the\n * record page and the export can render fields by name without any of them\n * reaching into `facets.{groupId}` for itself. One projection rather than a\n * facet read at every field is what keeps a surface from ever showing\n * another holder's records: there is no field on this shape that came from\n * the top of the document except the two that ARE shared, the address and\n * the canonical name.\n */\nexport interface ContactRecord {\n $id: string\n /**\n * The facet this row was flattened through — the viewing group under a\n * site, the person's primary holder at the org level (AGL-2630). A write\n * that edits the row goes back to the same facet, so a surface that has\n * only the flat record can still address the holder's own fields.\n */\n groupId: string\n /**\n * A site of that holder's group — the viewing site under a site, and at\n * the organization level the site the primary holder was resolved from —\n * which is where a stage move for this row is made and announced. `''` for\n * a row no site holds.\n */\n holderHostId: string\n /** Every site that has captured this person, sorted — the org-level \"Known by\". */\n capturedByHostIds: string[]\n /** The shared identity and the dedupe key. */\n email: string\n /** The other addresses a merge folded into this record (AGL-2625). */\n alternateEmails: string[]\n /** What THIS holder sees — their own override, or the canonical name. */\n name: string\n /** The canonical name: the identity of last resort, shared by every holder. */\n canonicalName: string\n /** This holder's own name for the person, when they have set one. */\n nameOverride: string\n sources: Partial<Record<ContactSource, true>>\n /** This holder's timeline, narrowed to the sites the group covers. */\n interactions: ContactInteraction[]\n tags: string[]\n notes: string\n campaignIds: string[]\n ltvCents: number\n ordersCount: number\n phone: string\n jobTitle: string\n /** This holder's lead source label (AGL-3298), `''` for none. */\n leadSource: string\n /*\n * Salesforce's standard contact fields (AGL-3515), this holder's, each\n * `''` (or `null`, `false`) for none — see `ContactFacet`.\n */\n salutation: string\n firstName: string\n lastName: string\n department: string\n mobilePhone: string\n homePhone: string\n otherPhone: string\n fax: string\n /** `YYYY-MM-DD`. */\n birthdate: string\n assistantName: string\n assistantPhone: string\n /** The contact this person reports to, by id. */\n reportsToContactId: string\n otherAddress: AglynPostalAddress | null\n doNotCall: boolean\n companyName: string\n companyId: string\n /**\n * What linking this person to a company has to know — this holder's link,\n * the shared mirror and the other holders' ids — so the properties card\n * and the bulk bar can plan a link from the row without the document. Not\n * a display field: the mirror is an index every reader of the document\n * already holds, and nothing renders it.\n */\n companyLink: Aglyn.ContactCompanyLinkState\n /** The mailing address. */\n address: AglynPostalAddress | null\n /** This holder's custom field values, keyed by definition key (AGL-2601). */\n custom?: Record<string, Aglyn.ContactCustomValue>\n /** This holder's attached org-library files, by media id (AGL-2662). */\n mediaIds?: string[]\n ownerUid: string\n /** Empty when the holder has not placed the person in the funnel. */\n lifecycleStage: ContactLifecycleStage | ''\n /**\n * When the person last opened or clicked one of this holder's campaigns\n * (AGL-2616), or `null` when they never have since the stamp shipped.\n */\n lastEmailEngagementAtMs: number | null\n /**\n * When the earliest open task against this person is due (AGL-2661), or\n * `null` when nothing is scheduled. SHARED, not the holder's: a task is\n * filed against the contact document, and every holder reading the row\n * sees the same next step.\n */\n nextTaskAtMs: number | null\n /**\n * The last verdict on the address (AGL-3245) — bounced, blocked,\n * unsubscribed, do-not-contact — or `null` when nothing is known. SHARED\n * like the address it is about.\n */\n emailState: Aglyn.EmailState | null\n /**\n * Why the viewing group sees this person when the answer is a share\n * (AGL-3336) — `null` when it holds them, and at the organization level.\n */\n shareChip?: CrmShareChip | null\n createdAt?: unknown\n updatedAt?: unknown\n}\n\n/*\n * Which holder a cross-site reader sees one contact through (AGL-2630) —\n * `contact-holder.ts`, shared with the server's whole-collection export so\n * the file and the organization-level list flatten a contact one way.\n */\nexport { contactPrimaryGroup, NO_HOLDER_GROUP } from './contact-holder'\n\n/** A document off the wire, flattened through one group's facet. */\nexport function contactRecordFromDoc(\n row: Record<string, any>,\n group: ConsentGroup,\n): ContactRecord {\n const facet = Aglyn.readContactFacet(row, group.groupId)\n return {\n $id: String(row['$id'] ?? ''),\n groupId: group.groupId,\n holderHostId: group.hostId,\n capturedByHostIds: Aglyn.contactCaptureHostIds(row),\n email: typeof row['email'] === 'string' ? row['email'] : '',\n alternateEmails: Array.isArray(row[Aglyn.CONTACT_ALTERNATE_EMAILS_FIELD])\n ? (row[Aglyn.CONTACT_ALTERNATE_EMAILS_FIELD] as unknown[]).filter(\n (email): email is string => typeof email === 'string',\n )\n : [],\n name: Aglyn.contactDisplayName(row, group.groupId),\n canonicalName: typeof row['name'] === 'string' ? row['name'] : '',\n nameOverride: facet.name ?? '',\n sources: facet.sources,\n interactions: Aglyn.interactionsForGroup(facet.interactions, group.hostIds),\n tags: facet.tags ?? [],\n notes: facet.notes ?? '',\n campaignIds: Aglyn.readContactContainerIds(row, group.groupId, 'campaign'),\n ltvCents: facet.ltvCents ?? 0,\n ordersCount: facet.ordersCount ?? 0,\n phone: facet.phone ?? '',\n jobTitle: facet.jobTitle ?? '',\n leadSource: facet.leadSource ?? '',\n salutation: facet.salutation ?? '',\n firstName: facet.firstName ?? '',\n lastName: facet.lastName ?? '',\n department: facet.department ?? '',\n mobilePhone: facet.mobilePhone ?? '',\n homePhone: facet.homePhone ?? '',\n otherPhone: facet.otherPhone ?? '',\n fax: facet.fax ?? '',\n birthdate: facet.birthdate ?? '',\n assistantName: facet.assistantName ?? '',\n assistantPhone: facet.assistantPhone ?? '',\n reportsToContactId: facet.reportsToContactId ?? '',\n otherAddress: facet.otherAddress ?? null,\n doNotCall: facet.doNotCall === true,\n companyName: facet.companyName ?? '',\n companyId: facet.companyId ?? '',\n companyLink: Aglyn.readContactCompanyLink(row, group.groupId),\n address: facet.address ?? null,\n custom: facet.custom ?? {},\n mediaIds: facet.mediaIds ?? [],\n ownerUid: facet.ownerUid ?? '',\n lifecycleStage: Aglyn.isContactLifecycleStage(facet.lifecycleStage)\n ? facet.lifecycleStage\n : '',\n lastEmailEngagementAtMs:\n typeof facet.lastEmailEngagementAtMs === 'number' &&\n Number.isFinite(facet.lastEmailEngagementAtMs) &&\n facet.lastEmailEngagementAtMs > 0\n ? facet.lastEmailEngagementAtMs\n : null,\n nextTaskAtMs: readNextTaskAtMs(row as { nextTaskAtMs?: unknown }),\n emailState: Aglyn.readEmailState(row),\n shareChip: crmShareChipFor(row, group.hostIds),\n createdAt: row['createdAt'],\n updatedAt: row['updatedAt'],\n }\n}\n\n/**\n * Tags as typed — comma-separated — into the shape the facet stores:\n * lower-cased, trimmed, deduplicated and capped, so a tag typed on the\n * record page and one typed in the create drawer are the same tag to the\n * segment filter that matches on them.\n */\nexport function parseContactTags(input: string): string[] {\n return [\n ...new Set(\n input\n .split(',')\n .map((tag) => tag.trim().toLowerCase().slice(0, 40))\n .filter(Boolean)\n .slice(0, 20),\n ),\n ]\n}\n"],"names":["Aglyn","readNextTaskAtMs","crmShareChipFor","contactPrimaryGroup","NO_HOLDER_GROUP","contactRecordFromDoc","row","group","facet","readContactFacet","groupId","$id","String","holderHostId","hostId","capturedByHostIds","contactCaptureHostIds","email","alternateEmails","Array","isArray","CONTACT_ALTERNATE_EMAILS_FIELD","filter","name","contactDisplayName","canonicalName","nameOverride","sources","interactions","interactionsForGroup","hostIds","tags","notes","campaignIds","readContactContainerIds","ltvCents","ordersCount","phone","jobTitle","leadSource","salutation","firstName","lastName","department","mobilePhone","homePhone","otherPhone","fax","birthdate","assistantName","assistantPhone","reportsToContactId","otherAddress","doNotCall","companyName","companyId","companyLink","readContactCompanyLink","address","custom","mediaIds","ownerUid","lifecycleStage","isContactLifecycleStage","lastEmailEngagementAtMs","Number","isFinite","nextTaskAtMs","emailState","readEmailState","shareChip","createdAt","updatedAt","parseContactTags","input","Set","split","map","tag","trim","toLowerCase","slice","Boolean"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/contact-record.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 * as Aglyn from '@aglyn/aglyn'\nimport { readNextTaskAtMs } from './crm-next-activity'\nimport { type CrmShareChip, crmShareChipFor } from './crm-sharing'\nimport type {\n AglynPostalAddress,\n ConsentGroup,\n ContactInteraction,\n ContactLifecycleStage,\n ContactSource,\n} from '@aglyn/aglyn'\n\n/**\n * One contact, as the VIEWING GROUP may see it (AGL-2596).\n *\n * A contact document is shared by every site in the org, and almost nothing\n * on it is legitimately shared: the notes, tags, timeline, profile and\n * commercial figures are the holder's own records and live in that holder's\n * facet. This is the row after the facet read — flat, so the list, the\n * record page and the export can render fields by name without any of them\n * reaching into `facets.{groupId}` for itself. One projection rather than a\n * facet read at every field is what keeps a surface from ever showing\n * another holder's records: there is no field on this shape that came from\n * the top of the document except the two that ARE shared, the address and\n * the canonical name.\n */\nexport interface ContactRecord {\n $id: string\n /**\n * The facet this row was flattened through — the viewing group under a\n * site, the person's primary holder at the org level (AGL-2630). A write\n * that edits the row goes back to the same facet, so a surface that has\n * only the flat record can still address the holder's own fields.\n */\n groupId: string\n /**\n * A site of that holder's group — the viewing site under a site, and at\n * the organization level the site the primary holder was resolved from —\n * which is where a stage move for this row is made and announced. `''` for\n * a row no site holds.\n */\n holderHostId: string\n /** Every site that has captured this person, sorted — the org-level \"Known by\". */\n capturedByHostIds: string[]\n /** The shared identity and the dedupe key. */\n email: string\n /** The other addresses a merge folded into this record (AGL-2625). */\n alternateEmails: string[]\n /** What THIS holder sees — their own override, or the canonical name. */\n name: string\n /** The canonical name: the identity of last resort, shared by every holder. */\n canonicalName: string\n /** This holder's own name for the person, when they have set one. */\n nameOverride: string\n sources: Partial<Record<ContactSource, true>>\n /** This holder's timeline, narrowed to the sites the group covers. */\n interactions: ContactInteraction[]\n tags: string[]\n notes: string\n campaignIds: string[]\n ltvCents: number\n ordersCount: number\n phone: string\n jobTitle: string\n /** This holder's lead source label (AGL-3298), `''` for none. */\n leadSource: string\n /*\n * Salesforce's standard contact fields (AGL-3515), this holder's, each\n * `''` (or `null`, `false`) for none — see `ContactFacet`.\n */\n salutation: string\n firstName: string\n lastName: string\n department: string\n mobilePhone: string\n homePhone: string\n otherPhone: string\n fax: string\n /** `YYYY-MM-DD`. */\n birthdate: string\n assistantName: string\n assistantPhone: string\n /** The contact this person reports to, by id. */\n reportsToContactId: string\n otherAddress: AglynPostalAddress | null\n doNotCall: boolean\n companyName: string\n companyId: string\n /**\n * What linking this person to a company has to know — this holder's link,\n * the shared mirror and the other holders' ids — so the properties card\n * and the bulk bar can plan a link from the row without the document. Not\n * a display field: the mirror is an index every reader of the document\n * already holds, and nothing renders it.\n */\n companyLink: Aglyn.ContactCompanyLinkState\n /** The mailing address. */\n address: AglynPostalAddress | null\n /** This holder's custom field values, keyed by definition key (AGL-2601). */\n custom?: Record<string, Aglyn.ContactCustomValue>\n /** This holder's attached org-library files, by media id (AGL-2662). */\n mediaIds?: string[]\n ownerUid: string\n /** Empty when the holder has not placed the person in the funnel. */\n lifecycleStage: ContactLifecycleStage | ''\n /**\n * When the person last opened or clicked one of this holder's campaigns\n * (AGL-2616), or `null` when they never have since the stamp shipped.\n */\n lastEmailEngagementAtMs: number | null\n /**\n * When the earliest open task against this person is due (AGL-2661), or\n * `null` when nothing is scheduled. SHARED, not the holder's: a task is\n * filed against the contact document, and every holder reading the row\n * sees the same next step.\n */\n nextTaskAtMs: number | null\n /**\n * The last verdict on the address (AGL-3245) — bounced, blocked,\n * unsubscribed, do-not-contact — or `null` when nothing is known. SHARED\n * like the address it is about.\n */\n emailState: Aglyn.EmailState | null\n /**\n * Why the viewing group sees this person when the answer is a share\n * (AGL-3336) — `null` when it holds them, and at the organization level.\n */\n shareChip?: CrmShareChip | null\n createdAt?: unknown\n updatedAt?: unknown\n}\n\n/*\n * Which holder a cross-site reader sees one contact through (AGL-2630) —\n * `contact-holder.ts`, shared with the server's whole-collection export so\n * the file and the organization-level list flatten a contact one way.\n */\nexport { contactPrimaryGroup, NO_HOLDER_GROUP } from './contact-holder'\n\n/** A document off the wire, flattened through one group's facet. */\n/**\n * What to call a person: their name, then their address, then their number. A\n * contact needs no email (AGL-4122), so no surface may assume one is there.\n */\nexport function contactRecordLabel(\n record: Pick<ContactRecord, 'name' | 'email' | 'phone' | 'mobilePhone'>,\n): string {\n return record.name || record.email || record.phone || record.mobilePhone || 'Unnamed contact'\n}\n\nexport function contactRecordFromDoc(\n row: Record<string, any>,\n group: ConsentGroup,\n): ContactRecord {\n const facet = Aglyn.readContactFacet(row, group.groupId)\n return {\n $id: String(row['$id'] ?? ''),\n groupId: group.groupId,\n holderHostId: group.hostId,\n capturedByHostIds: Aglyn.contactCaptureHostIds(row),\n email: typeof row['email'] === 'string' ? row['email'] : '',\n alternateEmails: Array.isArray(row[Aglyn.CONTACT_ALTERNATE_EMAILS_FIELD])\n ? (row[Aglyn.CONTACT_ALTERNATE_EMAILS_FIELD] as unknown[]).filter(\n (email): email is string => typeof email === 'string',\n )\n : [],\n name: Aglyn.contactDisplayName(row, group.groupId),\n canonicalName: typeof row['name'] === 'string' ? row['name'] : '',\n nameOverride: facet.name ?? '',\n sources: facet.sources,\n interactions: Aglyn.interactionsForGroup(facet.interactions, group.hostIds),\n tags: facet.tags ?? [],\n notes: facet.notes ?? '',\n campaignIds: Aglyn.readContactContainerIds(row, group.groupId, 'campaign'),\n ltvCents: facet.ltvCents ?? 0,\n ordersCount: facet.ordersCount ?? 0,\n phone: facet.phone ?? '',\n jobTitle: facet.jobTitle ?? '',\n leadSource: facet.leadSource ?? '',\n salutation: facet.salutation ?? '',\n firstName: facet.firstName ?? '',\n lastName: facet.lastName ?? '',\n department: facet.department ?? '',\n mobilePhone: facet.mobilePhone ?? '',\n homePhone: facet.homePhone ?? '',\n otherPhone: facet.otherPhone ?? '',\n fax: facet.fax ?? '',\n birthdate: facet.birthdate ?? '',\n assistantName: facet.assistantName ?? '',\n assistantPhone: facet.assistantPhone ?? '',\n reportsToContactId: facet.reportsToContactId ?? '',\n otherAddress: facet.otherAddress ?? null,\n doNotCall: facet.doNotCall === true,\n companyName: facet.companyName ?? '',\n companyId: facet.companyId ?? '',\n companyLink: Aglyn.readContactCompanyLink(row, group.groupId),\n address: facet.address ?? null,\n custom: facet.custom ?? {},\n mediaIds: facet.mediaIds ?? [],\n ownerUid: facet.ownerUid ?? '',\n lifecycleStage: Aglyn.isContactLifecycleStage(facet.lifecycleStage)\n ? facet.lifecycleStage\n : '',\n lastEmailEngagementAtMs:\n typeof facet.lastEmailEngagementAtMs === 'number' &&\n Number.isFinite(facet.lastEmailEngagementAtMs) &&\n facet.lastEmailEngagementAtMs > 0\n ? facet.lastEmailEngagementAtMs\n : null,\n nextTaskAtMs: readNextTaskAtMs(row as { nextTaskAtMs?: unknown }),\n emailState: Aglyn.readEmailState(row),\n shareChip: crmShareChipFor(row, group.hostIds),\n createdAt: row['createdAt'],\n updatedAt: row['updatedAt'],\n }\n}\n\n/**\n * Tags as typed — comma-separated — into the shape the facet stores:\n * lower-cased, trimmed, deduplicated and capped, so a tag typed on the\n * record page and one typed in the create drawer are the same tag to the\n * segment filter that matches on them.\n */\nexport function parseContactTags(input: string): string[] {\n return [\n ...new Set(\n input\n .split(',')\n .map((tag) => tag.trim().toLowerCase().slice(0, 40))\n .filter(Boolean)\n .slice(0, 20),\n ),\n ]\n}\n"],"names":["Aglyn","readNextTaskAtMs","crmShareChipFor","contactPrimaryGroup","NO_HOLDER_GROUP","contactRecordLabel","record","name","email","phone","mobilePhone","contactRecordFromDoc","row","group","facet","readContactFacet","groupId","$id","String","holderHostId","hostId","capturedByHostIds","contactCaptureHostIds","alternateEmails","Array","isArray","CONTACT_ALTERNATE_EMAILS_FIELD","filter","contactDisplayName","canonicalName","nameOverride","sources","interactions","interactionsForGroup","hostIds","tags","notes","campaignIds","readContactContainerIds","ltvCents","ordersCount","jobTitle","leadSource","salutation","firstName","lastName","department","homePhone","otherPhone","fax","birthdate","assistantName","assistantPhone","reportsToContactId","otherAddress","doNotCall","companyName","companyId","companyLink","readContactCompanyLink","address","custom","mediaIds","ownerUid","lifecycleStage","isContactLifecycleStage","lastEmailEngagementAtMs","Number","isFinite","nextTaskAtMs","emailState","readEmailState","shareChip","createdAt","updatedAt","parseContactTags","input","Set","split","map","tag","trim","toLowerCase","slice","Boolean"],"mappings":"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"}
|
|
@@ -45,6 +45,12 @@ export declare const CRM_CONTACT_UPDATE_MAX = 200;
|
|
|
45
45
|
* `doNotCall: false` clears the flag.
|
|
46
46
|
*/
|
|
47
47
|
export interface ContactUpdateFields {
|
|
48
|
+
/**
|
|
49
|
+
* The address, which every site shares, so not a field of any facet. Set on
|
|
50
|
+
* one contact at a time, and refused when another contact already answers
|
|
51
|
+
* to it (AGL-4122): an email can come after the person does.
|
|
52
|
+
*/
|
|
53
|
+
email?: string;
|
|
48
54
|
/**
|
|
49
55
|
* This holder's own name for the person. The canonical name is never
|
|
50
56
|
* written. While the holder keeps a first or last name, the name is their
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
* as much as a selection reaches. A larger selection is sent in pieces.
|
|
32
32
|
*/ export const CRM_CONTACT_UPDATE_MAX = 200;
|
|
33
33
|
/** Every field the route reads. A body naming any other is refused, not trimmed. */ export const CONTACT_UPDATE_FIELDS = [
|
|
34
|
+
'email',
|
|
34
35
|
'name',
|
|
35
36
|
'salutation',
|
|
36
37
|
'firstName',
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/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\nimport type { AglynPostalAddress, ContactCustomValue } from '@aglyn/aglyn'\n\n/**\n * THE CONTACT PROFILE ROUTE'S CONTRACT (AGL-2804), in one module both halves\n * import, so the field the console sends and the field the handler reads\n * cannot drift apart.\n *\n * `crm/contact-update` is the console's one writer of a contact's facets.\n * The Firestore rules cannot tell one field of a holder's facet from another\n * — the holder is a map key a rule cannot address — so a client update may\n * not change a facet at all beyond letting a holder go, and every edit the\n * console makes to one arrives here: the record page's profile, custom\n * fields, files and campaign filing, the company page's link, and the bulk\n * bar's tags, owner and company. A lifecycle stage has a route of its own,\n * `crm/contact-stage`, because moving it is an event.\n */\n\n/**\n * The most contacts one request names — the contacts list's window, which is\n * as much as a selection reaches. A larger selection is sent in pieces.\n */\nexport const CRM_CONTACT_UPDATE_MAX = 200\n\n/**\n * What one request sets, for ONE holder: the viewing site's group under a\n * site, and at the organization level each contact's own primary holder.\n *\n * A text field sent as `''` is CLEARED — removed from the facet — except\n * `notes`, which are stored as typed. A list replaces the stored list;\n * `addTag` and `removeTag` change one tag and leave the rest as the server\n * holds them. An address or the reports-to sent as `null` clears it, and\n * `doNotCall: false` clears the flag.\n */\nexport interface ContactUpdateFields {\n /**\n * This holder's own name for the person. The canonical name is never\n * written. While the holder keeps a first or last name, the name is their\n * composition, and a `name` that differs from it is refused (AGL-3515).\n */\n name?: string\n /** One of the org's salutation values, by label, or `''` to clear (AGL-3515). */\n salutation?: string\n /** Given and family names; the facet's `name` follows them — see `ContactFacet.firstName`. */\n firstName?: string\n lastName?: string\n department?: string\n /** As typed; stored as E.164, and refused when it cannot be read as a number. */\n mobilePhone?: string\n homePhone?: string\n otherPhone?: string\n fax?: string\n assistantPhone?: string\n /** `YYYY-MM-DD`, not in the future, or `''` to clear. */\n birthdate?: string\n assistantName?: string\n /**\n * Another contact the holder can see, or `null` to clear. Refused when it\n * is the contact itself or when its own chain leads back to the contact.\n */\n reportsToContactId?: string | null\n /** The second postal address — `address` is the mailing one. */\n otherAddress?: AglynPostalAddress | null\n /** The person asked not to be phoned; `false` clears the flag. */\n doNotCall?: boolean\n /** As typed; stored as E.164, and refused when it cannot be read as a number. */\n phone?: string\n jobTitle?: string\n /**\n * One of the org's lead source values, by label (AGL-3298), or `''` to\n * clear. Refused when it is not an active value and not the one the\n * contact already holds.\n */\n leadSource?: string\n address?: AglynPostalAddress | null\n notes?: string\n tags?: string[]\n addTag?: string\n removeTag?: string\n campaignIds?: string[]\n /** A member of the organization, or `''` for nobody. */\n ownerUid?: string\n /** The company to file the person under, or `null` to unlink them. */\n companyId?: string | null\n /**\n * The company's label on the record. Left out beside `companyId`, the\n * linked company's own name is written, and an unlink clears it.\n */\n companyName?: string\n /** Only the keys that changed, each judged against its definition; `null` clears. */\n custom?: Record<string, ContactCustomValue>\n mediaIds?: string[]\n}\n\n/** Every field the route reads. A body naming any other is refused, not trimmed. */\nexport const CONTACT_UPDATE_FIELDS: ReadonlyArray<keyof ContactUpdateFields> = [\n 'name',\n 'salutation',\n 'firstName',\n 'lastName',\n 'phone',\n 'mobilePhone',\n 'homePhone',\n 'otherPhone',\n 'fax',\n 'jobTitle',\n 'department',\n 'birthdate',\n 'assistantName',\n 'assistantPhone',\n 'reportsToContactId',\n 'leadSource',\n 'address',\n 'otherAddress',\n 'doNotCall',\n 'notes',\n 'tags',\n 'addTag',\n 'removeTag',\n 'campaignIds',\n 'ownerUid',\n 'companyId',\n 'companyName',\n 'custom',\n 'mediaIds',\n]\n\nexport interface ContactUpdateRequest {\n /** The mounted site; at the organization level the record's own site, or nothing. */\n hostId?: string | null\n /** The organization, which makes the call the route's organization variant. */\n orgId?: string\n contactIds: string[]\n set: ContactUpdateFields\n}\n\n/** One contact's answer: saved, or refused with a sentence written for the reader. */\nexport type ContactUpdateOutcome =\n | { contactId: string; ok: true }\n | { contactId: string; ok: false; error: string }\n\nexport interface ContactUpdateResponse {\n ok: true\n results: ContactUpdateOutcome[]\n}\n"],"names":["CRM_CONTACT_UPDATE_MAX","CONTACT_UPDATE_FIELDS"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAID;;;;;;;;;;;;;CAaC,GAED;;;CAGC,GACD,OAAO,MAAMA,yBAAyB,IAAG;
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/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\nimport type { AglynPostalAddress, ContactCustomValue } from '@aglyn/aglyn'\n\n/**\n * THE CONTACT PROFILE ROUTE'S CONTRACT (AGL-2804), in one module both halves\n * import, so the field the console sends and the field the handler reads\n * cannot drift apart.\n *\n * `crm/contact-update` is the console's one writer of a contact's facets.\n * The Firestore rules cannot tell one field of a holder's facet from another\n * — the holder is a map key a rule cannot address — so a client update may\n * not change a facet at all beyond letting a holder go, and every edit the\n * console makes to one arrives here: the record page's profile, custom\n * fields, files and campaign filing, the company page's link, and the bulk\n * bar's tags, owner and company. A lifecycle stage has a route of its own,\n * `crm/contact-stage`, because moving it is an event.\n */\n\n/**\n * The most contacts one request names — the contacts list's window, which is\n * as much as a selection reaches. A larger selection is sent in pieces.\n */\nexport const CRM_CONTACT_UPDATE_MAX = 200\n\n/**\n * What one request sets, for ONE holder: the viewing site's group under a\n * site, and at the organization level each contact's own primary holder.\n *\n * A text field sent as `''` is CLEARED — removed from the facet — except\n * `notes`, which are stored as typed. A list replaces the stored list;\n * `addTag` and `removeTag` change one tag and leave the rest as the server\n * holds them. An address or the reports-to sent as `null` clears it, and\n * `doNotCall: false` clears the flag.\n */\nexport interface ContactUpdateFields {\n /**\n * The address, which every site shares, so not a field of any facet. Set on\n * one contact at a time, and refused when another contact already answers\n * to it (AGL-4122): an email can come after the person does.\n */\n email?: string\n /**\n * This holder's own name for the person. The canonical name is never\n * written. While the holder keeps a first or last name, the name is their\n * composition, and a `name` that differs from it is refused (AGL-3515).\n */\n name?: string\n /** One of the org's salutation values, by label, or `''` to clear (AGL-3515). */\n salutation?: string\n /** Given and family names; the facet's `name` follows them — see `ContactFacet.firstName`. */\n firstName?: string\n lastName?: string\n department?: string\n /** As typed; stored as E.164, and refused when it cannot be read as a number. */\n mobilePhone?: string\n homePhone?: string\n otherPhone?: string\n fax?: string\n assistantPhone?: string\n /** `YYYY-MM-DD`, not in the future, or `''` to clear. */\n birthdate?: string\n assistantName?: string\n /**\n * Another contact the holder can see, or `null` to clear. Refused when it\n * is the contact itself or when its own chain leads back to the contact.\n */\n reportsToContactId?: string | null\n /** The second postal address — `address` is the mailing one. */\n otherAddress?: AglynPostalAddress | null\n /** The person asked not to be phoned; `false` clears the flag. */\n doNotCall?: boolean\n /** As typed; stored as E.164, and refused when it cannot be read as a number. */\n phone?: string\n jobTitle?: string\n /**\n * One of the org's lead source values, by label (AGL-3298), or `''` to\n * clear. Refused when it is not an active value and not the one the\n * contact already holds.\n */\n leadSource?: string\n address?: AglynPostalAddress | null\n notes?: string\n tags?: string[]\n addTag?: string\n removeTag?: string\n campaignIds?: string[]\n /** A member of the organization, or `''` for nobody. */\n ownerUid?: string\n /** The company to file the person under, or `null` to unlink them. */\n companyId?: string | null\n /**\n * The company's label on the record. Left out beside `companyId`, the\n * linked company's own name is written, and an unlink clears it.\n */\n companyName?: string\n /** Only the keys that changed, each judged against its definition; `null` clears. */\n custom?: Record<string, ContactCustomValue>\n mediaIds?: string[]\n}\n\n/** Every field the route reads. A body naming any other is refused, not trimmed. */\nexport const CONTACT_UPDATE_FIELDS: ReadonlyArray<keyof ContactUpdateFields> = [\n 'email',\n 'name',\n 'salutation',\n 'firstName',\n 'lastName',\n 'phone',\n 'mobilePhone',\n 'homePhone',\n 'otherPhone',\n 'fax',\n 'jobTitle',\n 'department',\n 'birthdate',\n 'assistantName',\n 'assistantPhone',\n 'reportsToContactId',\n 'leadSource',\n 'address',\n 'otherAddress',\n 'doNotCall',\n 'notes',\n 'tags',\n 'addTag',\n 'removeTag',\n 'campaignIds',\n 'ownerUid',\n 'companyId',\n 'companyName',\n 'custom',\n 'mediaIds',\n]\n\nexport interface ContactUpdateRequest {\n /** The mounted site; at the organization level the record's own site, or nothing. */\n hostId?: string | null\n /** The organization, which makes the call the route's organization variant. */\n orgId?: string\n contactIds: string[]\n set: ContactUpdateFields\n}\n\n/** One contact's answer: saved, or refused with a sentence written for the reader. */\nexport type ContactUpdateOutcome =\n | { contactId: string; ok: true }\n | { contactId: string; ok: false; error: string }\n\nexport interface ContactUpdateResponse {\n ok: true\n results: ContactUpdateOutcome[]\n}\n"],"names":["CRM_CONTACT_UPDATE_MAX","CONTACT_UPDATE_FIELDS"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAID;;;;;;;;;;;;;CAaC,GAED;;;CAGC,GACD,OAAO,MAAMA,yBAAyB,IAAG;AA8EzC,kFAAkF,GAClF,OAAO,MAAMC,wBAAkE;IAC7E;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD,CAAA"}
|