@aglyn/plugins-crm 1.0.0-beta.203 → 1.0.0-beta.205
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 +7 -0
- package/src/lib/components/contact-detail-page.js.map +1 -1
- package/src/lib/components/crm-email-check.d.ts +70 -0
- package/src/lib/components/crm-email-check.js +135 -0
- package/src/lib/components/crm-email-check.js.map +1 -0
- package/src/lib/components/crm-send-email-dialog.js +12 -0
- package/src/lib/components/crm-send-email-dialog.js.map +1 -1
- package/src/lib/components/csv-import-drawer.js +5 -1
- package/src/lib/components/csv-import-drawer.js.map +1 -1
- package/src/lib/components/lead-properties-card.js +7 -1
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/use-crm-api.d.ts +1 -1
- package/src/lib/components/use-crm-api.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/lead-filters.d.ts +1 -1
- package/src/lib/server/capture-contact.js +8 -2
- package/src/lib/server/capture-contact.js.map +1 -1
- package/src/lib/server/contacts-import.js +18 -2
- package/src/lib/server/contacts-import.js.map +1 -1
- package/src/lib/server/email-check.d.ts +51 -0
- package/src/lib/server/email-check.js +193 -0
- package/src/lib/server/email-check.js.map +1 -0
- package/src/lib/server/email-send.js +8 -0
- package/src/lib/server/email-send.js.map +1 -1
- package/src/lib/server/import-context.d.ts +2 -0
- package/src/lib/server/import-context.js +1 -0
- package/src/lib/server/import-context.js.map +1 -1
- package/src/lib/server/leads-import.js +16 -2
- package/src/lib/server/leads-import.js.map +1 -1
- package/src/lib/server/record-email-state.js +21 -4
- package/src/lib/server/record-email-state.js.map +1 -1
- package/src/lib/server/task-routes.d.ts +2 -0
- package/src/lib/server/task-routes.js +9 -0
- package/src/lib/server/task-routes.js.map +1 -1
- package/src/lib/server-deal-stage.js +18 -2
- package/src/lib/server-deal-stage.js.map +1 -1
- package/src/lib/server.js +23 -2
- package/src/lib/server.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/lead-properties-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport type {\n AglynOrgBilling,\n ConsentGroup,\n CrmLeadFields,\n CrmLeadProfilePatch,\n CrmLeadStatus,\n} from '@aglyn/aglyn'\nimport { mdiAccountCancelOutline } from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n type FirestoreDocStatus,\n useFirestore,\n writeGuardedBySeed,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Button,\n FormControl,\n InputLabel,\n MenuItem,\n Select,\n Stack,\n TextField,\n Tooltip,\n Typography,\n} from '@mui/material'\nimport { deleteField, doc, serverTimestamp, updateDoc } from 'firebase/firestore'\nimport { useEffect, useId, useMemo, useState } from 'react'\nimport { useContactFieldDefinitions } from '../hooks/use-contact-field-definitions'\nimport { useLeadSourcePicklist } from '../hooks/use-lead-source-picklist'\nimport {\n crmCustomDraftChanges,\n type CrmCustomDraft,\n crmCustomDraftMissingRequired,\n crmCustomDraftValue,\n crmCustomDraftWrites,\n} from '../model/crm-custom-draft'\nimport { CrmCustomFieldControl } from './crm-custom-field-control'\nimport { crmRoutes } from '../model/crm-routes'\nimport {\n addressDraftFrom,\n ContactAddressFields,\n type AddressDraft,\n} from './contact-address-fields'\nimport { CrmCallButton, CrmPhoneLink } from './crm-call-actions'\nimport { CrmEmailStateChip } from './crm-email-state-chip'\nimport { CrmRecordChip, CrmRecordHeader } from './crm-record-header'\nimport { CrmSendEmailButton } from './crm-send-email-button'\nimport type { OrgMemberOptions } from '../hooks/use-org-member-options'\nimport { LeadOwnerSelect } from './lead-owner-select'\nimport { LeadSourceSelect } from './lead-source-select'\nimport { LeadStatusChip } from './lead-status-chip'\nimport { crmClientListFields, CRM_CLIENT_SEARCH_FIELDS } from '../model/crm-list-query'\n\n/** The Leads list's fields a profile save rewrites; the verdict key is the server's. */\nconst LEAD_CLIENT_LIST_FIELDS = [...CRM_CLIENT_SEARCH_FIELDS, 'leadSourceKey'] as const\n\nconst NOTES_MAX = Aglyn.CRM_LEAD_NOTES_MAX\nconst TEXT_MAX = Aglyn.CRM_LEAD_TEXT_MAX\n\n/** The profile as the form holds it: every field a string, the address a draft. */\ninterface ProfileDraft {\n company: string\n jobTitle: string\n phone: string\n website: string\n leadSource: string\n tags: string\n address: AddressDraft\n}\n\n/** The stored profile as a draft the fields can edit. */\nfunction profileDraftFrom(lead: Record<string, unknown> & CrmLeadFields): ProfileDraft {\n return {\n company: String(lead.company ?? ''),\n jobTitle: String(lead.jobTitle ?? ''),\n phone: String(lead.phone ?? lead['phone'] ?? ''),\n website: String(lead.website ?? ''),\n leadSource: String(lead.leadSource ?? ''),\n tags: (lead.tags ?? []).join(', '),\n address: addressDraftFrom(lead.address ?? null),\n }\n}\n\n/** The patch as the document takes it: a cleared field is deleted, not blanked. */\nfunction profileWrite(patch: CrmLeadProfilePatch): Record<string, unknown> {\n const write: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(patch)) {\n if (value === undefined) continue\n write[key] = value === null ? deleteField() : value\n }\n return write\n}\n\n/**\n * Why Convert is refused while an erasure waits on the person — the same\n * sentence shape the overflow's items carry, so the two read as one state.\n */\nexport const CONVERT_PENDING_ERASURE_REASON = 'An erasure is pending for this person'\n\n/** A label over a value — the record page's one row shape. */\nfunction Fact(props: { label: string; children: React.ReactNode }) {\n return (\n <Stack spacing={0.25}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {props.label}\n </Typography>\n <Typography variant=\"body2\" component=\"div\">\n {props.children}\n </Typography>\n </Stack>\n )\n}\n\nexport interface LeadPropertiesCardProps {\n /**\n * The site this lead is acted on as — the mounted one, or at the\n * organization level the lead's own first capturing site (AGL-3278).\n * `null` for a lead no site has captured: the booking door, the call and\n * the email close, because each of them is one site's to offer.\n */\n hostId: string | null\n /**\n * The group the consent basis is read against (AGL-3278): the site's own\n * declared group, resolved by the page. Not `soloConsentGroup(hostId)`,\n * which is what this card used to spell and which cannot see a refusal\n * recorded against a sibling brand — three sites presenting as one sender\n * are one sender to the person unsubscribing from them.\n */\n consentGroup: ConsentGroup\n /**\n * The org the site belongs to, as the page already resolved it — the\n * custom lead fields are ORG-wide (AGL-3272), and a second lookup here\n * would be one more read per record page for an answer already in hand.\n */\n orgId: string | null\n leadId: string\n lead: Record<string, unknown> & CrmLeadFields\n leadStatus: FirestoreDocStatus\n /** The listener has not confirmed this document with the server yet. */\n fromCache: boolean\n basePath: string\n roster: OrgMemberOptions\n onConvert: () => void\n onUnqualify: () => void\n /**\n * Items the page adds to the overflow beside Unqualify — the privacy\n * erasure (AGL-2623) lives on the page, because it needs the workspace\n * role and the API, and this card owns the header it must appear in.\n */\n extraMenuItems?: RowActionsMenuItem[]\n /** What the page shows above the facts — the erasure-pending state. */\n banner?: React.ReactNode\n /**\n * Chips the page adds after the owner — the campaigns the lead is filed\n * under (AGL-3274), named from the containers the page reads once for\n * this header and the Campaigns card below it.\n */\n extraChips?: React.ReactNode\n /**\n * An erasure request is waiting on this person (AGL-2623). Convert stays\n * on the page but is refused with the reason, the way the overflow's items\n * are: a conversion filed now would reach the capture door only to be\n * refused there, and the lead itself goes when the request runs.\n */\n erasurePending?: boolean\n /**\n * The org the shell passed: the booking door reads whether the lead's site\n * runs Bookings and whether the plan is entitled to it (AGL-2660), and a\n * logged call reads the activity scope it belongs in (AGL-2661).\n */\n org?: Partial<AglynOrgBilling> | null\n}\n\n/**\n * What the team knows and decides about a lead: status, owner, notes, and\n * the identity and consent the capture recorded (AGL-2608).\n *\n * Two cards. The record's header carries the identity, the status and owner,\n * the consent the capture recorded and the record's actions. **Details**\n * under it carries the profile, the org's own lead fields under **More\n * fields**, and the notes — one card with one Save in its header, the way\n * Salesforce keeps a record's standard and custom fields in one Details\n * section.\n *\n * Status and owner are single-field client writes — the rules let a site\n * admin, editor or author update `hosts/{hostId}/leads`, and a one-field\n * `update` cannot roll anything else back. The details are drafts seeded\n * from the document, so their save goes through `writeGuardedBySeed`: a\n * draft edited over a cached read would otherwise overwrite a newer note or\n * profile with an older one.\n *\n * Converted leads are read-only here, but for the notes. Their status is the\n * conversion, and the header's actions become links to what the conversion\n * made.\n */\nexport function LeadPropertiesCard(props: LeadPropertiesCardProps) {\n const {\n hostId,\n consentGroup,\n orgId,\n leadId,\n lead,\n leadStatus,\n fromCache,\n basePath,\n roster,\n onConvert,\n onUnqualify,\n extraMenuItems = [],\n banner,\n extraChips,\n erasurePending = false,\n org,\n } = props\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const routes = crmRoutes(basePath)\n /*\n * Built at USE, not at render (AGL-3275). A lead is an org row, so the org\n * has to be known to address one — and `orgId` is null while the lookup\n * settles. Composing a ref from that null throws during render, which the\n * page shows as a 500 rather than as a card still loading.\n */\n const refFor = () => (orgId ? doc(firestore, 'orgs', orgId, 'leads', leadId) : null)\n const status = Aglyn.crmLeadStatus(lead)\n const converted = Boolean(lead.convertedContactId)\n const open = Aglyn.isCrmLeadOpen(lead) && !converted\n // The last verdict on the address (AGL-3245), as the platform stamped it.\n const emailState = Aglyn.readEmailState(lead)\n /** What a capture that took a number left on the document (AGL-2661). */\n const leadPhone = String(lead['phone'] ?? '').trim()\n\n const [notes, setNotes] = useState(String(lead.notes ?? ''))\n // The label's id, so the status combobox is named \"Status\" rather than\n // after the status it shows — see `LeadOwnerSelect`.\n const statusLabelId = useId()\n const [notesDirty, setNotesDirty] = useState(false)\n /*\n * THE LEAD'S OWN PROFILE (AGL-3231) — company, title, phone, website,\n * address, tags, lead source — edited in the Details card with the notes\n * and the custom fields, under one Save, the way the contact's Properties\n * card saves. Seeded from the document and guarded on save: a draft\n * edited over a cached read must not overwrite a newer profile with an\n * older one.\n */\n const [profile, setProfile] = useState<ProfileDraft>(() => profileDraftFrom(lead))\n const [profileDirty, setProfileDirty] = useState(false)\n const [saving, setSaving] = useState(false)\n const [profileErrors, setProfileErrors] = useState<Record<string, string>>({})\n useEffect(() => {\n if (!profileDirty) setProfile(profileDraftFrom(lead))\n // The draft follows the document until it is edited; the fields are\n // read one by one so a change to any of them reseeds.\n }, [\n profileDirty,\n lead.company,\n lead.jobTitle,\n lead.phone,\n lead.website,\n lead.leadSource,\n lead.tags,\n lead.address,\n ])\n /*\n * THE ORG'S OWN LEAD FIELDS (AGL-3272), edited under the same Save as\n * the profile: one button over one card, so a person filling a lead in\n * does not have to find two.\n *\n * The draft holds only the keys the reader touched, and Save writes the\n * difference as dotted paths — a `custom` map written whole would take\n * out every key this card did not show, which is what a retired field's\n * values and an integration's writes sit under.\n */\n const fields = useContactFieldDefinitions(orgId, 'lead')\n // The org's lead source values (AGL-3298), for the select below.\n const leadSources = useLeadSourcePicklist(orgId)\n const storedCustom = useMemo(() => lead.custom ?? {}, [lead.custom])\n const [custom, setCustom] = useState<CrmCustomDraft>({})\n\n const editProfile = <K extends keyof ProfileDraft>(key: K, value: ProfileDraft[K]) => {\n setProfile((current) => ({ ...current, [key]: value }))\n setProfileDirty(true)\n }\n // A newer note from the server replaces an UNEDITED draft; an edited one is\n // the reader's, and the guard on save decides whether it may land.\n useEffect(() => {\n if (!notesDirty) setNotes(String(lead.notes ?? ''))\n }, [lead.notes, notesDirty])\n\n /** One update of the lead, and whether it landed. */\n const write = async (fields: Record<string, unknown>, done: string): Promise<boolean> => {\n try {\n const ref = refFor()\n if (!ref) {\n enqueueSnackbar('Still loading this workspace — try again in a moment.', {\n variant: 'warning',\n persist: false,\n })\n return false\n }\n await updateDoc(ref, { ...fields, updatedAt: serverTimestamp() })\n enqueueSnackbar(done, { variant: 'success', persist: false })\n return true\n } catch (error) {\n enqueueSnackbar(\n error instanceof Error ? error.message : 'The lead could not be updated.',\n { variant: 'error' },\n )\n return false\n }\n }\n\n /*\n * What Save is offered for: a profile field edited, a custom value that\n * differs from the stored map, or the notes. Touched-and-put-back does\n * not count for a custom value — the draft keeps the key either way, and\n * a Save that wrote nothing would still bump `updatedAt`.\n */\n const customChanged = crmCustomDraftChanges(storedCustom, custom).length > 0\n const dirty = profileDirty || customChanged || notesDirty\n\n /** Every draft back to what the document holds. */\n const discard = () => {\n setProfile(profileDraftFrom(lead))\n setProfileDirty(false)\n setProfileErrors({})\n setCustom({})\n setNotes(String(lead.notes ?? ''))\n setNotesDirty(false)\n }\n\n /*\n * The Details card's one Save: the profile and the custom values while\n * the lead is open, and the notes, in one update. A converted lead's\n * profile is the contact's now, so only its notes are written.\n */\n const saveDetails = async () => {\n const update: Record<string, unknown> = {}\n if (!converted && (profileDirty || customChanged)) {\n const { patch, errors } = Aglyn.normalizeCrmLeadProfile({\n company: profile.company,\n jobTitle: profile.jobTitle,\n phone: profile.phone,\n website: profile.website,\n leadSource: profile.leadSource,\n tags: profile.tags,\n address: profile.address,\n })\n setProfileErrors(errors)\n if (Object.keys(errors).length) return\n /*\n * A required field the reader CLEARED is refused; one the lead has\n * always lacked is not this save's to demand, or a field added after\n * the lead was captured would block every later edit to its company.\n */\n const missing = crmCustomDraftMissingRequired(\n fields.active,\n storedCustom,\n custom,\n 'edit',\n )\n if (missing.length) {\n enqueueSnackbar(`${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} required.`, {\n variant: 'warning',\n })\n return\n }\n const profileFields = profileWrite(patch)\n Object.assign(\n update,\n profileFields,\n crmCustomDraftWrites(storedCustom, custom),\n // The company, title, tags and lead source are what the Leads list\n // searches and filters by (AGL-3321).\n crmClientListFields('leads', lead, profileFields, LEAD_CLIENT_LIST_FIELDS),\n )\n }\n if (notesDirty) update['notes'] = notes.trim().slice(0, NOTES_MAX)\n if (!Object.keys(update).length) return\n setSaving(true)\n let landed = false\n const verdict = await writeGuardedBySeed(\n { subject: 'lead', fromCache, unreadable: leadStatus === 'error' },\n async () => {\n landed = await write(update, 'Lead saved')\n },\n )\n setSaving(false)\n if (!verdict.ok) {\n enqueueSnackbar(verdict.message ?? 'The lead could not be saved.', {\n variant: 'warning',\n })\n return\n }\n if (!landed) return\n setProfileDirty(false)\n setNotesDirty(false)\n // The draft is spent: what it held is stored, and the controls read\n // the document again.\n setCustom({})\n }\n\n const consent = Aglyn.readMarketingBasis(lead, consentGroup)\n const consentLine =\n consent.basis === 'granted'\n ? `Opted in to marketing${\n consent.basisAtMs ? ` on ${new Date(consent.basisAtMs).toLocaleDateString()}` : ''\n }`\n : consent.basis === 'declined'\n ? 'Declined marketing'\n : 'No marketing consent recorded — this lead cannot be emailed marketing'\n\n return (\n <>\n <CrmRecordHeader\n kind=\"Lead\"\n title={String(lead['name'] || lead['email'] || leadId)}\n // The name is the heading; the address is the one line under it,\n // unless the address IS the name, in which case there is no second fact.\n subtitle={lead['name'] ? String(lead['email'] ?? '') : undefined}\n help={Aglyn.pluginDocsHelp('crmLeads', { anchor: '#working-a-lead-from-the-row' })}\n backHref={routes.section('leads')}\n backLabel=\"Back to leads\"\n // The booking door (AGL-2660), while the lead is still the record\n // being worked: once converted, the contact is where a meeting is\n // booked from, and the links below lead there.\n booking={converted ? undefined : { hostId, org, kind: 'lead', recordId: leadId }}\n actions={\n <>\n {/*\n Once converted, the lead's actions are the records the\n conversion made.\n */}\n {converted ? (\n <>\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.contact(String(lead.convertedContactId))}\n size=\"small\"\n variant=\"outlined\"\n >\n {'Open contact'}\n </Button>\n {lead.companyId ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.company(lead.companyId)}\n size=\"small\"\n variant=\"outlined\"\n >\n {'Open company'}\n </Button>\n ) : null}\n {lead.dealId ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.deal(lead.dealId)}\n size=\"small\"\n variant=\"outlined\"\n >\n {'Open deal'}\n </Button>\n ) : null}\n </>\n ) : erasurePending ? (\n <Tooltip title={CONVERT_PENDING_ERASURE_REASON}>\n {/* A disabled button receives no pointer events, so the\n tooltip anchors on the span around it. */}\n <span>\n <Button size=\"small\" variant=\"contained\" disabled>\n {'Convert'}\n </Button>\n </span>\n </Tooltip>\n ) : (\n <Button size=\"small\" variant=\"contained\" onClick={onConvert}>\n {'Convert'}\n </Button>\n )}\n {/* Dial the number the capture carried, and log the call (AGL-2661). */}\n <CrmCallButton\n hostId={hostId}\n org={org}\n link={{ leadId }}\n phone={leadPhone}\n />\n <CrmSendEmailButton\n hostId={hostId}\n leadId={leadId}\n email={String(lead['email'] ?? '')}\n name={String(lead['name'] ?? '')}\n emailState={emailState}\n />\n </>\n }\n menuItems={[\n ...(open\n ? [\n {\n key: 'unqualify',\n label: 'Unqualify',\n icon: <MdiIcon path={mdiAccountCancelOutline.path} size={0.8} />,\n destructive: true,\n onClick: onUnqualify,\n } satisfies RowActionsMenuItem,\n ]\n : []),\n ...extraMenuItems,\n ]}\n chips={\n <>\n <LeadStatusChip lead={lead} />\n {/* The verdict on the address (AGL-3245), beside the status: a\n bounce does not move New or Working, but it is the first\n thing a person deciding whether to write must see. */}\n <CrmEmailStateChip state={emailState} />\n <CrmRecordChip\n label=\"Owner\"\n value={lead.ownerUid ? roster.labelFor(lead.ownerUid) : undefined}\n />\n {extraChips}\n </>\n }\n >\n <Stack spacing={3}>\n {banner}\n {/*\n Only when the capture carried one (AGL-2661): the sign-up and\n booking doors write no phone, so a row for every lead would be a\n permanent blank. A form that captures one fills this.\n */}\n {leadPhone ? (\n <Fact label=\"Phone\">\n <CrmPhoneLink phone={leadPhone} />\n </Fact>\n ) : null}\n <Fact label=\"Marketing consent\">{consentLine}</Fact>\n <Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>\n {converted ? (\n <Fact label=\"Status\">\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <LeadStatusChip lead={lead} />\n <Typography variant=\"body2\" color=\"text.secondary\">\n {lead.convertedAtMs\n ? `Converted ${new Date(lead.convertedAtMs).toLocaleString()}`\n : 'Converted'}\n </Typography>\n </Stack>\n </Fact>\n ) : (\n <FormControl size=\"small\" sx={{ minWidth: 200 }}>\n <InputLabel id={statusLabelId}>{'Status'}</InputLabel>\n <Select\n labelId={statusLabelId}\n label=\"Status\"\n value={status === 'unqualified' ? 'unqualified' : status}\n onChange={(event) => {\n const next = String(event.target.value) as CrmLeadStatus\n if (next === 'unqualified') {\n onUnqualify()\n return\n }\n // Reopening drops the reason with the closed state: a lead\n // being worked again is not \"unqualified because …\".\n void write(\n {\n status: next,\n ...(status === 'unqualified' ? { unqualifiedReason: deleteField() } : {}),\n },\n 'Status updated',\n )\n }}\n >\n <MenuItem value=\"new\">{Aglyn.CRM_LEAD_STATUS_LABELS.new}</MenuItem>\n <MenuItem value=\"working\">{Aglyn.CRM_LEAD_STATUS_LABELS.working}</MenuItem>\n <MenuItem value=\"unqualified\">\n {`${Aglyn.CRM_LEAD_STATUS_LABELS.unqualified}…`}\n </MenuItem>\n </Select>\n </FormControl>\n )}\n <LeadOwnerSelect\n value={lead.ownerUid}\n roster={roster}\n fullWidth={false}\n onChange={(uid) =>\n void write({ ownerUid: uid || deleteField() }, uid ? 'Owner assigned' : 'Owner cleared')\n }\n />\n </Stack>\n {status === 'unqualified' && lead.unqualifiedReason ? (\n <Alert severity=\"info\">{`Unqualified: ${lead.unqualifiedReason}`}</Alert>\n ) : null}\n </Stack>\n </CrmRecordHeader>\n {/*\n The profile (AGL-3231), the org's own lead fields (AGL-3272) and the\n notes: what Salesforce keeps on a lead and hands to the contact and\n the account on convert. Read-only once converted — the contact is the\n record then, and the header's links lead there — but for the notes.\n */}\n <CardDisplay\n header={'Details'}\n help={Aglyn.pluginDocsHelp('crmLeads', { anchor: '#a-leads-page' })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Stack direction=\"row\" spacing={1}>\n {dirty ? (\n <Button size=\"small\" disabled={saving} onClick={discard}>\n {'Discard changes'}\n </Button>\n ) : null}\n <Button\n size=\"small\"\n variant=\"contained\"\n onClick={() => void saveDetails()}\n disabled={!dirty || saving}\n >\n {saving ? 'Saving…' : 'Save'}\n </Button>\n </Stack>\n ),\n }}\n >\n <Stack spacing={2}>\n <Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>\n <TextField\n size=\"small\"\n label=\"Company\"\n value={profile.company}\n onChange={(event) => editProfile('company', event.target.value)}\n disabled={converted}\n slotProps={{ htmlInput: { maxLength: TEXT_MAX } }}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Job title\"\n value={profile.jobTitle}\n onChange={(event) => editProfile('jobTitle', event.target.value)}\n disabled={converted}\n slotProps={{ htmlInput: { maxLength: TEXT_MAX } }}\n fullWidth\n />\n </Stack>\n <Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>\n <TextField\n size=\"small\"\n label=\"Phone\"\n value={profile.phone}\n onChange={(event) => editProfile('phone', event.target.value)}\n disabled={converted}\n error={Boolean(profileErrors['phone'])}\n helperText={profileErrors['phone'] || 'With the country code, like +1 512 555 0107'}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Website\"\n value={profile.website}\n onChange={(event) => editProfile('website', event.target.value)}\n disabled={converted}\n error={Boolean(profileErrors['website'])}\n helperText={profileErrors['website'] || 'Like acme.com'}\n fullWidth\n />\n </Stack>\n <Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>\n {/* The org's own values (AGL-3298); the stored one stays shown when the list no longer offers it. */}\n <LeadSourceSelect\n picklist={leadSources.picklist}\n value={profile.leadSource}\n stored={String(lead.leadSource ?? '')}\n onChange={(label) => editProfile('leadSource', label)}\n disabled={converted}\n />\n <TextField\n size=\"small\"\n label=\"Tags\"\n value={profile.tags}\n onChange={(event) => editProfile('tags', event.target.value)}\n disabled={converted}\n helperText=\"Comma-separated\"\n fullWidth\n />\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Address'}\n </Typography>\n <ContactAddressFields\n value={profile.address}\n onChange={(next) => editProfile('address', next)}\n disabled={converted}\n />\n {/*\n The org's own lead fields (AGL-3272), after the built-in ones\n because they describe the same record and save with it. Absent,\n not an empty box, while the org defines none.\n */}\n {fields.active.length ? (\n <>\n <Typography variant=\"subtitle2\">{'More fields'}</Typography>\n {fields.active.map((definition) => (\n <CrmCustomFieldControl\n key={definition.$id}\n definition={definition}\n value={crmCustomDraftValue(storedCustom, custom, definition.key)}\n onChange={(value) =>\n setCustom((current) => ({ ...current, [definition.key]: value }))\n }\n disabled={converted || saving}\n />\n ))}\n </>\n ) : null}\n <TextField\n size=\"small\"\n label=\"Notes\"\n value={notes}\n onChange={(event) => {\n setNotes(event.target.value)\n setNotesDirty(true)\n }}\n multiline\n minRows={3}\n fullWidth\n slotProps={{ htmlInput: { maxLength: NOTES_MAX } }}\n />\n </Stack>\n </CardDisplay>\n </>\n )\n}\nLeadPropertiesCard.displayName = 'LeadPropertiesCard'\n\nexport default LeadPropertiesCard\n"],"names":["Aglyn","mdiAccountCancelOutline","AppLink","CardDisplay","MdiIcon","useSnackbar","useFirestore","writeGuardedBySeed","Alert","Button","FormControl","InputLabel","MenuItem","Select","Stack","TextField","Tooltip","Typography","deleteField","doc","serverTimestamp","updateDoc","useEffect","useId","useMemo","useState","useContactFieldDefinitions","useLeadSourcePicklist","crmCustomDraftChanges","crmCustomDraftMissingRequired","crmCustomDraftValue","crmCustomDraftWrites","CrmCustomFieldControl","crmRoutes","addressDraftFrom","ContactAddressFields","CrmCallButton","CrmPhoneLink","CrmEmailStateChip","CrmRecordChip","CrmRecordHeader","CrmSendEmailButton","LeadOwnerSelect","LeadSourceSelect","LeadStatusChip","crmClientListFields","CRM_CLIENT_SEARCH_FIELDS","LEAD_CLIENT_LIST_FIELDS","NOTES_MAX","CRM_LEAD_NOTES_MAX","TEXT_MAX","CRM_LEAD_TEXT_MAX","profileDraftFrom","lead","company","String","jobTitle","phone","website","leadSource","tags","join","address","profileWrite","patch","write","key","value","Object","entries","undefined","CONVERT_PENDING_ERASURE_REASON","Fact","props","spacing","variant","color","label","component","children","LeadPropertiesCard","hostId","consentGroup","orgId","leadId","leadStatus","fromCache","basePath","roster","onConvert","onUnqualify","extraMenuItems","banner","extraChips","erasurePending","org","firestore","enqueueSnackbar","routes","refFor","status","crmLeadStatus","converted","Boolean","convertedContactId","open","isCrmLeadOpen","emailState","readEmailState","leadPhone","trim","notes","setNotes","statusLabelId","notesDirty","setNotesDirty","profile","setProfile","profileDirty","setProfileDirty","saving","setSaving","profileErrors","setProfileErrors","fields","leadSources","storedCustom","custom","setCustom","editProfile","current","done","ref","persist","updatedAt","error","Error","message","customChanged","length","dirty","discard","saveDetails","update","errors","normalizeCrmLeadProfile","keys","missing","active","profileFields","assign","slice","landed","verdict","subject","unreadable","ok","consent","readMarketingBasis","consentLine","basis","basisAtMs","Date","toLocaleDateString","kind","title","subtitle","help","pluginDocsHelp","anchor","backHref","section","backLabel","booking","recordId","actions","componentVariant","nativeButton","href","contact","size","companyId","dealId","deal","span","disabled","onClick","link","email","name","menuItems","icon","path","destructive","chips","state","ownerUid","labelFor","direction","xs","md","sx","alignItems","convertedAtMs","toLocaleString","minWidth","id","labelId","onChange","event","next","target","unqualifiedReason","CRM_LEAD_STATUS_LABELS","new","working","unqualified","fullWidth","uid","severity","header","contentGutterX","contentGutterY","HeaderProps","action","slotProps","htmlInput","maxLength","helperText","picklist","stored","map","definition","$id","multiline","minRows","displayName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/lead-properties-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport type {\n AglynOrgBilling,\n ConsentGroup,\n CrmLeadFields,\n CrmLeadProfilePatch,\n CrmLeadStatus,\n} from '@aglyn/aglyn'\nimport { mdiAccountCancelOutline } from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport {\n type FirestoreDocStatus,\n useFirestore,\n writeGuardedBySeed,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Alert,\n Button,\n FormControl,\n InputLabel,\n MenuItem,\n Select,\n Stack,\n TextField,\n Tooltip,\n Typography,\n} from '@mui/material'\nimport { deleteField, doc, serverTimestamp, updateDoc } from 'firebase/firestore'\nimport { useEffect, useId, useMemo, useState } from 'react'\nimport { useContactFieldDefinitions } from '../hooks/use-contact-field-definitions'\nimport { useLeadSourcePicklist } from '../hooks/use-lead-source-picklist'\nimport {\n crmCustomDraftChanges,\n type CrmCustomDraft,\n crmCustomDraftMissingRequired,\n crmCustomDraftValue,\n crmCustomDraftWrites,\n} from '../model/crm-custom-draft'\nimport { CrmCustomFieldControl } from './crm-custom-field-control'\nimport { crmRoutes } from '../model/crm-routes'\nimport {\n addressDraftFrom,\n ContactAddressFields,\n type AddressDraft,\n} from './contact-address-fields'\nimport { CrmCallButton, CrmPhoneLink } from './crm-call-actions'\nimport { CrmEmailStateChip } from './crm-email-state-chip'\nimport { CrmEmailGatewayChip } from './crm-email-check'\nimport { CrmRecordChip, CrmRecordHeader } from './crm-record-header'\nimport { CrmSendEmailButton } from './crm-send-email-button'\nimport type { OrgMemberOptions } from '../hooks/use-org-member-options'\nimport { LeadOwnerSelect } from './lead-owner-select'\nimport { LeadSourceSelect } from './lead-source-select'\nimport { LeadStatusChip } from './lead-status-chip'\nimport { crmClientListFields, CRM_CLIENT_SEARCH_FIELDS } from '../model/crm-list-query'\n\n/** The Leads list's fields a profile save rewrites; the verdict key is the server's. */\nconst LEAD_CLIENT_LIST_FIELDS = [...CRM_CLIENT_SEARCH_FIELDS, 'leadSourceKey'] as const\n\nconst NOTES_MAX = Aglyn.CRM_LEAD_NOTES_MAX\nconst TEXT_MAX = Aglyn.CRM_LEAD_TEXT_MAX\n\n/** The profile as the form holds it: every field a string, the address a draft. */\ninterface ProfileDraft {\n company: string\n jobTitle: string\n phone: string\n website: string\n leadSource: string\n tags: string\n address: AddressDraft\n}\n\n/** The stored profile as a draft the fields can edit. */\nfunction profileDraftFrom(lead: Record<string, unknown> & CrmLeadFields): ProfileDraft {\n return {\n company: String(lead.company ?? ''),\n jobTitle: String(lead.jobTitle ?? ''),\n phone: String(lead.phone ?? lead['phone'] ?? ''),\n website: String(lead.website ?? ''),\n leadSource: String(lead.leadSource ?? ''),\n tags: (lead.tags ?? []).join(', '),\n address: addressDraftFrom(lead.address ?? null),\n }\n}\n\n/** The patch as the document takes it: a cleared field is deleted, not blanked. */\nfunction profileWrite(patch: CrmLeadProfilePatch): Record<string, unknown> {\n const write: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(patch)) {\n if (value === undefined) continue\n write[key] = value === null ? deleteField() : value\n }\n return write\n}\n\n/**\n * Why Convert is refused while an erasure waits on the person — the same\n * sentence shape the overflow's items carry, so the two read as one state.\n */\nexport const CONVERT_PENDING_ERASURE_REASON = 'An erasure is pending for this person'\n\n/** A label over a value — the record page's one row shape. */\nfunction Fact(props: { label: string; children: React.ReactNode }) {\n return (\n <Stack spacing={0.25}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {props.label}\n </Typography>\n <Typography variant=\"body2\" component=\"div\">\n {props.children}\n </Typography>\n </Stack>\n )\n}\n\nexport interface LeadPropertiesCardProps {\n /**\n * The site this lead is acted on as — the mounted one, or at the\n * organization level the lead's own first capturing site (AGL-3278).\n * `null` for a lead no site has captured: the booking door, the call and\n * the email close, because each of them is one site's to offer.\n */\n hostId: string | null\n /**\n * The group the consent basis is read against (AGL-3278): the site's own\n * declared group, resolved by the page. Not `soloConsentGroup(hostId)`,\n * which is what this card used to spell and which cannot see a refusal\n * recorded against a sibling brand — three sites presenting as one sender\n * are one sender to the person unsubscribing from them.\n */\n consentGroup: ConsentGroup\n /**\n * The org the site belongs to, as the page already resolved it — the\n * custom lead fields are ORG-wide (AGL-3272), and a second lookup here\n * would be one more read per record page for an answer already in hand.\n */\n orgId: string | null\n leadId: string\n lead: Record<string, unknown> & CrmLeadFields\n leadStatus: FirestoreDocStatus\n /** The listener has not confirmed this document with the server yet. */\n fromCache: boolean\n basePath: string\n roster: OrgMemberOptions\n onConvert: () => void\n onUnqualify: () => void\n /**\n * Items the page adds to the overflow beside Unqualify — the privacy\n * erasure (AGL-2623) lives on the page, because it needs the workspace\n * role and the API, and this card owns the header it must appear in.\n */\n extraMenuItems?: RowActionsMenuItem[]\n /** What the page shows above the facts — the erasure-pending state. */\n banner?: React.ReactNode\n /**\n * Chips the page adds after the owner — the campaigns the lead is filed\n * under (AGL-3274), named from the containers the page reads once for\n * this header and the Campaigns card below it.\n */\n extraChips?: React.ReactNode\n /**\n * An erasure request is waiting on this person (AGL-2623). Convert stays\n * on the page but is refused with the reason, the way the overflow's items\n * are: a conversion filed now would reach the capture door only to be\n * refused there, and the lead itself goes when the request runs.\n */\n erasurePending?: boolean\n /**\n * The org the shell passed: the booking door reads whether the lead's site\n * runs Bookings and whether the plan is entitled to it (AGL-2660), and a\n * logged call reads the activity scope it belongs in (AGL-2661).\n */\n org?: Partial<AglynOrgBilling> | null\n}\n\n/**\n * What the team knows and decides about a lead: status, owner, notes, and\n * the identity and consent the capture recorded (AGL-2608).\n *\n * Two cards. The record's header carries the identity, the status and owner,\n * the consent the capture recorded and the record's actions. **Details**\n * under it carries the profile, the org's own lead fields under **More\n * fields**, and the notes — one card with one Save in its header, the way\n * Salesforce keeps a record's standard and custom fields in one Details\n * section.\n *\n * Status and owner are single-field client writes — the rules let a site\n * admin, editor or author update `hosts/{hostId}/leads`, and a one-field\n * `update` cannot roll anything else back. The details are drafts seeded\n * from the document, so their save goes through `writeGuardedBySeed`: a\n * draft edited over a cached read would otherwise overwrite a newer note or\n * profile with an older one.\n *\n * Converted leads are read-only here, but for the notes. Their status is the\n * conversion, and the header's actions become links to what the conversion\n * made.\n */\nexport function LeadPropertiesCard(props: LeadPropertiesCardProps) {\n const {\n hostId,\n consentGroup,\n orgId,\n leadId,\n lead,\n leadStatus,\n fromCache,\n basePath,\n roster,\n onConvert,\n onUnqualify,\n extraMenuItems = [],\n banner,\n extraChips,\n erasurePending = false,\n org,\n } = props\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const routes = crmRoutes(basePath)\n /*\n * Built at USE, not at render (AGL-3275). A lead is an org row, so the org\n * has to be known to address one — and `orgId` is null while the lookup\n * settles. Composing a ref from that null throws during render, which the\n * page shows as a 500 rather than as a card still loading.\n */\n const refFor = () => (orgId ? doc(firestore, 'orgs', orgId, 'leads', leadId) : null)\n const status = Aglyn.crmLeadStatus(lead)\n const converted = Boolean(lead.convertedContactId)\n const open = Aglyn.isCrmLeadOpen(lead) && !converted\n // The last verdict on the address (AGL-3245), as the platform stamped it.\n const emailState = Aglyn.readEmailState(lead)\n /** What a capture that took a number left on the document (AGL-2661). */\n const leadPhone = String(lead['phone'] ?? '').trim()\n\n const [notes, setNotes] = useState(String(lead.notes ?? ''))\n // The label's id, so the status combobox is named \"Status\" rather than\n // after the status it shows — see `LeadOwnerSelect`.\n const statusLabelId = useId()\n const [notesDirty, setNotesDirty] = useState(false)\n /*\n * THE LEAD'S OWN PROFILE (AGL-3231) — company, title, phone, website,\n * address, tags, lead source — edited in the Details card with the notes\n * and the custom fields, under one Save, the way the contact's Properties\n * card saves. Seeded from the document and guarded on save: a draft\n * edited over a cached read must not overwrite a newer profile with an\n * older one.\n */\n const [profile, setProfile] = useState<ProfileDraft>(() => profileDraftFrom(lead))\n const [profileDirty, setProfileDirty] = useState(false)\n const [saving, setSaving] = useState(false)\n const [profileErrors, setProfileErrors] = useState<Record<string, string>>({})\n useEffect(() => {\n if (!profileDirty) setProfile(profileDraftFrom(lead))\n // The draft follows the document until it is edited; the fields are\n // read one by one so a change to any of them reseeds.\n }, [\n profileDirty,\n lead.company,\n lead.jobTitle,\n lead.phone,\n lead.website,\n lead.leadSource,\n lead.tags,\n lead.address,\n ])\n /*\n * THE ORG'S OWN LEAD FIELDS (AGL-3272), edited under the same Save as\n * the profile: one button over one card, so a person filling a lead in\n * does not have to find two.\n *\n * The draft holds only the keys the reader touched, and Save writes the\n * difference as dotted paths — a `custom` map written whole would take\n * out every key this card did not show, which is what a retired field's\n * values and an integration's writes sit under.\n */\n const fields = useContactFieldDefinitions(orgId, 'lead')\n // The org's lead source values (AGL-3298), for the select below.\n const leadSources = useLeadSourcePicklist(orgId)\n const storedCustom = useMemo(() => lead.custom ?? {}, [lead.custom])\n const [custom, setCustom] = useState<CrmCustomDraft>({})\n\n const editProfile = <K extends keyof ProfileDraft>(key: K, value: ProfileDraft[K]) => {\n setProfile((current) => ({ ...current, [key]: value }))\n setProfileDirty(true)\n }\n // A newer note from the server replaces an UNEDITED draft; an edited one is\n // the reader's, and the guard on save decides whether it may land.\n useEffect(() => {\n if (!notesDirty) setNotes(String(lead.notes ?? ''))\n }, [lead.notes, notesDirty])\n\n /** One update of the lead, and whether it landed. */\n const write = async (fields: Record<string, unknown>, done: string): Promise<boolean> => {\n try {\n const ref = refFor()\n if (!ref) {\n enqueueSnackbar('Still loading this workspace — try again in a moment.', {\n variant: 'warning',\n persist: false,\n })\n return false\n }\n await updateDoc(ref, { ...fields, updatedAt: serverTimestamp() })\n enqueueSnackbar(done, { variant: 'success', persist: false })\n return true\n } catch (error) {\n enqueueSnackbar(\n error instanceof Error ? error.message : 'The lead could not be updated.',\n { variant: 'error' },\n )\n return false\n }\n }\n\n /*\n * What Save is offered for: a profile field edited, a custom value that\n * differs from the stored map, or the notes. Touched-and-put-back does\n * not count for a custom value — the draft keeps the key either way, and\n * a Save that wrote nothing would still bump `updatedAt`.\n */\n const customChanged = crmCustomDraftChanges(storedCustom, custom).length > 0\n const dirty = profileDirty || customChanged || notesDirty\n\n /** Every draft back to what the document holds. */\n const discard = () => {\n setProfile(profileDraftFrom(lead))\n setProfileDirty(false)\n setProfileErrors({})\n setCustom({})\n setNotes(String(lead.notes ?? ''))\n setNotesDirty(false)\n }\n\n /*\n * The Details card's one Save: the profile and the custom values while\n * the lead is open, and the notes, in one update. A converted lead's\n * profile is the contact's now, so only its notes are written.\n */\n const saveDetails = async () => {\n const update: Record<string, unknown> = {}\n if (!converted && (profileDirty || customChanged)) {\n const { patch, errors } = Aglyn.normalizeCrmLeadProfile({\n company: profile.company,\n jobTitle: profile.jobTitle,\n phone: profile.phone,\n website: profile.website,\n leadSource: profile.leadSource,\n tags: profile.tags,\n address: profile.address,\n })\n setProfileErrors(errors)\n if (Object.keys(errors).length) return\n /*\n * A required field the reader CLEARED is refused; one the lead has\n * always lacked is not this save's to demand, or a field added after\n * the lead was captured would block every later edit to its company.\n */\n const missing = crmCustomDraftMissingRequired(\n fields.active,\n storedCustom,\n custom,\n 'edit',\n )\n if (missing.length) {\n enqueueSnackbar(`${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} required.`, {\n variant: 'warning',\n })\n return\n }\n const profileFields = profileWrite(patch)\n Object.assign(\n update,\n profileFields,\n crmCustomDraftWrites(storedCustom, custom),\n // The company, title, tags and lead source are what the Leads list\n // searches and filters by (AGL-3321).\n crmClientListFields('leads', lead, profileFields, LEAD_CLIENT_LIST_FIELDS),\n )\n }\n if (notesDirty) update['notes'] = notes.trim().slice(0, NOTES_MAX)\n if (!Object.keys(update).length) return\n setSaving(true)\n let landed = false\n const verdict = await writeGuardedBySeed(\n { subject: 'lead', fromCache, unreadable: leadStatus === 'error' },\n async () => {\n landed = await write(update, 'Lead saved')\n },\n )\n setSaving(false)\n if (!verdict.ok) {\n enqueueSnackbar(verdict.message ?? 'The lead could not be saved.', {\n variant: 'warning',\n })\n return\n }\n if (!landed) return\n setProfileDirty(false)\n setNotesDirty(false)\n // The draft is spent: what it held is stored, and the controls read\n // the document again.\n setCustom({})\n }\n\n const consent = Aglyn.readMarketingBasis(lead, consentGroup)\n const consentLine =\n consent.basis === 'granted'\n ? `Opted in to marketing${\n consent.basisAtMs ? ` on ${new Date(consent.basisAtMs).toLocaleDateString()}` : ''\n }`\n : consent.basis === 'declined'\n ? 'Declined marketing'\n : 'No marketing consent recorded — this lead cannot be emailed marketing'\n\n return (\n <>\n <CrmRecordHeader\n kind=\"Lead\"\n title={String(lead['name'] || lead['email'] || leadId)}\n // The name is the heading; the address is the one line under it,\n // unless the address IS the name, in which case there is no second fact.\n subtitle={lead['name'] ? String(lead['email'] ?? '') : undefined}\n help={Aglyn.pluginDocsHelp('crmLeads', { anchor: '#working-a-lead-from-the-row' })}\n backHref={routes.section('leads')}\n backLabel=\"Back to leads\"\n // The booking door (AGL-2660), while the lead is still the record\n // being worked: once converted, the contact is where a meeting is\n // booked from, and the links below lead there.\n booking={converted ? undefined : { hostId, org, kind: 'lead', recordId: leadId }}\n actions={\n <>\n {/*\n Once converted, the lead's actions are the records the\n conversion made.\n */}\n {converted ? (\n <>\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.contact(String(lead.convertedContactId))}\n size=\"small\"\n variant=\"outlined\"\n >\n {'Open contact'}\n </Button>\n {lead.companyId ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.company(lead.companyId)}\n size=\"small\"\n variant=\"outlined\"\n >\n {'Open company'}\n </Button>\n ) : null}\n {lead.dealId ? (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.deal(lead.dealId)}\n size=\"small\"\n variant=\"outlined\"\n >\n {'Open deal'}\n </Button>\n ) : null}\n </>\n ) : erasurePending ? (\n <Tooltip title={CONVERT_PENDING_ERASURE_REASON}>\n {/* A disabled button receives no pointer events, so the\n tooltip anchors on the span around it. */}\n <span>\n <Button size=\"small\" variant=\"contained\" disabled>\n {'Convert'}\n </Button>\n </span>\n </Tooltip>\n ) : (\n <Button size=\"small\" variant=\"contained\" onClick={onConvert}>\n {'Convert'}\n </Button>\n )}\n {/* Dial the number the capture carried, and log the call (AGL-2661). */}\n <CrmCallButton\n hostId={hostId}\n org={org}\n link={{ leadId }}\n phone={leadPhone}\n />\n <CrmSendEmailButton\n hostId={hostId}\n leadId={leadId}\n email={String(lead['email'] ?? '')}\n name={String(lead['name'] ?? '')}\n emailState={emailState}\n />\n </>\n }\n menuItems={[\n ...(open\n ? [\n {\n key: 'unqualify',\n label: 'Unqualify',\n icon: <MdiIcon path={mdiAccountCancelOutline.path} size={0.8} />,\n destructive: true,\n onClick: onUnqualify,\n } satisfies RowActionsMenuItem,\n ]\n : []),\n ...extraMenuItems,\n ]}\n chips={\n <>\n <LeadStatusChip lead={lead} />\n {/* The verdict on the address (AGL-3245), beside the status: a\n bounce does not move New or Working, but it is the first\n thing a person deciding whether to write must see. */}\n <CrmEmailStateChip state={emailState} />\n {/* The mail gateway in front of the address, and what it did\n with this site's mail this week (AGL-3328). */}\n <CrmEmailGatewayChip\n hostId={hostId}\n email={String(lead['email'] ?? '')}\n emailState={emailState}\n />\n <CrmRecordChip\n label=\"Owner\"\n value={lead.ownerUid ? roster.labelFor(lead.ownerUid) : undefined}\n />\n {extraChips}\n </>\n }\n >\n <Stack spacing={3}>\n {banner}\n {/*\n Only when the capture carried one (AGL-2661): the sign-up and\n booking doors write no phone, so a row for every lead would be a\n permanent blank. A form that captures one fills this.\n */}\n {leadPhone ? (\n <Fact label=\"Phone\">\n <CrmPhoneLink phone={leadPhone} />\n </Fact>\n ) : null}\n <Fact label=\"Marketing consent\">{consentLine}</Fact>\n <Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>\n {converted ? (\n <Fact label=\"Status\">\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <LeadStatusChip lead={lead} />\n <Typography variant=\"body2\" color=\"text.secondary\">\n {lead.convertedAtMs\n ? `Converted ${new Date(lead.convertedAtMs).toLocaleString()}`\n : 'Converted'}\n </Typography>\n </Stack>\n </Fact>\n ) : (\n <FormControl size=\"small\" sx={{ minWidth: 200 }}>\n <InputLabel id={statusLabelId}>{'Status'}</InputLabel>\n <Select\n labelId={statusLabelId}\n label=\"Status\"\n value={status === 'unqualified' ? 'unqualified' : status}\n onChange={(event) => {\n const next = String(event.target.value) as CrmLeadStatus\n if (next === 'unqualified') {\n onUnqualify()\n return\n }\n // Reopening drops the reason with the closed state: a lead\n // being worked again is not \"unqualified because …\".\n void write(\n {\n status: next,\n ...(status === 'unqualified' ? { unqualifiedReason: deleteField() } : {}),\n },\n 'Status updated',\n )\n }}\n >\n <MenuItem value=\"new\">{Aglyn.CRM_LEAD_STATUS_LABELS.new}</MenuItem>\n <MenuItem value=\"working\">{Aglyn.CRM_LEAD_STATUS_LABELS.working}</MenuItem>\n <MenuItem value=\"unqualified\">\n {`${Aglyn.CRM_LEAD_STATUS_LABELS.unqualified}…`}\n </MenuItem>\n </Select>\n </FormControl>\n )}\n <LeadOwnerSelect\n value={lead.ownerUid}\n roster={roster}\n fullWidth={false}\n onChange={(uid) =>\n void write({ ownerUid: uid || deleteField() }, uid ? 'Owner assigned' : 'Owner cleared')\n }\n />\n </Stack>\n {status === 'unqualified' && lead.unqualifiedReason ? (\n <Alert severity=\"info\">{`Unqualified: ${lead.unqualifiedReason}`}</Alert>\n ) : null}\n </Stack>\n </CrmRecordHeader>\n {/*\n The profile (AGL-3231), the org's own lead fields (AGL-3272) and the\n notes: what Salesforce keeps on a lead and hands to the contact and\n the account on convert. Read-only once converted — the contact is the\n record then, and the header's links lead there — but for the notes.\n */}\n <CardDisplay\n header={'Details'}\n help={Aglyn.pluginDocsHelp('crmLeads', { anchor: '#a-leads-page' })}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Stack direction=\"row\" spacing={1}>\n {dirty ? (\n <Button size=\"small\" disabled={saving} onClick={discard}>\n {'Discard changes'}\n </Button>\n ) : null}\n <Button\n size=\"small\"\n variant=\"contained\"\n onClick={() => void saveDetails()}\n disabled={!dirty || saving}\n >\n {saving ? 'Saving…' : 'Save'}\n </Button>\n </Stack>\n ),\n }}\n >\n <Stack spacing={2}>\n <Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>\n <TextField\n size=\"small\"\n label=\"Company\"\n value={profile.company}\n onChange={(event) => editProfile('company', event.target.value)}\n disabled={converted}\n slotProps={{ htmlInput: { maxLength: TEXT_MAX } }}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Job title\"\n value={profile.jobTitle}\n onChange={(event) => editProfile('jobTitle', event.target.value)}\n disabled={converted}\n slotProps={{ htmlInput: { maxLength: TEXT_MAX } }}\n fullWidth\n />\n </Stack>\n <Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>\n <TextField\n size=\"small\"\n label=\"Phone\"\n value={profile.phone}\n onChange={(event) => editProfile('phone', event.target.value)}\n disabled={converted}\n error={Boolean(profileErrors['phone'])}\n helperText={profileErrors['phone'] || 'With the country code, like +1 512 555 0107'}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Website\"\n value={profile.website}\n onChange={(event) => editProfile('website', event.target.value)}\n disabled={converted}\n error={Boolean(profileErrors['website'])}\n helperText={profileErrors['website'] || 'Like acme.com'}\n fullWidth\n />\n </Stack>\n <Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>\n {/* The org's own values (AGL-3298); the stored one stays shown when the list no longer offers it. */}\n <LeadSourceSelect\n picklist={leadSources.picklist}\n value={profile.leadSource}\n stored={String(lead.leadSource ?? '')}\n onChange={(label) => editProfile('leadSource', label)}\n disabled={converted}\n />\n <TextField\n size=\"small\"\n label=\"Tags\"\n value={profile.tags}\n onChange={(event) => editProfile('tags', event.target.value)}\n disabled={converted}\n helperText=\"Comma-separated\"\n fullWidth\n />\n </Stack>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'Address'}\n </Typography>\n <ContactAddressFields\n value={profile.address}\n onChange={(next) => editProfile('address', next)}\n disabled={converted}\n />\n {/*\n The org's own lead fields (AGL-3272), after the built-in ones\n because they describe the same record and save with it. Absent,\n not an empty box, while the org defines none.\n */}\n {fields.active.length ? (\n <>\n <Typography variant=\"subtitle2\">{'More fields'}</Typography>\n {fields.active.map((definition) => (\n <CrmCustomFieldControl\n key={definition.$id}\n definition={definition}\n value={crmCustomDraftValue(storedCustom, custom, definition.key)}\n onChange={(value) =>\n setCustom((current) => ({ ...current, [definition.key]: value }))\n }\n disabled={converted || saving}\n />\n ))}\n </>\n ) : null}\n <TextField\n size=\"small\"\n label=\"Notes\"\n value={notes}\n onChange={(event) => {\n setNotes(event.target.value)\n setNotesDirty(true)\n }}\n multiline\n minRows={3}\n fullWidth\n slotProps={{ htmlInput: { maxLength: NOTES_MAX } }}\n />\n </Stack>\n </CardDisplay>\n </>\n )\n}\nLeadPropertiesCard.displayName = 'LeadPropertiesCard'\n\nexport default LeadPropertiesCard\n"],"names":["Aglyn","mdiAccountCancelOutline","AppLink","CardDisplay","MdiIcon","useSnackbar","useFirestore","writeGuardedBySeed","Alert","Button","FormControl","InputLabel","MenuItem","Select","Stack","TextField","Tooltip","Typography","deleteField","doc","serverTimestamp","updateDoc","useEffect","useId","useMemo","useState","useContactFieldDefinitions","useLeadSourcePicklist","crmCustomDraftChanges","crmCustomDraftMissingRequired","crmCustomDraftValue","crmCustomDraftWrites","CrmCustomFieldControl","crmRoutes","addressDraftFrom","ContactAddressFields","CrmCallButton","CrmPhoneLink","CrmEmailStateChip","CrmEmailGatewayChip","CrmRecordChip","CrmRecordHeader","CrmSendEmailButton","LeadOwnerSelect","LeadSourceSelect","LeadStatusChip","crmClientListFields","CRM_CLIENT_SEARCH_FIELDS","LEAD_CLIENT_LIST_FIELDS","NOTES_MAX","CRM_LEAD_NOTES_MAX","TEXT_MAX","CRM_LEAD_TEXT_MAX","profileDraftFrom","lead","company","String","jobTitle","phone","website","leadSource","tags","join","address","profileWrite","patch","write","key","value","Object","entries","undefined","CONVERT_PENDING_ERASURE_REASON","Fact","props","spacing","variant","color","label","component","children","LeadPropertiesCard","hostId","consentGroup","orgId","leadId","leadStatus","fromCache","basePath","roster","onConvert","onUnqualify","extraMenuItems","banner","extraChips","erasurePending","org","firestore","enqueueSnackbar","routes","refFor","status","crmLeadStatus","converted","Boolean","convertedContactId","open","isCrmLeadOpen","emailState","readEmailState","leadPhone","trim","notes","setNotes","statusLabelId","notesDirty","setNotesDirty","profile","setProfile","profileDirty","setProfileDirty","saving","setSaving","profileErrors","setProfileErrors","fields","leadSources","storedCustom","custom","setCustom","editProfile","current","done","ref","persist","updatedAt","error","Error","message","customChanged","length","dirty","discard","saveDetails","update","errors","normalizeCrmLeadProfile","keys","missing","active","profileFields","assign","slice","landed","verdict","subject","unreadable","ok","consent","readMarketingBasis","consentLine","basis","basisAtMs","Date","toLocaleDateString","kind","title","subtitle","help","pluginDocsHelp","anchor","backHref","section","backLabel","booking","recordId","actions","componentVariant","nativeButton","href","contact","size","companyId","dealId","deal","span","disabled","onClick","link","email","name","menuItems","icon","path","destructive","chips","state","ownerUid","labelFor","direction","xs","md","sx","alignItems","convertedAtMs","toLocaleString","minWidth","id","labelId","onChange","event","next","target","unqualifiedReason","CRM_LEAD_STATUS_LABELS","new","working","unqualified","fullWidth","uid","severity","header","contentGutterX","contentGutterY","HeaderProps","action","slotProps","htmlInput","maxLength","helperText","picklist","stored","map","definition","$id","multiline","minRows","displayName"],"mappings":"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"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** The routes `registerCrmConsoleApi` registers under `/api/crm/`. */
|
|
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';
|
|
2
|
+
export type CrmApiRoute = 'contacts-create' | 'leads-create' | 'contact-email-history' | 'contact-update' | 'company-delete' | 'contacts-merge' | 'email-send' | 'email-check' | '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 | '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;
|
|
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 | 'email-check'\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;AAyB3D;;;;;;;;;;;;;;;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"}
|
|
@@ -19,6 +19,7 @@ import { registerPluginConsentGroupParticipant } from "@aglyn/aglyn/plugin-manag
|
|
|
19
19
|
import { registerPluginContactCaptureWriter } from "@aglyn/aglyn/plugin-manager/plugin-contact-capture";
|
|
20
20
|
import { registerPluginLeadConversionListener } from "@aglyn/aglyn/plugin-manager/plugin-lead-conversion";
|
|
21
21
|
import { registerPluginPersonMatcher } from "@aglyn/aglyn/plugin-manager/plugin-person-matches";
|
|
22
|
+
import { registerPluginRecordEmailStateWriter } from "@aglyn/aglyn/plugin-manager/plugin-record-email-state";
|
|
22
23
|
import { BUNDLE_ID } from "./constants/bundle-common.js";
|
|
23
24
|
import { summarizeConsentGroupChange } from "./model/consent-group-summary.js";
|
|
24
25
|
/**
|
|
@@ -90,6 +91,20 @@ import { summarizeConsentGroupChange } from "./model/consent-group-summary.js";
|
|
|
90
91
|
// the CRM switched on, because the contacts — and the refusals on them —
|
|
91
92
|
// outlive the switch. Deferred like the rest; the summary is plain words
|
|
92
93
|
// and answers synchronously.
|
|
94
|
+
// The record email-state writer (AGL-3245), here as well as in the console
|
|
95
|
+
// API so the tenant's doors reach it too: a form submission's capture is
|
|
96
|
+
// checked for deliverability after its response (AGL-3328), and the "Would
|
|
97
|
+
// bounce" that check finds is written through this seam. Deferred like the
|
|
98
|
+
// rest; the console's own registration replaces this one with the same
|
|
99
|
+
// writer, loaded eagerly.
|
|
100
|
+
registerPluginRecordEmailStateWriter({
|
|
101
|
+
async stamp (request) {
|
|
102
|
+
const { createCrmRecordEmailStateWriter, defaultCrmRecordEmailStateDeps } = await import("./server/record-email-state.js");
|
|
103
|
+
return createCrmRecordEmailStateWriter(defaultCrmRecordEmailStateDeps()).stamp(request);
|
|
104
|
+
}
|
|
105
|
+
}, {
|
|
106
|
+
pluginId: BUNDLE_ID
|
|
107
|
+
});
|
|
93
108
|
registerPluginConsentGroupParticipant({
|
|
94
109
|
async preview (request) {
|
|
95
110
|
const { consentGroupParticipant } = await import("./server/consent-group-participant.js");
|
|
@@ -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 { registerPluginConsentGroupParticipant } from '@aglyn/aglyn/plugin-manager/plugin-consent-group-change'\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 { registerPluginPersonMatcher } from '@aglyn/aglyn/plugin-manager/plugin-person-matches'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { summarizeConsentGroupChange } from './model/consent-group-summary'\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 // \"Did this workspace already know this person?\" (AGL-3289), asked by the\n // staff console's acquisition card. Deferred like the rest: the reads load\n // when a staff member first opens a card, not when the process starts.\n registerPluginPersonMatcher(\n async (request) => {\n const { matchCrmPeople } = await import('./server/person-matches')\n return matchCrmPeople(request)\n },\n { pluginId: BUNDLE_ID },\n )\n // The CRM's share of a consent group change (AGL-3320): a contact's\n // per-site refusal carried across a separation, and the records keyed by a\n // group re-homed after the flip. Registered whether or not a workspace has\n // the CRM switched on, because the contacts — and the refusals on them —\n // outlive the switch. Deferred like the rest; the summary is plain words\n // and answers synchronously.\n registerPluginConsentGroupParticipant(\n {\n async preview(request) {\n const { consentGroupParticipant } = await import('./server/consent-group-participant')\n return consentGroupParticipant.preview(request)\n },\n async run(request) {\n const { consentGroupParticipant } = await import('./server/consent-group-participant')\n return consentGroupParticipant.run(request)\n },\n summarize: summarizeConsentGroupChange,\n },\n { pluginId: BUNDLE_ID },\n )\n}\n\n"],"names":["registerPluginConsentGroupParticipant","registerPluginContactCaptureWriter","registerPluginLeadConversionListener","registerPluginPersonMatcher","BUNDLE_ID","summarizeConsentGroupChange","crmContactCaptureWriter","capture","request","captureContactForCrm","registerCrmServerDeclarations","pluginId","carryLeadCampaignsOnConversion","matchCrmPeople","preview","consentGroupParticipant","run","summarize"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,8EAA8E;AAC9E,6EAA6E;AAC7E,SAASA,qCAAqC,QAAQ,0DAAyD;AAC/G,SACEC,kCAAkC,QAE7B,qDAAoD;AAC3D,SAASC,oCAAoC,QAAQ,qDAAoD;AACzG,SAASC,2BAA2B,QAAQ,oDAAmD;AAC/F,SAASC,SAAS,QAAQ,+BAA2B;AACrD,SAASC,2BAA2B,QAAQ,mCAA+B;AAE3E;;;;;;;;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 { registerPluginConsentGroupParticipant } from '@aglyn/aglyn/plugin-manager/plugin-consent-group-change'\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 { registerPluginPersonMatcher } from '@aglyn/aglyn/plugin-manager/plugin-person-matches'\nimport { registerPluginRecordEmailStateWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { summarizeConsentGroupChange } from './model/consent-group-summary'\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 // \"Did this workspace already know this person?\" (AGL-3289), asked by the\n // staff console's acquisition card. Deferred like the rest: the reads load\n // when a staff member first opens a card, not when the process starts.\n registerPluginPersonMatcher(\n async (request) => {\n const { matchCrmPeople } = await import('./server/person-matches')\n return matchCrmPeople(request)\n },\n { pluginId: BUNDLE_ID },\n )\n // The CRM's share of a consent group change (AGL-3320): a contact's\n // per-site refusal carried across a separation, and the records keyed by a\n // group re-homed after the flip. Registered whether or not a workspace has\n // the CRM switched on, because the contacts — and the refusals on them —\n // outlive the switch. Deferred like the rest; the summary is plain words\n // and answers synchronously.\n // The record email-state writer (AGL-3245), here as well as in the console\n // API so the tenant's doors reach it too: a form submission's capture is\n // checked for deliverability after its response (AGL-3328), and the \"Would\n // bounce\" that check finds is written through this seam. Deferred like the\n // rest; the console's own registration replaces this one with the same\n // writer, loaded eagerly.\n registerPluginRecordEmailStateWriter(\n {\n async stamp(request) {\n const { createCrmRecordEmailStateWriter, defaultCrmRecordEmailStateDeps } =\n await import('./server/record-email-state')\n return createCrmRecordEmailStateWriter(defaultCrmRecordEmailStateDeps()).stamp(request)\n },\n },\n { pluginId: BUNDLE_ID },\n )\n registerPluginConsentGroupParticipant(\n {\n async preview(request) {\n const { consentGroupParticipant } = await import('./server/consent-group-participant')\n return consentGroupParticipant.preview(request)\n },\n async run(request) {\n const { consentGroupParticipant } = await import('./server/consent-group-participant')\n return consentGroupParticipant.run(request)\n },\n summarize: summarizeConsentGroupChange,\n },\n { pluginId: BUNDLE_ID },\n )\n}\n\n"],"names":["registerPluginConsentGroupParticipant","registerPluginContactCaptureWriter","registerPluginLeadConversionListener","registerPluginPersonMatcher","registerPluginRecordEmailStateWriter","BUNDLE_ID","summarizeConsentGroupChange","crmContactCaptureWriter","capture","request","captureContactForCrm","registerCrmServerDeclarations","pluginId","carryLeadCampaignsOnConversion","matchCrmPeople","stamp","createCrmRecordEmailStateWriter","defaultCrmRecordEmailStateDeps","preview","consentGroupParticipant","run","summarize"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,8EAA8E;AAC9E,6EAA6E;AAC7E,SAASA,qCAAqC,QAAQ,0DAAyD;AAC/G,SACEC,kCAAkC,QAE7B,qDAAoD;AAC3D,SAASC,oCAAoC,QAAQ,qDAAoD;AACzG,SAASC,2BAA2B,QAAQ,oDAAmD;AAC/F,SAASC,oCAAoC,QAAQ,wDAAuD;AAC5G,SAASC,SAAS,QAAQ,+BAA2B;AACrD,SAASC,2BAA2B,QAAQ,mCAA+B;AAE3E;;;;;;;;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;IACdV,mCAAmCM,yBAAyB;QAC1DK,UAAUP;IACZ;IACA,kEAAkE;IAClE,qEAAqE;IACrE,mEAAmE;IACnE,sEAAsE;IACtE,sEAAsE;IACtE,sEAAsE;IACtE,sEAAsE;IACtE,8DAA8D;IAC9DH,qCACE,OAAOO;QACL,MAAM,EAAEI,8BAA8B,EAAE,GACtC,MAAM,MAAM,CAAC;QACf,OAAOA,+BAA+BJ;IACxC,GACA;QAAEG,UAAUP;IAAU;IAExB,0EAA0E;IAC1E,2EAA2E;IAC3E,uEAAuE;IACvEF,4BACE,OAAOM;QACL,MAAM,EAAEK,cAAc,EAAE,GAAG,MAAM,MAAM,CAAC;QACxC,OAAOA,eAAeL;IACxB,GACA;QAAEG,UAAUP;IAAU;IAExB,oEAAoE;IACpE,2EAA2E;IAC3E,2EAA2E;IAC3E,yEAAyE;IACzE,yEAAyE;IACzE,6BAA6B;IAC7B,2EAA2E;IAC3E,yEAAyE;IACzE,2EAA2E;IAC3E,2EAA2E;IAC3E,uEAAuE;IACvE,0BAA0B;IAC1BD,qCACE;QACE,MAAMW,OAAMN,OAAO;YACjB,MAAM,EAAEO,+BAA+B,EAAEC,8BAA8B,EAAE,GACvE,MAAM,MAAM,CAAC;YACf,OAAOD,gCAAgCC,kCAAkCF,KAAK,CAACN;QACjF;IACF,GACA;QAAEG,UAAUP;IAAU;IAExBL,sCACE;QACE,MAAMkB,SAAQT,OAAO;YACnB,MAAM,EAAEU,uBAAuB,EAAE,GAAG,MAAM,MAAM,CAAC;YACjD,OAAOA,wBAAwBD,OAAO,CAACT;QACzC;QACA,MAAMW,KAAIX,OAAO;YACf,MAAM,EAAEU,uBAAuB,EAAE,GAAG,MAAM,MAAM,CAAC;YACjD,OAAOA,wBAAwBC,GAAG,CAACX;QACrC;QACAY,WAAWf;IACb,GACA;QAAEM,UAAUP;IAAU;AAE1B"}
|
|
@@ -57,7 +57,7 @@ export declare const LEAD_STATUS_FILTER_OPTIONS: {
|
|
|
57
57
|
}[];
|
|
58
58
|
/** The choices for Email: every verdict, plus the two aggregates. */
|
|
59
59
|
export declare const LEAD_EMAIL_FILTER_OPTIONS: {
|
|
60
|
-
value: "none" | "bounced" | "complained" | "ok" | "
|
|
60
|
+
value: "none" | "bounced" | "complained" | "ok" | "blocked" | "undeliverable" | "unsubscribed" | "do_not_contact" | "problem";
|
|
61
61
|
label: string;
|
|
62
62
|
}[];
|
|
63
63
|
/**
|
|
@@ -18,6 +18,7 @@ import { CONTACT_SOURCE_LABELS } from "@aglyn/aglyn/app-utils/contacts";
|
|
|
18
18
|
import { formLeadSource } from "@aglyn/aglyn/app-utils/forms";
|
|
19
19
|
import { isCrmLeadOpen, normalizeContactEmail, personKey } from "@aglyn/aglyn/server";
|
|
20
20
|
import { captureHostContact } from "@aglyn/tenant-runtime/capture-host-contact";
|
|
21
|
+
import { contactCaptureActor } from "@aglyn/tenant-runtime/capture-actor";
|
|
21
22
|
import { emitHostEvent } from "@aglyn/tenant-runtime/emit-host-event";
|
|
22
23
|
import { addHostLeadOutcome, findContactByEmail, firebaseAdmin, orgDataCollectionForHost, readLeadForHost } from "@aglyn/tenant-data-admin";
|
|
23
24
|
import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
|
|
@@ -167,13 +168,16 @@ import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
|
|
|
167
168
|
};
|
|
168
169
|
}
|
|
169
170
|
if (created) {
|
|
171
|
+
const cause = contactCaptureActor(request.interaction.source, email, request.actor);
|
|
170
172
|
await emitHostEvent(request.hostId, 'lead', _extends({
|
|
171
173
|
email,
|
|
172
174
|
source,
|
|
173
175
|
leadId: key
|
|
174
176
|
}, request.identity.name ? {
|
|
175
177
|
name: request.identity.name
|
|
176
|
-
} : {})
|
|
178
|
+
} : {}), cause ? {
|
|
179
|
+
actor: cause
|
|
180
|
+
} : {});
|
|
177
181
|
}
|
|
178
182
|
return {
|
|
179
183
|
ok: true,
|
|
@@ -186,7 +190,9 @@ import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
|
|
|
186
190
|
/** The capture as a CONTACT — what every capture was before the rule above. */ async function captureOnContact(request) {
|
|
187
191
|
try {
|
|
188
192
|
var _request_tags, _request_campaignIds;
|
|
189
|
-
const verdict = await captureHostContact(_extends({
|
|
193
|
+
const verdict = await captureHostContact(_extends({}, request.actor ? {
|
|
194
|
+
actor: request.actor
|
|
195
|
+
} : {}, {
|
|
190
196
|
hostId: request.hostId,
|
|
191
197
|
email: request.identity.email
|
|
192
198
|
}, request.identity.name ? {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/capture-contact.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {\n PluginContactCaptureRequest,\n PluginContactCaptured,\n} from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport {\n CONTACT_SOURCE_LABELS,\n type ContactSource,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport { formLeadSource } from '@aglyn/aglyn/app-utils/forms'\nimport {\n type CrmLeadFields,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { captureHostContact } from '@aglyn/tenant-runtime/capture-host-contact'\nimport { emitHostEvent } from '@aglyn/tenant-runtime/emit-host-event'\nimport {\n addHostLeadOutcome,\n findContactByEmail,\n firebaseAdmin,\n orgDataCollectionForHost,\n readLeadForHost,\n type UpsertHostContactOptions,\n} from '@aglyn/tenant-data-admin'\nimport type { ResolvedCampaignTouch } from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { convertOpenLeadOntoContact } from './convert-open-lead'\n\n/**\n * The CRM answering the platform's contact-capture contract (AGL-3080).\n *\n * Four silos meet the same person — a form submission, a member signing up,\n * an order, a booking — and none of them is the record system. Each holds an\n * address, a name and the fact that something happened, and each wants the\n * workspace's person record to know. Today each imports `captureHostContact`\n * directly, which is the highest fan-in edge in the repo and makes the CRM\n * something a storefront cannot take a payment without.\n *\n * So this is the CRM's side of the seam: the silo reports what it saw, and\n * the plugin that keeps people decides everything a record system decides —\n * keying the address, whether this is somebody new, the audience band, the\n * erasure rows, the company, the owner, and what a new person sets off.\n * `captureHostContact` already does all of that; this translates the\n * contract's vocabulary into its options and its verdict back.\n *\n * ⚠️ IT NEVER THROWS. Every caller has already done the thing it is\n * recording — the submission was accepted, the order was paid — so a refusal\n * is RETURNED and costs the silo nothing. A throw here would lose an order\n * for a contact that could not be filed.\n */\n/**\n * WHICH RECORD A CAPTURE LANDS ON (AGL-3232) — the Salesforce rule, decided\n * here because the CRM is the plugin that models both records.\n *\n * One person is one record: a LEAD until somebody qualifies them, a CONTACT\n * after. Every door used to write both — a lead-routed form filed a lead\n * AND a contact at stage Lead — so one person sat in two lists and the Leads\n * queue was never the whole story. Now a door says what kind of surface it\n * is (`request.surface`) and this decides:\n *\n * - a LEAD surface files a lead, unless the workspace already holds the\n * address as a contact — a customer who books a demo is a customer's\n * interaction, not a new lead — in which case the capture lands on the\n * contact and no lead is filed;\n * - a RELATIONSHIP (a member account, a purchase) makes the contact, and\n * an open lead the site held for the address is stamped converted onto\n * it, so nobody keeps working a lead who already joined or bought;\n * - a TOUCH (an unrouted form, a newsletter opt-in) lands on the open lead\n * when the site holds one — its consent and its history stay on the one\n * record the rep is working — and on the contact otherwise.\n *\n * A lead is filed through `addHostLead`, the one writer of the leads silo,\n * so it is keyed on the person, counted against the ceiling and carries the\n * campaign touch the door resolved. A NEW lead announces itself with the\n * `lead` host event, which is what a \"new lead\" automation listens for; a\n * repeat capture on a lead the site already held announces nothing, the\n * way a repeat visit by a contact is an interaction and not a creation.\n */\nexport async function captureContactForCrm(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n const surface = request.surface ?? 'touch'\n // The one refusal every surface shares, answered before any read: an\n // address nothing can key is a person nothing can record.\n if (!normalizeContactEmail(request.identity.email)) return refusedEmail()\n try {\n if (surface === 'lead') {\n if (!(await heldAsContact(request))) return await fileLead(request)\n } else if (surface === 'touch') {\n if (await openLeadFor(request)) return await fileLead(request)\n }\n const verdict = await captureOnContact(request)\n if (verdict.ok && verdict.record === 'contact' && surface === 'relationship') {\n await convertOpenLeadOntoContact({\n hostId: request.hostId,\n email: request.identity.email,\n contactId: verdict.contactId,\n by: request.interaction.source === 'order' ? 'purchase' : 'signup',\n })\n }\n return verdict\n } catch (error) {\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/** The refusal every door gets for an address nothing can key. */\nfunction refusedEmail(): PluginContactCaptured {\n return { ok: false, reason: 'invalid-email', error: refusalText('invalid-email') }\n}\n\n/**\n * Whether the workspace already holds the address as a contact — the\n * address index first, then the query, the same lookup every dedupe uses.\n * ORG-wide, not scoped to the capturing site: the contact door dedupes\n * across every site in the org, so a person any site holds as a contact\n * is a contact, whichever site met them this time.\n */\nasync function heldAsContact(request: PluginContactCaptureRequest): Promise<boolean> {\n const contacts = await orgDataCollectionForHost(request.hostId, 'contacts')\n return (await findContactByEmail(contacts, request.identity.email)) !== null\n}\n\n/** Whether the site holds an OPEN lead for the address — neither converted nor closed. */\nasync function openLeadFor(request: PluginContactCaptureRequest): Promise<boolean> {\n const key = personKey(request.identity.email) as string\n /*\n * Through the seam, which reads the org row and falls back to a lead the\n * backfill has not reached (AGL-3275). UNSCOPED on purpose: a sibling brand\n * in the same consent group has to find this person, or its door concludes\n * there is no lead and files the duplicate this migration exists to end.\n */\n const snapshot = await readLeadForHost(request.hostId, key)\n if (!snapshot) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n return !lead.convertedContactId && isCrmLeadOpen(lead)\n}\n\n/**\n * The capture as a LEAD. The source string is the lead silo's own\n * vocabulary — `form:{formId}` for a form, the door's word otherwise — so\n * a lead's provenance survives the form being renamed, as the submission's\n * does.\n */\nasync function fileLead(request: PluginContactCaptureRequest): Promise<PluginContactCaptured> {\n const email = normalizeContactEmail(request.identity.email) as string\n const key = personKey(email) as string\n const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(request.hostId)\n // `addHostLead` resolves and writes the org collection itself (AGL-3275);\n // this only needs to know whether the person is new, which decides the event.\n const created = !(await readLeadForHost(request.hostId, key))\n const { formId } = entryPointOf(request.detail)\n const source =\n request.interaction.source === 'form' && formId\n ? formLeadSource(formId)\n : request.interaction.source\n const outcome = await addHostLeadOutcome({\n hostRef,\n hostId: request.hostId,\n lead: {\n email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source,\n ...(request.marketingConsent ? { marketingConsent: true } : {}),\n ...disclosureOf(request),\n },\n ...(campaignTouchOf(request.detail).campaignTouch\n ? { touch: campaignTouchOf(request.detail).campaignTouch }\n : {}),\n })\n if (!outcome.stored) {\n return {\n ok: false,\n reason: 'band',\n error: 'This site is at the number of leads it may hold, so the lead was not recorded.',\n }\n }\n if (created) {\n await emitHostEvent(request.hostId, 'lead', {\n email,\n source,\n leadId: key,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n })\n }\n return { ok: true, record: 'lead', leadId: key, created, sourceAdded: outcome.sourceAdded }\n}\n\n/** The capture as a CONTACT — what every capture was before the rule above. */\nasync function captureOnContact(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n try {\n const verdict = await captureHostContact({\n hostId: request.hostId,\n email: request.identity.email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source: contactSourceOf(request.interaction.source),\n interaction: {\n ...(request.interaction.atMs === undefined\n ? {}\n : { atMs: request.interaction.atMs }),\n ...(request.interaction.refId ? { refId: request.interaction.refId } : {}),\n ...(request.interaction.summary\n ? { summary: request.interaction.summary }\n : {}),\n ...entryPointOf(request.detail),\n },\n ...campaignTouchOf(request.detail),\n ...(request.marketingConsent === undefined\n ? {}\n : { marketingConsent: request.marketingConsent }),\n ...disclosureOf(request),\n ...(request.tags?.length ? { tags: request.tags } : {}),\n ...(request.campaignIds?.length ? { campaignIds: request.campaignIds } : {}),\n ...(request.purchaseCents === undefined\n ? {}\n : { purchaseCents: request.purchaseCents }),\n ...(request.purchaseCurrency\n ? { purchaseCurrency: request.purchaseCurrency }\n : {}),\n ...(request.lifecycleFloor\n ? { initialLifecycleStage: request.lifecycleFloor as never }\n : {}),\n ...(request.profile ? { facet: request.profile as never } : {}),\n })\n if ('refused' in verdict) {\n return { ok: false, reason: verdict.refused, error: refusalText(verdict.refused) }\n }\n return {\n ok: true,\n record: 'contact',\n contactId: verdict.contactId,\n created: verdict.created,\n }\n } catch (error) {\n /*\n * The contract's `error` is the last state, not a channel for a stack:\n * a silo may show or log it as it stands, so it says what happened and\n * names nothing internal. The detail goes to the log, where whoever is\n * debugging a missing contact will look.\n */\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/**\n * THE DISCLOSURE the capture surface rendered, handed to whichever record the\n * capture lands on (AGL-3320).\n *\n * Passed through unread: both writers resolve the site's group themselves and\n * pool the opt-in only when this is its current key, so a lead and a contact\n * written by one capture record the same sites. A string or nothing, since\n * the writers compare it exactly and anything else could only fail to match.\n */\nfunction disclosureOf(\n request: PluginContactCaptureRequest,\n): { disclosedConsentGroup?: string } {\n const key = request.disclosedConsentGroup\n return typeof key === 'string' && key ? { disclosedConsentGroup: key } : {}\n}\n\n/**\n * THE ENTRY POINT, off the silo's own `detail` bag (AGL-3080).\n *\n * The contract carries a capture's silo-side facts opaquely, so the two the\n * CRM models are picked out here rather than typed into the platform. Both\n * ride the INTERACTION, which is where `upsertHostContact` already keeps\n * them: which form a person came in through routes the owner-assignment\n * rules and rides the `contactCreated` payload, and the page is what a\n * timeline row says about where they were.\n *\n * Absent, misspelled or the wrong type is the same answer as a door that\n * never had one — a capture without an entry point, which is every capture\n * that did not come through a form. It is never a reason to refuse: the\n * person is the part that matters.\n */\nfunction entryPointOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): { formId?: string; path?: string } {\n const text = (value: unknown): string | undefined =>\n typeof value === 'string' && value.trim() ? value : undefined\n const formId = text(detail?.['formId'])\n const path = text(detail?.['path'])\n return {\n ...(formId ? { formId } : {}),\n ...(path ? { path } : {}),\n }\n}\n\n/**\n * WHERE THE VISITOR CAME FROM, off the same bag.\n *\n * ⚠️ A different fact from `campaignIds` and the two must never be folded\n * together — `upsert-contact.ts` says so at the field itself. A touch is the\n * ad or the link the visitor arrived by, already resolved through the\n * allowlist by the silo; `campaignIds` is which campaigns the merchant filed\n * the capture SURFACE under, which is true of everybody who fills that form\n * in. Folding them would credit a campaign for a visitor who typed the\n * address.\n *\n * Passed through as the silo resolved it, unread: the touch's shape belongs\n * to whatever resolves it, and re-validating it here would be a second copy\n * of a rule that has already run. Only its presence is decided here, because\n * `null` and absent mean the same thing to the writer and a caller should\n * not have to know which one it sends.\n */\nfunction campaignTouchOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): Pick<UpsertHostContactOptions, 'campaignTouch'> {\n const touch = detail?.['campaignTouch']\n return touch ? { campaignTouch: touch as ResolvedCampaignTouch } : {}\n}\n\n/**\n * The silo's word for its door, as a source the CRM stores.\n *\n * ⚠️ `ContactSource` is a CLOSED union and the contract's `source` is an open\n * string, deliberately: a silo declares its door with\n * `registerPluginContactSource` rather than core enumerating every plugin's.\n * The two meet here, and a word outside the union is passed through rather\n * than rejected — refusing it would lose a third-party plugin's capture over\n * a label, and the capture is the part that matters.\n *\n * ⛔ What it costs, until the union is widened: the console's source filter\n * and `SOURCE_LABELS` key on these values, so an undeclared word renders raw\n * and matches no filter. Every first-party silo uses a word in the union, so\n * nothing does that today.\n */\nexport function contactSourceOf(source: string): ContactSource {\n const word = String(source ?? '').trim()\n if (!Object.hasOwn(CONTACT_SOURCE_LABELS, word)) {\n // Said once, where somebody debugging an unlabelled row will find it.\n // Not a refusal: the capture is worth more than the label.\n console.warn(\n `crm contact capture: source \"${word}\" has no label, so it will render ` +\n 'raw and match no filter in the console.',\n )\n }\n return word as ContactSource\n}\n\n/** What a refused caller is told — customer-safe, and never a key. */\nfunction refusalText(reason: 'invalid-email' | 'band' | 'erased' | 'error'): string {\n switch (reason) {\n case 'invalid-email':\n return 'That address could not be read, so no contact was recorded.'\n case 'band':\n return 'This workspace is at the number of contacts its plan holds.'\n case 'erased':\n return 'This person was erased from this workspace and was not recreated.'\n default:\n return 'The contact could not be recorded. Nothing else was affected.'\n }\n}\n"],"names":["CONTACT_SOURCE_LABELS","formLeadSource","isCrmLeadOpen","normalizeContactEmail","personKey","captureHostContact","emitHostEvent","addHostLeadOutcome","findContactByEmail","firebaseAdmin","orgDataCollectionForHost","readLeadForHost","convertOpenLeadOntoContact","captureContactForCrm","request","surface","identity","email","refusedEmail","heldAsContact","fileLead","openLeadFor","verdict","captureOnContact","ok","record","hostId","contactId","by","interaction","source","error","console","reason","refusalText","contacts","snapshot","key","lead","data","convertedContactId","hostRef","app","firestore","collection","doc","created","formId","entryPointOf","detail","outcome","name","marketingConsent","disclosureOf","campaignTouchOf","campaignTouch","touch","stored","leadId","sourceAdded","contactSourceOf","atMs","undefined","refId","summary","tags","length","campaignIds","purchaseCents","purchaseCurrency","lifecycleFloor","initialLifecycleStage","profile","facet","refused","disclosedConsentGroup","text","value","trim","path","word","String","Object","hasOwn","warn"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/capture-contact.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {\n PluginContactCaptureRequest,\n PluginContactCaptured,\n} from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport {\n CONTACT_SOURCE_LABELS,\n type ContactSource,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport { formLeadSource } from '@aglyn/aglyn/app-utils/forms'\nimport {\n type CrmLeadFields,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { captureHostContact } from '@aglyn/tenant-runtime/capture-host-contact'\nimport { contactCaptureActor } from '@aglyn/tenant-runtime/capture-actor'\nimport { emitHostEvent } from '@aglyn/tenant-runtime/emit-host-event'\nimport {\n addHostLeadOutcome,\n findContactByEmail,\n firebaseAdmin,\n orgDataCollectionForHost,\n readLeadForHost,\n type UpsertHostContactOptions,\n} from '@aglyn/tenant-data-admin'\nimport type { ResolvedCampaignTouch } from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { convertOpenLeadOntoContact } from './convert-open-lead'\n\n/**\n * The CRM answering the platform's contact-capture contract (AGL-3080).\n *\n * Four silos meet the same person — a form submission, a member signing up,\n * an order, a booking — and none of them is the record system. Each holds an\n * address, a name and the fact that something happened, and each wants the\n * workspace's person record to know. Today each imports `captureHostContact`\n * directly, which is the highest fan-in edge in the repo and makes the CRM\n * something a storefront cannot take a payment without.\n *\n * So this is the CRM's side of the seam: the silo reports what it saw, and\n * the plugin that keeps people decides everything a record system decides —\n * keying the address, whether this is somebody new, the audience band, the\n * erasure rows, the company, the owner, and what a new person sets off.\n * `captureHostContact` already does all of that; this translates the\n * contract's vocabulary into its options and its verdict back.\n *\n * ⚠️ IT NEVER THROWS. Every caller has already done the thing it is\n * recording — the submission was accepted, the order was paid — so a refusal\n * is RETURNED and costs the silo nothing. A throw here would lose an order\n * for a contact that could not be filed.\n */\n/**\n * WHICH RECORD A CAPTURE LANDS ON (AGL-3232) — the Salesforce rule, decided\n * here because the CRM is the plugin that models both records.\n *\n * One person is one record: a LEAD until somebody qualifies them, a CONTACT\n * after. Every door used to write both — a lead-routed form filed a lead\n * AND a contact at stage Lead — so one person sat in two lists and the Leads\n * queue was never the whole story. Now a door says what kind of surface it\n * is (`request.surface`) and this decides:\n *\n * - a LEAD surface files a lead, unless the workspace already holds the\n * address as a contact — a customer who books a demo is a customer's\n * interaction, not a new lead — in which case the capture lands on the\n * contact and no lead is filed;\n * - a RELATIONSHIP (a member account, a purchase) makes the contact, and\n * an open lead the site held for the address is stamped converted onto\n * it, so nobody keeps working a lead who already joined or bought;\n * - a TOUCH (an unrouted form, a newsletter opt-in) lands on the open lead\n * when the site holds one — its consent and its history stay on the one\n * record the rep is working — and on the contact otherwise.\n *\n * A lead is filed through `addHostLead`, the one writer of the leads silo,\n * so it is keyed on the person, counted against the ceiling and carries the\n * campaign touch the door resolved. A NEW lead announces itself with the\n * `lead` host event, which is what a \"new lead\" automation listens for; a\n * repeat capture on a lead the site already held announces nothing, the\n * way a repeat visit by a contact is an interaction and not a creation.\n */\nexport async function captureContactForCrm(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n const surface = request.surface ?? 'touch'\n // The one refusal every surface shares, answered before any read: an\n // address nothing can key is a person nothing can record.\n if (!normalizeContactEmail(request.identity.email)) return refusedEmail()\n try {\n if (surface === 'lead') {\n if (!(await heldAsContact(request))) return await fileLead(request)\n } else if (surface === 'touch') {\n if (await openLeadFor(request)) return await fileLead(request)\n }\n const verdict = await captureOnContact(request)\n if (verdict.ok && verdict.record === 'contact' && surface === 'relationship') {\n await convertOpenLeadOntoContact({\n hostId: request.hostId,\n email: request.identity.email,\n contactId: verdict.contactId,\n by: request.interaction.source === 'order' ? 'purchase' : 'signup',\n })\n }\n return verdict\n } catch (error) {\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/** The refusal every door gets for an address nothing can key. */\nfunction refusedEmail(): PluginContactCaptured {\n return { ok: false, reason: 'invalid-email', error: refusalText('invalid-email') }\n}\n\n/**\n * Whether the workspace already holds the address as a contact — the\n * address index first, then the query, the same lookup every dedupe uses.\n * ORG-wide, not scoped to the capturing site: the contact door dedupes\n * across every site in the org, so a person any site holds as a contact\n * is a contact, whichever site met them this time.\n */\nasync function heldAsContact(request: PluginContactCaptureRequest): Promise<boolean> {\n const contacts = await orgDataCollectionForHost(request.hostId, 'contacts')\n return (await findContactByEmail(contacts, request.identity.email)) !== null\n}\n\n/** Whether the site holds an OPEN lead for the address — neither converted nor closed. */\nasync function openLeadFor(request: PluginContactCaptureRequest): Promise<boolean> {\n const key = personKey(request.identity.email) as string\n /*\n * Through the seam, which reads the org row and falls back to a lead the\n * backfill has not reached (AGL-3275). UNSCOPED on purpose: a sibling brand\n * in the same consent group has to find this person, or its door concludes\n * there is no lead and files the duplicate this migration exists to end.\n */\n const snapshot = await readLeadForHost(request.hostId, key)\n if (!snapshot) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n return !lead.convertedContactId && isCrmLeadOpen(lead)\n}\n\n/**\n * The capture as a LEAD. The source string is the lead silo's own\n * vocabulary — `form:{formId}` for a form, the door's word otherwise — so\n * a lead's provenance survives the form being renamed, as the submission's\n * does.\n */\nasync function fileLead(request: PluginContactCaptureRequest): Promise<PluginContactCaptured> {\n const email = normalizeContactEmail(request.identity.email) as string\n const key = personKey(email) as string\n const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(request.hostId)\n // `addHostLead` resolves and writes the org collection itself (AGL-3275);\n // this only needs to know whether the person is new, which decides the event.\n const created = !(await readLeadForHost(request.hostId, key))\n const { formId } = entryPointOf(request.detail)\n const source =\n request.interaction.source === 'form' && formId\n ? formLeadSource(formId)\n : request.interaction.source\n const outcome = await addHostLeadOutcome({\n hostRef,\n hostId: request.hostId,\n lead: {\n email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source,\n ...(request.marketingConsent ? { marketingConsent: true } : {}),\n ...disclosureOf(request),\n },\n ...(campaignTouchOf(request.detail).campaignTouch\n ? { touch: campaignTouchOf(request.detail).campaignTouch }\n : {}),\n })\n if (!outcome.stored) {\n return {\n ok: false,\n reason: 'band',\n error: 'This site is at the number of leads it may hold, so the lead was not recorded.',\n }\n }\n if (created) {\n const cause = contactCaptureActor(\n request.interaction.source,\n email,\n request.actor,\n )\n await emitHostEvent(\n request.hostId,\n 'lead',\n {\n email,\n source,\n leadId: key,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n },\n cause ? { actor: cause } : {},\n )\n }\n return { ok: true, record: 'lead', leadId: key, created, sourceAdded: outcome.sourceAdded }\n}\n\n/** The capture as a CONTACT — what every capture was before the rule above. */\nasync function captureOnContact(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n try {\n const verdict = await captureHostContact({\n ...(request.actor ? { actor: request.actor } : {}),\n hostId: request.hostId,\n email: request.identity.email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source: contactSourceOf(request.interaction.source),\n interaction: {\n ...(request.interaction.atMs === undefined\n ? {}\n : { atMs: request.interaction.atMs }),\n ...(request.interaction.refId ? { refId: request.interaction.refId } : {}),\n ...(request.interaction.summary\n ? { summary: request.interaction.summary }\n : {}),\n ...entryPointOf(request.detail),\n },\n ...campaignTouchOf(request.detail),\n ...(request.marketingConsent === undefined\n ? {}\n : { marketingConsent: request.marketingConsent }),\n ...disclosureOf(request),\n ...(request.tags?.length ? { tags: request.tags } : {}),\n ...(request.campaignIds?.length ? { campaignIds: request.campaignIds } : {}),\n ...(request.purchaseCents === undefined\n ? {}\n : { purchaseCents: request.purchaseCents }),\n ...(request.purchaseCurrency\n ? { purchaseCurrency: request.purchaseCurrency }\n : {}),\n ...(request.lifecycleFloor\n ? { initialLifecycleStage: request.lifecycleFloor as never }\n : {}),\n ...(request.profile ? { facet: request.profile as never } : {}),\n })\n if ('refused' in verdict) {\n return { ok: false, reason: verdict.refused, error: refusalText(verdict.refused) }\n }\n return {\n ok: true,\n record: 'contact',\n contactId: verdict.contactId,\n created: verdict.created,\n }\n } catch (error) {\n /*\n * The contract's `error` is the last state, not a channel for a stack:\n * a silo may show or log it as it stands, so it says what happened and\n * names nothing internal. The detail goes to the log, where whoever is\n * debugging a missing contact will look.\n */\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/**\n * THE DISCLOSURE the capture surface rendered, handed to whichever record the\n * capture lands on (AGL-3320).\n *\n * Passed through unread: both writers resolve the site's group themselves and\n * pool the opt-in only when this is its current key, so a lead and a contact\n * written by one capture record the same sites. A string or nothing, since\n * the writers compare it exactly and anything else could only fail to match.\n */\nfunction disclosureOf(\n request: PluginContactCaptureRequest,\n): { disclosedConsentGroup?: string } {\n const key = request.disclosedConsentGroup\n return typeof key === 'string' && key ? { disclosedConsentGroup: key } : {}\n}\n\n/**\n * THE ENTRY POINT, off the silo's own `detail` bag (AGL-3080).\n *\n * The contract carries a capture's silo-side facts opaquely, so the two the\n * CRM models are picked out here rather than typed into the platform. Both\n * ride the INTERACTION, which is where `upsertHostContact` already keeps\n * them: which form a person came in through routes the owner-assignment\n * rules and rides the `contactCreated` payload, and the page is what a\n * timeline row says about where they were.\n *\n * Absent, misspelled or the wrong type is the same answer as a door that\n * never had one — a capture without an entry point, which is every capture\n * that did not come through a form. It is never a reason to refuse: the\n * person is the part that matters.\n */\nfunction entryPointOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): { formId?: string; path?: string } {\n const text = (value: unknown): string | undefined =>\n typeof value === 'string' && value.trim() ? value : undefined\n const formId = text(detail?.['formId'])\n const path = text(detail?.['path'])\n return {\n ...(formId ? { formId } : {}),\n ...(path ? { path } : {}),\n }\n}\n\n/**\n * WHERE THE VISITOR CAME FROM, off the same bag.\n *\n * ⚠️ A different fact from `campaignIds` and the two must never be folded\n * together — `upsert-contact.ts` says so at the field itself. A touch is the\n * ad or the link the visitor arrived by, already resolved through the\n * allowlist by the silo; `campaignIds` is which campaigns the merchant filed\n * the capture SURFACE under, which is true of everybody who fills that form\n * in. Folding them would credit a campaign for a visitor who typed the\n * address.\n *\n * Passed through as the silo resolved it, unread: the touch's shape belongs\n * to whatever resolves it, and re-validating it here would be a second copy\n * of a rule that has already run. Only its presence is decided here, because\n * `null` and absent mean the same thing to the writer and a caller should\n * not have to know which one it sends.\n */\nfunction campaignTouchOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): Pick<UpsertHostContactOptions, 'campaignTouch'> {\n const touch = detail?.['campaignTouch']\n return touch ? { campaignTouch: touch as ResolvedCampaignTouch } : {}\n}\n\n/**\n * The silo's word for its door, as a source the CRM stores.\n *\n * ⚠️ `ContactSource` is a CLOSED union and the contract's `source` is an open\n * string, deliberately: a silo declares its door with\n * `registerPluginContactSource` rather than core enumerating every plugin's.\n * The two meet here, and a word outside the union is passed through rather\n * than rejected — refusing it would lose a third-party plugin's capture over\n * a label, and the capture is the part that matters.\n *\n * ⛔ What it costs, until the union is widened: the console's source filter\n * and `SOURCE_LABELS` key on these values, so an undeclared word renders raw\n * and matches no filter. Every first-party silo uses a word in the union, so\n * nothing does that today.\n */\nexport function contactSourceOf(source: string): ContactSource {\n const word = String(source ?? '').trim()\n if (!Object.hasOwn(CONTACT_SOURCE_LABELS, word)) {\n // Said once, where somebody debugging an unlabelled row will find it.\n // Not a refusal: the capture is worth more than the label.\n console.warn(\n `crm contact capture: source \"${word}\" has no label, so it will render ` +\n 'raw and match no filter in the console.',\n )\n }\n return word as ContactSource\n}\n\n/** What a refused caller is told — customer-safe, and never a key. */\nfunction refusalText(reason: 'invalid-email' | 'band' | 'erased' | 'error'): string {\n switch (reason) {\n case 'invalid-email':\n return 'That address could not be read, so no contact was recorded.'\n case 'band':\n return 'This workspace is at the number of contacts its plan holds.'\n case 'erased':\n return 'This person was erased from this workspace and was not recreated.'\n default:\n return 'The contact could not be recorded. Nothing else was affected.'\n }\n}\n"],"names":["CONTACT_SOURCE_LABELS","formLeadSource","isCrmLeadOpen","normalizeContactEmail","personKey","captureHostContact","contactCaptureActor","emitHostEvent","addHostLeadOutcome","findContactByEmail","firebaseAdmin","orgDataCollectionForHost","readLeadForHost","convertOpenLeadOntoContact","captureContactForCrm","request","surface","identity","email","refusedEmail","heldAsContact","fileLead","openLeadFor","verdict","captureOnContact","ok","record","hostId","contactId","by","interaction","source","error","console","reason","refusalText","contacts","snapshot","key","lead","data","convertedContactId","hostRef","app","firestore","collection","doc","created","formId","entryPointOf","detail","outcome","name","marketingConsent","disclosureOf","campaignTouchOf","campaignTouch","touch","stored","cause","actor","leadId","sourceAdded","contactSourceOf","atMs","undefined","refId","summary","tags","length","campaignIds","purchaseCents","purchaseCurrency","lifecycleFloor","initialLifecycleStage","profile","facet","refused","disclosedConsentGroup","text","value","trim","path","word","String","Object","hasOwn","warn"],"mappings":"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"}
|
|
@@ -62,6 +62,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
62
62
|
import { CONTACT_IMPORT_CHUNK_SIZE, CONTACT_IMPORT_MAX_BODY_BYTES, normalizeContactImportRow } from "../model/crm-import.js";
|
|
63
63
|
import { crmRecordsQuotaForOrg, firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
64
64
|
import { captureHostContact } from "@aglyn/tenant-runtime";
|
|
65
|
+
// The leaf, so a spec that stands a partial barrel in still reaches it.
|
|
66
|
+
import { countUndeliverableEmails, findUndeliverableEmails, IMPORT_MAIL_CHECK_GRACE_MS, settleWithin } from "@aglyn/tenant-data-admin/server/capture-email-check";
|
|
65
67
|
import { FieldValue } from "firebase-admin/firestore";
|
|
66
68
|
import { ownerDirectory, readImportRows, resolveImportContext } from "./import-context.js";
|
|
67
69
|
/** The one sentence every imported contact's timeline opens with. */ export const CONTACT_IMPORT_INTERACTION_SUMMARY = 'Imported from CSV';
|
|
@@ -196,6 +198,11 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
196
198
|
row: verdict.row
|
|
197
199
|
});
|
|
198
200
|
});
|
|
201
|
+
// The addresses' mail servers (AGL-3328), asked while the rows are
|
|
202
|
+
// written — see the leads import. The check `upsertHostContact` queues
|
|
203
|
+
// stamps the records; this is the count the result states.
|
|
204
|
+
const mailServers = findUndeliverableEmails(normalized.map((entry)=>entry.row.email));
|
|
205
|
+
const imported = [];
|
|
199
206
|
const owners = await ownerDirectory(context.orgId, normalized.map((entry)=>entry.row));
|
|
200
207
|
const ownersUnresolved = new Set();
|
|
201
208
|
const companies = new Map();
|
|
@@ -218,6 +225,11 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
218
225
|
name: row.name
|
|
219
226
|
} : {}, {
|
|
220
227
|
source: 'import',
|
|
228
|
+
actor: {
|
|
229
|
+
kind: 'member',
|
|
230
|
+
uid: context.uid,
|
|
231
|
+
email: context.email
|
|
232
|
+
},
|
|
221
233
|
interaction: {
|
|
222
234
|
summary: CONTACT_IMPORT_INTERACTION_SUMMARY
|
|
223
235
|
},
|
|
@@ -251,10 +263,12 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
251
263
|
});
|
|
252
264
|
continue;
|
|
253
265
|
}
|
|
266
|
+
imported.push(row.email);
|
|
254
267
|
if (verdict.created) created += 1;
|
|
255
268
|
else merged += 1;
|
|
256
269
|
}
|
|
257
|
-
const
|
|
270
|
+
const noMailServer = countUndeliverableEmails(imported, await settleWithin(mailServers, IMPORT_MAIL_CHECK_GRACE_MS));
|
|
271
|
+
const result = _extends({
|
|
258
272
|
received: read.rows.length,
|
|
259
273
|
created,
|
|
260
274
|
merged,
|
|
@@ -264,7 +278,9 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
|
|
|
264
278
|
ownersUnresolved: [
|
|
265
279
|
...ownersUnresolved
|
|
266
280
|
]
|
|
267
|
-
}
|
|
281
|
+
}, noMailServer !== undefined ? {
|
|
282
|
+
noMailServer
|
|
283
|
+
} : {});
|
|
268
284
|
return res.status(200).json(result);
|
|
269
285
|
} catch (error) {
|
|
270
286
|
console.error('crm/contacts-import failed', error);
|