@aglyn/plugins-crm 1.0.0-beta.152 → 1.0.0-beta.153

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.
Files changed (38) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/lead-convert-dialog.js +4 -2
  3. package/src/lib/components/lead-convert-dialog.js.map +1 -1
  4. package/src/lib/components/lead-history-card.js +3 -0
  5. package/src/lib/components/lead-history-card.js.map +1 -1
  6. package/src/lib/components/lead-properties-card.js +209 -1
  7. package/src/lib/components/lead-properties-card.js.map +1 -1
  8. package/src/lib/components/lead-surfaces-note.d.ts +7 -3
  9. package/src/lib/components/lead-surfaces-note.js +6 -2
  10. package/src/lib/components/lead-surfaces-note.js.map +1 -1
  11. package/src/lib/components/leads-section.js +114 -2
  12. package/src/lib/components/leads-section.js.map +1 -1
  13. package/src/lib/components/new-lead-drawer.d.ts +56 -0
  14. package/src/lib/components/new-lead-drawer.js +327 -0
  15. package/src/lib/components/new-lead-drawer.js.map +1 -0
  16. package/src/lib/components/use-crm-api.d.ts +1 -1
  17. package/src/lib/components/use-crm-api.js.map +1 -1
  18. package/src/lib/model/crm-lead-import.d.ts +19 -3
  19. package/src/lib/model/crm-lead-import.js +159 -6
  20. package/src/lib/model/crm-lead-import.js.map +1 -1
  21. package/src/lib/model/lead-company-suggestion.d.ts +18 -3
  22. package/src/lib/model/lead-company-suggestion.js +41 -11
  23. package/src/lib/model/lead-company-suggestion.js.map +1 -1
  24. package/src/lib/server/capture-contact.d.ts +28 -0
  25. package/src/lib/server/capture-contact.js +139 -0
  26. package/src/lib/server/capture-contact.js.map +1 -1
  27. package/src/lib/server/convert-open-lead.d.ts +32 -0
  28. package/src/lib/server/convert-open-lead.js +91 -0
  29. package/src/lib/server/convert-open-lead.js.map +1 -0
  30. package/src/lib/server/lead-create.d.ts +76 -0
  31. package/src/lib/server/lead-create.js +227 -0
  32. package/src/lib/server/lead-create.js.map +1 -0
  33. package/src/lib/server/leads-import.js +7 -2
  34. package/src/lib/server/leads-import.js.map +1 -1
  35. package/src/lib/server/record-timeline.js +4 -2
  36. package/src/lib/server/record-timeline.js.map +1 -1
  37. package/src/lib/server.js +3 -0
  38. package/src/lib/server.js.map +1 -1
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/new-lead-drawer.tsx"],"sourcesContent":["'use client'\n\nimport {\n CRM_LEAD_STATUS_LABELS,\n CRM_LEAD_TEXT_MAX,\n type CrmLeadStatus,\n normalizeCrmLeadTags,\n normalizeContactEmail,\n normalizeCrmLeadProfile,\n type AglynPostalAddress,\n} from '@aglyn/aglyn'\nimport { ICON_VARIANT_CLOSE } from '@aglyn/shared-data-enums'\nimport { Container, MdiIcon, SrOnly } from '@aglyn/shared-ui-jsx'\nimport { NavigationDrawerComponent } from '@aglyn/shared-ui-jsx/components/navigation-drawer.component'\nimport {\n Alert,\n Button,\n IconButton,\n MenuItem,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport { useEffect, useState } from 'react'\nimport { useCrmScope } from '../hooks/use-crm-scope'\nimport type { OrgMemberOptions } from '../hooks/use-org-member-options'\nimport {\n ContactAddressFields,\n EMPTY_ADDRESS,\n type AddressDraft,\n} from './contact-address-fields'\nimport { CrmSitePicker } from './crm-site-picker'\nimport { LeadOwnerSelect } from './lead-owner-select'\n\n/** What the drawer hands back — already normalized where the route would. */\nexport interface NewLeadValues {\n email: string\n name: string\n company: string\n jobTitle: string\n phone: string\n website: string\n leadSource: string\n status: Extract<CrmLeadStatus, 'new' | 'working'>\n ownerUid: string\n tags: string[]\n address: AglynPostalAddress | null\n notes: string\n}\n\nexport interface NewLeadDrawerProps {\n open: boolean\n onClose: () => void\n /**\n * The site the lead is filed under — a lead is private to one site by\n * path. `null` at the organization level, where the drawer asks which\n * site with a picker and holds its submit until one is named.\n */\n hostId: string | null\n org?: Record<string, unknown> | null\n /** The request is in flight — the form holds still and the button says so. */\n busy?: boolean\n /** What the route answered when it refused, shown above the form. */\n error?: string | null\n /** The team, for the owner picker. */\n roster: OrgMemberOptions\n onSubmit: (values: NewLeadValues) => void\n}\n\nconst NOTES_MAX = 4000\n\n/**\n * ADDING ONE LEAD BY HAND, IN A DRAWER (AGL-3231).\n *\n * Salesforce's New Lead, on the Leads list: a person the team has heard of\n * and not yet qualified, entered with what is known — the address, the\n * name, the company as text, a title, a phone, a website, an address, tags\n * and where they came from. It makes a lead and nothing else: no contact,\n * no company record. Those are the conversion's to create once the lead is\n * real, which is what keeps one person from sitting in two lists.\n *\n * A drawer and not a form above the list, for the reason every list in this\n * console gives. The two fields a person can mistype in a way the record\n * cannot hold — the email and the phone, and the website beside them — are\n * checked before the request leaves, with the same normalizer the route\n * runs, so the refusal lands under the field. Everything else is the\n * route's to decide: whether the site already holds the address (it\n * updates that lead), whether the plan carries the suite, whether the site\n * is at the platform ceiling.\n */\nexport function NewLeadDrawer(props: NewLeadDrawerProps) {\n const { open, onClose, hostId, org, busy, error, roster, onSubmit } = props\n // The site the route files the lead under: the mounted site, or at the\n // organization level the picked one. `null` until it is known.\n const { createHostId } = useCrmScope({ hostId, org: org as never })\n\n const [email, setEmail] = useState('')\n const [name, setName] = useState('')\n const [company, setCompany] = useState('')\n const [jobTitle, setJobTitle] = useState('')\n const [phone, setPhone] = useState('')\n const [website, setWebsite] = useState('')\n const [leadSource, setLeadSource] = useState('')\n const [status, setStatus] = useState<NewLeadValues['status']>('new')\n const [ownerUid, setOwnerUid] = useState('')\n const [tags, setTags] = useState('')\n const [address, setAddress] = useState<AddressDraft>(EMPTY_ADDRESS)\n const [notes, setNotes] = useState('')\n const [emailError, setEmailError] = useState('')\n const [phoneError, setPhoneError] = useState('')\n const [websiteError, setWebsiteError] = useState('')\n\n // A fresh form on every opening: a person typed and then abandoned must\n // not reappear half-filled the next time somebody reaches for New lead.\n useEffect(() => {\n if (!open) return\n setEmail('')\n setName('')\n setCompany('')\n setJobTitle('')\n setPhone('')\n setWebsite('')\n setLeadSource('')\n setStatus('new')\n setOwnerUid('')\n setTags('')\n setAddress(EMPTY_ADDRESS)\n setNotes('')\n setEmailError('')\n setPhoneError('')\n setWebsiteError('')\n }, [open])\n\n const handleSubmit = () => {\n const normalizedEmail = normalizeContactEmail(email)\n const { patch, errors } = normalizeCrmLeadProfile({\n company,\n jobTitle,\n phone,\n website,\n leadSource,\n address,\n tags: normalizeCrmLeadTags(tags),\n })\n setEmailError(normalizedEmail ? '' : 'Enter a valid email address.')\n setPhoneError(errors.phone ?? '')\n setWebsiteError(errors.website ?? '')\n if (!normalizedEmail || errors.phone || errors.website) return\n onSubmit({\n email: normalizedEmail,\n name: name.trim().replace(/\\s+/g, ' ').slice(0, CRM_LEAD_TEXT_MAX),\n company: patch.company ?? '',\n jobTitle: patch.jobTitle ?? '',\n phone: patch.phone ?? '',\n website: patch.website ?? '',\n leadSource: patch.leadSource ?? '',\n status,\n ownerUid,\n tags: patch.tags ?? [],\n address: patch.address ?? null,\n notes: notes.trim().slice(0, NOTES_MAX),\n })\n }\n\n return (\n <NavigationDrawerComponent\n open={open}\n anchor=\"right\"\n variant=\"temporary\"\n onClose={onClose}\n AppBarProps={{ color: 'surface' }}\n sx={{ '& .MuiDrawer-paper': { width: { xs: '100%', sm: 480 } } }}\n appBarLeft={\n <>\n <IconButton color=\"inherit\" edge=\"start\" onClick={onClose} sx={{ mr: 2 }}>\n <MdiIcon path={ICON_VARIANT_CLOSE.path} />\n <SrOnly>close drawer</SrOnly>\n </IconButton>\n <Typography variant=\"h6\" component=\"div\">\n {'New lead'}\n </Typography>\n </>\n }\n appBarRight={\n <Button variant=\"outlined\" color=\"inherit\" onClick={onClose}>\n {'Cancel'}\n </Button>\n }\n >\n <Container gutterY>\n <Stack spacing={2}>\n {error ? <Alert severity=\"warning\">{error}</Alert> : null}\n {/* First, because the lead is filed under the answer. Renders nothing under a site. */}\n <CrmSitePicker\n hostId={hostId}\n disabled={Boolean(busy)}\n helperText=\"The site this lead is filed under — a lead is private to one site.\"\n />\n <TextField\n size=\"small\"\n label=\"Email\"\n type=\"email\"\n value={email}\n onChange={(event) => setEmail(event.target.value)}\n error={Boolean(emailError)}\n helperText={\n emailError ||\n 'The one field that is required — a site holds one lead per address.'\n }\n required\n fullWidth\n autoFocus\n />\n <TextField\n size=\"small\"\n label=\"Name\"\n value={name}\n onChange={(event) => setName(event.target.value)}\n slotProps={{ htmlInput: { maxLength: CRM_LEAD_TEXT_MAX } }}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Company\"\n value={company}\n onChange={(event) => setCompany(event.target.value)}\n helperText=\"As text — converting the lead is what makes it a company record.\"\n slotProps={{ htmlInput: { maxLength: CRM_LEAD_TEXT_MAX } }}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Job title\"\n value={jobTitle}\n onChange={(event) => setJobTitle(event.target.value)}\n slotProps={{ htmlInput: { maxLength: CRM_LEAD_TEXT_MAX } }}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Phone\"\n value={phone}\n onChange={(event) => setPhone(event.target.value)}\n error={Boolean(phoneError)}\n helperText={phoneError || 'With the country code, like +1 512 555 0107'}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Website\"\n value={website}\n onChange={(event) => setWebsite(event.target.value)}\n error={Boolean(websiteError)}\n helperText={websiteError || 'Like acme.com'}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Lead source\"\n value={leadSource}\n onChange={(event) => setLeadSource(event.target.value)}\n helperText=\"Where this lead came from — a list, an event, a referral\"\n slotProps={{ htmlInput: { maxLength: CRM_LEAD_TEXT_MAX } }}\n fullWidth\n />\n <Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>\n <TextField\n select\n size=\"small\"\n label=\"Status\"\n value={status}\n onChange={(event) =>\n setStatus(event.target.value as NewLeadValues['status'])\n }\n fullWidth\n >\n <MenuItem value=\"new\">{CRM_LEAD_STATUS_LABELS.new}</MenuItem>\n <MenuItem value=\"working\">{CRM_LEAD_STATUS_LABELS.working}</MenuItem>\n </TextField>\n <LeadOwnerSelect value={ownerUid} onChange={setOwnerUid} roster={roster} />\n </Stack>\n <TextField\n size=\"small\"\n label=\"Tags\"\n placeholder=\"icp2, a-list\"\n helperText=\"Comma-separated\"\n value={tags}\n onChange={(event) => setTags(event.target.value)}\n fullWidth\n />\n <Typography variant=\"subtitle2\">{'Address'}</Typography>\n <ContactAddressFields value={address} onChange={setAddress} />\n <TextField\n size=\"small\"\n label=\"Notes\"\n value={notes}\n onChange={(event) => setNotes(event.target.value)}\n multiline\n minRows={2}\n slotProps={{ htmlInput: { maxLength: NOTES_MAX } }}\n fullWidth\n />\n <Button\n variant=\"contained\"\n color=\"primary\"\n // Held until the site is known: the route files the lead under it.\n disabled={Boolean(busy) || !createHostId}\n onClick={handleSubmit}\n >\n {busy ? 'Adding…' : 'Add lead'}\n </Button>\n </Stack>\n </Container>\n </NavigationDrawerComponent>\n )\n}\n\nexport default NewLeadDrawer\n"],"names":["CRM_LEAD_STATUS_LABELS","CRM_LEAD_TEXT_MAX","normalizeCrmLeadTags","normalizeContactEmail","normalizeCrmLeadProfile","ICON_VARIANT_CLOSE","Container","MdiIcon","SrOnly","NavigationDrawerComponent","Alert","Button","IconButton","MenuItem","Stack","TextField","Typography","useEffect","useState","useCrmScope","ContactAddressFields","EMPTY_ADDRESS","CrmSitePicker","LeadOwnerSelect","NOTES_MAX","NewLeadDrawer","props","open","onClose","hostId","org","busy","error","roster","onSubmit","createHostId","email","setEmail","name","setName","company","setCompany","jobTitle","setJobTitle","phone","setPhone","website","setWebsite","leadSource","setLeadSource","status","setStatus","ownerUid","setOwnerUid","tags","setTags","address","setAddress","notes","setNotes","emailError","setEmailError","phoneError","setPhoneError","websiteError","setWebsiteError","handleSubmit","errors","patch","normalizedEmail","trim","replace","slice","anchor","variant","AppBarProps","color","sx","width","xs","sm","appBarLeft","edge","onClick","mr","path","component","appBarRight","gutterY","spacing","severity","disabled","Boolean","helperText","size","label","type","value","onChange","event","target","required","fullWidth","autoFocus","slotProps","htmlInput","maxLength","direction","select","new","working","placeholder","multiline","minRows"],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  /** The routes `registerCrmConsoleApi` registers under `/api/crm/`. */
2
- export type CrmApiRoute = 'contacts-create' | 'contact-email-history' | 'contact-update' | 'company-delete' | 'contacts-merge' | 'email-send' | 'erase-person' | 'org-activity' | 'recipe-install' | 'recipe-status' | 'inbound-address' | 'email-template-duplicate';
2
+ export type CrmApiRoute = 'contacts-create' | 'leads-create' | 'contact-email-history' | 'contact-update' | 'company-delete' | 'contacts-merge' | 'email-send' | 'erase-person' | 'org-activity' | 'recipe-install' | 'recipe-status' | 'inbound-address' | 'email-template-duplicate';
3
3
  /** What one call to the CRM API answered with. */
4
4
  export interface CrmApiResult {
5
5
  response: Response;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/use-crm-api.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 authorizedFetch,\n type TokenSource,\n} from '@aglyn/shared-util-http/authorized-token'\nimport { useUser } from '@aglyn/tenant-feature-instance'\nimport { useCallback, useRef } from 'react'\nimport { useCrmOrgMount } from '../hooks/use-crm-org-mount'\n\n/** The routes `registerCrmConsoleApi` registers under `/api/crm/`. */\nexport type CrmApiRoute =\n | 'contacts-create'\n | 'contact-email-history'\n | 'contact-update'\n | 'company-delete'\n | 'contacts-merge'\n | 'email-send'\n | 'erase-person'\n | 'org-activity'\n | 'recipe-install'\n | 'recipe-status'\n | 'inbound-address'\n | 'email-template-duplicate'\n\n/** What one call to the CRM API answered with. */\nexport interface CrmApiResult {\n response: Response\n payload: Record<string, any>\n}\n\n/**\n * ONE AUTHORIZED POST TO THE CRM's SERVER HALF (AGL-2596).\n *\n * The route is a fixed member of {@link CrmApiRoute} rather than a string\n * the caller composes, so a component cannot post the console's credentials\n * anywhere this plugin does not own. The site id rides in the body because\n * that is where the dispatcher's per-site gate reads it from.\n *\n * The user is read through a ref, for the reason the campaign API hook\n * gives: the user object is a new object on most renders, and a callback\n * that changed identity with it would re-fire every effect that depends on\n * it. The token is fetched at call time either way — `authorizedFetch`\n * resolves it under a deadline and answers a 401 of its own rather than\n * sending the request unauthenticated, so a signed-out caller is told so\n * through the same `response.ok` branch every other refusal takes.\n */\n/**\n * ## The organization level\n *\n * Beneath the org hub's mount (AGL-2630) the body also names the ORG, and\n * that is what makes the call the route's org variant (AGL-2634): the route\n * authorizes the caller by the org rather than by a site's role, and a\n * `hostId` beside it — the record's own capturing site, or `null` for a\n * record no site has captured — is what the act needs a site FOR, never\n * what it is authorized against. Under a site there is no mount and the\n * body is what it always was.\n */\nexport function useCrmApi(\n /**\n * The site the request is made for. At the organization level the\n * record's own site, or `null` for a record no site has captured — the\n * org variant of a route serves both, and a route with no org variant\n * (a create, which is always captured BY a site) refuses the null as it\n * always has.\n */\n hostId: string | null,\n) {\n const { data: user } = useUser()\n const userRef = useRef(user)\n userRef.current = user\n const orgId = useCrmOrgMount()?.orgId ?? null\n return useCallback(\n async (\n route: CrmApiRoute,\n payload: Record<string, unknown>,\n ): Promise<CrmApiResult> => {\n const response = await authorizedFetch(\n userRef.current as TokenSource | null | undefined,\n `/api/crm/${route}`,\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n hostId,\n ...(orgId ? { orgId } : {}),\n ...payload,\n }),\n },\n )\n const json = await response.json().catch(() => ({}))\n return { response, payload: json as Record<string, any> }\n },\n [hostId, orgId],\n )\n}\n\nexport default useCrmApi\n"],"names":["authorizedFetch","useUser","useCallback","useRef","useCrmOrgMount","useCrmApi","hostId","data","user","userRef","current","orgId","route","payload","response","method","headers","body","JSON","stringify","json","catch"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SACEA,eAAe,QAEV,2CAA0C;AACjD,SAASC,OAAO,QAAQ,iCAAgC;AACxD,SAASC,WAAW,EAAEC,MAAM,QAAQ,QAAO;AAC3C,SAASC,cAAc,QAAQ,gCAA4B;AAuB3D;;;;;;;;;;;;;;;CAeC,GACD;;;;;;;;;;CAUC,GACD,OAAO,SAASC,UACd;;;;;;GAMC,GACDC,MAAqB;;QAKPF;IAHd,MAAM,EAAEG,MAAMC,IAAI,EAAE,GAAGP;IACvB,MAAMQ,UAAUN,OAAOK;IACvBC,QAAQC,OAAO,GAAGF;IAClB,MAAMG,iBAAQP,kBAAAA,qCAAAA,gBAAkBO,KAAK,mBAAI;IACzC,OAAOT,YACL,OACEU,OACAC;QAEA,MAAMC,WAAW,MAAMd,gBACrBS,QAAQC,OAAO,EACf,CAAC,SAAS,EAAEE,OAAO,EACnB;YACEG,QAAQ;YACRC,SAAS;gBAAE,gBAAgB;YAAmB;YAC9CC,MAAMC,KAAKC,SAAS,CAAC;gBACnBb;eACIK,QAAQ;gBAAEA;YAAM,IAAI,CAAC,GACtBE;QAEP;QAEF,MAAMO,OAAO,MAAMN,SAASM,IAAI,GAAGC,KAAK,CAAC,IAAO,CAAA,CAAC,CAAA;QACjD,OAAO;YAAEP;YAAUD,SAASO;QAA4B;IAC1D,GACA;QAACd;QAAQK;KAAM;AAEnB;AAEA,eAAeN,UAAS"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/use-crm-api.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 authorizedFetch,\n type TokenSource,\n} from '@aglyn/shared-util-http/authorized-token'\nimport { useUser } from '@aglyn/tenant-feature-instance'\nimport { useCallback, useRef } from 'react'\nimport { useCrmOrgMount } from '../hooks/use-crm-org-mount'\n\n/** The routes `registerCrmConsoleApi` registers under `/api/crm/`. */\nexport type CrmApiRoute =\n | 'contacts-create'\n | 'leads-create'\n | 'contact-email-history'\n | 'contact-update'\n | 'company-delete'\n | 'contacts-merge'\n | 'email-send'\n | 'erase-person'\n | 'org-activity'\n | 'recipe-install'\n | 'recipe-status'\n | 'inbound-address'\n | 'email-template-duplicate'\n\n/** What one call to the CRM API answered with. */\nexport interface CrmApiResult {\n response: Response\n payload: Record<string, any>\n}\n\n/**\n * ONE AUTHORIZED POST TO THE CRM's SERVER HALF (AGL-2596).\n *\n * The route is a fixed member of {@link CrmApiRoute} rather than a string\n * the caller composes, so a component cannot post the console's credentials\n * anywhere this plugin does not own. The site id rides in the body because\n * that is where the dispatcher's per-site gate reads it from.\n *\n * The user is read through a ref, for the reason the campaign API hook\n * gives: the user object is a new object on most renders, and a callback\n * that changed identity with it would re-fire every effect that depends on\n * it. The token is fetched at call time either way — `authorizedFetch`\n * resolves it under a deadline and answers a 401 of its own rather than\n * sending the request unauthenticated, so a signed-out caller is told so\n * through the same `response.ok` branch every other refusal takes.\n */\n/**\n * ## The organization level\n *\n * Beneath the org hub's mount (AGL-2630) the body also names the ORG, and\n * that is what makes the call the route's org variant (AGL-2634): the route\n * authorizes the caller by the org rather than by a site's role, and a\n * `hostId` beside it — the record's own capturing site, or `null` for a\n * record no site has captured — is what the act needs a site FOR, never\n * what it is authorized against. Under a site there is no mount and the\n * body is what it always was.\n */\nexport function useCrmApi(\n /**\n * The site the request is made for. At the organization level the\n * record's own site, or `null` for a record no site has captured — the\n * org variant of a route serves both, and a route with no org variant\n * (a create, which is always captured BY a site) refuses the null as it\n * always has.\n */\n hostId: string | null,\n) {\n const { data: user } = useUser()\n const userRef = useRef(user)\n userRef.current = user\n const orgId = useCrmOrgMount()?.orgId ?? null\n return useCallback(\n async (\n route: CrmApiRoute,\n payload: Record<string, unknown>,\n ): Promise<CrmApiResult> => {\n const response = await authorizedFetch(\n userRef.current as TokenSource | null | undefined,\n `/api/crm/${route}`,\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n hostId,\n ...(orgId ? { orgId } : {}),\n ...payload,\n }),\n },\n )\n const json = await response.json().catch(() => ({}))\n return { response, payload: json as Record<string, any> }\n },\n [hostId, orgId],\n )\n}\n\nexport default useCrmApi\n"],"names":["authorizedFetch","useUser","useCallback","useRef","useCrmOrgMount","useCrmApi","hostId","data","user","userRef","current","orgId","route","payload","response","method","headers","body","JSON","stringify","json","catch"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SACEA,eAAe,QAEV,2CAA0C;AACjD,SAASC,OAAO,QAAQ,iCAAgC;AACxD,SAASC,WAAW,EAAEC,MAAM,QAAQ,QAAO;AAC3C,SAASC,cAAc,QAAQ,gCAA4B;AAwB3D;;;;;;;;;;;;;;;CAeC,GACD;;;;;;;;;;CAUC,GACD,OAAO,SAASC,UACd;;;;;;GAMC,GACDC,MAAqB;;QAKPF;IAHd,MAAM,EAAEG,MAAMC,IAAI,EAAE,GAAGP;IACvB,MAAMQ,UAAUN,OAAOK;IACvBC,QAAQC,OAAO,GAAGF;IAClB,MAAMG,iBAAQP,kBAAAA,qCAAAA,gBAAkBO,KAAK,mBAAI;IACzC,OAAOT,YACL,OACEU,OACAC;QAEA,MAAMC,WAAW,MAAMd,gBACrBS,QAAQC,OAAO,EACf,CAAC,SAAS,EAAEE,OAAO,EACnB;YACEG,QAAQ;YACRC,SAAS;gBAAE,gBAAgB;YAAmB;YAC9CC,MAAMC,KAAKC,SAAS,CAAC;gBACnBb;eACIK,QAAQ;gBAAEA;YAAM,IAAI,CAAC,GACtBE;QAEP;QAEF,MAAMO,OAAO,MAAMN,SAASM,IAAI,GAAGC,KAAK,CAAC,IAAO,CAAA,CAAC,CAAA;QACjD,OAAO;YAAEP;YAAUD,SAASO;QAA4B;IAC1D,GACA;QAACd;QAAQK;KAAM;AAEnB;AAEA,eAAeN,UAAS"}
@@ -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 CrmLeadStatus } from '@aglyn/aglyn/app-utils/crm';
17
+ import { type CrmLeadProfile, type CrmLeadStatus } 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;
@@ -32,8 +32,17 @@ export declare const LEAD_IMPORT_REASON_MAX = 500;
32
32
  * contact it produced.
33
33
  */
34
34
  export declare const LEAD_IMPORT_STATUSES: readonly CrmLeadStatus[];
35
- /** The fields a column may be mapped to, in the order the mapping menu lists them. */
36
- export declare const LEAD_IMPORT_FIELDS: readonly ["email", "name", "status", "ownerEmail", "unqualifiedReason", "notes"];
35
+ /**
36
+ * The fields a column may be mapped to, in the order the mapping menu
37
+ * lists them.
38
+ *
39
+ * The lead's own profile (AGL-3231) — company, title, phone, website, the
40
+ * lead source, the address parts and tags — is what a list from another
41
+ * tool actually carries, and the reason a lead can be worked without a
42
+ * contact beside it. They read under the same labels the contacts import
43
+ * uses, so one spreadsheet maps the same way into either.
44
+ */
45
+ export declare const LEAD_IMPORT_FIELDS: readonly ["email", "name", "company", "jobTitle", "phone", "website", "leadSource", "status", "ownerEmail", "addressLine1", "addressLine2", "addressCity", "addressState", "addressPostalCode", "addressCountry", "tags", "unqualifiedReason", "notes"];
37
46
  export type LeadImportField = (typeof LEAD_IMPORT_FIELDS)[number];
38
47
  /** How each field reads in the mapping menu. Typed so a field cannot ship unlabeled. */
39
48
  export declare const LEAD_IMPORT_FIELD_LABELS: Record<LeadImportField, string>;
@@ -57,6 +66,13 @@ export interface LeadImportRow {
57
66
  /** Normalized — the address `personKey` derives the document id from. */
58
67
  email: string;
59
68
  name?: string;
69
+ /**
70
+ * The lead's own profile, already normalized the way the record stores
71
+ * it (AGL-3231): a key present is a value to write, a key absent leaves
72
+ * an existing lead's value alone. Never a clear — a blank cell in a file
73
+ * is a cell nobody filled, not a decision to erase what the site knows.
74
+ */
75
+ profile: CrmLeadProfile;
60
76
  /** Absent leaves an existing lead's status alone and a new one reading as `new`. */
61
77
  status?: CrmLeadStatus;
62
78
  /** Normalized, for the server to resolve against the org's members. */
@@ -65,7 +65,8 @@ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_wi
65
65
  * {@link LeadImportRow.dropped}, so the operator learns that a column went
66
66
  * nowhere rather than discovering it weeks later.
67
67
  */ import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
68
- import { CRM_LEAD_STATUS_LABELS, CRM_LEAD_STATUSES, isCrmLeadStatus } from "@aglyn/aglyn/app-utils/crm";
68
+ import { CRM_LEAD_STATUS_LABELS, CRM_LEAD_STATUSES, CRM_LEAD_TEXT_MAX, isCrmLeadStatus, normalizeCrmLeadTags, normalizeCompanyWebsite } from "@aglyn/aglyn/app-utils/crm";
69
+ import { normalizeAddress, normalizePhone } from "@aglyn/aglyn/foundation/definitions/contact.types";
69
70
  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";
70
71
  /** The shared ceilings, under this collection's names. */ export const LEAD_IMPORT_MAX_ROWS = CSV_IMPORT_MAX_ROWS;
71
72
  export const LEAD_IMPORT_CHUNK_SIZE = CSV_IMPORT_CHUNK_SIZE;
@@ -81,19 +82,52 @@ export const LEAD_IMPORT_REASON_MAX = 500;
81
82
  * reason given above: `qualified` is written by the conversion, beside the
82
83
  * contact it produced.
83
84
  */ export const LEAD_IMPORT_STATUSES = CRM_LEAD_STATUSES.filter((status)=>status !== 'qualified');
84
- /** The fields a column may be mapped to, in the order the mapping menu lists them. */ export const LEAD_IMPORT_FIELDS = [
85
+ /**
86
+ * The fields a column may be mapped to, in the order the mapping menu
87
+ * lists them.
88
+ *
89
+ * The lead's own profile (AGL-3231) — company, title, phone, website, the
90
+ * lead source, the address parts and tags — is what a list from another
91
+ * tool actually carries, and the reason a lead can be worked without a
92
+ * contact beside it. They read under the same labels the contacts import
93
+ * uses, so one spreadsheet maps the same way into either.
94
+ */ export const LEAD_IMPORT_FIELDS = [
85
95
  'email',
86
96
  'name',
97
+ 'company',
98
+ 'jobTitle',
99
+ 'phone',
100
+ 'website',
101
+ 'leadSource',
87
102
  'status',
88
103
  'ownerEmail',
104
+ 'addressLine1',
105
+ 'addressLine2',
106
+ 'addressCity',
107
+ 'addressState',
108
+ 'addressPostalCode',
109
+ 'addressCountry',
110
+ 'tags',
89
111
  'unqualifiedReason',
90
112
  'notes'
91
113
  ];
92
114
  /** How each field reads in the mapping menu. Typed so a field cannot ship unlabeled. */ export const LEAD_IMPORT_FIELD_LABELS = {
93
115
  email: 'Email (required)',
94
116
  name: 'Name',
117
+ company: 'Company name',
118
+ jobTitle: 'Job title',
119
+ phone: 'Phone',
120
+ website: 'Website',
121
+ leadSource: 'Lead source',
95
122
  status: 'Status (new, working, unqualified)',
96
123
  ownerEmail: 'Owner (team member email)',
124
+ addressLine1: 'Address line 1',
125
+ addressLine2: 'Address line 2',
126
+ addressCity: 'City',
127
+ addressState: 'State or region',
128
+ addressPostalCode: 'Postal code',
129
+ addressCountry: 'Country (two-letter code)',
130
+ tags: 'Tags (comma or | separated)',
97
131
  unqualifiedReason: 'Unqualified reason',
98
132
  notes: 'Notes'
99
133
  };
@@ -122,12 +156,51 @@ export const LEAD_IMPORT_REASON_MAX = 500;
122
156
  'contact name',
123
157
  'person'
124
158
  ],
159
+ company: [
160
+ 'company',
161
+ 'company name',
162
+ 'organization',
163
+ 'organisation',
164
+ 'account',
165
+ 'account name',
166
+ 'employer'
167
+ ],
168
+ jobTitle: [
169
+ 'job title',
170
+ 'title',
171
+ 'position',
172
+ 'role',
173
+ 'headline'
174
+ ],
175
+ phone: [
176
+ 'phone',
177
+ 'phone number',
178
+ 'mobile',
179
+ 'mobile phone',
180
+ 'telephone',
181
+ 'tel',
182
+ 'cell'
183
+ ],
184
+ website: [
185
+ 'website',
186
+ 'web site',
187
+ 'url',
188
+ 'company website',
189
+ 'company domain',
190
+ 'domain'
191
+ ],
192
+ leadSource: [
193
+ 'lead source',
194
+ 'source',
195
+ 'origin',
196
+ 'channel',
197
+ 'campaign'
198
+ ],
125
199
  status: [
126
200
  'status',
127
201
  'lead status',
128
202
  'stage',
129
- 'lead stage',
130
- 'state'
203
+ 'lead stage'
131
204
  ],
132
205
  ownerEmail: [
133
206
  'owner',
@@ -137,6 +210,47 @@ export const LEAD_IMPORT_REASON_MAX = 500;
137
210
  'rep',
138
211
  'sales rep'
139
212
  ],
213
+ addressLine1: [
214
+ 'address',
215
+ 'address line 1',
216
+ 'street',
217
+ 'street address',
218
+ 'address 1'
219
+ ],
220
+ addressLine2: [
221
+ 'address line 2',
222
+ 'address 2',
223
+ 'suite',
224
+ 'apartment'
225
+ ],
226
+ addressCity: [
227
+ 'city',
228
+ 'town',
229
+ 'locality'
230
+ ],
231
+ addressState: [
232
+ 'state',
233
+ 'state or region',
234
+ 'region',
235
+ 'province',
236
+ 'county'
237
+ ],
238
+ addressPostalCode: [
239
+ 'postal code',
240
+ 'postcode',
241
+ 'zip',
242
+ 'zip code'
243
+ ],
244
+ addressCountry: [
245
+ 'country',
246
+ 'country code'
247
+ ],
248
+ tags: [
249
+ 'tags',
250
+ 'tag',
251
+ 'labels',
252
+ 'lists'
253
+ ],
140
254
  unqualifiedReason: [
141
255
  'unqualified reason',
142
256
  'reason',
@@ -188,8 +302,8 @@ const FIELD_ALIAS_KEYS = importAliasKeys(LEAD_IMPORT_FIELDS, FIELD_ALIASES);
188
302
  * the document id; the owner is refused by the server, which alone can
189
303
  * look them up.
190
304
  */ export function normalizeLeadImportRow(raw) {
191
- var _importTextValue;
192
- var _importTextValue1;
305
+ var _importTextValue, _raw_tags;
306
+ var _importTextValue1, _importTextValue2, _importTextValue3, _importTextValue4;
193
307
  const emailText = (_importTextValue = importTextValue(raw.email, 320)) != null ? _importTextValue : '';
194
308
  const email = normalizeContactEmail(emailText);
195
309
  if (!email) {
@@ -208,10 +322,49 @@ const FIELD_ALIAS_KEYS = importAliasKeys(LEAD_IMPORT_FIELDS, FIELD_ALIASES);
208
322
  };
209
323
  const row = {
210
324
  email,
325
+ profile: {},
211
326
  dropped
212
327
  };
213
328
  const name = (_importTextValue1 = importTextValue(raw.name, NAME_MAX)) == null ? void 0 : _importTextValue1.replace(/\s+/g, ' ');
214
329
  if (name) row.name = name;
330
+ /*
331
+ * The profile (AGL-3231), field by field, each through the normalizer
332
+ * the record's own card runs. A phone or a website the normalizer cannot
333
+ * read is dropped and reported rather than stored as typed, because the
334
+ * record renders both as links; the country is the one address part the
335
+ * address normalizer drops silently — a typed name is not a code — so it
336
+ * is the one part the report has to name.
337
+ */ const company = (_importTextValue2 = importTextValue(raw.company, CRM_LEAD_TEXT_MAX)) == null ? void 0 : _importTextValue2.replace(/\s+/g, ' ');
338
+ if (company) row.profile.company = company;
339
+ const jobTitle = (_importTextValue3 = importTextValue(raw.jobTitle, CRM_LEAD_TEXT_MAX)) == null ? void 0 : _importTextValue3.replace(/\s+/g, ' ');
340
+ if (jobTitle) row.profile.jobTitle = jobTitle;
341
+ const leadSource = (_importTextValue4 = importTextValue(raw.leadSource, CRM_LEAD_TEXT_MAX)) == null ? void 0 : _importTextValue4.replace(/\s+/g, ' ');
342
+ if (leadSource) row.profile.leadSource = leadSource;
343
+ const phoneText = importTextValue(raw.phone, 64);
344
+ if (phoneText) {
345
+ const phone = normalizePhone(phoneText);
346
+ if (phone) row.profile.phone = phone;
347
+ else drop('phone', phoneText);
348
+ }
349
+ const websiteText = importTextValue(raw.website, 320);
350
+ if (websiteText) {
351
+ const website = normalizeCompanyWebsite(websiteText);
352
+ if (website) row.profile.website = website;
353
+ else drop('website', websiteText);
354
+ }
355
+ const address = normalizeAddress({
356
+ line1: importTextValue(raw.addressLine1, 200),
357
+ line2: importTextValue(raw.addressLine2, 200),
358
+ city: importTextValue(raw.addressCity, 120),
359
+ state: importTextValue(raw.addressState, 120),
360
+ postalCode: importTextValue(raw.addressPostalCode, 32),
361
+ country: importTextValue(raw.addressCountry, 8)
362
+ });
363
+ if (address) row.profile.address = address;
364
+ const countryText = importTextValue(raw.addressCountry, 64);
365
+ if (countryText && !(address == null ? void 0 : address.country)) drop('addressCountry', countryText);
366
+ const tags = normalizeCrmLeadTags(String((_raw_tags = raw.tags) != null ? _raw_tags : '').split(/[,|]/).map((tag)=>tag.trim()));
367
+ if (tags.length) row.profile.tags = tags;
215
368
  const statusText = importTextValue(raw.status, 32);
216
369
  if (statusText) {
217
370
  const status = parseImportLeadStatus(statusText);
@@ -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 { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport {\n CRM_LEAD_STATUS_LABELS,\n CRM_LEAD_STATUSES,\n type CrmLeadStatus,\n isCrmLeadStatus,\n} from '@aglyn/aglyn/app-utils/crm'\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/** The fields a column may be mapped to, in the order the mapping menu lists them. */\nexport const LEAD_IMPORT_FIELDS = [\n 'email',\n 'name',\n 'status',\n 'ownerEmail',\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 status: 'Status (new, working, unqualified)',\n ownerEmail: 'Owner (team member email)',\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 status: ['status', 'lead status', 'stage', 'lead stage', 'state'],\n ownerEmail: ['owner', 'owner email', 'assigned to', 'assignee', 'rep', 'sales rep'],\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 | '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 'write-failed': 'Could not be saved',\n}\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 /** 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, dropped }\n\n const name = importTextValue(raw.name, NAME_MAX)?.replace(/\\s+/g, ' ')\n if (name) row.name = name\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":["normalizeContactEmail","CRM_LEAD_STATUS_LABELS","CRM_LEAD_STATUSES","isCrmLeadStatus","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","ownerEmail","unqualifiedReason","notes","FIELD_ALIASES","FIELD_ALIAS_KEYS","guessLeadImportMapping","columns","mapLeadImportRow","cells","mapping","custom","_custom","row","LEAD_IMPORT_SKIP_LABELS","duplicate","parseImportLeadStatus","value","text","String","trim","toLowerCase","byId","byLabel","find","includes","normalizeLeadImportRow","raw","emailText","ok","reason","input","dropped","drop","field","push","replace","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 { 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 '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 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 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 | '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 'write-failed': 'Could not be saved',\n}\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 /** 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 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":["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","unqualifiedReason","notes","FIELD_ALIASES","FIELD_ALIAS_KEYS","guessLeadImportMapping","columns","mapLeadImportRow","cells","mapping","custom","_custom","row","LEAD_IMPORT_SKIP_LABELS","duplicate","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","statusText","ownerText","owner","reasonText","emptyLeadImportResult","mergeLeadImportResults","total","chunk","offset","leadImportSkippedCsv","entries"],"mappings":"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"}
@@ -32,8 +32,15 @@ export type CompanySuggestion =
32
32
  domain: string;
33
33
  };
34
34
  /**
35
- * The company a lead's address implies, matched against the companies the
36
- * caller can already see (AGL-2608).
35
+ * The company a lead names, or its address implies, matched against the
36
+ * companies the caller can already see (AGL-2608, AGL-3233).
37
+ *
38
+ * The lead's own `company` text comes first: it is the account the lead
39
+ * carries, the way a Salesforce lead does, and a company already filed
40
+ * under that name — whatever its domain — is the one to link. Failing a
41
+ * name match, the domain. Failing both, a new company: named as the lead
42
+ * names it, with the domain beside it when the address has one, or named
43
+ * after the domain when the lead names nothing.
37
44
  *
38
45
  * The email domain is the one fact about a company a capture carries, and
39
46
  * `companyDomainForEmail` already refuses the public mailboxes, so a lead at
@@ -52,7 +59,15 @@ export type CompanySuggestion =
52
59
  export declare function suggestCompanyForLead(email: unknown, companies: ReadonlyArray<{
53
60
  $id: string;
54
61
  domain?: unknown;
55
- }>): CompanySuggestion;
62
+ name?: unknown;
63
+ }>,
64
+ /**
65
+ * The company the lead names as text (AGL-3233) — what a Salesforce lead
66
+ * carries and what the convert step turns into the account. It outranks
67
+ * the domain: a name the converter typed or imported is a fact about the
68
+ * business, a domain is a guess from the address.
69
+ */
70
+ companyText?: unknown): CompanySuggestion;
56
71
  /**
57
72
  * A typed dollar amount as cents, `null` for an empty field, and `undefined`
58
73
  * for something that is not an amount — the three answers a form has to tell
@@ -15,8 +15,15 @@
15
15
  * limitations under the License.
16
16
  */ import { companyDomainForEmail, companyNameForDomain } from "@aglyn/aglyn";
17
17
  /**
18
- * The company a lead's address implies, matched against the companies the
19
- * caller can already see (AGL-2608).
18
+ * The company a lead names, or its address implies, matched against the
19
+ * companies the caller can already see (AGL-2608, AGL-3233).
20
+ *
21
+ * The lead's own `company` text comes first: it is the account the lead
22
+ * carries, the way a Salesforce lead does, and a company already filed
23
+ * under that name — whatever its domain — is the one to link. Failing a
24
+ * name match, the domain. Failing both, a new company: named as the lead
25
+ * names it, with the domain beside it when the address has one, or named
26
+ * after the domain when the lead names nothing.
20
27
  *
21
28
  * The email domain is the one fact about a company a capture carries, and
22
29
  * `companyDomainForEmail` already refuses the public mailboxes, so a lead at
@@ -31,19 +38,42 @@
31
38
  * by a capture at the same domain start out called the same thing. A
32
39
  * starting point the converter edits, not a claim about what the business
33
40
  * is called.
34
- */ export function suggestCompanyForLead(email, companies) {
41
+ */ export function suggestCompanyForLead(email, companies, /**
42
+ * The company the lead names as text (AGL-3233) — what a Salesforce lead
43
+ * carries and what the convert step turns into the account. It outranks
44
+ * the domain: a name the converter typed or imported is a fact about the
45
+ * business, a domain is a guess from the address.
46
+ */ companyText) {
47
+ const name = String(companyText != null ? companyText : '').trim().replace(/\s+/g, ' ');
35
48
  const domain = companyDomainForEmail(email);
49
+ if (name) {
50
+ const byName = companies.find((company)=>{
51
+ var _company_name;
52
+ return String((_company_name = company.name) != null ? _company_name : '').trim().toLowerCase() === name.toLowerCase();
53
+ });
54
+ if (byName) return {
55
+ mode: 'existing',
56
+ companyId: byName.$id
57
+ };
58
+ }
59
+ if (domain) {
60
+ const byDomain = companies.find((company)=>{
61
+ var _company_domain;
62
+ return String((_company_domain = company.domain) != null ? _company_domain : '').toLowerCase() === domain;
63
+ });
64
+ if (byDomain) return {
65
+ mode: 'existing',
66
+ companyId: byDomain.$id
67
+ };
68
+ }
69
+ if (name) return {
70
+ mode: 'new',
71
+ name,
72
+ domain: domain != null ? domain : ''
73
+ };
36
74
  if (!domain) return {
37
75
  mode: 'none'
38
76
  };
39
- const existing = companies.find((company)=>{
40
- var _company_domain;
41
- return String((_company_domain = company.domain) != null ? _company_domain : '').toLowerCase() === domain;
42
- });
43
- if (existing) return {
44
- mode: 'existing',
45
- companyId: existing.$id
46
- };
47
77
  return {
48
78
  mode: 'new',
49
79
  name: companyNameForDomain(domain),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/lead-company-suggestion.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 { companyDomainForEmail, companyNameForDomain } from '@aglyn/aglyn'\n\n/** What the convert dialog should propose for the company step. */\nexport type CompanySuggestion =\n /** A public mailbox or a malformed address implies no company. */\n | { mode: 'none' }\n /** The org already has a company at this domain the caller can see. */\n | { mode: 'existing'; companyId: string }\n /** No company at this domain yet — propose creating one. */\n | { mode: 'new'; name: string; domain: string }\n\n/**\n * The company a lead's address implies, matched against the companies the\n * caller can already see (AGL-2608).\n *\n * The email domain is the one fact about a company a capture carries, and\n * `companyDomainForEmail` already refuses the public mailboxes, so a lead at\n * `gmail.com` proposes nothing rather than a phantom account. A match on a\n * loaded company wins over creating one: two contacts at one business should\n * land under one company, and the dialog's job is to make that the default\n * click rather than a lookup the converter has to remember to do.\n *\n * The proposed name is `companyNameForDomain`'s — the domain's first label\n * with a capital, `acme.com` → `Acme` — the same name the capture door gives\n * a company it mints on its own, so a company proposed here and one created\n * by a capture at the same domain start out called the same thing. A\n * starting point the converter edits, not a claim about what the business\n * is called.\n */\nexport function suggestCompanyForLead(\n email: unknown,\n companies: ReadonlyArray<{ $id: string; domain?: unknown }>,\n): CompanySuggestion {\n const domain = companyDomainForEmail(email)\n if (!domain) return { mode: 'none' }\n const existing = companies.find(\n (company) => String(company.domain ?? '').toLowerCase() === domain,\n )\n if (existing) return { mode: 'existing', companyId: existing.$id }\n return { mode: 'new', name: companyNameForDomain(domain), domain }\n}\n\n/**\n * A typed dollar amount as cents, `null` for an empty field, and `undefined`\n * for something that is not an amount — the three answers a form has to tell\n * apart: nothing entered, a number entered, and a mistake to point at.\n */\nexport function dollarsToCents(input: string): number | null | undefined {\n const cleaned = input.replace(/[,$\\s]/g, '')\n if (!cleaned) return null\n const dollars = Number(cleaned)\n if (!Number.isFinite(dollars) || dollars < 0) return undefined\n return Math.round(dollars * 100)\n}\n"],"names":["companyDomainForEmail","companyNameForDomain","suggestCompanyForLead","email","companies","domain","mode","existing","find","company","String","toLowerCase","companyId","$id","name","dollarsToCents","input","cleaned","replace","dollars","Number","isFinite","undefined","Math","round"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,qBAAqB,EAAEC,oBAAoB,QAAQ,eAAc;AAW1E;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASC,sBACdC,KAAc,EACdC,SAA2D;IAE3D,MAAMC,SAASL,sBAAsBG;IACrC,IAAI,CAACE,QAAQ,OAAO;QAAEC,MAAM;IAAO;IACnC,MAAMC,WAAWH,UAAUI,IAAI,CAC7B,CAACC;YAAmBA;eAAPC,QAAOD,kBAAAA,QAAQJ,MAAM,YAAdI,kBAAkB,IAAIE,WAAW,OAAON;;IAE9D,IAAIE,UAAU,OAAO;QAAED,MAAM;QAAYM,WAAWL,SAASM,GAAG;IAAC;IACjE,OAAO;QAAEP,MAAM;QAAOQ,MAAMb,qBAAqBI;QAASA;IAAO;AACnE;AAEA;;;;CAIC,GACD,OAAO,SAASU,eAAeC,KAAa;IAC1C,MAAMC,UAAUD,MAAME,OAAO,CAAC,WAAW;IACzC,IAAI,CAACD,SAAS,OAAO;IACrB,MAAME,UAAUC,OAAOH;IACvB,IAAI,CAACG,OAAOC,QAAQ,CAACF,YAAYA,UAAU,GAAG,OAAOG;IACrD,OAAOC,KAAKC,KAAK,CAACL,UAAU;AAC9B"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/lead-company-suggestion.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 { companyDomainForEmail, companyNameForDomain } from '@aglyn/aglyn'\n\n/** What the convert dialog should propose for the company step. */\nexport type CompanySuggestion =\n /** A public mailbox or a malformed address implies no company. */\n | { mode: 'none' }\n /** The org already has a company at this domain the caller can see. */\n | { mode: 'existing'; companyId: string }\n /** No company at this domain yet — propose creating one. */\n | { mode: 'new'; name: string; domain: string }\n\n/**\n * The company a lead names, or its address implies, matched against the\n * companies the caller can already see (AGL-2608, AGL-3233).\n *\n * The lead's own `company` text comes first: it is the account the lead\n * carries, the way a Salesforce lead does, and a company already filed\n * under that name — whatever its domain — is the one to link. Failing a\n * name match, the domain. Failing both, a new company: named as the lead\n * names it, with the domain beside it when the address has one, or named\n * after the domain when the lead names nothing.\n *\n * The email domain is the one fact about a company a capture carries, and\n * `companyDomainForEmail` already refuses the public mailboxes, so a lead at\n * `gmail.com` proposes nothing rather than a phantom account. A match on a\n * loaded company wins over creating one: two contacts at one business should\n * land under one company, and the dialog's job is to make that the default\n * click rather than a lookup the converter has to remember to do.\n *\n * The proposed name is `companyNameForDomain`'s — the domain's first label\n * with a capital, `acme.com` → `Acme` — the same name the capture door gives\n * a company it mints on its own, so a company proposed here and one created\n * by a capture at the same domain start out called the same thing. A\n * starting point the converter edits, not a claim about what the business\n * is called.\n */\nexport function suggestCompanyForLead(\n email: unknown,\n companies: ReadonlyArray<{ $id: string; domain?: unknown; name?: unknown }>,\n /**\n * The company the lead names as text (AGL-3233) — what a Salesforce lead\n * carries and what the convert step turns into the account. It outranks\n * the domain: a name the converter typed or imported is a fact about the\n * business, a domain is a guess from the address.\n */\n companyText?: unknown,\n): CompanySuggestion {\n const name = String(companyText ?? '')\n .trim()\n .replace(/\\s+/g, ' ')\n const domain = companyDomainForEmail(email)\n if (name) {\n const byName = companies.find(\n (company) => String(company.name ?? '').trim().toLowerCase() === name.toLowerCase(),\n )\n if (byName) return { mode: 'existing', companyId: byName.$id }\n }\n if (domain) {\n const byDomain = companies.find(\n (company) => String(company.domain ?? '').toLowerCase() === domain,\n )\n if (byDomain) return { mode: 'existing', companyId: byDomain.$id }\n }\n if (name) return { mode: 'new', name, domain: domain ?? '' }\n if (!domain) return { mode: 'none' }\n return { mode: 'new', name: companyNameForDomain(domain), domain }\n}\n\n/**\n * A typed dollar amount as cents, `null` for an empty field, and `undefined`\n * for something that is not an amount — the three answers a form has to tell\n * apart: nothing entered, a number entered, and a mistake to point at.\n */\nexport function dollarsToCents(input: string): number | null | undefined {\n const cleaned = input.replace(/[,$\\s]/g, '')\n if (!cleaned) return null\n const dollars = Number(cleaned)\n if (!Number.isFinite(dollars) || dollars < 0) return undefined\n return Math.round(dollars * 100)\n}\n"],"names":["companyDomainForEmail","companyNameForDomain","suggestCompanyForLead","email","companies","companyText","name","String","trim","replace","domain","byName","find","company","toLowerCase","mode","companyId","$id","byDomain","dollarsToCents","input","cleaned","dollars","Number","isFinite","undefined","Math","round"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,qBAAqB,EAAEC,oBAAoB,QAAQ,eAAc;AAW1E;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GACD,OAAO,SAASC,sBACdC,KAAc,EACdC,SAA2E,EAC3E;;;;;GAKC,GACDC,WAAqB;IAErB,MAAMC,OAAOC,OAAOF,sBAAAA,cAAe,IAChCG,IAAI,GACJC,OAAO,CAAC,QAAQ;IACnB,MAAMC,SAASV,sBAAsBG;IACrC,IAAIG,MAAM;QACR,MAAMK,SAASP,UAAUQ,IAAI,CAC3B,CAACC;gBAAmBA;mBAAPN,QAAOM,gBAAAA,QAAQP,IAAI,YAAZO,gBAAgB,IAAIL,IAAI,GAAGM,WAAW,OAAOR,KAAKQ,WAAW;;QAEnF,IAAIH,QAAQ,OAAO;YAAEI,MAAM;YAAYC,WAAWL,OAAOM,GAAG;QAAC;IAC/D;IACA,IAAIP,QAAQ;QACV,MAAMQ,WAAWd,UAAUQ,IAAI,CAC7B,CAACC;gBAAmBA;mBAAPN,QAAOM,kBAAAA,QAAQH,MAAM,YAAdG,kBAAkB,IAAIC,WAAW,OAAOJ;;QAE9D,IAAIQ,UAAU,OAAO;YAAEH,MAAM;YAAYC,WAAWE,SAASD,GAAG;QAAC;IACnE;IACA,IAAIX,MAAM,OAAO;QAAES,MAAM;QAAOT;QAAMI,MAAM,EAAEA,iBAAAA,SAAU;IAAG;IAC3D,IAAI,CAACA,QAAQ,OAAO;QAAEK,MAAM;IAAO;IACnC,OAAO;QAAEA,MAAM;QAAOT,MAAML,qBAAqBS;QAASA;IAAO;AACnE;AAEA;;;;CAIC,GACD,OAAO,SAASS,eAAeC,KAAa;IAC1C,MAAMC,UAAUD,MAAMX,OAAO,CAAC,WAAW;IACzC,IAAI,CAACY,SAAS,OAAO;IACrB,MAAMC,UAAUC,OAAOF;IACvB,IAAI,CAACE,OAAOC,QAAQ,CAACF,YAAYA,UAAU,GAAG,OAAOG;IACrD,OAAOC,KAAKC,KAAK,CAACL,UAAU;AAC9B"}