@aglyn/plugins-crm 1.0.0-beta.173 → 1.0.0-beta.174
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-associations-card.js +11 -6
- package/src/lib/components/contact-associations-card.js.map +1 -1
- package/src/lib/components/lead-campaigns-card.js +1 -1
- package/src/lib/components/lead-campaigns-card.js.map +1 -1
- package/src/lib/components/lead-detail-page.d.ts +1 -2
- package/src/lib/components/lead-detail-page.js +12 -8
- package/src/lib/components/lead-detail-page.js.map +1 -1
- package/src/lib/components/leads-bulk-bar.js +28 -17
- package/src/lib/components/leads-bulk-bar.js.map +1 -1
- package/src/lib/components/leads-section.js +21 -19
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/new-lead-drawer.js +13 -14
- package/src/lib/components/new-lead-drawer.js.map +1 -1
- package/src/lib/hooks/use-campaign-filing-log.d.ts +6 -1
- package/src/lib/hooks/use-campaign-filing-log.js +10 -4
- package/src/lib/hooks/use-campaign-filing-log.js.map +1 -1
- package/src/lib/hooks/use-crm-campaigns.d.ts +21 -0
- package/src/lib/hooks/use-crm-campaigns.js +44 -0
- package/src/lib/hooks/use-crm-campaigns.js.map +1 -0
- package/src/lib/server/campaign-filing-activity.d.ts +4 -3
- package/src/lib/server/campaign-filing-activity.js +9 -4
- package/src/lib/server/campaign-filing-activity.js.map +1 -1
- package/src/lib/server/contact-email-history.js +22 -14
- package/src/lib/server/contact-email-history.js.map +1 -1
- package/src/lib/server/lead-campaign-carry.js +2 -2
- package/src/lib/server/lead-campaign-carry.js.map +1 -1
- package/src/lib/server/lead-create.d.ts +14 -13
- package/src/lib/server/lead-create.js +16 -15
- package/src/lib/server/lead-create.js.map +1 -1
- package/src/lib/server/leads-import.js +22 -12
- package/src/lib/server/leads-import.js.map +1 -1
|
@@ -7,9 +7,9 @@ import { Container, MdiIcon, SrOnly } from "@aglyn/shared-ui-jsx";
|
|
|
7
7
|
import { NavigationDrawerComponent } from "@aglyn/shared-ui-jsx/components/navigation-drawer.component";
|
|
8
8
|
import { Alert, Button, IconButton, MenuItem, Stack, TextField, Typography } from "@mui/material";
|
|
9
9
|
import CampaignPicker from "@aglyn/shared-ui-email-campaigns/components/campaign-picker.component";
|
|
10
|
-
import { useHostCampaigns } from "@aglyn/tenant-feature-instance";
|
|
11
10
|
import { useEffect, useState } from "react";
|
|
12
11
|
import { useContactFieldDefinitions } from "../hooks/use-contact-field-definitions.js";
|
|
12
|
+
import { useCrmCampaigns } from "../hooks/use-crm-campaigns.js";
|
|
13
13
|
import { useCrmScope } from "../hooks/use-crm-scope.js";
|
|
14
14
|
import { ContactAddressFields, EMPTY_ADDRESS } from "./contact-address-fields.js";
|
|
15
15
|
import { CrmSitePicker } from "./crm-site-picker.js";
|
|
@@ -67,12 +67,15 @@ const NOTES_MAX = 4000;
|
|
|
67
67
|
const [phoneError, setPhoneError] = useState('');
|
|
68
68
|
const [websiteError, setWebsiteError] = useState('');
|
|
69
69
|
/*
|
|
70
|
-
* The
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*/ const campaigns =
|
|
75
|
-
|
|
70
|
+
* The campaigns, for the picker (AGL-3254): read while the drawer is
|
|
71
|
+
* open. Under a site, the ones placed on it; at the organization level,
|
|
72
|
+
* every campaign in the org — the lead is the org's record whichever
|
|
73
|
+
* site it is captured by, so the site picked above does not narrow them.
|
|
74
|
+
*/ const campaigns = useCrmCampaigns({
|
|
75
|
+
hostId,
|
|
76
|
+
orgId
|
|
77
|
+
}, {
|
|
78
|
+
enabled: open
|
|
76
79
|
});
|
|
77
80
|
// A fresh form on every opening: a person typed and then abandoned must
|
|
78
81
|
// not reappear half-filled the next time somebody reaches for New lead.
|
|
@@ -99,10 +102,6 @@ const NOTES_MAX = 4000;
|
|
|
99
102
|
}, [
|
|
100
103
|
open
|
|
101
104
|
]);
|
|
102
|
-
// A campaign picked under one site is not one of another site's.
|
|
103
|
-
useEffect(()=>setCampaignIds([]), [
|
|
104
|
-
createHostId
|
|
105
|
-
]);
|
|
106
105
|
const handleSubmit = ()=>{
|
|
107
106
|
var _errors_phone, _errors_website, _patch_company, _patch_jobTitle, _patch_phone, _patch_website, _patch_leadSource, _patch_tags, _patch_address;
|
|
108
107
|
const normalizedEmail = normalizeContactEmail(email);
|
|
@@ -322,15 +321,15 @@ const NOTES_MAX = 4000;
|
|
|
322
321
|
onChange: (event)=>setTags(event.target.value),
|
|
323
322
|
fullWidth: true
|
|
324
323
|
}),
|
|
325
|
-
|
|
324
|
+
/*#__PURE__*/ _jsx(CampaignPicker, {
|
|
326
325
|
options: campaigns.options,
|
|
327
326
|
value: campaignIds,
|
|
328
327
|
onChange: setCampaignIds,
|
|
329
328
|
helperText: "The campaigns this lead is part of. It does not decide who a campaign mails.",
|
|
330
329
|
disabled: Boolean(busy),
|
|
331
330
|
empty: campaigns.ready && !campaigns.options.length,
|
|
332
|
-
emptyText:
|
|
333
|
-
})
|
|
331
|
+
emptyText: hostId ? 'This site has no campaigns yet. Create one from Marketing to file leads under it.' : 'There are no campaigns yet. Create one from Marketing to file leads under it.'
|
|
332
|
+
}),
|
|
334
333
|
/*#__PURE__*/ _jsx(Typography, {
|
|
335
334
|
variant: "subtitle2",
|
|
336
335
|
children: 'Address'
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/new-lead-drawer.tsx"],"sourcesContent":["'use client'\n\nimport {\n CRM_LEAD_STATUS_LABELS,\n CRM_LEAD_TEXT_MAX,\n type CrmCustomValue,\n type CrmLeadStatus,\n campaignMembershipValue,\n normalizeCrmLeadTags,\n normalizeContactEmail,\n normalizeCrmLeadProfile,\n type AglynPostalAddress,\n} from '@aglyn/aglyn'\nimport { ICON_VARIANT_CLOSE } from '@aglyn/shared-data-enums'\nimport { Container, MdiIcon, SrOnly } from '@aglyn/shared-ui-jsx'\nimport { NavigationDrawerComponent } from '@aglyn/shared-ui-jsx/components/navigation-drawer.component'\nimport {\n Alert,\n Button,\n IconButton,\n MenuItem,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport CampaignPicker from '@aglyn/shared-ui-email-campaigns/components/campaign-picker.component'\nimport { useHostCampaigns } from '@aglyn/tenant-feature-instance'\nimport { useEffect, useState } from 'react'\nimport { useContactFieldDefinitions } from '../hooks/use-contact-field-definitions'\nimport { useCrmScope } from '../hooks/use-crm-scope'\nimport type { OrgMemberOptions } from '../hooks/use-org-member-options'\nimport {\n ContactAddressFields,\n EMPTY_ADDRESS,\n type AddressDraft,\n} from './contact-address-fields'\nimport { CrmSitePicker } from './crm-site-picker'\nimport { CrmCustomFieldControl } from './crm-custom-field-control'\nimport {\n type CrmCustomDraft,\n crmCustomDraftDocument,\n crmCustomDraftMissingRequired,\n} from '../model/crm-custom-draft'\nimport { LeadOwnerSelect } from './lead-owner-select'\n\n/** What the drawer hands back — already normalized where the route would. */\nexport interface NewLeadValues {\n email: string\n name: string\n company: string\n jobTitle: string\n phone: string\n website: string\n leadSource: string\n status: Extract<CrmLeadStatus, 'new' | 'working'>\n ownerUid: string\n tags: string[]\n /** The site's campaigns to file the lead under (AGL-3254), by id. */\n campaignIds: string[]\n address: AglynPostalAddress | null\n notes: string\n /**\n * The org's custom lead fields (AGL-3272), keyed by each definition's\n * `key`. Absent when the org has defined none, or none was filled — the\n * route stores a `custom` map only when there is one.\n */\n custom?: Record<string, CrmCustomValue>\n}\n\nexport interface NewLeadDrawerProps {\n open: boolean\n onClose: () => void\n /**\n * The site the lead is filed under — a lead is private to one site by\n * path. `null` at the organization level, where the drawer asks which\n * site with a picker and holds its submit until one is named.\n */\n hostId: string | null\n org?: Record<string, unknown> | null\n /** The request is in flight — the form holds still and the button says so. */\n busy?: boolean\n /** What the route answered when it refused, shown above the form. */\n error?: string | null\n /** The team, for the owner picker. */\n roster: OrgMemberOptions\n /**\n * The org the custom lead fields belong to (AGL-3272), as the list\n * already resolved it. `null` while it is in flight — the drawer then\n * shows the standard fields and no custom ones, which is what an org\n * with none defined shows anyway.\n */\n orgId?: string | null\n onSubmit: (values: NewLeadValues) => void\n}\n\nconst NOTES_MAX = 4000\n\n/**\n * ADDING ONE LEAD BY HAND, IN A DRAWER (AGL-3231).\n *\n * Salesforce's New Lead, on the Leads list: a person the team has heard of\n * and not yet qualified, entered with what is known — the address, the\n * name, the company as text, a title, a phone, a website, an address, tags\n * and where they came from. It makes a lead and nothing else: no contact,\n * no company record. Those are the conversion's to create once the lead is\n * real, which is what keeps one person from sitting in two lists.\n *\n * A drawer and not a form above the list, for the reason every list in this\n * console gives. The two fields a person can mistype in a way the record\n * cannot hold — the email and the phone, and the website beside them — are\n * checked before the request leaves, with the same normalizer the route\n * runs, so the refusal lands under the field. Everything else is the\n * route's to decide: whether the site already holds the address (it\n * updates that lead), whether the plan carries the suite, whether the site\n * is at the platform ceiling.\n */\nexport function NewLeadDrawer(props: NewLeadDrawerProps) {\n const { open, onClose, hostId, org, busy, error, roster, orgId, onSubmit } = props\n // The site the route files the lead under: the mounted site, or at the\n // organization level the picked one. `null` until it is known.\n const { createHostId } = useCrmScope({ hostId, org: org as never })\n\n const [email, setEmail] = useState('')\n const [name, setName] = useState('')\n const [company, setCompany] = useState('')\n const [jobTitle, setJobTitle] = useState('')\n const [phone, setPhone] = useState('')\n const [website, setWebsite] = useState('')\n const [leadSource, setLeadSource] = useState('')\n const [status, setStatus] = useState<NewLeadValues['status']>('new')\n const [ownerUid, setOwnerUid] = useState('')\n const [tags, setTags] = useState('')\n const [campaignIds, setCampaignIds] = useState<string[]>([])\n const [address, setAddress] = useState<AddressDraft>(EMPTY_ADDRESS)\n const [notes, setNotes] = useState('')\n /*\n * The org's own lead fields (AGL-3272). A CREATE holds only a draft —\n * there is no stored map to diff against — and `crmCustomDraftDocument`\n * turns it into the `custom` the route stores, or nothing.\n */\n const fields = useContactFieldDefinitions(orgId ?? null, 'lead')\n const [custom, setCustom] = useState<CrmCustomDraft>({})\n const [customError, setCustomError] = useState('')\n const [emailError, setEmailError] = useState('')\n const [phoneError, setPhoneError] = useState('')\n const [websiteError, setWebsiteError] = useState('')\n /*\n * The site's campaigns, for the picker (AGL-3254): read while the drawer\n * is open, under the site the lead will be filed under — at the\n * organization level, the picked one — and re-read when that changes,\n * since a campaign belongs to one site.\n */\n const campaigns = useHostCampaigns(createHostId ?? undefined, {\n enabled: open && Boolean(createHostId),\n })\n\n // A fresh form on every opening: a person typed and then abandoned must\n // not reappear half-filled the next time somebody reaches for New lead.\n useEffect(() => {\n if (!open) return\n setEmail('')\n setName('')\n setCompany('')\n setJobTitle('')\n setPhone('')\n setWebsite('')\n setLeadSource('')\n setStatus('new')\n setOwnerUid('')\n setTags('')\n setCampaignIds([])\n setAddress(EMPTY_ADDRESS)\n setNotes('')\n setCustom({})\n setCustomError('')\n setEmailError('')\n setPhoneError('')\n setWebsiteError('')\n }, [open])\n // A campaign picked under one site is not one of another site's.\n useEffect(() => setCampaignIds([]), [createHostId])\n\n const handleSubmit = () => {\n const normalizedEmail = normalizeContactEmail(email)\n const { patch, errors } = normalizeCrmLeadProfile({\n company,\n jobTitle,\n phone,\n website,\n leadSource,\n address,\n tags: normalizeCrmLeadTags(tags),\n })\n // Every required lead field counts on a create: the record is being\n // made whole, and nothing has been written to come back and fill.\n const missing = crmCustomDraftMissingRequired(fields.active, {}, custom, 'create')\n setEmailError(normalizedEmail ? '' : 'Enter a valid email address.')\n setPhoneError(errors.phone ?? '')\n setWebsiteError(errors.website ?? '')\n setCustomError(\n missing.length\n ? `${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} required.`\n : '',\n )\n if (!normalizedEmail || errors.phone || errors.website || missing.length) return\n onSubmit({\n email: normalizedEmail,\n name: name.trim().replace(/\\s+/g, ' ').slice(0, CRM_LEAD_TEXT_MAX),\n company: patch.company ?? '',\n jobTitle: patch.jobTitle ?? '',\n phone: patch.phone ?? '',\n website: patch.website ?? '',\n leadSource: patch.leadSource ?? '',\n status,\n ownerUid,\n tags: patch.tags ?? [],\n campaignIds: campaignMembershipValue(campaignIds),\n address: patch.address ?? null,\n notes: notes.trim().slice(0, NOTES_MAX),\n custom: crmCustomDraftDocument(custom),\n })\n }\n\n return (\n <NavigationDrawerComponent\n open={open}\n anchor=\"right\"\n variant=\"temporary\"\n onClose={onClose}\n AppBarProps={{ color: 'surface' }}\n sx={{ '& .MuiDrawer-paper': { width: { xs: '100%', sm: 480 } } }}\n appBarLeft={\n <>\n <IconButton\n color=\"inherit\"\n edge=\"start\"\n onClick={onClose}\n sx={{ mr: 2 }}\n >\n <MdiIcon path={ICON_VARIANT_CLOSE.path} />\n <SrOnly>close drawer</SrOnly>\n </IconButton>\n <Typography variant=\"h6\" component=\"div\">\n {'New lead'}\n </Typography>\n </>\n }\n appBarRight={\n <Button variant=\"outlined\" color=\"inherit\" onClick={onClose}>\n {'Cancel'}\n </Button>\n }\n >\n <Container gutterY>\n <Stack spacing={2}>\n {error ? <Alert severity=\"warning\">{error}</Alert> : null}\n {/* First, because the lead is filed under the answer. Renders nothing under a site. */}\n <CrmSitePicker\n hostId={hostId}\n disabled={Boolean(busy)}\n helperText=\"The site this lead is filed under — a lead is private to one site.\"\n />\n <TextField\n size=\"small\"\n label=\"Email\"\n type=\"email\"\n value={email}\n onChange={(event) => setEmail(event.target.value)}\n error={Boolean(emailError)}\n helperText={\n emailError ||\n 'The one field that is required — a site holds one lead per address.'\n }\n required\n fullWidth\n autoFocus\n />\n <TextField\n size=\"small\"\n label=\"Name\"\n value={name}\n onChange={(event) => setName(event.target.value)}\n slotProps={{ htmlInput: { maxLength: CRM_LEAD_TEXT_MAX } }}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Company\"\n value={company}\n onChange={(event) => setCompany(event.target.value)}\n helperText=\"As text — converting the lead is what makes it a company record.\"\n slotProps={{ htmlInput: { maxLength: CRM_LEAD_TEXT_MAX } }}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Job title\"\n value={jobTitle}\n onChange={(event) => setJobTitle(event.target.value)}\n slotProps={{ htmlInput: { maxLength: CRM_LEAD_TEXT_MAX } }}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Phone\"\n value={phone}\n onChange={(event) => setPhone(event.target.value)}\n error={Boolean(phoneError)}\n helperText={\n phoneError || 'With the country code, like +1 512 555 0107'\n }\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Website\"\n value={website}\n onChange={(event) => setWebsite(event.target.value)}\n error={Boolean(websiteError)}\n helperText={websiteError || 'Like acme.com'}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Lead source\"\n value={leadSource}\n onChange={(event) => setLeadSource(event.target.value)}\n helperText=\"Where this lead came from — a list, an event, a referral\"\n slotProps={{ htmlInput: { maxLength: CRM_LEAD_TEXT_MAX } }}\n fullWidth\n />\n <Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>\n <TextField\n select\n size=\"small\"\n label=\"Status\"\n value={status}\n onChange={(event) =>\n setStatus(event.target.value as NewLeadValues['status'])\n }\n fullWidth\n >\n <MenuItem value=\"new\">{CRM_LEAD_STATUS_LABELS.new}</MenuItem>\n <MenuItem value=\"working\">\n {CRM_LEAD_STATUS_LABELS.working}\n </MenuItem>\n </TextField>\n <LeadOwnerSelect\n value={ownerUid}\n onChange={setOwnerUid}\n roster={roster}\n />\n </Stack>\n <TextField\n size=\"small\"\n label=\"Tags\"\n placeholder=\"icp2, a-list\"\n helperText=\"Comma-separated\"\n value={tags}\n onChange={(event) => setTags(event.target.value)}\n fullWidth\n />\n {/*\n The campaigns to file the lead under (AGL-3254), picked the way a\n form's page picks them. Grouping, not consent: it decides which\n campaign pages list the lead, never whether anything mails them.\n */}\n {createHostId ? (\n <CampaignPicker\n options={campaigns.options}\n value={campaignIds}\n onChange={setCampaignIds}\n helperText=\"The campaigns this lead is part of. It does not decide who a campaign mails.\"\n disabled={Boolean(busy)}\n empty={campaigns.ready && !campaigns.options.length}\n emptyText=\"This site has no campaigns yet. Create one from Marketing to file leads under it.\"\n />\n ) : null}\n <Typography variant=\"subtitle2\">{'Address'}</Typography>\n <ContactAddressFields value={address} onChange={setAddress} />\n {/* The org's own lead fields (AGL-3272), the same controls the\n lead's page edits them with. */}\n {fields.active.length ? (\n <>\n <Typography variant=\"subtitle2\">{'Custom fields'}</Typography>\n {customError ? <Alert severity=\"warning\">{customError}</Alert> : null}\n {fields.active.map((definition) => (\n <CrmCustomFieldControl\n key={definition.$id}\n definition={definition}\n value={custom[definition.key]}\n onChange={(value) =>\n setCustom((current) => ({ ...current, [definition.key]: value }))\n }\n disabled={Boolean(busy)}\n />\n ))}\n </>\n ) : null}\n <TextField\n size=\"small\"\n label=\"Notes\"\n value={notes}\n onChange={(event) => setNotes(event.target.value)}\n multiline\n minRows={2}\n slotProps={{ htmlInput: { maxLength: NOTES_MAX } }}\n fullWidth\n />\n <Button\n variant=\"contained\"\n color=\"primary\"\n // Held until the site is known: the route files the lead under it.\n disabled={Boolean(busy) || !createHostId}\n onClick={handleSubmit}\n >\n {busy ? 'Adding…' : 'Add lead'}\n </Button>\n </Stack>\n </Container>\n </NavigationDrawerComponent>\n )\n}\n\nexport default NewLeadDrawer\n"],"names":["CRM_LEAD_STATUS_LABELS","CRM_LEAD_TEXT_MAX","campaignMembershipValue","normalizeCrmLeadTags","normalizeContactEmail","normalizeCrmLeadProfile","ICON_VARIANT_CLOSE","Container","MdiIcon","SrOnly","NavigationDrawerComponent","Alert","Button","IconButton","MenuItem","Stack","TextField","Typography","CampaignPicker","useHostCampaigns","useEffect","useState","useContactFieldDefinitions","useCrmScope","ContactAddressFields","EMPTY_ADDRESS","CrmSitePicker","CrmCustomFieldControl","crmCustomDraftDocument","crmCustomDraftMissingRequired","LeadOwnerSelect","NOTES_MAX","NewLeadDrawer","props","open","onClose","hostId","org","busy","error","roster","orgId","onSubmit","createHostId","email","setEmail","name","setName","company","setCompany","jobTitle","setJobTitle","phone","setPhone","website","setWebsite","leadSource","setLeadSource","status","setStatus","ownerUid","setOwnerUid","tags","setTags","campaignIds","setCampaignIds","address","setAddress","notes","setNotes","fields","custom","setCustom","customError","setCustomError","emailError","setEmailError","phoneError","setPhoneError","websiteError","setWebsiteError","campaigns","undefined","enabled","Boolean","handleSubmit","errors","patch","normalizedEmail","missing","active","length","join","trim","replace","slice","anchor","variant","AppBarProps","color","sx","width","xs","sm","appBarLeft","edge","onClick","mr","path","component","appBarRight","gutterY","spacing","severity","disabled","helperText","size","label","type","value","onChange","event","target","required","fullWidth","autoFocus","slotProps","htmlInput","maxLength","direction","select","new","working","placeholder","options","empty","ready","emptyText","map","definition","key","current","$id","multiline","minRows"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/new-lead-drawer.tsx"],"sourcesContent":["'use client'\n\nimport {\n CRM_LEAD_STATUS_LABELS,\n CRM_LEAD_TEXT_MAX,\n type CrmCustomValue,\n type CrmLeadStatus,\n campaignMembershipValue,\n normalizeCrmLeadTags,\n normalizeContactEmail,\n normalizeCrmLeadProfile,\n type AglynPostalAddress,\n} from '@aglyn/aglyn'\nimport { ICON_VARIANT_CLOSE } from '@aglyn/shared-data-enums'\nimport { Container, MdiIcon, SrOnly } from '@aglyn/shared-ui-jsx'\nimport { NavigationDrawerComponent } from '@aglyn/shared-ui-jsx/components/navigation-drawer.component'\nimport {\n Alert,\n Button,\n IconButton,\n MenuItem,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport CampaignPicker from '@aglyn/shared-ui-email-campaigns/components/campaign-picker.component'\nimport { useEffect, useState } from 'react'\nimport { useContactFieldDefinitions } from '../hooks/use-contact-field-definitions'\nimport { useCrmCampaigns } from '../hooks/use-crm-campaigns'\nimport { useCrmScope } from '../hooks/use-crm-scope'\nimport type { OrgMemberOptions } from '../hooks/use-org-member-options'\nimport {\n ContactAddressFields,\n EMPTY_ADDRESS,\n type AddressDraft,\n} from './contact-address-fields'\nimport { CrmSitePicker } from './crm-site-picker'\nimport { CrmCustomFieldControl } from './crm-custom-field-control'\nimport {\n type CrmCustomDraft,\n crmCustomDraftDocument,\n crmCustomDraftMissingRequired,\n} from '../model/crm-custom-draft'\nimport { LeadOwnerSelect } from './lead-owner-select'\n\n/** What the drawer hands back — already normalized where the route would. */\nexport interface NewLeadValues {\n email: string\n name: string\n company: string\n jobTitle: string\n phone: string\n website: string\n leadSource: string\n status: Extract<CrmLeadStatus, 'new' | 'working'>\n ownerUid: string\n tags: string[]\n /** The site's campaigns to file the lead under (AGL-3254), by id. */\n campaignIds: string[]\n address: AglynPostalAddress | null\n notes: string\n /**\n * The org's custom lead fields (AGL-3272), keyed by each definition's\n * `key`. Absent when the org has defined none, or none was filled — the\n * route stores a `custom` map only when there is one.\n */\n custom?: Record<string, CrmCustomValue>\n}\n\nexport interface NewLeadDrawerProps {\n open: boolean\n onClose: () => void\n /**\n * The site the lead is filed under — a lead is private to one site by\n * path. `null` at the organization level, where the drawer asks which\n * site with a picker and holds its submit until one is named.\n */\n hostId: string | null\n org?: Record<string, unknown> | null\n /** The request is in flight — the form holds still and the button says so. */\n busy?: boolean\n /** What the route answered when it refused, shown above the form. */\n error?: string | null\n /** The team, for the owner picker. */\n roster: OrgMemberOptions\n /**\n * The org the custom lead fields belong to (AGL-3272), as the list\n * already resolved it. `null` while it is in flight — the drawer then\n * shows the standard fields and no custom ones, which is what an org\n * with none defined shows anyway.\n */\n orgId?: string | null\n onSubmit: (values: NewLeadValues) => void\n}\n\nconst NOTES_MAX = 4000\n\n/**\n * ADDING ONE LEAD BY HAND, IN A DRAWER (AGL-3231).\n *\n * Salesforce's New Lead, on the Leads list: a person the team has heard of\n * and not yet qualified, entered with what is known — the address, the\n * name, the company as text, a title, a phone, a website, an address, tags\n * and where they came from. It makes a lead and nothing else: no contact,\n * no company record. Those are the conversion's to create once the lead is\n * real, which is what keeps one person from sitting in two lists.\n *\n * A drawer and not a form above the list, for the reason every list in this\n * console gives. The two fields a person can mistype in a way the record\n * cannot hold — the email and the phone, and the website beside them — are\n * checked before the request leaves, with the same normalizer the route\n * runs, so the refusal lands under the field. Everything else is the\n * route's to decide: whether the site already holds the address (it\n * updates that lead), whether the plan carries the suite, whether the site\n * is at the platform ceiling.\n */\nexport function NewLeadDrawer(props: NewLeadDrawerProps) {\n const { open, onClose, hostId, org, busy, error, roster, orgId, onSubmit } = props\n // The site the route files the lead under: the mounted site, or at the\n // organization level the picked one. `null` until it is known.\n const { createHostId } = useCrmScope({ hostId, org: org as never })\n\n const [email, setEmail] = useState('')\n const [name, setName] = useState('')\n const [company, setCompany] = useState('')\n const [jobTitle, setJobTitle] = useState('')\n const [phone, setPhone] = useState('')\n const [website, setWebsite] = useState('')\n const [leadSource, setLeadSource] = useState('')\n const [status, setStatus] = useState<NewLeadValues['status']>('new')\n const [ownerUid, setOwnerUid] = useState('')\n const [tags, setTags] = useState('')\n const [campaignIds, setCampaignIds] = useState<string[]>([])\n const [address, setAddress] = useState<AddressDraft>(EMPTY_ADDRESS)\n const [notes, setNotes] = useState('')\n /*\n * The org's own lead fields (AGL-3272). A CREATE holds only a draft —\n * there is no stored map to diff against — and `crmCustomDraftDocument`\n * turns it into the `custom` the route stores, or nothing.\n */\n const fields = useContactFieldDefinitions(orgId ?? null, 'lead')\n const [custom, setCustom] = useState<CrmCustomDraft>({})\n const [customError, setCustomError] = useState('')\n const [emailError, setEmailError] = useState('')\n const [phoneError, setPhoneError] = useState('')\n const [websiteError, setWebsiteError] = useState('')\n /*\n * The campaigns, for the picker (AGL-3254): read while the drawer is\n * open. Under a site, the ones placed on it; at the organization level,\n * every campaign in the org — the lead is the org's record whichever\n * site it is captured by, so the site picked above does not narrow them.\n */\n const campaigns = useCrmCampaigns({ hostId, orgId }, { enabled: open })\n\n // A fresh form on every opening: a person typed and then abandoned must\n // not reappear half-filled the next time somebody reaches for New lead.\n useEffect(() => {\n if (!open) return\n setEmail('')\n setName('')\n setCompany('')\n setJobTitle('')\n setPhone('')\n setWebsite('')\n setLeadSource('')\n setStatus('new')\n setOwnerUid('')\n setTags('')\n setCampaignIds([])\n setAddress(EMPTY_ADDRESS)\n setNotes('')\n setCustom({})\n setCustomError('')\n setEmailError('')\n setPhoneError('')\n setWebsiteError('')\n }, [open])\n\n const handleSubmit = () => {\n const normalizedEmail = normalizeContactEmail(email)\n const { patch, errors } = normalizeCrmLeadProfile({\n company,\n jobTitle,\n phone,\n website,\n leadSource,\n address,\n tags: normalizeCrmLeadTags(tags),\n })\n // Every required lead field counts on a create: the record is being\n // made whole, and nothing has been written to come back and fill.\n const missing = crmCustomDraftMissingRequired(fields.active, {}, custom, 'create')\n setEmailError(normalizedEmail ? '' : 'Enter a valid email address.')\n setPhoneError(errors.phone ?? '')\n setWebsiteError(errors.website ?? '')\n setCustomError(\n missing.length\n ? `${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} required.`\n : '',\n )\n if (!normalizedEmail || errors.phone || errors.website || missing.length) return\n onSubmit({\n email: normalizedEmail,\n name: name.trim().replace(/\\s+/g, ' ').slice(0, CRM_LEAD_TEXT_MAX),\n company: patch.company ?? '',\n jobTitle: patch.jobTitle ?? '',\n phone: patch.phone ?? '',\n website: patch.website ?? '',\n leadSource: patch.leadSource ?? '',\n status,\n ownerUid,\n tags: patch.tags ?? [],\n campaignIds: campaignMembershipValue(campaignIds),\n address: patch.address ?? null,\n notes: notes.trim().slice(0, NOTES_MAX),\n custom: crmCustomDraftDocument(custom),\n })\n }\n\n return (\n <NavigationDrawerComponent\n open={open}\n anchor=\"right\"\n variant=\"temporary\"\n onClose={onClose}\n AppBarProps={{ color: 'surface' }}\n sx={{ '& .MuiDrawer-paper': { width: { xs: '100%', sm: 480 } } }}\n appBarLeft={\n <>\n <IconButton\n color=\"inherit\"\n edge=\"start\"\n onClick={onClose}\n sx={{ mr: 2 }}\n >\n <MdiIcon path={ICON_VARIANT_CLOSE.path} />\n <SrOnly>close drawer</SrOnly>\n </IconButton>\n <Typography variant=\"h6\" component=\"div\">\n {'New lead'}\n </Typography>\n </>\n }\n appBarRight={\n <Button variant=\"outlined\" color=\"inherit\" onClick={onClose}>\n {'Cancel'}\n </Button>\n }\n >\n <Container gutterY>\n <Stack spacing={2}>\n {error ? <Alert severity=\"warning\">{error}</Alert> : null}\n {/* First, because the lead is filed under the answer. Renders nothing under a site. */}\n <CrmSitePicker\n hostId={hostId}\n disabled={Boolean(busy)}\n helperText=\"The site this lead is filed under — a lead is private to one site.\"\n />\n <TextField\n size=\"small\"\n label=\"Email\"\n type=\"email\"\n value={email}\n onChange={(event) => setEmail(event.target.value)}\n error={Boolean(emailError)}\n helperText={\n emailError ||\n 'The one field that is required — a site holds one lead per address.'\n }\n required\n fullWidth\n autoFocus\n />\n <TextField\n size=\"small\"\n label=\"Name\"\n value={name}\n onChange={(event) => setName(event.target.value)}\n slotProps={{ htmlInput: { maxLength: CRM_LEAD_TEXT_MAX } }}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Company\"\n value={company}\n onChange={(event) => setCompany(event.target.value)}\n helperText=\"As text — converting the lead is what makes it a company record.\"\n slotProps={{ htmlInput: { maxLength: CRM_LEAD_TEXT_MAX } }}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Job title\"\n value={jobTitle}\n onChange={(event) => setJobTitle(event.target.value)}\n slotProps={{ htmlInput: { maxLength: CRM_LEAD_TEXT_MAX } }}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Phone\"\n value={phone}\n onChange={(event) => setPhone(event.target.value)}\n error={Boolean(phoneError)}\n helperText={\n phoneError || 'With the country code, like +1 512 555 0107'\n }\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Website\"\n value={website}\n onChange={(event) => setWebsite(event.target.value)}\n error={Boolean(websiteError)}\n helperText={websiteError || 'Like acme.com'}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Lead source\"\n value={leadSource}\n onChange={(event) => setLeadSource(event.target.value)}\n helperText=\"Where this lead came from — a list, an event, a referral\"\n slotProps={{ htmlInput: { maxLength: CRM_LEAD_TEXT_MAX } }}\n fullWidth\n />\n <Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>\n <TextField\n select\n size=\"small\"\n label=\"Status\"\n value={status}\n onChange={(event) =>\n setStatus(event.target.value as NewLeadValues['status'])\n }\n fullWidth\n >\n <MenuItem value=\"new\">{CRM_LEAD_STATUS_LABELS.new}</MenuItem>\n <MenuItem value=\"working\">\n {CRM_LEAD_STATUS_LABELS.working}\n </MenuItem>\n </TextField>\n <LeadOwnerSelect\n value={ownerUid}\n onChange={setOwnerUid}\n roster={roster}\n />\n </Stack>\n <TextField\n size=\"small\"\n label=\"Tags\"\n placeholder=\"icp2, a-list\"\n helperText=\"Comma-separated\"\n value={tags}\n onChange={(event) => setTags(event.target.value)}\n fullWidth\n />\n {/*\n The campaigns to file the lead under (AGL-3254), picked the way a\n form's page picks them. Grouping, not consent: it decides which\n campaign pages list the lead, never whether anything mails them.\n */}\n <CampaignPicker\n options={campaigns.options}\n value={campaignIds}\n onChange={setCampaignIds}\n helperText=\"The campaigns this lead is part of. It does not decide who a campaign mails.\"\n disabled={Boolean(busy)}\n empty={campaigns.ready && !campaigns.options.length}\n emptyText={\n hostId\n ? 'This site has no campaigns yet. Create one from Marketing to file leads under it.'\n : 'There are no campaigns yet. Create one from Marketing to file leads under it.'\n }\n />\n <Typography variant=\"subtitle2\">{'Address'}</Typography>\n <ContactAddressFields value={address} onChange={setAddress} />\n {/* The org's own lead fields (AGL-3272), the same controls the\n lead's page edits them with. */}\n {fields.active.length ? (\n <>\n <Typography variant=\"subtitle2\">{'Custom fields'}</Typography>\n {customError ? <Alert severity=\"warning\">{customError}</Alert> : null}\n {fields.active.map((definition) => (\n <CrmCustomFieldControl\n key={definition.$id}\n definition={definition}\n value={custom[definition.key]}\n onChange={(value) =>\n setCustom((current) => ({ ...current, [definition.key]: value }))\n }\n disabled={Boolean(busy)}\n />\n ))}\n </>\n ) : null}\n <TextField\n size=\"small\"\n label=\"Notes\"\n value={notes}\n onChange={(event) => setNotes(event.target.value)}\n multiline\n minRows={2}\n slotProps={{ htmlInput: { maxLength: NOTES_MAX } }}\n fullWidth\n />\n <Button\n variant=\"contained\"\n color=\"primary\"\n // Held until the site is known: the route files the lead under it.\n disabled={Boolean(busy) || !createHostId}\n onClick={handleSubmit}\n >\n {busy ? 'Adding…' : 'Add lead'}\n </Button>\n </Stack>\n </Container>\n </NavigationDrawerComponent>\n )\n}\n\nexport default NewLeadDrawer\n"],"names":["CRM_LEAD_STATUS_LABELS","CRM_LEAD_TEXT_MAX","campaignMembershipValue","normalizeCrmLeadTags","normalizeContactEmail","normalizeCrmLeadProfile","ICON_VARIANT_CLOSE","Container","MdiIcon","SrOnly","NavigationDrawerComponent","Alert","Button","IconButton","MenuItem","Stack","TextField","Typography","CampaignPicker","useEffect","useState","useContactFieldDefinitions","useCrmCampaigns","useCrmScope","ContactAddressFields","EMPTY_ADDRESS","CrmSitePicker","CrmCustomFieldControl","crmCustomDraftDocument","crmCustomDraftMissingRequired","LeadOwnerSelect","NOTES_MAX","NewLeadDrawer","props","open","onClose","hostId","org","busy","error","roster","orgId","onSubmit","createHostId","email","setEmail","name","setName","company","setCompany","jobTitle","setJobTitle","phone","setPhone","website","setWebsite","leadSource","setLeadSource","status","setStatus","ownerUid","setOwnerUid","tags","setTags","campaignIds","setCampaignIds","address","setAddress","notes","setNotes","fields","custom","setCustom","customError","setCustomError","emailError","setEmailError","phoneError","setPhoneError","websiteError","setWebsiteError","campaigns","enabled","handleSubmit","errors","patch","normalizedEmail","missing","active","length","join","trim","replace","slice","anchor","variant","AppBarProps","color","sx","width","xs","sm","appBarLeft","edge","onClick","mr","path","component","appBarRight","gutterY","spacing","severity","disabled","Boolean","helperText","size","label","type","value","onChange","event","target","required","fullWidth","autoFocus","slotProps","htmlInput","maxLength","direction","select","new","working","placeholder","options","empty","ready","emptyText","map","definition","key","current","$id","multiline","minRows"],"mappings":"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"}
|
|
@@ -16,6 +16,11 @@ export interface CampaignFilingChange {
|
|
|
16
16
|
* on this site carries (`crmScopeTokens`), the signed-in member as author,
|
|
17
17
|
* and the CRM's own id as the filing plugin.
|
|
18
18
|
*
|
|
19
|
+
* The site an entry carries is the hook's, or — for a surface that files
|
|
20
|
+
* leads from several sites at once, the organization-level bulk bar — the
|
|
21
|
+
* one a call names for its record. With neither there is no scope to stamp,
|
|
22
|
+
* and nothing is written.
|
|
23
|
+
*
|
|
19
24
|
* Bookkeeping beside the act: the membership has landed by the time this
|
|
20
25
|
* runs, so a write that fails is logged and the filing stands — the card
|
|
21
26
|
* shows the campaigns from the document, not from the timeline.
|
|
@@ -25,5 +30,5 @@ export declare function useCampaignFilingLog(input: {
|
|
|
25
30
|
/** The site the entry carries as provenance: the lead's, or the contact's viewing site. */
|
|
26
31
|
hostId: string | null | undefined;
|
|
27
32
|
org: Record<string, unknown> | null | undefined;
|
|
28
|
-
}): (link: CrmActivityLink, change: CampaignFilingChange) => Promise<void>;
|
|
33
|
+
}): (link: CrmActivityLink, change: CampaignFilingChange, recordHostId?: string | null) => Promise<void>;
|
|
29
34
|
export default useCampaignFilingLog;
|
|
@@ -31,6 +31,11 @@ import { buildCampaignFilingActivity } from "../model/campaign-filing-activity.j
|
|
|
31
31
|
* on this site carries (`crmScopeTokens`), the signed-in member as author,
|
|
32
32
|
* and the CRM's own id as the filing plugin.
|
|
33
33
|
*
|
|
34
|
+
* The site an entry carries is the hook's, or — for a surface that files
|
|
35
|
+
* leads from several sites at once, the organization-level bulk bar — the
|
|
36
|
+
* one a call names for its record. With neither there is no scope to stamp,
|
|
37
|
+
* and nothing is written.
|
|
38
|
+
*
|
|
34
39
|
* Bookkeeping beside the act: the membership has landed by the time this
|
|
35
40
|
* runs, so a write that fails is logged and the filing stands — the card
|
|
36
41
|
* shows the campaigns from the document, not from the timeline.
|
|
@@ -40,9 +45,10 @@ import { buildCampaignFilingActivity } from "../model/campaign-filing-activity.j
|
|
|
40
45
|
const { data: user } = useUser();
|
|
41
46
|
const authorName = useUserName();
|
|
42
47
|
const uid = user == null ? void 0 : user.uid;
|
|
43
|
-
return useCallback(async (link, change)=>{
|
|
48
|
+
return useCallback(async (link, change, recordHostId)=>{
|
|
44
49
|
var _change_filed, _change_removed;
|
|
45
|
-
|
|
50
|
+
const siteHostId = hostId || recordHostId || null;
|
|
51
|
+
if (!orgId || !siteHostId || !uid) return;
|
|
46
52
|
const entries = [
|
|
47
53
|
...((_change_filed = change.filed) != null ? _change_filed : []).map((campaign)=>({
|
|
48
54
|
action: 'filed',
|
|
@@ -54,7 +60,7 @@ import { buildCampaignFilingActivity } from "../model/campaign-filing-activity.j
|
|
|
54
60
|
}))
|
|
55
61
|
];
|
|
56
62
|
if (!entries.length) return;
|
|
57
|
-
const visibleTo = crmScopeTokens(org, consentGroupForHost(org,
|
|
63
|
+
const visibleTo = crmScopeTokens(org, consentGroupForHost(org, siteHostId));
|
|
58
64
|
const atMs = Date.now();
|
|
59
65
|
const activities = collection(firestore, 'orgs', orgId, CRM_COLLECTIONS.activities);
|
|
60
66
|
for (const entry of entries){
|
|
@@ -64,7 +70,7 @@ import { buildCampaignFilingActivity } from "../model/campaign-filing-activity.j
|
|
|
64
70
|
action: entry.action,
|
|
65
71
|
campaign: entry.campaign,
|
|
66
72
|
link,
|
|
67
|
-
hostId,
|
|
73
|
+
hostId: siteHostId,
|
|
68
74
|
visibleTo,
|
|
69
75
|
atMs,
|
|
70
76
|
byUid: uid,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/hooks/use-campaign-filing-log.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n CRM_COLLECTIONS,\n consentGroupForHost,\n createResourceUid,\n crmScopeTokens,\n type CrmActivityLink,\n} from '@aglyn/aglyn'\nimport { useFirestore, useUser, useUserName } from '@aglyn/tenant-feature-instance'\nimport { collection, doc, setDoc } from 'firebase/firestore'\nimport { useCallback } from 'react'\nimport {\n buildCampaignFilingActivity,\n type CampaignFilingRef,\n} from '../model/campaign-filing-activity'\n\n/** What one surface's save changed, by campaign, with the names it showed. */\nexport interface CampaignFilingChange {\n filed?: readonly CampaignFilingRef[]\n removed?: readonly CampaignFilingRef[]\n}\n\n/**\n * FILES A MEMBER'S FILING ON THE RECORD'S TIMELINE (AGL-3274), from the\n * console.\n *\n * The lead's Campaigns card and the Leads list's bulk bar write the\n * membership client-direct — a one-field update the rules already admit —\n * so the entry that records it is written the same way, into the org's\n * activity collection the log dialog writes, with the scope a record made\n * on this site carries (`crmScopeTokens`), the signed-in member as author,\n * and the CRM's own id as the filing plugin.\n *\n * Bookkeeping beside the act: the membership has landed by the time this\n * runs, so a write that fails is logged and the filing stands — the card\n * shows the campaigns from the document, not from the timeline.\n */\nexport function useCampaignFilingLog(input: {\n orgId: string | null | undefined\n /** The site the entry carries as provenance: the lead's, or the contact's viewing site. */\n hostId: string | null | undefined\n org: Record<string, unknown> | null | undefined\n}) {\n const { orgId, hostId, org } = input\n const firestore = useFirestore()\n const { data: user } = useUser()\n const authorName = useUserName()\n const uid = user?.uid\n\n return useCallback(\n async (link: CrmActivityLink
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/hooks/use-campaign-filing-log.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n CRM_COLLECTIONS,\n consentGroupForHost,\n createResourceUid,\n crmScopeTokens,\n type CrmActivityLink,\n} from '@aglyn/aglyn'\nimport { useFirestore, useUser, useUserName } from '@aglyn/tenant-feature-instance'\nimport { collection, doc, setDoc } from 'firebase/firestore'\nimport { useCallback } from 'react'\nimport {\n buildCampaignFilingActivity,\n type CampaignFilingRef,\n} from '../model/campaign-filing-activity'\n\n/** What one surface's save changed, by campaign, with the names it showed. */\nexport interface CampaignFilingChange {\n filed?: readonly CampaignFilingRef[]\n removed?: readonly CampaignFilingRef[]\n}\n\n/**\n * FILES A MEMBER'S FILING ON THE RECORD'S TIMELINE (AGL-3274), from the\n * console.\n *\n * The lead's Campaigns card and the Leads list's bulk bar write the\n * membership client-direct — a one-field update the rules already admit —\n * so the entry that records it is written the same way, into the org's\n * activity collection the log dialog writes, with the scope a record made\n * on this site carries (`crmScopeTokens`), the signed-in member as author,\n * and the CRM's own id as the filing plugin.\n *\n * The site an entry carries is the hook's, or — for a surface that files\n * leads from several sites at once, the organization-level bulk bar — the\n * one a call names for its record. With neither there is no scope to stamp,\n * and nothing is written.\n *\n * Bookkeeping beside the act: the membership has landed by the time this\n * runs, so a write that fails is logged and the filing stands — the card\n * shows the campaigns from the document, not from the timeline.\n */\nexport function useCampaignFilingLog(input: {\n orgId: string | null | undefined\n /** The site the entry carries as provenance: the lead's, or the contact's viewing site. */\n hostId: string | null | undefined\n org: Record<string, unknown> | null | undefined\n}) {\n const { orgId, hostId, org } = input\n const firestore = useFirestore()\n const { data: user } = useUser()\n const authorName = useUserName()\n const uid = user?.uid\n\n return useCallback(\n async (\n link: CrmActivityLink,\n change: CampaignFilingChange,\n recordHostId?: string | null,\n ): Promise<void> => {\n const siteHostId = hostId || recordHostId || null\n if (!orgId || !siteHostId || !uid) return\n const entries = [\n ...(change.filed ?? []).map((campaign) => ({ action: 'filed' as const, campaign })),\n ...(change.removed ?? []).map((campaign) => ({ action: 'removed' as const, campaign })),\n ]\n if (!entries.length) return\n const visibleTo = crmScopeTokens(org, consentGroupForHost(org, siteHostId))\n const atMs = Date.now()\n const activities = collection(firestore, 'orgs', orgId, CRM_COLLECTIONS.activities)\n for (const entry of entries) {\n try {\n // Named by the platform's own id, as every console resource is.\n await setDoc(doc(activities, createResourceUid()), {\n ...buildCampaignFilingActivity({\n action: entry.action,\n campaign: entry.campaign,\n link,\n hostId: siteHostId,\n visibleTo,\n atMs,\n byUid: uid,\n byName: authorName || null,\n }),\n createdAt: new Date(),\n updatedAt: new Date(),\n })\n } catch (error) {\n console.error('[crm] the filing could not be written on the timeline', error)\n }\n }\n },\n [firestore, orgId, hostId, org, uid, authorName],\n )\n}\n\nexport default useCampaignFilingLog\n"],"names":["CRM_COLLECTIONS","consentGroupForHost","createResourceUid","crmScopeTokens","useFirestore","useUser","useUserName","collection","doc","setDoc","useCallback","buildCampaignFilingActivity","useCampaignFilingLog","input","orgId","hostId","org","firestore","data","user","authorName","uid","link","change","recordHostId","siteHostId","entries","filed","map","campaign","action","removed","length","visibleTo","atMs","Date","now","activities","entry","byUid","byName","createdAt","updatedAt","error","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SACEA,eAAe,EACfC,mBAAmB,EACnBC,iBAAiB,EACjBC,cAAc,QAET,eAAc;AACrB,SAASC,YAAY,EAAEC,OAAO,EAAEC,WAAW,QAAQ,iCAAgC;AACnF,SAASC,UAAU,EAAEC,GAAG,EAAEC,MAAM,QAAQ,qBAAoB;AAC5D,SAASC,WAAW,QAAQ,QAAO;AACnC,SACEC,2BAA2B,QAEtB,uCAAmC;AAQ1C;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASC,qBAAqBC,KAKpC;IACC,MAAM,EAAEC,KAAK,EAAEC,MAAM,EAAEC,GAAG,EAAE,GAAGH;IAC/B,MAAMI,YAAYb;IAClB,MAAM,EAAEc,MAAMC,IAAI,EAAE,GAAGd;IACvB,MAAMe,aAAad;IACnB,MAAMe,MAAMF,wBAAAA,KAAME,GAAG;IAErB,OAAOX,YACL,OACEY,MACAC,QACAC;YAKMD,eACAA;QAJN,MAAME,aAAaV,UAAUS,gBAAgB;QAC7C,IAAI,CAACV,SAAS,CAACW,cAAc,CAACJ,KAAK;QACnC,MAAMK,UAAU;eACX,EAACH,gBAAAA,OAAOI,KAAK,YAAZJ,gBAAgB,EAAE,EAAEK,GAAG,CAAC,CAACC,WAAc,CAAA;oBAAEC,QAAQ;oBAAkBD;gBAAS,CAAA;eAC7E,EAACN,kBAAAA,OAAOQ,OAAO,YAAdR,kBAAkB,EAAE,EAAEK,GAAG,CAAC,CAACC,WAAc,CAAA;oBAAEC,QAAQ;oBAAoBD;gBAAS,CAAA;SACrF;QACD,IAAI,CAACH,QAAQM,MAAM,EAAE;QACrB,MAAMC,YAAY9B,eAAea,KAAKf,oBAAoBe,KAAKS;QAC/D,MAAMS,OAAOC,KAAKC,GAAG;QACrB,MAAMC,aAAa9B,WAAWU,WAAW,QAAQH,OAAOd,gBAAgBqC,UAAU;QAClF,KAAK,MAAMC,SAASZ,QAAS;YAC3B,IAAI;gBACF,gEAAgE;gBAChE,MAAMjB,OAAOD,IAAI6B,YAAYnC,sBAAsB,aAC9CS,4BAA4B;oBAC7BmB,QAAQQ,MAAMR,MAAM;oBACpBD,UAAUS,MAAMT,QAAQ;oBACxBP;oBACAP,QAAQU;oBACRQ;oBACAC;oBACAK,OAAOlB;oBACPmB,QAAQpB,cAAc;gBACxB;oBACAqB,WAAW,IAAIN;oBACfO,WAAW,IAAIP;;YAEnB,EAAE,OAAOQ,OAAO;gBACdC,QAAQD,KAAK,CAAC,yDAAyDA;YACzE;QACF;IACF,GACA;QAAC1B;QAAWH;QAAOC;QAAQC;QAAKK;QAAKD;KAAW;AAEpD;AAEA,eAAeR,qBAAoB"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type HostCampaigns } from '@aglyn/tenant-feature-instance';
|
|
2
|
+
/**
|
|
3
|
+
* THE CAMPAIGNS A CRM SURFACE OFFERS AND NAMES, at either level.
|
|
4
|
+
*
|
|
5
|
+
* Campaigns belong to the organization, and so do leads. Under a site the
|
|
6
|
+
* list is the campaigns placed on that site — the ones its hub shows and
|
|
7
|
+
* its captures file under. At the organization level (`hostId` null) it is
|
|
8
|
+
* every campaign in the org, because the list there spans every site and a
|
|
9
|
+
* lead from any of them may be filed under any campaign.
|
|
10
|
+
*
|
|
11
|
+
* Both hooks are called on every render, since a hook may not be called
|
|
12
|
+
* conditionally; only the one for this level is enabled, so only one
|
|
13
|
+
* listener opens.
|
|
14
|
+
*/
|
|
15
|
+
export declare function useCrmCampaigns(scope: {
|
|
16
|
+
hostId: string | null | undefined;
|
|
17
|
+
orgId: string | null | undefined;
|
|
18
|
+
}, options?: {
|
|
19
|
+
enabled?: boolean;
|
|
20
|
+
}): HostCampaigns;
|
|
21
|
+
export default useCrmCampaigns;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ 'use client';
|
|
17
|
+
import { useHostCampaigns, useOrgCampaigns } from "@aglyn/tenant-feature-instance";
|
|
18
|
+
/**
|
|
19
|
+
* THE CAMPAIGNS A CRM SURFACE OFFERS AND NAMES, at either level.
|
|
20
|
+
*
|
|
21
|
+
* Campaigns belong to the organization, and so do leads. Under a site the
|
|
22
|
+
* list is the campaigns placed on that site — the ones its hub shows and
|
|
23
|
+
* its captures file under. At the organization level (`hostId` null) it is
|
|
24
|
+
* every campaign in the org, because the list there spans every site and a
|
|
25
|
+
* lead from any of them may be filed under any campaign.
|
|
26
|
+
*
|
|
27
|
+
* Both hooks are called on every render, since a hook may not be called
|
|
28
|
+
* conditionally; only the one for this level is enabled, so only one
|
|
29
|
+
* listener opens.
|
|
30
|
+
*/ export function useCrmCampaigns(scope, options) {
|
|
31
|
+
var _ref;
|
|
32
|
+
const enabled = (_ref = options == null ? void 0 : options.enabled) != null ? _ref : false;
|
|
33
|
+
const hostId = scope.hostId || null;
|
|
34
|
+
const site = useHostCampaigns(hostId != null ? hostId : undefined, {
|
|
35
|
+
enabled: enabled && Boolean(hostId)
|
|
36
|
+
});
|
|
37
|
+
const org = useOrgCampaigns(scope.orgId, {
|
|
38
|
+
enabled: enabled && !hostId
|
|
39
|
+
});
|
|
40
|
+
return hostId ? site : org;
|
|
41
|
+
}
|
|
42
|
+
export default useCrmCampaigns;
|
|
43
|
+
|
|
44
|
+
//# sourceMappingURL=use-crm-campaigns.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/hooks/use-crm-campaigns.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 useHostCampaigns,\n useOrgCampaigns,\n type HostCampaigns,\n} from '@aglyn/tenant-feature-instance'\n\n/**\n * THE CAMPAIGNS A CRM SURFACE OFFERS AND NAMES, at either level.\n *\n * Campaigns belong to the organization, and so do leads. Under a site the\n * list is the campaigns placed on that site — the ones its hub shows and\n * its captures file under. At the organization level (`hostId` null) it is\n * every campaign in the org, because the list there spans every site and a\n * lead from any of them may be filed under any campaign.\n *\n * Both hooks are called on every render, since a hook may not be called\n * conditionally; only the one for this level is enabled, so only one\n * listener opens.\n */\nexport function useCrmCampaigns(\n scope: {\n hostId: string | null | undefined\n orgId: string | null | undefined\n },\n options?: { enabled?: boolean },\n): HostCampaigns {\n const enabled = options?.enabled ?? false\n const hostId = scope.hostId || null\n const site = useHostCampaigns(hostId ?? undefined, {\n enabled: enabled && Boolean(hostId),\n })\n const org = useOrgCampaigns(scope.orgId, {\n enabled: enabled && !hostId,\n })\n return hostId ? site : org\n}\n\nexport default useCrmCampaigns\n"],"names":["useHostCampaigns","useOrgCampaigns","useCrmCampaigns","scope","options","enabled","hostId","site","undefined","Boolean","org","orgId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;AAEA,SACEA,gBAAgB,EAChBC,eAAe,QAEV,iCAAgC;AAEvC;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,gBACdC,KAGC,EACDC,OAA+B;;IAE/B,MAAMC,kBAAUD,2BAAAA,QAASC,OAAO,mBAAI;IACpC,MAAMC,SAASH,MAAMG,MAAM,IAAI;IAC/B,MAAMC,OAAOP,iBAAiBM,iBAAAA,SAAUE,WAAW;QACjDH,SAASA,WAAWI,QAAQH;IAC9B;IACA,MAAMI,MAAMT,gBAAgBE,MAAMQ,KAAK,EAAE;QACvCN,SAASA,WAAW,CAACC;IACvB;IACA,OAAOA,SAASC,OAAOG;AACzB;AAEA,eAAeR,gBAAe"}
|
|
@@ -70,10 +70,11 @@ export interface CampaignFilingActivitiesInput {
|
|
|
70
70
|
/** The document id a keyed entry files under: the CRM's namespace, never a raw key. */
|
|
71
71
|
export declare function campaignFilingActivityId(key: string): string;
|
|
72
72
|
/**
|
|
73
|
-
* The names of
|
|
74
|
-
* A container that is gone answers its id,
|
|
73
|
+
* The names of an org's campaigns (`orgs/{orgId}/emailCampaigns`), by id,
|
|
74
|
+
* for the entries that name them. A container that is gone answers its id,
|
|
75
|
+
* so the entry still says which.
|
|
75
76
|
*/
|
|
76
|
-
export declare function
|
|
77
|
+
export declare function orgCampaignRefs(firestore: Firestore, orgId: string, campaignIds: readonly string[]): Promise<CampaignFilingRef[]>;
|
|
77
78
|
/** Files one entry per campaign; answers how many were written. */
|
|
78
79
|
export declare function fileCampaignFilingActivities(firestore: Firestore, input: CampaignFilingActivitiesInput): Promise<number>;
|
|
79
80
|
export {};
|
|
@@ -27,11 +27,16 @@ import { buildCampaignFilingActivity } from "../model/campaign-filing-activity.j
|
|
|
27
27
|
}
|
|
28
28
|
/** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */ const ALREADY_EXISTS = 6;
|
|
29
29
|
/**
|
|
30
|
-
* The names of
|
|
31
|
-
* A container that is gone answers its id,
|
|
32
|
-
|
|
30
|
+
* The names of an org's campaigns (`orgs/{orgId}/emailCampaigns`), by id,
|
|
31
|
+
* for the entries that name them. A container that is gone answers its id,
|
|
32
|
+
* so the entry still says which.
|
|
33
|
+
*/ export async function orgCampaignRefs(firestore, orgId, campaignIds) {
|
|
33
34
|
if (!campaignIds.length) return [];
|
|
34
|
-
|
|
35
|
+
if (!orgId) return campaignIds.map((id)=>({
|
|
36
|
+
id,
|
|
37
|
+
name: id
|
|
38
|
+
}));
|
|
39
|
+
const containers = firestore.collection('orgs').doc(orgId).collection('emailCampaigns');
|
|
35
40
|
const found = await firestore.getAll(...campaignIds.map((id)=>containers.doc(id)));
|
|
36
41
|
return campaignIds.map((id, index)=>{
|
|
37
42
|
var _ref;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/campaign-filing-activity.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 { consentGroupForHost } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport {\n CRM_COLLECTIONS,\n crmActivityLogHasRoom,\n crmScopeTokens,\n type CrmActivityLink,\n} from '@aglyn/aglyn/app-utils/crm'\nimport { countCrmActivitiesForRecord } from '@aglyn/tenant-data-admin/server/crm-records'\nimport { createHash } from 'crypto'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport {\n buildCampaignFilingActivity,\n type CampaignFilingAction,\n type CampaignFilingRef,\n} from '../model/campaign-filing-activity'\n\n/**\n * FILES A FILING ON THE RECORD'S TIMELINE (AGL-3274), from the server.\n *\n * The two doors that file a lead under a campaign on the server — the\n * `leads-create` route behind the New lead drawer, and the conversion's\n * carry of a lead's campaigns onto the contact — write the entry through\n * this, with the scope a record made on the site carries and the CRM's\n * own id as the filing plugin, exactly as the console's hook writes one.\n *\n * ## Once, when the caller says so\n *\n * A member's act is an act each time, and the route's entries are named\n * by `createResourceUid()`, the id every console resource carries. The\n * conversion carry runs from a seam every converting\n * door reaches, and a door that runs twice must not file twice — so a\n * caller that hands in a `dedupeKey` gets a keyed `create()`, and the\n * second run finds its own entry and writes nothing. The key is hashed\n * under the CRM's id the way the timeline seam hashes a plugin's, so no\n * caller's raw key becomes a document id.\n *\n * ## Never throws\n *\n * The membership has landed by the time this runs; an entry that could\n * not be filed is logged, and the record's page shows the campaigns from\n * the document either way. A record at its activity ceiling is refused\n * before anything is written, in the same breath.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CampaignFilingActivitiesInput {\n orgId: string\n /**\n * The organization document, when the caller already holds it — the\n * scope tokens read `defaultResourceScope` and the consent groups off\n * it. Read here otherwise.\n */\n org?: Record<string, unknown> | null\n /** The site the entry carries as provenance. */\n hostId: string\n link: CrmActivityLink\n action: CampaignFilingAction\n campaigns: readonly CampaignFilingRef[]\n atMs: number\n /** The member who did it, or `''` for the conversion. */\n byUid: string\n byName?: string | null\n /**\n * A key that files each campaign's entry once across runs — the carry's\n * — or nothing for a member's act, which is new each time.\n */\n dedupeKey?: string | null\n}\n\n/** The document id a keyed entry files under: the CRM's namespace, never a raw key. */\nexport function campaignFilingActivityId(key: string): string {\n return `plg_${createHash('sha256').update(`${BUNDLE_ID}:${key}`).digest('hex').slice(0, 28)}`\n}\n\n/** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */\nconst ALREADY_EXISTS = 6\n\n/**\n * The names of
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/campaign-filing-activity.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 { consentGroupForHost } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport {\n CRM_COLLECTIONS,\n crmActivityLogHasRoom,\n crmScopeTokens,\n type CrmActivityLink,\n} from '@aglyn/aglyn/app-utils/crm'\nimport { countCrmActivitiesForRecord } from '@aglyn/tenant-data-admin/server/crm-records'\nimport { createHash } from 'crypto'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport {\n buildCampaignFilingActivity,\n type CampaignFilingAction,\n type CampaignFilingRef,\n} from '../model/campaign-filing-activity'\n\n/**\n * FILES A FILING ON THE RECORD'S TIMELINE (AGL-3274), from the server.\n *\n * The two doors that file a lead under a campaign on the server — the\n * `leads-create` route behind the New lead drawer, and the conversion's\n * carry of a lead's campaigns onto the contact — write the entry through\n * this, with the scope a record made on the site carries and the CRM's\n * own id as the filing plugin, exactly as the console's hook writes one.\n *\n * ## Once, when the caller says so\n *\n * A member's act is an act each time, and the route's entries are named\n * by `createResourceUid()`, the id every console resource carries. The\n * conversion carry runs from a seam every converting\n * door reaches, and a door that runs twice must not file twice — so a\n * caller that hands in a `dedupeKey` gets a keyed `create()`, and the\n * second run finds its own entry and writes nothing. The key is hashed\n * under the CRM's id the way the timeline seam hashes a plugin's, so no\n * caller's raw key becomes a document id.\n *\n * ## Never throws\n *\n * The membership has landed by the time this runs; an entry that could\n * not be filed is logged, and the record's page shows the campaigns from\n * the document either way. A record at its activity ceiling is refused\n * before anything is written, in the same breath.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CampaignFilingActivitiesInput {\n orgId: string\n /**\n * The organization document, when the caller already holds it — the\n * scope tokens read `defaultResourceScope` and the consent groups off\n * it. Read here otherwise.\n */\n org?: Record<string, unknown> | null\n /** The site the entry carries as provenance. */\n hostId: string\n link: CrmActivityLink\n action: CampaignFilingAction\n campaigns: readonly CampaignFilingRef[]\n atMs: number\n /** The member who did it, or `''` for the conversion. */\n byUid: string\n byName?: string | null\n /**\n * A key that files each campaign's entry once across runs — the carry's\n * — or nothing for a member's act, which is new each time.\n */\n dedupeKey?: string | null\n}\n\n/** The document id a keyed entry files under: the CRM's namespace, never a raw key. */\nexport function campaignFilingActivityId(key: string): string {\n return `plg_${createHash('sha256').update(`${BUNDLE_ID}:${key}`).digest('hex').slice(0, 28)}`\n}\n\n/** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */\nconst ALREADY_EXISTS = 6\n\n/**\n * The names of an org's campaigns (`orgs/{orgId}/emailCampaigns`), by id,\n * for the entries that name them. A container that is gone answers its id,\n * so the entry still says which.\n */\nexport async function orgCampaignRefs(\n firestore: Firestore,\n orgId: string,\n campaignIds: readonly string[],\n): Promise<CampaignFilingRef[]> {\n if (!campaignIds.length) return []\n if (!orgId) return campaignIds.map((id) => ({ id, name: id }))\n const containers = firestore.collection('orgs').doc(orgId).collection('emailCampaigns')\n const found = await firestore.getAll(...campaignIds.map((id) => containers.doc(id)))\n return campaignIds.map((id, index) => ({\n id,\n name: String(found[index]?.get('name') ?? '').trim() || id,\n }))\n}\n\n/** Files one entry per campaign; answers how many were written. */\nexport async function fileCampaignFilingActivities(\n firestore: Firestore,\n input: CampaignFilingActivitiesInput,\n): Promise<number> {\n if (!input.campaigns.length) return 0\n try {\n const orgRef = firestore.collection('orgs').doc(input.orgId)\n const org =\n input.org ?? ((await orgRef.get()).data() as Record<string, unknown> | undefined) ?? null\n const visibleTo = crmScopeTokens(org, consentGroupForHost(org, input.hostId))\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, input.link))) {\n console.warn('[crm] the filing was not written on the timeline: the record’s log is full', input.link)\n return 0\n }\n const activities = orgRef.collection(CRM_COLLECTIONS.activities)\n let written = 0\n for (const campaign of input.campaigns) {\n const entry = {\n ...buildCampaignFilingActivity({\n action: input.action,\n campaign,\n link: input.link,\n hostId: input.hostId,\n visibleTo,\n atMs: input.atMs,\n byUid: input.byUid,\n byName: input.byName ?? null,\n }),\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n if (input.dedupeKey) {\n try {\n await activities.doc(campaignFilingActivityId(`${input.dedupeKey}:${campaign.id}`)).create(entry)\n written += 1\n } catch (error) {\n if ((error as { code?: unknown })?.code !== ALREADY_EXISTS) throw error\n }\n } else {\n // A member's act, new each time, named by the platform's own id.\n await activities.doc(createResourceUid()).create(entry)\n written += 1\n }\n }\n return written\n } catch (error) {\n console.error('[crm] the filing could not be written on the timeline', input.link, error)\n return 0\n }\n}\n"],"names":["consentGroupForHost","createResourceUid","CRM_COLLECTIONS","crmActivityLogHasRoom","crmScopeTokens","countCrmActivitiesForRecord","createHash","FieldValue","BUNDLE_ID","buildCampaignFilingActivity","campaignFilingActivityId","key","update","digest","slice","ALREADY_EXISTS","orgCampaignRefs","firestore","orgId","campaignIds","length","map","id","name","containers","collection","doc","found","getAll","index","String","get","trim","fileCampaignFilingActivities","input","campaigns","orgRef","org","data","visibleTo","hostId","link","console","warn","activities","written","campaign","entry","action","atMs","byUid","byName","createdAt","serverTimestamp","updatedAt","dedupeKey","create","error","code"],"mappings":"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"}
|
|
@@ -165,26 +165,34 @@ const refuse = (status, error)=>({
|
|
|
165
165
|
* trip however many there are, and it needs no index. An email the team has
|
|
166
166
|
* since deleted reads as `null`, which the timeline draws as the subject the
|
|
167
167
|
* person received with no report to link to.
|
|
168
|
-
|
|
168
|
+
*
|
|
169
|
+
* A send is the organization's (`orgs/{orgId}/campaigns/{sendId}`); the
|
|
170
|
+
* rows reaching here were already narrowed to the reading group's sites,
|
|
171
|
+
* all of which belong to that org.
|
|
172
|
+
*/ async function readCampaignNames(orgId, rows) {
|
|
169
173
|
const names = new Map();
|
|
170
174
|
const firestore = firebaseAdmin.app().firestore();
|
|
171
|
-
const
|
|
175
|
+
const pending = [];
|
|
172
176
|
for (const row of rows){
|
|
173
177
|
const key = `${row.hostId}/${row.campaignId}`;
|
|
174
178
|
if (names.has(key)) continue;
|
|
175
179
|
names.set(key, null);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
var _ref, _snapshot_get, _snapshot_get1;
|
|
182
|
-
var _snapshot_ref_parent_parent;
|
|
183
|
-
if (!snapshot.exists) continue;
|
|
184
|
-
const hostId = (_ref = (_snapshot_ref_parent_parent = snapshot.ref.parent.parent) == null ? void 0 : _snapshot_ref_parent_parent.id) != null ? _ref : '';
|
|
185
|
-
const name = String((_snapshot_get = snapshot.get('displayName')) != null ? _snapshot_get : '').trim() || String((_snapshot_get1 = snapshot.get('subject')) != null ? _snapshot_get1 : '').trim();
|
|
186
|
-
names.set(`${hostId}/${snapshot.id}`, name || null);
|
|
180
|
+
pending.push({
|
|
181
|
+
key,
|
|
182
|
+
hostId: String(row.hostId),
|
|
183
|
+
sendId: String(row.campaignId)
|
|
184
|
+
});
|
|
187
185
|
}
|
|
186
|
+
if (!pending.length) return names;
|
|
187
|
+
const nameOf = (snapshot)=>{
|
|
188
|
+
var _snapshot_get, _snapshot_get1;
|
|
189
|
+
return String((_snapshot_get = snapshot.get('displayName')) != null ? _snapshot_get : '').trim() || String((_snapshot_get1 = snapshot.get('subject')) != null ? _snapshot_get1 : '').trim() || null;
|
|
190
|
+
};
|
|
191
|
+
const sends = firestore.collection('orgs').doc(orgId).collection('campaigns');
|
|
192
|
+
const found = await firestore.getAll(...pending.map((entry)=>sends.doc(entry.sendId)));
|
|
193
|
+
found.forEach((snapshot, index)=>{
|
|
194
|
+
if (snapshot.exists) names.set(pending[index].key, nameOf(snapshot));
|
|
195
|
+
});
|
|
188
196
|
return names;
|
|
189
197
|
}
|
|
190
198
|
export const contactEmailHistoryHandler = async (req, res)=>{
|
|
@@ -241,7 +249,7 @@ export const contactEmailHistoryHandler = async (req, res)=>{
|
|
|
241
249
|
limit: EMAIL_DELIVERY_READ_LIMIT
|
|
242
250
|
});
|
|
243
251
|
const rows = history.rows.filter((row)=>Boolean(row.campaignId) && Boolean(row.hostId) && group.hostIds.includes(String(row.hostId)));
|
|
244
|
-
const names = await readCampaignNames(rows);
|
|
252
|
+
const names = await readCampaignNames(reader.orgId, rows);
|
|
245
253
|
const emails = rows.map((row)=>{
|
|
246
254
|
var _names_get;
|
|
247
255
|
return contactCampaignEmailFromDelivery(row, (_names_get = names.get(`${row.hostId}/${row.campaignId}`)) != null ? _names_get : null);
|