@aglyn/plugins-crm 1.0.0-beta.178 → 1.0.0-beta.179
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 +13 -13
- package/src/lib/components/contact-detail-page.js +1 -0
- package/src/lib/components/contact-detail-page.js.map +1 -1
- package/src/lib/components/contact-properties-card.d.ts +6 -0
- package/src/lib/components/contact-properties-card.js +25 -2
- package/src/lib/components/contact-properties-card.js.map +1 -1
- package/src/lib/components/csv-import-drawer.d.ts +7 -0
- package/src/lib/components/csv-import-drawer.js +7 -0
- package/src/lib/components/csv-import-drawer.js.map +1 -1
- package/src/lib/components/fields-section.js +6 -0
- package/src/lib/components/fields-section.js.map +1 -1
- package/src/lib/components/lead-import-drawer.d.ts +3 -0
- package/src/lib/components/lead-import-drawer.js +29 -6
- package/src/lib/components/lead-import-drawer.js.map +1 -1
- package/src/lib/components/lead-properties-card.js +10 -12
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/lead-source-select.d.ts +30 -0
- package/src/lib/components/lead-source-select.js +96 -0
- package/src/lib/components/lead-source-select.js.map +1 -0
- package/src/lib/components/lead-source-values-card.d.ts +27 -0
- package/src/lib/components/lead-source-values-card.js +606 -0
- package/src/lib/components/lead-source-values-card.js.map +1 -0
- package/src/lib/components/leads-section.js +100 -4
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/new-lead-drawer.js +27 -11
- package/src/lib/components/new-lead-drawer.js.map +1 -1
- package/src/lib/hooks/use-lead-source-picklist.d.ts +23 -0
- package/src/lib/hooks/use-lead-source-picklist.js +50 -0
- package/src/lib/hooks/use-lead-source-picklist.js.map +1 -0
- package/src/lib/model/contact-record.d.ts +2 -0
- package/src/lib/model/contact-record.js +2 -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-lead-import.d.ts +10 -2
- package/src/lib/model/crm-lead-import.js +19 -1
- package/src/lib/model/crm-lead-import.js.map +1 -1
- package/src/lib/model/lead-filters.d.ts +13 -0
- package/src/lib/model/lead-filters.js +17 -1
- package/src/lib/model/lead-filters.js.map +1 -1
- package/src/lib/model/lead-source-values.d.ts +82 -0
- package/src/lib/model/lead-source-values.js +149 -0
- package/src/lib/model/lead-source-values.js.map +1 -0
- package/src/lib/server/contact-update.js +25 -2
- package/src/lib/server/contact-update.js.map +1 -1
- package/src/lib/server/lead-create.js +19 -0
- package/src/lib/server/lead-create.js.map +1 -1
- package/src/lib/server/lead-source-picklist.d.ts +42 -0
- package/src/lib/server/lead-source-picklist.js +52 -0
- package/src/lib/server/lead-source-picklist.js.map +1 -0
- package/src/lib/server/lead-source-values.d.ts +50 -0
- package/src/lib/server/lead-source-values.js +187 -0
- package/src/lib/server/lead-source-values.js.map +1 -0
- package/src/lib/server/leads-import.js +31 -2
- package/src/lib/server/leads-import.js.map +1 -1
- package/src/lib/server.js +5 -0
- package/src/lib/server.js.map +1 -1
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
*/ 'use client';
|
|
17
|
+
import { CRM_COLLECTIONS, CRM_LEAD_SOURCE_PICKLIST, effectiveCrmLeadSourcePicklist, normalizeCrmPicklist } from "@aglyn/aglyn";
|
|
18
|
+
import { useFirestore, useFirestoreDoc } from "@aglyn/tenant-feature-instance";
|
|
19
|
+
import { doc } from "firebase/firestore";
|
|
20
|
+
import { useMemo } from "react";
|
|
21
|
+
/**
|
|
22
|
+
* The org's lead source values (AGL-3298), for every surface that offers
|
|
23
|
+
* or manages them: the lead page's select, the New lead drawer, the
|
|
24
|
+
* contact's card, the leads list's filter and the Fields page.
|
|
25
|
+
*
|
|
26
|
+
* One document listen, shared by the Firestore SDK across every mount that
|
|
27
|
+
* asks for the same org, so a page with the list and a drawer open pays
|
|
28
|
+
* for it once. `orgId` null issues no read and answers the starter set,
|
|
29
|
+
* unready.
|
|
30
|
+
*/ export function useLeadSourcePicklist(orgId) {
|
|
31
|
+
const firestore = useFirestore();
|
|
32
|
+
const { data, status, fromCache } = useFirestoreDoc(()=>orgId ? doc(firestore, 'orgs', orgId, CRM_COLLECTIONS.picklists, CRM_LEAD_SOURCE_PICKLIST) : null, [
|
|
33
|
+
firestore,
|
|
34
|
+
orgId
|
|
35
|
+
]);
|
|
36
|
+
return useMemo(()=>({
|
|
37
|
+
picklist: effectiveCrmLeadSourcePicklist(data),
|
|
38
|
+
stored: normalizeCrmPicklist(data) !== null,
|
|
39
|
+
ready: Boolean(orgId) && status !== 'loading',
|
|
40
|
+
fromCache
|
|
41
|
+
}), [
|
|
42
|
+
data,
|
|
43
|
+
orgId,
|
|
44
|
+
status,
|
|
45
|
+
fromCache
|
|
46
|
+
]);
|
|
47
|
+
}
|
|
48
|
+
export default useLeadSourcePicklist;
|
|
49
|
+
|
|
50
|
+
//# sourceMappingURL=use-lead-source-picklist.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/hooks/use-lead-source-picklist.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 CRM_COLLECTIONS,\n CRM_LEAD_SOURCE_PICKLIST,\n type CrmPicklist,\n effectiveCrmLeadSourcePicklist,\n normalizeCrmPicklist,\n} from '@aglyn/aglyn'\nimport { useFirestore, useFirestoreDoc } from '@aglyn/tenant-feature-instance'\nimport { doc } from 'firebase/firestore'\nimport { useMemo } from 'react'\n\nexport interface LeadSourcePicklistResult {\n /** The list every picker offers — the org's own, or the starter set. */\n picklist: CrmPicklist\n /** The org has written its own list; false while it reads the starter set. */\n stored: boolean\n /** The server has answered, so the list is the org's and not a placeholder. */\n ready: boolean\n /** The document has not been confirmed by the server — `writeGuardedBySeed`'s input. */\n fromCache: boolean\n}\n\n/**\n * The org's lead source values (AGL-3298), for every surface that offers\n * or manages them: the lead page's select, the New lead drawer, the\n * contact's card, the leads list's filter and the Fields page.\n *\n * One document listen, shared by the Firestore SDK across every mount that\n * asks for the same org, so a page with the list and a drawer open pays\n * for it once. `orgId` null issues no read and answers the starter set,\n * unready.\n */\nexport function useLeadSourcePicklist(orgId: string | null | undefined): LeadSourcePicklistResult {\n const firestore = useFirestore()\n const { data, status, fromCache } = useFirestoreDoc<Record<string, unknown>>(\n () =>\n orgId\n ? doc(firestore, 'orgs', orgId, CRM_COLLECTIONS.picklists, CRM_LEAD_SOURCE_PICKLIST)\n : null,\n [firestore, orgId],\n )\n return useMemo(\n () => ({\n picklist: effectiveCrmLeadSourcePicklist(data),\n stored: normalizeCrmPicklist(data) !== null,\n ready: Boolean(orgId) && status !== 'loading',\n fromCache,\n }),\n [data, orgId, status, fromCache],\n )\n}\n\nexport default useLeadSourcePicklist\n"],"names":["CRM_COLLECTIONS","CRM_LEAD_SOURCE_PICKLIST","effectiveCrmLeadSourcePicklist","normalizeCrmPicklist","useFirestore","useFirestoreDoc","doc","useMemo","useLeadSourcePicklist","orgId","firestore","data","status","fromCache","picklists","picklist","stored","ready","Boolean"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;AAEA,SACEA,eAAe,EACfC,wBAAwB,EAExBC,8BAA8B,EAC9BC,oBAAoB,QACf,eAAc;AACrB,SAASC,YAAY,EAAEC,eAAe,QAAQ,iCAAgC;AAC9E,SAASC,GAAG,QAAQ,qBAAoB;AACxC,SAASC,OAAO,QAAQ,QAAO;AAa/B;;;;;;;;;CASC,GACD,OAAO,SAASC,sBAAsBC,KAAgC;IACpE,MAAMC,YAAYN;IAClB,MAAM,EAAEO,IAAI,EAAEC,MAAM,EAAEC,SAAS,EAAE,GAAGR,gBAClC,IACEI,QACIH,IAAII,WAAW,QAAQD,OAAOT,gBAAgBc,SAAS,EAAEb,4BACzD,MACN;QAACS;QAAWD;KAAM;IAEpB,OAAOF,QACL,IAAO,CAAA;YACLQ,UAAUb,+BAA+BS;YACzCK,QAAQb,qBAAqBQ,UAAU;YACvCM,OAAOC,QAAQT,UAAUG,WAAW;YACpCC;QACF,CAAA,GACA;QAACF;QAAMF;QAAOG;QAAQC;KAAU;AAEpC;AAEA,eAAeL,sBAAqB"}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* not import this plugin. Re-exported here, so every caller is unchanged.
|
|
22
22
|
*/ export { contactPrimaryGroup, NO_HOLDER_GROUP } from "@aglyn/aglyn";
|
|
23
23
|
/** A document off the wire, flattened through one group's facet. */ export function contactRecordFromDoc(row, group) {
|
|
24
|
-
var _row_$id, _facet_name, _facet_tags, _facet_notes, _facet_ltvCents, _facet_ordersCount, _facet_phone, _facet_jobTitle, _facet_companyName, _facet_companyId, _facet_address, _facet_custom, _facet_mediaIds, _facet_ownerUid;
|
|
24
|
+
var _row_$id, _facet_name, _facet_tags, _facet_notes, _facet_ltvCents, _facet_ordersCount, _facet_phone, _facet_jobTitle, _facet_leadSource, _facet_companyName, _facet_companyId, _facet_address, _facet_custom, _facet_mediaIds, _facet_ownerUid;
|
|
25
25
|
const facet = Aglyn.readContactFacet(row, group.groupId);
|
|
26
26
|
return {
|
|
27
27
|
$id: String((_row_$id = row['$id']) != null ? _row_$id : ''),
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
ordersCount: (_facet_ordersCount = facet.ordersCount) != null ? _facet_ordersCount : 0,
|
|
43
43
|
phone: (_facet_phone = facet.phone) != null ? _facet_phone : '',
|
|
44
44
|
jobTitle: (_facet_jobTitle = facet.jobTitle) != null ? _facet_jobTitle : '',
|
|
45
|
+
leadSource: (_facet_leadSource = facet.leadSource) != null ? _facet_leadSource : '',
|
|
45
46
|
companyName: (_facet_companyName = facet.companyName) != null ? _facet_companyName : '',
|
|
46
47
|
companyId: (_facet_companyId = facet.companyId) != null ? _facet_companyId : '',
|
|
47
48
|
companyLink: Aglyn.readContactCompanyLink(row, group.groupId),
|
|
@@ -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 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 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 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 createdAt?: unknown\n updatedAt?: unknown\n}\n\n/*\n * `NO_HOLDER_GROUP` and `contactPrimaryGroup` moved to `@aglyn/aglyn`\n * under AGL-2662 so the server's whole-collection export can flatten a\n * contact the way the organization-level list does — the console app may\n * not import this plugin. Re-exported here, so every caller is unchanged.\n */\nexport { contactPrimaryGroup, NO_HOLDER_GROUP } from '@aglyn/aglyn'\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.readContactCampaignIds(row, group.groupId),\n ltvCents: facet.ltvCents ?? 0,\n ordersCount: facet.ordersCount ?? 0,\n phone: facet.phone ?? '',\n jobTitle: facet.jobTitle ?? '',\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: Aglyn.readNextTaskAtMs(row as { nextTaskAtMs?: unknown }),\n emailState: Aglyn.readEmailState(row),\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","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","readContactCampaignIds","ltvCents","ordersCount","phone","jobTitle","companyName","companyId","companyLink","readContactCompanyLink","address","custom","mediaIds","ownerUid","lifecycleStage","isContactLifecycleStage","lastEmailEngagementAtMs","Number","isFinite","nextTaskAtMs","readNextTaskAtMs","emailState","readEmailState","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 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 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 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 createdAt?: unknown\n updatedAt?: unknown\n}\n\n/*\n * `NO_HOLDER_GROUP` and `contactPrimaryGroup` moved to `@aglyn/aglyn`\n * under AGL-2662 so the server's whole-collection export can flatten a\n * contact the way the organization-level list does — the console app may\n * not import this plugin. Re-exported here, so every caller is unchanged.\n */\nexport { contactPrimaryGroup, NO_HOLDER_GROUP } from '@aglyn/aglyn'\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.readContactCampaignIds(row, group.groupId),\n ltvCents: facet.ltvCents ?? 0,\n ordersCount: facet.ordersCount ?? 0,\n phone: facet.phone ?? '',\n jobTitle: facet.jobTitle ?? '',\n leadSource: facet.leadSource ?? '',\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: Aglyn.readNextTaskAtMs(row as { nextTaskAtMs?: unknown }),\n emailState: Aglyn.readEmailState(row),\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","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","readContactCampaignIds","ltvCents","ordersCount","phone","jobTitle","leadSource","companyName","companyId","companyLink","readContactCompanyLink","address","custom","mediaIds","ownerUid","lifecycleStage","isContactLifecycleStage","lastEmailEngagementAtMs","Number","isFinite","nextTaskAtMs","readNextTaskAtMs","emailState","readEmailState","createdAt","updatedAt","parseContactTags","input","Set","split","map","tag","trim","toLowerCase","slice","Boolean"],"mappings":"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"}
|
|
@@ -49,6 +49,12 @@ export interface ContactUpdateFields {
|
|
|
49
49
|
/** As typed; stored as E.164, and refused when it cannot be read as a number. */
|
|
50
50
|
phone?: string;
|
|
51
51
|
jobTitle?: string;
|
|
52
|
+
/**
|
|
53
|
+
* One of the org's lead source values, by label (AGL-3298), or `''` to
|
|
54
|
+
* clear. Refused when it is not an active value and not the one the
|
|
55
|
+
* contact already holds.
|
|
56
|
+
*/
|
|
57
|
+
leadSource?: string;
|
|
52
58
|
address?: AglynPostalAddress | null;
|
|
53
59
|
notes?: string;
|
|
54
60
|
tags?: string[];
|
|
@@ -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.\n */\nexport interface ContactUpdateFields {\n /** This holder's own name for the person. The canonical name is never written. */\n name?: string\n /** As typed; stored as E.164, and refused when it cannot be read as a number. */\n phone?: string\n jobTitle?: 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 'phone',\n 'jobTitle',\n 'address',\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.\n */\nexport interface ContactUpdateFields {\n /** This holder's own name for the person. The canonical name is never written. */\n name?: string\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 'phone',\n 'jobTitle',\n 'leadSource',\n 'address',\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;AA2CzC,kFAAkF,GAClF,OAAO,MAAMC,wBAAkE;IAC7E;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD,CAAA"}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
-
import { type CrmLeadProfile, type CrmLeadStatus } from '@aglyn/aglyn/app-utils/crm';
|
|
17
|
+
import { type CrmLeadProfile, type CrmLeadStatus, type CrmPicklist } from '@aglyn/aglyn/app-utils/crm';
|
|
18
18
|
import { type ImportChunkResult, type ImportDroppedValue } from '@aglyn/aglyn/app-utils/csv-import';
|
|
19
19
|
/** The shared ceilings, under this collection's names. */
|
|
20
20
|
export declare const LEAD_IMPORT_MAX_ROWS = 5000;
|
|
@@ -58,7 +58,7 @@ export declare function guessLeadImportMapping(columns: readonly string[]): Lead
|
|
|
58
58
|
export type LeadImportRawRow = Partial<Record<LeadImportField, unknown>>;
|
|
59
59
|
/** One parsed line under the mapping. Empty cells are left absent; a lead has no custom fields. */
|
|
60
60
|
export declare function mapLeadImportRow(cells: readonly string[], mapping: Record<number, LeadImportField | `custom:${string}`>): LeadImportRawRow;
|
|
61
|
-
export type LeadImportSkipReason = 'invalid-email' | 'duplicate' | 'lead-ceiling' | 'campaign-unknown' | 'write-failed';
|
|
61
|
+
export type LeadImportSkipReason = 'invalid-email' | 'duplicate' | 'lead-ceiling' | 'campaign-unknown' | 'lead-source-unknown' | 'write-failed';
|
|
62
62
|
/** How a skip reason reads on screen and in the downloaded file. */
|
|
63
63
|
export declare const LEAD_IMPORT_SKIP_LABELS: Record<LeadImportSkipReason, string>;
|
|
64
64
|
/** The most campaigns one row may name — the membership field's own cap. */
|
|
@@ -128,3 +128,11 @@ export declare function leadImportSkippedCsv(columns: readonly string[], entries
|
|
|
128
128
|
cells: readonly string[];
|
|
129
129
|
reason: LeadImportSkipReason;
|
|
130
130
|
}[]): string;
|
|
131
|
+
/**
|
|
132
|
+
* The lead sources a file names that the org's list would refuse
|
|
133
|
+
* (AGL-3298), each once, as the file spelled it — the drawer's warning
|
|
134
|
+
* before anything is sent. Judged the way the server judges a NEW lead,
|
|
135
|
+
* so a value the list holds but has deactivated is named too; a row
|
|
136
|
+
* merging onto a lead that already holds that value would still pass.
|
|
137
|
+
*/
|
|
138
|
+
export declare function leadImportUnknownLeadSources(rows: readonly LeadImportRawRow[], picklist: CrmPicklist): string[];
|
|
@@ -66,7 +66,7 @@ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_wi
|
|
|
66
66
|
* nowhere rather than discovering it weeks later.
|
|
67
67
|
*/ import { CAMPAIGN_MEMBERSHIP_CAP } from "@aglyn/aglyn/app-utils/campaign-membership";
|
|
68
68
|
import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
|
|
69
|
-
import { CRM_LEAD_STATUS_LABELS, CRM_LEAD_STATUSES, CRM_LEAD_TEXT_MAX, isCrmLeadStatus, normalizeCrmLeadTags, normalizeCompanyWebsite } from "@aglyn/aglyn/app-utils/crm";
|
|
69
|
+
import { CRM_LEAD_STATUS_LABELS, CRM_LEAD_STATUSES, CRM_LEAD_TEXT_MAX, isCrmLeadStatus, judgeCrmLeadSource, normalizeCrmPicklistLabel, normalizeCrmLeadTags, normalizeCompanyWebsite } from "@aglyn/aglyn/app-utils/crm";
|
|
70
70
|
import { normalizeAddress, normalizePhone } from "@aglyn/aglyn/foundation/definitions/contact.types";
|
|
71
71
|
import { CSV_IMPORT_CHUNK_SIZE, CSV_IMPORT_MAX_BODY_BYTES, CSV_IMPORT_MAX_ROWS, CSV_IMPORT_PREVIEW_ROWS, emptyImportResult, guessImportMapping, importAliasKeys, importSkippedCsv, importTextValue, mapImportRow, mergeImportResults } from "@aglyn/aglyn/app-utils/csv-import";
|
|
72
72
|
/** The shared ceilings, under this collection's names. */ export const LEAD_IMPORT_MAX_ROWS = CSV_IMPORT_MAX_ROWS;
|
|
@@ -293,6 +293,7 @@ const FIELD_ALIAS_KEYS = importAliasKeys(LEAD_IMPORT_FIELDS, FIELD_ALIASES);
|
|
|
293
293
|
duplicate: 'The same address appears earlier in this file',
|
|
294
294
|
'lead-ceiling': 'This site is at the platform lead limit',
|
|
295
295
|
'campaign-unknown': 'Names a campaign this site does not have',
|
|
296
|
+
'lead-source-unknown': "Names a lead source that isn't one of this organization's active values",
|
|
296
297
|
'write-failed': 'Could not be saved'
|
|
297
298
|
};
|
|
298
299
|
/** The most campaigns one row may name — the membership field's own cap. */ export const LEAD_IMPORT_CAMPAIGNS_MAX = CAMPAIGN_MEMBERSHIP_CAP;
|
|
@@ -424,5 +425,22 @@ export function mergeLeadImportResults(total, chunk, offset = 0) {
|
|
|
424
425
|
/** The skipped rows as a file the operator can fix and re-import. */ export function leadImportSkippedCsv(columns, entries) {
|
|
425
426
|
return importSkippedCsv(columns, entries, LEAD_IMPORT_SKIP_LABELS);
|
|
426
427
|
}
|
|
428
|
+
/**
|
|
429
|
+
* The lead sources a file names that the org's list would refuse
|
|
430
|
+
* (AGL-3298), each once, as the file spelled it — the drawer's warning
|
|
431
|
+
* before anything is sent. Judged the way the server judges a NEW lead,
|
|
432
|
+
* so a value the list holds but has deactivated is named too; a row
|
|
433
|
+
* merging onto a lead that already holds that value would still pass.
|
|
434
|
+
*/ export function leadImportUnknownLeadSources(rows, picklist) {
|
|
435
|
+
const unknown = new Map();
|
|
436
|
+
for (const row of rows){
|
|
437
|
+
const label = normalizeCrmPicklistLabel(row.leadSource);
|
|
438
|
+
if (!label || unknown.has(label.toLowerCase())) continue;
|
|
439
|
+
if (!judgeCrmLeadSource(picklist, label).ok) unknown.set(label.toLowerCase(), label);
|
|
440
|
+
}
|
|
441
|
+
return [
|
|
442
|
+
...unknown.values()
|
|
443
|
+
];
|
|
444
|
+
}
|
|
427
445
|
|
|
428
446
|
//# sourceMappingURL=crm-lead-import.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-lead-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 * BRINGING A SPREADSHEET OF LEADS INTO THE CRM — the pure half (AGL-2701).\n *\n * The contact import's three stages (`crm-import.ts`) over the lead\n * vocabulary. A lead is the one CRM record a person does not type: it is\n * what a capture door wrote when somebody signed up, booked or submitted a\n * form, plus the working state the team wrote on top. A file therefore\n * carries HALF a lead — the person, and the team's annotations — and the\n * capture half is the door's to write.\n *\n * ## The address is the identity, because it is the document id\n *\n * A lead lives at `hosts/{hostId}/leads/{personKey}`, and `personKey` is\n * the sha256 of the normalized address. So the address is not merely the\n * one required cell: it is the row's name. A cell the normalizer cannot\n * read is refused as `invalid-email`, and two rows of one file that\n * normalize to the same address are one person — the second is skipped as\n * `duplicate` rather than merged onto the first, for the reason the\n * contacts import gives: one person reported once created and once merged\n * is a number the operator cannot reconcile against the rows they sent.\n *\n * ## What a file may NOT say\n *\n * The export's `Sources`, `First seen`, `Last seen` and `Captures` columns\n * are the capture door's own record of what the visitor did, and\n * `Converted` is stamped by `crm/lead-convert` after the contact exists.\n * None of them is a field here, so the mapping leaves those columns\n * unmapped rather than mis-mapping them — a file that could write\n * \"first seen\" could rewrite the history it is supposed to describe. The\n * organization-level file's `Site` column is left out for a second reason:\n * the site is the drawer's question and the route's permission is granted\n * for that ONE site, so a cell must not be able to redirect a row to\n * another.\n *\n * ## Qualified is a conversion, not a status a cell can claim\n *\n * `qualified` means a lead BECAME a contact, and `convertedContactId`\n * beside it names the contact that was really created. Only the convert\n * route writes the pair, so the list's own status control offers new,\n * working and unqualified and nothing else. A cell naming `qualified` is\n * dropped and reported here for the same reason: a status claiming a\n * conversion that never happened would show a contact link to nowhere.\n *\n * ## A bad cell is dropped and named; a bad row is skipped and named\n *\n * The address is the only cell that can make a row unstorable. An\n * unreadable status, or an unqualified reason with no unqualified status\n * to belong to, is left off the lead and REPORTED under\n * {@link LeadImportRow.dropped}, so the operator learns that a column went\n * nowhere rather than discovering it weeks later.\n */\n\nimport { CAMPAIGN_MEMBERSHIP_CAP } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport {\n CRM_LEAD_STATUS_LABELS,\n CRM_LEAD_STATUSES,\n CRM_LEAD_TEXT_MAX,\n type CrmLeadProfile,\n type CrmLeadStatus,\n isCrmLeadStatus,\n normalizeCrmLeadTags,\n normalizeCompanyWebsite,\n} from '@aglyn/aglyn/app-utils/crm'\nimport {\n normalizeAddress,\n normalizePhone,\n} from '@aglyn/aglyn/foundation/definitions/contact.types'\nimport {\n CSV_IMPORT_CHUNK_SIZE,\n CSV_IMPORT_MAX_BODY_BYTES,\n CSV_IMPORT_MAX_ROWS,\n CSV_IMPORT_PREVIEW_ROWS,\n emptyImportResult,\n guessImportMapping,\n importAliasKeys,\n type ImportChunkResult,\n type ImportDroppedValue,\n importSkippedCsv,\n importTextValue,\n mapImportRow,\n mergeImportResults,\n} from '@aglyn/aglyn/app-utils/csv-import'\n\n/** The shared ceilings, under this collection's names. */\nexport const LEAD_IMPORT_MAX_ROWS = CSV_IMPORT_MAX_ROWS\nexport const LEAD_IMPORT_CHUNK_SIZE = CSV_IMPORT_CHUNK_SIZE\nexport const LEAD_IMPORT_MAX_BODY_BYTES = CSV_IMPORT_MAX_BODY_BYTES\nexport const LEAD_IMPORT_PREVIEW_ROWS = CSV_IMPORT_PREVIEW_ROWS\n\n/** The same caps the lead's own cards hold a typed value to. */\nexport const LEAD_IMPORT_NOTES_MAX = 4000\nexport const LEAD_IMPORT_REASON_MAX = 500\n\n/** A person's name, capped where every other import caps one. */\nconst NAME_MAX = 120\n\n/**\n * The statuses a file may set.\n *\n * The list's own status control offers exactly these three, and for the\n * reason given above: `qualified` is written by the conversion, beside the\n * contact it produced.\n */\nexport const LEAD_IMPORT_STATUSES: readonly CrmLeadStatus[] =\n CRM_LEAD_STATUSES.filter((status) => status !== 'qualified')\n\n/**\n * The fields a column may be mapped to, in the order the mapping menu\n * lists them.\n *\n * The lead's own profile (AGL-3231) — company, title, phone, website, the\n * lead source, the address parts and tags — is what a list from another\n * tool actually carries, and the reason a lead can be worked without a\n * contact beside it. They read under the same labels the contacts import\n * uses, so one spreadsheet maps the same way into either.\n */\nexport const LEAD_IMPORT_FIELDS = [\n 'email',\n 'name',\n 'company',\n 'jobTitle',\n 'phone',\n 'website',\n 'leadSource',\n 'status',\n 'ownerEmail',\n 'addressLine1',\n 'addressLine2',\n 'addressCity',\n 'addressState',\n 'addressPostalCode',\n 'addressCountry',\n 'tags',\n 'campaigns',\n 'unqualifiedReason',\n 'notes',\n] as const\n\nexport type LeadImportField = (typeof LEAD_IMPORT_FIELDS)[number]\n\n/** How each field reads in the mapping menu. Typed so a field cannot ship unlabeled. */\nexport const LEAD_IMPORT_FIELD_LABELS: Record<LeadImportField, string> = {\n email: 'Email (required)',\n name: 'Name',\n company: 'Company name',\n jobTitle: 'Job title',\n phone: 'Phone',\n website: 'Website',\n leadSource: 'Lead source',\n status: 'Status (new, working, unqualified)',\n ownerEmail: 'Owner (team member email)',\n addressLine1: 'Address line 1',\n addressLine2: 'Address line 2',\n addressCity: 'City',\n addressState: 'State or region',\n addressPostalCode: 'Postal code',\n addressCountry: 'Country (two-letter code)',\n tags: 'Tags (comma or | separated)',\n campaigns: 'Campaigns (by name, comma or | separated)',\n unqualifiedReason: 'Unqualified reason',\n notes: 'Notes',\n}\n\n/**\n * Header aliases per field, matched after the shared header normalization.\n *\n * First in each list is the header this CRM's own leads export writes, so\n * an export re-imports without a hand mapping; the export's Sources, First\n * seen, Last seen, Captures, Converted and Site columns are deliberately\n * absent from every list, so they land on \"Do not import\".\n */\nconst FIELD_ALIASES: Record<LeadImportField, readonly string[]> = {\n email: ['email', 'email address', 'e mail', 'mail', 'contact email', 'work email'],\n name: ['name', 'full name', 'lead', 'lead name', 'contact', 'contact name', 'person'],\n company: [\n 'company',\n 'company name',\n 'organization',\n 'organisation',\n 'account',\n 'account name',\n 'employer',\n ],\n jobTitle: ['job title', 'title', 'position', 'role', 'headline'],\n phone: ['phone', 'phone number', 'mobile', 'mobile phone', 'telephone', 'tel', 'cell'],\n website: ['website', 'web site', 'url', 'company website', 'company domain', 'domain'],\n leadSource: ['lead source', 'source', 'origin', 'channel', 'campaign'],\n status: ['status', 'lead status', 'stage', 'lead stage'],\n ownerEmail: ['owner', 'owner email', 'assigned to', 'assignee', 'rep', 'sales rep'],\n addressLine1: ['address', 'address line 1', 'street', 'street address', 'address 1'],\n addressLine2: ['address line 2', 'address 2', 'suite', 'apartment'],\n addressCity: ['city', 'town', 'locality'],\n addressState: ['state', 'state or region', 'region', 'province', 'county'],\n addressPostalCode: ['postal code', 'postcode', 'zip', 'zip code'],\n addressCountry: ['country', 'country code'],\n tags: ['tags', 'tag', 'labels', 'lists'],\n // `campaign` alone stays the lead source's: another tool's \"Campaign\"\n // column names where the person came from, not one of this site's\n // containers (AGL-3254).\n campaigns: ['campaigns', 'campaign names', 'in campaigns', 'email campaigns'],\n unqualifiedReason: [\n 'unqualified reason',\n 'reason',\n 'disqualified reason',\n 'lost reason',\n 'reason lost',\n ],\n notes: ['notes', 'note', 'description', 'comments', 'details'],\n}\n\nconst FIELD_ALIAS_KEYS = importAliasKeys(LEAD_IMPORT_FIELDS, FIELD_ALIASES)\n\n/** Column index → field. A column absent from the map is not imported. */\nexport type LeadImportMapping = Record<number, LeadImportField>\n\n/** A proposed mapping from a file's header row, each field taken at most once. */\nexport function guessLeadImportMapping(columns: readonly string[]): LeadImportMapping {\n return guessImportMapping(columns, LEAD_IMPORT_FIELDS, FIELD_ALIAS_KEYS)\n}\n\n/**\n * What the browser posts for one line: the cells the mapping selected,\n * under the field they were mapped to, verbatim. `unknown` because the\n * server reads this off an untrusted body.\n */\nexport type LeadImportRawRow = Partial<Record<LeadImportField, unknown>>\n\n/** One parsed line under the mapping. Empty cells are left absent; a lead has no custom fields. */\nexport function mapLeadImportRow(\n cells: readonly string[],\n mapping: Record<number, LeadImportField | `custom:${string}`>,\n): LeadImportRawRow {\n const { custom: _custom, ...row } = mapImportRow(cells, mapping)\n return row\n}\n\nexport type LeadImportSkipReason =\n | 'invalid-email'\n | 'duplicate'\n | 'lead-ceiling'\n | 'campaign-unknown'\n | 'write-failed'\n\n/** How a skip reason reads on screen and in the downloaded file. */\nexport const LEAD_IMPORT_SKIP_LABELS: Record<LeadImportSkipReason, string> = {\n 'invalid-email': 'No usable email address',\n duplicate: 'The same address appears earlier in this file',\n 'lead-ceiling': 'This site is at the platform lead limit',\n 'campaign-unknown': 'Names a campaign this site does not have',\n 'write-failed': 'Could not be saved',\n}\n\n/** The most campaigns one row may name — the membership field's own cap. */\nexport const LEAD_IMPORT_CAMPAIGNS_MAX = CAMPAIGN_MEMBERSHIP_CAP\n\n/** One row, ready for the server to resolve and write. */\nexport interface LeadImportRow {\n /** Normalized — the address `personKey` derives the document id from. */\n email: string\n name?: string\n /**\n * The lead's own profile, already normalized the way the record stores\n * it (AGL-3231): a key present is a value to write, a key absent leaves\n * an existing lead's value alone. Never a clear — a blank cell in a file\n * is a cell nobody filled, not a decision to erase what the site knows.\n */\n profile: CrmLeadProfile\n /**\n * The campaigns the row files the lead under (AGL-3254), by NAME as the\n * file wrote them: the server resolves each against the site's own\n * containers and refuses the row whole when one is not there, because a\n * row filed under half its campaigns is a row nobody asked for.\n */\n campaigns?: string[]\n /** Absent leaves an existing lead's status alone and a new one reading as `new`. */\n status?: CrmLeadStatus\n /** Normalized, for the server to resolve against the org's members. */\n ownerEmail?: string\n /** Only ever set beside `status: 'unqualified'` — the pair is one fact. */\n unqualifiedReason?: string\n notes?: string\n dropped: ImportDroppedValue[]\n}\n\nexport type LeadImportRowVerdict =\n | { ok: true; row: LeadImportRow }\n | { ok: false; reason: 'invalid-email'; input: string }\n\n/**\n * A status cell by id or by label — `working`, `Working` and `WORKING` are\n * one status. `null` for a cell naming no status a file may set, which\n * includes the well-spelled `Qualified` the export writes for a converted\n * lead.\n */\nexport function parseImportLeadStatus(value: unknown): CrmLeadStatus | null {\n const text = String(value ?? '')\n .trim()\n .toLowerCase()\n if (!text) return null\n const byId = isCrmLeadStatus(text) ? text : null\n const byLabel =\n LEAD_IMPORT_STATUSES.find(\n (status) => CRM_LEAD_STATUS_LABELS[status].toLowerCase() === text,\n ) ?? null\n const status = byId ?? byLabel\n return status && LEAD_IMPORT_STATUSES.includes(status) ? status : null\n}\n\n/**\n * One raw row as the values that will be written, or the reason it cannot\n * be. Refused here only for an unusable address, because the address is\n * the document id; the owner is refused by the server, which alone can\n * look them up.\n */\nexport function normalizeLeadImportRow(raw: LeadImportRawRow): LeadImportRowVerdict {\n const emailText = importTextValue(raw.email, 320) ?? ''\n const email = normalizeContactEmail(emailText)\n if (!email) {\n return { ok: false, reason: 'invalid-email', input: emailText }\n }\n const dropped: ImportDroppedValue[] = []\n const drop = (field: LeadImportField, value: unknown) => {\n dropped.push({ field, value: String(value ?? '').trim() })\n }\n const row: LeadImportRow = { email, profile: {}, dropped }\n\n const name = importTextValue(raw.name, NAME_MAX)?.replace(/\\s+/g, ' ')\n if (name) row.name = name\n\n /*\n * The profile (AGL-3231), field by field, each through the normalizer\n * the record's own card runs. A phone or a website the normalizer cannot\n * read is dropped and reported rather than stored as typed, because the\n * record renders both as links; the country is the one address part the\n * address normalizer drops silently — a typed name is not a code — so it\n * is the one part the report has to name.\n */\n const company = importTextValue(raw.company, CRM_LEAD_TEXT_MAX)?.replace(/\\s+/g, ' ')\n if (company) row.profile.company = company\n const jobTitle = importTextValue(raw.jobTitle, CRM_LEAD_TEXT_MAX)?.replace(/\\s+/g, ' ')\n if (jobTitle) row.profile.jobTitle = jobTitle\n const leadSource = importTextValue(raw.leadSource, CRM_LEAD_TEXT_MAX)?.replace(\n /\\s+/g,\n ' ',\n )\n if (leadSource) row.profile.leadSource = leadSource\n const phoneText = importTextValue(raw.phone, 64)\n if (phoneText) {\n const phone = normalizePhone(phoneText)\n if (phone) row.profile.phone = phone\n else drop('phone', phoneText)\n }\n const websiteText = importTextValue(raw.website, 320)\n if (websiteText) {\n const website = normalizeCompanyWebsite(websiteText)\n if (website) row.profile.website = website\n else drop('website', websiteText)\n }\n const address = normalizeAddress({\n line1: importTextValue(raw.addressLine1, 200),\n line2: importTextValue(raw.addressLine2, 200),\n city: importTextValue(raw.addressCity, 120),\n state: importTextValue(raw.addressState, 120),\n postalCode: importTextValue(raw.addressPostalCode, 32),\n country: importTextValue(raw.addressCountry, 8),\n })\n if (address) row.profile.address = address\n const countryText = importTextValue(raw.addressCountry, 64)\n if (countryText && !address?.country) drop('addressCountry', countryText)\n const tags = normalizeCrmLeadTags(\n String(raw.tags ?? '')\n .split(/[,|]/)\n .map((tag) => tag.trim()),\n )\n if (tags.length) row.profile.tags = tags\n\n // Names, trimmed and deduplicated as typed; the server matches them to\n // the site's campaigns without regard to case.\n const campaigns = [\n ...new Set(\n String(raw.campaigns ?? '')\n .split(/[,|]/)\n .map((name) => name.trim().replace(/\\s+/g, ' '))\n .filter(Boolean),\n ),\n ].slice(0, LEAD_IMPORT_CAMPAIGNS_MAX)\n if (campaigns.length) row.campaigns = campaigns\n\n const statusText = importTextValue(raw.status, 32)\n if (statusText) {\n const status = parseImportLeadStatus(statusText)\n if (status) row.status = status\n else drop('status', statusText)\n }\n\n const ownerText = importTextValue(raw.ownerEmail, 320)\n if (ownerText) {\n const owner = normalizeContactEmail(ownerText)\n if (owner) row.ownerEmail = owner\n else drop('ownerEmail', ownerText)\n }\n\n /*\n * The reason belongs to the status: it is what an unqualified lead was\n * closed FOR, and on a lead that is not unqualified it describes\n * nothing. A row that carries one without the other keeps the status and\n * reports the reason, so a mis-mapped column is visible rather than\n * stored where nothing reads it.\n */\n const reasonText = importTextValue(raw.unqualifiedReason, LEAD_IMPORT_REASON_MAX)\n if (reasonText) {\n if (row.status === 'unqualified') row.unqualifiedReason = reasonText\n else drop('unqualifiedReason', reasonText)\n }\n\n const notes = importTextValue(raw.notes, LEAD_IMPORT_NOTES_MAX)\n if (notes) row.notes = notes\n\n return { ok: true, row }\n}\n\n/** One row the server did not store, by its index in the request, named by the address. */\nexport interface LeadImportSkippedRow {\n index: number\n email: string\n reason: LeadImportSkipReason\n}\n\n/** What one request did. The drawer sums these across a file. */\nexport type LeadImportChunkResult = ImportChunkResult<LeadImportSkippedRow>\n\nexport function emptyLeadImportResult(): LeadImportChunkResult {\n return emptyImportResult<LeadImportSkippedRow>()\n}\n\nexport function mergeLeadImportResults(\n total: LeadImportChunkResult,\n chunk: LeadImportChunkResult,\n offset = 0,\n): LeadImportChunkResult {\n return mergeImportResults(total, chunk, offset)\n}\n\n/** The skipped rows as a file the operator can fix and re-import. */\nexport function leadImportSkippedCsv(\n columns: readonly string[],\n entries: readonly { cells: readonly string[]; reason: LeadImportSkipReason }[],\n): string {\n return importSkippedCsv(columns, entries, LEAD_IMPORT_SKIP_LABELS)\n}\n"],"names":["CAMPAIGN_MEMBERSHIP_CAP","normalizeContactEmail","CRM_LEAD_STATUS_LABELS","CRM_LEAD_STATUSES","CRM_LEAD_TEXT_MAX","isCrmLeadStatus","normalizeCrmLeadTags","normalizeCompanyWebsite","normalizeAddress","normalizePhone","CSV_IMPORT_CHUNK_SIZE","CSV_IMPORT_MAX_BODY_BYTES","CSV_IMPORT_MAX_ROWS","CSV_IMPORT_PREVIEW_ROWS","emptyImportResult","guessImportMapping","importAliasKeys","importSkippedCsv","importTextValue","mapImportRow","mergeImportResults","LEAD_IMPORT_MAX_ROWS","LEAD_IMPORT_CHUNK_SIZE","LEAD_IMPORT_MAX_BODY_BYTES","LEAD_IMPORT_PREVIEW_ROWS","LEAD_IMPORT_NOTES_MAX","LEAD_IMPORT_REASON_MAX","NAME_MAX","LEAD_IMPORT_STATUSES","filter","status","LEAD_IMPORT_FIELDS","LEAD_IMPORT_FIELD_LABELS","email","name","company","jobTitle","phone","website","leadSource","ownerEmail","addressLine1","addressLine2","addressCity","addressState","addressPostalCode","addressCountry","tags","campaigns","unqualifiedReason","notes","FIELD_ALIASES","FIELD_ALIAS_KEYS","guessLeadImportMapping","columns","mapLeadImportRow","cells","mapping","custom","_custom","row","LEAD_IMPORT_SKIP_LABELS","duplicate","LEAD_IMPORT_CAMPAIGNS_MAX","parseImportLeadStatus","value","text","String","trim","toLowerCase","byId","byLabel","find","includes","normalizeLeadImportRow","raw","emailText","ok","reason","input","dropped","drop","field","push","profile","replace","phoneText","websiteText","address","line1","line2","city","state","postalCode","country","countryText","split","map","tag","length","Set","Boolean","slice","statusText","ownerText","owner","reasonText","emptyLeadImportResult","mergeLeadImportResults","total","chunk","offset","leadImportSkippedCsv","entries"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-lead-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 * BRINGING A SPREADSHEET OF LEADS INTO THE CRM — the pure half (AGL-2701).\n *\n * The contact import's three stages (`crm-import.ts`) over the lead\n * vocabulary. A lead is the one CRM record a person does not type: it is\n * what a capture door wrote when somebody signed up, booked or submitted a\n * form, plus the working state the team wrote on top. A file therefore\n * carries HALF a lead — the person, and the team's annotations — and the\n * capture half is the door's to write.\n *\n * ## The address is the identity, because it is the document id\n *\n * A lead lives at `hosts/{hostId}/leads/{personKey}`, and `personKey` is\n * the sha256 of the normalized address. So the address is not merely the\n * one required cell: it is the row's name. A cell the normalizer cannot\n * read is refused as `invalid-email`, and two rows of one file that\n * normalize to the same address are one person — the second is skipped as\n * `duplicate` rather than merged onto the first, for the reason the\n * contacts import gives: one person reported once created and once merged\n * is a number the operator cannot reconcile against the rows they sent.\n *\n * ## What a file may NOT say\n *\n * The export's `Sources`, `First seen`, `Last seen` and `Captures` columns\n * are the capture door's own record of what the visitor did, and\n * `Converted` is stamped by `crm/lead-convert` after the contact exists.\n * None of them is a field here, so the mapping leaves those columns\n * unmapped rather than mis-mapping them — a file that could write\n * \"first seen\" could rewrite the history it is supposed to describe. The\n * organization-level file's `Site` column is left out for a second reason:\n * the site is the drawer's question and the route's permission is granted\n * for that ONE site, so a cell must not be able to redirect a row to\n * another.\n *\n * ## Qualified is a conversion, not a status a cell can claim\n *\n * `qualified` means a lead BECAME a contact, and `convertedContactId`\n * beside it names the contact that was really created. Only the convert\n * route writes the pair, so the list's own status control offers new,\n * working and unqualified and nothing else. A cell naming `qualified` is\n * dropped and reported here for the same reason: a status claiming a\n * conversion that never happened would show a contact link to nowhere.\n *\n * ## A bad cell is dropped and named; a bad row is skipped and named\n *\n * The address is the only cell that can make a row unstorable. An\n * unreadable status, or an unqualified reason with no unqualified status\n * to belong to, is left off the lead and REPORTED under\n * {@link LeadImportRow.dropped}, so the operator learns that a column went\n * nowhere rather than discovering it weeks later.\n */\n\nimport { CAMPAIGN_MEMBERSHIP_CAP } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport {\n CRM_LEAD_STATUS_LABELS,\n CRM_LEAD_STATUSES,\n CRM_LEAD_TEXT_MAX,\n type CrmLeadProfile,\n type CrmLeadStatus,\n type CrmPicklist,\n isCrmLeadStatus,\n judgeCrmLeadSource,\n normalizeCrmPicklistLabel,\n normalizeCrmLeadTags,\n normalizeCompanyWebsite,\n} from '@aglyn/aglyn/app-utils/crm'\nimport {\n normalizeAddress,\n normalizePhone,\n} from '@aglyn/aglyn/foundation/definitions/contact.types'\nimport {\n CSV_IMPORT_CHUNK_SIZE,\n CSV_IMPORT_MAX_BODY_BYTES,\n CSV_IMPORT_MAX_ROWS,\n CSV_IMPORT_PREVIEW_ROWS,\n emptyImportResult,\n guessImportMapping,\n importAliasKeys,\n type ImportChunkResult,\n type ImportDroppedValue,\n importSkippedCsv,\n importTextValue,\n mapImportRow,\n mergeImportResults,\n} from '@aglyn/aglyn/app-utils/csv-import'\n\n/** The shared ceilings, under this collection's names. */\nexport const LEAD_IMPORT_MAX_ROWS = CSV_IMPORT_MAX_ROWS\nexport const LEAD_IMPORT_CHUNK_SIZE = CSV_IMPORT_CHUNK_SIZE\nexport const LEAD_IMPORT_MAX_BODY_BYTES = CSV_IMPORT_MAX_BODY_BYTES\nexport const LEAD_IMPORT_PREVIEW_ROWS = CSV_IMPORT_PREVIEW_ROWS\n\n/** The same caps the lead's own cards hold a typed value to. */\nexport const LEAD_IMPORT_NOTES_MAX = 4000\nexport const LEAD_IMPORT_REASON_MAX = 500\n\n/** A person's name, capped where every other import caps one. */\nconst NAME_MAX = 120\n\n/**\n * The statuses a file may set.\n *\n * The list's own status control offers exactly these three, and for the\n * reason given above: `qualified` is written by the conversion, beside the\n * contact it produced.\n */\nexport const LEAD_IMPORT_STATUSES: readonly CrmLeadStatus[] =\n CRM_LEAD_STATUSES.filter((status) => status !== 'qualified')\n\n/**\n * The fields a column may be mapped to, in the order the mapping menu\n * lists them.\n *\n * The lead's own profile (AGL-3231) — company, title, phone, website, the\n * lead source, the address parts and tags — is what a list from another\n * tool actually carries, and the reason a lead can be worked without a\n * contact beside it. They read under the same labels the contacts import\n * uses, so one spreadsheet maps the same way into either.\n */\nexport const LEAD_IMPORT_FIELDS = [\n 'email',\n 'name',\n 'company',\n 'jobTitle',\n 'phone',\n 'website',\n 'leadSource',\n 'status',\n 'ownerEmail',\n 'addressLine1',\n 'addressLine2',\n 'addressCity',\n 'addressState',\n 'addressPostalCode',\n 'addressCountry',\n 'tags',\n 'campaigns',\n 'unqualifiedReason',\n 'notes',\n] as const\n\nexport type LeadImportField = (typeof LEAD_IMPORT_FIELDS)[number]\n\n/** How each field reads in the mapping menu. Typed so a field cannot ship unlabeled. */\nexport const LEAD_IMPORT_FIELD_LABELS: Record<LeadImportField, string> = {\n email: 'Email (required)',\n name: 'Name',\n company: 'Company name',\n jobTitle: 'Job title',\n phone: 'Phone',\n website: 'Website',\n leadSource: 'Lead source',\n status: 'Status (new, working, unqualified)',\n ownerEmail: 'Owner (team member email)',\n addressLine1: 'Address line 1',\n addressLine2: 'Address line 2',\n addressCity: 'City',\n addressState: 'State or region',\n addressPostalCode: 'Postal code',\n addressCountry: 'Country (two-letter code)',\n tags: 'Tags (comma or | separated)',\n campaigns: 'Campaigns (by name, comma or | separated)',\n unqualifiedReason: 'Unqualified reason',\n notes: 'Notes',\n}\n\n/**\n * Header aliases per field, matched after the shared header normalization.\n *\n * First in each list is the header this CRM's own leads export writes, so\n * an export re-imports without a hand mapping; the export's Sources, First\n * seen, Last seen, Captures, Converted and Site columns are deliberately\n * absent from every list, so they land on \"Do not import\".\n */\nconst FIELD_ALIASES: Record<LeadImportField, readonly string[]> = {\n email: ['email', 'email address', 'e mail', 'mail', 'contact email', 'work email'],\n name: ['name', 'full name', 'lead', 'lead name', 'contact', 'contact name', 'person'],\n company: [\n 'company',\n 'company name',\n 'organization',\n 'organisation',\n 'account',\n 'account name',\n 'employer',\n ],\n jobTitle: ['job title', 'title', 'position', 'role', 'headline'],\n phone: ['phone', 'phone number', 'mobile', 'mobile phone', 'telephone', 'tel', 'cell'],\n website: ['website', 'web site', 'url', 'company website', 'company domain', 'domain'],\n leadSource: ['lead source', 'source', 'origin', 'channel', 'campaign'],\n status: ['status', 'lead status', 'stage', 'lead stage'],\n ownerEmail: ['owner', 'owner email', 'assigned to', 'assignee', 'rep', 'sales rep'],\n addressLine1: ['address', 'address line 1', 'street', 'street address', 'address 1'],\n addressLine2: ['address line 2', 'address 2', 'suite', 'apartment'],\n addressCity: ['city', 'town', 'locality'],\n addressState: ['state', 'state or region', 'region', 'province', 'county'],\n addressPostalCode: ['postal code', 'postcode', 'zip', 'zip code'],\n addressCountry: ['country', 'country code'],\n tags: ['tags', 'tag', 'labels', 'lists'],\n // `campaign` alone stays the lead source's: another tool's \"Campaign\"\n // column names where the person came from, not one of this site's\n // containers (AGL-3254).\n campaigns: ['campaigns', 'campaign names', 'in campaigns', 'email campaigns'],\n unqualifiedReason: [\n 'unqualified reason',\n 'reason',\n 'disqualified reason',\n 'lost reason',\n 'reason lost',\n ],\n notes: ['notes', 'note', 'description', 'comments', 'details'],\n}\n\nconst FIELD_ALIAS_KEYS = importAliasKeys(LEAD_IMPORT_FIELDS, FIELD_ALIASES)\n\n/** Column index → field. A column absent from the map is not imported. */\nexport type LeadImportMapping = Record<number, LeadImportField>\n\n/** A proposed mapping from a file's header row, each field taken at most once. */\nexport function guessLeadImportMapping(columns: readonly string[]): LeadImportMapping {\n return guessImportMapping(columns, LEAD_IMPORT_FIELDS, FIELD_ALIAS_KEYS)\n}\n\n/**\n * What the browser posts for one line: the cells the mapping selected,\n * under the field they were mapped to, verbatim. `unknown` because the\n * server reads this off an untrusted body.\n */\nexport type LeadImportRawRow = Partial<Record<LeadImportField, unknown>>\n\n/** One parsed line under the mapping. Empty cells are left absent; a lead has no custom fields. */\nexport function mapLeadImportRow(\n cells: readonly string[],\n mapping: Record<number, LeadImportField | `custom:${string}`>,\n): LeadImportRawRow {\n const { custom: _custom, ...row } = mapImportRow(cells, mapping)\n return row\n}\n\nexport type LeadImportSkipReason =\n | 'invalid-email'\n | 'duplicate'\n | 'lead-ceiling'\n | 'campaign-unknown'\n | 'lead-source-unknown'\n | 'write-failed'\n\n/** How a skip reason reads on screen and in the downloaded file. */\nexport const LEAD_IMPORT_SKIP_LABELS: Record<LeadImportSkipReason, string> = {\n 'invalid-email': 'No usable email address',\n duplicate: 'The same address appears earlier in this file',\n 'lead-ceiling': 'This site is at the platform lead limit',\n 'campaign-unknown': 'Names a campaign this site does not have',\n 'lead-source-unknown':\n \"Names a lead source that isn't one of this organization's active values\",\n 'write-failed': 'Could not be saved',\n}\n\n/** The most campaigns one row may name — the membership field's own cap. */\nexport const LEAD_IMPORT_CAMPAIGNS_MAX = CAMPAIGN_MEMBERSHIP_CAP\n\n/** One row, ready for the server to resolve and write. */\nexport interface LeadImportRow {\n /** Normalized — the address `personKey` derives the document id from. */\n email: string\n name?: string\n /**\n * The lead's own profile, already normalized the way the record stores\n * it (AGL-3231): a key present is a value to write, a key absent leaves\n * an existing lead's value alone. Never a clear — a blank cell in a file\n * is a cell nobody filled, not a decision to erase what the site knows.\n */\n profile: CrmLeadProfile\n /**\n * The campaigns the row files the lead under (AGL-3254), by NAME as the\n * file wrote them: the server resolves each against the site's own\n * containers and refuses the row whole when one is not there, because a\n * row filed under half its campaigns is a row nobody asked for.\n */\n campaigns?: string[]\n /** Absent leaves an existing lead's status alone and a new one reading as `new`. */\n status?: CrmLeadStatus\n /** Normalized, for the server to resolve against the org's members. */\n ownerEmail?: string\n /** Only ever set beside `status: 'unqualified'` — the pair is one fact. */\n unqualifiedReason?: string\n notes?: string\n dropped: ImportDroppedValue[]\n}\n\nexport type LeadImportRowVerdict =\n | { ok: true; row: LeadImportRow }\n | { ok: false; reason: 'invalid-email'; input: string }\n\n/**\n * A status cell by id or by label — `working`, `Working` and `WORKING` are\n * one status. `null` for a cell naming no status a file may set, which\n * includes the well-spelled `Qualified` the export writes for a converted\n * lead.\n */\nexport function parseImportLeadStatus(value: unknown): CrmLeadStatus | null {\n const text = String(value ?? '')\n .trim()\n .toLowerCase()\n if (!text) return null\n const byId = isCrmLeadStatus(text) ? text : null\n const byLabel =\n LEAD_IMPORT_STATUSES.find(\n (status) => CRM_LEAD_STATUS_LABELS[status].toLowerCase() === text,\n ) ?? null\n const status = byId ?? byLabel\n return status && LEAD_IMPORT_STATUSES.includes(status) ? status : null\n}\n\n/**\n * One raw row as the values that will be written, or the reason it cannot\n * be. Refused here only for an unusable address, because the address is\n * the document id; the owner is refused by the server, which alone can\n * look them up.\n */\nexport function normalizeLeadImportRow(raw: LeadImportRawRow): LeadImportRowVerdict {\n const emailText = importTextValue(raw.email, 320) ?? ''\n const email = normalizeContactEmail(emailText)\n if (!email) {\n return { ok: false, reason: 'invalid-email', input: emailText }\n }\n const dropped: ImportDroppedValue[] = []\n const drop = (field: LeadImportField, value: unknown) => {\n dropped.push({ field, value: String(value ?? '').trim() })\n }\n const row: LeadImportRow = { email, profile: {}, dropped }\n\n const name = importTextValue(raw.name, NAME_MAX)?.replace(/\\s+/g, ' ')\n if (name) row.name = name\n\n /*\n * The profile (AGL-3231), field by field, each through the normalizer\n * the record's own card runs. A phone or a website the normalizer cannot\n * read is dropped and reported rather than stored as typed, because the\n * record renders both as links; the country is the one address part the\n * address normalizer drops silently — a typed name is not a code — so it\n * is the one part the report has to name.\n */\n const company = importTextValue(raw.company, CRM_LEAD_TEXT_MAX)?.replace(/\\s+/g, ' ')\n if (company) row.profile.company = company\n const jobTitle = importTextValue(raw.jobTitle, CRM_LEAD_TEXT_MAX)?.replace(/\\s+/g, ' ')\n if (jobTitle) row.profile.jobTitle = jobTitle\n const leadSource = importTextValue(raw.leadSource, CRM_LEAD_TEXT_MAX)?.replace(\n /\\s+/g,\n ' ',\n )\n if (leadSource) row.profile.leadSource = leadSource\n const phoneText = importTextValue(raw.phone, 64)\n if (phoneText) {\n const phone = normalizePhone(phoneText)\n if (phone) row.profile.phone = phone\n else drop('phone', phoneText)\n }\n const websiteText = importTextValue(raw.website, 320)\n if (websiteText) {\n const website = normalizeCompanyWebsite(websiteText)\n if (website) row.profile.website = website\n else drop('website', websiteText)\n }\n const address = normalizeAddress({\n line1: importTextValue(raw.addressLine1, 200),\n line2: importTextValue(raw.addressLine2, 200),\n city: importTextValue(raw.addressCity, 120),\n state: importTextValue(raw.addressState, 120),\n postalCode: importTextValue(raw.addressPostalCode, 32),\n country: importTextValue(raw.addressCountry, 8),\n })\n if (address) row.profile.address = address\n const countryText = importTextValue(raw.addressCountry, 64)\n if (countryText && !address?.country) drop('addressCountry', countryText)\n const tags = normalizeCrmLeadTags(\n String(raw.tags ?? '')\n .split(/[,|]/)\n .map((tag) => tag.trim()),\n )\n if (tags.length) row.profile.tags = tags\n\n // Names, trimmed and deduplicated as typed; the server matches them to\n // the site's campaigns without regard to case.\n const campaigns = [\n ...new Set(\n String(raw.campaigns ?? '')\n .split(/[,|]/)\n .map((name) => name.trim().replace(/\\s+/g, ' '))\n .filter(Boolean),\n ),\n ].slice(0, LEAD_IMPORT_CAMPAIGNS_MAX)\n if (campaigns.length) row.campaigns = campaigns\n\n const statusText = importTextValue(raw.status, 32)\n if (statusText) {\n const status = parseImportLeadStatus(statusText)\n if (status) row.status = status\n else drop('status', statusText)\n }\n\n const ownerText = importTextValue(raw.ownerEmail, 320)\n if (ownerText) {\n const owner = normalizeContactEmail(ownerText)\n if (owner) row.ownerEmail = owner\n else drop('ownerEmail', ownerText)\n }\n\n /*\n * The reason belongs to the status: it is what an unqualified lead was\n * closed FOR, and on a lead that is not unqualified it describes\n * nothing. A row that carries one without the other keeps the status and\n * reports the reason, so a mis-mapped column is visible rather than\n * stored where nothing reads it.\n */\n const reasonText = importTextValue(raw.unqualifiedReason, LEAD_IMPORT_REASON_MAX)\n if (reasonText) {\n if (row.status === 'unqualified') row.unqualifiedReason = reasonText\n else drop('unqualifiedReason', reasonText)\n }\n\n const notes = importTextValue(raw.notes, LEAD_IMPORT_NOTES_MAX)\n if (notes) row.notes = notes\n\n return { ok: true, row }\n}\n\n/** One row the server did not store, by its index in the request, named by the address. */\nexport interface LeadImportSkippedRow {\n index: number\n email: string\n reason: LeadImportSkipReason\n}\n\n/** What one request did. The drawer sums these across a file. */\nexport type LeadImportChunkResult = ImportChunkResult<LeadImportSkippedRow>\n\nexport function emptyLeadImportResult(): LeadImportChunkResult {\n return emptyImportResult<LeadImportSkippedRow>()\n}\n\nexport function mergeLeadImportResults(\n total: LeadImportChunkResult,\n chunk: LeadImportChunkResult,\n offset = 0,\n): LeadImportChunkResult {\n return mergeImportResults(total, chunk, offset)\n}\n\n/** The skipped rows as a file the operator can fix and re-import. */\nexport function leadImportSkippedCsv(\n columns: readonly string[],\n entries: readonly { cells: readonly string[]; reason: LeadImportSkipReason }[],\n): string {\n return importSkippedCsv(columns, entries, LEAD_IMPORT_SKIP_LABELS)\n}\n\n/**\n * The lead sources a file names that the org's list would refuse\n * (AGL-3298), each once, as the file spelled it — the drawer's warning\n * before anything is sent. Judged the way the server judges a NEW lead,\n * so a value the list holds but has deactivated is named too; a row\n * merging onto a lead that already holds that value would still pass.\n */\nexport function leadImportUnknownLeadSources(\n rows: readonly LeadImportRawRow[],\n picklist: CrmPicklist,\n): string[] {\n const unknown = new Map<string, string>()\n for (const row of rows) {\n const label = normalizeCrmPicklistLabel(row.leadSource)\n if (!label || unknown.has(label.toLowerCase())) continue\n if (!judgeCrmLeadSource(picklist, label).ok) unknown.set(label.toLowerCase(), label)\n }\n return [...unknown.values()]\n}\n"],"names":["CAMPAIGN_MEMBERSHIP_CAP","normalizeContactEmail","CRM_LEAD_STATUS_LABELS","CRM_LEAD_STATUSES","CRM_LEAD_TEXT_MAX","isCrmLeadStatus","judgeCrmLeadSource","normalizeCrmPicklistLabel","normalizeCrmLeadTags","normalizeCompanyWebsite","normalizeAddress","normalizePhone","CSV_IMPORT_CHUNK_SIZE","CSV_IMPORT_MAX_BODY_BYTES","CSV_IMPORT_MAX_ROWS","CSV_IMPORT_PREVIEW_ROWS","emptyImportResult","guessImportMapping","importAliasKeys","importSkippedCsv","importTextValue","mapImportRow","mergeImportResults","LEAD_IMPORT_MAX_ROWS","LEAD_IMPORT_CHUNK_SIZE","LEAD_IMPORT_MAX_BODY_BYTES","LEAD_IMPORT_PREVIEW_ROWS","LEAD_IMPORT_NOTES_MAX","LEAD_IMPORT_REASON_MAX","NAME_MAX","LEAD_IMPORT_STATUSES","filter","status","LEAD_IMPORT_FIELDS","LEAD_IMPORT_FIELD_LABELS","email","name","company","jobTitle","phone","website","leadSource","ownerEmail","addressLine1","addressLine2","addressCity","addressState","addressPostalCode","addressCountry","tags","campaigns","unqualifiedReason","notes","FIELD_ALIASES","FIELD_ALIAS_KEYS","guessLeadImportMapping","columns","mapLeadImportRow","cells","mapping","custom","_custom","row","LEAD_IMPORT_SKIP_LABELS","duplicate","LEAD_IMPORT_CAMPAIGNS_MAX","parseImportLeadStatus","value","text","String","trim","toLowerCase","byId","byLabel","find","includes","normalizeLeadImportRow","raw","emailText","ok","reason","input","dropped","drop","field","push","profile","replace","phoneText","websiteText","address","line1","line2","city","state","postalCode","country","countryText","split","map","tag","length","Set","Boolean","slice","statusText","ownerText","owner","reasonText","emptyLeadImportResult","mergeLeadImportResults","total","chunk","offset","leadImportSkippedCsv","entries","leadImportUnknownLeadSources","rows","picklist","unknown","Map","label","has","set","values"],"mappings":"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"}
|
|
@@ -56,3 +56,16 @@ export declare function leadMatchesCampaignFilter(lead: Readonly<Record<string,
|
|
|
56
56
|
* every lead, so the box emptied is the list unfiltered.
|
|
57
57
|
*/
|
|
58
58
|
export declare function leadMatchesSearch(lead: Readonly<Record<string, unknown>>, term: string): boolean;
|
|
59
|
+
/**
|
|
60
|
+
* The `Lead source` control's "no lead source" choice (AGL-3298). Held in
|
|
61
|
+
* the saved view as an `isEmpty` clause, never as this string; it is only
|
|
62
|
+
* the select's own value for the choice.
|
|
63
|
+
*/
|
|
64
|
+
export declare const LEAD_SOURCE_FILTER_NONE = "\0none";
|
|
65
|
+
/**
|
|
66
|
+
* The Leads section's `Lead source` control (AGL-3298): every lead (`''`),
|
|
67
|
+
* the ones holding one value — compared the way the picklist compares
|
|
68
|
+
* labels, so a value typed in another case before the list existed is
|
|
69
|
+
* still found — or the ones holding none.
|
|
70
|
+
*/
|
|
71
|
+
export declare function leadMatchesLeadSourceFilter(lead: Readonly<Record<string, unknown>>, filter: string): boolean;
|
|
@@ -14,7 +14,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
14
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
|
-
*/ import { EMAIL_STATE_LABELS, EMAIL_STATE_STATUSES, CRM_LEAD_STATUS_LABELS, emailStateForbidsEmail, crmLeadStatus, isCrmLeadOpen, readCampaignIds, readEmailState } from "@aglyn/aglyn";
|
|
17
|
+
*/ import { EMAIL_STATE_LABELS, EMAIL_STATE_STATUSES, CRM_LEAD_STATUS_LABELS, emailStateForbidsEmail, crmLeadStatus, isCrmLeadOpen, normalizeCrmPicklistLabel, readCampaignIds, readEmailState } from "@aglyn/aglyn";
|
|
18
18
|
export const LEAD_FILTERS = [
|
|
19
19
|
'open',
|
|
20
20
|
'new',
|
|
@@ -92,5 +92,21 @@ export const LEAD_EMAIL_FILTER_LABELS = _extends({
|
|
|
92
92
|
].filter((value)=>typeof value === 'string' && value !== '').map((value)=>value.toLowerCase());
|
|
93
93
|
return words.every((word)=>values.some((value)=>value.includes(word)));
|
|
94
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* The `Lead source` control's "no lead source" choice (AGL-3298). Held in
|
|
97
|
+
* the saved view as an `isEmpty` clause, never as this string; it is only
|
|
98
|
+
* the select's own value for the choice.
|
|
99
|
+
*/ export const LEAD_SOURCE_FILTER_NONE = '\u0000none';
|
|
100
|
+
/**
|
|
101
|
+
* The Leads section's `Lead source` control (AGL-3298): every lead (`''`),
|
|
102
|
+
* the ones holding one value — compared the way the picklist compares
|
|
103
|
+
* labels, so a value typed in another case before the list existed is
|
|
104
|
+
* still found — or the ones holding none.
|
|
105
|
+
*/ export function leadMatchesLeadSourceFilter(lead, filter) {
|
|
106
|
+
if (!filter) return true;
|
|
107
|
+
const held = normalizeCrmPicklistLabel(lead['leadSource']).toLowerCase();
|
|
108
|
+
if (filter === LEAD_SOURCE_FILTER_NONE) return held === '';
|
|
109
|
+
return held === normalizeCrmPicklistLabel(filter).toLowerCase();
|
|
110
|
+
}
|
|
95
111
|
|
|
96
112
|
//# sourceMappingURL=lead-filters.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/lead-filters.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n EMAIL_STATE_LABELS,\n EMAIL_STATE_STATUSES,\n CRM_LEAD_STATUS_LABELS,\n type EmailStateStatus,\n type CrmLeadFields,\n type CrmLeadStatus,\n emailStateForbidsEmail,\n crmLeadStatus,\n isCrmLeadOpen,\n readCampaignIds,\n readEmailState,\n} from '@aglyn/aglyn'\n\n/**\n * What the Leads section's `Show` control offers (AGL-2608): the two\n * aggregate views and each status on its own. `open` is the default — the\n * list opens on the work — and it is the one view a Firestore query cannot\n * express, because a lead nobody has touched carries no status field at all;\n * see the section for why the filter runs over a loaded window.\n */\nexport type LeadFilter = 'open' | 'all' | CrmLeadStatus\n\nexport const LEAD_FILTERS: readonly LeadFilter[] = [\n 'open',\n 'new',\n 'working',\n 'qualified',\n 'unqualified',\n 'all',\n]\n\nexport const LEAD_FILTER_LABELS: Record<LeadFilter, string> = {\n open: 'Open',\n all: 'All',\n new: CRM_LEAD_STATUS_LABELS.new,\n working: CRM_LEAD_STATUS_LABELS.working,\n qualified: CRM_LEAD_STATUS_LABELS.qualified,\n unqualified: CRM_LEAD_STATUS_LABELS.unqualified,\n}\n\n/** Whether a lead belongs in a filter's view. An absent status is `new`. */\nexport function leadMatchesFilter(\n lead: Pick<CrmLeadFields, 'status'>,\n filter: LeadFilter,\n): boolean {\n if (filter === 'all') return true\n if (filter === 'open') return isCrmLeadOpen(lead)\n return crmLeadStatus(lead) === filter\n}\n\n/**\n * The Leads section's `Email` control (AGL-3245): every lead, the ones a\n * member must not email — any verdict but `ok` — the ones nothing has been\n * said about, or one verdict on its own. Beside `Show`, not inside it: a\n * bounce does not move a lead out of Open, and a queue of bounced leads is\n * a queue of people to reach some other way.\n */\nexport type LeadEmailFilter = 'any' | 'problem' | 'none' | EmailStateStatus\n\nexport const LEAD_EMAIL_FILTERS: readonly LeadEmailFilter[] = [\n 'any',\n 'problem',\n ...EMAIL_STATE_STATUSES,\n 'none',\n]\n\nexport const LEAD_EMAIL_FILTER_LABELS: Record<LeadEmailFilter, string> = {\n any: 'Any',\n problem: 'Cannot be emailed',\n none: 'Nothing known',\n ...EMAIL_STATE_LABELS,\n}\n\n/** Whether a lead belongs in an email filter's view. */\nexport function leadMatchesEmailFilter(\n lead: Readonly<Record<string, unknown>>,\n filter: LeadEmailFilter,\n): boolean {\n if (filter === 'any') return true\n const state = readEmailState(lead)\n if (filter === 'none') return state === null\n if (filter === 'problem') return emailStateForbidsEmail(state)\n return state?.status === filter\n}\n\n/**\n * The Leads section's `Campaign` control (AGL-3254): every lead, or the\n * ones filed under one campaign — read off the lead's own `campaignIds`,\n * the field every campaign member carries. `''` is every lead, which is\n * what the control opens on; the ids are the site's containers', and the\n * section resolves their names for the menu.\n */\nexport function leadMatchesCampaignFilter(\n lead: Readonly<Record<string, unknown>>,\n campaignId: string,\n): boolean {\n if (!campaignId) return true\n return readCampaignIds(lead).includes(campaignId)\n}\n\n/**\n * The fields the Leads section's search box reads (AGL-3246): who the lead\n * is and where they work — the words a person types to find one — plus the\n * tags, which are how an import names its batch (`sal-15`). `name` and\n * `email` are the capture door's fields rather than the CRM's, so the row is\n * read as a record rather than through `CrmLeadFields`.\n */\nconst LEAD_SEARCH_FIELDS = ['name', 'email', 'company', 'jobTitle'] as const\n\n/**\n * Whether a lead answers a search term.\n *\n * Case-insensitive, and every whitespace-separated word of the term must\n * appear in SOME searched field: `morgan lamphere` and `lamphere sal-15`\n * both find Morgan Lamphere, `morgan smith` does not. A blank term matches\n * every lead, so the box emptied is the list unfiltered.\n */\nexport function leadMatchesSearch(\n lead: Readonly<Record<string, unknown>>,\n term: string,\n): boolean {\n const words = term.trim().toLowerCase().split(/\\s+/).filter(Boolean)\n if (!words.length) return true\n const values = [\n ...LEAD_SEARCH_FIELDS.map((field) => lead[field]),\n ...(Array.isArray(lead['tags']) ? lead['tags'] : []),\n ]\n .filter((value): value is string => typeof value === 'string' && value !== '')\n .map((value) => value.toLowerCase())\n return words.every((word) => values.some((value) => value.includes(word)))\n}\n"],"names":["EMAIL_STATE_LABELS","EMAIL_STATE_STATUSES","CRM_LEAD_STATUS_LABELS","emailStateForbidsEmail","crmLeadStatus","isCrmLeadOpen","readCampaignIds","readEmailState","LEAD_FILTERS","LEAD_FILTER_LABELS","open","all","new","working","qualified","unqualified","leadMatchesFilter","lead","filter","LEAD_EMAIL_FILTERS","LEAD_EMAIL_FILTER_LABELS","any","problem","none","leadMatchesEmailFilter","state","status","leadMatchesCampaignFilter","campaignId","includes","LEAD_SEARCH_FIELDS","leadMatchesSearch","term","words","trim","toLowerCase","split","Boolean","length","values","map","field","Array","isArray","value","every","word","some"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,kBAAkB,EAClBC,oBAAoB,EACpBC,sBAAsB,EAItBC,sBAAsB,EACtBC,aAAa,EACbC,aAAa,EACbC,eAAe,EACfC,cAAc,QACT,eAAc;AAWrB,OAAO,MAAMC,eAAsC;IACjD;IACA;IACA;IACA;IACA;IACA;CACD,CAAA;AAED,OAAO,MAAMC,qBAAiD;IAC5DC,MAAM;IACNC,KAAK;IACLC,
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/lead-filters.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n EMAIL_STATE_LABELS,\n EMAIL_STATE_STATUSES,\n CRM_LEAD_STATUS_LABELS,\n type EmailStateStatus,\n type CrmLeadFields,\n type CrmLeadStatus,\n emailStateForbidsEmail,\n crmLeadStatus,\n isCrmLeadOpen,\n normalizeCrmPicklistLabel,\n readCampaignIds,\n readEmailState,\n} from '@aglyn/aglyn'\n\n/**\n * What the Leads section's `Show` control offers (AGL-2608): the two\n * aggregate views and each status on its own. `open` is the default — the\n * list opens on the work — and it is the one view a Firestore query cannot\n * express, because a lead nobody has touched carries no status field at all;\n * see the section for why the filter runs over a loaded window.\n */\nexport type LeadFilter = 'open' | 'all' | CrmLeadStatus\n\nexport const LEAD_FILTERS: readonly LeadFilter[] = [\n 'open',\n 'new',\n 'working',\n 'qualified',\n 'unqualified',\n 'all',\n]\n\nexport const LEAD_FILTER_LABELS: Record<LeadFilter, string> = {\n open: 'Open',\n all: 'All',\n new: CRM_LEAD_STATUS_LABELS.new,\n working: CRM_LEAD_STATUS_LABELS.working,\n qualified: CRM_LEAD_STATUS_LABELS.qualified,\n unqualified: CRM_LEAD_STATUS_LABELS.unqualified,\n}\n\n/** Whether a lead belongs in a filter's view. An absent status is `new`. */\nexport function leadMatchesFilter(\n lead: Pick<CrmLeadFields, 'status'>,\n filter: LeadFilter,\n): boolean {\n if (filter === 'all') return true\n if (filter === 'open') return isCrmLeadOpen(lead)\n return crmLeadStatus(lead) === filter\n}\n\n/**\n * The Leads section's `Email` control (AGL-3245): every lead, the ones a\n * member must not email — any verdict but `ok` — the ones nothing has been\n * said about, or one verdict on its own. Beside `Show`, not inside it: a\n * bounce does not move a lead out of Open, and a queue of bounced leads is\n * a queue of people to reach some other way.\n */\nexport type LeadEmailFilter = 'any' | 'problem' | 'none' | EmailStateStatus\n\nexport const LEAD_EMAIL_FILTERS: readonly LeadEmailFilter[] = [\n 'any',\n 'problem',\n ...EMAIL_STATE_STATUSES,\n 'none',\n]\n\nexport const LEAD_EMAIL_FILTER_LABELS: Record<LeadEmailFilter, string> = {\n any: 'Any',\n problem: 'Cannot be emailed',\n none: 'Nothing known',\n ...EMAIL_STATE_LABELS,\n}\n\n/** Whether a lead belongs in an email filter's view. */\nexport function leadMatchesEmailFilter(\n lead: Readonly<Record<string, unknown>>,\n filter: LeadEmailFilter,\n): boolean {\n if (filter === 'any') return true\n const state = readEmailState(lead)\n if (filter === 'none') return state === null\n if (filter === 'problem') return emailStateForbidsEmail(state)\n return state?.status === filter\n}\n\n/**\n * The Leads section's `Campaign` control (AGL-3254): every lead, or the\n * ones filed under one campaign — read off the lead's own `campaignIds`,\n * the field every campaign member carries. `''` is every lead, which is\n * what the control opens on; the ids are the site's containers', and the\n * section resolves their names for the menu.\n */\nexport function leadMatchesCampaignFilter(\n lead: Readonly<Record<string, unknown>>,\n campaignId: string,\n): boolean {\n if (!campaignId) return true\n return readCampaignIds(lead).includes(campaignId)\n}\n\n/**\n * The fields the Leads section's search box reads (AGL-3246): who the lead\n * is and where they work — the words a person types to find one — plus the\n * tags, which are how an import names its batch (`sal-15`). `name` and\n * `email` are the capture door's fields rather than the CRM's, so the row is\n * read as a record rather than through `CrmLeadFields`.\n */\nconst LEAD_SEARCH_FIELDS = ['name', 'email', 'company', 'jobTitle'] as const\n\n/**\n * Whether a lead answers a search term.\n *\n * Case-insensitive, and every whitespace-separated word of the term must\n * appear in SOME searched field: `morgan lamphere` and `lamphere sal-15`\n * both find Morgan Lamphere, `morgan smith` does not. A blank term matches\n * every lead, so the box emptied is the list unfiltered.\n */\nexport function leadMatchesSearch(\n lead: Readonly<Record<string, unknown>>,\n term: string,\n): boolean {\n const words = term.trim().toLowerCase().split(/\\s+/).filter(Boolean)\n if (!words.length) return true\n const values = [\n ...LEAD_SEARCH_FIELDS.map((field) => lead[field]),\n ...(Array.isArray(lead['tags']) ? lead['tags'] : []),\n ]\n .filter((value): value is string => typeof value === 'string' && value !== '')\n .map((value) => value.toLowerCase())\n return words.every((word) => values.some((value) => value.includes(word)))\n}\n\n/**\n * The `Lead source` control's \"no lead source\" choice (AGL-3298). Held in\n * the saved view as an `isEmpty` clause, never as this string; it is only\n * the select's own value for the choice.\n */\nexport const LEAD_SOURCE_FILTER_NONE = '\\u0000none'\n\n/**\n * The Leads section's `Lead source` control (AGL-3298): every lead (`''`),\n * the ones holding one value — compared the way the picklist compares\n * labels, so a value typed in another case before the list existed is\n * still found — or the ones holding none.\n */\nexport function leadMatchesLeadSourceFilter(\n lead: Readonly<Record<string, unknown>>,\n filter: string,\n): boolean {\n if (!filter) return true\n const held = normalizeCrmPicklistLabel(lead['leadSource']).toLowerCase()\n if (filter === LEAD_SOURCE_FILTER_NONE) return held === ''\n return held === normalizeCrmPicklistLabel(filter).toLowerCase()\n}\n"],"names":["EMAIL_STATE_LABELS","EMAIL_STATE_STATUSES","CRM_LEAD_STATUS_LABELS","emailStateForbidsEmail","crmLeadStatus","isCrmLeadOpen","normalizeCrmPicklistLabel","readCampaignIds","readEmailState","LEAD_FILTERS","LEAD_FILTER_LABELS","open","all","new","working","qualified","unqualified","leadMatchesFilter","lead","filter","LEAD_EMAIL_FILTERS","LEAD_EMAIL_FILTER_LABELS","any","problem","none","leadMatchesEmailFilter","state","status","leadMatchesCampaignFilter","campaignId","includes","LEAD_SEARCH_FIELDS","leadMatchesSearch","term","words","trim","toLowerCase","split","Boolean","length","values","map","field","Array","isArray","value","every","word","some","LEAD_SOURCE_FILTER_NONE","leadMatchesLeadSourceFilter","held"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,kBAAkB,EAClBC,oBAAoB,EACpBC,sBAAsB,EAItBC,sBAAsB,EACtBC,aAAa,EACbC,aAAa,EACbC,yBAAyB,EACzBC,eAAe,EACfC,cAAc,QACT,eAAc;AAWrB,OAAO,MAAMC,eAAsC;IACjD;IACA;IACA;IACA;IACA;IACA;CACD,CAAA;AAED,OAAO,MAAMC,qBAAiD;IAC5DC,MAAM;IACNC,KAAK;IACLC,KAAKX,uBAAuBW,GAAG;IAC/BC,SAASZ,uBAAuBY,OAAO;IACvCC,WAAWb,uBAAuBa,SAAS;IAC3CC,aAAad,uBAAuBc,WAAW;AACjD,EAAC;AAED,0EAA0E,GAC1E,OAAO,SAASC,kBACdC,IAAmC,EACnCC,MAAkB;IAElB,IAAIA,WAAW,OAAO,OAAO;IAC7B,IAAIA,WAAW,QAAQ,OAAOd,cAAca;IAC5C,OAAOd,cAAcc,UAAUC;AACjC;AAWA,OAAO,MAAMC,qBAAiD;IAC5D;IACA;OACGnB;IACH;CACD,CAAA;AAED,OAAO,MAAMoB,2BAA4D;IACvEC,KAAK;IACLC,SAAS;IACTC,MAAM;GACHxB,oBACJ;AAED,sDAAsD,GACtD,OAAO,SAASyB,uBACdP,IAAuC,EACvCC,MAAuB;IAEvB,IAAIA,WAAW,OAAO,OAAO;IAC7B,MAAMO,QAAQlB,eAAeU;IAC7B,IAAIC,WAAW,QAAQ,OAAOO,UAAU;IACxC,IAAIP,WAAW,WAAW,OAAOhB,uBAAuBuB;IACxD,OAAOA,CAAAA,yBAAAA,MAAOC,MAAM,MAAKR;AAC3B;AAEA;;;;;;CAMC,GACD,OAAO,SAASS,0BACdV,IAAuC,EACvCW,UAAkB;IAElB,IAAI,CAACA,YAAY,OAAO;IACxB,OAAOtB,gBAAgBW,MAAMY,QAAQ,CAACD;AACxC;AAEA;;;;;;CAMC,GACD,MAAME,qBAAqB;IAAC;IAAQ;IAAS;IAAW;CAAW;AAEnE;;;;;;;CAOC,GACD,OAAO,SAASC,kBACdd,IAAuC,EACvCe,IAAY;IAEZ,MAAMC,QAAQD,KAAKE,IAAI,GAAGC,WAAW,GAAGC,KAAK,CAAC,OAAOlB,MAAM,CAACmB;IAC5D,IAAI,CAACJ,MAAMK,MAAM,EAAE,OAAO;IAC1B,MAAMC,SAAS;WACVT,mBAAmBU,GAAG,CAAC,CAACC,QAAUxB,IAAI,CAACwB,MAAM;WAC5CC,MAAMC,OAAO,CAAC1B,IAAI,CAAC,OAAO,IAAIA,IAAI,CAAC,OAAO,GAAG,EAAE;KACpD,CACEC,MAAM,CAAC,CAAC0B,QAA2B,OAAOA,UAAU,YAAYA,UAAU,IAC1EJ,GAAG,CAAC,CAACI,QAAUA,MAAMT,WAAW;IACnC,OAAOF,MAAMY,KAAK,CAAC,CAACC,OAASP,OAAOQ,IAAI,CAAC,CAACH,QAAUA,MAAMf,QAAQ,CAACiB;AACrE;AAEA;;;;CAIC,GACD,OAAO,MAAME,0BAA0B,aAAY;AAEnD;;;;;CAKC,GACD,OAAO,SAASC,4BACdhC,IAAuC,EACvCC,MAAc;IAEd,IAAI,CAACA,QAAQ,OAAO;IACpB,MAAMgC,OAAO7C,0BAA0BY,IAAI,CAAC,aAAa,EAAEkB,WAAW;IACtE,IAAIjB,WAAW8B,yBAAyB,OAAOE,SAAS;IACxD,OAAOA,SAAS7C,0BAA0Ba,QAAQiB,WAAW;AAC/D"}
|
|
@@ -0,0 +1,82 @@
|
|
|
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 CrmPicklist } from '@aglyn/aglyn/app-utils/crm';
|
|
18
|
+
/**
|
|
19
|
+
* THE LEAD SOURCE LIST'S EDITS (AGL-3298), as pure moves over a picklist.
|
|
20
|
+
*
|
|
21
|
+
* The Fields page applies the ones that touch only the list — add, reorder,
|
|
22
|
+
* sort, activate, deactivate, default — as one client write of the whole
|
|
23
|
+
* document. The two that change what RECORDS hold — a rename and a delete
|
|
24
|
+
* with its replacement — go to `crm/lead-source-values`, which rewrites the
|
|
25
|
+
* list and every lead and contact holding the old label in one request;
|
|
26
|
+
* this module is where both sides agree what each move does.
|
|
27
|
+
*/
|
|
28
|
+
/** The route the two record-changing moves are posted to. */
|
|
29
|
+
export declare const CRM_LEAD_SOURCE_VALUES_ROUTE: "crm/lead-source-values";
|
|
30
|
+
export declare const crmLeadSourceValuesRouteUrl: () => string;
|
|
31
|
+
/** What the route is asked. `hostId` or `orgId` names the org, as every CRM route's scope does. */
|
|
32
|
+
export type LeadSourceValuesRequest = {
|
|
33
|
+
hostId?: string | null;
|
|
34
|
+
orgId?: string;
|
|
35
|
+
} & ({
|
|
36
|
+
action: 'rename';
|
|
37
|
+
valueId: string;
|
|
38
|
+
label: string;
|
|
39
|
+
} | {
|
|
40
|
+
action: 'delete';
|
|
41
|
+
valueId: string;
|
|
42
|
+
/** An active value's label the records move to, or `null` to clear them. */
|
|
43
|
+
replaceWith: string | null;
|
|
44
|
+
});
|
|
45
|
+
/** What the route answers. */
|
|
46
|
+
export interface LeadSourceValuesResponse {
|
|
47
|
+
ok: true;
|
|
48
|
+
/** How many leads now hold the new label (or none). */
|
|
49
|
+
leads: number;
|
|
50
|
+
/**
|
|
51
|
+
* How many contact FACETS were rewritten — one per holder that held the
|
|
52
|
+
* old label, so a person two sites both filed under it counts twice.
|
|
53
|
+
*/
|
|
54
|
+
contacts: number;
|
|
55
|
+
}
|
|
56
|
+
/** Why a move is refused, in the sentence the page shows. */
|
|
57
|
+
export type PicklistMove = {
|
|
58
|
+
ok: true;
|
|
59
|
+
picklist: CrmPicklist;
|
|
60
|
+
} | {
|
|
61
|
+
ok: false;
|
|
62
|
+
error: string;
|
|
63
|
+
};
|
|
64
|
+
/** Append a value. */
|
|
65
|
+
export declare function addPicklistValue(picklist: CrmPicklist, rawLabel: string): PicklistMove;
|
|
66
|
+
/** Rename a value, keeping its id — the list half of the route's rename. */
|
|
67
|
+
export declare function renamePicklistValue(picklist: CrmPicklist, valueId: string, rawLabel: string): PicklistMove;
|
|
68
|
+
/**
|
|
69
|
+
* Remove a value — the list half of the route's delete. The replacement,
|
|
70
|
+
* when there is one, must be another ACTIVE value: records are not moved
|
|
71
|
+
* onto a value the pickers no longer offer. A default that named the
|
|
72
|
+
* deleted value is cleared.
|
|
73
|
+
*/
|
|
74
|
+
export declare function deletePicklistValue(picklist: CrmPicklist, valueId: string, replaceWith: string | null): PicklistMove;
|
|
75
|
+
/** Turn a value on or off. Deactivating the default clears the default. */
|
|
76
|
+
export declare function setPicklistValueActive(picklist: CrmPicklist, valueId: string, active: boolean): CrmPicklist;
|
|
77
|
+
/** Make a value the default for new records, or clear the default with `null`. */
|
|
78
|
+
export declare function setPicklistDefault(picklist: CrmPicklist, valueId: string | null): CrmPicklist;
|
|
79
|
+
/** Move the value at `from` to `to`, the drag and the arrows alike. */
|
|
80
|
+
export declare function movePicklistValue(picklist: CrmPicklist, from: number, to: number): CrmPicklist;
|
|
81
|
+
/** Every value in A–Z order by label, as Salesforce's "Sort alphabetically" does. */
|
|
82
|
+
export declare function sortPicklistValues(picklist: CrmPicklist): CrmPicklist;
|