@aglyn/plugins-crm 1.0.0-beta.296 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -14
- package/src/lib/components/activity-link-fields.d.ts +34 -0
- package/src/lib/components/activity-link-fields.js +141 -0
- package/src/lib/components/activity-link-fields.js.map +1 -0
- package/src/lib/components/activity-list.d.ts +8 -0
- package/src/lib/components/activity-list.js +4 -2
- package/src/lib/components/activity-list.js.map +1 -1
- package/src/lib/components/activity-queries.d.ts +7 -2
- package/src/lib/components/activity-queries.js +7 -3
- package/src/lib/components/activity-queries.js.map +1 -1
- package/src/lib/components/company-contact-roles-card.d.ts +42 -0
- package/src/lib/components/company-contact-roles-card.js +135 -0
- package/src/lib/components/company-contact-roles-card.js.map +1 -0
- package/src/lib/components/company-detail-page.js +12 -1
- package/src/lib/components/company-detail-page.js.map +1 -1
- package/src/lib/components/company-edit-drawer.js +9 -6
- package/src/lib/components/company-edit-drawer.js.map +1 -1
- package/src/lib/components/company-picker.js +4 -2
- package/src/lib/components/company-picker.js.map +1 -1
- package/src/lib/components/contact-associations-card.js +15 -1
- package/src/lib/components/contact-associations-card.js.map +1 -1
- package/src/lib/components/contact-detail-page.js +13 -14
- package/src/lib/components/contact-detail-page.js.map +1 -1
- package/src/lib/components/contact-list-columns.js +5 -4
- package/src/lib/components/contact-list-columns.js.map +1 -1
- package/src/lib/components/contact-properties-card.js +41 -12
- 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/lead-campaigns-card.js +26 -14
- package/src/lib/components/lead-campaigns-card.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/picklist-values-card.js +7 -4
- package/src/lib/components/picklist-values-card.js.map +1 -1
- package/src/lib/components/pipeline-stages-dialog.js +5 -1
- package/src/lib/components/pipeline-stages-dialog.js.map +1 -1
- package/src/lib/components/record-activity-card.d.ts +11 -2
- package/src/lib/components/record-activity-card.js +90 -5
- package/src/lib/components/record-activity-card.js.map +1 -1
- package/src/lib/components/settings-section.d.ts +4 -2
- package/src/lib/components/settings-section.js +7 -5
- package/src/lib/components/settings-section.js.map +1 -1
- package/src/lib/hooks/crm-activity-client-reads.d.ts +8 -0
- package/src/lib/hooks/crm-activity-client-reads.js +50 -0
- package/src/lib/hooks/crm-activity-client-reads.js.map +1 -0
- package/src/lib/hooks/use-campaign-filing-log.d.ts +2 -1
- package/src/lib/hooks/use-campaign-filing-log.js +10 -3
- package/src/lib/hooks/use-campaign-filing-log.js.map +1 -1
- package/src/lib/hooks/use-crm-org-mount.d.ts +8 -6
- package/src/lib/hooks/use-crm-org-mount.js +2 -1
- package/src/lib/hooks/use-crm-org-mount.js.map +1 -1
- package/src/lib/hooks/use-crm-scope.d.ts +17 -5
- package/src/lib/hooks/use-crm-scope.js +12 -5
- package/src/lib/hooks/use-crm-scope.js.map +1 -1
- package/src/lib/model/contact-holder.d.ts +12 -0
- package/src/lib/model/contact-holder.js +20 -1
- package/src/lib/model/contact-holder.js.map +1 -1
- package/src/lib/model/contact-record.d.ts +5 -0
- package/src/lib/model/contact-record.js +7 -1
- package/src/lib/model/contact-record.js.map +1 -1
- package/src/lib/model/contact-update.d.ts +6 -0
- package/src/lib/model/contact-update.js +1 -0
- package/src/lib/model/contact-update.js.map +1 -1
- package/src/lib/model/crm-activity-link.d.ts +117 -0
- package/src/lib/model/crm-activity-link.js +113 -0
- package/src/lib/model/crm-activity-link.js.map +1 -0
- package/src/lib/model/crm-api.d.ts +4 -1
- package/src/lib/model/crm-api.js +9 -5
- package/src/lib/model/crm-api.js.map +1 -1
- package/src/lib/model/deal-board-model.js +5 -3
- package/src/lib/model/deal-board-model.js.map +1 -1
- package/src/lib/model/deal-contact-roles.d.ts +15 -1
- package/src/lib/model/deal-contact-roles.js +17 -3
- package/src/lib/model/deal-contact-roles.js.map +1 -1
- package/src/lib/model/settle-stage-names.d.ts +25 -0
- package/src/lib/model/settle-stage-names.js +29 -0
- package/src/lib/model/settle-stage-names.js.map +1 -0
- package/src/lib/native-nav.js +1 -1
- package/src/lib/native-nav.js.map +1 -1
- package/src/lib/server/add-contact-by-hand.d.ts +84 -0
- package/src/lib/server/add-contact-by-hand.js +212 -0
- package/src/lib/server/add-contact-by-hand.js.map +1 -0
- package/src/lib/server/api-v1/contacts.js +61 -23
- package/src/lib/server/api-v1/contacts.js.map +1 -1
- package/src/lib/server/api-v1/crm-activities.js +13 -3
- package/src/lib/server/api-v1/crm-activities.js.map +1 -1
- package/src/lib/server/api-v1/crm-pipelines.d.ts +1 -1
- package/src/lib/server/api-v1/crm-pipelines.js +8 -2
- package/src/lib/server/api-v1/crm-pipelines.js.map +1 -1
- package/src/lib/server/api-v1/crm-shared.d.ts +16 -9
- package/src/lib/server/api-v1/crm-shared.js +23 -11
- package/src/lib/server/api-v1/crm-shared.js.map +1 -1
- package/src/lib/server/api-v1/openapi.js +3 -3
- package/src/lib/server/api-v1/openapi.js.map +1 -1
- package/src/lib/server/automation-steps.js +11 -9
- package/src/lib/server/automation-steps.js.map +1 -1
- package/src/lib/server/campaign-filing-activity.js +7 -2
- package/src/lib/server/campaign-filing-activity.js.map +1 -1
- package/src/lib/server/contact-update.d.ts +4 -0
- package/src/lib/server/contact-update.js +71 -10
- package/src/lib/server/contact-update.js.map +1 -1
- package/src/lib/server/convert-host-lead.js +13 -2
- package/src/lib/server/convert-host-lead.js.map +1 -1
- package/src/lib/server/convert-open-lead.js +8 -1
- package/src/lib/server/convert-open-lead.js.map +1 -1
- package/src/lib/server/crm-activity-company.d.ts +25 -0
- package/src/lib/server/crm-activity-company.js +34 -0
- package/src/lib/server/crm-activity-company.js.map +1 -0
- package/src/lib/server/crm-inbound-email.js +21 -15
- package/src/lib/server/crm-inbound-email.js.map +1 -1
- package/src/lib/server/crm-sharing.js +9 -2
- package/src/lib/server/crm-sharing.js.map +1 -1
- package/src/lib/server/email-send.js +10 -8
- package/src/lib/server/email-send.js.map +1 -1
- package/src/lib/server/merge-contacts.js +6 -2
- package/src/lib/server/merge-contacts.js.map +1 -1
- package/src/lib/server/org-caller.d.ts +8 -0
- package/src/lib/server/org-caller.js +10 -0
- package/src/lib/server/org-caller.js.map +1 -1
- package/src/lib/server/record-timeline.d.ts +3 -1
- package/src/lib/server/record-timeline.js +11 -3
- package/src/lib/server/record-timeline.js.map +1 -1
- package/src/lib/server/task-reminders-route.d.ts +7 -5
- package/src/lib/server/task-reminders-route.js +82 -15
- package/src/lib/server/task-reminders-route.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
|
@@ -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"}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { CrmActivityLink } from '@aglyn/aglyn/app-utils/crm';
|
|
18
|
+
/**
|
|
19
|
+
* WHO, WHAT AND WHICH ACCOUNT AN ACTIVITY IS ABOUT (AGL-4123).
|
|
20
|
+
*
|
|
21
|
+
* Salesforce's activity model, on the three links an activity already had:
|
|
22
|
+
* `contactId` is its Who (the **Name** field), `dealId` or `companyId` its
|
|
23
|
+
* What (the **Related To** field), and `companyId` its Account, the rollup
|
|
24
|
+
* that puts a call with a person, or about a deal, on the timeline of the
|
|
25
|
+
* company behind them. Each timeline lists by its own field, so an entry
|
|
26
|
+
* that carries all three shows on all three pages.
|
|
27
|
+
*
|
|
28
|
+
* Every writer of `crmActivities` stamps the Account here. A rollup a writer
|
|
29
|
+
* spelled for itself is the one that would disagree: a company's timeline is
|
|
30
|
+
* only as complete as the least careful door that writes to it.
|
|
31
|
+
*
|
|
32
|
+
* ## The order
|
|
33
|
+
*
|
|
34
|
+
* A company the writer named stands: it was said on purpose. Otherwise the
|
|
35
|
+
* deal's, because the deal is the thing the entry is about; otherwise the
|
|
36
|
+
* contact's.
|
|
37
|
+
*
|
|
38
|
+
* ## A contact's company is one holder's knowledge
|
|
39
|
+
*
|
|
40
|
+
* Which company a person is at lives in a holder's facet, and that is what is
|
|
41
|
+
* read. `companyIds` is the facet's mirror for queries, and carries every
|
|
42
|
+
* holder's link, so reading it here would file one business's call under a
|
|
43
|
+
* company only a sibling brand knows the person by. A writer standing on a
|
|
44
|
+
* site names that site's group; one standing at the organization names none
|
|
45
|
+
* and reads the person's primary holder, the one their page shows. The facet
|
|
46
|
+
* of a person the organization added itself (AGL-4122) is the one exception:
|
|
47
|
+
* it is the whole org's, so it answers for any site that has no link of its
|
|
48
|
+
* own.
|
|
49
|
+
*/
|
|
50
|
+
/** What the Account is worked out from, as the writer already holds it. */
|
|
51
|
+
export interface CrmActivityCompanySources {
|
|
52
|
+
/** The deal the activity names, when the writer has read it. */
|
|
53
|
+
deal?: Record<string, unknown> | null;
|
|
54
|
+
/** The contact the activity names, when the writer has read it. */
|
|
55
|
+
contact?: Record<string, unknown> | null;
|
|
56
|
+
/** The holder whose facet is read; absent, the contact's primary holder. */
|
|
57
|
+
groupId?: string | null;
|
|
58
|
+
/** The org document, for the primary holder when {@link groupId} is absent. */
|
|
59
|
+
org?: Record<string, unknown> | null;
|
|
60
|
+
}
|
|
61
|
+
/** A link as a stamp hands it back: what went in, and the company it found. */
|
|
62
|
+
export type StampedCrmActivityLink<Link extends CrmActivityLink> = Link & Pick<CrmActivityLink, 'companyId'>;
|
|
63
|
+
/** The company an activity rolls up to, or `null` when nothing names one. */
|
|
64
|
+
export declare function crmActivityCompanyId(link: Pick<CrmActivityLink, 'companyId'>, sources: CrmActivityCompanySources): string | null;
|
|
65
|
+
/**
|
|
66
|
+
* `link` with its Account stamped. A link with no company to find comes back
|
|
67
|
+
* as it went in, with no blank key: Firestore refuses `undefined`.
|
|
68
|
+
*/
|
|
69
|
+
export declare function stampCrmActivityCompany<Link extends CrmActivityLink>(link: Link, sources: CrmActivityCompanySources): StampedCrmActivityLink<Link>;
|
|
70
|
+
/** Each side's own way to read a record by id: the Admin SDK's, or the client's. */
|
|
71
|
+
export interface CrmActivityRecordReads {
|
|
72
|
+
deal(id: string): Promise<Record<string, unknown> | null>;
|
|
73
|
+
contact(id: string): Promise<Record<string, unknown> | null>;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* {@link stampCrmActivityCompany}, reading what the writer does not hold.
|
|
77
|
+
*
|
|
78
|
+
* The deal is read first and the contact only when the deal names no company,
|
|
79
|
+
* so an activity costs the fewest reads that answer it, and none when a
|
|
80
|
+
* company was named.
|
|
81
|
+
*
|
|
82
|
+
* **Never throws.** The rollup is bookkeeping beside the entry, and a record
|
|
83
|
+
* this reader may not open, or a read that fails, must not cost the person
|
|
84
|
+
* the call they are logging. The entry is written with the links it has, and
|
|
85
|
+
* the company's timeline is the one thing it misses.
|
|
86
|
+
*/
|
|
87
|
+
export declare function resolveCrmActivityCompany<Link extends CrmActivityLink>(link: Link, sources: CrmActivityCompanySources, reads: CrmActivityRecordReads): Promise<StampedCrmActivityLink<Link>>;
|
|
88
|
+
/** What **Related To** names: one company or one deal, Salesforce's What. */
|
|
89
|
+
export interface CrmActivityRelated {
|
|
90
|
+
kind: 'company' | 'deal';
|
|
91
|
+
id: string;
|
|
92
|
+
}
|
|
93
|
+
/** The two fields of the log dialog, as a person leaves them. */
|
|
94
|
+
export interface CrmActivityPicks {
|
|
95
|
+
/** **Name**: the contact it was with, Salesforce's Who. */
|
|
96
|
+
contactId: string | null;
|
|
97
|
+
/** **Related To**. */
|
|
98
|
+
related: CrmActivityRelated | null;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The fields as they open on `link`: a new activity's record, or an activity
|
|
102
|
+
* being edited. A deal outranks the company stamped beside it, because the
|
|
103
|
+
* deal is the What and the company only its rollup.
|
|
104
|
+
*
|
|
105
|
+
* Opened from a deal, **Name** defaults to the deal's Primary, so the entry
|
|
106
|
+
* lands on that person's timeline too. A link that already names a contact
|
|
107
|
+
* keeps them.
|
|
108
|
+
*/
|
|
109
|
+
export declare function crmActivityPicksFrom(link: Pick<CrmActivityLink, 'contactId' | 'companyId' | 'dealId'>, primaryContactId?: string | null): CrmActivityPicks;
|
|
110
|
+
/**
|
|
111
|
+
* The links the fields name, before the Account is stamped. A lead's own log
|
|
112
|
+
* keeps its lead: a person not yet converted is not a contact the field could
|
|
113
|
+
* name.
|
|
114
|
+
*/
|
|
115
|
+
export declare function crmActivityLinkFromPicks(picks: CrmActivityPicks, leadId?: string | null): CrmActivityLink;
|
|
116
|
+
/** Whether two sets of picks name the same records. */
|
|
117
|
+
export declare function crmActivityPicksEqual(a: CrmActivityPicks, b: CrmActivityPicks): boolean;
|