@aglyn/plugins-crm 1.0.0-beta.162 → 1.0.0-beta.164
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/leads-bulk-bar.js +51 -5
- package/src/lib/components/leads-bulk-bar.js.map +1 -1
- package/src/lib/components/leads-section.js +93 -4
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/new-lead-drawer.d.ts +2 -0
- package/src/lib/components/new-lead-drawer.js +27 -1
- package/src/lib/components/new-lead-drawer.js.map +1 -1
- package/src/lib/declarations.server.js +15 -0
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/model/crm-lead-import.d.ts +11 -2
- package/src/lib/model/crm-lead-import.js +22 -2
- package/src/lib/model/crm-lead-import.js.map +1 -1
- package/src/lib/model/lead-filters.d.ts +8 -0
- package/src/lib/model/lead-filters.js +11 -1
- package/src/lib/model/lead-filters.js.map +1 -1
- package/src/lib/server/lead-campaign-carry.d.ts +50 -0
- package/src/lib/server/lead-campaign-carry.js +75 -0
- package/src/lib/server/lead-campaign-carry.js.map +1 -0
- package/src/lib/server/lead-create.d.ts +13 -0
- package/src/lib/server/lead-create.js +30 -0
- package/src/lib/server/lead-create.js.map +1 -1
- package/src/lib/server/leads-import.js +50 -5
- package/src/lib/server/leads-import.js.map +1 -1
|
@@ -64,7 +64,8 @@ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_wi
|
|
|
64
64
|
* to belong to, is left off the lead and REPORTED under
|
|
65
65
|
* {@link LeadImportRow.dropped}, so the operator learns that a column went
|
|
66
66
|
* nowhere rather than discovering it weeks later.
|
|
67
|
-
*/ import {
|
|
67
|
+
*/ import { CAMPAIGN_MEMBERSHIP_CAP } from "@aglyn/aglyn/app-utils/campaign-membership";
|
|
68
|
+
import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
|
|
68
69
|
import { CRM_LEAD_STATUS_LABELS, CRM_LEAD_STATUSES, CRM_LEAD_TEXT_MAX, isCrmLeadStatus, normalizeCrmLeadTags, normalizeCompanyWebsite } from "@aglyn/aglyn/app-utils/crm";
|
|
69
70
|
import { normalizeAddress, normalizePhone } from "@aglyn/aglyn/foundation/definitions/contact.types";
|
|
70
71
|
import { CSV_IMPORT_CHUNK_SIZE, CSV_IMPORT_MAX_BODY_BYTES, CSV_IMPORT_MAX_ROWS, CSV_IMPORT_PREVIEW_ROWS, emptyImportResult, guessImportMapping, importAliasKeys, importSkippedCsv, importTextValue, mapImportRow, mergeImportResults } from "@aglyn/aglyn/app-utils/csv-import";
|
|
@@ -108,6 +109,7 @@ export const LEAD_IMPORT_REASON_MAX = 500;
|
|
|
108
109
|
'addressPostalCode',
|
|
109
110
|
'addressCountry',
|
|
110
111
|
'tags',
|
|
112
|
+
'campaigns',
|
|
111
113
|
'unqualifiedReason',
|
|
112
114
|
'notes'
|
|
113
115
|
];
|
|
@@ -128,6 +130,7 @@ export const LEAD_IMPORT_REASON_MAX = 500;
|
|
|
128
130
|
addressPostalCode: 'Postal code',
|
|
129
131
|
addressCountry: 'Country (two-letter code)',
|
|
130
132
|
tags: 'Tags (comma or | separated)',
|
|
133
|
+
campaigns: 'Campaigns (by name, comma or | separated)',
|
|
131
134
|
unqualifiedReason: 'Unqualified reason',
|
|
132
135
|
notes: 'Notes'
|
|
133
136
|
};
|
|
@@ -251,6 +254,15 @@ export const LEAD_IMPORT_REASON_MAX = 500;
|
|
|
251
254
|
'labels',
|
|
252
255
|
'lists'
|
|
253
256
|
],
|
|
257
|
+
// `campaign` alone stays the lead source's: another tool's "Campaign"
|
|
258
|
+
// column names where the person came from, not one of this site's
|
|
259
|
+
// containers (AGL-3254).
|
|
260
|
+
campaigns: [
|
|
261
|
+
'campaigns',
|
|
262
|
+
'campaign names',
|
|
263
|
+
'in campaigns',
|
|
264
|
+
'email campaigns'
|
|
265
|
+
],
|
|
254
266
|
unqualifiedReason: [
|
|
255
267
|
'unqualified reason',
|
|
256
268
|
'reason',
|
|
@@ -280,8 +292,10 @@ const FIELD_ALIAS_KEYS = importAliasKeys(LEAD_IMPORT_FIELDS, FIELD_ALIASES);
|
|
|
280
292
|
'invalid-email': 'No usable email address',
|
|
281
293
|
duplicate: 'The same address appears earlier in this file',
|
|
282
294
|
'lead-ceiling': 'This site is at the platform lead limit',
|
|
295
|
+
'campaign-unknown': 'Names a campaign this site does not have',
|
|
283
296
|
'write-failed': 'Could not be saved'
|
|
284
297
|
};
|
|
298
|
+
/** The most campaigns one row may name — the membership field's own cap. */ export const LEAD_IMPORT_CAMPAIGNS_MAX = CAMPAIGN_MEMBERSHIP_CAP;
|
|
285
299
|
/**
|
|
286
300
|
* A status cell by id or by label — `working`, `Working` and `WORKING` are
|
|
287
301
|
* one status. `null` for a cell naming no status a file may set, which
|
|
@@ -302,7 +316,7 @@ const FIELD_ALIAS_KEYS = importAliasKeys(LEAD_IMPORT_FIELDS, FIELD_ALIASES);
|
|
|
302
316
|
* the document id; the owner is refused by the server, which alone can
|
|
303
317
|
* look them up.
|
|
304
318
|
*/ export function normalizeLeadImportRow(raw) {
|
|
305
|
-
var _importTextValue, _raw_tags;
|
|
319
|
+
var _importTextValue, _raw_tags, _raw_campaigns;
|
|
306
320
|
var _importTextValue1, _importTextValue2, _importTextValue3, _importTextValue4;
|
|
307
321
|
const emailText = (_importTextValue = importTextValue(raw.email, 320)) != null ? _importTextValue : '';
|
|
308
322
|
const email = normalizeContactEmail(emailText);
|
|
@@ -365,6 +379,12 @@ const FIELD_ALIAS_KEYS = importAliasKeys(LEAD_IMPORT_FIELDS, FIELD_ALIASES);
|
|
|
365
379
|
if (countryText && !(address == null ? void 0 : address.country)) drop('addressCountry', countryText);
|
|
366
380
|
const tags = normalizeCrmLeadTags(String((_raw_tags = raw.tags) != null ? _raw_tags : '').split(/[,|]/).map((tag)=>tag.trim()));
|
|
367
381
|
if (tags.length) row.profile.tags = tags;
|
|
382
|
+
// Names, trimmed and deduplicated as typed; the server matches them to
|
|
383
|
+
// the site's campaigns without regard to case.
|
|
384
|
+
const campaigns = [
|
|
385
|
+
...new Set(String((_raw_campaigns = raw.campaigns) != null ? _raw_campaigns : '').split(/[,|]/).map((name)=>name.trim().replace(/\s+/g, ' ')).filter(Boolean))
|
|
386
|
+
].slice(0, LEAD_IMPORT_CAMPAIGNS_MAX);
|
|
387
|
+
if (campaigns.length) row.campaigns = campaigns;
|
|
368
388
|
const statusText = importTextValue(raw.status, 32);
|
|
369
389
|
if (statusText) {
|
|
370
390
|
const status = parseImportLeadStatus(statusText);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-lead-import.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * BRINGING A SPREADSHEET OF LEADS INTO THE CRM — the pure half (AGL-2701).\n *\n * The contact import's three stages (`crm-import.ts`) over the lead\n * vocabulary. A lead is the one CRM record a person does not type: it is\n * what a capture door wrote when somebody signed up, booked or submitted a\n * form, plus the working state the team wrote on top. A file therefore\n * carries HALF a lead — the person, and the team's annotations — and the\n * capture half is the door's to write.\n *\n * ## The address is the identity, because it is the document id\n *\n * A lead lives at `hosts/{hostId}/leads/{personKey}`, and `personKey` is\n * the sha256 of the normalized address. So the address is not merely the\n * one required cell: it is the row's name. A cell the normalizer cannot\n * read is refused as `invalid-email`, and two rows of one file that\n * normalize to the same address are one person — the second is skipped as\n * `duplicate` rather than merged onto the first, for the reason the\n * contacts import gives: one person reported once created and once merged\n * is a number the operator cannot reconcile against the rows they sent.\n *\n * ## What a file may NOT say\n *\n * The export's `Sources`, `First seen`, `Last seen` and `Captures` columns\n * are the capture door's own record of what the visitor did, and\n * `Converted` is stamped by `crm/lead-convert` after the contact exists.\n * None of them is a field here, so the mapping leaves those columns\n * unmapped rather than mis-mapping them — a file that could write\n * \"first seen\" could rewrite the history it is supposed to describe. The\n * organization-level file's `Site` column is left out for a second reason:\n * the site is the drawer's question and the route's permission is granted\n * for that ONE site, so a cell must not be able to redirect a row to\n * another.\n *\n * ## Qualified is a conversion, not a status a cell can claim\n *\n * `qualified` means a lead BECAME a contact, and `convertedContactId`\n * beside it names the contact that was really created. Only the convert\n * route writes the pair, so the list's own status control offers new,\n * working and unqualified and nothing else. A cell naming `qualified` is\n * dropped and reported here for the same reason: a status claiming a\n * conversion that never happened would show a contact link to nowhere.\n *\n * ## A bad cell is dropped and named; a bad row is skipped and named\n *\n * The address is the only cell that can make a row unstorable. An\n * unreadable status, or an unqualified reason with no unqualified status\n * to belong to, is left off the lead and REPORTED under\n * {@link LeadImportRow.dropped}, so the operator learns that a column went\n * nowhere rather than discovering it weeks later.\n */\n\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport {\n CRM_LEAD_STATUS_LABELS,\n CRM_LEAD_STATUSES,\n CRM_LEAD_TEXT_MAX,\n type CrmLeadProfile,\n type CrmLeadStatus,\n isCrmLeadStatus,\n normalizeCrmLeadTags,\n normalizeCompanyWebsite,\n} from '@aglyn/aglyn/app-utils/crm'\nimport {\n normalizeAddress,\n normalizePhone,\n} from '@aglyn/aglyn/foundation/definitions/contact.types'\nimport {\n CSV_IMPORT_CHUNK_SIZE,\n CSV_IMPORT_MAX_BODY_BYTES,\n CSV_IMPORT_MAX_ROWS,\n CSV_IMPORT_PREVIEW_ROWS,\n emptyImportResult,\n guessImportMapping,\n importAliasKeys,\n type ImportChunkResult,\n type ImportDroppedValue,\n importSkippedCsv,\n importTextValue,\n mapImportRow,\n mergeImportResults,\n} from '@aglyn/aglyn/app-utils/csv-import'\n\n/** The shared ceilings, under this collection's names. */\nexport const LEAD_IMPORT_MAX_ROWS = CSV_IMPORT_MAX_ROWS\nexport const LEAD_IMPORT_CHUNK_SIZE = CSV_IMPORT_CHUNK_SIZE\nexport const LEAD_IMPORT_MAX_BODY_BYTES = CSV_IMPORT_MAX_BODY_BYTES\nexport const LEAD_IMPORT_PREVIEW_ROWS = CSV_IMPORT_PREVIEW_ROWS\n\n/** The same caps the lead's own cards hold a typed value to. */\nexport const LEAD_IMPORT_NOTES_MAX = 4000\nexport const LEAD_IMPORT_REASON_MAX = 500\n\n/** A person's name, capped where every other import caps one. */\nconst NAME_MAX = 120\n\n/**\n * The statuses a file may set.\n *\n * The list's own status control offers exactly these three, and for the\n * reason given above: `qualified` is written by the conversion, beside the\n * contact it produced.\n */\nexport const LEAD_IMPORT_STATUSES: readonly CrmLeadStatus[] =\n CRM_LEAD_STATUSES.filter((status) => status !== 'qualified')\n\n/**\n * The fields a column may be mapped to, in the order the mapping menu\n * lists them.\n *\n * The lead's own profile (AGL-3231) — company, title, phone, website, the\n * lead source, the address parts and tags — is what a list from another\n * tool actually carries, and the reason a lead can be worked without a\n * contact beside it. They read under the same labels the contacts import\n * uses, so one spreadsheet maps the same way into either.\n */\nexport const LEAD_IMPORT_FIELDS = [\n 'email',\n 'name',\n 'company',\n 'jobTitle',\n 'phone',\n 'website',\n 'leadSource',\n 'status',\n 'ownerEmail',\n 'addressLine1',\n 'addressLine2',\n 'addressCity',\n 'addressState',\n 'addressPostalCode',\n 'addressCountry',\n 'tags',\n 'unqualifiedReason',\n 'notes',\n] as const\n\nexport type LeadImportField = (typeof LEAD_IMPORT_FIELDS)[number]\n\n/** How each field reads in the mapping menu. Typed so a field cannot ship unlabeled. */\nexport const LEAD_IMPORT_FIELD_LABELS: Record<LeadImportField, string> = {\n email: 'Email (required)',\n name: 'Name',\n company: 'Company name',\n jobTitle: 'Job title',\n phone: 'Phone',\n website: 'Website',\n leadSource: 'Lead source',\n status: 'Status (new, working, unqualified)',\n ownerEmail: 'Owner (team member email)',\n addressLine1: 'Address line 1',\n addressLine2: 'Address line 2',\n addressCity: 'City',\n addressState: 'State or region',\n addressPostalCode: 'Postal code',\n addressCountry: 'Country (two-letter code)',\n tags: 'Tags (comma or | separated)',\n unqualifiedReason: 'Unqualified reason',\n notes: 'Notes',\n}\n\n/**\n * Header aliases per field, matched after the shared header normalization.\n *\n * First in each list is the header this CRM's own leads export writes, so\n * an export re-imports without a hand mapping; the export's Sources, First\n * seen, Last seen, Captures, Converted and Site columns are deliberately\n * absent from every list, so they land on \"Do not import\".\n */\nconst FIELD_ALIASES: Record<LeadImportField, readonly string[]> = {\n email: ['email', 'email address', 'e mail', 'mail', 'contact email', 'work email'],\n name: ['name', 'full name', 'lead', 'lead name', 'contact', 'contact name', 'person'],\n company: [\n 'company',\n 'company name',\n 'organization',\n 'organisation',\n 'account',\n 'account name',\n 'employer',\n ],\n jobTitle: ['job title', 'title', 'position', 'role', 'headline'],\n phone: ['phone', 'phone number', 'mobile', 'mobile phone', 'telephone', 'tel', 'cell'],\n website: ['website', 'web site', 'url', 'company website', 'company domain', 'domain'],\n leadSource: ['lead source', 'source', 'origin', 'channel', 'campaign'],\n status: ['status', 'lead status', 'stage', 'lead stage'],\n ownerEmail: ['owner', 'owner email', 'assigned to', 'assignee', 'rep', 'sales rep'],\n addressLine1: ['address', 'address line 1', 'street', 'street address', 'address 1'],\n addressLine2: ['address line 2', 'address 2', 'suite', 'apartment'],\n addressCity: ['city', 'town', 'locality'],\n addressState: ['state', 'state or region', 'region', 'province', 'county'],\n addressPostalCode: ['postal code', 'postcode', 'zip', 'zip code'],\n addressCountry: ['country', 'country code'],\n tags: ['tags', 'tag', 'labels', 'lists'],\n unqualifiedReason: [\n 'unqualified reason',\n 'reason',\n 'disqualified reason',\n 'lost reason',\n 'reason lost',\n ],\n notes: ['notes', 'note', 'description', 'comments', 'details'],\n}\n\nconst FIELD_ALIAS_KEYS = importAliasKeys(LEAD_IMPORT_FIELDS, FIELD_ALIASES)\n\n/** Column index → field. A column absent from the map is not imported. */\nexport type LeadImportMapping = Record<number, LeadImportField>\n\n/** A proposed mapping from a file's header row, each field taken at most once. */\nexport function guessLeadImportMapping(columns: readonly string[]): LeadImportMapping {\n return guessImportMapping(columns, LEAD_IMPORT_FIELDS, FIELD_ALIAS_KEYS)\n}\n\n/**\n * What the browser posts for one line: the cells the mapping selected,\n * under the field they were mapped to, verbatim. `unknown` because the\n * server reads this off an untrusted body.\n */\nexport type LeadImportRawRow = Partial<Record<LeadImportField, unknown>>\n\n/** One parsed line under the mapping. Empty cells are left absent; a lead has no custom fields. */\nexport function mapLeadImportRow(\n cells: readonly string[],\n mapping: Record<number, LeadImportField | `custom:${string}`>,\n): LeadImportRawRow {\n const { custom: _custom, ...row } = mapImportRow(cells, mapping)\n return row\n}\n\nexport type LeadImportSkipReason =\n | 'invalid-email'\n | 'duplicate'\n | 'lead-ceiling'\n | 'write-failed'\n\n/** How a skip reason reads on screen and in the downloaded file. */\nexport const LEAD_IMPORT_SKIP_LABELS: Record<LeadImportSkipReason, string> = {\n 'invalid-email': 'No usable email address',\n duplicate: 'The same address appears earlier in this file',\n 'lead-ceiling': 'This site is at the platform lead limit',\n 'write-failed': 'Could not be saved',\n}\n\n/** One row, ready for the server to resolve and write. */\nexport interface LeadImportRow {\n /** Normalized — the address `personKey` derives the document id from. */\n email: string\n name?: string\n /**\n * The lead's own profile, already normalized the way the record stores\n * it (AGL-3231): a key present is a value to write, a key absent leaves\n * an existing lead's value alone. Never a clear — a blank cell in a file\n * is a cell nobody filled, not a decision to erase what the site knows.\n */\n profile: CrmLeadProfile\n /** Absent leaves an existing lead's status alone and a new one reading as `new`. */\n status?: CrmLeadStatus\n /** Normalized, for the server to resolve against the org's members. */\n ownerEmail?: string\n /** Only ever set beside `status: 'unqualified'` — the pair is one fact. */\n unqualifiedReason?: string\n notes?: string\n dropped: ImportDroppedValue[]\n}\n\nexport type LeadImportRowVerdict =\n | { ok: true; row: LeadImportRow }\n | { ok: false; reason: 'invalid-email'; input: string }\n\n/**\n * A status cell by id or by label — `working`, `Working` and `WORKING` are\n * one status. `null` for a cell naming no status a file may set, which\n * includes the well-spelled `Qualified` the export writes for a converted\n * lead.\n */\nexport function parseImportLeadStatus(value: unknown): CrmLeadStatus | null {\n const text = String(value ?? '')\n .trim()\n .toLowerCase()\n if (!text) return null\n const byId = isCrmLeadStatus(text) ? text : null\n const byLabel =\n LEAD_IMPORT_STATUSES.find(\n (status) => CRM_LEAD_STATUS_LABELS[status].toLowerCase() === text,\n ) ?? null\n const status = byId ?? byLabel\n return status && LEAD_IMPORT_STATUSES.includes(status) ? status : null\n}\n\n/**\n * One raw row as the values that will be written, or the reason it cannot\n * be. Refused here only for an unusable address, because the address is\n * the document id; the owner is refused by the server, which alone can\n * look them up.\n */\nexport function normalizeLeadImportRow(raw: LeadImportRawRow): LeadImportRowVerdict {\n const emailText = importTextValue(raw.email, 320) ?? ''\n const email = normalizeContactEmail(emailText)\n if (!email) {\n return { ok: false, reason: 'invalid-email', input: emailText }\n }\n const dropped: ImportDroppedValue[] = []\n const drop = (field: LeadImportField, value: unknown) => {\n dropped.push({ field, value: String(value ?? '').trim() })\n }\n const row: LeadImportRow = { email, profile: {}, dropped }\n\n const name = importTextValue(raw.name, NAME_MAX)?.replace(/\\s+/g, ' ')\n if (name) row.name = name\n\n /*\n * The profile (AGL-3231), field by field, each through the normalizer\n * the record's own card runs. A phone or a website the normalizer cannot\n * read is dropped and reported rather than stored as typed, because the\n * record renders both as links; the country is the one address part the\n * address normalizer drops silently — a typed name is not a code — so it\n * is the one part the report has to name.\n */\n const company = importTextValue(raw.company, CRM_LEAD_TEXT_MAX)?.replace(/\\s+/g, ' ')\n if (company) row.profile.company = company\n const jobTitle = importTextValue(raw.jobTitle, CRM_LEAD_TEXT_MAX)?.replace(/\\s+/g, ' ')\n if (jobTitle) row.profile.jobTitle = jobTitle\n const leadSource = importTextValue(raw.leadSource, CRM_LEAD_TEXT_MAX)?.replace(\n /\\s+/g,\n ' ',\n )\n if (leadSource) row.profile.leadSource = leadSource\n const phoneText = importTextValue(raw.phone, 64)\n if (phoneText) {\n const phone = normalizePhone(phoneText)\n if (phone) row.profile.phone = phone\n else drop('phone', phoneText)\n }\n const websiteText = importTextValue(raw.website, 320)\n if (websiteText) {\n const website = normalizeCompanyWebsite(websiteText)\n if (website) row.profile.website = website\n else drop('website', websiteText)\n }\n const address = normalizeAddress({\n line1: importTextValue(raw.addressLine1, 200),\n line2: importTextValue(raw.addressLine2, 200),\n city: importTextValue(raw.addressCity, 120),\n state: importTextValue(raw.addressState, 120),\n postalCode: importTextValue(raw.addressPostalCode, 32),\n country: importTextValue(raw.addressCountry, 8),\n })\n if (address) row.profile.address = address\n const countryText = importTextValue(raw.addressCountry, 64)\n if (countryText && !address?.country) drop('addressCountry', countryText)\n const tags = normalizeCrmLeadTags(\n String(raw.tags ?? '')\n .split(/[,|]/)\n .map((tag) => tag.trim()),\n )\n if (tags.length) row.profile.tags = tags\n\n const statusText = importTextValue(raw.status, 32)\n if (statusText) {\n const status = parseImportLeadStatus(statusText)\n if (status) row.status = status\n else drop('status', statusText)\n }\n\n const ownerText = importTextValue(raw.ownerEmail, 320)\n if (ownerText) {\n const owner = normalizeContactEmail(ownerText)\n if (owner) row.ownerEmail = owner\n else drop('ownerEmail', ownerText)\n }\n\n /*\n * The reason belongs to the status: it is what an unqualified lead was\n * closed FOR, and on a lead that is not unqualified it describes\n * nothing. A row that carries one without the other keeps the status and\n * reports the reason, so a mis-mapped column is visible rather than\n * stored where nothing reads it.\n */\n const reasonText = importTextValue(raw.unqualifiedReason, LEAD_IMPORT_REASON_MAX)\n if (reasonText) {\n if (row.status === 'unqualified') row.unqualifiedReason = reasonText\n else drop('unqualifiedReason', reasonText)\n }\n\n const notes = importTextValue(raw.notes, LEAD_IMPORT_NOTES_MAX)\n if (notes) row.notes = notes\n\n return { ok: true, row }\n}\n\n/** One row the server did not store, by its index in the request, named by the address. */\nexport interface LeadImportSkippedRow {\n index: number\n email: string\n reason: LeadImportSkipReason\n}\n\n/** What one request did. The drawer sums these across a file. */\nexport type LeadImportChunkResult = ImportChunkResult<LeadImportSkippedRow>\n\nexport function emptyLeadImportResult(): LeadImportChunkResult {\n return emptyImportResult<LeadImportSkippedRow>()\n}\n\nexport function mergeLeadImportResults(\n total: LeadImportChunkResult,\n chunk: LeadImportChunkResult,\n offset = 0,\n): LeadImportChunkResult {\n return mergeImportResults(total, chunk, offset)\n}\n\n/** The skipped rows as a file the operator can fix and re-import. */\nexport function leadImportSkippedCsv(\n columns: readonly string[],\n entries: readonly { cells: readonly string[]; reason: LeadImportSkipReason }[],\n): string {\n return importSkippedCsv(columns, entries, LEAD_IMPORT_SKIP_LABELS)\n}\n"],"names":["normalizeContactEmail","CRM_LEAD_STATUS_LABELS","CRM_LEAD_STATUSES","CRM_LEAD_TEXT_MAX","isCrmLeadStatus","normalizeCrmLeadTags","normalizeCompanyWebsite","normalizeAddress","normalizePhone","CSV_IMPORT_CHUNK_SIZE","CSV_IMPORT_MAX_BODY_BYTES","CSV_IMPORT_MAX_ROWS","CSV_IMPORT_PREVIEW_ROWS","emptyImportResult","guessImportMapping","importAliasKeys","importSkippedCsv","importTextValue","mapImportRow","mergeImportResults","LEAD_IMPORT_MAX_ROWS","LEAD_IMPORT_CHUNK_SIZE","LEAD_IMPORT_MAX_BODY_BYTES","LEAD_IMPORT_PREVIEW_ROWS","LEAD_IMPORT_NOTES_MAX","LEAD_IMPORT_REASON_MAX","NAME_MAX","LEAD_IMPORT_STATUSES","filter","status","LEAD_IMPORT_FIELDS","LEAD_IMPORT_FIELD_LABELS","email","name","company","jobTitle","phone","website","leadSource","ownerEmail","addressLine1","addressLine2","addressCity","addressState","addressPostalCode","addressCountry","tags","unqualifiedReason","notes","FIELD_ALIASES","FIELD_ALIAS_KEYS","guessLeadImportMapping","columns","mapLeadImportRow","cells","mapping","custom","_custom","row","LEAD_IMPORT_SKIP_LABELS","duplicate","parseImportLeadStatus","value","text","String","trim","toLowerCase","byId","byLabel","find","includes","normalizeLeadImportRow","raw","emailText","ok","reason","input","dropped","drop","field","push","profile","replace","phoneText","websiteText","address","line1","line2","city","state","postalCode","country","countryText","split","map","tag","length","statusText","ownerText","owner","reasonText","emptyLeadImportResult","mergeLeadImportResults","total","chunk","offset","leadImportSkippedCsv","entries"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-lead-import.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * BRINGING A SPREADSHEET OF LEADS INTO THE CRM — the pure half (AGL-2701).\n *\n * The contact import's three stages (`crm-import.ts`) over the lead\n * vocabulary. A lead is the one CRM record a person does not type: it is\n * what a capture door wrote when somebody signed up, booked or submitted a\n * form, plus the working state the team wrote on top. A file therefore\n * carries HALF a lead — the person, and the team's annotations — and the\n * capture half is the door's to write.\n *\n * ## The address is the identity, because it is the document id\n *\n * A lead lives at `hosts/{hostId}/leads/{personKey}`, and `personKey` is\n * the sha256 of the normalized address. So the address is not merely the\n * one required cell: it is the row's name. A cell the normalizer cannot\n * read is refused as `invalid-email`, and two rows of one file that\n * normalize to the same address are one person — the second is skipped as\n * `duplicate` rather than merged onto the first, for the reason the\n * contacts import gives: one person reported once created and once merged\n * is a number the operator cannot reconcile against the rows they sent.\n *\n * ## What a file may NOT say\n *\n * The export's `Sources`, `First seen`, `Last seen` and `Captures` columns\n * are the capture door's own record of what the visitor did, and\n * `Converted` is stamped by `crm/lead-convert` after the contact exists.\n * None of them is a field here, so the mapping leaves those columns\n * unmapped rather than mis-mapping them — a file that could write\n * \"first seen\" could rewrite the history it is supposed to describe. The\n * organization-level file's `Site` column is left out for a second reason:\n * the site is the drawer's question and the route's permission is granted\n * for that ONE site, so a cell must not be able to redirect a row to\n * another.\n *\n * ## Qualified is a conversion, not a status a cell can claim\n *\n * `qualified` means a lead BECAME a contact, and `convertedContactId`\n * beside it names the contact that was really created. Only the convert\n * route writes the pair, so the list's own status control offers new,\n * working and unqualified and nothing else. A cell naming `qualified` is\n * dropped and reported here for the same reason: a status claiming a\n * conversion that never happened would show a contact link to nowhere.\n *\n * ## A bad cell is dropped and named; a bad row is skipped and named\n *\n * The address is the only cell that can make a row unstorable. An\n * unreadable status, or an unqualified reason with no unqualified status\n * to belong to, is left off the lead and REPORTED under\n * {@link LeadImportRow.dropped}, so the operator learns that a column went\n * nowhere rather than discovering it weeks later.\n */\n\nimport { CAMPAIGN_MEMBERSHIP_CAP } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport {\n CRM_LEAD_STATUS_LABELS,\n CRM_LEAD_STATUSES,\n CRM_LEAD_TEXT_MAX,\n type CrmLeadProfile,\n type CrmLeadStatus,\n isCrmLeadStatus,\n normalizeCrmLeadTags,\n normalizeCompanyWebsite,\n} from '@aglyn/aglyn/app-utils/crm'\nimport {\n normalizeAddress,\n normalizePhone,\n} from '@aglyn/aglyn/foundation/definitions/contact.types'\nimport {\n CSV_IMPORT_CHUNK_SIZE,\n CSV_IMPORT_MAX_BODY_BYTES,\n CSV_IMPORT_MAX_ROWS,\n CSV_IMPORT_PREVIEW_ROWS,\n emptyImportResult,\n guessImportMapping,\n importAliasKeys,\n type ImportChunkResult,\n type ImportDroppedValue,\n importSkippedCsv,\n importTextValue,\n mapImportRow,\n mergeImportResults,\n} from '@aglyn/aglyn/app-utils/csv-import'\n\n/** The shared ceilings, under this collection's names. */\nexport const LEAD_IMPORT_MAX_ROWS = CSV_IMPORT_MAX_ROWS\nexport const LEAD_IMPORT_CHUNK_SIZE = CSV_IMPORT_CHUNK_SIZE\nexport const LEAD_IMPORT_MAX_BODY_BYTES = CSV_IMPORT_MAX_BODY_BYTES\nexport const LEAD_IMPORT_PREVIEW_ROWS = CSV_IMPORT_PREVIEW_ROWS\n\n/** The same caps the lead's own cards hold a typed value to. */\nexport const LEAD_IMPORT_NOTES_MAX = 4000\nexport const LEAD_IMPORT_REASON_MAX = 500\n\n/** A person's name, capped where every other import caps one. */\nconst NAME_MAX = 120\n\n/**\n * The statuses a file may set.\n *\n * The list's own status control offers exactly these three, and for the\n * reason given above: `qualified` is written by the conversion, beside the\n * contact it produced.\n */\nexport const LEAD_IMPORT_STATUSES: readonly CrmLeadStatus[] =\n CRM_LEAD_STATUSES.filter((status) => status !== 'qualified')\n\n/**\n * The fields a column may be mapped to, in the order the mapping menu\n * lists them.\n *\n * The lead's own profile (AGL-3231) — company, title, phone, website, the\n * lead source, the address parts and tags — is what a list from another\n * tool actually carries, and the reason a lead can be worked without a\n * contact beside it. They read under the same labels the contacts import\n * uses, so one spreadsheet maps the same way into either.\n */\nexport const LEAD_IMPORT_FIELDS = [\n 'email',\n 'name',\n 'company',\n 'jobTitle',\n 'phone',\n 'website',\n 'leadSource',\n 'status',\n 'ownerEmail',\n 'addressLine1',\n 'addressLine2',\n 'addressCity',\n 'addressState',\n 'addressPostalCode',\n 'addressCountry',\n 'tags',\n 'campaigns',\n 'unqualifiedReason',\n 'notes',\n] as const\n\nexport type LeadImportField = (typeof LEAD_IMPORT_FIELDS)[number]\n\n/** How each field reads in the mapping menu. Typed so a field cannot ship unlabeled. */\nexport const LEAD_IMPORT_FIELD_LABELS: Record<LeadImportField, string> = {\n email: 'Email (required)',\n name: 'Name',\n company: 'Company name',\n jobTitle: 'Job title',\n phone: 'Phone',\n website: 'Website',\n leadSource: 'Lead source',\n status: 'Status (new, working, unqualified)',\n ownerEmail: 'Owner (team member email)',\n addressLine1: 'Address line 1',\n addressLine2: 'Address line 2',\n addressCity: 'City',\n addressState: 'State or region',\n addressPostalCode: 'Postal code',\n addressCountry: 'Country (two-letter code)',\n tags: 'Tags (comma or | separated)',\n campaigns: 'Campaigns (by name, comma or | separated)',\n unqualifiedReason: 'Unqualified reason',\n notes: 'Notes',\n}\n\n/**\n * Header aliases per field, matched after the shared header normalization.\n *\n * First in each list is the header this CRM's own leads export writes, so\n * an export re-imports without a hand mapping; the export's Sources, First\n * seen, Last seen, Captures, Converted and Site columns are deliberately\n * absent from every list, so they land on \"Do not import\".\n */\nconst FIELD_ALIASES: Record<LeadImportField, readonly string[]> = {\n email: ['email', 'email address', 'e mail', 'mail', 'contact email', 'work email'],\n name: ['name', 'full name', 'lead', 'lead name', 'contact', 'contact name', 'person'],\n company: [\n 'company',\n 'company name',\n 'organization',\n 'organisation',\n 'account',\n 'account name',\n 'employer',\n ],\n jobTitle: ['job title', 'title', 'position', 'role', 'headline'],\n phone: ['phone', 'phone number', 'mobile', 'mobile phone', 'telephone', 'tel', 'cell'],\n website: ['website', 'web site', 'url', 'company website', 'company domain', 'domain'],\n leadSource: ['lead source', 'source', 'origin', 'channel', 'campaign'],\n status: ['status', 'lead status', 'stage', 'lead stage'],\n ownerEmail: ['owner', 'owner email', 'assigned to', 'assignee', 'rep', 'sales rep'],\n addressLine1: ['address', 'address line 1', 'street', 'street address', 'address 1'],\n addressLine2: ['address line 2', 'address 2', 'suite', 'apartment'],\n addressCity: ['city', 'town', 'locality'],\n addressState: ['state', 'state or region', 'region', 'province', 'county'],\n addressPostalCode: ['postal code', 'postcode', 'zip', 'zip code'],\n addressCountry: ['country', 'country code'],\n tags: ['tags', 'tag', 'labels', 'lists'],\n // `campaign` alone stays the lead source's: another tool's \"Campaign\"\n // column names where the person came from, not one of this site's\n // containers (AGL-3254).\n campaigns: ['campaigns', 'campaign names', 'in campaigns', 'email campaigns'],\n unqualifiedReason: [\n 'unqualified reason',\n 'reason',\n 'disqualified reason',\n 'lost reason',\n 'reason lost',\n ],\n notes: ['notes', 'note', 'description', 'comments', 'details'],\n}\n\nconst FIELD_ALIAS_KEYS = importAliasKeys(LEAD_IMPORT_FIELDS, FIELD_ALIASES)\n\n/** Column index → field. A column absent from the map is not imported. */\nexport type LeadImportMapping = Record<number, LeadImportField>\n\n/** A proposed mapping from a file's header row, each field taken at most once. */\nexport function guessLeadImportMapping(columns: readonly string[]): LeadImportMapping {\n return guessImportMapping(columns, LEAD_IMPORT_FIELDS, FIELD_ALIAS_KEYS)\n}\n\n/**\n * What the browser posts for one line: the cells the mapping selected,\n * under the field they were mapped to, verbatim. `unknown` because the\n * server reads this off an untrusted body.\n */\nexport type LeadImportRawRow = Partial<Record<LeadImportField, unknown>>\n\n/** One parsed line under the mapping. Empty cells are left absent; a lead has no custom fields. */\nexport function mapLeadImportRow(\n cells: readonly string[],\n mapping: Record<number, LeadImportField | `custom:${string}`>,\n): LeadImportRawRow {\n const { custom: _custom, ...row } = mapImportRow(cells, mapping)\n return row\n}\n\nexport type LeadImportSkipReason =\n | 'invalid-email'\n | 'duplicate'\n | 'lead-ceiling'\n | 'campaign-unknown'\n | 'write-failed'\n\n/** How a skip reason reads on screen and in the downloaded file. */\nexport const LEAD_IMPORT_SKIP_LABELS: Record<LeadImportSkipReason, string> = {\n 'invalid-email': 'No usable email address',\n duplicate: 'The same address appears earlier in this file',\n 'lead-ceiling': 'This site is at the platform lead limit',\n 'campaign-unknown': 'Names a campaign this site does not have',\n 'write-failed': 'Could not be saved',\n}\n\n/** The most campaigns one row may name — the membership field's own cap. */\nexport const LEAD_IMPORT_CAMPAIGNS_MAX = CAMPAIGN_MEMBERSHIP_CAP\n\n/** One row, ready for the server to resolve and write. */\nexport interface LeadImportRow {\n /** Normalized — the address `personKey` derives the document id from. */\n email: string\n name?: string\n /**\n * The lead's own profile, already normalized the way the record stores\n * it (AGL-3231): a key present is a value to write, a key absent leaves\n * an existing lead's value alone. Never a clear — a blank cell in a file\n * is a cell nobody filled, not a decision to erase what the site knows.\n */\n profile: CrmLeadProfile\n /**\n * The campaigns the row files the lead under (AGL-3254), by NAME as the\n * file wrote them: the server resolves each against the site's own\n * containers and refuses the row whole when one is not there, because a\n * row filed under half its campaigns is a row nobody asked for.\n */\n campaigns?: string[]\n /** Absent leaves an existing lead's status alone and a new one reading as `new`. */\n status?: CrmLeadStatus\n /** Normalized, for the server to resolve against the org's members. */\n ownerEmail?: string\n /** Only ever set beside `status: 'unqualified'` — the pair is one fact. */\n unqualifiedReason?: string\n notes?: string\n dropped: ImportDroppedValue[]\n}\n\nexport type LeadImportRowVerdict =\n | { ok: true; row: LeadImportRow }\n | { ok: false; reason: 'invalid-email'; input: string }\n\n/**\n * A status cell by id or by label — `working`, `Working` and `WORKING` are\n * one status. `null` for a cell naming no status a file may set, which\n * includes the well-spelled `Qualified` the export writes for a converted\n * lead.\n */\nexport function parseImportLeadStatus(value: unknown): CrmLeadStatus | null {\n const text = String(value ?? '')\n .trim()\n .toLowerCase()\n if (!text) return null\n const byId = isCrmLeadStatus(text) ? text : null\n const byLabel =\n LEAD_IMPORT_STATUSES.find(\n (status) => CRM_LEAD_STATUS_LABELS[status].toLowerCase() === text,\n ) ?? null\n const status = byId ?? byLabel\n return status && LEAD_IMPORT_STATUSES.includes(status) ? status : null\n}\n\n/**\n * One raw row as the values that will be written, or the reason it cannot\n * be. Refused here only for an unusable address, because the address is\n * the document id; the owner is refused by the server, which alone can\n * look them up.\n */\nexport function normalizeLeadImportRow(raw: LeadImportRawRow): LeadImportRowVerdict {\n const emailText = importTextValue(raw.email, 320) ?? ''\n const email = normalizeContactEmail(emailText)\n if (!email) {\n return { ok: false, reason: 'invalid-email', input: emailText }\n }\n const dropped: ImportDroppedValue[] = []\n const drop = (field: LeadImportField, value: unknown) => {\n dropped.push({ field, value: String(value ?? '').trim() })\n }\n const row: LeadImportRow = { email, profile: {}, dropped }\n\n const name = importTextValue(raw.name, NAME_MAX)?.replace(/\\s+/g, ' ')\n if (name) row.name = name\n\n /*\n * The profile (AGL-3231), field by field, each through the normalizer\n * the record's own card runs. A phone or a website the normalizer cannot\n * read is dropped and reported rather than stored as typed, because the\n * record renders both as links; the country is the one address part the\n * address normalizer drops silently — a typed name is not a code — so it\n * is the one part the report has to name.\n */\n const company = importTextValue(raw.company, CRM_LEAD_TEXT_MAX)?.replace(/\\s+/g, ' ')\n if (company) row.profile.company = company\n const jobTitle = importTextValue(raw.jobTitle, CRM_LEAD_TEXT_MAX)?.replace(/\\s+/g, ' ')\n if (jobTitle) row.profile.jobTitle = jobTitle\n const leadSource = importTextValue(raw.leadSource, CRM_LEAD_TEXT_MAX)?.replace(\n /\\s+/g,\n ' ',\n )\n if (leadSource) row.profile.leadSource = leadSource\n const phoneText = importTextValue(raw.phone, 64)\n if (phoneText) {\n const phone = normalizePhone(phoneText)\n if (phone) row.profile.phone = phone\n else drop('phone', phoneText)\n }\n const websiteText = importTextValue(raw.website, 320)\n if (websiteText) {\n const website = normalizeCompanyWebsite(websiteText)\n if (website) row.profile.website = website\n else drop('website', websiteText)\n }\n const address = normalizeAddress({\n line1: importTextValue(raw.addressLine1, 200),\n line2: importTextValue(raw.addressLine2, 200),\n city: importTextValue(raw.addressCity, 120),\n state: importTextValue(raw.addressState, 120),\n postalCode: importTextValue(raw.addressPostalCode, 32),\n country: importTextValue(raw.addressCountry, 8),\n })\n if (address) row.profile.address = address\n const countryText = importTextValue(raw.addressCountry, 64)\n if (countryText && !address?.country) drop('addressCountry', countryText)\n const tags = normalizeCrmLeadTags(\n String(raw.tags ?? '')\n .split(/[,|]/)\n .map((tag) => tag.trim()),\n )\n if (tags.length) row.profile.tags = tags\n\n // Names, trimmed and deduplicated as typed; the server matches them to\n // the site's campaigns without regard to case.\n const campaigns = [\n ...new Set(\n String(raw.campaigns ?? '')\n .split(/[,|]/)\n .map((name) => name.trim().replace(/\\s+/g, ' '))\n .filter(Boolean),\n ),\n ].slice(0, LEAD_IMPORT_CAMPAIGNS_MAX)\n if (campaigns.length) row.campaigns = campaigns\n\n const statusText = importTextValue(raw.status, 32)\n if (statusText) {\n const status = parseImportLeadStatus(statusText)\n if (status) row.status = status\n else drop('status', statusText)\n }\n\n const ownerText = importTextValue(raw.ownerEmail, 320)\n if (ownerText) {\n const owner = normalizeContactEmail(ownerText)\n if (owner) row.ownerEmail = owner\n else drop('ownerEmail', ownerText)\n }\n\n /*\n * The reason belongs to the status: it is what an unqualified lead was\n * closed FOR, and on a lead that is not unqualified it describes\n * nothing. A row that carries one without the other keeps the status and\n * reports the reason, so a mis-mapped column is visible rather than\n * stored where nothing reads it.\n */\n const reasonText = importTextValue(raw.unqualifiedReason, LEAD_IMPORT_REASON_MAX)\n if (reasonText) {\n if (row.status === 'unqualified') row.unqualifiedReason = reasonText\n else drop('unqualifiedReason', reasonText)\n }\n\n const notes = importTextValue(raw.notes, LEAD_IMPORT_NOTES_MAX)\n if (notes) row.notes = notes\n\n return { ok: true, row }\n}\n\n/** One row the server did not store, by its index in the request, named by the address. */\nexport interface LeadImportSkippedRow {\n index: number\n email: string\n reason: LeadImportSkipReason\n}\n\n/** What one request did. The drawer sums these across a file. */\nexport type LeadImportChunkResult = ImportChunkResult<LeadImportSkippedRow>\n\nexport function emptyLeadImportResult(): LeadImportChunkResult {\n return emptyImportResult<LeadImportSkippedRow>()\n}\n\nexport function mergeLeadImportResults(\n total: LeadImportChunkResult,\n chunk: LeadImportChunkResult,\n offset = 0,\n): LeadImportChunkResult {\n return mergeImportResults(total, chunk, offset)\n}\n\n/** The skipped rows as a file the operator can fix and re-import. */\nexport function leadImportSkippedCsv(\n columns: readonly string[],\n entries: readonly { cells: readonly string[]; reason: LeadImportSkipReason }[],\n): string {\n return importSkippedCsv(columns, entries, LEAD_IMPORT_SKIP_LABELS)\n}\n"],"names":["CAMPAIGN_MEMBERSHIP_CAP","normalizeContactEmail","CRM_LEAD_STATUS_LABELS","CRM_LEAD_STATUSES","CRM_LEAD_TEXT_MAX","isCrmLeadStatus","normalizeCrmLeadTags","normalizeCompanyWebsite","normalizeAddress","normalizePhone","CSV_IMPORT_CHUNK_SIZE","CSV_IMPORT_MAX_BODY_BYTES","CSV_IMPORT_MAX_ROWS","CSV_IMPORT_PREVIEW_ROWS","emptyImportResult","guessImportMapping","importAliasKeys","importSkippedCsv","importTextValue","mapImportRow","mergeImportResults","LEAD_IMPORT_MAX_ROWS","LEAD_IMPORT_CHUNK_SIZE","LEAD_IMPORT_MAX_BODY_BYTES","LEAD_IMPORT_PREVIEW_ROWS","LEAD_IMPORT_NOTES_MAX","LEAD_IMPORT_REASON_MAX","NAME_MAX","LEAD_IMPORT_STATUSES","filter","status","LEAD_IMPORT_FIELDS","LEAD_IMPORT_FIELD_LABELS","email","name","company","jobTitle","phone","website","leadSource","ownerEmail","addressLine1","addressLine2","addressCity","addressState","addressPostalCode","addressCountry","tags","campaigns","unqualifiedReason","notes","FIELD_ALIASES","FIELD_ALIAS_KEYS","guessLeadImportMapping","columns","mapLeadImportRow","cells","mapping","custom","_custom","row","LEAD_IMPORT_SKIP_LABELS","duplicate","LEAD_IMPORT_CAMPAIGNS_MAX","parseImportLeadStatus","value","text","String","trim","toLowerCase","byId","byLabel","find","includes","normalizeLeadImportRow","raw","emailText","ok","reason","input","dropped","drop","field","push","profile","replace","phoneText","websiteText","address","line1","line2","city","state","postalCode","country","countryText","split","map","tag","length","Set","Boolean","slice","statusText","ownerText","owner","reasonText","emptyLeadImportResult","mergeLeadImportResults","total","chunk","offset","leadImportSkippedCsv","entries"],"mappings":"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"}
|
|
@@ -39,6 +39,14 @@ export declare const LEAD_EMAIL_FILTERS: readonly LeadEmailFilter[];
|
|
|
39
39
|
export declare const LEAD_EMAIL_FILTER_LABELS: Record<LeadEmailFilter, string>;
|
|
40
40
|
/** Whether a lead belongs in an email filter's view. */
|
|
41
41
|
export declare function leadMatchesEmailFilter(lead: Readonly<Record<string, unknown>>, filter: LeadEmailFilter): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* The Leads section's `Campaign` control (AGL-3254): every lead, or the
|
|
44
|
+
* ones filed under one campaign — read off the lead's own `campaignIds`,
|
|
45
|
+
* the field every campaign member carries. `''` is every lead, which is
|
|
46
|
+
* what the control opens on; the ids are the site's containers', and the
|
|
47
|
+
* section resolves their names for the menu.
|
|
48
|
+
*/
|
|
49
|
+
export declare function leadMatchesCampaignFilter(lead: Readonly<Record<string, unknown>>, campaignId: string): boolean;
|
|
42
50
|
/**
|
|
43
51
|
* Whether a lead answers a search term.
|
|
44
52
|
*
|
|
@@ -14,7 +14,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
14
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
|
-
*/ import { EMAIL_STATE_LABELS, EMAIL_STATE_STATUSES, CRM_LEAD_STATUS_LABELS, emailStateForbidsEmail, crmLeadStatus, isCrmLeadOpen, readEmailState } from "@aglyn/aglyn";
|
|
17
|
+
*/ import { EMAIL_STATE_LABELS, EMAIL_STATE_STATUSES, CRM_LEAD_STATUS_LABELS, emailStateForbidsEmail, crmLeadStatus, isCrmLeadOpen, readCampaignIds, readEmailState } from "@aglyn/aglyn";
|
|
18
18
|
export const LEAD_FILTERS = [
|
|
19
19
|
'open',
|
|
20
20
|
'new',
|
|
@@ -54,6 +54,16 @@ export const LEAD_EMAIL_FILTER_LABELS = _extends({
|
|
|
54
54
|
if (filter === 'problem') return emailStateForbidsEmail(state);
|
|
55
55
|
return (state == null ? void 0 : state.status) === filter;
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* The Leads section's `Campaign` control (AGL-3254): every lead, or the
|
|
59
|
+
* ones filed under one campaign — read off the lead's own `campaignIds`,
|
|
60
|
+
* the field every campaign member carries. `''` is every lead, which is
|
|
61
|
+
* what the control opens on; the ids are the site's containers', and the
|
|
62
|
+
* section resolves their names for the menu.
|
|
63
|
+
*/ export function leadMatchesCampaignFilter(lead, campaignId) {
|
|
64
|
+
if (!campaignId) return true;
|
|
65
|
+
return readCampaignIds(lead).includes(campaignId);
|
|
66
|
+
}
|
|
57
67
|
/**
|
|
58
68
|
* The fields the Leads section's search box reads (AGL-3246): who the lead
|
|
59
69
|
* is and where they work — the words a person types to find one — plus the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/lead-filters.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n EMAIL_STATE_LABELS,\n EMAIL_STATE_STATUSES,\n CRM_LEAD_STATUS_LABELS,\n type EmailStateStatus,\n type CrmLeadFields,\n type CrmLeadStatus,\n emailStateForbidsEmail,\n crmLeadStatus,\n isCrmLeadOpen,\n readEmailState,\n} from '@aglyn/aglyn'\n\n/**\n * What the Leads section's `Show` control offers (AGL-2608): the two\n * aggregate views and each status on its own. `open` is the default — the\n * list opens on the work — and it is the one view a Firestore query cannot\n * express, because a lead nobody has touched carries no status field at all;\n * see the section for why the filter runs over a loaded window.\n */\nexport type LeadFilter = 'open' | 'all' | CrmLeadStatus\n\nexport const LEAD_FILTERS: readonly LeadFilter[] = [\n 'open',\n 'new',\n 'working',\n 'qualified',\n 'unqualified',\n 'all',\n]\n\nexport const LEAD_FILTER_LABELS: Record<LeadFilter, string> = {\n open: 'Open',\n all: 'All',\n new: CRM_LEAD_STATUS_LABELS.new,\n working: CRM_LEAD_STATUS_LABELS.working,\n qualified: CRM_LEAD_STATUS_LABELS.qualified,\n unqualified: CRM_LEAD_STATUS_LABELS.unqualified,\n}\n\n/** Whether a lead belongs in a filter's view. An absent status is `new`. */\nexport function leadMatchesFilter(\n lead: Pick<CrmLeadFields, 'status'>,\n filter: LeadFilter,\n): boolean {\n if (filter === 'all') return true\n if (filter === 'open') return isCrmLeadOpen(lead)\n return crmLeadStatus(lead) === filter\n}\n\n/**\n * The Leads section's `Email` control (AGL-3245): every lead, the ones a\n * member must not email — any verdict but `ok` — the ones nothing has been\n * said about, or one verdict on its own. Beside `Show`, not inside it: a\n * bounce does not move a lead out of Open, and a queue of bounced leads is\n * a queue of people to reach some other way.\n */\nexport type LeadEmailFilter = 'any' | 'problem' | 'none' | EmailStateStatus\n\nexport const LEAD_EMAIL_FILTERS: readonly LeadEmailFilter[] = [\n 'any',\n 'problem',\n ...EMAIL_STATE_STATUSES,\n 'none',\n]\n\nexport const LEAD_EMAIL_FILTER_LABELS: Record<LeadEmailFilter, string> = {\n any: 'Any',\n problem: 'Cannot be emailed',\n none: 'Nothing known',\n ...EMAIL_STATE_LABELS,\n}\n\n/** Whether a lead belongs in an email filter's view. */\nexport function leadMatchesEmailFilter(\n lead: Readonly<Record<string, unknown>>,\n filter: LeadEmailFilter,\n): boolean {\n if (filter === 'any') return true\n const state = readEmailState(lead)\n if (filter === 'none') return state === null\n if (filter === 'problem') return emailStateForbidsEmail(state)\n return state?.status === filter\n}\n\n/**\n * The fields the Leads section's search box reads (AGL-3246): who the lead\n * is and where they work — the words a person types to find one — plus the\n * tags, which are how an import names its batch (`sal-15`). `name` and\n * `email` are the capture door's fields rather than the CRM's, so the row is\n * read as a record rather than through `CrmLeadFields`.\n */\nconst LEAD_SEARCH_FIELDS = ['name', 'email', 'company', 'jobTitle'] as const\n\n/**\n * Whether a lead answers a search term.\n *\n * Case-insensitive, and every whitespace-separated word of the term must\n * appear in SOME searched field: `morgan lamphere` and `lamphere sal-15`\n * both find Morgan Lamphere, `morgan smith` does not. A blank term matches\n * every lead, so the box emptied is the list unfiltered.\n */\nexport function leadMatchesSearch(\n lead: Readonly<Record<string, unknown>>,\n term: string,\n): boolean {\n const words = term.trim().toLowerCase().split(/\\s+/).filter(Boolean)\n if (!words.length) return true\n const values = [\n ...LEAD_SEARCH_FIELDS.map((field) => lead[field]),\n ...(Array.isArray(lead['tags']) ? lead['tags'] : []),\n ]\n .filter((value): value is string => typeof value === 'string' && value !== '')\n .map((value) => value.toLowerCase())\n return words.every((word) => values.some((value) => value.includes(word)))\n}\n"],"names":["EMAIL_STATE_LABELS","EMAIL_STATE_STATUSES","CRM_LEAD_STATUS_LABELS","emailStateForbidsEmail","crmLeadStatus","isCrmLeadOpen","readEmailState","LEAD_FILTERS","LEAD_FILTER_LABELS","open","all","new","working","qualified","unqualified","leadMatchesFilter","lead","filter","LEAD_EMAIL_FILTERS","LEAD_EMAIL_FILTER_LABELS","any","problem","none","leadMatchesEmailFilter","state","status","LEAD_SEARCH_FIELDS","leadMatchesSearch","term","words","trim","toLowerCase","split","Boolean","length","values","map","field","Array","isArray","value","every","word","some"
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/lead-filters.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n EMAIL_STATE_LABELS,\n EMAIL_STATE_STATUSES,\n CRM_LEAD_STATUS_LABELS,\n type EmailStateStatus,\n type CrmLeadFields,\n type CrmLeadStatus,\n emailStateForbidsEmail,\n crmLeadStatus,\n isCrmLeadOpen,\n readCampaignIds,\n readEmailState,\n} from '@aglyn/aglyn'\n\n/**\n * What the Leads section's `Show` control offers (AGL-2608): the two\n * aggregate views and each status on its own. `open` is the default — the\n * list opens on the work — and it is the one view a Firestore query cannot\n * express, because a lead nobody has touched carries no status field at all;\n * see the section for why the filter runs over a loaded window.\n */\nexport type LeadFilter = 'open' | 'all' | CrmLeadStatus\n\nexport const LEAD_FILTERS: readonly LeadFilter[] = [\n 'open',\n 'new',\n 'working',\n 'qualified',\n 'unqualified',\n 'all',\n]\n\nexport const LEAD_FILTER_LABELS: Record<LeadFilter, string> = {\n open: 'Open',\n all: 'All',\n new: CRM_LEAD_STATUS_LABELS.new,\n working: CRM_LEAD_STATUS_LABELS.working,\n qualified: CRM_LEAD_STATUS_LABELS.qualified,\n unqualified: CRM_LEAD_STATUS_LABELS.unqualified,\n}\n\n/** Whether a lead belongs in a filter's view. An absent status is `new`. */\nexport function leadMatchesFilter(\n lead: Pick<CrmLeadFields, 'status'>,\n filter: LeadFilter,\n): boolean {\n if (filter === 'all') return true\n if (filter === 'open') return isCrmLeadOpen(lead)\n return crmLeadStatus(lead) === filter\n}\n\n/**\n * The Leads section's `Email` control (AGL-3245): every lead, the ones a\n * member must not email — any verdict but `ok` — the ones nothing has been\n * said about, or one verdict on its own. Beside `Show`, not inside it: a\n * bounce does not move a lead out of Open, and a queue of bounced leads is\n * a queue of people to reach some other way.\n */\nexport type LeadEmailFilter = 'any' | 'problem' | 'none' | EmailStateStatus\n\nexport const LEAD_EMAIL_FILTERS: readonly LeadEmailFilter[] = [\n 'any',\n 'problem',\n ...EMAIL_STATE_STATUSES,\n 'none',\n]\n\nexport const LEAD_EMAIL_FILTER_LABELS: Record<LeadEmailFilter, string> = {\n any: 'Any',\n problem: 'Cannot be emailed',\n none: 'Nothing known',\n ...EMAIL_STATE_LABELS,\n}\n\n/** Whether a lead belongs in an email filter's view. */\nexport function leadMatchesEmailFilter(\n lead: Readonly<Record<string, unknown>>,\n filter: LeadEmailFilter,\n): boolean {\n if (filter === 'any') return true\n const state = readEmailState(lead)\n if (filter === 'none') return state === null\n if (filter === 'problem') return emailStateForbidsEmail(state)\n return state?.status === filter\n}\n\n/**\n * The Leads section's `Campaign` control (AGL-3254): every lead, or the\n * ones filed under one campaign — read off the lead's own `campaignIds`,\n * the field every campaign member carries. `''` is every lead, which is\n * what the control opens on; the ids are the site's containers', and the\n * section resolves their names for the menu.\n */\nexport function leadMatchesCampaignFilter(\n lead: Readonly<Record<string, unknown>>,\n campaignId: string,\n): boolean {\n if (!campaignId) return true\n return readCampaignIds(lead).includes(campaignId)\n}\n\n/**\n * The fields the Leads section's search box reads (AGL-3246): who the lead\n * is and where they work — the words a person types to find one — plus the\n * tags, which are how an import names its batch (`sal-15`). `name` and\n * `email` are the capture door's fields rather than the CRM's, so the row is\n * read as a record rather than through `CrmLeadFields`.\n */\nconst LEAD_SEARCH_FIELDS = ['name', 'email', 'company', 'jobTitle'] as const\n\n/**\n * Whether a lead answers a search term.\n *\n * Case-insensitive, and every whitespace-separated word of the term must\n * appear in SOME searched field: `morgan lamphere` and `lamphere sal-15`\n * both find Morgan Lamphere, `morgan smith` does not. A blank term matches\n * every lead, so the box emptied is the list unfiltered.\n */\nexport function leadMatchesSearch(\n lead: Readonly<Record<string, unknown>>,\n term: string,\n): boolean {\n const words = term.trim().toLowerCase().split(/\\s+/).filter(Boolean)\n if (!words.length) return true\n const values = [\n ...LEAD_SEARCH_FIELDS.map((field) => lead[field]),\n ...(Array.isArray(lead['tags']) ? lead['tags'] : []),\n ]\n .filter((value): value is string => typeof value === 'string' && value !== '')\n .map((value) => value.toLowerCase())\n return words.every((word) => values.some((value) => value.includes(word)))\n}\n"],"names":["EMAIL_STATE_LABELS","EMAIL_STATE_STATUSES","CRM_LEAD_STATUS_LABELS","emailStateForbidsEmail","crmLeadStatus","isCrmLeadOpen","readCampaignIds","readEmailState","LEAD_FILTERS","LEAD_FILTER_LABELS","open","all","new","working","qualified","unqualified","leadMatchesFilter","lead","filter","LEAD_EMAIL_FILTERS","LEAD_EMAIL_FILTER_LABELS","any","problem","none","leadMatchesEmailFilter","state","status","leadMatchesCampaignFilter","campaignId","includes","LEAD_SEARCH_FIELDS","leadMatchesSearch","term","words","trim","toLowerCase","split","Boolean","length","values","map","field","Array","isArray","value","every","word","some"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,kBAAkB,EAClBC,oBAAoB,EACpBC,sBAAsB,EAItBC,sBAAsB,EACtBC,aAAa,EACbC,aAAa,EACbC,eAAe,EACfC,cAAc,QACT,eAAc;AAWrB,OAAO,MAAMC,eAAsC;IACjD;IACA;IACA;IACA;IACA;IACA;CACD,CAAA;AAED,OAAO,MAAMC,qBAAiD;IAC5DC,MAAM;IACNC,KAAK;IACLC,KAAKV,uBAAuBU,GAAG;IAC/BC,SAASX,uBAAuBW,OAAO;IACvCC,WAAWZ,uBAAuBY,SAAS;IAC3CC,aAAab,uBAAuBa,WAAW;AACjD,EAAC;AAED,0EAA0E,GAC1E,OAAO,SAASC,kBACdC,IAAmC,EACnCC,MAAkB;IAElB,IAAIA,WAAW,OAAO,OAAO;IAC7B,IAAIA,WAAW,QAAQ,OAAOb,cAAcY;IAC5C,OAAOb,cAAca,UAAUC;AACjC;AAWA,OAAO,MAAMC,qBAAiD;IAC5D;IACA;OACGlB;IACH;CACD,CAAA;AAED,OAAO,MAAMmB,2BAA4D;IACvEC,KAAK;IACLC,SAAS;IACTC,MAAM;GACHvB,oBACJ;AAED,sDAAsD,GACtD,OAAO,SAASwB,uBACdP,IAAuC,EACvCC,MAAuB;IAEvB,IAAIA,WAAW,OAAO,OAAO;IAC7B,MAAMO,QAAQlB,eAAeU;IAC7B,IAAIC,WAAW,QAAQ,OAAOO,UAAU;IACxC,IAAIP,WAAW,WAAW,OAAOf,uBAAuBsB;IACxD,OAAOA,CAAAA,yBAAAA,MAAOC,MAAM,MAAKR;AAC3B;AAEA;;;;;;CAMC,GACD,OAAO,SAASS,0BACdV,IAAuC,EACvCW,UAAkB;IAElB,IAAI,CAACA,YAAY,OAAO;IACxB,OAAOtB,gBAAgBW,MAAMY,QAAQ,CAACD;AACxC;AAEA;;;;;;CAMC,GACD,MAAME,qBAAqB;IAAC;IAAQ;IAAS;IAAW;CAAW;AAEnE;;;;;;;CAOC,GACD,OAAO,SAASC,kBACdd,IAAuC,EACvCe,IAAY;IAEZ,MAAMC,QAAQD,KAAKE,IAAI,GAAGC,WAAW,GAAGC,KAAK,CAAC,OAAOlB,MAAM,CAACmB;IAC5D,IAAI,CAACJ,MAAMK,MAAM,EAAE,OAAO;IAC1B,MAAMC,SAAS;WACVT,mBAAmBU,GAAG,CAAC,CAACC,QAAUxB,IAAI,CAACwB,MAAM;WAC5CC,MAAMC,OAAO,CAAC1B,IAAI,CAAC,OAAO,IAAIA,IAAI,CAAC,OAAO,GAAG,EAAE;KACpD,CACEC,MAAM,CAAC,CAAC0B,QAA2B,OAAOA,UAAU,YAAYA,UAAU,IAC1EJ,GAAG,CAAC,CAACI,QAAUA,MAAMT,WAAW;IACnC,OAAOF,MAAMY,KAAK,CAAC,CAACC,OAASP,OAAOQ,IAAI,CAAC,CAACH,QAAUA,MAAMf,QAAQ,CAACiB;AACrE"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { PluginLeadConversionReport, PluginLeadConversionRequest } from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion';
|
|
18
|
+
/**
|
|
19
|
+
* WHAT A LEAD'S CAMPAIGNS BECOME WHEN IT CONVERTS (AGL-3254).
|
|
20
|
+
*
|
|
21
|
+
* A lead is filed under a site's campaigns at the top of its own document;
|
|
22
|
+
* a contact holds the same membership inside the site's consent-group
|
|
23
|
+
* facet, because a contact is shared across the org and a campaign is one
|
|
24
|
+
* site's. When the lead becomes a contact, the campaigns go with it — added
|
|
25
|
+
* to what the contact already carries there with `arrayUnion`, never in
|
|
26
|
+
* place of it — so the campaign's page goes on listing the person after
|
|
27
|
+
* they became a contact, and a lead a sequence enrolled under a campaign
|
|
28
|
+
* does not leave the campaign by converting.
|
|
29
|
+
*
|
|
30
|
+
* The CRM's own module, because a lead and a contact are the CRM's records
|
|
31
|
+
* (docs/PACKAGES.md rule 3): it runs as the CRM's share of the platform's
|
|
32
|
+
* lead-conversion seam, which every door that converts a lead — the
|
|
33
|
+
* dialog, the REST API, a sign-up, a purchase — reaches through
|
|
34
|
+
* `handOffLeadRecords`. Never throws: the conversion has happened, and a
|
|
35
|
+
* membership that could not be carried is one the contact's page can pick
|
|
36
|
+
* by hand.
|
|
37
|
+
*/
|
|
38
|
+
export declare function carryLeadCampaignsToContact(firestore: FirebaseFirestore.Firestore, request: Pick<PluginLeadConversionRequest, 'orgId' | 'hostId' | 'leadId' | 'contactId'>): Promise<PluginLeadConversionReport>;
|
|
39
|
+
/**
|
|
40
|
+
* The carry as the lead-conversion seam calls it: on the Admin SDK's own
|
|
41
|
+
* Firestore. The SDK is resolved HERE, not in `declarations.server.ts`, for
|
|
42
|
+
* two reasons that point the same way. That file must stay light — it runs
|
|
43
|
+
* at the boot of every server process — so it defers this module and must
|
|
44
|
+
* not import the data layer itself. And a library the plugin imports
|
|
45
|
+
* statically in thirty files cannot also be lazy-loaded in one:
|
|
46
|
+
* `enforce-module-boundaries` refuses every static import of a library the
|
|
47
|
+
* project lazy-loads anywhere. This module is the deferred one; the SDK
|
|
48
|
+
* comes with it, the way `capture-contact` brings its own.
|
|
49
|
+
*/
|
|
50
|
+
export declare function carryLeadCampaignsOnConversion(request: Pick<PluginLeadConversionRequest, 'orgId' | 'hostId' | 'leadId' | 'contactId'>): Promise<PluginLeadConversionReport>;
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
*/ import { contactCampaignFieldPath, readCampaignIds } from "@aglyn/aglyn/app-utils/campaign-membership";
|
|
17
|
+
import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
|
|
18
|
+
import { consentGroupForSite } from "@aglyn/tenant-data-admin/server/organizations";
|
|
19
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
20
|
+
/**
|
|
21
|
+
* WHAT A LEAD'S CAMPAIGNS BECOME WHEN IT CONVERTS (AGL-3254).
|
|
22
|
+
*
|
|
23
|
+
* A lead is filed under a site's campaigns at the top of its own document;
|
|
24
|
+
* a contact holds the same membership inside the site's consent-group
|
|
25
|
+
* facet, because a contact is shared across the org and a campaign is one
|
|
26
|
+
* site's. When the lead becomes a contact, the campaigns go with it — added
|
|
27
|
+
* to what the contact already carries there with `arrayUnion`, never in
|
|
28
|
+
* place of it — so the campaign's page goes on listing the person after
|
|
29
|
+
* they became a contact, and a lead a sequence enrolled under a campaign
|
|
30
|
+
* does not leave the campaign by converting.
|
|
31
|
+
*
|
|
32
|
+
* The CRM's own module, because a lead and a contact are the CRM's records
|
|
33
|
+
* (docs/PACKAGES.md rule 3): it runs as the CRM's share of the platform's
|
|
34
|
+
* lead-conversion seam, which every door that converts a lead — the
|
|
35
|
+
* dialog, the REST API, a sign-up, a purchase — reaches through
|
|
36
|
+
* `handOffLeadRecords`. Never throws: the conversion has happened, and a
|
|
37
|
+
* membership that could not be carried is one the contact's page can pick
|
|
38
|
+
* by hand.
|
|
39
|
+
*/ export async function carryLeadCampaignsToContact(firestore, request) {
|
|
40
|
+
try {
|
|
41
|
+
const lead = await firestore.collection('hosts').doc(request.hostId).collection('leads').doc(request.leadId).get();
|
|
42
|
+
const campaignIds = readCampaignIds(lead.exists ? lead.data() : null);
|
|
43
|
+
if (!campaignIds.length) return {
|
|
44
|
+
campaigns: 0
|
|
45
|
+
};
|
|
46
|
+
const group = await consentGroupForSite(request.hostId);
|
|
47
|
+
await firestore.collection('orgs').doc(request.orgId).collection('contacts').doc(request.contactId).update({
|
|
48
|
+
[contactCampaignFieldPath(group.groupId)]: FieldValue.arrayUnion(...campaignIds),
|
|
49
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
campaigns: campaignIds.length
|
|
53
|
+
};
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error('[crm] the lead’s campaigns could not be carried to the contact', request.hostId, request.leadId, error);
|
|
56
|
+
return {
|
|
57
|
+
campaigns: null
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The carry as the lead-conversion seam calls it: on the Admin SDK's own
|
|
63
|
+
* Firestore. The SDK is resolved HERE, not in `declarations.server.ts`, for
|
|
64
|
+
* two reasons that point the same way. That file must stay light — it runs
|
|
65
|
+
* at the boot of every server process — so it defers this module and must
|
|
66
|
+
* not import the data layer itself. And a library the plugin imports
|
|
67
|
+
* statically in thirty files cannot also be lazy-loaded in one:
|
|
68
|
+
* `enforce-module-boundaries` refuses every static import of a library the
|
|
69
|
+
* project lazy-loads anywhere. This module is the deferred one; the SDK
|
|
70
|
+
* comes with it, the way `capture-contact` brings its own.
|
|
71
|
+
*/ export function carryLeadCampaignsOnConversion(request) {
|
|
72
|
+
return carryLeadCampaignsToContact(firebaseAdmin.app().firestore(), request);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
//# sourceMappingURL=lead-campaign-carry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-campaign-carry.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n contactCampaignFieldPath,\n readCampaignIds,\n} from '@aglyn/aglyn/app-utils/campaign-membership'\nimport type {\n PluginLeadConversionReport,\n PluginLeadConversionRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { consentGroupForSite } from '@aglyn/tenant-data-admin/server/organizations'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * WHAT A LEAD'S CAMPAIGNS BECOME WHEN IT CONVERTS (AGL-3254).\n *\n * A lead is filed under a site's campaigns at the top of its own document;\n * a contact holds the same membership inside the site's consent-group\n * facet, because a contact is shared across the org and a campaign is one\n * site's. When the lead becomes a contact, the campaigns go with it — added\n * to what the contact already carries there with `arrayUnion`, never in\n * place of it — so the campaign's page goes on listing the person after\n * they became a contact, and a lead a sequence enrolled under a campaign\n * does not leave the campaign by converting.\n *\n * The CRM's own module, because a lead and a contact are the CRM's records\n * (docs/PACKAGES.md rule 3): it runs as the CRM's share of the platform's\n * lead-conversion seam, which every door that converts a lead — the\n * dialog, the REST API, a sign-up, a purchase — reaches through\n * `handOffLeadRecords`. Never throws: the conversion has happened, and a\n * membership that could not be carried is one the contact's page can pick\n * by hand.\n */\nexport async function carryLeadCampaignsToContact(\n firestore: FirebaseFirestore.Firestore,\n request: Pick<\n PluginLeadConversionRequest,\n 'orgId' | 'hostId' | 'leadId' | 'contactId'\n >,\n): Promise<PluginLeadConversionReport> {\n try {\n const lead = await firestore\n .collection('hosts')\n .doc(request.hostId)\n .collection('leads')\n .doc(request.leadId)\n .get()\n const campaignIds = readCampaignIds(\n lead.exists ? (lead.data() as Record<string, unknown>) : null,\n )\n if (!campaignIds.length) return { campaigns: 0 }\n const group = await consentGroupForSite(request.hostId)\n await firestore\n .collection('orgs')\n .doc(request.orgId)\n .collection('contacts')\n .doc(request.contactId)\n .update({\n [contactCampaignFieldPath(group.groupId)]: FieldValue.arrayUnion(\n ...campaignIds,\n ),\n updatedAt: FieldValue.serverTimestamp(),\n })\n return { campaigns: campaignIds.length }\n } catch (error) {\n console.error(\n '[crm] the lead’s campaigns could not be carried to the contact',\n request.hostId,\n request.leadId,\n error,\n )\n return { campaigns: null }\n }\n}\n\n/**\n * The carry as the lead-conversion seam calls it: on the Admin SDK's own\n * Firestore. The SDK is resolved HERE, not in `declarations.server.ts`, for\n * two reasons that point the same way. That file must stay light — it runs\n * at the boot of every server process — so it defers this module and must\n * not import the data layer itself. And a library the plugin imports\n * statically in thirty files cannot also be lazy-loaded in one:\n * `enforce-module-boundaries` refuses every static import of a library the\n * project lazy-loads anywhere. This module is the deferred one; the SDK\n * comes with it, the way `capture-contact` brings its own.\n */\nexport function carryLeadCampaignsOnConversion(\n request: Pick<\n PluginLeadConversionRequest,\n 'orgId' | 'hostId' | 'leadId' | 'contactId'\n >,\n): Promise<PluginLeadConversionReport> {\n return carryLeadCampaignsToContact(firebaseAdmin.app().firestore(), request)\n}\n"],"names":["contactCampaignFieldPath","readCampaignIds","firebaseAdmin","consentGroupForSite","FieldValue","carryLeadCampaignsToContact","firestore","request","lead","collection","doc","hostId","leadId","get","campaignIds","exists","data","length","campaigns","group","orgId","contactId","update","groupId","arrayUnion","updatedAt","serverTimestamp","error","console","carryLeadCampaignsOnConversion","app"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,wBAAwB,EACxBC,eAAe,QACV,6CAA4C;AAKnD,SAASC,aAAa,QAAQ,iDAAgD;AAC9E,SAASC,mBAAmB,QAAQ,gDAA+C;AACnF,SAASC,UAAU,QAAQ,2BAA0B;AAErD;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,eAAeC,4BACpBC,SAAsC,EACtCC,OAGC;IAED,IAAI;QACF,MAAMC,OAAO,MAAMF,UAChBG,UAAU,CAAC,SACXC,GAAG,CAACH,QAAQI,MAAM,EAClBF,UAAU,CAAC,SACXC,GAAG,CAACH,QAAQK,MAAM,EAClBC,GAAG;QACN,MAAMC,cAAcb,gBAClBO,KAAKO,MAAM,GAAIP,KAAKQ,IAAI,KAAiC;QAE3D,IAAI,CAACF,YAAYG,MAAM,EAAE,OAAO;YAAEC,WAAW;QAAE;QAC/C,MAAMC,QAAQ,MAAMhB,oBAAoBI,QAAQI,MAAM;QACtD,MAAML,UACHG,UAAU,CAAC,QACXC,GAAG,CAACH,QAAQa,KAAK,EACjBX,UAAU,CAAC,YACXC,GAAG,CAACH,QAAQc,SAAS,EACrBC,MAAM,CAAC;YACN,CAACtB,yBAAyBmB,MAAMI,OAAO,EAAE,EAAEnB,WAAWoB,UAAU,IAC3DV;YAELW,WAAWrB,WAAWsB,eAAe;QACvC;QACF,OAAO;YAAER,WAAWJ,YAAYG,MAAM;QAAC;IACzC,EAAE,OAAOU,OAAO;QACdC,QAAQD,KAAK,CACX,kEACApB,QAAQI,MAAM,EACdJ,QAAQK,MAAM,EACde;QAEF,OAAO;YAAET,WAAW;QAAK;IAC3B;AACF;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAASW,+BACdtB,OAGC;IAED,OAAOF,4BAA4BH,cAAc4B,GAAG,GAAGxB,SAAS,IAAIC;AACtE"}
|
|
@@ -63,7 +63,20 @@ export interface LeadCreateRequest {
|
|
|
63
63
|
status?: CrmLeadStatus;
|
|
64
64
|
ownerUid?: string;
|
|
65
65
|
notes?: string;
|
|
66
|
+
/** The site's campaigns to file the lead under (AGL-3254), by container id. */
|
|
67
|
+
campaignIds?: string[];
|
|
66
68
|
}
|
|
69
|
+
/** The sentence a campaign that is not the site's is refused with, under the field. */
|
|
70
|
+
export declare const LEAD_CAMPAIGN_REFUSAL = "One of the campaigns picked isn't a campaign of this site any more. Pick it again.";
|
|
71
|
+
/**
|
|
72
|
+
* The campaign ids a request may file a lead under: the site's own live
|
|
73
|
+
* containers, and nothing else. A request can claim any id, and a lead
|
|
74
|
+
* filed under another site's campaign — or one the console soft-deleted —
|
|
75
|
+
* would sit on a page nobody at this site can open.
|
|
76
|
+
*
|
|
77
|
+
* @returns the clean ids, or `null` when one of them is not the site's.
|
|
78
|
+
*/
|
|
79
|
+
export declare function siteCampaignIds(hostRef: FirebaseFirestore.DocumentReference, raw: unknown): Promise<string[] | null>;
|
|
67
80
|
/** What the route answers on success. */
|
|
68
81
|
export interface LeadCreateResponse {
|
|
69
82
|
leadId: string;
|
|
@@ -47,6 +47,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
47
47
|
*/ import { checkVisitorRecordCeiling, LEADS_MAX_PER_HOST, normalizeContactEmail, normalizeCrmLeadProfile, personKey } from "@aglyn/aglyn/server";
|
|
48
48
|
import { addHostLead, firebaseAdmin, getOrgForHost, logHostActivity } from "@aglyn/tenant-data-admin";
|
|
49
49
|
import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
|
|
50
|
+
import { normalizeCampaignIds } from "@aglyn/aglyn/app-utils/campaign-membership";
|
|
50
51
|
import { FieldValue } from "firebase-admin/firestore";
|
|
51
52
|
import { holdsDataManage } from "./org-caller.js";
|
|
52
53
|
import { crmSuiteRefusal } from "./suite-gate.js";
|
|
@@ -57,6 +58,23 @@ import { crmSuiteRefusal } from "./suite-gate.js";
|
|
|
57
58
|
const NAME_MAX = 120;
|
|
58
59
|
const NOTES_MAX = 4000;
|
|
59
60
|
/** The surface a hand-entered lead names, beside `signup`, `booking`, `form:{id}` and `import`. */ export const LEAD_MANUAL_SOURCE = 'manual';
|
|
61
|
+
/** The sentence a campaign that is not the site's is refused with, under the field. */ export const LEAD_CAMPAIGN_REFUSAL = "One of the campaigns picked isn't a campaign of this site any more. Pick it again.";
|
|
62
|
+
/**
|
|
63
|
+
* The campaign ids a request may file a lead under: the site's own live
|
|
64
|
+
* containers, and nothing else. A request can claim any id, and a lead
|
|
65
|
+
* filed under another site's campaign — or one the console soft-deleted —
|
|
66
|
+
* would sit on a page nobody at this site can open.
|
|
67
|
+
*
|
|
68
|
+
* @returns the clean ids, or `null` when one of them is not the site's.
|
|
69
|
+
*/ export async function siteCampaignIds(hostRef, raw) {
|
|
70
|
+
const ids = normalizeCampaignIds(raw);
|
|
71
|
+
if (!ids.length) return [];
|
|
72
|
+
if (ids.some((id)=>id.includes('/'))) return null;
|
|
73
|
+
const containers = hostRef.collection('emailCampaigns');
|
|
74
|
+
const found = await hostRef.firestore.getAll(...ids.map((id)=>containers.doc(id)));
|
|
75
|
+
const live = found.every((snapshot)=>snapshot.exists && !snapshot.get('deletedAt'));
|
|
76
|
+
return live ? ids : null;
|
|
77
|
+
}
|
|
60
78
|
/** The lead's own fields as one write, `null` in the patch meaning a clear. */ export function leadProfileWrite(patch) {
|
|
61
79
|
const write = {};
|
|
62
80
|
for (const [key, value] of Object.entries(patch)){
|
|
@@ -148,6 +166,16 @@ export const leadCreateHandler = async (req, res)=>{
|
|
|
148
166
|
const firestore = firebaseAdmin.app().firestore();
|
|
149
167
|
const hostRef = firestore.collection('hosts').doc(hostId);
|
|
150
168
|
const leadsRef = hostRef.collection('leads');
|
|
169
|
+
// The campaigns, judged before any write, so a refused pick leaves
|
|
170
|
+
// nothing to retry against (AGL-3254).
|
|
171
|
+
const campaignIds = await siteCampaignIds(hostRef, body.campaignIds);
|
|
172
|
+
if (campaignIds === null) {
|
|
173
|
+
res.status(400).json({
|
|
174
|
+
error: LEAD_CAMPAIGN_REFUSAL,
|
|
175
|
+
field: 'campaignIds'
|
|
176
|
+
});
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
151
179
|
// Non-null by construction: the normalizer refused every address this
|
|
152
180
|
// derivation could not key.
|
|
153
181
|
const leadId = personKey(email);
|
|
@@ -190,6 +218,8 @@ export const leadCreateHandler = async (req, res)=>{
|
|
|
190
218
|
ownerUid
|
|
191
219
|
} : {}, notes ? {
|
|
192
220
|
notes
|
|
221
|
+
} : {}, campaignIds.length ? {
|
|
222
|
+
campaignIds: FieldValue.arrayUnion(...campaignIds)
|
|
193
223
|
} : {});
|
|
194
224
|
if (Object.keys(working).length) {
|
|
195
225
|
await leadsRef.doc(leadId).set(_extends({}, working, {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-create.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/leads-create` — a lead typed in by hand (AGL-3231).\n *\n * Salesforce's New Lead: a person the team has heard of and has not yet\n * qualified, entered with what is known about them — the address, the\n * name, the company as text, a title, a phone, a website, an address, tags,\n * where they came from — and nothing else brought into being. No contact,\n * no company record: those are the conversion's to make, once the lead is\n * real. Until then the lead is the whole record, which is what lets the\n * Leads section be worked without a second row for every person in it.\n *\n * ## Through the one door\n *\n * `addHostLead` is the single writer of `hosts/{hostId}/leads` — the\n * capture doors, the import and the REST create all reach it — and it is\n * the writer here. That is what keys the lead on the person, so a second\n * \"new lead\" for one address updates the first rather than filing twice;\n * counts it against the platform ceiling inside the transaction that\n * writes; and records NO marketing basis, because a person typed in by the\n * team is not a person who ticked a box. The profile and the working state\n * are the CRM's own annotations and land in one merge write after the\n * door's, exactly as the import writes them.\n *\n * ## Who may call it\n *\n * The same two gates the convert route asks: `data.manage` in the org, and\n * a role on THIS site — a lead is the site's record, so an org member scoped\n * to a sibling site holds the key but not the host. Then the CRM suite gate,\n * because a lead entered by hand is suite work: the capture doors that fill\n * a Free workspace's leads do not come through here.\n */\n\nimport {\n checkVisitorRecordCeiling,\n type CrmLeadProfilePatch,\n type CrmLeadStatus,\n LEADS_MAX_PER_HOST,\n normalizeContactEmail,\n normalizeCrmLeadProfile,\n personKey,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n addHostLead,\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { holdsDataManage } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/** The statuses a lead may be entered in — everything but the converted state. */\nconst CREATE_STATUSES: readonly CrmLeadStatus[] = ['new', 'working']\n\nconst NAME_MAX = 120\nconst NOTES_MAX = 4000\n\n/** The surface a hand-entered lead names, beside `signup`, `booking`, `form:{id}` and `import`. */\nexport const LEAD_MANUAL_SOURCE = 'manual'\n\n/** What the New lead drawer posts. */\nexport interface LeadCreateRequest {\n hostId: string\n email: string\n name?: string\n company?: string\n jobTitle?: string\n phone?: string\n website?: string\n address?: Record<string, unknown> | null\n tags?: string[]\n leadSource?: string\n status?: CrmLeadStatus\n ownerUid?: string\n notes?: string\n}\n\n/** What the route answers on success. */\nexport interface LeadCreateResponse {\n leadId: string\n /** False when the site already held a lead for the address — it was updated. */\n created: boolean\n}\n\n/** The lead's own fields as one write, `null` in the patch meaning a clear. */\nexport function leadProfileWrite(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 ? FieldValue.delete() : value\n }\n return write\n}\n\nexport const leadCreateHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n const body: Partial<LeadCreateRequest> & Record<string, unknown> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n .trim()\n .slice(0, 128)\n if (!hostId) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const email = normalizeContactEmail(body.email)\n if (!email) {\n res.status(400).json({ error: 'Enter a valid email address.' })\n return\n }\n /*\n * The profile, refused BEFORE any read: a phone or a website the record\n * cannot hold is answered under its field, the sentence the drawer shows\n * beneath the input, and nothing was written to retry against.\n */\n const { patch, errors } = normalizeCrmLeadProfile(body)\n const [firstError] = Object.entries(errors)\n if (firstError) {\n res.status(400).json({ error: firstError[1], field: firstError[0] })\n return\n }\n const rawStatus = String(body.status ?? '').trim()\n if (rawStatus && !CREATE_STATUSES.includes(rawStatus as CrmLeadStatus)) {\n res.status(400).json({ error: 'A new lead is New or Working.' })\n return\n }\n const name = String(body.name ?? '')\n .trim()\n .replace(/\\s+/g, ' ')\n .slice(0, NAME_MAX)\n const ownerUid = String(body.ownerUid ?? '')\n .trim()\n .slice(0, 128)\n const notes = String(body.notes ?? '')\n .trim()\n .slice(0, NOTES_MAX)\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (\n !staff &&\n !(membership.hostRole && (await holdsDataManage(membership.orgId, decoded.uid)))\n ) {\n res\n .status(403)\n .json({ error: 'Adding a lead requires the data permission on this site' })\n return\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const suite = crmSuiteRefusal(resolved.org, 'Adding a lead by hand')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const leadsRef = hostRef.collection('leads')\n // Non-null by construction: the normalizer refused every address this\n // derivation could not key.\n const leadId = personKey(email) as string\n const before = await leadsRef.doc(leadId).get()\n const created = !before.exists\n /*\n * The platform ceiling, judged here so the drawer can say WHY rather\n * than only that the lead could not be saved; the door re-judges it\n * inside its own transaction, so a race is still refused there.\n */\n if (created) {\n const used = (await leadsRef.count().get()).data().count\n if (checkVisitorRecordCeiling(used, LEADS_MAX_PER_HOST).exceeded) {\n res.status(409).json({\n error:\n 'This site is at the platform lead limit, so the lead was not ' +\n 'added. Remove some leads, or contact support if this is real ' +\n 'traffic.',\n reason: 'lead-ceiling',\n })\n return\n }\n }\n const stored = await addHostLead({\n hostRef,\n hostId,\n lead: {\n email,\n ...(name ? { name } : {}),\n source: LEAD_MANUAL_SOURCE,\n },\n })\n if (!stored) {\n res.status(500).json({ error: 'The lead could not be saved.' })\n return\n }\n const working: Record<string, unknown> = {\n ...leadProfileWrite(patch),\n ...(rawStatus ? { status: rawStatus } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(notes ? { notes } : {}),\n }\n if (Object.keys(working).length) {\n await leadsRef\n .doc(leadId)\n .set({ ...working, updatedAt: FieldValue.serverTimestamp() }, { merge: true })\n }\n /*\n * The audit line, written by the route that verified the caller and\n * performed the write — the one writer that cannot record an add that\n * did not happen. An update of a lead the site already held says so.\n */\n await logHostActivity(\n hostId,\n { uid: decoded.uid, email: decoded.email ?? null },\n created ? 'Added lead' : 'Updated lead',\n { type: 'lead', id: leadId, name: name || email },\n )\n const answer: LeadCreateResponse = { leadId, created }\n res.status(created ? 201 : 200).json(answer)\n } catch (error) {\n console.error('leads-create failed', error)\n res.status(500).json({ error: 'The lead could not be saved.' })\n }\n}\n\nexport default leadCreateHandler\n"],"names":["checkVisitorRecordCeiling","LEADS_MAX_PER_HOST","normalizeContactEmail","normalizeCrmLeadProfile","personKey","addHostLead","firebaseAdmin","getOrgForHost","logHostActivity","resolveOrgPermissions","FieldValue","holdsDataManage","crmSuiteRefusal","CREATE_STATUSES","NAME_MAX","NOTES_MAX","LEAD_MANUAL_SOURCE","leadProfileWrite","patch","write","key","value","Object","entries","undefined","delete","leadCreateHandler","req","res","body","method","setHeader","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","JSON","parse","hostId","trim","email","errors","firstError","field","rawStatus","includes","name","replace","ownerUid","notes","decoded","app","auth","verifyIdToken","staff","membership","uid","hostRole","orgId","resolved","suite","org","firestore","hostRef","collection","doc","leadsRef","leadId","before","get","created","exists","used","count","data","exceeded","reason","stored","lead","source","working","keys","set","updatedAt","serverTimestamp","merge","type","id","answer","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-create.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `POST /api/crm/leads-create` — a lead typed in by hand (AGL-3231).\n *\n * Salesforce's New Lead: a person the team has heard of and has not yet\n * qualified, entered with what is known about them — the address, the\n * name, the company as text, a title, a phone, a website, an address, tags,\n * where they came from — and nothing else brought into being. No contact,\n * no company record: those are the conversion's to make, once the lead is\n * real. Until then the lead is the whole record, which is what lets the\n * Leads section be worked without a second row for every person in it.\n *\n * ## Through the one door\n *\n * `addHostLead` is the single writer of `hosts/{hostId}/leads` — the\n * capture doors, the import and the REST create all reach it — and it is\n * the writer here. That is what keys the lead on the person, so a second\n * \"new lead\" for one address updates the first rather than filing twice;\n * counts it against the platform ceiling inside the transaction that\n * writes; and records NO marketing basis, because a person typed in by the\n * team is not a person who ticked a box. The profile and the working state\n * are the CRM's own annotations and land in one merge write after the\n * door's, exactly as the import writes them.\n *\n * ## Who may call it\n *\n * The same two gates the convert route asks: `data.manage` in the org, and\n * a role on THIS site — a lead is the site's record, so an org member scoped\n * to a sibling site holds the key but not the host. Then the CRM suite gate,\n * because a lead entered by hand is suite work: the capture doors that fill\n * a Free workspace's leads do not come through here.\n */\n\nimport {\n checkVisitorRecordCeiling,\n type CrmLeadProfilePatch,\n type CrmLeadStatus,\n LEADS_MAX_PER_HOST,\n normalizeContactEmail,\n normalizeCrmLeadProfile,\n personKey,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n addHostLead,\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { normalizeCampaignIds } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { holdsDataManage } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/** The statuses a lead may be entered in — everything but the converted state. */\nconst CREATE_STATUSES: readonly CrmLeadStatus[] = ['new', 'working']\n\nconst NAME_MAX = 120\nconst NOTES_MAX = 4000\n\n/** The surface a hand-entered lead names, beside `signup`, `booking`, `form:{id}` and `import`. */\nexport const LEAD_MANUAL_SOURCE = 'manual'\n\n/** What the New lead drawer posts. */\nexport interface LeadCreateRequest {\n hostId: string\n email: string\n name?: string\n company?: string\n jobTitle?: string\n phone?: string\n website?: string\n address?: Record<string, unknown> | null\n tags?: string[]\n leadSource?: string\n status?: CrmLeadStatus\n ownerUid?: string\n notes?: string\n /** The site's campaigns to file the lead under (AGL-3254), by container id. */\n campaignIds?: string[]\n}\n\n/** The sentence a campaign that is not the site's is refused with, under the field. */\nexport const LEAD_CAMPAIGN_REFUSAL =\n \"One of the campaigns picked isn't a campaign of this site any more. Pick it again.\"\n\n/**\n * The campaign ids a request may file a lead under: the site's own live\n * containers, and nothing else. A request can claim any id, and a lead\n * filed under another site's campaign — or one the console soft-deleted —\n * would sit on a page nobody at this site can open.\n *\n * @returns the clean ids, or `null` when one of them is not the site's.\n */\nexport async function siteCampaignIds(\n hostRef: FirebaseFirestore.DocumentReference,\n raw: unknown,\n): Promise<string[] | null> {\n const ids = normalizeCampaignIds(raw)\n if (!ids.length) return []\n if (ids.some((id) => id.includes('/'))) return null\n const containers = hostRef.collection('emailCampaigns')\n const found = await hostRef.firestore.getAll(...ids.map((id) => containers.doc(id)))\n const live = found.every((snapshot) => snapshot.exists && !snapshot.get('deletedAt'))\n return live ? ids : null\n}\n\n/** What the route answers on success. */\nexport interface LeadCreateResponse {\n leadId: string\n /** False when the site already held a lead for the address — it was updated. */\n created: boolean\n}\n\n/** The lead's own fields as one write, `null` in the patch meaning a clear. */\nexport function leadProfileWrite(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 ? FieldValue.delete() : value\n }\n return write\n}\n\nexport const leadCreateHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n const body: Partial<LeadCreateRequest> & Record<string, unknown> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n .trim()\n .slice(0, 128)\n if (!hostId) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const email = normalizeContactEmail(body.email)\n if (!email) {\n res.status(400).json({ error: 'Enter a valid email address.' })\n return\n }\n /*\n * The profile, refused BEFORE any read: a phone or a website the record\n * cannot hold is answered under its field, the sentence the drawer shows\n * beneath the input, and nothing was written to retry against.\n */\n const { patch, errors } = normalizeCrmLeadProfile(body)\n const [firstError] = Object.entries(errors)\n if (firstError) {\n res.status(400).json({ error: firstError[1], field: firstError[0] })\n return\n }\n const rawStatus = String(body.status ?? '').trim()\n if (rawStatus && !CREATE_STATUSES.includes(rawStatus as CrmLeadStatus)) {\n res.status(400).json({ error: 'A new lead is New or Working.' })\n return\n }\n const name = String(body.name ?? '')\n .trim()\n .replace(/\\s+/g, ' ')\n .slice(0, NAME_MAX)\n const ownerUid = String(body.ownerUid ?? '')\n .trim()\n .slice(0, 128)\n const notes = String(body.notes ?? '')\n .trim()\n .slice(0, NOTES_MAX)\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (\n !staff &&\n !(membership.hostRole && (await holdsDataManage(membership.orgId, decoded.uid)))\n ) {\n res\n .status(403)\n .json({ error: 'Adding a lead requires the data permission on this site' })\n return\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const suite = crmSuiteRefusal(resolved.org, 'Adding a lead by hand')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const leadsRef = hostRef.collection('leads')\n // The campaigns, judged before any write, so a refused pick leaves\n // nothing to retry against (AGL-3254).\n const campaignIds = await siteCampaignIds(hostRef, body.campaignIds)\n if (campaignIds === null) {\n res.status(400).json({ error: LEAD_CAMPAIGN_REFUSAL, field: 'campaignIds' })\n return\n }\n // Non-null by construction: the normalizer refused every address this\n // derivation could not key.\n const leadId = personKey(email) as string\n const before = await leadsRef.doc(leadId).get()\n const created = !before.exists\n /*\n * The platform ceiling, judged here so the drawer can say WHY rather\n * than only that the lead could not be saved; the door re-judges it\n * inside its own transaction, so a race is still refused there.\n */\n if (created) {\n const used = (await leadsRef.count().get()).data().count\n if (checkVisitorRecordCeiling(used, LEADS_MAX_PER_HOST).exceeded) {\n res.status(409).json({\n error:\n 'This site is at the platform lead limit, so the lead was not ' +\n 'added. Remove some leads, or contact support if this is real ' +\n 'traffic.',\n reason: 'lead-ceiling',\n })\n return\n }\n }\n const stored = await addHostLead({\n hostRef,\n hostId,\n lead: {\n email,\n ...(name ? { name } : {}),\n source: LEAD_MANUAL_SOURCE,\n },\n })\n if (!stored) {\n res.status(500).json({ error: 'The lead could not be saved.' })\n return\n }\n const working: Record<string, unknown> = {\n ...leadProfileWrite(patch),\n ...(rawStatus ? { status: rawStatus } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(notes ? { notes } : {}),\n // Added to what a lead the site already held carries, never in place\n // of it: a second \"new lead\" for an address files it under one more\n // campaign, as the bulk bar would.\n ...(campaignIds.length ? { campaignIds: FieldValue.arrayUnion(...campaignIds) } : {}),\n }\n if (Object.keys(working).length) {\n await leadsRef\n .doc(leadId)\n .set({ ...working, updatedAt: FieldValue.serverTimestamp() }, { merge: true })\n }\n /*\n * The audit line, written by the route that verified the caller and\n * performed the write — the one writer that cannot record an add that\n * did not happen. An update of a lead the site already held says so.\n */\n await logHostActivity(\n hostId,\n { uid: decoded.uid, email: decoded.email ?? null },\n created ? 'Added lead' : 'Updated lead',\n { type: 'lead', id: leadId, name: name || email },\n )\n const answer: LeadCreateResponse = { leadId, created }\n res.status(created ? 201 : 200).json(answer)\n } catch (error) {\n console.error('leads-create failed', error)\n res.status(500).json({ error: 'The lead could not be saved.' })\n }\n}\n\nexport default leadCreateHandler\n"],"names":["checkVisitorRecordCeiling","LEADS_MAX_PER_HOST","normalizeContactEmail","normalizeCrmLeadProfile","personKey","addHostLead","firebaseAdmin","getOrgForHost","logHostActivity","resolveOrgPermissions","normalizeCampaignIds","FieldValue","holdsDataManage","crmSuiteRefusal","CREATE_STATUSES","NAME_MAX","NOTES_MAX","LEAD_MANUAL_SOURCE","LEAD_CAMPAIGN_REFUSAL","siteCampaignIds","hostRef","raw","ids","length","some","id","includes","containers","collection","found","firestore","getAll","map","doc","live","every","snapshot","exists","get","leadProfileWrite","patch","write","key","value","Object","entries","undefined","delete","leadCreateHandler","req","res","body","method","setHeader","status","json","error","authorization","String","headers","idToken","startsWith","slice","JSON","parse","hostId","trim","email","errors","firstError","field","rawStatus","name","replace","ownerUid","notes","decoded","app","auth","verifyIdToken","staff","membership","uid","hostRole","orgId","resolved","suite","org","leadsRef","campaignIds","leadId","before","created","used","count","data","exceeded","reason","stored","lead","source","working","arrayUnion","keys","set","updatedAt","serverTimestamp","merge","type","answer","console"],"mappings":"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"}
|