@aglyn/plugins-crm 1.0.0-beta.161 → 1.0.0-beta.163
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 +5 -0
- package/src/lib/components/contact-detail-page.js.map +1 -1
- package/src/lib/components/contact-list-columns.js +24 -2
- package/src/lib/components/contact-list-columns.js.map +1 -1
- package/src/lib/components/contacts-section.js +5 -0
- package/src/lib/components/contacts-section.js.map +1 -1
- package/src/lib/components/crm-email-state-chip.d.ts +17 -0
- package/src/lib/components/crm-email-state-chip.js +61 -0
- package/src/lib/components/crm-email-state-chip.js.map +1 -0
- package/src/lib/components/crm-send-email-button.d.ts +7 -1
- package/src/lib/components/crm-send-email-button.js +7 -3
- package/src/lib/components/crm-send-email-button.js.map +1 -1
- package/src/lib/components/lead-properties-card.js +8 -1
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/leads-bulk-bar.js +51 -5
- package/src/lib/components/leads-bulk-bar.js.map +1 -1
- package/src/lib/components/leads-section.js +179 -11
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/new-lead-drawer.d.ts +2 -0
- package/src/lib/components/new-lead-drawer.js +27 -1
- package/src/lib/components/new-lead-drawer.js.map +1 -1
- package/src/lib/constants/contact-filters.js +19 -1
- package/src/lib/constants/contact-filters.js.map +1 -1
- package/src/lib/declarations.server.js +15 -0
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/model/contact-record.d.ts +6 -0
- package/src/lib/model/contact-record.js +1 -0
- package/src/lib/model/contact-record.js.map +1 -1
- package/src/lib/model/crm-lead-import.d.ts +11 -2
- package/src/lib/model/crm-lead-import.js +22 -2
- package/src/lib/model/crm-lead-import.js.map +1 -1
- package/src/lib/model/lead-filters.d.ts +21 -1
- package/src/lib/model/lead-filters.js +30 -1
- package/src/lib/model/lead-filters.js.map +1 -1
- package/src/lib/server/email-send.js +10 -1
- package/src/lib/server/email-send.js.map +1 -1
- package/src/lib/server/lead-campaign-carry.d.ts +50 -0
- package/src/lib/server/lead-campaign-carry.js +75 -0
- package/src/lib/server/lead-campaign-carry.js.map +1 -0
- package/src/lib/server/lead-create.d.ts +13 -0
- package/src/lib/server/lead-create.js +30 -0
- package/src/lib/server/lead-create.js.map +1 -1
- package/src/lib/server/leads-import.js +50 -5
- package/src/lib/server/leads-import.js.map +1 -1
- package/src/lib/server/record-email-state.d.ts +52 -0
- package/src/lib/server/record-email-state.js +118 -0
- package/src/lib/server/record-email-state.js.map +1 -0
- package/src/lib/server/record-timeline.d.ts +5 -1
- package/src/lib/server/record-timeline.js +26 -1
- package/src/lib/server/record-timeline.js.map +1 -1
- package/src/lib/server.js +5 -0
- package/src/lib/server.js.map +1 -1
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
-
import { CRM_LEAD_STATUS_LABELS, CRM_LEAD_TEXT_MAX, normalizeCrmLeadTags, normalizeContactEmail, normalizeCrmLeadProfile } from "@aglyn/aglyn";
|
|
3
|
+
import { CRM_LEAD_STATUS_LABELS, CRM_LEAD_TEXT_MAX, campaignMembershipValue, normalizeCrmLeadTags, normalizeContactEmail, normalizeCrmLeadProfile } from "@aglyn/aglyn";
|
|
4
4
|
import { ICON_VARIANT_CLOSE } from "@aglyn/shared-data-enums";
|
|
5
5
|
import { Container, MdiIcon, SrOnly } from "@aglyn/shared-ui-jsx";
|
|
6
6
|
import { NavigationDrawerComponent } from "@aglyn/shared-ui-jsx/components/navigation-drawer.component";
|
|
7
7
|
import { Alert, Button, IconButton, MenuItem, Stack, TextField, Typography } from "@mui/material";
|
|
8
|
+
import CampaignPicker from "@aglyn/shared-ui-email-campaigns/components/campaign-picker.component";
|
|
9
|
+
import { useHostCampaigns } from "@aglyn/tenant-feature-instance";
|
|
8
10
|
import { useEffect, useState } from "react";
|
|
9
11
|
import { useCrmScope } from "../hooks/use-crm-scope.js";
|
|
10
12
|
import { ContactAddressFields, EMPTY_ADDRESS } from "./contact-address-fields.js";
|
|
@@ -47,11 +49,20 @@ const NOTES_MAX = 4000;
|
|
|
47
49
|
const [status, setStatus] = useState('new');
|
|
48
50
|
const [ownerUid, setOwnerUid] = useState('');
|
|
49
51
|
const [tags, setTags] = useState('');
|
|
52
|
+
const [campaignIds, setCampaignIds] = useState([]);
|
|
50
53
|
const [address, setAddress] = useState(EMPTY_ADDRESS);
|
|
51
54
|
const [notes, setNotes] = useState('');
|
|
52
55
|
const [emailError, setEmailError] = useState('');
|
|
53
56
|
const [phoneError, setPhoneError] = useState('');
|
|
54
57
|
const [websiteError, setWebsiteError] = useState('');
|
|
58
|
+
/*
|
|
59
|
+
* The site's campaigns, for the picker (AGL-3254): read while the drawer
|
|
60
|
+
* is open, under the site the lead will be filed under — at the
|
|
61
|
+
* organization level, the picked one — and re-read when that changes,
|
|
62
|
+
* since a campaign belongs to one site.
|
|
63
|
+
*/ const campaigns = useHostCampaigns(createHostId != null ? createHostId : undefined, {
|
|
64
|
+
enabled: open && Boolean(createHostId)
|
|
65
|
+
});
|
|
55
66
|
// A fresh form on every opening: a person typed and then abandoned must
|
|
56
67
|
// not reappear half-filled the next time somebody reaches for New lead.
|
|
57
68
|
useEffect(()=>{
|
|
@@ -66,6 +77,7 @@ const NOTES_MAX = 4000;
|
|
|
66
77
|
setStatus('new');
|
|
67
78
|
setOwnerUid('');
|
|
68
79
|
setTags('');
|
|
80
|
+
setCampaignIds([]);
|
|
69
81
|
setAddress(EMPTY_ADDRESS);
|
|
70
82
|
setNotes('');
|
|
71
83
|
setEmailError('');
|
|
@@ -74,6 +86,10 @@ const NOTES_MAX = 4000;
|
|
|
74
86
|
}, [
|
|
75
87
|
open
|
|
76
88
|
]);
|
|
89
|
+
// A campaign picked under one site is not one of another site's.
|
|
90
|
+
useEffect(()=>setCampaignIds([]), [
|
|
91
|
+
createHostId
|
|
92
|
+
]);
|
|
77
93
|
const handleSubmit = ()=>{
|
|
78
94
|
var _errors_phone, _errors_website, _patch_company, _patch_jobTitle, _patch_phone, _patch_website, _patch_leadSource, _patch_tags, _patch_address;
|
|
79
95
|
const normalizedEmail = normalizeContactEmail(email);
|
|
@@ -101,6 +117,7 @@ const NOTES_MAX = 4000;
|
|
|
101
117
|
status,
|
|
102
118
|
ownerUid,
|
|
103
119
|
tags: (_patch_tags = patch.tags) != null ? _patch_tags : [],
|
|
120
|
+
campaignIds: campaignMembershipValue(campaignIds),
|
|
104
121
|
address: (_patch_address = patch.address) != null ? _patch_address : null,
|
|
105
122
|
notes: notes.trim().slice(0, NOTES_MAX)
|
|
106
123
|
});
|
|
@@ -287,6 +304,15 @@ const NOTES_MAX = 4000;
|
|
|
287
304
|
onChange: (event)=>setTags(event.target.value),
|
|
288
305
|
fullWidth: true
|
|
289
306
|
}),
|
|
307
|
+
createHostId ? /*#__PURE__*/ _jsx(CampaignPicker, {
|
|
308
|
+
options: campaigns.options,
|
|
309
|
+
value: campaignIds,
|
|
310
|
+
onChange: setCampaignIds,
|
|
311
|
+
helperText: "The campaigns this lead is part of. It does not decide who a campaign mails.",
|
|
312
|
+
disabled: Boolean(busy),
|
|
313
|
+
empty: campaigns.ready && !campaigns.options.length,
|
|
314
|
+
emptyText: "This site has no campaigns yet. Create one from Marketing to file leads under it."
|
|
315
|
+
}) : null,
|
|
290
316
|
/*#__PURE__*/ _jsx(Typography, {
|
|
291
317
|
variant: "subtitle2",
|
|
292
318
|
children: 'Address'
|
|
@@ -1 +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
|
+
{"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 campaignMembershipValue,\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 CampaignPicker from '@aglyn/shared-ui-email-campaigns/components/campaign-picker.component'\nimport { useHostCampaigns } from '@aglyn/tenant-feature-instance'\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 /** The site's campaigns to file the lead under (AGL-3254), by id. */\n campaignIds: 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 [campaignIds, setCampaignIds] = useState<string[]>([])\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 * The site's campaigns, for the picker (AGL-3254): read while the drawer\n * is open, under the site the lead will be filed under — at the\n * organization level, the picked one — and re-read when that changes,\n * since a campaign belongs to one site.\n */\n const campaigns = useHostCampaigns(createHostId ?? undefined, {\n enabled: open && Boolean(createHostId),\n })\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 setCampaignIds([])\n setAddress(EMPTY_ADDRESS)\n setNotes('')\n setEmailError('')\n setPhoneError('')\n setWebsiteError('')\n }, [open])\n // A campaign picked under one site is not one of another site's.\n useEffect(() => setCampaignIds([]), [createHostId])\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 campaignIds: campaignMembershipValue(campaignIds),\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\n color=\"inherit\"\n edge=\"start\"\n onClick={onClose}\n sx={{ mr: 2 }}\n >\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={\n phoneError || 'With the country code, like +1 512 555 0107'\n }\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\">\n {CRM_LEAD_STATUS_LABELS.working}\n </MenuItem>\n </TextField>\n <LeadOwnerSelect\n value={ownerUid}\n onChange={setOwnerUid}\n roster={roster}\n />\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 {/*\n The campaigns to file the lead under (AGL-3254), picked the way a\n form's page picks them. Grouping, not consent: it decides which\n campaign pages list the lead, never whether anything mails them.\n */}\n {createHostId ? (\n <CampaignPicker\n options={campaigns.options}\n value={campaignIds}\n onChange={setCampaignIds}\n helperText=\"The campaigns this lead is part of. It does not decide who a campaign mails.\"\n disabled={Boolean(busy)}\n empty={campaigns.ready && !campaigns.options.length}\n emptyText=\"This site has no campaigns yet. Create one from Marketing to file leads under it.\"\n />\n ) : null}\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","campaignMembershipValue","normalizeCrmLeadTags","normalizeContactEmail","normalizeCrmLeadProfile","ICON_VARIANT_CLOSE","Container","MdiIcon","SrOnly","NavigationDrawerComponent","Alert","Button","IconButton","MenuItem","Stack","TextField","Typography","CampaignPicker","useHostCampaigns","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","campaignIds","setCampaignIds","address","setAddress","notes","setNotes","emailError","setEmailError","phoneError","setPhoneError","websiteError","setWebsiteError","campaigns","undefined","enabled","Boolean","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","helperText","size","label","type","value","onChange","event","target","required","fullWidth","autoFocus","slotProps","htmlInput","maxLength","direction","select","new","working","placeholder","options","empty","ready","length","emptyText","multiline","minRows"],"mappings":"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"}
|
|
@@ -193,6 +193,23 @@
|
|
|
193
193
|
'equals',
|
|
194
194
|
'isNotEmpty'
|
|
195
195
|
]
|
|
196
|
+
},
|
|
197
|
+
/*
|
|
198
|
+
* The verdict on the address (AGL-3245) — window-only, on the shared row,
|
|
199
|
+
* and `nullable` because most records carry none: "is empty" is the
|
|
200
|
+
* filter for people nothing has been said about.
|
|
201
|
+
*/ {
|
|
202
|
+
column: 'emailState',
|
|
203
|
+
kind: 'exact',
|
|
204
|
+
path: 'emailState.status',
|
|
205
|
+
windowOnly: true,
|
|
206
|
+
presence: 'nullable',
|
|
207
|
+
operators: [
|
|
208
|
+
'equals',
|
|
209
|
+
'isAnyOf',
|
|
210
|
+
'isEmpty',
|
|
211
|
+
'isNotEmpty'
|
|
212
|
+
]
|
|
196
213
|
}
|
|
197
214
|
];
|
|
198
215
|
/**
|
|
@@ -288,7 +305,8 @@
|
|
|
288
305
|
[CRM_CONTACT_VIEW_FIELDS.owner]: 'Owner',
|
|
289
306
|
[CRM_CONTACT_VIEW_FIELDS.stage]: 'Stage',
|
|
290
307
|
[CRM_CONTACT_VIEW_FIELDS.source]: 'Source',
|
|
291
|
-
[CRM_CONTACT_VIEW_FIELDS.company]: 'Company'
|
|
308
|
+
[CRM_CONTACT_VIEW_FIELDS.company]: 'Company',
|
|
309
|
+
emailState: 'Email'
|
|
292
310
|
};
|
|
293
311
|
|
|
294
312
|
//# sourceMappingURL=contact-filters.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/constants/contact-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 type ContactFieldDefinition,\n CRM_CONTACT_VIEW_FIELDS,\n crmContactCustomColumn,\n} from '@aglyn/aglyn'\nimport type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter'\n\n/*\n * Contacts (`orgs/{orgId}/contacts`, read scoped to a host).\n *\n * The card's listener is `limit(1000)` and stays that way — nobody needs\n * forty thousand rows streamed into a table. What that cap must NOT do is\n * decide what a search can find, and it did: an org with 40,000 contacts\n * searched 1,000 of them and answered \"no contacts match\" for the rest. The\n * head-count already learned this lesson (AGL-1706, a server aggregate); the\n * search is the same mistake one field over.\n *\n * ⛔ `marketingConsent`, `ordersCount` and `ltvCents` are written only when\n * they apply, so a `false`/`0` filter would return nothing rather than\n * everyone else. `isNotEmpty` is exact on them and is what the menu offers.\n */\nexport const CONTACT_LIST_FILTER_FIELDS: readonly ListFilterField[] = [\n {\n column: 'name',\n kind: 'text',\n path: 'name',\n lowerPath: 'nameLower',\n tokensPath: 'nameTokens',\n reversedPath: 'nameReversed',\n },\n {\n // `normalizeContactEmail` lower-cases before every write, so the stored\n // value IS its own normalized key.\n column: 'email',\n kind: 'text',\n path: 'email',\n lowerPath: 'email',\n presence: 'always',\n },\n {\n /*\n * Already an array of lower-cased tags, so `contains` is a plain\n * `array-contains` — the one place the word-level caveat does not apply,\n * because a tag is matched whole rather than by word prefix.\n */\n column: 'tags',\n kind: 'text',\n path: 'tags',\n tokensPath: 'tags',\n // Ordered by the timestamp the list already sorts on, not by the tag\n // array — `orderBy` on the same field an `array-contains` matches is a\n // composite index nobody wants to explain.\n containsOrderBy: 'updatedAt',\n // `isAnyOf` is a saved segment's \"any of these tags\" (AGL-2617). The\n // query cannot serve it — `array-contains-any` is the scope clause's\n // — so the translator refuses it and the list matches it over the\n // window, which the caption says.\n operators: ['contains', 'isAnyOf', 'isNotEmpty'],\n },\n {\n /*\n * The forms a person came in through — the top-level `formIds` mirror\n * of the interactions' `formId` (AGL-2612), matched whole and as typed\n * because a form id is minted with mixed case. The form's own page links\n * here with its id; nobody types one. Ordered by the list's own\n * timestamp for the reason `tags` is, served by the\n * `(formIds CONTAINS, updatedAt DESC)` index.\n *\n * ⚠️ An `array-contains` on the mirror cannot share a query with the\n * `visibleTo` scope clause, so the list drops that clause for this one\n * filter and the rules admit the read to an org-wide member only — the\n * caveat the company contacts card states, stated again on the list.\n */\n column: 'formIds',\n kind: 'text',\n path: 'formIds',\n tokensPath: 'formIds',\n verbatimTokens: true,\n containsOrderBy: 'updatedAt',\n operators: ['contains'],\n },\n { column: 'hostId', kind: 'exact', path: 'hostId', presence: 'always' },\n { column: 'ordersCount', kind: 'number', path: 'ordersCount' },\n { column: 'ltvCents', kind: 'number', path: 'ltvCents' },\n { column: 'createdAt', kind: 'date', path: 'createdAt', presence: 'always' },\n { column: 'updatedAt', kind: 'date', path: 'updatedAt', presence: 'always' },\n /*\n * When the earliest open task is due (AGL-2661) — window-only, because\n * the value is on the SHARED row but a record written before the field\n * existed has none, and Firestore cannot query for absence. `nullable`\n * is honest: the writer stores `null` for nothing scheduled, and the\n * matcher reads absent the same way, so \"is empty\" is the \"No next\n * activity\" filter over the loaded window.\n */\n {\n column: 'nextTaskAtMs',\n kind: 'date',\n path: 'nextTaskAtMs',\n windowOnly: true,\n presence: 'nullable',\n },\n /*\n * THE FACET FIELDS (AGL-2617) — window-only, every one of them.\n *\n * An owner, a stage, a company and the capture sources live on the\n * viewing group's facet of the shared contact row, and a facet path is\n * per group: a `where` on it would need an index per group, and the\n * translator would send a scoped member a query the rules deny. So these\n * are declared for the menu and for a saved view, matched over the loaded\n * window against the flattened row (`ContactRecord` carries each at the\n * top), and never put on a query — `windowOnly` is what holds that line.\n * The paths are the ROW'S, which is what the matcher reads.\n *\n * The values are picked, not typed — a uid from the roster, a stage from\n * the fixed list, a source from its labels, a company from the picker —\n * so `equals` and `isAnyOf` are what they offer; the section supplies\n * the choices. The column names are the contract `CRM_CONTACT_VIEW_FIELDS`\n * states, because the dynamic-list translator reads a view by them.\n */\n {\n column: CRM_CONTACT_VIEW_FIELDS.owner,\n kind: 'exact',\n path: 'ownerUid',\n windowOnly: true,\n operators: ['equals', 'isAnyOf', 'isNotEmpty'],\n },\n {\n column: CRM_CONTACT_VIEW_FIELDS.stage,\n kind: 'exact',\n path: 'lifecycleStage',\n windowOnly: true,\n operators: ['equals', 'isAnyOf', 'isNotEmpty'],\n },\n {\n // The `sources` presence map, matched on its keys — see `keysOf`.\n column: CRM_CONTACT_VIEW_FIELDS.source,\n kind: 'exact',\n path: 'sources',\n keysOf: true,\n windowOnly: true,\n operators: ['equals', 'isAnyOf'],\n },\n {\n column: CRM_CONTACT_VIEW_FIELDS.company,\n kind: 'exact',\n path: 'companyId',\n windowOnly: true,\n operators: ['equals', 'isNotEmpty'],\n },\n]\n\n/**\n * One filter field per active custom contact field (AGL-2617).\n *\n * A custom value lives under the facet's `custom` map, so every one is\n * window-only for the reason the facet fields above are, and its column is\n * the one the table shows it under — `crmContactCustomColumn(key)` — so a\n * clause and a column agree on the name and a saved view's columns and\n * filters name the same thing. The kind follows the definition's type, and\n * `nullable` is how a cleared value is stored, which is what makes both\n * empty operators honest here.\n */\nexport function contactCustomFilterFields(\n definitions: readonly Pick<ContactFieldDefinition, 'key' | 'type' | 'retiredAt'>[],\n): ListFilterField[] {\n return definitions\n .filter((definition) => !definition.retiredAt)\n .map((definition) => {\n const column = crmContactCustomColumn(definition.key)\n const path = `custom.${definition.key}`\n switch (definition.type) {\n case 'number':\n return { column, kind: 'number', path, windowOnly: true, presence: 'nullable' }\n case 'date':\n return { column, kind: 'date', path, windowOnly: true, presence: 'nullable' }\n case 'checkbox':\n return { column, kind: 'boolean', path, windowOnly: true }\n case 'select':\n return {\n column,\n kind: 'exact',\n path,\n windowOnly: true,\n presence: 'nullable',\n operators: ['equals', 'isAnyOf', 'isEmpty', 'isNotEmpty'],\n }\n default:\n return {\n column,\n kind: 'text',\n path,\n windowOnly: true,\n presence: 'nullable',\n operators: ['contains', 'equals', 'startsWith', 'isEmpty', 'isNotEmpty'],\n }\n }\n })\n}\n\n/** The custom fields' headers, keyed by the column each filters as. */\nexport function contactCustomFilterHeaders(\n definitions: readonly Pick<ContactFieldDefinition, 'key' | 'label'>[],\n): Record<string, string> {\n return Object.fromEntries(\n definitions.map((definition) => [\n crmContactCustomColumn(definition.key),\n definition.label || definition.key,\n ]),\n )\n}\n\n/**\n * How every filterable contact field reads — on a chip, in the add-filter\n * picker, and as the header of a filter-only hidden column.\n */\nexport const CONTACT_LIST_FILTER_HEADERS: Readonly<Record<string, string>> = {\n name: 'Contact',\n email: 'Email',\n tags: 'Tags',\n formIds: 'Form ID',\n hostId: 'Site ID',\n ordersCount: 'Orders',\n ltvCents: 'Lifetime value (cents)',\n createdAt: 'Created',\n updatedAt: 'Updated',\n nextTaskAtMs: 'Next activity',\n [CRM_CONTACT_VIEW_FIELDS.owner]: 'Owner',\n [CRM_CONTACT_VIEW_FIELDS.stage]: 'Stage',\n [CRM_CONTACT_VIEW_FIELDS.source]: 'Source',\n [CRM_CONTACT_VIEW_FIELDS.company]: 'Company',\n}\n"],"names":["CRM_CONTACT_VIEW_FIELDS","crmContactCustomColumn","CONTACT_LIST_FILTER_FIELDS","column","kind","path","lowerPath","tokensPath","reversedPath","presence","containsOrderBy","operators","verbatimTokens","windowOnly","owner","stage","source","keysOf","company","contactCustomFilterFields","definitions","filter","definition","retiredAt","map","key","type","contactCustomFilterHeaders","Object","fromEntries","label","CONTACT_LIST_FILTER_HEADERS","name","email","tags","formIds","hostId","ordersCount","ltvCents","createdAt","updatedAt","nextTaskAtMs"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/constants/contact-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 type ContactFieldDefinition,\n CRM_CONTACT_VIEW_FIELDS,\n crmContactCustomColumn,\n} from '@aglyn/aglyn'\nimport type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter'\n\n/*\n * Contacts (`orgs/{orgId}/contacts`, read scoped to a host).\n *\n * The card's listener is `limit(1000)` and stays that way — nobody needs\n * forty thousand rows streamed into a table. What that cap must NOT do is\n * decide what a search can find, and it did: an org with 40,000 contacts\n * searched 1,000 of them and answered \"no contacts match\" for the rest. The\n * head-count already learned this lesson (AGL-1706, a server aggregate); the\n * search is the same mistake one field over.\n *\n * ⛔ `marketingConsent`, `ordersCount` and `ltvCents` are written only when\n * they apply, so a `false`/`0` filter would return nothing rather than\n * everyone else. `isNotEmpty` is exact on them and is what the menu offers.\n */\nexport const CONTACT_LIST_FILTER_FIELDS: readonly ListFilterField[] = [\n {\n column: 'name',\n kind: 'text',\n path: 'name',\n lowerPath: 'nameLower',\n tokensPath: 'nameTokens',\n reversedPath: 'nameReversed',\n },\n {\n // `normalizeContactEmail` lower-cases before every write, so the stored\n // value IS its own normalized key.\n column: 'email',\n kind: 'text',\n path: 'email',\n lowerPath: 'email',\n presence: 'always',\n },\n {\n /*\n * Already an array of lower-cased tags, so `contains` is a plain\n * `array-contains` — the one place the word-level caveat does not apply,\n * because a tag is matched whole rather than by word prefix.\n */\n column: 'tags',\n kind: 'text',\n path: 'tags',\n tokensPath: 'tags',\n // Ordered by the timestamp the list already sorts on, not by the tag\n // array — `orderBy` on the same field an `array-contains` matches is a\n // composite index nobody wants to explain.\n containsOrderBy: 'updatedAt',\n // `isAnyOf` is a saved segment's \"any of these tags\" (AGL-2617). The\n // query cannot serve it — `array-contains-any` is the scope clause's\n // — so the translator refuses it and the list matches it over the\n // window, which the caption says.\n operators: ['contains', 'isAnyOf', 'isNotEmpty'],\n },\n {\n /*\n * The forms a person came in through — the top-level `formIds` mirror\n * of the interactions' `formId` (AGL-2612), matched whole and as typed\n * because a form id is minted with mixed case. The form's own page links\n * here with its id; nobody types one. Ordered by the list's own\n * timestamp for the reason `tags` is, served by the\n * `(formIds CONTAINS, updatedAt DESC)` index.\n *\n * ⚠️ An `array-contains` on the mirror cannot share a query with the\n * `visibleTo` scope clause, so the list drops that clause for this one\n * filter and the rules admit the read to an org-wide member only — the\n * caveat the company contacts card states, stated again on the list.\n */\n column: 'formIds',\n kind: 'text',\n path: 'formIds',\n tokensPath: 'formIds',\n verbatimTokens: true,\n containsOrderBy: 'updatedAt',\n operators: ['contains'],\n },\n { column: 'hostId', kind: 'exact', path: 'hostId', presence: 'always' },\n { column: 'ordersCount', kind: 'number', path: 'ordersCount' },\n { column: 'ltvCents', kind: 'number', path: 'ltvCents' },\n { column: 'createdAt', kind: 'date', path: 'createdAt', presence: 'always' },\n { column: 'updatedAt', kind: 'date', path: 'updatedAt', presence: 'always' },\n /*\n * When the earliest open task is due (AGL-2661) — window-only, because\n * the value is on the SHARED row but a record written before the field\n * existed has none, and Firestore cannot query for absence. `nullable`\n * is honest: the writer stores `null` for nothing scheduled, and the\n * matcher reads absent the same way, so \"is empty\" is the \"No next\n * activity\" filter over the loaded window.\n */\n {\n column: 'nextTaskAtMs',\n kind: 'date',\n path: 'nextTaskAtMs',\n windowOnly: true,\n presence: 'nullable',\n },\n /*\n * THE FACET FIELDS (AGL-2617) — window-only, every one of them.\n *\n * An owner, a stage, a company and the capture sources live on the\n * viewing group's facet of the shared contact row, and a facet path is\n * per group: a `where` on it would need an index per group, and the\n * translator would send a scoped member a query the rules deny. So these\n * are declared for the menu and for a saved view, matched over the loaded\n * window against the flattened row (`ContactRecord` carries each at the\n * top), and never put on a query — `windowOnly` is what holds that line.\n * The paths are the ROW'S, which is what the matcher reads.\n *\n * The values are picked, not typed — a uid from the roster, a stage from\n * the fixed list, a source from its labels, a company from the picker —\n * so `equals` and `isAnyOf` are what they offer; the section supplies\n * the choices. The column names are the contract `CRM_CONTACT_VIEW_FIELDS`\n * states, because the dynamic-list translator reads a view by them.\n */\n {\n column: CRM_CONTACT_VIEW_FIELDS.owner,\n kind: 'exact',\n path: 'ownerUid',\n windowOnly: true,\n operators: ['equals', 'isAnyOf', 'isNotEmpty'],\n },\n {\n column: CRM_CONTACT_VIEW_FIELDS.stage,\n kind: 'exact',\n path: 'lifecycleStage',\n windowOnly: true,\n operators: ['equals', 'isAnyOf', 'isNotEmpty'],\n },\n {\n // The `sources` presence map, matched on its keys — see `keysOf`.\n column: CRM_CONTACT_VIEW_FIELDS.source,\n kind: 'exact',\n path: 'sources',\n keysOf: true,\n windowOnly: true,\n operators: ['equals', 'isAnyOf'],\n },\n {\n column: CRM_CONTACT_VIEW_FIELDS.company,\n kind: 'exact',\n path: 'companyId',\n windowOnly: true,\n operators: ['equals', 'isNotEmpty'],\n },\n /*\n * The verdict on the address (AGL-3245) — window-only, on the shared row,\n * and `nullable` because most records carry none: \"is empty\" is the\n * filter for people nothing has been said about.\n */\n {\n column: 'emailState',\n kind: 'exact',\n path: 'emailState.status',\n windowOnly: true,\n presence: 'nullable',\n operators: ['equals', 'isAnyOf', 'isEmpty', 'isNotEmpty'],\n },\n]\n\n/**\n * One filter field per active custom contact field (AGL-2617).\n *\n * A custom value lives under the facet's `custom` map, so every one is\n * window-only for the reason the facet fields above are, and its column is\n * the one the table shows it under — `crmContactCustomColumn(key)` — so a\n * clause and a column agree on the name and a saved view's columns and\n * filters name the same thing. The kind follows the definition's type, and\n * `nullable` is how a cleared value is stored, which is what makes both\n * empty operators honest here.\n */\nexport function contactCustomFilterFields(\n definitions: readonly Pick<ContactFieldDefinition, 'key' | 'type' | 'retiredAt'>[],\n): ListFilterField[] {\n return definitions\n .filter((definition) => !definition.retiredAt)\n .map((definition) => {\n const column = crmContactCustomColumn(definition.key)\n const path = `custom.${definition.key}`\n switch (definition.type) {\n case 'number':\n return { column, kind: 'number', path, windowOnly: true, presence: 'nullable' }\n case 'date':\n return { column, kind: 'date', path, windowOnly: true, presence: 'nullable' }\n case 'checkbox':\n return { column, kind: 'boolean', path, windowOnly: true }\n case 'select':\n return {\n column,\n kind: 'exact',\n path,\n windowOnly: true,\n presence: 'nullable',\n operators: ['equals', 'isAnyOf', 'isEmpty', 'isNotEmpty'],\n }\n default:\n return {\n column,\n kind: 'text',\n path,\n windowOnly: true,\n presence: 'nullable',\n operators: ['contains', 'equals', 'startsWith', 'isEmpty', 'isNotEmpty'],\n }\n }\n })\n}\n\n/** The custom fields' headers, keyed by the column each filters as. */\nexport function contactCustomFilterHeaders(\n definitions: readonly Pick<ContactFieldDefinition, 'key' | 'label'>[],\n): Record<string, string> {\n return Object.fromEntries(\n definitions.map((definition) => [\n crmContactCustomColumn(definition.key),\n definition.label || definition.key,\n ]),\n )\n}\n\n/**\n * How every filterable contact field reads — on a chip, in the add-filter\n * picker, and as the header of a filter-only hidden column.\n */\nexport const CONTACT_LIST_FILTER_HEADERS: Readonly<Record<string, string>> = {\n name: 'Contact',\n email: 'Email',\n tags: 'Tags',\n formIds: 'Form ID',\n hostId: 'Site ID',\n ordersCount: 'Orders',\n ltvCents: 'Lifetime value (cents)',\n createdAt: 'Created',\n updatedAt: 'Updated',\n nextTaskAtMs: 'Next activity',\n [CRM_CONTACT_VIEW_FIELDS.owner]: 'Owner',\n [CRM_CONTACT_VIEW_FIELDS.stage]: 'Stage',\n [CRM_CONTACT_VIEW_FIELDS.source]: 'Source',\n [CRM_CONTACT_VIEW_FIELDS.company]: 'Company',\n emailState: 'Email',\n}\n"],"names":["CRM_CONTACT_VIEW_FIELDS","crmContactCustomColumn","CONTACT_LIST_FILTER_FIELDS","column","kind","path","lowerPath","tokensPath","reversedPath","presence","containsOrderBy","operators","verbatimTokens","windowOnly","owner","stage","source","keysOf","company","contactCustomFilterFields","definitions","filter","definition","retiredAt","map","key","type","contactCustomFilterHeaders","Object","fromEntries","label","CONTACT_LIST_FILTER_HEADERS","name","email","tags","formIds","hostId","ordersCount","ltvCents","createdAt","updatedAt","nextTaskAtMs","emailState"],"mappings":"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"}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/ // The contract from its own module, not the plugin-manager barrel: boot needs
|
|
17
17
|
// the registry and nothing else, and the barrel reaches the client contexts.
|
|
18
18
|
import { registerPluginContactCaptureWriter } from "@aglyn/aglyn/plugin-manager/plugin-contact-capture";
|
|
19
|
+
import { registerPluginLeadConversionListener } from "@aglyn/aglyn/plugin-manager/plugin-lead-conversion";
|
|
19
20
|
import { BUNDLE_ID } from "./constants/bundle-common.js";
|
|
20
21
|
/**
|
|
21
22
|
* The CRM as the plugin that keeps people.
|
|
@@ -57,6 +58,20 @@ import { BUNDLE_ID } from "./constants/bundle-common.js";
|
|
|
57
58
|
registerPluginContactCaptureWriter(crmContactCaptureWriter, {
|
|
58
59
|
pluginId: BUNDLE_ID
|
|
59
60
|
});
|
|
61
|
+
// The CRM's own share of a lead conversion (AGL-3254): the lead's
|
|
62
|
+
// campaigns go onto the contact's facet. Through the seam every door
|
|
63
|
+
// that converts a lead reaches, and deferred like the capture: the
|
|
64
|
+
// module that writes is loaded when the first conversion arrives, and
|
|
65
|
+
// it brings the Admin SDK with it — this file defers the plugin's OWN
|
|
66
|
+
// module only. A library imported statically across the plugin cannot
|
|
67
|
+
// also be lazy-loaded here: `enforce-module-boundaries` refuses every
|
|
68
|
+
// static import of a library the project lazy-loads anywhere.
|
|
69
|
+
registerPluginLeadConversionListener(async (request)=>{
|
|
70
|
+
const { carryLeadCampaignsOnConversion } = await import("./server/lead-campaign-carry.js");
|
|
71
|
+
return carryLeadCampaignsOnConversion(request);
|
|
72
|
+
}, {
|
|
73
|
+
pluginId: BUNDLE_ID
|
|
74
|
+
});
|
|
60
75
|
}
|
|
61
76
|
|
|
62
77
|
//# sourceMappingURL=declarations.server.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/plugins/crm/src/lib/declarations.server.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// The contract from its own module, not the plugin-manager barrel: boot needs\n// the registry and nothing else, and the barrel reaches the client contexts.\nimport {\n registerPluginContactCaptureWriter,\n type PluginContactCaptureWriter,\n} from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport { BUNDLE_ID } from './constants/bundle-common'\n\n/**\n * The CRM as the plugin that keeps people.\n *\n * Light by construction, the way the workflows listener is: the capture\n * itself is imported when the first one arrives, not when the process\n * starts, so a process that never captures anybody pays for this object and\n * nothing else. That matters because this registers in EVERY server process,\n * including ones that will never touch the CRM.\n */\nexport const crmContactCaptureWriter: PluginContactCaptureWriter = {\n async capture(request) {\n const { captureContactForCrm } = await import('./server/capture-contact')\n return await captureContactForCrm(request)\n },\n}\n\n/**\n * The plugin's SERVER declarations: what the server must know at boot, before\n * any door of the CRM has been called.\n *\n * ## Why a declaration and not a `tenantApi` registration\n *\n * The doors that meet a person mostly never load this plugin. A form\n * submission is a core route; an order and a booking are captured inside\n * STRIPE WEBHOOKS. None of them has a reason to load the CRM, and a registry\n * filled by a surface that did not load answers `null` — which\n * `capturePluginContact` defines as \"this workspace has no record system\", a\n * sentence that would be false and silent. Orders would stop creating\n * contacts with nothing red anywhere, which is the AGL-3025 shape.\n *\n * Running from both apps' generated server-declarations manifest at boot is\n * what makes the answer true in every process. It is also called from the\n * plugin's own API register functions, so a process whose boot did not run it\n * still registers the writer the first time a CRM door loads. Registering\n * twice replaces in place.\n *\n * ⚠️ The registry holds ONE writer: a workspace keeps one set of people. A\n * second plugin's is refused naming both, and the incumbent keeps serving.\n */\nexport function registerCrmServerDeclarations(): void {\n registerPluginContactCaptureWriter(crmContactCaptureWriter, {\n pluginId: BUNDLE_ID,\n })\n}\n"],"names":["registerPluginContactCaptureWriter","BUNDLE_ID","crmContactCaptureWriter","capture","request","captureContactForCrm","registerCrmServerDeclarations","pluginId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,8EAA8E;AAC9E,6EAA6E;AAC7E,SACEA,kCAAkC,QAE7B,qDAAoD;AAC3D,SAASC,SAAS,QAAQ,+BAA2B;AAErD;;;;;;;;CAQC,GACD,OAAO,MAAMC,0BAAsD;IACjE,MAAMC,SAAQC,OAAO;QACnB,MAAM,EAAEC,oBAAoB,EAAE,GAAG,MAAM,MAAM,CAAC;QAC9C,OAAO,MAAMA,qBAAqBD;IACpC;AACF,EAAC;AAED;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,SAASE;
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/plugins/crm/src/lib/declarations.server.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// The contract from its own module, not the plugin-manager barrel: boot needs\n// the registry and nothing else, and the barrel reaches the client contexts.\nimport {\n registerPluginContactCaptureWriter,\n type PluginContactCaptureWriter,\n} from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport { registerPluginLeadConversionListener } from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { BUNDLE_ID } from './constants/bundle-common'\n\n/**\n * The CRM as the plugin that keeps people.\n *\n * Light by construction, the way the workflows listener is: the capture\n * itself is imported when the first one arrives, not when the process\n * starts, so a process that never captures anybody pays for this object and\n * nothing else. That matters because this registers in EVERY server process,\n * including ones that will never touch the CRM.\n */\nexport const crmContactCaptureWriter: PluginContactCaptureWriter = {\n async capture(request) {\n const { captureContactForCrm } = await import('./server/capture-contact')\n return await captureContactForCrm(request)\n },\n}\n\n/**\n * The plugin's SERVER declarations: what the server must know at boot, before\n * any door of the CRM has been called.\n *\n * ## Why a declaration and not a `tenantApi` registration\n *\n * The doors that meet a person mostly never load this plugin. A form\n * submission is a core route; an order and a booking are captured inside\n * STRIPE WEBHOOKS. None of them has a reason to load the CRM, and a registry\n * filled by a surface that did not load answers `null` — which\n * `capturePluginContact` defines as \"this workspace has no record system\", a\n * sentence that would be false and silent. Orders would stop creating\n * contacts with nothing red anywhere, which is the AGL-3025 shape.\n *\n * Running from both apps' generated server-declarations manifest at boot is\n * what makes the answer true in every process. It is also called from the\n * plugin's own API register functions, so a process whose boot did not run it\n * still registers the writer the first time a CRM door loads. Registering\n * twice replaces in place.\n *\n * ⚠️ The registry holds ONE writer: a workspace keeps one set of people. A\n * second plugin's is refused naming both, and the incumbent keeps serving.\n */\nexport function registerCrmServerDeclarations(): void {\n registerPluginContactCaptureWriter(crmContactCaptureWriter, {\n pluginId: BUNDLE_ID,\n })\n // The CRM's own share of a lead conversion (AGL-3254): the lead's\n // campaigns go onto the contact's facet. Through the seam every door\n // that converts a lead reaches, and deferred like the capture: the\n // module that writes is loaded when the first conversion arrives, and\n // it brings the Admin SDK with it — this file defers the plugin's OWN\n // module only. A library imported statically across the plugin cannot\n // also be lazy-loaded here: `enforce-module-boundaries` refuses every\n // static import of a library the project lazy-loads anywhere.\n registerPluginLeadConversionListener(\n async (request) => {\n const { carryLeadCampaignsOnConversion } =\n await import('./server/lead-campaign-carry')\n return carryLeadCampaignsOnConversion(request)\n },\n { pluginId: BUNDLE_ID },\n )\n}\n"],"names":["registerPluginContactCaptureWriter","registerPluginLeadConversionListener","BUNDLE_ID","crmContactCaptureWriter","capture","request","captureContactForCrm","registerCrmServerDeclarations","pluginId","carryLeadCampaignsOnConversion"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,8EAA8E;AAC9E,6EAA6E;AAC7E,SACEA,kCAAkC,QAE7B,qDAAoD;AAC3D,SAASC,oCAAoC,QAAQ,qDAAoD;AACzG,SAASC,SAAS,QAAQ,+BAA2B;AAErD;;;;;;;;CAQC,GACD,OAAO,MAAMC,0BAAsD;IACjE,MAAMC,SAAQC,OAAO;QACnB,MAAM,EAAEC,oBAAoB,EAAE,GAAG,MAAM,MAAM,CAAC;QAC9C,OAAO,MAAMA,qBAAqBD;IACpC;AACF,EAAC;AAED;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,SAASE;IACdP,mCAAmCG,yBAAyB;QAC1DK,UAAUN;IACZ;IACA,kEAAkE;IAClE,qEAAqE;IACrE,mEAAmE;IACnE,sEAAsE;IACtE,sEAAsE;IACtE,sEAAsE;IACtE,sEAAsE;IACtE,8DAA8D;IAC9DD,qCACE,OAAOI;QACL,MAAM,EAAEI,8BAA8B,EAAE,GACtC,MAAM,MAAM,CAAC;QACf,OAAOA,+BAA+BJ;IACxC,GACA;QAAEG,UAAUN;IAAU;AAE1B"}
|
|
@@ -98,6 +98,12 @@ export interface ContactRecord {
|
|
|
98
98
|
* sees the same next step.
|
|
99
99
|
*/
|
|
100
100
|
nextTaskAtMs: number | null;
|
|
101
|
+
/**
|
|
102
|
+
* The last verdict on the address (AGL-3245) — bounced, blocked,
|
|
103
|
+
* unsubscribed, do-not-contact — or `null` when nothing is known. SHARED
|
|
104
|
+
* like the address it is about.
|
|
105
|
+
*/
|
|
106
|
+
emailState: Aglyn.EmailState | null;
|
|
101
107
|
createdAt?: unknown;
|
|
102
108
|
updatedAt?: unknown;
|
|
103
109
|
}
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
lifecycleStage: Aglyn.isContactLifecycleStage(facet.lifecycleStage) ? facet.lifecycleStage : '',
|
|
53
53
|
lastEmailEngagementAtMs: typeof facet.lastEmailEngagementAtMs === 'number' && Number.isFinite(facet.lastEmailEngagementAtMs) && facet.lastEmailEngagementAtMs > 0 ? facet.lastEmailEngagementAtMs : null,
|
|
54
54
|
nextTaskAtMs: Aglyn.readNextTaskAtMs(row),
|
|
55
|
+
emailState: Aglyn.readEmailState(row),
|
|
55
56
|
createdAt: row['createdAt'],
|
|
56
57
|
updatedAt: row['updatedAt']
|
|
57
58
|
};
|
|
@@ -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 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 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","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 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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"}
|
|
@@ -42,7 +42,7 @@ export declare const LEAD_IMPORT_STATUSES: readonly CrmLeadStatus[];
|
|
|
42
42
|
* contact beside it. They read under the same labels the contacts import
|
|
43
43
|
* uses, so one spreadsheet maps the same way into either.
|
|
44
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"];
|
|
45
|
+
export declare const LEAD_IMPORT_FIELDS: readonly ["email", "name", "company", "jobTitle", "phone", "website", "leadSource", "status", "ownerEmail", "addressLine1", "addressLine2", "addressCity", "addressState", "addressPostalCode", "addressCountry", "tags", "campaigns", "unqualifiedReason", "notes"];
|
|
46
46
|
export type LeadImportField = (typeof LEAD_IMPORT_FIELDS)[number];
|
|
47
47
|
/** How each field reads in the mapping menu. Typed so a field cannot ship unlabeled. */
|
|
48
48
|
export declare const LEAD_IMPORT_FIELD_LABELS: Record<LeadImportField, string>;
|
|
@@ -58,9 +58,11 @@ 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' | 'write-failed';
|
|
61
|
+
export type LeadImportSkipReason = 'invalid-email' | 'duplicate' | 'lead-ceiling' | 'campaign-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
|
+
/** The most campaigns one row may name — the membership field's own cap. */
|
|
65
|
+
export declare const LEAD_IMPORT_CAMPAIGNS_MAX = 20;
|
|
64
66
|
/** One row, ready for the server to resolve and write. */
|
|
65
67
|
export interface LeadImportRow {
|
|
66
68
|
/** Normalized — the address `personKey` derives the document id from. */
|
|
@@ -73,6 +75,13 @@ export interface LeadImportRow {
|
|
|
73
75
|
* is a cell nobody filled, not a decision to erase what the site knows.
|
|
74
76
|
*/
|
|
75
77
|
profile: CrmLeadProfile;
|
|
78
|
+
/**
|
|
79
|
+
* The campaigns the row files the lead under (AGL-3254), by NAME as the
|
|
80
|
+
* file wrote them: the server resolves each against the site's own
|
|
81
|
+
* containers and refuses the row whole when one is not there, because a
|
|
82
|
+
* row filed under half its campaigns is a row nobody asked for.
|
|
83
|
+
*/
|
|
84
|
+
campaigns?: string[];
|
|
76
85
|
/** Absent leaves an existing lead's status alone and a new one reading as `new`. */
|
|
77
86
|
status?: CrmLeadStatus;
|
|
78
87
|
/** Normalized, for the server to resolve against the org's members. */
|
|
@@ -64,7 +64,8 @@ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_wi
|
|
|
64
64
|
* to belong to, is left off the lead and REPORTED under
|
|
65
65
|
* {@link LeadImportRow.dropped}, so the operator learns that a column went
|
|
66
66
|
* nowhere rather than discovering it weeks later.
|
|
67
|
-
*/ import {
|
|
67
|
+
*/ import { CAMPAIGN_MEMBERSHIP_CAP } from "@aglyn/aglyn/app-utils/campaign-membership";
|
|
68
|
+
import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
|
|
68
69
|
import { CRM_LEAD_STATUS_LABELS, CRM_LEAD_STATUSES, CRM_LEAD_TEXT_MAX, isCrmLeadStatus, normalizeCrmLeadTags, normalizeCompanyWebsite } from "@aglyn/aglyn/app-utils/crm";
|
|
69
70
|
import { normalizeAddress, normalizePhone } from "@aglyn/aglyn/foundation/definitions/contact.types";
|
|
70
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";
|
|
@@ -108,6 +109,7 @@ export const LEAD_IMPORT_REASON_MAX = 500;
|
|
|
108
109
|
'addressPostalCode',
|
|
109
110
|
'addressCountry',
|
|
110
111
|
'tags',
|
|
112
|
+
'campaigns',
|
|
111
113
|
'unqualifiedReason',
|
|
112
114
|
'notes'
|
|
113
115
|
];
|
|
@@ -128,6 +130,7 @@ export const LEAD_IMPORT_REASON_MAX = 500;
|
|
|
128
130
|
addressPostalCode: 'Postal code',
|
|
129
131
|
addressCountry: 'Country (two-letter code)',
|
|
130
132
|
tags: 'Tags (comma or | separated)',
|
|
133
|
+
campaigns: 'Campaigns (by name, comma or | separated)',
|
|
131
134
|
unqualifiedReason: 'Unqualified reason',
|
|
132
135
|
notes: 'Notes'
|
|
133
136
|
};
|
|
@@ -251,6 +254,15 @@ export const LEAD_IMPORT_REASON_MAX = 500;
|
|
|
251
254
|
'labels',
|
|
252
255
|
'lists'
|
|
253
256
|
],
|
|
257
|
+
// `campaign` alone stays the lead source's: another tool's "Campaign"
|
|
258
|
+
// column names where the person came from, not one of this site's
|
|
259
|
+
// containers (AGL-3254).
|
|
260
|
+
campaigns: [
|
|
261
|
+
'campaigns',
|
|
262
|
+
'campaign names',
|
|
263
|
+
'in campaigns',
|
|
264
|
+
'email campaigns'
|
|
265
|
+
],
|
|
254
266
|
unqualifiedReason: [
|
|
255
267
|
'unqualified reason',
|
|
256
268
|
'reason',
|
|
@@ -280,8 +292,10 @@ const FIELD_ALIAS_KEYS = importAliasKeys(LEAD_IMPORT_FIELDS, FIELD_ALIASES);
|
|
|
280
292
|
'invalid-email': 'No usable email address',
|
|
281
293
|
duplicate: 'The same address appears earlier in this file',
|
|
282
294
|
'lead-ceiling': 'This site is at the platform lead limit',
|
|
295
|
+
'campaign-unknown': 'Names a campaign this site does not have',
|
|
283
296
|
'write-failed': 'Could not be saved'
|
|
284
297
|
};
|
|
298
|
+
/** The most campaigns one row may name — the membership field's own cap. */ export const LEAD_IMPORT_CAMPAIGNS_MAX = CAMPAIGN_MEMBERSHIP_CAP;
|
|
285
299
|
/**
|
|
286
300
|
* A status cell by id or by label — `working`, `Working` and `WORKING` are
|
|
287
301
|
* one status. `null` for a cell naming no status a file may set, which
|
|
@@ -302,7 +316,7 @@ const FIELD_ALIAS_KEYS = importAliasKeys(LEAD_IMPORT_FIELDS, FIELD_ALIASES);
|
|
|
302
316
|
* the document id; the owner is refused by the server, which alone can
|
|
303
317
|
* look them up.
|
|
304
318
|
*/ export function normalizeLeadImportRow(raw) {
|
|
305
|
-
var _importTextValue, _raw_tags;
|
|
319
|
+
var _importTextValue, _raw_tags, _raw_campaigns;
|
|
306
320
|
var _importTextValue1, _importTextValue2, _importTextValue3, _importTextValue4;
|
|
307
321
|
const emailText = (_importTextValue = importTextValue(raw.email, 320)) != null ? _importTextValue : '';
|
|
308
322
|
const email = normalizeContactEmail(emailText);
|
|
@@ -365,6 +379,12 @@ const FIELD_ALIAS_KEYS = importAliasKeys(LEAD_IMPORT_FIELDS, FIELD_ALIASES);
|
|
|
365
379
|
if (countryText && !(address == null ? void 0 : address.country)) drop('addressCountry', countryText);
|
|
366
380
|
const tags = normalizeCrmLeadTags(String((_raw_tags = raw.tags) != null ? _raw_tags : '').split(/[,|]/).map((tag)=>tag.trim()));
|
|
367
381
|
if (tags.length) row.profile.tags = tags;
|
|
382
|
+
// Names, trimmed and deduplicated as typed; the server matches them to
|
|
383
|
+
// the site's campaigns without regard to case.
|
|
384
|
+
const campaigns = [
|
|
385
|
+
...new Set(String((_raw_campaigns = raw.campaigns) != null ? _raw_campaigns : '').split(/[,|]/).map((name)=>name.trim().replace(/\s+/g, ' ')).filter(Boolean))
|
|
386
|
+
].slice(0, LEAD_IMPORT_CAMPAIGNS_MAX);
|
|
387
|
+
if (campaigns.length) row.campaigns = campaigns;
|
|
368
388
|
const statusText = importTextValue(raw.status, 32);
|
|
369
389
|
if (statusText) {
|
|
370
390
|
const status = parseImportLeadStatus(statusText);
|