@aglyn/plugins-crm 1.0.0-beta.149 → 1.0.0-beta.151
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/plugins-crm",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.151",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,18 +25,18 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-ui-email-campaigns": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-ui-next": "1.0.0-beta.
|
|
34
|
-
"@aglyn/shared-ui-snackstack": "1.0.0-beta.
|
|
35
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
36
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
37
|
-
"@aglyn/tenant-data-admin": "1.0.0-beta.
|
|
38
|
-
"@aglyn/tenant-feature-instance": "1.0.0-beta.
|
|
39
|
-
"@aglyn/tenant-runtime": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.151",
|
|
29
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.151",
|
|
30
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.151",
|
|
31
|
+
"@aglyn/shared-ui-email-campaigns": "1.0.0-beta.151",
|
|
32
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.151",
|
|
33
|
+
"@aglyn/shared-ui-next": "1.0.0-beta.151",
|
|
34
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.151",
|
|
35
|
+
"@aglyn/shared-util-email": "1.0.0-beta.151",
|
|
36
|
+
"@aglyn/shared-util-http": "1.0.0-beta.151",
|
|
37
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.151",
|
|
38
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.151",
|
|
39
|
+
"@aglyn/tenant-runtime": "1.0.0-beta.151",
|
|
40
40
|
"@dnd-kit/core": "^6.3.1",
|
|
41
41
|
"@dnd-kit/utilities": "^3.2.2",
|
|
42
42
|
"@swc/helpers": "0.5.23",
|
|
@@ -47,7 +47,7 @@ function writeRememberedPick(orgId, hostId) {
|
|
|
47
47
|
* how a surface tells the two levels apart without a second prop.
|
|
48
48
|
*/ export function CrmOrgMountProvider(props) {
|
|
49
49
|
const { mount, children } = props;
|
|
50
|
-
const { orgId, hosts, hostsReady, hostsPath } = mount;
|
|
50
|
+
const { orgId, orgSlug, hosts, hostsReady, hostsPath } = mount;
|
|
51
51
|
const [picked, setPicked] = useState(null);
|
|
52
52
|
// Read after mount rather than in the initializer, so the server and the
|
|
53
53
|
// first client paint agree: the pick only ever affects a drawer somebody
|
|
@@ -109,6 +109,7 @@ function writeRememberedPick(orgId, hostId) {
|
|
|
109
109
|
]);
|
|
110
110
|
const value = useMemo(()=>({
|
|
111
111
|
orgId,
|
|
112
|
+
orgSlug,
|
|
112
113
|
hosts,
|
|
113
114
|
hostsReady,
|
|
114
115
|
hostsPath,
|
|
@@ -119,6 +120,7 @@ function writeRememberedPick(orgId, hostId) {
|
|
|
119
120
|
siteHubHref
|
|
120
121
|
}), [
|
|
121
122
|
orgId,
|
|
123
|
+
orgSlug,
|
|
122
124
|
hosts,
|
|
123
125
|
hostsReady,
|
|
124
126
|
hostsPath,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/hooks/use-crm-org-mount.tsx"],"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 type { ConsolePluginOrgHost, ConsolePluginOrgMount } from '@aglyn/aglyn'\nimport {\n type ComponentType,\n createContext,\n type ReactNode,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useState,\n} from 'react'\n\n/**\n * The organization-level mount, as every CRM surface beneath the hub reads\n * it (AGL-2630).\n *\n * The shell hands the hub page an org and its sites; the hub publishes them\n * through this context so a drawer four components down — the one that has\n * to ask which site a new record is captured by — reaches them without four\n * components threading a prop they never read. `useCrmScope` reads it too:\n * a surface handed `hostId: null` learns from here which org it is under,\n * which is what lets every existing `useCrmScope({ hostId, org })` call\n * serve the org level unchanged.\n */\nexport interface CrmOrgMount extends ConsolePluginOrgMount {\n /**\n * The site the next create stamps from: the reader's last pick this\n * session, or the org's only site when it has exactly one, else `null`\n * until somebody picks. A record is always captured BY a site — its\n * `hostId`, its `visibleTo`, the consent group its profile lives on all\n * come from one — and at the org level nothing else can say which.\n */\n createHostId: string | null\n setCreateHostId: (hostId: string) => void\n /** How a site reads on screen — its name, or its id when the list cannot name it. */\n siteName: (hostId: string) => string\n /**\n * The subdomain a console URL under `/hosts/[host]` takes, or `null` for\n * a site the list did not answer for — named, never linked.\n */\n siteSubdomain: (hostId: string) => string | null\n /**\n * The site's own CRM hub — `${hostsPath}/${subdomain}/crm` — or `null`\n * for a site whose subdomain the list could not answer, which is named\n * and not linked.\n */\n siteHubHref: (hostId: string) => string | null\n}\n\nconst CrmOrgMountContext = createContext<CrmOrgMount | null>(null)\n\n/**\n * The session's memory of the picked site, per org. Session storage rather\n * than local: the pick is a working convenience for one sitting, and a\n * site remembered across weeks would stamp a record onto a site the reader\n * had forgotten choosing.\n */\nconst pickStorageKey = (orgId: string) => `aglyn.crm.createSite.${orgId}`\n\nfunction readRememberedPick(orgId: string): string | null {\n try {\n return window.sessionStorage.getItem(pickStorageKey(orgId))\n } catch {\n return null\n }\n}\n\nfunction writeRememberedPick(orgId: string, hostId: string): void {\n try {\n window.sessionStorage.setItem(pickStorageKey(orgId), hostId)\n } catch {\n // A browser that refuses storage still gets the pick for this page.\n }\n}\n\n/**\n * Publishes the org-level mount to every surface beneath it.\n *\n * Mounted by the hub page ONLY when the shell handed it an `orgMount`; under\n * a site there is no provider and `useCrmOrgMount` answers `null`, which is\n * how a surface tells the two levels apart without a second prop.\n */\nexport function CrmOrgMountProvider(props: {\n mount: ConsolePluginOrgMount\n children: ReactNode\n}) {\n const { mount, children } = props\n const { orgId, hosts, hostsReady, hostsPath } = mount\n const [picked, setPicked] = useState<string | null>(null)\n // Read after mount rather than in the initializer, so the server and the\n // first client paint agree: the pick only ever affects a drawer somebody\n // has since opened.\n useEffect(() => {\n setPicked(readRememberedPick(orgId))\n }, [orgId])\n\n const byId = useMemo(() => {\n const index = new Map<string, ConsolePluginOrgHost>()\n for (const host of hosts) index.set(host.id, host)\n return index\n }, [hosts])\n\n /*\n * A remembered site the list no longer carries is forgotten rather than\n * stamped — the reader may have lost the site, or the site may be gone.\n * An org with exactly one site needs no picker at all; nothing else is\n * picked silently, because a wrong guess files a person under a brand\n * that never met them.\n */\n const createHostId = useMemo(() => {\n if (picked && byId.has(picked)) return picked\n if (hostsReady && hosts.length === 1) return hosts[0].id\n return null\n }, [picked, byId, hostsReady, hosts])\n\n const setCreateHostId = useCallback(\n (hostId: string) => {\n setPicked(hostId)\n writeRememberedPick(orgId, hostId)\n },\n [orgId],\n )\n const siteName = useCallback(\n (hostId: string) => byId.get(hostId)?.name || hostId,\n [byId],\n )\n const siteSubdomain = useCallback(\n (hostId: string) => byId.get(hostId)?.subdomain ?? null,\n [byId],\n )\n const siteHubHref = useCallback(\n (hostId: string) => {\n const subdomain = byId.get(hostId)?.subdomain\n return subdomain ? `${hostsPath}/${encodeURIComponent(subdomain)}/crm` : null\n },\n [byId, hostsPath],\n )\n\n const value = useMemo<CrmOrgMount>(\n () => ({\n orgId,\n hosts,\n hostsReady,\n hostsPath,\n createHostId,\n setCreateHostId,\n siteName,\n siteSubdomain,\n siteHubHref,\n }),\n [\n orgId,\n hosts,\n hostsReady,\n hostsPath,\n createHostId,\n setCreateHostId,\n siteName,\n siteSubdomain,\n siteHubHref,\n ],\n )\n return (\n <CrmOrgMountContext.Provider value={value}>\n {children}\n </CrmOrgMountContext.Provider>\n )\n}\nCrmOrgMountProvider.displayName = 'CrmOrgMountProvider'\n\n/** The org-level mount, or `null` under a site. */\nexport function useCrmOrgMount(): CrmOrgMount | null {\n return useContext(CrmOrgMountContext)\n}\n\n/**\n * `Card` beneath the org-level mount the SHELL hands it as a prop (AGL-2636).\n *\n * The hub page mounts the provider itself, because the shell hands the hub\n * page an `orgMount` and the page owns everything under it. A widget on the\n * org's `orgDashboard` slot is dropped into a console page the CRM does not\n * own — the org's sites list — and the console never imports a plugin, so\n * the page cannot mount this plugin's provider around the slot. The slot\n * hands each widget the same `orgMount` instead, and this puts the provider\n * where the hub page puts it: around the surface, one level down. `Card`\n * then reads `useCrmOrgMount()` and `useCrmScope({ hostId: null })` exactly\n * as it does under the hub, and stays one component for both mounts.\n *\n * Handed no mount — a slot that names a site, or a shell older than the org\n * zone — the card renders as it is, and `useCrmOrgMount` answers `null` the\n * way it does under a site.\n */\nexport function withCrmOrgMount<P extends object>(\n Card: ComponentType<P>,\n): ComponentType<P & { orgMount?: ConsolePluginOrgMount }> {\n function CrmOrgMounted(props: P & { orgMount?: ConsolePluginOrgMount }) {\n const { orgMount, ...rest } = props\n const card = <Card {...(rest as P)} />\n return orgMount ? (\n <CrmOrgMountProvider mount={orgMount}>{card}</CrmOrgMountProvider>\n ) : (\n card\n )\n }\n CrmOrgMounted.displayName = `CrmOrgMounted(${\n Card.displayName ?? (Card as { name?: string }).name ?? 'Card'\n })`\n return CrmOrgMounted\n}\n\n/**\n * THE SITE A CREATE OPENED FROM A RECORD DEFAULTS TO (AGL-2630).\n *\n * A task, an activity or a deal filed from a contact's page belongs, nine\n * times in ten, with the site that captured the contact — not with the\n * site the reader last picked in a list's drawer. So a record page wraps\n * its cards in this, naming its record's own capturing site, and every\n * create under it defaults its Site picker there. A pick the reader makes\n * INSIDE such a create is held for that page alone; the session's pick,\n * which a create opened from a list keeps defaulting to, is untouched.\n *\n * Nothing under a site, nothing for a record no site has captured, and\n * nothing for a site the mount's list does not carry — the reader may not\n * have it: the children render as they are and the session's pick stands.\n */\nexport function CrmCreateSiteDefault(props: {\n hostId: string | null | undefined\n children: ReactNode\n}) {\n const { hostId, children } = props\n const mount = useContext(CrmOrgMountContext)\n const [picked, setPicked] = useState<string | null>(null)\n // Another record on the same page starts from its own site again.\n useEffect(() => {\n setPicked(null)\n }, [hostId])\n const value = useMemo<CrmOrgMount | null>(() => {\n if (!mount || !hostId) return mount\n if (!mount.hosts.some((host) => host.id === hostId)) return mount\n return {\n ...mount,\n createHostId: picked ?? hostId,\n setCreateHostId: setPicked,\n }\n }, [mount, hostId, picked])\n return (\n <CrmOrgMountContext.Provider value={value}>\n {children}\n </CrmOrgMountContext.Provider>\n )\n}\nCrmCreateSiteDefault.displayName = 'CrmCreateSiteDefault'\n\nexport default useCrmOrgMount\n"],"names":["createContext","useCallback","useContext","useEffect","useMemo","useState","CrmOrgMountContext","pickStorageKey","orgId","readRememberedPick","window","sessionStorage","getItem","writeRememberedPick","hostId","setItem","CrmOrgMountProvider","props","mount","children","hosts","hostsReady","hostsPath","picked","setPicked","byId","index","Map","host","set","id","createHostId","has","length","setCreateHostId","siteName","get","name","siteSubdomain","subdomain","siteHubHref","encodeURIComponent","value","Provider","displayName","useCrmOrgMount","withCrmOrgMount","Card","CrmOrgMounted","orgMount","rest","card","CrmCreateSiteDefault","some"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/hooks/use-crm-org-mount.tsx"],"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 type { ConsolePluginOrgHost, ConsolePluginOrgMount } from '@aglyn/aglyn'\nimport {\n type ComponentType,\n createContext,\n type ReactNode,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useState,\n} from 'react'\n\n/**\n * The organization-level mount, as every CRM surface beneath the hub reads\n * it (AGL-2630).\n *\n * The shell hands the hub page an org and its sites; the hub publishes them\n * through this context so a drawer four components down — the one that has\n * to ask which site a new record is captured by — reaches them without four\n * components threading a prop they never read. `useCrmScope` reads it too:\n * a surface handed `hostId: null` learns from here which org it is under,\n * which is what lets every existing `useCrmScope({ hostId, org })` call\n * serve the org level unchanged.\n */\nexport interface CrmOrgMount extends ConsolePluginOrgMount {\n /**\n * The site the next create stamps from: the reader's last pick this\n * session, or the org's only site when it has exactly one, else `null`\n * until somebody picks. A record is always captured BY a site — its\n * `hostId`, its `visibleTo`, the consent group its profile lives on all\n * come from one — and at the org level nothing else can say which.\n */\n createHostId: string | null\n setCreateHostId: (hostId: string) => void\n /** How a site reads on screen — its name, or its id when the list cannot name it. */\n siteName: (hostId: string) => string\n /**\n * The subdomain a console URL under `/hosts/[host]` takes, or `null` for\n * a site the list did not answer for — named, never linked.\n */\n siteSubdomain: (hostId: string) => string | null\n /**\n * The site's own CRM hub — `${hostsPath}/${subdomain}/crm` — or `null`\n * for a site whose subdomain the list could not answer, which is named\n * and not linked.\n */\n siteHubHref: (hostId: string) => string | null\n}\n\nconst CrmOrgMountContext = createContext<CrmOrgMount | null>(null)\n\n/**\n * The session's memory of the picked site, per org. Session storage rather\n * than local: the pick is a working convenience for one sitting, and a\n * site remembered across weeks would stamp a record onto a site the reader\n * had forgotten choosing.\n */\nconst pickStorageKey = (orgId: string) => `aglyn.crm.createSite.${orgId}`\n\nfunction readRememberedPick(orgId: string): string | null {\n try {\n return window.sessionStorage.getItem(pickStorageKey(orgId))\n } catch {\n return null\n }\n}\n\nfunction writeRememberedPick(orgId: string, hostId: string): void {\n try {\n window.sessionStorage.setItem(pickStorageKey(orgId), hostId)\n } catch {\n // A browser that refuses storage still gets the pick for this page.\n }\n}\n\n/**\n * Publishes the org-level mount to every surface beneath it.\n *\n * Mounted by the hub page ONLY when the shell handed it an `orgMount`; under\n * a site there is no provider and `useCrmOrgMount` answers `null`, which is\n * how a surface tells the two levels apart without a second prop.\n */\nexport function CrmOrgMountProvider(props: {\n mount: ConsolePluginOrgMount\n children: ReactNode\n}) {\n const { mount, children } = props\n const { orgId, orgSlug, hosts, hostsReady, hostsPath } = mount\n const [picked, setPicked] = useState<string | null>(null)\n // Read after mount rather than in the initializer, so the server and the\n // first client paint agree: the pick only ever affects a drawer somebody\n // has since opened.\n useEffect(() => {\n setPicked(readRememberedPick(orgId))\n }, [orgId])\n\n const byId = useMemo(() => {\n const index = new Map<string, ConsolePluginOrgHost>()\n for (const host of hosts) index.set(host.id, host)\n return index\n }, [hosts])\n\n /*\n * A remembered site the list no longer carries is forgotten rather than\n * stamped — the reader may have lost the site, or the site may be gone.\n * An org with exactly one site needs no picker at all; nothing else is\n * picked silently, because a wrong guess files a person under a brand\n * that never met them.\n */\n const createHostId = useMemo(() => {\n if (picked && byId.has(picked)) return picked\n if (hostsReady && hosts.length === 1) return hosts[0].id\n return null\n }, [picked, byId, hostsReady, hosts])\n\n const setCreateHostId = useCallback(\n (hostId: string) => {\n setPicked(hostId)\n writeRememberedPick(orgId, hostId)\n },\n [orgId],\n )\n const siteName = useCallback(\n (hostId: string) => byId.get(hostId)?.name || hostId,\n [byId],\n )\n const siteSubdomain = useCallback(\n (hostId: string) => byId.get(hostId)?.subdomain ?? null,\n [byId],\n )\n const siteHubHref = useCallback(\n (hostId: string) => {\n const subdomain = byId.get(hostId)?.subdomain\n return subdomain ? `${hostsPath}/${encodeURIComponent(subdomain)}/crm` : null\n },\n [byId, hostsPath],\n )\n\n const value = useMemo<CrmOrgMount>(\n () => ({\n orgId,\n orgSlug,\n hosts,\n hostsReady,\n hostsPath,\n createHostId,\n setCreateHostId,\n siteName,\n siteSubdomain,\n siteHubHref,\n }),\n [\n orgId,\n orgSlug,\n hosts,\n hostsReady,\n hostsPath,\n createHostId,\n setCreateHostId,\n siteName,\n siteSubdomain,\n siteHubHref,\n ],\n )\n return (\n <CrmOrgMountContext.Provider value={value}>\n {children}\n </CrmOrgMountContext.Provider>\n )\n}\nCrmOrgMountProvider.displayName = 'CrmOrgMountProvider'\n\n/** The org-level mount, or `null` under a site. */\nexport function useCrmOrgMount(): CrmOrgMount | null {\n return useContext(CrmOrgMountContext)\n}\n\n/**\n * `Card` beneath the org-level mount the SHELL hands it as a prop (AGL-2636).\n *\n * The hub page mounts the provider itself, because the shell hands the hub\n * page an `orgMount` and the page owns everything under it. A widget on the\n * org's `orgDashboard` slot is dropped into a console page the CRM does not\n * own — the org's sites list — and the console never imports a plugin, so\n * the page cannot mount this plugin's provider around the slot. The slot\n * hands each widget the same `orgMount` instead, and this puts the provider\n * where the hub page puts it: around the surface, one level down. `Card`\n * then reads `useCrmOrgMount()` and `useCrmScope({ hostId: null })` exactly\n * as it does under the hub, and stays one component for both mounts.\n *\n * Handed no mount — a slot that names a site, or a shell older than the org\n * zone — the card renders as it is, and `useCrmOrgMount` answers `null` the\n * way it does under a site.\n */\nexport function withCrmOrgMount<P extends object>(\n Card: ComponentType<P>,\n): ComponentType<P & { orgMount?: ConsolePluginOrgMount }> {\n function CrmOrgMounted(props: P & { orgMount?: ConsolePluginOrgMount }) {\n const { orgMount, ...rest } = props\n const card = <Card {...(rest as P)} />\n return orgMount ? (\n <CrmOrgMountProvider mount={orgMount}>{card}</CrmOrgMountProvider>\n ) : (\n card\n )\n }\n CrmOrgMounted.displayName = `CrmOrgMounted(${\n Card.displayName ?? (Card as { name?: string }).name ?? 'Card'\n })`\n return CrmOrgMounted\n}\n\n/**\n * THE SITE A CREATE OPENED FROM A RECORD DEFAULTS TO (AGL-2630).\n *\n * A task, an activity or a deal filed from a contact's page belongs, nine\n * times in ten, with the site that captured the contact — not with the\n * site the reader last picked in a list's drawer. So a record page wraps\n * its cards in this, naming its record's own capturing site, and every\n * create under it defaults its Site picker there. A pick the reader makes\n * INSIDE such a create is held for that page alone; the session's pick,\n * which a create opened from a list keeps defaulting to, is untouched.\n *\n * Nothing under a site, nothing for a record no site has captured, and\n * nothing for a site the mount's list does not carry — the reader may not\n * have it: the children render as they are and the session's pick stands.\n */\nexport function CrmCreateSiteDefault(props: {\n hostId: string | null | undefined\n children: ReactNode\n}) {\n const { hostId, children } = props\n const mount = useContext(CrmOrgMountContext)\n const [picked, setPicked] = useState<string | null>(null)\n // Another record on the same page starts from its own site again.\n useEffect(() => {\n setPicked(null)\n }, [hostId])\n const value = useMemo<CrmOrgMount | null>(() => {\n if (!mount || !hostId) return mount\n if (!mount.hosts.some((host) => host.id === hostId)) return mount\n return {\n ...mount,\n createHostId: picked ?? hostId,\n setCreateHostId: setPicked,\n }\n }, [mount, hostId, picked])\n return (\n <CrmOrgMountContext.Provider value={value}>\n {children}\n </CrmOrgMountContext.Provider>\n )\n}\nCrmCreateSiteDefault.displayName = 'CrmCreateSiteDefault'\n\nexport default useCrmOrgMount\n"],"names":["createContext","useCallback","useContext","useEffect","useMemo","useState","CrmOrgMountContext","pickStorageKey","orgId","readRememberedPick","window","sessionStorage","getItem","writeRememberedPick","hostId","setItem","CrmOrgMountProvider","props","mount","children","orgSlug","hosts","hostsReady","hostsPath","picked","setPicked","byId","index","Map","host","set","id","createHostId","has","length","setCreateHostId","siteName","get","name","siteSubdomain","subdomain","siteHubHref","encodeURIComponent","value","Provider","displayName","useCrmOrgMount","withCrmOrgMount","Card","CrmOrgMounted","orgMount","rest","card","CrmCreateSiteDefault","some"],"mappings":"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"}
|
|
@@ -86,7 +86,7 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
86
86
|
* see — two clients of one agency may each have an "Acme" and must each get
|
|
87
87
|
* their own. Created with the same scope stamp every CRM creator uses, so
|
|
88
88
|
* the company lands exactly where a contact captured on this site would.
|
|
89
|
-
*/ async function
|
|
89
|
+
*/ async function resolveCompany(context, name, cache, tally) {
|
|
90
90
|
const fields = nameSearchFields(name);
|
|
91
91
|
const cached = cache.get(fields.nameLower);
|
|
92
92
|
if (cached) return cached;
|
|
@@ -95,8 +95,15 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
95
95
|
const matches = await companies.where('nameLower', '==', fields.nameLower).limit(10).get();
|
|
96
96
|
const seen = matches.docs.find((doc)=>visibleToTokens(doc.get('visibleTo'), context.readTokens));
|
|
97
97
|
if (seen) {
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
var _seen_get;
|
|
99
|
+
// The name as the record spells it, not as this row typed it, so the
|
|
100
|
+
// contact's facet reads the same as the company page.
|
|
101
|
+
const found = {
|
|
102
|
+
id: seen.id,
|
|
103
|
+
name: String((_seen_get = seen.get('name')) != null ? _seen_get : name)
|
|
104
|
+
};
|
|
105
|
+
cache.set(fields.nameLower, found);
|
|
106
|
+
return found;
|
|
100
107
|
}
|
|
101
108
|
/*
|
|
102
109
|
* THE RECORDS BAND (AGL-2611). A company is a record of the same band
|
|
@@ -108,7 +115,7 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
108
115
|
* rate never reaches this branch — a rate is what makes the band meter
|
|
109
116
|
* instead of refuse.
|
|
110
117
|
*/ const room = await crmRecordsQuotaForOrg(context.org, orgRef);
|
|
111
|
-
if (!room.allowed) return
|
|
118
|
+
if (!room.allowed) return null;
|
|
112
119
|
const created = await companies.add(_extends({}, fields, {
|
|
113
120
|
hostId: context.hostId,
|
|
114
121
|
visibleTo: context.scopeTokens,
|
|
@@ -117,8 +124,12 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
117
124
|
updatedAt: FieldValue.serverTimestamp()
|
|
118
125
|
}));
|
|
119
126
|
tally.created += 1;
|
|
120
|
-
|
|
121
|
-
|
|
127
|
+
const made = {
|
|
128
|
+
id: created.id,
|
|
129
|
+
name
|
|
130
|
+
};
|
|
131
|
+
cache.set(fields.nameLower, made);
|
|
132
|
+
return made;
|
|
122
133
|
}
|
|
123
134
|
/**
|
|
124
135
|
* `POST crm/contacts-import` — `{ hostId, rows }` → a {@link ContactImportChunkResult}.
|
|
@@ -197,7 +208,7 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
197
208
|
ownerUid = owners.get(row.ownerEmail);
|
|
198
209
|
if (!ownerUid) ownersUnresolved.add(row.ownerEmail);
|
|
199
210
|
}
|
|
200
|
-
const
|
|
211
|
+
const company = row.companyName ? await resolveCompany(context, row.companyName, companies, companyTally) : null;
|
|
201
212
|
const verdict = await captureHostContact(_extends({
|
|
202
213
|
hostId: context.hostId,
|
|
203
214
|
email: row.email
|
|
@@ -214,8 +225,9 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
214
225
|
phone: row.phone
|
|
215
226
|
} : {}, row.jobTitle ? {
|
|
216
227
|
jobTitle: row.jobTitle
|
|
217
|
-
} : {},
|
|
218
|
-
companyId
|
|
228
|
+
} : {}, company ? {
|
|
229
|
+
companyId: company.id,
|
|
230
|
+
companyName: company.name
|
|
219
231
|
} : {}, row.address ? {
|
|
220
232
|
address: row.address
|
|
221
233
|
} : {}, ownerUid ? {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contacts-import.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/contacts-import` — one chunk of a contact file, written\n * (AGL-2602).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link CONTACT_IMPORT_CHUNK_SIZE} raw\n * rows in the field vocabulary `crm-import.ts` defines. This route judges\n * each one and writes it, and it is the ONLY place either happens: the\n * drawer shows a preview from the same mapper but stores nothing, so a\n * stale tab cannot store something the server would have refused.\n *\n * ## Every row goes through the door every capture goes through\n *\n * `captureHostContact` — the runtime's wrapper over `upsertHostContact`,\n * the one writer of the contacts collection for every door that is not the\n * v1 API: forms, checkout, bookings, the newsletter box — is the writer\n * here too, so a row this file creates raises `contactCreated` like any\n * other capture (AGL-2605). That is what makes an import\n * dedupe on the address the way a second form submission does, land in the\n * capturing group's facet the way a form capture does, record consent\n * against the capturing site, and stop at a hard band where a form\n * capture stops. A bulk path with its own `add()` would be the fastest way\n * to grow an unscoped, unbanded, unconsented copy of the address book.\n *\n * What the door did not know how to write until now — a phone, a title, a\n * company, an owner, a stage, custom values — it now takes as `facet`, and\n * what it did not say until now — created or merged or refused — it now\n * returns as a verdict. Both changes are the door's, so the v1 API and the\n * manual add can reach them next.\n *\n * ## Two lookups paid once per request, not once per row\n *\n * An owner is named by email in the file and stored by uid on the record,\n * so the org's roster is read once and every row resolves against it. A\n * company is named by name and stored by id, so each distinct name is\n * looked up once, created once when missing, and remembered for the rest\n * of the request — two hundred rows at one company is one read and at most\n * one write, not two hundred of each.\n *\n * ## Who may call it\n *\n * `resolveImportContext` (`import-context.ts`) — the two gates every CSV\n * import route asks, shared with the companies import so one permission\n * has one spelling.\n */\n\nimport {\n type ContactFieldDefinition,\n CRM_COLLECTIONS,\n nameSearchFields,\n type PluginApiHandler,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n CONTACT_IMPORT_CHUNK_SIZE,\n CONTACT_IMPORT_MAX_BODY_BYTES,\n type ContactImportChunkResult,\n type ContactImportRawRow,\n type ContactImportRow,\n type ContactImportSkippedRow,\n normalizeContactImportRow,\n} from '../model/crm-import'\nimport { crmRecordsQuotaForOrg, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { captureHostContact } from '@aglyn/tenant-runtime'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n type ImportContext,\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\n\n/** The one sentence every imported contact's timeline opens with. */\nexport const CONTACT_IMPORT_INTERACTION_SUMMARY = 'Imported from CSV'\n\n/**\n * The holder's live custom-field definitions.\n *\n * Read in full and filtered in memory: a holder has a handful of fields,\n * and a `where` on `retiredAt` would need an index for a collection that\n * fits in one page. Definitions the caller's scope cannot see are left out\n * for the same reason a value under an undefined key is — a value written\n * under a field the holder's own form will never show is a value nobody\n * can edit.\n */\nasync function loadFieldDefinitions(\n orgId: string,\n readTokens: readonly string[],\n): Promise<ContactFieldDefinition[]> {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(200)\n .get()\n return snapshot.docs\n .map((doc) => doc.data() as ContactFieldDefinition)\n .filter(\n (field) =>\n !!field.key &&\n !field.retiredAt &&\n visibleToTokens(field.visibleTo, readTokens),\n )\n}\n\n/**\n * The company a name refers to in this scope, created when there is none.\n *\n * Looked up by `nameLower`, the search twin `nameSearchFields` writes on\n * every company, and narrowed in memory to the ones the caller's scope can\n * see — two clients of one agency may each have an \"Acme\" and must each get\n * their own. Created with the same scope stamp every CRM creator uses, so\n * the company lands exactly where a contact captured on this site would.\n */\nasync function resolveCompanyId(\n context: Extract<ImportContext, { ok: true }>,\n name: string,\n cache: Map<string, string>,\n tally: { created: number },\n): Promise<string> {\n const fields = nameSearchFields(name)\n const cached = cache.get(fields.nameLower)\n if (cached) return cached\n const orgRef = firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(context.orgId)\n const companies = orgRef.collection(CRM_COLLECTIONS.companies)\n const matches = await companies\n .where('nameLower', '==', fields.nameLower)\n .limit(10)\n .get()\n const seen = matches.docs.find((doc) =>\n visibleToTokens(doc.get('visibleTo'), context.readTokens),\n )\n if (seen) {\n cache.set(fields.nameLower, seen.id)\n return seen.id\n }\n /*\n * THE RECORDS BAND (AGL-2611). A company is a record of the same band\n * the contact behind this row will meet at its own door, so on an org\n * at a full hard band the row gets no company rather than a company it\n * was not allowed to hold. Nothing is reported here: the contact is\n * refused `audience-band` a few lines on and the row is listed skipped,\n * which is the one message the operator needs. A plan with an overage\n * rate never reaches this branch — a rate is what makes the band meter\n * instead of refuse.\n */\n const room = await crmRecordsQuotaForOrg(context.org as never, orgRef)\n if (!room.allowed) return ''\n const created = await companies.add({\n ...fields,\n hostId: context.hostId,\n visibleTo: context.scopeTokens,\n createdByUid: context.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n tally.created += 1\n cache.set(fields.nameLower, created.id)\n return created.id\n}\n\n/**\n * `POST crm/contacts-import` — `{ hostId, rows }` → a {@link ContactImportChunkResult}.\n *\n * Rows are written one after another rather than in parallel, because the\n * door's create path counts the collection before it adds: two hundred\n * creates racing one another would each count the same audience and each\n * pass a band the last of them should have hit.\n */\nexport const crmContactsImportHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const read = readImportRows<ContactImportRawRow>(req, {\n maxBodyBytes: CONTACT_IMPORT_MAX_BODY_BYTES,\n chunkSize: CONTACT_IMPORT_CHUNK_SIZE,\n })\n if ('error' in read) return res.status(read.status).json({ error: read.error })\n try {\n const context = await resolveImportContext(req)\n if (context.ok === false) return res.status(context.status).json(context.body)\n\n const fields = await loadFieldDefinitions(context.orgId, context.readTokens)\n const skipped: ContactImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: ContactImportRow }[] = []\n /*\n * A duplicate WITHIN the request is skipped rather than merged: the\n * second row would merge onto the first's record and the count would\n * read one created and one merged for one person in one file, which is\n * a number the operator cannot reconcile against the rows they sent.\n * Across requests the door's own dedupe answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeContactImportRow(raw, fields)\n if (verdict.ok === false) {\n skipped.push({ index, email: verdict.input, reason: verdict.reason })\n return\n }\n if (seen.has(verdict.row.email)) {\n skipped.push({ index, email: verdict.row.email, reason: 'duplicate' })\n return\n }\n seen.add(verdict.row.email)\n for (const entry of verdict.row.dropped) {\n dropped[entry.field] = (dropped[entry.field] ?? 0) + 1\n }\n normalized.push({ index, row: verdict.row })\n })\n\n const owners = await ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n )\n const ownersUnresolved = new Set<string>()\n const companies = new Map<string, string>()\n const companyTally = { created: 0 }\n let created = 0\n let merged = 0\n\n for (const { index, row } of normalized) {\n let ownerUid: string | undefined\n if (row.ownerEmail) {\n ownerUid = owners.get(row.ownerEmail)\n if (!ownerUid) ownersUnresolved.add(row.ownerEmail)\n }\n const companyId = row.companyName\n ? await resolveCompanyId(context, row.companyName, companies, companyTally)\n : undefined\n const verdict = await captureHostContact({\n hostId: context.hostId,\n email: row.email,\n ...(row.name ? { name: row.name } : {}),\n source: 'import',\n interaction: { summary: CONTACT_IMPORT_INTERACTION_SUMMARY },\n marketingConsent: row.marketingConsent,\n tags: row.tags,\n facet: {\n ...(row.phone ? { phone: row.phone } : {}),\n ...(row.jobTitle ? { jobTitle: row.jobTitle } : {}),\n ...(companyId ? { companyId } : {}),\n ...(row.address ? { address: row.address } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.lifecycleStage ? { lifecycleStage: row.lifecycleStage } : {}),\n ...(Object.keys(row.custom).length ? { custom: row.custom } : {}),\n },\n // An import is the merchant's own act and files nobody under a\n // campaign; the picker on the profile is where that happens.\n campaignIds: [],\n })\n if ('refused' in verdict) {\n skipped.push({\n index,\n email: row.email,\n reason:\n verdict.refused === 'band'\n ? 'audience-band'\n : verdict.refused === 'invalid-email'\n ? 'invalid-email'\n : verdict.refused === 'erased'\n ? 'erased'\n : 'write-failed',\n })\n continue\n }\n if (verdict.created) created += 1\n else merged += 1\n }\n\n const result: ContactImportChunkResult = {\n received: read.rows.length,\n created,\n merged,\n skipped: skipped.sort((a, b) => a.index - b.index),\n dropped,\n companiesCreated: companyTally.created,\n ownersUnresolved: [...ownersUnresolved],\n }\n return res.status(200).json(result)\n } catch (error) {\n console.error('crm/contacts-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n"],"names":["CRM_COLLECTIONS","nameSearchFields","visibleToTokens","CONTACT_IMPORT_CHUNK_SIZE","CONTACT_IMPORT_MAX_BODY_BYTES","normalizeContactImportRow","crmRecordsQuotaForOrg","firebaseAdmin","captureHostContact","FieldValue","ownerDirectory","readImportRows","resolveImportContext","CONTACT_IMPORT_INTERACTION_SUMMARY","loadFieldDefinitions","orgId","readTokens","snapshot","app","firestore","collection","doc","contactFields","limit","get","docs","map","data","filter","field","key","retiredAt","visibleTo","resolveCompanyId","context","name","cache","tally","fields","cached","nameLower","orgRef","companies","matches","where","seen","find","set","id","room","org","allowed","created","add","hostId","scopeTokens","createdByUid","uid","createdAt","serverTimestamp","updatedAt","crmContactsImportHandler","req","res","method","setHeader","status","json","error","read","maxBodyBytes","chunkSize","ok","body","skipped","dropped","normalized","Set","rows","forEach","raw","index","verdict","push","email","input","reason","has","row","entry","owners","ownersUnresolved","Map","companyTally","merged","ownerUid","ownerEmail","companyId","companyName","undefined","source","interaction","summary","marketingConsent","tags","facet","phone","jobTitle","address","lifecycleStage","Object","keys","custom","length","campaignIds","refused","result","received","sort","a","b","companiesCreated","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contacts-import.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/contacts-import` — one chunk of a contact file, written\n * (AGL-2602).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link CONTACT_IMPORT_CHUNK_SIZE} raw\n * rows in the field vocabulary `crm-import.ts` defines. This route judges\n * each one and writes it, and it is the ONLY place either happens: the\n * drawer shows a preview from the same mapper but stores nothing, so a\n * stale tab cannot store something the server would have refused.\n *\n * ## Every row goes through the door every capture goes through\n *\n * `captureHostContact` — the runtime's wrapper over `upsertHostContact`,\n * the one writer of the contacts collection for every door that is not the\n * v1 API: forms, checkout, bookings, the newsletter box — is the writer\n * here too, so a row this file creates raises `contactCreated` like any\n * other capture (AGL-2605). That is what makes an import\n * dedupe on the address the way a second form submission does, land in the\n * capturing group's facet the way a form capture does, record consent\n * against the capturing site, and stop at a hard band where a form\n * capture stops. A bulk path with its own `add()` would be the fastest way\n * to grow an unscoped, unbanded, unconsented copy of the address book.\n *\n * What the door did not know how to write until now — a phone, a title, a\n * company, an owner, a stage, custom values — it now takes as `facet`, and\n * what it did not say until now — created or merged or refused — it now\n * returns as a verdict. Both changes are the door's, so the v1 API and the\n * manual add can reach them next.\n *\n * ## Two lookups paid once per request, not once per row\n *\n * An owner is named by email in the file and stored by uid on the record,\n * so the org's roster is read once and every row resolves against it. A\n * company is named by name and stored by id, so each distinct name is\n * looked up once, created once when missing, and remembered for the rest\n * of the request — two hundred rows at one company is one read and at most\n * one write, not two hundred of each.\n *\n * ## Who may call it\n *\n * `resolveImportContext` (`import-context.ts`) — the two gates every CSV\n * import route asks, shared with the companies import so one permission\n * has one spelling.\n */\n\nimport {\n type ContactFieldDefinition,\n CRM_COLLECTIONS,\n nameSearchFields,\n type PluginApiHandler,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n CONTACT_IMPORT_CHUNK_SIZE,\n CONTACT_IMPORT_MAX_BODY_BYTES,\n type ContactImportChunkResult,\n type ContactImportRawRow,\n type ContactImportRow,\n type ContactImportSkippedRow,\n normalizeContactImportRow,\n} from '../model/crm-import'\nimport { crmRecordsQuotaForOrg, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { captureHostContact } from '@aglyn/tenant-runtime'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n type ImportContext,\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\n\n/** The one sentence every imported contact's timeline opens with. */\nexport const CONTACT_IMPORT_INTERACTION_SUMMARY = 'Imported from CSV'\n\n/**\n * The holder's live custom-field definitions.\n *\n * Read in full and filtered in memory: a holder has a handful of fields,\n * and a `where` on `retiredAt` would need an index for a collection that\n * fits in one page. Definitions the caller's scope cannot see are left out\n * for the same reason a value under an undefined key is — a value written\n * under a field the holder's own form will never show is a value nobody\n * can edit.\n */\nasync function loadFieldDefinitions(\n orgId: string,\n readTokens: readonly string[],\n): Promise<ContactFieldDefinition[]> {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(200)\n .get()\n return snapshot.docs\n .map((doc) => doc.data() as ContactFieldDefinition)\n .filter(\n (field) =>\n !!field.key &&\n !field.retiredAt &&\n visibleToTokens(field.visibleTo, readTokens),\n )\n}\n\n/** A company an import row named, as the CRM stores it. */\ninterface ImportCompany {\n id: string\n name: string\n}\n\n/**\n * The company a name refers to in this scope, created when there is none.\n *\n * Looked up by `nameLower`, the search twin `nameSearchFields` writes on\n * every company, and narrowed in memory to the ones the caller's scope can\n * see — two clients of one agency may each have an \"Acme\" and must each get\n * their own. Created with the same scope stamp every CRM creator uses, so\n * the company lands exactly where a contact captured on this site would.\n */\nasync function resolveCompany(\n context: Extract<ImportContext, { ok: true }>,\n name: string,\n cache: Map<string, ImportCompany>,\n tally: { created: number },\n): Promise<ImportCompany | null> {\n const fields = nameSearchFields(name)\n const cached = cache.get(fields.nameLower)\n if (cached) return cached\n const orgRef = firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(context.orgId)\n const companies = orgRef.collection(CRM_COLLECTIONS.companies)\n const matches = await companies\n .where('nameLower', '==', fields.nameLower)\n .limit(10)\n .get()\n const seen = matches.docs.find((doc) =>\n visibleToTokens(doc.get('visibleTo'), context.readTokens),\n )\n if (seen) {\n // The name as the record spells it, not as this row typed it, so the\n // contact's facet reads the same as the company page.\n const found = { id: seen.id, name: String(seen.get('name') ?? name) }\n cache.set(fields.nameLower, found)\n return found\n }\n /*\n * THE RECORDS BAND (AGL-2611). A company is a record of the same band\n * the contact behind this row will meet at its own door, so on an org\n * at a full hard band the row gets no company rather than a company it\n * was not allowed to hold. Nothing is reported here: the contact is\n * refused `audience-band` a few lines on and the row is listed skipped,\n * which is the one message the operator needs. A plan with an overage\n * rate never reaches this branch — a rate is what makes the band meter\n * instead of refuse.\n */\n const room = await crmRecordsQuotaForOrg(context.org as never, orgRef)\n if (!room.allowed) return null\n const created = await companies.add({\n ...fields,\n hostId: context.hostId,\n visibleTo: context.scopeTokens,\n createdByUid: context.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n tally.created += 1\n const made = { id: created.id, name }\n cache.set(fields.nameLower, made)\n return made\n}\n\n/**\n * `POST crm/contacts-import` — `{ hostId, rows }` → a {@link ContactImportChunkResult}.\n *\n * Rows are written one after another rather than in parallel, because the\n * door's create path counts the collection before it adds: two hundred\n * creates racing one another would each count the same audience and each\n * pass a band the last of them should have hit.\n */\nexport const crmContactsImportHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const read = readImportRows<ContactImportRawRow>(req, {\n maxBodyBytes: CONTACT_IMPORT_MAX_BODY_BYTES,\n chunkSize: CONTACT_IMPORT_CHUNK_SIZE,\n })\n if ('error' in read) return res.status(read.status).json({ error: read.error })\n try {\n const context = await resolveImportContext(req)\n if (context.ok === false) return res.status(context.status).json(context.body)\n\n const fields = await loadFieldDefinitions(context.orgId, context.readTokens)\n const skipped: ContactImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: ContactImportRow }[] = []\n /*\n * A duplicate WITHIN the request is skipped rather than merged: the\n * second row would merge onto the first's record and the count would\n * read one created and one merged for one person in one file, which is\n * a number the operator cannot reconcile against the rows they sent.\n * Across requests the door's own dedupe answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeContactImportRow(raw, fields)\n if (verdict.ok === false) {\n skipped.push({ index, email: verdict.input, reason: verdict.reason })\n return\n }\n if (seen.has(verdict.row.email)) {\n skipped.push({ index, email: verdict.row.email, reason: 'duplicate' })\n return\n }\n seen.add(verdict.row.email)\n for (const entry of verdict.row.dropped) {\n dropped[entry.field] = (dropped[entry.field] ?? 0) + 1\n }\n normalized.push({ index, row: verdict.row })\n })\n\n const owners = await ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n )\n const ownersUnresolved = new Set<string>()\n const companies = new Map<string, ImportCompany>()\n const companyTally = { created: 0 }\n let created = 0\n let merged = 0\n\n for (const { index, row } of normalized) {\n let ownerUid: string | undefined\n if (row.ownerEmail) {\n ownerUid = owners.get(row.ownerEmail)\n if (!ownerUid) ownersUnresolved.add(row.ownerEmail)\n }\n const company = row.companyName\n ? await resolveCompany(context, row.companyName, companies, companyTally)\n : null\n const verdict = await captureHostContact({\n hostId: context.hostId,\n email: row.email,\n ...(row.name ? { name: row.name } : {}),\n source: 'import',\n interaction: { summary: CONTACT_IMPORT_INTERACTION_SUMMARY },\n marketingConsent: row.marketingConsent,\n tags: row.tags,\n facet: {\n ...(row.phone ? { phone: row.phone } : {}),\n ...(row.jobTitle ? { jobTitle: row.jobTitle } : {}),\n // The link AND the name: the merge fields read the facet's\n // `companyName`, and a link written alone renders as nothing.\n ...(company ? { companyId: company.id, companyName: company.name } : {}),\n ...(row.address ? { address: row.address } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.lifecycleStage ? { lifecycleStage: row.lifecycleStage } : {}),\n ...(Object.keys(row.custom).length ? { custom: row.custom } : {}),\n },\n // An import is the merchant's own act and files nobody under a\n // campaign; the picker on the profile is where that happens.\n campaignIds: [],\n })\n if ('refused' in verdict) {\n skipped.push({\n index,\n email: row.email,\n reason:\n verdict.refused === 'band'\n ? 'audience-band'\n : verdict.refused === 'invalid-email'\n ? 'invalid-email'\n : verdict.refused === 'erased'\n ? 'erased'\n : 'write-failed',\n })\n continue\n }\n if (verdict.created) created += 1\n else merged += 1\n }\n\n const result: ContactImportChunkResult = {\n received: read.rows.length,\n created,\n merged,\n skipped: skipped.sort((a, b) => a.index - b.index),\n dropped,\n companiesCreated: companyTally.created,\n ownersUnresolved: [...ownersUnresolved],\n }\n return res.status(200).json(result)\n } catch (error) {\n console.error('crm/contacts-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n"],"names":["CRM_COLLECTIONS","nameSearchFields","visibleToTokens","CONTACT_IMPORT_CHUNK_SIZE","CONTACT_IMPORT_MAX_BODY_BYTES","normalizeContactImportRow","crmRecordsQuotaForOrg","firebaseAdmin","captureHostContact","FieldValue","ownerDirectory","readImportRows","resolveImportContext","CONTACT_IMPORT_INTERACTION_SUMMARY","loadFieldDefinitions","orgId","readTokens","snapshot","app","firestore","collection","doc","contactFields","limit","get","docs","map","data","filter","field","key","retiredAt","visibleTo","resolveCompany","context","name","cache","tally","fields","cached","nameLower","orgRef","companies","matches","where","seen","find","found","id","String","set","room","org","allowed","created","add","hostId","scopeTokens","createdByUid","uid","createdAt","serverTimestamp","updatedAt","made","crmContactsImportHandler","req","res","method","setHeader","status","json","error","read","maxBodyBytes","chunkSize","ok","body","skipped","dropped","normalized","Set","rows","forEach","raw","index","verdict","push","email","input","reason","has","row","entry","owners","ownersUnresolved","Map","companyTally","merged","ownerUid","ownerEmail","company","companyName","source","interaction","summary","marketingConsent","tags","facet","phone","jobTitle","companyId","address","lifecycleStage","Object","keys","custom","length","campaignIds","refused","result","received","sort","a","b","companiesCreated","console"],"mappings":"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"}
|