@aglyn/aglyn 1.0.0-beta.152 → 1.0.0-beta.153
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -10
- package/src/lib/app-utils/crm-csv.d.ts +10 -3
- package/src/lib/app-utils/crm-csv.js +37 -2
- package/src/lib/app-utils/crm-csv.js.map +1 -1
- package/src/lib/app-utils/crm-email-templates.js +15 -0
- package/src/lib/app-utils/crm-email-templates.js.map +1 -1
- package/src/lib/app-utils/crm.d.ts +69 -2
- package/src/lib/app-utils/crm.js +126 -1
- package/src/lib/app-utils/crm.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +1 -1
- package/src/lib/app-utils/docs-help.generated.js +2 -0
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +25 -11
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/email-starter-policy.js +5 -3
- package/src/lib/app-utils/email-starter-policy.js.map +1 -1
- package/src/lib/app-utils/plan-entitlements.d.ts +34 -0
- package/src/lib/app-utils/plan-entitlements.js +40 -0
- package/src/lib/app-utils/plan-entitlements.js.map +1 -1
- package/src/lib/app-utils/server.d.ts +0 -1
- package/src/lib/app-utils/server.js +0 -1
- package/src/lib/app-utils/server.js.map +1 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.js +1 -0
- package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
- package/src/lib/plugin-manager/plugin-artifact-inventory.d.ts +145 -0
- package/src/lib/plugin-manager/plugin-artifact-inventory.js +126 -0
- package/src/lib/plugin-manager/plugin-artifact-inventory.js.map +1 -0
- package/src/lib/plugin-manager/plugin-contact-capture.d.ts +32 -1
- package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
- package/src/lib/plugin-manager/plugin-lead-conversion.d.ts +67 -0
- package/src/lib/plugin-manager/plugin-lead-conversion.js +93 -0
- package/src/lib/plugin-manager/plugin-lead-conversion.js.map +1 -0
- package/src/lib/plugin-manager/plugin-org-capacity.d.ts +12 -0
- package/src/lib/plugin-manager/plugin-org-capacity.js.map +1 -1
- package/src/lib/plugin-manager/plugin-record-timeline.d.ts +8 -1
- package/src/lib/plugin-manager/plugin-record-timeline.js.map +1 -1
- package/src/lib/app-utils/marketplace-verification.d.ts +0 -95
- package/src/lib/app-utils/marketplace-verification.js +0 -81
- package/src/lib/app-utils/marketplace-verification.js.map +0 -1
- package/src/lib/app-utils/publisher-attestation.d.ts +0 -99
- package/src/lib/app-utils/publisher-attestation.js +0 -158
- package/src/lib/app-utils/publisher-attestation.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/aglyn",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.153",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -33,15 +33,15 @@
|
|
|
33
33
|
"./package.json": "./package.json"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
37
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
38
|
-
"@aglyn/shared-data-types": "1.0.0-beta.
|
|
39
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
40
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
41
|
-
"@aglyn/shared-util-logger": "1.0.0-beta.
|
|
42
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
43
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
44
|
-
"@aglyn/shared-util-vendor": "1.0.0-beta.
|
|
36
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.153",
|
|
37
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.153",
|
|
38
|
+
"@aglyn/shared-data-types": "1.0.0-beta.153",
|
|
39
|
+
"@aglyn/shared-util-email": "1.0.0-beta.153",
|
|
40
|
+
"@aglyn/shared-util-http": "1.0.0-beta.153",
|
|
41
|
+
"@aglyn/shared-util-logger": "1.0.0-beta.153",
|
|
42
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.153",
|
|
43
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.153",
|
|
44
|
+
"@aglyn/shared-util-vendor": "1.0.0-beta.153",
|
|
45
45
|
"@data-driven-forms/react-form-renderer": "^4.2.0",
|
|
46
46
|
"@msgpack/msgpack": "^3.1.3",
|
|
47
47
|
"@types/unist": "^3.0.3",
|
|
@@ -171,7 +171,7 @@ export declare function csvLeadSourceLabel(source: string): string;
|
|
|
171
171
|
/** Every surface that produced a capture — the array, or the older single field. */
|
|
172
172
|
export declare function csvLeadSources(lead: Record<string, unknown>): string[];
|
|
173
173
|
/** As much of a lead row as the file reads. */
|
|
174
|
-
export type LeadCsvRow = Record<string, unknown> & Pick<CrmLeadFields, 'status' | 'ownerUid' | 'notes' | 'unqualifiedReason' | 'convertedAtMs'> & {
|
|
174
|
+
export type LeadCsvRow = Record<string, unknown> & Pick<CrmLeadFields, 'status' | 'ownerUid' | 'notes' | 'unqualifiedReason' | 'convertedAtMs' | 'company' | 'jobTitle' | 'phone' | 'website' | 'address' | 'tags' | 'leadSource'> & {
|
|
175
175
|
/** The site the lead lives under — what the `Site` column names. */
|
|
176
176
|
hostId?: string;
|
|
177
177
|
};
|
|
@@ -184,8 +184,15 @@ export interface LeadCsvOptions {
|
|
|
184
184
|
*/
|
|
185
185
|
siteName?: (hostId: string) => string | undefined;
|
|
186
186
|
}
|
|
187
|
-
/**
|
|
188
|
-
|
|
187
|
+
/**
|
|
188
|
+
* The columns every leads file carries, in the list's order.
|
|
189
|
+
*
|
|
190
|
+
* The lead's own profile (AGL-3231) — company, title, phone, website, the
|
|
191
|
+
* address parts, tags and the lead source — sits where the contacts file
|
|
192
|
+
* keeps the same facts, under the same headers, so one spreadsheet of
|
|
193
|
+
* people reads into either import with the same mapping.
|
|
194
|
+
*/
|
|
195
|
+
export declare const LEAD_CSV_COLUMNS: readonly ["Email", "Name", "Company", "Job title", "Phone", "Website", "Status", "Owner", "Lead source", "Sources", "First seen", "Last seen", "Captures", "Address line 1", "Address line 2", "City", "State", "Postal code", "Country", "Tags", "Unqualified reason", "Converted", "Notes"];
|
|
189
196
|
/** The header row: the standard columns, with `Site` after `Owner` at the org level. */
|
|
190
197
|
export declare function leadCsvHeader(options?: LeadCsvOptions): string[];
|
|
191
198
|
/** One lead's cells, in the header's order. */
|
|
@@ -286,6 +286,9 @@ const TASK_STATUS_LABELS = {
|
|
|
286
286
|
if (source === 'booking') return 'Booking';
|
|
287
287
|
if (source === 'import') return CONTACT_SOURCE_LABELS.import;
|
|
288
288
|
if (source === 'form') return CONTACT_SOURCE_LABELS.form;
|
|
289
|
+
// A lead entered by hand or over the REST API (AGL-3231).
|
|
290
|
+
if (source === 'manual') return 'Added by hand';
|
|
291
|
+
if (source === 'api') return 'API';
|
|
289
292
|
if (source.startsWith('form:')) return `Form ${source.slice('form:'.length)}`;
|
|
290
293
|
return source;
|
|
291
294
|
}
|
|
@@ -298,15 +301,34 @@ const TASK_STATUS_LABELS = {
|
|
|
298
301
|
lead['source']
|
|
299
302
|
] : [];
|
|
300
303
|
}
|
|
301
|
-
/**
|
|
304
|
+
/**
|
|
305
|
+
* The columns every leads file carries, in the list's order.
|
|
306
|
+
*
|
|
307
|
+
* The lead's own profile (AGL-3231) — company, title, phone, website, the
|
|
308
|
+
* address parts, tags and the lead source — sits where the contacts file
|
|
309
|
+
* keeps the same facts, under the same headers, so one spreadsheet of
|
|
310
|
+
* people reads into either import with the same mapping.
|
|
311
|
+
*/ export const LEAD_CSV_COLUMNS = [
|
|
302
312
|
'Email',
|
|
303
313
|
'Name',
|
|
314
|
+
'Company',
|
|
315
|
+
'Job title',
|
|
316
|
+
'Phone',
|
|
317
|
+
'Website',
|
|
304
318
|
'Status',
|
|
305
319
|
'Owner',
|
|
320
|
+
'Lead source',
|
|
306
321
|
'Sources',
|
|
307
322
|
'First seen',
|
|
308
323
|
'Last seen',
|
|
309
324
|
'Captures',
|
|
325
|
+
'Address line 1',
|
|
326
|
+
'Address line 2',
|
|
327
|
+
'City',
|
|
328
|
+
'State',
|
|
329
|
+
'Postal code',
|
|
330
|
+
'Country',
|
|
331
|
+
'Tags',
|
|
310
332
|
'Unqualified reason',
|
|
311
333
|
'Converted',
|
|
312
334
|
'Notes'
|
|
@@ -319,22 +341,35 @@ const TASK_STATUS_LABELS = {
|
|
|
319
341
|
return columns;
|
|
320
342
|
}
|
|
321
343
|
/** One lead's cells, in the header's order. */ export function leadCsvCells(lead, options = {}) {
|
|
322
|
-
var _lead_hostId, _lead_submissionCount, _lead_email, _lead_name, _ref, _siteName, _lead_firstSeenAtMs, _lead_lastSeenAtMs, _lead_unqualifiedReason, _lead_notes;
|
|
344
|
+
var _lead_hostId, _lead_submissionCount, _lead_email, _lead_name, _lead_company, _lead_jobTitle, _lead_phone, _lead_website, _ref, _siteName, _lead_leadSource, _lead_firstSeenAtMs, _lead_lastSeenAtMs, _ref1, _ref2, _ref3, _ref4, _ref5, _ref6, _lead_tags, _lead_unqualifiedReason, _lead_notes;
|
|
345
|
+
var _lead_address, _lead_address1, _lead_address2, _lead_address3, _lead_address4, _lead_address5;
|
|
323
346
|
const { ownerEmail, siteName } = options;
|
|
324
347
|
const hostId = String((_lead_hostId = lead.hostId) != null ? _lead_hostId : '');
|
|
325
348
|
const captures = Number((_lead_submissionCount = lead['submissionCount']) != null ? _lead_submissionCount : 0);
|
|
326
349
|
return [
|
|
327
350
|
String((_lead_email = lead['email']) != null ? _lead_email : ''),
|
|
328
351
|
String((_lead_name = lead['name']) != null ? _lead_name : ''),
|
|
352
|
+
(_lead_company = lead.company) != null ? _lead_company : '',
|
|
353
|
+
(_lead_jobTitle = lead.jobTitle) != null ? _lead_jobTitle : '',
|
|
354
|
+
(_lead_phone = lead.phone) != null ? _lead_phone : '',
|
|
355
|
+
(_lead_website = lead.website) != null ? _lead_website : '',
|
|
329
356
|
CRM_LEAD_STATUS_LABELS[crmLeadStatus(lead)],
|
|
330
357
|
lead.ownerUid ? (_ref = ownerEmail == null ? void 0 : ownerEmail(lead.ownerUid)) != null ? _ref : lead.ownerUid : '',
|
|
331
358
|
...siteName ? [
|
|
332
359
|
(_siteName = siteName(hostId)) != null ? _siteName : hostId
|
|
333
360
|
] : [],
|
|
361
|
+
(_lead_leadSource = lead.leadSource) != null ? _lead_leadSource : '',
|
|
334
362
|
csvLeadSources(lead).map(csvLeadSourceLabel).join('|'),
|
|
335
363
|
csvStoredInstant((_lead_firstSeenAtMs = lead['firstSeenAtMs']) != null ? _lead_firstSeenAtMs : lead['createdAt']),
|
|
336
364
|
csvStoredInstant((_lead_lastSeenAtMs = lead['lastSeenAtMs']) != null ? _lead_lastSeenAtMs : lead['createdAt']),
|
|
337
365
|
Number.isFinite(captures) && captures > 0 ? String(captures) : '',
|
|
366
|
+
(_ref1 = (_lead_address = lead.address) == null ? void 0 : _lead_address.line1) != null ? _ref1 : '',
|
|
367
|
+
(_ref2 = (_lead_address1 = lead.address) == null ? void 0 : _lead_address1.line2) != null ? _ref2 : '',
|
|
368
|
+
(_ref3 = (_lead_address2 = lead.address) == null ? void 0 : _lead_address2.city) != null ? _ref3 : '',
|
|
369
|
+
(_ref4 = (_lead_address3 = lead.address) == null ? void 0 : _lead_address3.state) != null ? _ref4 : '',
|
|
370
|
+
(_ref5 = (_lead_address4 = lead.address) == null ? void 0 : _lead_address4.postalCode) != null ? _ref5 : '',
|
|
371
|
+
(_ref6 = (_lead_address5 = lead.address) == null ? void 0 : _lead_address5.country) != null ? _ref6 : '',
|
|
372
|
+
((_lead_tags = lead.tags) != null ? _lead_tags : []).join('|'),
|
|
338
373
|
(_lead_unqualifiedReason = lead.unqualifiedReason) != null ? _lead_unqualifiedReason : '',
|
|
339
374
|
csvInstant(lead.convertedAtMs),
|
|
340
375
|
(_lead_notes = lead.notes) != null ? _lead_notes : ''
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/crm-csv.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 * EVERY CRM CSV, WRITTEN ONCE (AGL-2662).\n *\n * ## Why these moved out of the plugin\n *\n * Each CRM section already wrote a file from the rows it had LOADED, and\n * the whole-collection export writes the same file from the server over\n * every row there is. The console app may not import a feature plugin —\n * plugins reach an app only through the generated loader manifests — so a\n * server route could not have called the plugin's writers, and writing a\n * second set would have given one feature two file formats, discovered by\n * whichever merchant opened both in a spreadsheet.\n *\n * So the columns and the cells live here, in the library both sides may\n * import. The plugin's `contacts-csv.ts`, `companies-csv.ts`,\n * `deals-csv.ts`, `tasks-csv.ts` and `leads-csv.ts` keep their names and\n * delegate: the sections import what they always did, and there is one\n * implementation under all of it. Their specs are unchanged and are the\n * proof that the move changed no byte of any file.\n *\n * ## What a resolver is for, and what an absent one means\n *\n * A stored document holds ids — an owner uid, a pipeline id, a linked\n * record id — and a spreadsheet cannot read an id. Each writer therefore\n * takes optional resolvers, and every one of them degrades the same way:\n * ABSENT resolver writes the id itself rather than an empty cell, because\n * a row whose owner is a uid is still a row whose owner can be found, and\n * a blank is a claim that nobody owns it.\n *\n * The owner columns carry an ADDRESS where they can, which is also what\n * the contacts and companies imports resolve an owner by — so an exported\n * file re-imports without a hand mapping.\n */\n\nimport type { AglynPostalAddress } from '../foundation'\nimport {\n CONTACT_SOURCE_LABELS,\n contactDisplayName,\n interactionsForGroup,\n readContactFacet,\n} from './contacts'\nimport {\n CONTACT_LIFECYCLE_STAGE_LABELS,\n CRM_LEAD_STATUS_LABELS,\n CRM_TASK_KIND_LABELS,\n type ContactCustomValue,\n type ContactFieldDefinition,\n type ContactLifecycleStage,\n type CrmCompany,\n type CrmLeadFields,\n type CrmTask,\n crmLeadStatus,\n isContactLifecycleStage,\n} from './crm'\nimport { csvDocument } from './csv-import'\n\n/*==========================================\n * CELLS EVERY FILE SHARES\n *=========================================*/\n\n/** Cents as a major-unit decimal — `125000` → `1250.00`; `''` for none. */\nexport function csvAmount(cents: number | null | undefined): string {\n return typeof cents === 'number' && Number.isFinite(cents)\n ? (cents / 100).toFixed(2)\n : ''\n}\n\n/** An epoch as an ISO timestamp, or `''` for none. */\nexport function csvInstant(ms: number | null | undefined): string {\n return typeof ms === 'number' && Number.isFinite(ms)\n ? new Date(ms).toISOString()\n : ''\n}\n\n/**\n * An epoch as a local calendar DATE — `YYYY-MM-DD`.\n *\n * For the fields that are a day rather than a moment: an expected close is\n * stored at local noon precisely so the day survives a timezone round trip,\n * and writing it as an instant would hand a spreadsheet a time nobody\n * chose. The same arithmetic the deal drawer's date field uses, so the\n * file and the field read one day.\n */\nexport function csvLocalDate(ms: number | null | undefined): string {\n if (typeof ms !== 'number' || !Number.isFinite(ms)) return ''\n const offset = new Date(ms).getTimezoneOffset() * 60_000\n return new Date(ms - offset).toISOString().slice(0, 10)\n}\n\n/** Epoch millis or a Firestore timestamp as an ISO instant, or `''`. */\nexport function csvStoredInstant(value: unknown): string {\n if (typeof value === 'number') return csvInstant(value)\n const asDate = (value as { toDate?: () => Date } | null | undefined)?.toDate?.()\n return asDate ? asDate.toISOString() : ''\n}\n\n/** The one degradation rule: a resolved name, else the id, else empty. */\nfunction named(id: string | undefined, resolve?: (id: string) => string | undefined) {\n return id ? (resolve?.(id) || id) : ''\n}\n\n/*==========================================\n * CONTACTS\n *=========================================*/\n\n/** As much of a projected contact row as the file reads. */\nexport interface ContactCsvRow {\n email?: string\n name?: string\n phone?: string\n jobTitle?: string\n companyName?: string\n ownerUid?: string\n lifecycleStage?: ContactLifecycleStage | ''\n address?: AglynPostalAddress | null\n tags?: string[]\n sources?: Record<string, unknown>\n interactions?: Array<{ atMs: number }>\n /** The last open or click on one of the site's campaigns (AGL-2616). */\n lastEmailEngagementAtMs?: number\n notes?: string\n /** This holder's custom values, keyed by definition key. */\n custom?: Record<string, ContactCustomValue>\n}\n\nexport interface ContactCsvOptions {\n /**\n * The owner's address for a stored uid — what the import resolves an\n * owner by. Absent, the uid is written as it is.\n */\n ownerEmail?: (uid: string) => string\n /** The org's custom fields, one column each, headed by the field's label. */\n customFields?: readonly Pick<ContactFieldDefinition, 'key' | 'label'>[]\n}\n\n/**\n * The standard columns, in order, headed as the import reads them. A\n * custom field's column follows these, headed by its label.\n */\nexport const CONTACT_CSV_COLUMNS = [\n 'Email',\n 'Name',\n 'Phone',\n 'Job title',\n 'Company',\n 'Owner',\n 'Lifecycle stage',\n 'Address line 1',\n 'Address line 2',\n 'City',\n 'State',\n 'Postal code',\n 'Country',\n 'Tags',\n 'Sources',\n 'Last interaction',\n 'Last engaged',\n 'Notes',\n] as const\n\n/** The header row, with one column per custom field after the standard ones. */\nexport function contactCsvHeader(\n customFields: ContactCsvOptions['customFields'] = [],\n): string[] {\n return [...CONTACT_CSV_COLUMNS, ...customFields.map((field) => field.label)]\n}\n\n/** A custom value as a cell: a boolean as yes/no, everything else as text. */\nconst customCell = (value: ContactCustomValue | undefined): string => {\n if (value === null || value === undefined) return ''\n if (typeof value === 'boolean') return value ? 'yes' : 'no'\n return String(value)\n}\n\n/** One contact's cells, in the header's order. */\nexport function contactCsvCells(\n contact: ContactCsvRow,\n options: ContactCsvOptions = {},\n): unknown[] {\n const { ownerEmail, customFields = [] } = options\n return [\n contact.email ?? '',\n contact.name ?? '',\n contact.phone ?? '',\n contact.jobTitle ?? '',\n contact.companyName ?? '',\n contact.ownerUid ? (ownerEmail?.(contact.ownerUid) ?? contact.ownerUid) : '',\n contact.lifecycleStage\n ? CONTACT_LIFECYCLE_STAGE_LABELS[contact.lifecycleStage]\n : '',\n contact.address?.line1 ?? '',\n contact.address?.line2 ?? '',\n contact.address?.city ?? '',\n contact.address?.state ?? '',\n contact.address?.postalCode ?? '',\n contact.address?.country ?? '',\n (contact.tags ?? []).join('|'),\n Object.keys(contact.sources ?? {}).join('|'),\n contact.interactions?.[0]\n ? new Date(contact.interactions[0].atMs).toISOString()\n : '',\n contact.lastEmailEngagementAtMs\n ? new Date(contact.lastEmailEngagementAtMs).toISOString()\n : '',\n contact.notes ?? '',\n ...customFields.map((field) => customCell(contact.custom?.[field.key])),\n ]\n}\n\n/** The whole file, header first. */\nexport function contactsCsv(\n rows: readonly ContactCsvRow[],\n options: ContactCsvOptions = {},\n): string {\n return csvDocument(\n contactCsvHeader(options.customFields),\n rows.map((contact) => contactCsvCells(contact, options)),\n )\n}\n\n/*==========================================\n * COMPANIES\n *=========================================*/\n\n/** As much of a company row as the file reads. */\nexport type CompanyCsvRow = Partial<\n Pick<\n CrmCompany,\n | 'name'\n | 'domain'\n | 'website'\n | 'phone'\n | 'industry'\n | 'ownerUid'\n | 'tags'\n | 'notes'\n | 'contactsCount'\n >\n> & { address?: AglynPostalAddress | null }\n\nexport interface CompanyCsvOptions {\n /** The owner's address for a stored uid; absent, the uid is written. */\n ownerEmail?: (uid: string) => string\n}\n\n/** The columns, in order, headed as the import reads them. */\nexport const COMPANY_CSV_COLUMNS = [\n 'Company',\n 'Domain',\n 'Website',\n 'Phone',\n 'Industry',\n 'Owner',\n 'Address line 1',\n 'Address line 2',\n 'City',\n 'State',\n 'Postal code',\n 'Country',\n 'Tags',\n 'Notes',\n 'Contacts',\n] as const\n\n/** One company's cells, in the header's order. */\nexport function companyCsvCells(\n company: CompanyCsvRow,\n options: CompanyCsvOptions = {},\n): unknown[] {\n const { ownerEmail } = options\n return [\n company.name ?? '',\n company.domain ?? '',\n company.website ?? '',\n company.phone ?? '',\n company.industry ?? '',\n company.ownerUid ? (ownerEmail?.(company.ownerUid) ?? company.ownerUid) : '',\n company.address?.line1 ?? '',\n company.address?.line2 ?? '',\n company.address?.city ?? '',\n company.address?.state ?? '',\n company.address?.postalCode ?? '',\n company.address?.country ?? '',\n (company.tags ?? []).join('|'),\n company.notes ?? '',\n Number(company.contactsCount ?? 0),\n ]\n}\n\n/** The whole file, header first. */\nexport function companiesCsv(\n rows: readonly CompanyCsvRow[],\n options: CompanyCsvOptions = {},\n): string {\n return csvDocument(\n COMPANY_CSV_COLUMNS,\n rows.map((company) => companyCsvCells(company, options)),\n )\n}\n\n/*==========================================\n * DEALS\n *=========================================*/\n\n/** The currency a deal with no code of its own is read in. */\nexport const CSV_DEFAULT_DEAL_CURRENCY = 'usd'\n\n/** How a deal's `status` reads in a file. */\nexport const CSV_DEAL_STATUS_LABELS: Record<string, string> = {\n open: 'Open',\n won: 'Won',\n lost: 'Lost',\n}\n\n/** As much of a deal row as the file reads. */\nexport interface DealCsvRow {\n title?: string\n pipelineId?: string\n stageId?: string\n status?: string\n amountCents?: number\n currency?: string\n ownerUid?: string\n expectedCloseAtMs?: number | null\n closedAtMs?: number | null\n contactName?: string\n companyName?: string\n lostReason?: string\n notes?: string\n}\n\nexport interface DealCsvOptions {\n /** The pipeline's name for a stored id; absent, the id is written. */\n pipelineName?: (pipelineId: string) => string | undefined\n /** The stage's name within its pipeline; absent, the stage id is written. */\n stageName?: (pipelineId: string, stageId: string) => string | undefined\n /** The owner's address for a stored uid; absent, the uid is written. */\n ownerEmail?: (uid: string) => string\n}\n\nexport const DEAL_CSV_COLUMNS = [\n 'Title',\n 'Pipeline',\n 'Stage',\n 'Amount',\n 'Currency',\n 'Owner',\n 'Expected close',\n 'Status',\n 'Contact',\n 'Company',\n 'Closed',\n 'Lost reason',\n 'Notes',\n] as const\n\n/** One deal's cells, in the header's order. */\nexport function dealCsvCells(\n deal: DealCsvRow,\n options: DealCsvOptions = {},\n): unknown[] {\n const { pipelineName, stageName, ownerEmail } = options\n const pipelineId = deal.pipelineId ?? ''\n const stageId = deal.stageId ?? ''\n return [\n deal.title ?? '',\n pipelineName?.(pipelineId) ?? pipelineId,\n stageName?.(pipelineId, stageId) ?? stageId,\n csvAmount(deal.amountCents),\n typeof deal.amountCents === 'number'\n ? String(deal.currency || CSV_DEFAULT_DEAL_CURRENCY).toUpperCase()\n : '',\n deal.ownerUid ? (ownerEmail?.(deal.ownerUid) ?? deal.ownerUid) : '',\n // A close DATE, in the reader's own zone — stored at local noon, so\n // the calendar day is what comes back out.\n csvLocalDate(deal.expectedCloseAtMs),\n deal.status ? (CSV_DEAL_STATUS_LABELS[deal.status] ?? deal.status) : '',\n deal.contactName ?? '',\n deal.companyName ?? '',\n csvInstant(deal.closedAtMs),\n deal.lostReason ?? '',\n deal.notes ?? '',\n ]\n}\n\n/** The whole file, header first. */\nexport function dealsCsv(\n rows: readonly DealCsvRow[],\n options: DealCsvOptions = {},\n): string {\n return csvDocument(\n DEAL_CSV_COLUMNS,\n rows.map((deal) => dealCsvCells(deal, options)),\n )\n}\n\n/*==========================================\n * TASKS\n *=========================================*/\n\n/** As much of a task row as the file reads. */\nexport type TaskCsvRow = Partial<\n Pick<\n CrmTask,\n | 'title'\n | 'kind'\n | 'priority'\n | 'status'\n | 'dueAtMs'\n | 'completedAtMs'\n | 'assigneeUid'\n | 'contactId'\n | 'companyId'\n | 'dealId'\n | 'notes'\n >\n>\n\nexport interface TaskCsvOptions {\n /** The assignee's address for a stored uid; absent, the uid is written. */\n assigneeEmail?: (uid: string) => string\n /** What a linked record is called; absent, the id is written. */\n recordName?: (kind: 'contact' | 'company' | 'deal', id: string) => string | undefined\n}\n\nexport const TASK_CSV_COLUMNS = [\n 'Title',\n 'Kind',\n 'Priority',\n 'Status',\n 'Due',\n 'Assignee',\n 'Contact',\n 'Company',\n 'Deal',\n 'Completed',\n 'Notes',\n] as const\n\nconst TASK_PRIORITY_LABELS: Record<string, string> = {\n low: 'Low',\n normal: 'Normal',\n high: 'High',\n}\n\nconst TASK_STATUS_LABELS: Record<string, string> = {\n open: 'Open',\n done: 'Done',\n}\n\n/** One task's cells, in the header's order. */\nexport function taskCsvCells(\n task: TaskCsvRow,\n options: TaskCsvOptions = {},\n): unknown[] {\n const { assigneeEmail, recordName } = options\n const link = (kind: 'contact' | 'company' | 'deal', id: string | undefined) =>\n named(id, (value) => recordName?.(kind, value))\n return [\n task.title ?? '',\n task.kind ? (CRM_TASK_KIND_LABELS[task.kind] ?? task.kind) : '',\n task.priority ? (TASK_PRIORITY_LABELS[task.priority] ?? task.priority) : '',\n task.status ? (TASK_STATUS_LABELS[task.status] ?? task.status) : '',\n csvInstant(task.dueAtMs),\n task.assigneeUid ? (assigneeEmail?.(task.assigneeUid) ?? task.assigneeUid) : '',\n link('contact', task.contactId),\n link('company', task.companyId),\n link('deal', task.dealId),\n csvInstant(task.completedAtMs),\n task.notes ?? '',\n ]\n}\n\n/** The whole file, header first. */\nexport function tasksCsv(\n rows: readonly TaskCsvRow[],\n options: TaskCsvOptions = {},\n): string {\n return csvDocument(\n TASK_CSV_COLUMNS,\n rows.map((task) => taskCsvCells(task, options)),\n )\n}\n\n/*==========================================\n * LEADS\n *=========================================*/\n\n/** How one capture surface reads in a file. */\nexport function csvLeadSourceLabel(source: string): string {\n if (source === 'signup') return 'Sign-up'\n if (source === 'booking') return 'Booking'\n if (source === 'import') return CONTACT_SOURCE_LABELS.import\n if (source === 'form') return CONTACT_SOURCE_LABELS.form\n if (source.startsWith('form:')) return `Form ${source.slice('form:'.length)}`\n return source\n}\n\n/** Every surface that produced a capture — the array, or the older single field. */\nexport function csvLeadSources(lead: Record<string, unknown>): string[] {\n const sources = lead['sources']\n if (Array.isArray(sources) && sources.length) {\n return sources.map((source) => String(source))\n }\n return typeof lead['source'] === 'string' && lead['source'] ? [lead['source']] : []\n}\n\n/** As much of a lead row as the file reads. */\nexport type LeadCsvRow = Record<string, unknown> &\n Pick<\n CrmLeadFields,\n 'status' | 'ownerUid' | 'notes' | 'unqualifiedReason' | 'convertedAtMs'\n > & {\n /** The site the lead lives under — what the `Site` column names. */\n hostId?: string\n }\n\nexport interface LeadCsvOptions {\n /** The owner's address for a stored uid; absent, the uid is written. */\n ownerEmail?: (uid: string) => string\n /**\n * The site's name for a row's `hostId`. Given, the file carries a `Site`\n * column — the organization-level file; absent, it does not.\n */\n siteName?: (hostId: string) => string | undefined\n}\n\n/** The columns every leads file carries, in the list's order. */\nexport const LEAD_CSV_COLUMNS = [\n 'Email',\n 'Name',\n 'Status',\n 'Owner',\n 'Sources',\n 'First seen',\n 'Last seen',\n 'Captures',\n 'Unqualified reason',\n 'Converted',\n 'Notes',\n] as const\n\n/** The header row: the standard columns, with `Site` after `Owner` at the org level. */\nexport function leadCsvHeader(options: LeadCsvOptions = {}): string[] {\n const columns: string[] = [...LEAD_CSV_COLUMNS]\n if (options.siteName) columns.splice(columns.indexOf('Owner') + 1, 0, 'Site')\n return columns\n}\n\n/** One lead's cells, in the header's order. */\nexport function leadCsvCells(\n lead: LeadCsvRow,\n options: LeadCsvOptions = {},\n): unknown[] {\n const { ownerEmail, siteName } = options\n const hostId = String(lead.hostId ?? '')\n const captures = Number(lead['submissionCount'] ?? 0)\n return [\n String(lead['email'] ?? ''),\n String(lead['name'] ?? ''),\n CRM_LEAD_STATUS_LABELS[crmLeadStatus(lead)],\n lead.ownerUid ? (ownerEmail?.(lead.ownerUid) ?? lead.ownerUid) : '',\n ...(siteName ? [siteName(hostId) ?? hostId] : []),\n csvLeadSources(lead).map(csvLeadSourceLabel).join('|'),\n csvStoredInstant(lead['firstSeenAtMs'] ?? lead['createdAt']),\n csvStoredInstant(lead['lastSeenAtMs'] ?? lead['createdAt']),\n Number.isFinite(captures) && captures > 0 ? String(captures) : '',\n lead.unqualifiedReason ?? '',\n csvInstant(lead.convertedAtMs),\n lead.notes ?? '',\n ]\n}\n\n/** The whole file, header first. */\nexport function leadsCsv(\n rows: readonly LeadCsvRow[],\n options: LeadCsvOptions = {},\n): string {\n return csvDocument(\n leadCsvHeader(options),\n rows.map((lead) => leadCsvCells(lead, options)),\n )\n}\n\n/*==========================================\n * WHAT THE WHOLE-COLLECTION EXPORT WRITES\n *=========================================*/\n\n/** The five tables a whole-collection export can be asked for. */\nexport const CRM_EXPORT_RESOURCES = [\n 'contacts',\n 'companies',\n 'deals',\n 'tasks',\n 'leads',\n] as const\n\nexport type CrmExportResource = (typeof CRM_EXPORT_RESOURCES)[number]\n\nexport function isCrmExportResource(value: unknown): value is CrmExportResource {\n return (\n typeof value === 'string' &&\n (CRM_EXPORT_RESOURCES as readonly string[]).includes(value)\n )\n}\n\n/** Every resolver any of the five writers takes, in one bag. */\nexport type CrmExportOptions = ContactCsvOptions &\n DealCsvOptions &\n TaskCsvOptions &\n LeadCsvOptions & { assigneeEmail?: (uid: string) => string }\n\n/** The header row one resource's file carries. */\nexport function crmExportHeader(\n resource: CrmExportResource,\n options: CrmExportOptions = {},\n): string[] {\n switch (resource) {\n case 'contacts':\n return contactCsvHeader(options.customFields)\n case 'companies':\n return [...COMPANY_CSV_COLUMNS]\n case 'deals':\n return [...DEAL_CSV_COLUMNS]\n case 'tasks':\n return [...TASK_CSV_COLUMNS]\n default:\n return leadCsvHeader(options)\n }\n}\n\n/**\n * One stored document's cells for one resource's file.\n *\n * The bridge the server route stands on: it holds a raw document rather\n * than the projected row a list built, and this is the one place that\n * turns the first into the second — so the streamed file and the file the\n * Export button writes are the same columns filled the same way.\n */\nexport function crmExportCells(\n resource: CrmExportResource,\n document: Record<string, unknown>,\n options: CrmExportOptions = {},\n): unknown[] {\n switch (resource) {\n case 'contacts':\n return contactCsvCells(document as ContactCsvRow, options)\n case 'companies':\n return companyCsvCells(document as CompanyCsvRow, options)\n case 'deals':\n return dealCsvCells(document as DealCsvRow, options)\n case 'tasks':\n return taskCsvCells(document as TaskCsvRow, options)\n default:\n return leadCsvCells(document as LeadCsvRow, options)\n }\n}\n\n/** What the contacts file needs to know about the holder it is written for. */\nexport interface CrmExportContactHolder {\n /** The consent group whose facet the file reads — a facet is per holder. */\n groupId: string\n /** The sites in that group, which bound the timeline the file reads. */\n hostIds: readonly string[]\n}\n\n/**\n * A stored contact document as the contacts file reads it.\n *\n * The projection the Contacts list makes before it writes its own file,\n * spelled here so the server can make the same one: a contact document\n * holds one facet per HOLDER, and every field the file writes but the\n * address itself — the phone, the company, the tags, the notes, the\n * lifecycle stage, the custom values — lives in the viewing holder's facet.\n * Reading them off the top of the document is how one business's file ends\n * up carrying another's notes.\n */\nexport function contactCsvRowFromDoc(\n document: Record<string, unknown>,\n holder: CrmExportContactHolder,\n): ContactCsvRow {\n const facet = readContactFacet(document, holder.groupId)\n return {\n email: typeof document['email'] === 'string' ? document['email'] : '',\n name: contactDisplayName(document, holder.groupId),\n phone: facet.phone ?? '',\n jobTitle: facet.jobTitle ?? '',\n companyName: facet.companyName ?? '',\n ownerUid: facet.ownerUid ?? '',\n lifecycleStage: isContactLifecycleStage(facet.lifecycleStage)\n ? facet.lifecycleStage\n : '',\n address: facet.address ?? null,\n tags: facet.tags ?? [],\n sources: facet.sources,\n interactions: interactionsForGroup(facet.interactions, holder.hostIds),\n ...(typeof facet.lastEmailEngagementAtMs === 'number' &&\n Number.isFinite(facet.lastEmailEngagementAtMs) &&\n facet.lastEmailEngagementAtMs > 0\n ? { lastEmailEngagementAtMs: facet.lastEmailEngagementAtMs }\n : {}),\n notes: facet.notes ?? '',\n custom: facet.custom ?? {},\n }\n}\n"],"names":["CONTACT_SOURCE_LABELS","contactDisplayName","interactionsForGroup","readContactFacet","CONTACT_LIFECYCLE_STAGE_LABELS","CRM_LEAD_STATUS_LABELS","CRM_TASK_KIND_LABELS","crmLeadStatus","isContactLifecycleStage","csvDocument","csvAmount","cents","Number","isFinite","toFixed","csvInstant","ms","Date","toISOString","csvLocalDate","offset","getTimezoneOffset","slice","csvStoredInstant","value","asDate","toDate","named","id","resolve","CONTACT_CSV_COLUMNS","contactCsvHeader","customFields","map","field","label","customCell","undefined","String","contactCsvCells","contact","options","ownerEmail","email","name","phone","jobTitle","companyName","ownerUid","lifecycleStage","address","line1","line2","city","state","postalCode","country","tags","join","Object","keys","sources","interactions","atMs","lastEmailEngagementAtMs","notes","custom","key","contactsCsv","rows","COMPANY_CSV_COLUMNS","companyCsvCells","company","domain","website","industry","contactsCount","companiesCsv","CSV_DEFAULT_DEAL_CURRENCY","CSV_DEAL_STATUS_LABELS","open","won","lost","DEAL_CSV_COLUMNS","dealCsvCells","deal","pipelineName","stageName","pipelineId","stageId","title","amountCents","currency","toUpperCase","expectedCloseAtMs","status","contactName","closedAtMs","lostReason","dealsCsv","TASK_CSV_COLUMNS","TASK_PRIORITY_LABELS","low","normal","high","TASK_STATUS_LABELS","done","taskCsvCells","task","assigneeEmail","recordName","link","kind","priority","dueAtMs","assigneeUid","contactId","companyId","dealId","completedAtMs","tasksCsv","csvLeadSourceLabel","source","import","form","startsWith","length","csvLeadSources","lead","Array","isArray","LEAD_CSV_COLUMNS","leadCsvHeader","columns","siteName","splice","indexOf","leadCsvCells","hostId","captures","unqualifiedReason","convertedAtMs","leadsCsv","CRM_EXPORT_RESOURCES","isCrmExportResource","includes","crmExportHeader","resource","crmExportCells","document","contactCsvRowFromDoc","holder","facet","groupId","hostIds"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/crm-csv.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 * EVERY CRM CSV, WRITTEN ONCE (AGL-2662).\n *\n * ## Why these moved out of the plugin\n *\n * Each CRM section already wrote a file from the rows it had LOADED, and\n * the whole-collection export writes the same file from the server over\n * every row there is. The console app may not import a feature plugin —\n * plugins reach an app only through the generated loader manifests — so a\n * server route could not have called the plugin's writers, and writing a\n * second set would have given one feature two file formats, discovered by\n * whichever merchant opened both in a spreadsheet.\n *\n * So the columns and the cells live here, in the library both sides may\n * import. The plugin's `contacts-csv.ts`, `companies-csv.ts`,\n * `deals-csv.ts`, `tasks-csv.ts` and `leads-csv.ts` keep their names and\n * delegate: the sections import what they always did, and there is one\n * implementation under all of it. Their specs are unchanged and are the\n * proof that the move changed no byte of any file.\n *\n * ## What a resolver is for, and what an absent one means\n *\n * A stored document holds ids — an owner uid, a pipeline id, a linked\n * record id — and a spreadsheet cannot read an id. Each writer therefore\n * takes optional resolvers, and every one of them degrades the same way:\n * ABSENT resolver writes the id itself rather than an empty cell, because\n * a row whose owner is a uid is still a row whose owner can be found, and\n * a blank is a claim that nobody owns it.\n *\n * The owner columns carry an ADDRESS where they can, which is also what\n * the contacts and companies imports resolve an owner by — so an exported\n * file re-imports without a hand mapping.\n */\n\nimport type { AglynPostalAddress } from '../foundation'\nimport {\n CONTACT_SOURCE_LABELS,\n contactDisplayName,\n interactionsForGroup,\n readContactFacet,\n} from './contacts'\nimport {\n CONTACT_LIFECYCLE_STAGE_LABELS,\n CRM_LEAD_STATUS_LABELS,\n CRM_TASK_KIND_LABELS,\n type ContactCustomValue,\n type ContactFieldDefinition,\n type ContactLifecycleStage,\n type CrmCompany,\n type CrmLeadFields,\n type CrmTask,\n crmLeadStatus,\n isContactLifecycleStage,\n} from './crm'\nimport { csvDocument } from './csv-import'\n\n/*==========================================\n * CELLS EVERY FILE SHARES\n *=========================================*/\n\n/** Cents as a major-unit decimal — `125000` → `1250.00`; `''` for none. */\nexport function csvAmount(cents: number | null | undefined): string {\n return typeof cents === 'number' && Number.isFinite(cents)\n ? (cents / 100).toFixed(2)\n : ''\n}\n\n/** An epoch as an ISO timestamp, or `''` for none. */\nexport function csvInstant(ms: number | null | undefined): string {\n return typeof ms === 'number' && Number.isFinite(ms)\n ? new Date(ms).toISOString()\n : ''\n}\n\n/**\n * An epoch as a local calendar DATE — `YYYY-MM-DD`.\n *\n * For the fields that are a day rather than a moment: an expected close is\n * stored at local noon precisely so the day survives a timezone round trip,\n * and writing it as an instant would hand a spreadsheet a time nobody\n * chose. The same arithmetic the deal drawer's date field uses, so the\n * file and the field read one day.\n */\nexport function csvLocalDate(ms: number | null | undefined): string {\n if (typeof ms !== 'number' || !Number.isFinite(ms)) return ''\n const offset = new Date(ms).getTimezoneOffset() * 60_000\n return new Date(ms - offset).toISOString().slice(0, 10)\n}\n\n/** Epoch millis or a Firestore timestamp as an ISO instant, or `''`. */\nexport function csvStoredInstant(value: unknown): string {\n if (typeof value === 'number') return csvInstant(value)\n const asDate = (value as { toDate?: () => Date } | null | undefined)?.toDate?.()\n return asDate ? asDate.toISOString() : ''\n}\n\n/** The one degradation rule: a resolved name, else the id, else empty. */\nfunction named(id: string | undefined, resolve?: (id: string) => string | undefined) {\n return id ? (resolve?.(id) || id) : ''\n}\n\n/*==========================================\n * CONTACTS\n *=========================================*/\n\n/** As much of a projected contact row as the file reads. */\nexport interface ContactCsvRow {\n email?: string\n name?: string\n phone?: string\n jobTitle?: string\n companyName?: string\n ownerUid?: string\n lifecycleStage?: ContactLifecycleStage | ''\n address?: AglynPostalAddress | null\n tags?: string[]\n sources?: Record<string, unknown>\n interactions?: Array<{ atMs: number }>\n /** The last open or click on one of the site's campaigns (AGL-2616). */\n lastEmailEngagementAtMs?: number\n notes?: string\n /** This holder's custom values, keyed by definition key. */\n custom?: Record<string, ContactCustomValue>\n}\n\nexport interface ContactCsvOptions {\n /**\n * The owner's address for a stored uid — what the import resolves an\n * owner by. Absent, the uid is written as it is.\n */\n ownerEmail?: (uid: string) => string\n /** The org's custom fields, one column each, headed by the field's label. */\n customFields?: readonly Pick<ContactFieldDefinition, 'key' | 'label'>[]\n}\n\n/**\n * The standard columns, in order, headed as the import reads them. A\n * custom field's column follows these, headed by its label.\n */\nexport const CONTACT_CSV_COLUMNS = [\n 'Email',\n 'Name',\n 'Phone',\n 'Job title',\n 'Company',\n 'Owner',\n 'Lifecycle stage',\n 'Address line 1',\n 'Address line 2',\n 'City',\n 'State',\n 'Postal code',\n 'Country',\n 'Tags',\n 'Sources',\n 'Last interaction',\n 'Last engaged',\n 'Notes',\n] as const\n\n/** The header row, with one column per custom field after the standard ones. */\nexport function contactCsvHeader(\n customFields: ContactCsvOptions['customFields'] = [],\n): string[] {\n return [...CONTACT_CSV_COLUMNS, ...customFields.map((field) => field.label)]\n}\n\n/** A custom value as a cell: a boolean as yes/no, everything else as text. */\nconst customCell = (value: ContactCustomValue | undefined): string => {\n if (value === null || value === undefined) return ''\n if (typeof value === 'boolean') return value ? 'yes' : 'no'\n return String(value)\n}\n\n/** One contact's cells, in the header's order. */\nexport function contactCsvCells(\n contact: ContactCsvRow,\n options: ContactCsvOptions = {},\n): unknown[] {\n const { ownerEmail, customFields = [] } = options\n return [\n contact.email ?? '',\n contact.name ?? '',\n contact.phone ?? '',\n contact.jobTitle ?? '',\n contact.companyName ?? '',\n contact.ownerUid ? (ownerEmail?.(contact.ownerUid) ?? contact.ownerUid) : '',\n contact.lifecycleStage\n ? CONTACT_LIFECYCLE_STAGE_LABELS[contact.lifecycleStage]\n : '',\n contact.address?.line1 ?? '',\n contact.address?.line2 ?? '',\n contact.address?.city ?? '',\n contact.address?.state ?? '',\n contact.address?.postalCode ?? '',\n contact.address?.country ?? '',\n (contact.tags ?? []).join('|'),\n Object.keys(contact.sources ?? {}).join('|'),\n contact.interactions?.[0]\n ? new Date(contact.interactions[0].atMs).toISOString()\n : '',\n contact.lastEmailEngagementAtMs\n ? new Date(contact.lastEmailEngagementAtMs).toISOString()\n : '',\n contact.notes ?? '',\n ...customFields.map((field) => customCell(contact.custom?.[field.key])),\n ]\n}\n\n/** The whole file, header first. */\nexport function contactsCsv(\n rows: readonly ContactCsvRow[],\n options: ContactCsvOptions = {},\n): string {\n return csvDocument(\n contactCsvHeader(options.customFields),\n rows.map((contact) => contactCsvCells(contact, options)),\n )\n}\n\n/*==========================================\n * COMPANIES\n *=========================================*/\n\n/** As much of a company row as the file reads. */\nexport type CompanyCsvRow = Partial<\n Pick<\n CrmCompany,\n | 'name'\n | 'domain'\n | 'website'\n | 'phone'\n | 'industry'\n | 'ownerUid'\n | 'tags'\n | 'notes'\n | 'contactsCount'\n >\n> & { address?: AglynPostalAddress | null }\n\nexport interface CompanyCsvOptions {\n /** The owner's address for a stored uid; absent, the uid is written. */\n ownerEmail?: (uid: string) => string\n}\n\n/** The columns, in order, headed as the import reads them. */\nexport const COMPANY_CSV_COLUMNS = [\n 'Company',\n 'Domain',\n 'Website',\n 'Phone',\n 'Industry',\n 'Owner',\n 'Address line 1',\n 'Address line 2',\n 'City',\n 'State',\n 'Postal code',\n 'Country',\n 'Tags',\n 'Notes',\n 'Contacts',\n] as const\n\n/** One company's cells, in the header's order. */\nexport function companyCsvCells(\n company: CompanyCsvRow,\n options: CompanyCsvOptions = {},\n): unknown[] {\n const { ownerEmail } = options\n return [\n company.name ?? '',\n company.domain ?? '',\n company.website ?? '',\n company.phone ?? '',\n company.industry ?? '',\n company.ownerUid ? (ownerEmail?.(company.ownerUid) ?? company.ownerUid) : '',\n company.address?.line1 ?? '',\n company.address?.line2 ?? '',\n company.address?.city ?? '',\n company.address?.state ?? '',\n company.address?.postalCode ?? '',\n company.address?.country ?? '',\n (company.tags ?? []).join('|'),\n company.notes ?? '',\n Number(company.contactsCount ?? 0),\n ]\n}\n\n/** The whole file, header first. */\nexport function companiesCsv(\n rows: readonly CompanyCsvRow[],\n options: CompanyCsvOptions = {},\n): string {\n return csvDocument(\n COMPANY_CSV_COLUMNS,\n rows.map((company) => companyCsvCells(company, options)),\n )\n}\n\n/*==========================================\n * DEALS\n *=========================================*/\n\n/** The currency a deal with no code of its own is read in. */\nexport const CSV_DEFAULT_DEAL_CURRENCY = 'usd'\n\n/** How a deal's `status` reads in a file. */\nexport const CSV_DEAL_STATUS_LABELS: Record<string, string> = {\n open: 'Open',\n won: 'Won',\n lost: 'Lost',\n}\n\n/** As much of a deal row as the file reads. */\nexport interface DealCsvRow {\n title?: string\n pipelineId?: string\n stageId?: string\n status?: string\n amountCents?: number\n currency?: string\n ownerUid?: string\n expectedCloseAtMs?: number | null\n closedAtMs?: number | null\n contactName?: string\n companyName?: string\n lostReason?: string\n notes?: string\n}\n\nexport interface DealCsvOptions {\n /** The pipeline's name for a stored id; absent, the id is written. */\n pipelineName?: (pipelineId: string) => string | undefined\n /** The stage's name within its pipeline; absent, the stage id is written. */\n stageName?: (pipelineId: string, stageId: string) => string | undefined\n /** The owner's address for a stored uid; absent, the uid is written. */\n ownerEmail?: (uid: string) => string\n}\n\nexport const DEAL_CSV_COLUMNS = [\n 'Title',\n 'Pipeline',\n 'Stage',\n 'Amount',\n 'Currency',\n 'Owner',\n 'Expected close',\n 'Status',\n 'Contact',\n 'Company',\n 'Closed',\n 'Lost reason',\n 'Notes',\n] as const\n\n/** One deal's cells, in the header's order. */\nexport function dealCsvCells(\n deal: DealCsvRow,\n options: DealCsvOptions = {},\n): unknown[] {\n const { pipelineName, stageName, ownerEmail } = options\n const pipelineId = deal.pipelineId ?? ''\n const stageId = deal.stageId ?? ''\n return [\n deal.title ?? '',\n pipelineName?.(pipelineId) ?? pipelineId,\n stageName?.(pipelineId, stageId) ?? stageId,\n csvAmount(deal.amountCents),\n typeof deal.amountCents === 'number'\n ? String(deal.currency || CSV_DEFAULT_DEAL_CURRENCY).toUpperCase()\n : '',\n deal.ownerUid ? (ownerEmail?.(deal.ownerUid) ?? deal.ownerUid) : '',\n // A close DATE, in the reader's own zone — stored at local noon, so\n // the calendar day is what comes back out.\n csvLocalDate(deal.expectedCloseAtMs),\n deal.status ? (CSV_DEAL_STATUS_LABELS[deal.status] ?? deal.status) : '',\n deal.contactName ?? '',\n deal.companyName ?? '',\n csvInstant(deal.closedAtMs),\n deal.lostReason ?? '',\n deal.notes ?? '',\n ]\n}\n\n/** The whole file, header first. */\nexport function dealsCsv(\n rows: readonly DealCsvRow[],\n options: DealCsvOptions = {},\n): string {\n return csvDocument(\n DEAL_CSV_COLUMNS,\n rows.map((deal) => dealCsvCells(deal, options)),\n )\n}\n\n/*==========================================\n * TASKS\n *=========================================*/\n\n/** As much of a task row as the file reads. */\nexport type TaskCsvRow = Partial<\n Pick<\n CrmTask,\n | 'title'\n | 'kind'\n | 'priority'\n | 'status'\n | 'dueAtMs'\n | 'completedAtMs'\n | 'assigneeUid'\n | 'contactId'\n | 'companyId'\n | 'dealId'\n | 'notes'\n >\n>\n\nexport interface TaskCsvOptions {\n /** The assignee's address for a stored uid; absent, the uid is written. */\n assigneeEmail?: (uid: string) => string\n /** What a linked record is called; absent, the id is written. */\n recordName?: (kind: 'contact' | 'company' | 'deal', id: string) => string | undefined\n}\n\nexport const TASK_CSV_COLUMNS = [\n 'Title',\n 'Kind',\n 'Priority',\n 'Status',\n 'Due',\n 'Assignee',\n 'Contact',\n 'Company',\n 'Deal',\n 'Completed',\n 'Notes',\n] as const\n\nconst TASK_PRIORITY_LABELS: Record<string, string> = {\n low: 'Low',\n normal: 'Normal',\n high: 'High',\n}\n\nconst TASK_STATUS_LABELS: Record<string, string> = {\n open: 'Open',\n done: 'Done',\n}\n\n/** One task's cells, in the header's order. */\nexport function taskCsvCells(\n task: TaskCsvRow,\n options: TaskCsvOptions = {},\n): unknown[] {\n const { assigneeEmail, recordName } = options\n const link = (kind: 'contact' | 'company' | 'deal', id: string | undefined) =>\n named(id, (value) => recordName?.(kind, value))\n return [\n task.title ?? '',\n task.kind ? (CRM_TASK_KIND_LABELS[task.kind] ?? task.kind) : '',\n task.priority ? (TASK_PRIORITY_LABELS[task.priority] ?? task.priority) : '',\n task.status ? (TASK_STATUS_LABELS[task.status] ?? task.status) : '',\n csvInstant(task.dueAtMs),\n task.assigneeUid ? (assigneeEmail?.(task.assigneeUid) ?? task.assigneeUid) : '',\n link('contact', task.contactId),\n link('company', task.companyId),\n link('deal', task.dealId),\n csvInstant(task.completedAtMs),\n task.notes ?? '',\n ]\n}\n\n/** The whole file, header first. */\nexport function tasksCsv(\n rows: readonly TaskCsvRow[],\n options: TaskCsvOptions = {},\n): string {\n return csvDocument(\n TASK_CSV_COLUMNS,\n rows.map((task) => taskCsvCells(task, options)),\n )\n}\n\n/*==========================================\n * LEADS\n *=========================================*/\n\n/** How one capture surface reads in a file. */\nexport function csvLeadSourceLabel(source: string): string {\n if (source === 'signup') return 'Sign-up'\n if (source === 'booking') return 'Booking'\n if (source === 'import') return CONTACT_SOURCE_LABELS.import\n if (source === 'form') return CONTACT_SOURCE_LABELS.form\n // A lead entered by hand or over the REST API (AGL-3231).\n if (source === 'manual') return 'Added by hand'\n if (source === 'api') return 'API'\n if (source.startsWith('form:')) return `Form ${source.slice('form:'.length)}`\n return source\n}\n\n/** Every surface that produced a capture — the array, or the older single field. */\nexport function csvLeadSources(lead: Record<string, unknown>): string[] {\n const sources = lead['sources']\n if (Array.isArray(sources) && sources.length) {\n return sources.map((source) => String(source))\n }\n return typeof lead['source'] === 'string' && lead['source'] ? [lead['source']] : []\n}\n\n/** As much of a lead row as the file reads. */\nexport type LeadCsvRow = Record<string, unknown> &\n Pick<\n CrmLeadFields,\n | 'status'\n | 'ownerUid'\n | 'notes'\n | 'unqualifiedReason'\n | 'convertedAtMs'\n | 'company'\n | 'jobTitle'\n | 'phone'\n | 'website'\n | 'address'\n | 'tags'\n | 'leadSource'\n > & {\n /** The site the lead lives under — what the `Site` column names. */\n hostId?: string\n }\n\nexport interface LeadCsvOptions {\n /** The owner's address for a stored uid; absent, the uid is written. */\n ownerEmail?: (uid: string) => string\n /**\n * The site's name for a row's `hostId`. Given, the file carries a `Site`\n * column — the organization-level file; absent, it does not.\n */\n siteName?: (hostId: string) => string | undefined\n}\n\n/**\n * The columns every leads file carries, in the list's order.\n *\n * The lead's own profile (AGL-3231) — company, title, phone, website, the\n * address parts, tags and the lead source — sits where the contacts file\n * keeps the same facts, under the same headers, so one spreadsheet of\n * people reads into either import with the same mapping.\n */\nexport const LEAD_CSV_COLUMNS = [\n 'Email',\n 'Name',\n 'Company',\n 'Job title',\n 'Phone',\n 'Website',\n 'Status',\n 'Owner',\n 'Lead source',\n 'Sources',\n 'First seen',\n 'Last seen',\n 'Captures',\n 'Address line 1',\n 'Address line 2',\n 'City',\n 'State',\n 'Postal code',\n 'Country',\n 'Tags',\n 'Unqualified reason',\n 'Converted',\n 'Notes',\n] as const\n\n/** The header row: the standard columns, with `Site` after `Owner` at the org level. */\nexport function leadCsvHeader(options: LeadCsvOptions = {}): string[] {\n const columns: string[] = [...LEAD_CSV_COLUMNS]\n if (options.siteName) columns.splice(columns.indexOf('Owner') + 1, 0, 'Site')\n return columns\n}\n\n/** One lead's cells, in the header's order. */\nexport function leadCsvCells(\n lead: LeadCsvRow,\n options: LeadCsvOptions = {},\n): unknown[] {\n const { ownerEmail, siteName } = options\n const hostId = String(lead.hostId ?? '')\n const captures = Number(lead['submissionCount'] ?? 0)\n return [\n String(lead['email'] ?? ''),\n String(lead['name'] ?? ''),\n lead.company ?? '',\n lead.jobTitle ?? '',\n lead.phone ?? '',\n lead.website ?? '',\n CRM_LEAD_STATUS_LABELS[crmLeadStatus(lead)],\n lead.ownerUid ? (ownerEmail?.(lead.ownerUid) ?? lead.ownerUid) : '',\n ...(siteName ? [siteName(hostId) ?? hostId] : []),\n lead.leadSource ?? '',\n csvLeadSources(lead).map(csvLeadSourceLabel).join('|'),\n csvStoredInstant(lead['firstSeenAtMs'] ?? lead['createdAt']),\n csvStoredInstant(lead['lastSeenAtMs'] ?? lead['createdAt']),\n Number.isFinite(captures) && captures > 0 ? String(captures) : '',\n lead.address?.line1 ?? '',\n lead.address?.line2 ?? '',\n lead.address?.city ?? '',\n lead.address?.state ?? '',\n lead.address?.postalCode ?? '',\n lead.address?.country ?? '',\n (lead.tags ?? []).join('|'),\n lead.unqualifiedReason ?? '',\n csvInstant(lead.convertedAtMs),\n lead.notes ?? '',\n ]\n}\n\n/** The whole file, header first. */\nexport function leadsCsv(\n rows: readonly LeadCsvRow[],\n options: LeadCsvOptions = {},\n): string {\n return csvDocument(\n leadCsvHeader(options),\n rows.map((lead) => leadCsvCells(lead, options)),\n )\n}\n\n/*==========================================\n * WHAT THE WHOLE-COLLECTION EXPORT WRITES\n *=========================================*/\n\n/** The five tables a whole-collection export can be asked for. */\nexport const CRM_EXPORT_RESOURCES = [\n 'contacts',\n 'companies',\n 'deals',\n 'tasks',\n 'leads',\n] as const\n\nexport type CrmExportResource = (typeof CRM_EXPORT_RESOURCES)[number]\n\nexport function isCrmExportResource(value: unknown): value is CrmExportResource {\n return (\n typeof value === 'string' &&\n (CRM_EXPORT_RESOURCES as readonly string[]).includes(value)\n )\n}\n\n/** Every resolver any of the five writers takes, in one bag. */\nexport type CrmExportOptions = ContactCsvOptions &\n DealCsvOptions &\n TaskCsvOptions &\n LeadCsvOptions & { assigneeEmail?: (uid: string) => string }\n\n/** The header row one resource's file carries. */\nexport function crmExportHeader(\n resource: CrmExportResource,\n options: CrmExportOptions = {},\n): string[] {\n switch (resource) {\n case 'contacts':\n return contactCsvHeader(options.customFields)\n case 'companies':\n return [...COMPANY_CSV_COLUMNS]\n case 'deals':\n return [...DEAL_CSV_COLUMNS]\n case 'tasks':\n return [...TASK_CSV_COLUMNS]\n default:\n return leadCsvHeader(options)\n }\n}\n\n/**\n * One stored document's cells for one resource's file.\n *\n * The bridge the server route stands on: it holds a raw document rather\n * than the projected row a list built, and this is the one place that\n * turns the first into the second — so the streamed file and the file the\n * Export button writes are the same columns filled the same way.\n */\nexport function crmExportCells(\n resource: CrmExportResource,\n document: Record<string, unknown>,\n options: CrmExportOptions = {},\n): unknown[] {\n switch (resource) {\n case 'contacts':\n return contactCsvCells(document as ContactCsvRow, options)\n case 'companies':\n return companyCsvCells(document as CompanyCsvRow, options)\n case 'deals':\n return dealCsvCells(document as DealCsvRow, options)\n case 'tasks':\n return taskCsvCells(document as TaskCsvRow, options)\n default:\n return leadCsvCells(document as LeadCsvRow, options)\n }\n}\n\n/** What the contacts file needs to know about the holder it is written for. */\nexport interface CrmExportContactHolder {\n /** The consent group whose facet the file reads — a facet is per holder. */\n groupId: string\n /** The sites in that group, which bound the timeline the file reads. */\n hostIds: readonly string[]\n}\n\n/**\n * A stored contact document as the contacts file reads it.\n *\n * The projection the Contacts list makes before it writes its own file,\n * spelled here so the server can make the same one: a contact document\n * holds one facet per HOLDER, and every field the file writes but the\n * address itself — the phone, the company, the tags, the notes, the\n * lifecycle stage, the custom values — lives in the viewing holder's facet.\n * Reading them off the top of the document is how one business's file ends\n * up carrying another's notes.\n */\nexport function contactCsvRowFromDoc(\n document: Record<string, unknown>,\n holder: CrmExportContactHolder,\n): ContactCsvRow {\n const facet = readContactFacet(document, holder.groupId)\n return {\n email: typeof document['email'] === 'string' ? document['email'] : '',\n name: contactDisplayName(document, holder.groupId),\n phone: facet.phone ?? '',\n jobTitle: facet.jobTitle ?? '',\n companyName: facet.companyName ?? '',\n ownerUid: facet.ownerUid ?? '',\n lifecycleStage: isContactLifecycleStage(facet.lifecycleStage)\n ? facet.lifecycleStage\n : '',\n address: facet.address ?? null,\n tags: facet.tags ?? [],\n sources: facet.sources,\n interactions: interactionsForGroup(facet.interactions, holder.hostIds),\n ...(typeof facet.lastEmailEngagementAtMs === 'number' &&\n Number.isFinite(facet.lastEmailEngagementAtMs) &&\n facet.lastEmailEngagementAtMs > 0\n ? { lastEmailEngagementAtMs: facet.lastEmailEngagementAtMs }\n : {}),\n notes: facet.notes ?? '',\n custom: facet.custom ?? {},\n }\n}\n"],"names":["CONTACT_SOURCE_LABELS","contactDisplayName","interactionsForGroup","readContactFacet","CONTACT_LIFECYCLE_STAGE_LABELS","CRM_LEAD_STATUS_LABELS","CRM_TASK_KIND_LABELS","crmLeadStatus","isContactLifecycleStage","csvDocument","csvAmount","cents","Number","isFinite","toFixed","csvInstant","ms","Date","toISOString","csvLocalDate","offset","getTimezoneOffset","slice","csvStoredInstant","value","asDate","toDate","named","id","resolve","CONTACT_CSV_COLUMNS","contactCsvHeader","customFields","map","field","label","customCell","undefined","String","contactCsvCells","contact","options","ownerEmail","email","name","phone","jobTitle","companyName","ownerUid","lifecycleStage","address","line1","line2","city","state","postalCode","country","tags","join","Object","keys","sources","interactions","atMs","lastEmailEngagementAtMs","notes","custom","key","contactsCsv","rows","COMPANY_CSV_COLUMNS","companyCsvCells","company","domain","website","industry","contactsCount","companiesCsv","CSV_DEFAULT_DEAL_CURRENCY","CSV_DEAL_STATUS_LABELS","open","won","lost","DEAL_CSV_COLUMNS","dealCsvCells","deal","pipelineName","stageName","pipelineId","stageId","title","amountCents","currency","toUpperCase","expectedCloseAtMs","status","contactName","closedAtMs","lostReason","dealsCsv","TASK_CSV_COLUMNS","TASK_PRIORITY_LABELS","low","normal","high","TASK_STATUS_LABELS","done","taskCsvCells","task","assigneeEmail","recordName","link","kind","priority","dueAtMs","assigneeUid","contactId","companyId","dealId","completedAtMs","tasksCsv","csvLeadSourceLabel","source","import","form","startsWith","length","csvLeadSources","lead","Array","isArray","LEAD_CSV_COLUMNS","leadCsvHeader","columns","siteName","splice","indexOf","leadCsvCells","hostId","captures","leadSource","unqualifiedReason","convertedAtMs","leadsCsv","CRM_EXPORT_RESOURCES","isCrmExportResource","includes","crmExportHeader","resource","crmExportCells","document","contactCsvRowFromDoc","holder","facet","groupId","hostIds"],"mappings":"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"}
|
|
@@ -180,6 +180,16 @@ const asMs = (value)=>typeof value === 'number' && Number.isFinite(value) ? valu
|
|
|
180
180
|
label: 'Email address',
|
|
181
181
|
group: 'Lead'
|
|
182
182
|
},
|
|
183
|
+
{
|
|
184
|
+
key: 'lead.company',
|
|
185
|
+
label: 'Company',
|
|
186
|
+
group: 'Lead'
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
key: 'lead.title',
|
|
190
|
+
label: 'Job title',
|
|
191
|
+
group: 'Lead'
|
|
192
|
+
},
|
|
183
193
|
{
|
|
184
194
|
key: 'deal.name',
|
|
185
195
|
label: 'Deal name',
|
|
@@ -305,6 +315,11 @@ const text = (value)=>typeof value === 'string' ? value.trim() : '';
|
|
|
305
315
|
case 'email':
|
|
306
316
|
var _normalizeContactEmail1;
|
|
307
317
|
return (_normalizeContactEmail1 = normalizeContactEmail(lead['email'])) != null ? _normalizeContactEmail1 : '';
|
|
318
|
+
// The lead's own profile (AGL-3231): the company as text, the title.
|
|
319
|
+
case 'company':
|
|
320
|
+
return text(lead['company']);
|
|
321
|
+
case 'title':
|
|
322
|
+
return text(lead['jobTitle']);
|
|
308
323
|
default:
|
|
309
324
|
return '';
|
|
310
325
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/crm-email-templates.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 * EMAIL TEMPLATES, SNIPPETS AND MERGE FIELDS (AGL-2658).\n *\n * A one-to-one email is a letter, and a rep writes the same three letters\n * all week: the follow-up, the proposal cover note, the \"still keen?\". A\n * TEMPLATE is one of those letters kept under a name — a subject and a body\n * the dialog fills in whole. A SNIPPET is a paragraph kept under a name —\n * a signature block, a pricing line — inserted where the caret is. Both\n * carry MERGE FIELDS, `{{contact.firstName}}` and the like, which the send\n * route fills from the record the email is about, so a template written\n * once reads as a letter to each person it is sent to.\n *\n * ## One resolver, run twice\n *\n * The server resolves at send time, off the documents it has already read\n * to find the recipient, and what it writes to the timeline is the letter\n * as it left. The dialog runs the SAME resolver over the same documents to\n * show the rep what will go out, so the preview and the message cannot\n * disagree. The resolver is pure and lives here, beside the model, so\n * neither caller can drift into its own grammar.\n *\n * ## What a field with no value does\n *\n * It renders as nothing. A template that greets `{{contact.firstName}}` is\n * sent to a contact known only by email as \"Hi ,\" — which is why the dialog\n * counts the empty fields and says so before the rep clicks Send, and why\n * an unknown field name renders as nothing too rather than as its own\n * braces: a typo in a merge field must not reach a customer as `{{contct.\n * firstName}}`. Nothing is escaped, because the mail is plain text.\n *\n * ## Shared or personal\n *\n * A template is either the team's — every CRM editor may use and change it\n * — or one person's, listed for its owner alone. The rules gate the writes\n * (`cloud/firebase-firestore.rules`); the listing hides a colleague's\n * personal rows the way a private saved view is hidden (AGL-2617), because\n * a rule per reader would need a query per reader.\n *==========================================*/\n\nimport {\n contactDisplayName,\n normalizeContactEmail,\n readContactFacet,\n} from './contacts'\nimport { CRM_EMAIL_BODY_MAX, CRM_EMAIL_SUBJECT_MAX, type CrmScoped } from './crm'\n\n/** The most a template's name may hold. */\nexport const CRM_EMAIL_TEMPLATE_NAME_MAX = 80\n\n/**\n * The listing's window, and the ceiling the console's create respects.\n *\n * A LOOKUP rather than a list: the picker is a menu a rep opens, and two\n * hundred named letters for one workspace is past what anybody scrolls.\n * Bounded so the listen cannot grow with the org.\n */\nexport const CRM_EMAIL_TEMPLATES_LIMIT = 200\n\n/** A whole letter under a name, or a paragraph under one. */\nexport const CRM_EMAIL_TEMPLATE_KINDS = ['template', 'snippet'] as const\nexport type CrmEmailTemplateKind = (typeof CRM_EMAIL_TEMPLATE_KINDS)[number]\n\nexport const CRM_EMAIL_TEMPLATE_KIND_LABELS: Record<CrmEmailTemplateKind, string> = {\n template: 'Template',\n snippet: 'Snippet',\n}\n\nexport function isCrmEmailTemplateKind(value: unknown): value is CrmEmailTemplateKind {\n return (\n typeof value === 'string' &&\n (CRM_EMAIL_TEMPLATE_KINDS as readonly string[]).includes(value)\n )\n}\n\n/** The team's, or one person's. */\nexport const CRM_EMAIL_TEMPLATE_VISIBILITIES = ['shared', 'personal'] as const\nexport type CrmEmailTemplateVisibility = (typeof CRM_EMAIL_TEMPLATE_VISIBILITIES)[number]\n\nexport const CRM_EMAIL_TEMPLATE_VISIBILITY_LABELS: Record<CrmEmailTemplateVisibility, string> = {\n shared: 'Shared',\n personal: 'Personal',\n}\n\nexport function isCrmEmailTemplateVisibility(\n value: unknown,\n): value is CrmEmailTemplateVisibility {\n return (\n typeof value === 'string' &&\n (CRM_EMAIL_TEMPLATE_VISIBILITIES as readonly string[]).includes(value)\n )\n}\n\n/**\n * `orgs/{orgId}/crmEmailTemplates/{templateId}`.\n *\n * Scoped like a task (AGL-2637): under a site it carries that site's tokens\n * and `hostId`; written from the organization's own hub it carries the org\n * token and `hostId: null`, because a letter the whole workspace keeps is\n * not any one brand's. `ownerUid` is set on a personal template only, and\n * is the one member the listing shows it to.\n */\nexport interface CrmEmailTemplate extends Omit<CrmScoped, 'hostId'> {\n /** The site it was written under, or `null` for the organization's own. */\n hostId: string | null\n name: string\n /** The subject line a template fills in; `''` on a snippet. */\n subject: string\n /** Plain text, with merge fields — see {@link renderCrmMergeFields}. */\n body: string\n kind: CrmEmailTemplateKind\n visibility: CrmEmailTemplateVisibility\n /** The one member a personal template is listed for; absent on a shared one. */\n ownerUid?: string\n /** Who made it — `'api'` for a key, which has no uid. */\n createdByUid: string\n createdAtMs: number\n updatedAtMs: number\n}\n\n/** A template as a listener hands it back: the document plus its id. */\nexport type CrmEmailTemplateRow = CrmEmailTemplate & { $id: string }\n\n/**\n * Whether a template belongs in `uid`'s menu: every shared one, and the\n * personal ones they own. A colleague's personal template is readable under\n * the rules and hidden here — see the module header.\n */\nexport function crmEmailTemplateIsListed(\n template: Pick<CrmEmailTemplate, 'visibility' | 'ownerUid'>,\n uid: string | null | undefined,\n): boolean {\n if (template.visibility !== 'personal') return true\n return Boolean(uid) && template.ownerUid === uid\n}\n\nconst asText = (value: unknown, max: number): string =>\n typeof value === 'string' ? value.slice(0, max) : ''\n\nconst asMs = (value: unknown): number =>\n typeof value === 'number' && Number.isFinite(value) ? value : 0\n\n/**\n * A stored document held to shape, for a listener that reads it raw.\n *\n * Lenient where the rules are not: a kind or a visibility the union does not\n * name reads as the default, because a row some other writer produced is\n * still a letter somebody kept, and dropping it from the menu would lose it.\n */\nexport function normalizeCrmEmailTemplate(\n row: Record<string, unknown> | null | undefined,\n): CrmEmailTemplate {\n const data = row ?? {}\n const visibility = isCrmEmailTemplateVisibility(data['visibility'])\n ? data['visibility']\n : 'shared'\n const ownerUid = asText(data['ownerUid'], 128)\n return {\n name: asText(data['name'], CRM_EMAIL_TEMPLATE_NAME_MAX).trim(),\n subject: asText(data['subject'], CRM_EMAIL_SUBJECT_MAX),\n body: asText(data['body'], CRM_EMAIL_BODY_MAX),\n kind: isCrmEmailTemplateKind(data['kind']) ? data['kind'] : 'template',\n visibility,\n ...(visibility === 'personal' && ownerUid ? { ownerUid } : {}),\n createdByUid: asText(data['createdByUid'], 128),\n createdAtMs: asMs(data['createdAtMs']),\n updatedAtMs: asMs(data['updatedAtMs']),\n hostId: typeof data['hostId'] === 'string' ? data['hostId'] : null,\n visibleTo: Array.isArray(data['visibleTo'])\n ? (data['visibleTo'] as unknown[]).map(String)\n : [],\n createdAt: data['createdAt'],\n updatedAt: data['updatedAt'],\n }\n}\n\n/*==========================================\n * MERGE FIELDS.\n *==========================================*/\n\n/** The group a field belongs to, as the picker headings it. */\nexport type CrmMergeFieldGroup = 'Contact' | 'Lead' | 'Deal' | 'You' | 'Site'\n\nexport interface CrmMergeFieldDefinition {\n key: string\n label: string\n group: CrmMergeFieldGroup\n}\n\n/**\n * Every field the resolver knows, in the order the picker offers them.\n *\n * A fixed list rather than a path into the document, because a merge field\n * is a promise about what a person will read: `contact.title` has to mean\n * the job title on every send, whatever the document happens to call it\n * this year, and a free path would let a template reach into a note or a\n * consent map.\n */\nexport const CRM_MERGE_FIELDS: readonly CrmMergeFieldDefinition[] = [\n { key: 'contact.firstName', label: 'First name', group: 'Contact' },\n { key: 'contact.lastName', label: 'Last name', group: 'Contact' },\n { key: 'contact.name', label: 'Full name', group: 'Contact' },\n { key: 'contact.email', label: 'Email address', group: 'Contact' },\n { key: 'contact.company', label: 'Company', group: 'Contact' },\n { key: 'contact.title', label: 'Job title', group: 'Contact' },\n { key: 'lead.firstName', label: 'First name', group: 'Lead' },\n { key: 'lead.lastName', label: 'Last name', group: 'Lead' },\n { key: 'lead.name', label: 'Full name', group: 'Lead' },\n { key: 'lead.email', label: 'Email address', group: 'Lead' },\n { key: 'deal.name', label: 'Deal name', group: 'Deal' },\n { key: 'deal.amount', label: 'Deal amount', group: 'Deal' },\n { key: 'sender.firstName', label: 'Your first name', group: 'You' },\n { key: 'sender.name', label: 'Your name', group: 'You' },\n { key: 'sender.email', label: 'Your email address', group: 'You' },\n { key: 'site.name', label: 'Site name', group: 'Site' },\n]\n\n/** `{{key}}` — the one spelling the resolver reads and the picker inserts. */\nexport function crmMergeFieldToken(key: string): string {\n return `{{${key}}}`\n}\n\n/**\n * What the resolver reads from: the documents as they are stored, so the\n * send route hands over what it already read and the dialog what it\n * fetched for the preview, with no projection in between to drift.\n */\nexport interface CrmMergeContext {\n /** The contact document, `orgs/{orgId}/contacts/{id}`. */\n contact?: Record<string, unknown> | null\n /**\n * The holder group whose facet names the contact — the sending site's,\n * since a name, a job title and a company are one holder's knowledge of\n * a person (`readContactFacet`). The canonical name is the fallback.\n */\n contactGroupId?: string | null\n /** The lead document, `hosts/{hostId}/leads/{id}`. */\n lead?: Record<string, unknown> | null\n /** The deal document, `orgs/{orgId}/deals/{id}`. */\n deal?: Record<string, unknown> | null\n /** The person sending, as their token or session names them. */\n sender?: { name?: string | null; email?: string | null } | null\n /** The site the email leaves from. */\n site?: { name?: string | null } | null\n /**\n * Fields a caller brings from a record of its own, keyed by the whole\n * `group.field` name — a plugin's `enrollment.personalLine`, say.\n *\n * Read only for a group the resolver above does not know, so no caller can\n * change what `contact.email` means. The fixed list is a promise about what\n * a person will read, and this is where a caller that makes its own promise\n * keeps it: the value is passed as written, never looked up by path.\n */\n extra?: Readonly<Record<string, string | null | undefined>> | null\n}\n\nexport interface CrmMergeResult {\n /** The text with every field replaced — by its value, or by nothing. */\n text: string\n /** The fields that rendered as nothing, unique, in order of appearance. */\n unresolved: string[]\n}\n\n/*\n * `{{ group.field }}`, spaces allowed inside the braces because a rep will\n * type them. A single brace pair or a bare word is left alone: a letter may\n * legitimately contain braces.\n */\nconst MERGE_FIELD_PATTERN = /\\{\\{\\s*([A-Za-z][A-Za-z0-9]*\\.[A-Za-z][A-Za-z0-9]*)\\s*\\}\\}/g\n\n/** The merge fields a text names, unique, in order of first appearance. */\nexport function crmMergeFieldsIn(text: string): string[] {\n const seen = new Set<string>()\n const fields: string[] = []\n for (const match of String(text ?? '').matchAll(MERGE_FIELD_PATTERN)) {\n const key = match[1]\n if (seen.has(key)) continue\n seen.add(key)\n fields.push(key)\n }\n return fields\n}\n\nexport function hasCrmMergeFields(text: string): boolean {\n MERGE_FIELD_PATTERN.lastIndex = 0\n return MERGE_FIELD_PATTERN.test(String(text ?? ''))\n}\n\n/** A name as one string → its first word and the rest. */\nexport function splitPersonName(name: unknown): { firstName: string; lastName: string } {\n const words = String(name ?? '')\n .trim()\n .split(/\\s+/)\n .filter(Boolean)\n return {\n firstName: words[0] ?? '',\n lastName: words.slice(1).join(' '),\n }\n}\n\n/**\n * A deal's amount as a letter prints it.\n *\n * A fixed locale rather than the reader's, on purpose: the server has no\n * reader, and the preview has to print what the server will send. Cents\n * that are not a number render as nothing, so a deal with no value greets\n * nobody with \"$0.00\".\n */\nexport function formatCrmMergeAmount(cents: unknown, currency: unknown): string {\n if (typeof cents !== 'number' || !Number.isFinite(cents)) return ''\n const code = String(currency || 'usd').toUpperCase()\n const amount = cents / 100\n try {\n return new Intl.NumberFormat('en-US', { style: 'currency', currency: code }).format(amount)\n } catch {\n return `${amount.toFixed(2)} ${code}`\n }\n}\n\nconst text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')\n\n/** One field's value from the context, or `''` for anything it cannot answer. */\nfunction resolveCrmMergeField(key: string, context: CrmMergeContext): string {\n const [group, field] = key.split('.')\n switch (group) {\n case 'contact': {\n const contact = context.contact\n if (!contact) return ''\n const groupId = text(context.contactGroupId)\n const facet = readContactFacet(contact, groupId)\n const name = groupId\n ? contactDisplayName(contact, groupId)\n : text(contact['name'])\n switch (field) {\n case 'firstName':\n return splitPersonName(name).firstName\n case 'lastName':\n return splitPersonName(name).lastName\n case 'name':\n return name\n case 'email':\n return normalizeContactEmail(contact['email']) ?? ''\n case 'company':\n return text(facet.companyName)\n case 'title':\n return text(facet.jobTitle)\n default:\n return ''\n }\n }\n case 'lead': {\n const lead = context.lead\n if (!lead) return ''\n switch (field) {\n case 'firstName':\n return splitPersonName(lead['name']).firstName\n case 'lastName':\n return splitPersonName(lead['name']).lastName\n case 'name':\n return text(lead['name'])\n case 'email':\n return normalizeContactEmail(lead['email']) ?? ''\n default:\n return ''\n }\n }\n case 'deal': {\n const deal = context.deal\n if (!deal) return ''\n switch (field) {\n case 'name':\n return text(deal['title'])\n case 'amount':\n return formatCrmMergeAmount(deal['amountCents'], deal['currency'])\n default:\n return ''\n }\n }\n case 'sender': {\n const sender = context.sender\n if (!sender) return ''\n switch (field) {\n case 'firstName':\n return splitPersonName(sender.name).firstName\n case 'name':\n return text(sender.name)\n case 'email':\n return normalizeContactEmail(sender.email) ?? ''\n default:\n return ''\n }\n }\n case 'site':\n return field === 'name' ? text(context.site?.name) : ''\n default: {\n const extra = context.extra\n return extra && Object.prototype.hasOwnProperty.call(extra, key)\n ? text(extra[key])\n : ''\n }\n }\n}\n\n/**\n * Every merge field in `text` replaced from `context`, and the ones that\n * had nothing to say. Pure, and never throws: a missing record, an unknown\n * field, a malformed context each render as nothing and are named in\n * `unresolved`.\n */\nexport function resolveCrmMergeFields(\n text: string,\n context: CrmMergeContext | null | undefined,\n): CrmMergeResult {\n const source = String(text ?? '')\n const ctx = context ?? {}\n const unresolved = new Set<string>()\n const rendered = source.replace(MERGE_FIELD_PATTERN, (_match, key: string) => {\n const value = resolveCrmMergeField(key, ctx)\n if (!value) unresolved.add(key)\n return value\n })\n return { text: rendered, unresolved: [...unresolved] }\n}\n\n/** {@link resolveCrmMergeFields}, text alone — what the send route sends. */\nexport function renderCrmMergeFields(\n text: string,\n context: CrmMergeContext | null | undefined,\n): string {\n return resolveCrmMergeFields(text, context).text\n}\n\n/** What the dialog says under a draft whose fields have nothing to fill. */\nexport function crmMergeUnresolvedMessage(unresolved: readonly string[]): string {\n if (unresolved.length === 0) return ''\n const count = unresolved.length\n const noun = count === 1 ? '1 field has no value' : `${count} fields have no value`\n return `${noun}: ${unresolved.map(crmMergeFieldToken).join(', ')}`\n}\n"],"names":["contactDisplayName","normalizeContactEmail","readContactFacet","CRM_EMAIL_BODY_MAX","CRM_EMAIL_SUBJECT_MAX","CRM_EMAIL_TEMPLATE_NAME_MAX","CRM_EMAIL_TEMPLATES_LIMIT","CRM_EMAIL_TEMPLATE_KINDS","CRM_EMAIL_TEMPLATE_KIND_LABELS","template","snippet","isCrmEmailTemplateKind","value","includes","CRM_EMAIL_TEMPLATE_VISIBILITIES","CRM_EMAIL_TEMPLATE_VISIBILITY_LABELS","shared","personal","isCrmEmailTemplateVisibility","crmEmailTemplateIsListed","uid","visibility","Boolean","ownerUid","asText","max","slice","asMs","Number","isFinite","normalizeCrmEmailTemplate","row","data","name","trim","subject","body","kind","createdByUid","createdAtMs","updatedAtMs","hostId","visibleTo","Array","isArray","map","String","createdAt","updatedAt","CRM_MERGE_FIELDS","key","label","group","crmMergeFieldToken","MERGE_FIELD_PATTERN","crmMergeFieldsIn","text","seen","Set","fields","match","matchAll","has","add","push","hasCrmMergeFields","lastIndex","test","splitPersonName","words","split","filter","firstName","lastName","join","formatCrmMergeAmount","cents","currency","code","toUpperCase","amount","Intl","NumberFormat","style","format","toFixed","resolveCrmMergeField","context","field","contact","groupId","contactGroupId","facet","companyName","jobTitle","lead","deal","sender","email","site","extra","Object","prototype","hasOwnProperty","call","resolveCrmMergeFields","source","ctx","unresolved","rendered","replace","_match","renderCrmMergeFields","crmMergeUnresolvedMessage","length","count","noun"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/crm-email-templates.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 * EMAIL TEMPLATES, SNIPPETS AND MERGE FIELDS (AGL-2658).\n *\n * A one-to-one email is a letter, and a rep writes the same three letters\n * all week: the follow-up, the proposal cover note, the \"still keen?\". A\n * TEMPLATE is one of those letters kept under a name — a subject and a body\n * the dialog fills in whole. A SNIPPET is a paragraph kept under a name —\n * a signature block, a pricing line — inserted where the caret is. Both\n * carry MERGE FIELDS, `{{contact.firstName}}` and the like, which the send\n * route fills from the record the email is about, so a template written\n * once reads as a letter to each person it is sent to.\n *\n * ## One resolver, run twice\n *\n * The server resolves at send time, off the documents it has already read\n * to find the recipient, and what it writes to the timeline is the letter\n * as it left. The dialog runs the SAME resolver over the same documents to\n * show the rep what will go out, so the preview and the message cannot\n * disagree. The resolver is pure and lives here, beside the model, so\n * neither caller can drift into its own grammar.\n *\n * ## What a field with no value does\n *\n * It renders as nothing. A template that greets `{{contact.firstName}}` is\n * sent to a contact known only by email as \"Hi ,\" — which is why the dialog\n * counts the empty fields and says so before the rep clicks Send, and why\n * an unknown field name renders as nothing too rather than as its own\n * braces: a typo in a merge field must not reach a customer as `{{contct.\n * firstName}}`. Nothing is escaped, because the mail is plain text.\n *\n * ## Shared or personal\n *\n * A template is either the team's — every CRM editor may use and change it\n * — or one person's, listed for its owner alone. The rules gate the writes\n * (`cloud/firebase-firestore.rules`); the listing hides a colleague's\n * personal rows the way a private saved view is hidden (AGL-2617), because\n * a rule per reader would need a query per reader.\n *==========================================*/\n\nimport {\n contactDisplayName,\n normalizeContactEmail,\n readContactFacet,\n} from './contacts'\nimport { CRM_EMAIL_BODY_MAX, CRM_EMAIL_SUBJECT_MAX, type CrmScoped } from './crm'\n\n/** The most a template's name may hold. */\nexport const CRM_EMAIL_TEMPLATE_NAME_MAX = 80\n\n/**\n * The listing's window, and the ceiling the console's create respects.\n *\n * A LOOKUP rather than a list: the picker is a menu a rep opens, and two\n * hundred named letters for one workspace is past what anybody scrolls.\n * Bounded so the listen cannot grow with the org.\n */\nexport const CRM_EMAIL_TEMPLATES_LIMIT = 200\n\n/** A whole letter under a name, or a paragraph under one. */\nexport const CRM_EMAIL_TEMPLATE_KINDS = ['template', 'snippet'] as const\nexport type CrmEmailTemplateKind = (typeof CRM_EMAIL_TEMPLATE_KINDS)[number]\n\nexport const CRM_EMAIL_TEMPLATE_KIND_LABELS: Record<CrmEmailTemplateKind, string> = {\n template: 'Template',\n snippet: 'Snippet',\n}\n\nexport function isCrmEmailTemplateKind(value: unknown): value is CrmEmailTemplateKind {\n return (\n typeof value === 'string' &&\n (CRM_EMAIL_TEMPLATE_KINDS as readonly string[]).includes(value)\n )\n}\n\n/** The team's, or one person's. */\nexport const CRM_EMAIL_TEMPLATE_VISIBILITIES = ['shared', 'personal'] as const\nexport type CrmEmailTemplateVisibility = (typeof CRM_EMAIL_TEMPLATE_VISIBILITIES)[number]\n\nexport const CRM_EMAIL_TEMPLATE_VISIBILITY_LABELS: Record<CrmEmailTemplateVisibility, string> = {\n shared: 'Shared',\n personal: 'Personal',\n}\n\nexport function isCrmEmailTemplateVisibility(\n value: unknown,\n): value is CrmEmailTemplateVisibility {\n return (\n typeof value === 'string' &&\n (CRM_EMAIL_TEMPLATE_VISIBILITIES as readonly string[]).includes(value)\n )\n}\n\n/**\n * `orgs/{orgId}/crmEmailTemplates/{templateId}`.\n *\n * Scoped like a task (AGL-2637): under a site it carries that site's tokens\n * and `hostId`; written from the organization's own hub it carries the org\n * token and `hostId: null`, because a letter the whole workspace keeps is\n * not any one brand's. `ownerUid` is set on a personal template only, and\n * is the one member the listing shows it to.\n */\nexport interface CrmEmailTemplate extends Omit<CrmScoped, 'hostId'> {\n /** The site it was written under, or `null` for the organization's own. */\n hostId: string | null\n name: string\n /** The subject line a template fills in; `''` on a snippet. */\n subject: string\n /** Plain text, with merge fields — see {@link renderCrmMergeFields}. */\n body: string\n kind: CrmEmailTemplateKind\n visibility: CrmEmailTemplateVisibility\n /** The one member a personal template is listed for; absent on a shared one. */\n ownerUid?: string\n /** Who made it — `'api'` for a key, which has no uid. */\n createdByUid: string\n createdAtMs: number\n updatedAtMs: number\n}\n\n/** A template as a listener hands it back: the document plus its id. */\nexport type CrmEmailTemplateRow = CrmEmailTemplate & { $id: string }\n\n/**\n * Whether a template belongs in `uid`'s menu: every shared one, and the\n * personal ones they own. A colleague's personal template is readable under\n * the rules and hidden here — see the module header.\n */\nexport function crmEmailTemplateIsListed(\n template: Pick<CrmEmailTemplate, 'visibility' | 'ownerUid'>,\n uid: string | null | undefined,\n): boolean {\n if (template.visibility !== 'personal') return true\n return Boolean(uid) && template.ownerUid === uid\n}\n\nconst asText = (value: unknown, max: number): string =>\n typeof value === 'string' ? value.slice(0, max) : ''\n\nconst asMs = (value: unknown): number =>\n typeof value === 'number' && Number.isFinite(value) ? value : 0\n\n/**\n * A stored document held to shape, for a listener that reads it raw.\n *\n * Lenient where the rules are not: a kind or a visibility the union does not\n * name reads as the default, because a row some other writer produced is\n * still a letter somebody kept, and dropping it from the menu would lose it.\n */\nexport function normalizeCrmEmailTemplate(\n row: Record<string, unknown> | null | undefined,\n): CrmEmailTemplate {\n const data = row ?? {}\n const visibility = isCrmEmailTemplateVisibility(data['visibility'])\n ? data['visibility']\n : 'shared'\n const ownerUid = asText(data['ownerUid'], 128)\n return {\n name: asText(data['name'], CRM_EMAIL_TEMPLATE_NAME_MAX).trim(),\n subject: asText(data['subject'], CRM_EMAIL_SUBJECT_MAX),\n body: asText(data['body'], CRM_EMAIL_BODY_MAX),\n kind: isCrmEmailTemplateKind(data['kind']) ? data['kind'] : 'template',\n visibility,\n ...(visibility === 'personal' && ownerUid ? { ownerUid } : {}),\n createdByUid: asText(data['createdByUid'], 128),\n createdAtMs: asMs(data['createdAtMs']),\n updatedAtMs: asMs(data['updatedAtMs']),\n hostId: typeof data['hostId'] === 'string' ? data['hostId'] : null,\n visibleTo: Array.isArray(data['visibleTo'])\n ? (data['visibleTo'] as unknown[]).map(String)\n : [],\n createdAt: data['createdAt'],\n updatedAt: data['updatedAt'],\n }\n}\n\n/*==========================================\n * MERGE FIELDS.\n *==========================================*/\n\n/** The group a field belongs to, as the picker headings it. */\nexport type CrmMergeFieldGroup = 'Contact' | 'Lead' | 'Deal' | 'You' | 'Site'\n\nexport interface CrmMergeFieldDefinition {\n key: string\n label: string\n group: CrmMergeFieldGroup\n}\n\n/**\n * Every field the resolver knows, in the order the picker offers them.\n *\n * A fixed list rather than a path into the document, because a merge field\n * is a promise about what a person will read: `contact.title` has to mean\n * the job title on every send, whatever the document happens to call it\n * this year, and a free path would let a template reach into a note or a\n * consent map.\n */\nexport const CRM_MERGE_FIELDS: readonly CrmMergeFieldDefinition[] = [\n { key: 'contact.firstName', label: 'First name', group: 'Contact' },\n { key: 'contact.lastName', label: 'Last name', group: 'Contact' },\n { key: 'contact.name', label: 'Full name', group: 'Contact' },\n { key: 'contact.email', label: 'Email address', group: 'Contact' },\n { key: 'contact.company', label: 'Company', group: 'Contact' },\n { key: 'contact.title', label: 'Job title', group: 'Contact' },\n { key: 'lead.firstName', label: 'First name', group: 'Lead' },\n { key: 'lead.lastName', label: 'Last name', group: 'Lead' },\n { key: 'lead.name', label: 'Full name', group: 'Lead' },\n { key: 'lead.email', label: 'Email address', group: 'Lead' },\n { key: 'lead.company', label: 'Company', group: 'Lead' },\n { key: 'lead.title', label: 'Job title', group: 'Lead' },\n { key: 'deal.name', label: 'Deal name', group: 'Deal' },\n { key: 'deal.amount', label: 'Deal amount', group: 'Deal' },\n { key: 'sender.firstName', label: 'Your first name', group: 'You' },\n { key: 'sender.name', label: 'Your name', group: 'You' },\n { key: 'sender.email', label: 'Your email address', group: 'You' },\n { key: 'site.name', label: 'Site name', group: 'Site' },\n]\n\n/** `{{key}}` — the one spelling the resolver reads and the picker inserts. */\nexport function crmMergeFieldToken(key: string): string {\n return `{{${key}}}`\n}\n\n/**\n * What the resolver reads from: the documents as they are stored, so the\n * send route hands over what it already read and the dialog what it\n * fetched for the preview, with no projection in between to drift.\n */\nexport interface CrmMergeContext {\n /** The contact document, `orgs/{orgId}/contacts/{id}`. */\n contact?: Record<string, unknown> | null\n /**\n * The holder group whose facet names the contact — the sending site's,\n * since a name, a job title and a company are one holder's knowledge of\n * a person (`readContactFacet`). The canonical name is the fallback.\n */\n contactGroupId?: string | null\n /** The lead document, `hosts/{hostId}/leads/{id}`. */\n lead?: Record<string, unknown> | null\n /** The deal document, `orgs/{orgId}/deals/{id}`. */\n deal?: Record<string, unknown> | null\n /** The person sending, as their token or session names them. */\n sender?: { name?: string | null; email?: string | null } | null\n /** The site the email leaves from. */\n site?: { name?: string | null } | null\n /**\n * Fields a caller brings from a record of its own, keyed by the whole\n * `group.field` name — a plugin's `enrollment.personalLine`, say.\n *\n * Read only for a group the resolver above does not know, so no caller can\n * change what `contact.email` means. The fixed list is a promise about what\n * a person will read, and this is where a caller that makes its own promise\n * keeps it: the value is passed as written, never looked up by path.\n */\n extra?: Readonly<Record<string, string | null | undefined>> | null\n}\n\nexport interface CrmMergeResult {\n /** The text with every field replaced — by its value, or by nothing. */\n text: string\n /** The fields that rendered as nothing, unique, in order of appearance. */\n unresolved: string[]\n}\n\n/*\n * `{{ group.field }}`, spaces allowed inside the braces because a rep will\n * type them. A single brace pair or a bare word is left alone: a letter may\n * legitimately contain braces.\n */\nconst MERGE_FIELD_PATTERN = /\\{\\{\\s*([A-Za-z][A-Za-z0-9]*\\.[A-Za-z][A-Za-z0-9]*)\\s*\\}\\}/g\n\n/** The merge fields a text names, unique, in order of first appearance. */\nexport function crmMergeFieldsIn(text: string): string[] {\n const seen = new Set<string>()\n const fields: string[] = []\n for (const match of String(text ?? '').matchAll(MERGE_FIELD_PATTERN)) {\n const key = match[1]\n if (seen.has(key)) continue\n seen.add(key)\n fields.push(key)\n }\n return fields\n}\n\nexport function hasCrmMergeFields(text: string): boolean {\n MERGE_FIELD_PATTERN.lastIndex = 0\n return MERGE_FIELD_PATTERN.test(String(text ?? ''))\n}\n\n/** A name as one string → its first word and the rest. */\nexport function splitPersonName(name: unknown): { firstName: string; lastName: string } {\n const words = String(name ?? '')\n .trim()\n .split(/\\s+/)\n .filter(Boolean)\n return {\n firstName: words[0] ?? '',\n lastName: words.slice(1).join(' '),\n }\n}\n\n/**\n * A deal's amount as a letter prints it.\n *\n * A fixed locale rather than the reader's, on purpose: the server has no\n * reader, and the preview has to print what the server will send. Cents\n * that are not a number render as nothing, so a deal with no value greets\n * nobody with \"$0.00\".\n */\nexport function formatCrmMergeAmount(cents: unknown, currency: unknown): string {\n if (typeof cents !== 'number' || !Number.isFinite(cents)) return ''\n const code = String(currency || 'usd').toUpperCase()\n const amount = cents / 100\n try {\n return new Intl.NumberFormat('en-US', { style: 'currency', currency: code }).format(amount)\n } catch {\n return `${amount.toFixed(2)} ${code}`\n }\n}\n\nconst text = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')\n\n/** One field's value from the context, or `''` for anything it cannot answer. */\nfunction resolveCrmMergeField(key: string, context: CrmMergeContext): string {\n const [group, field] = key.split('.')\n switch (group) {\n case 'contact': {\n const contact = context.contact\n if (!contact) return ''\n const groupId = text(context.contactGroupId)\n const facet = readContactFacet(contact, groupId)\n const name = groupId\n ? contactDisplayName(contact, groupId)\n : text(contact['name'])\n switch (field) {\n case 'firstName':\n return splitPersonName(name).firstName\n case 'lastName':\n return splitPersonName(name).lastName\n case 'name':\n return name\n case 'email':\n return normalizeContactEmail(contact['email']) ?? ''\n case 'company':\n return text(facet.companyName)\n case 'title':\n return text(facet.jobTitle)\n default:\n return ''\n }\n }\n case 'lead': {\n const lead = context.lead\n if (!lead) return ''\n switch (field) {\n case 'firstName':\n return splitPersonName(lead['name']).firstName\n case 'lastName':\n return splitPersonName(lead['name']).lastName\n case 'name':\n return text(lead['name'])\n case 'email':\n return normalizeContactEmail(lead['email']) ?? ''\n // The lead's own profile (AGL-3231): the company as text, the title.\n case 'company':\n return text(lead['company'])\n case 'title':\n return text(lead['jobTitle'])\n default:\n return ''\n }\n }\n case 'deal': {\n const deal = context.deal\n if (!deal) return ''\n switch (field) {\n case 'name':\n return text(deal['title'])\n case 'amount':\n return formatCrmMergeAmount(deal['amountCents'], deal['currency'])\n default:\n return ''\n }\n }\n case 'sender': {\n const sender = context.sender\n if (!sender) return ''\n switch (field) {\n case 'firstName':\n return splitPersonName(sender.name).firstName\n case 'name':\n return text(sender.name)\n case 'email':\n return normalizeContactEmail(sender.email) ?? ''\n default:\n return ''\n }\n }\n case 'site':\n return field === 'name' ? text(context.site?.name) : ''\n default: {\n const extra = context.extra\n return extra && Object.prototype.hasOwnProperty.call(extra, key)\n ? text(extra[key])\n : ''\n }\n }\n}\n\n/**\n * Every merge field in `text` replaced from `context`, and the ones that\n * had nothing to say. Pure, and never throws: a missing record, an unknown\n * field, a malformed context each render as nothing and are named in\n * `unresolved`.\n */\nexport function resolveCrmMergeFields(\n text: string,\n context: CrmMergeContext | null | undefined,\n): CrmMergeResult {\n const source = String(text ?? '')\n const ctx = context ?? {}\n const unresolved = new Set<string>()\n const rendered = source.replace(MERGE_FIELD_PATTERN, (_match, key: string) => {\n const value = resolveCrmMergeField(key, ctx)\n if (!value) unresolved.add(key)\n return value\n })\n return { text: rendered, unresolved: [...unresolved] }\n}\n\n/** {@link resolveCrmMergeFields}, text alone — what the send route sends. */\nexport function renderCrmMergeFields(\n text: string,\n context: CrmMergeContext | null | undefined,\n): string {\n return resolveCrmMergeFields(text, context).text\n}\n\n/** What the dialog says under a draft whose fields have nothing to fill. */\nexport function crmMergeUnresolvedMessage(unresolved: readonly string[]): string {\n if (unresolved.length === 0) return ''\n const count = unresolved.length\n const noun = count === 1 ? '1 field has no value' : `${count} fields have no value`\n return `${noun}: ${unresolved.map(crmMergeFieldToken).join(', ')}`\n}\n"],"names":["contactDisplayName","normalizeContactEmail","readContactFacet","CRM_EMAIL_BODY_MAX","CRM_EMAIL_SUBJECT_MAX","CRM_EMAIL_TEMPLATE_NAME_MAX","CRM_EMAIL_TEMPLATES_LIMIT","CRM_EMAIL_TEMPLATE_KINDS","CRM_EMAIL_TEMPLATE_KIND_LABELS","template","snippet","isCrmEmailTemplateKind","value","includes","CRM_EMAIL_TEMPLATE_VISIBILITIES","CRM_EMAIL_TEMPLATE_VISIBILITY_LABELS","shared","personal","isCrmEmailTemplateVisibility","crmEmailTemplateIsListed","uid","visibility","Boolean","ownerUid","asText","max","slice","asMs","Number","isFinite","normalizeCrmEmailTemplate","row","data","name","trim","subject","body","kind","createdByUid","createdAtMs","updatedAtMs","hostId","visibleTo","Array","isArray","map","String","createdAt","updatedAt","CRM_MERGE_FIELDS","key","label","group","crmMergeFieldToken","MERGE_FIELD_PATTERN","crmMergeFieldsIn","text","seen","Set","fields","match","matchAll","has","add","push","hasCrmMergeFields","lastIndex","test","splitPersonName","words","split","filter","firstName","lastName","join","formatCrmMergeAmount","cents","currency","code","toUpperCase","amount","Intl","NumberFormat","style","format","toFixed","resolveCrmMergeField","context","field","contact","groupId","contactGroupId","facet","companyName","jobTitle","lead","deal","sender","email","site","extra","Object","prototype","hasOwnProperty","call","resolveCrmMergeFields","source","ctx","unresolved","rendered","replace","_match","renderCrmMergeFields","crmMergeUnresolvedMessage","length","count","noun"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;4CAqC4C,GAE5C,SACEA,kBAAkB,EAClBC,qBAAqB,EACrBC,gBAAgB,QACX,gBAAY;AACnB,SAASC,kBAAkB,EAAEC,qBAAqB,QAAwB,WAAO;AAEjF,yCAAyC,GACzC,OAAO,MAAMC,8BAA8B,GAAE;AAE7C;;;;;;CAMC,GACD,OAAO,MAAMC,4BAA4B,IAAG;AAE5C,2DAA2D,GAC3D,OAAO,MAAMC,2BAA2B;IAAC;IAAY;CAAU,CAAS;AAGxE,OAAO,MAAMC,iCAAuE;IAClFC,UAAU;IACVC,SAAS;AACX,EAAC;AAED,OAAO,SAASC,uBAAuBC,KAAc;IACnD,OACE,OAAOA,UAAU,YACjB,AAACL,yBAA+CM,QAAQ,CAACD;AAE7D;AAEA,iCAAiC,GACjC,OAAO,MAAME,kCAAkC;IAAC;IAAU;CAAW,CAAS;AAG9E,OAAO,MAAMC,uCAAmF;IAC9FC,QAAQ;IACRC,UAAU;AACZ,EAAC;AAED,OAAO,SAASC,6BACdN,KAAc;IAEd,OACE,OAAOA,UAAU,YACjB,AAACE,gCAAsDD,QAAQ,CAACD;AAEpE;AAgCA;;;;CAIC,GACD,OAAO,SAASO,yBACdV,QAA2D,EAC3DW,GAA8B;IAE9B,IAAIX,SAASY,UAAU,KAAK,YAAY,OAAO;IAC/C,OAAOC,QAAQF,QAAQX,SAASc,QAAQ,KAAKH;AAC/C;AAEA,MAAMI,SAAS,CAACZ,OAAgBa,MAC9B,OAAOb,UAAU,WAAWA,MAAMc,KAAK,CAAC,GAAGD,OAAO;AAEpD,MAAME,OAAO,CAACf,QACZ,OAAOA,UAAU,YAAYgB,OAAOC,QAAQ,CAACjB,SAASA,QAAQ;AAEhE;;;;;;CAMC,GACD,OAAO,SAASkB,0BACdC,GAA+C;IAE/C,MAAMC,OAAOD,cAAAA,MAAO,CAAC;IACrB,MAAMV,aAAaH,6BAA6Bc,IAAI,CAAC,aAAa,IAC9DA,IAAI,CAAC,aAAa,GAClB;IACJ,MAAMT,WAAWC,OAAOQ,IAAI,CAAC,WAAW,EAAE;IAC1C,OAAO;QACLC,MAAMT,OAAOQ,IAAI,CAAC,OAAO,EAAE3B,6BAA6B6B,IAAI;QAC5DC,SAASX,OAAOQ,IAAI,CAAC,UAAU,EAAE5B;QACjCgC,MAAMZ,OAAOQ,IAAI,CAAC,OAAO,EAAE7B;QAC3BkC,MAAM1B,uBAAuBqB,IAAI,CAAC,OAAO,IAAIA,IAAI,CAAC,OAAO,GAAG;QAC5DX;OACIA,eAAe,cAAcE,WAAW;QAAEA;IAAS,IAAI,CAAC;QAC5De,cAAcd,OAAOQ,IAAI,CAAC,eAAe,EAAE;QAC3CO,aAAaZ,KAAKK,IAAI,CAAC,cAAc;QACrCQ,aAAab,KAAKK,IAAI,CAAC,cAAc;QACrCS,QAAQ,OAAOT,IAAI,CAAC,SAAS,KAAK,WAAWA,IAAI,CAAC,SAAS,GAAG;QAC9DU,WAAWC,MAAMC,OAAO,CAACZ,IAAI,CAAC,YAAY,IACtC,AAACA,IAAI,CAAC,YAAY,CAAea,GAAG,CAACC,UACrC,EAAE;QACNC,WAAWf,IAAI,CAAC,YAAY;QAC5BgB,WAAWhB,IAAI,CAAC,YAAY;;AAEhC;AAeA;;;;;;;;CAQC,GACD,OAAO,MAAMiB,mBAAuD;IAClE;QAAEC,KAAK;QAAqBC,OAAO;QAAcC,OAAO;IAAU;IAClE;QAAEF,KAAK;QAAoBC,OAAO;QAAaC,OAAO;IAAU;IAChE;QAAEF,KAAK;QAAgBC,OAAO;QAAaC,OAAO;IAAU;IAC5D;QAAEF,KAAK;QAAiBC,OAAO;QAAiBC,OAAO;IAAU;IACjE;QAAEF,KAAK;QAAmBC,OAAO;QAAWC,OAAO;IAAU;IAC7D;QAAEF,KAAK;QAAiBC,OAAO;QAAaC,OAAO;IAAU;IAC7D;QAAEF,KAAK;QAAkBC,OAAO;QAAcC,OAAO;IAAO;IAC5D;QAAEF,KAAK;QAAiBC,OAAO;QAAaC,OAAO;IAAO;IAC1D;QAAEF,KAAK;QAAaC,OAAO;QAAaC,OAAO;IAAO;IACtD;QAAEF,KAAK;QAAcC,OAAO;QAAiBC,OAAO;IAAO;IAC3D;QAAEF,KAAK;QAAgBC,OAAO;QAAWC,OAAO;IAAO;IACvD;QAAEF,KAAK;QAAcC,OAAO;QAAaC,OAAO;IAAO;IACvD;QAAEF,KAAK;QAAaC,OAAO;QAAaC,OAAO;IAAO;IACtD;QAAEF,KAAK;QAAeC,OAAO;QAAeC,OAAO;IAAO;IAC1D;QAAEF,KAAK;QAAoBC,OAAO;QAAmBC,OAAO;IAAM;IAClE;QAAEF,KAAK;QAAeC,OAAO;QAAaC,OAAO;IAAM;IACvD;QAAEF,KAAK;QAAgBC,OAAO;QAAsBC,OAAO;IAAM;IACjE;QAAEF,KAAK;QAAaC,OAAO;QAAaC,OAAO;IAAO;CACvD,CAAA;AAED,4EAA4E,GAC5E,OAAO,SAASC,mBAAmBH,GAAW;IAC5C,OAAO,CAAC,EAAE,EAAEA,IAAI,EAAE,CAAC;AACrB;AA2CA;;;;CAIC,GACD,MAAMI,sBAAsB;AAE5B,yEAAyE,GACzE,OAAO,SAASC,iBAAiBC,IAAY;IAC3C,MAAMC,OAAO,IAAIC;IACjB,MAAMC,SAAmB,EAAE;IAC3B,KAAK,MAAMC,SAASd,OAAOU,eAAAA,OAAQ,IAAIK,QAAQ,CAACP,qBAAsB;QACpE,MAAMJ,MAAMU,KAAK,CAAC,EAAE;QACpB,IAAIH,KAAKK,GAAG,CAACZ,MAAM;QACnBO,KAAKM,GAAG,CAACb;QACTS,OAAOK,IAAI,CAACd;IACd;IACA,OAAOS;AACT;AAEA,OAAO,SAASM,kBAAkBT,IAAY;IAC5CF,oBAAoBY,SAAS,GAAG;IAChC,OAAOZ,oBAAoBa,IAAI,CAACrB,OAAOU,eAAAA,OAAQ;AACjD;AAEA,wDAAwD,GACxD,OAAO,SAASY,gBAAgBnC,IAAa;QAM9BoC;IALb,MAAMA,QAAQvB,OAAOb,eAAAA,OAAQ,IAC1BC,IAAI,GACJoC,KAAK,CAAC,OACNC,MAAM,CAACjD;IACV,OAAO;QACLkD,SAAS,GAAEH,UAAAA,KAAK,CAAC,EAAE,YAARA,UAAY;QACvBI,UAAUJ,MAAM3C,KAAK,CAAC,GAAGgD,IAAI,CAAC;IAChC;AACF;AAEA;;;;;;;CAOC,GACD,OAAO,SAASC,qBAAqBC,KAAc,EAAEC,QAAiB;IACpE,IAAI,OAAOD,UAAU,YAAY,CAAChD,OAAOC,QAAQ,CAAC+C,QAAQ,OAAO;IACjE,MAAME,OAAOhC,OAAO+B,YAAY,OAAOE,WAAW;IAClD,MAAMC,SAASJ,QAAQ;IACvB,IAAI;QACF,OAAO,IAAIK,KAAKC,YAAY,CAAC,SAAS;YAAEC,OAAO;YAAYN,UAAUC;QAAK,GAAGM,MAAM,CAACJ;IACtF,EAAE,eAAM;QACN,OAAO,GAAGA,OAAOK,OAAO,CAAC,GAAG,CAAC,EAAEP,MAAM;IACvC;AACF;AAEA,MAAMtB,OAAO,CAAC5C,QAA4B,OAAOA,UAAU,WAAWA,MAAMsB,IAAI,KAAK;AAErF,+EAA+E,GAC/E,SAASoD,qBAAqBpC,GAAW,EAAEqC,OAAwB;IACjE,MAAM,CAACnC,OAAOoC,MAAM,GAAGtC,IAAIoB,KAAK,CAAC;IACjC,OAAQlB;QACN,KAAK;YAAW;gBACd,MAAMqC,UAAUF,QAAQE,OAAO;gBAC/B,IAAI,CAACA,SAAS,OAAO;gBACrB,MAAMC,UAAUlC,KAAK+B,QAAQI,cAAc;gBAC3C,MAAMC,QAAQ1F,iBAAiBuF,SAASC;gBACxC,MAAMzD,OAAOyD,UACT1F,mBAAmByF,SAASC,WAC5BlC,KAAKiC,OAAO,CAAC,OAAO;gBACxB,OAAQD;oBACN,KAAK;wBACH,OAAOpB,gBAAgBnC,MAAMuC,SAAS;oBACxC,KAAK;wBACH,OAAOJ,gBAAgBnC,MAAMwC,QAAQ;oBACvC,KAAK;wBACH,OAAOxC;oBACT,KAAK;4BACIhC;wBAAP,QAAOA,yBAAAA,sBAAsBwF,OAAO,CAAC,QAAQ,aAAtCxF,yBAA2C;oBACpD,KAAK;wBACH,OAAOuD,KAAKoC,MAAMC,WAAW;oBAC/B,KAAK;wBACH,OAAOrC,KAAKoC,MAAME,QAAQ;oBAC5B;wBACE,OAAO;gBACX;YACF;QACA,KAAK;YAAQ;gBACX,MAAMC,OAAOR,QAAQQ,IAAI;gBACzB,IAAI,CAACA,MAAM,OAAO;gBAClB,OAAQP;oBACN,KAAK;wBACH,OAAOpB,gBAAgB2B,IAAI,CAAC,OAAO,EAAEvB,SAAS;oBAChD,KAAK;wBACH,OAAOJ,gBAAgB2B,IAAI,CAAC,OAAO,EAAEtB,QAAQ;oBAC/C,KAAK;wBACH,OAAOjB,KAAKuC,IAAI,CAAC,OAAO;oBAC1B,KAAK;4BACI9F;wBAAP,QAAOA,0BAAAA,sBAAsB8F,IAAI,CAAC,QAAQ,aAAnC9F,0BAAwC;oBACjD,qEAAqE;oBACrE,KAAK;wBACH,OAAOuD,KAAKuC,IAAI,CAAC,UAAU;oBAC7B,KAAK;wBACH,OAAOvC,KAAKuC,IAAI,CAAC,WAAW;oBAC9B;wBACE,OAAO;gBACX;YACF;QACA,KAAK;YAAQ;gBACX,MAAMC,OAAOT,QAAQS,IAAI;gBACzB,IAAI,CAACA,MAAM,OAAO;gBAClB,OAAQR;oBACN,KAAK;wBACH,OAAOhC,KAAKwC,IAAI,CAAC,QAAQ;oBAC3B,KAAK;wBACH,OAAOrB,qBAAqBqB,IAAI,CAAC,cAAc,EAAEA,IAAI,CAAC,WAAW;oBACnE;wBACE,OAAO;gBACX;YACF;QACA,KAAK;YAAU;gBACb,MAAMC,SAASV,QAAQU,MAAM;gBAC7B,IAAI,CAACA,QAAQ,OAAO;gBACpB,OAAQT;oBACN,KAAK;wBACH,OAAOpB,gBAAgB6B,OAAOhE,IAAI,EAAEuC,SAAS;oBAC/C,KAAK;wBACH,OAAOhB,KAAKyC,OAAOhE,IAAI;oBACzB,KAAK;4BACIhC;wBAAP,QAAOA,0BAAAA,sBAAsBgG,OAAOC,KAAK,aAAlCjG,0BAAuC;oBAChD;wBACE,OAAO;gBACX;YACF;QACA,KAAK;gBAC4BsF;YAA/B,OAAOC,UAAU,SAAShC,MAAK+B,gBAAAA,QAAQY,IAAI,qBAAZZ,cAActD,IAAI,IAAI;QACvD;YAAS;gBACP,MAAMmE,QAAQb,QAAQa,KAAK;gBAC3B,OAAOA,SAASC,OAAOC,SAAS,CAACC,cAAc,CAACC,IAAI,CAACJ,OAAOlD,OACxDM,KAAK4C,KAAK,CAAClD,IAAI,IACf;YACN;IACF;AACF;AAEA;;;;;CAKC,GACD,OAAO,SAASuD,sBACdjD,IAAY,EACZ+B,OAA2C;IAE3C,MAAMmB,SAAS5D,OAAOU,eAAAA,OAAQ;IAC9B,MAAMmD,MAAMpB,kBAAAA,UAAW,CAAC;IACxB,MAAMqB,aAAa,IAAIlD;IACvB,MAAMmD,WAAWH,OAAOI,OAAO,CAACxD,qBAAqB,CAACyD,QAAQ7D;QAC5D,MAAMtC,QAAQ0E,qBAAqBpC,KAAKyD;QACxC,IAAI,CAAC/F,OAAOgG,WAAW7C,GAAG,CAACb;QAC3B,OAAOtC;IACT;IACA,OAAO;QAAE4C,MAAMqD;QAAUD,YAAY;eAAIA;SAAW;IAAC;AACvD;AAEA,2EAA2E,GAC3E,OAAO,SAASI,qBACdxD,IAAY,EACZ+B,OAA2C;IAE3C,OAAOkB,sBAAsBjD,MAAM+B,SAAS/B,IAAI;AAClD;AAEA,0EAA0E,GAC1E,OAAO,SAASyD,0BAA0BL,UAA6B;IACrE,IAAIA,WAAWM,MAAM,KAAK,GAAG,OAAO;IACpC,MAAMC,QAAQP,WAAWM,MAAM;IAC/B,MAAME,OAAOD,UAAU,IAAI,yBAAyB,GAAGA,MAAM,qBAAqB,CAAC;IACnF,OAAO,GAAGC,KAAK,EAAE,EAAER,WAAW/D,GAAG,CAACQ,oBAAoBqB,IAAI,CAAC,OAAO;AACpE"}
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
* Pure data module: types, constants and the small helpers every surface
|
|
44
44
|
* would otherwise write for itself. No Firestore, no React.
|
|
45
45
|
*/
|
|
46
|
-
import type
|
|
46
|
+
import { type AglynPostalAddress, type OrgCrmAssignmentRule } from '../foundation';
|
|
47
47
|
import { type ConsentGroup } from './consent-groups';
|
|
48
48
|
import { type ContactLifecycleStage, type CrmActivityKind, type CrmTaskKind } from './crm-kinds';
|
|
49
49
|
import { type ContactInteraction, type ContactSegment, type ContactSource } from './contacts';
|
|
@@ -438,6 +438,13 @@ export interface CrmTask extends Omit<CrmScoped, 'hostId'> {
|
|
|
438
438
|
contactId?: string;
|
|
439
439
|
companyId?: string;
|
|
440
440
|
dealId?: string;
|
|
441
|
+
/**
|
|
442
|
+
* `hosts/{hostId}/leads/{leadId}` — a task filed on a lead not yet
|
|
443
|
+
* converted (AGL-3233): a sequence's call step or a reply to answer. The
|
|
444
|
+
* conversion stamps the contact beside it, so the task is the contact's
|
|
445
|
+
* from then on and the lead's page still lists it.
|
|
446
|
+
*/
|
|
447
|
+
leadId?: string;
|
|
441
448
|
/**
|
|
442
449
|
* When the assignee is reminded (AGL-2659): the due time unless a person
|
|
443
450
|
* moved it, `null` for no reminder. The hourly `/api/crm/task-reminders`
|
|
@@ -1323,7 +1330,7 @@ export declare function isCrmLeadStatus(value: unknown): value is CrmLeadStatus;
|
|
|
1323
1330
|
* says. `unqualifiedReason` travels with `status: 'unqualified'` and is the
|
|
1324
1331
|
* one free-text field a report will want to read back.
|
|
1325
1332
|
*/
|
|
1326
|
-
export interface CrmLeadFields {
|
|
1333
|
+
export interface CrmLeadFields extends CrmLeadProfile {
|
|
1327
1334
|
status?: CrmLeadStatus;
|
|
1328
1335
|
/** The team member working the lead. */
|
|
1329
1336
|
ownerUid?: string;
|
|
@@ -1337,6 +1344,66 @@ export interface CrmLeadFields {
|
|
|
1337
1344
|
/** The company the conversion created or linked, when it named one. */
|
|
1338
1345
|
companyId?: string;
|
|
1339
1346
|
}
|
|
1347
|
+
/** The longest text one profile field holds — the contact's own cap. */
|
|
1348
|
+
export declare const CRM_LEAD_TEXT_MAX = 120;
|
|
1349
|
+
/** The most tags one lead keeps — the contact's own cap. */
|
|
1350
|
+
export declare const CRM_LEAD_TAGS_MAX = 20;
|
|
1351
|
+
/** The longest note a lead keeps — the lead page's own cap. */
|
|
1352
|
+
export declare const CRM_LEAD_NOTES_MAX = 4000;
|
|
1353
|
+
export interface CrmLeadProfile {
|
|
1354
|
+
/** The company's name, as text — see the block header. */
|
|
1355
|
+
company?: string;
|
|
1356
|
+
jobTitle?: string;
|
|
1357
|
+
/** E.164 — `normalizePhone` before writing. */
|
|
1358
|
+
phone?: string;
|
|
1359
|
+
/** An http(s) URL — `normalizeCompanyWebsite` before writing. */
|
|
1360
|
+
website?: string;
|
|
1361
|
+
address?: AglynPostalAddress | null;
|
|
1362
|
+
/** Lower-cased, deduplicated, at most {@link CRM_LEAD_TAGS_MAX}. */
|
|
1363
|
+
tags?: string[];
|
|
1364
|
+
/** Free text naming where the lead came from — Salesforce's Lead Source. */
|
|
1365
|
+
leadSource?: string;
|
|
1366
|
+
}
|
|
1367
|
+
/** The profile's keys, in the order a card lists them. */
|
|
1368
|
+
export declare const CRM_LEAD_PROFILE_KEYS: readonly ["company", "jobTitle", "phone", "website", "address", "tags", "leadSource"];
|
|
1369
|
+
export type CrmLeadProfileKey = (typeof CRM_LEAD_PROFILE_KEYS)[number];
|
|
1370
|
+
/**
|
|
1371
|
+
* A profile as a writer receives it: a key present is a value to store, a
|
|
1372
|
+
* key present and `null` is a field to clear, a key absent is left alone.
|
|
1373
|
+
* The PATCH semantics every CRM write has.
|
|
1374
|
+
*/
|
|
1375
|
+
export type CrmLeadProfilePatch = {
|
|
1376
|
+
[K in CrmLeadProfileKey]?: NonNullable<CrmLeadProfile[K]> | null;
|
|
1377
|
+
};
|
|
1378
|
+
/** Why one field of a profile could not be read, under the field. */
|
|
1379
|
+
export type CrmLeadProfileErrors = Partial<Record<CrmLeadProfileKey, string>>;
|
|
1380
|
+
/** The sentence an unreadable phone number is refused with, under the field. */
|
|
1381
|
+
export declare const CRM_LEAD_PHONE_REFUSAL: string;
|
|
1382
|
+
/** The sentence an unreadable website is refused with, under the field. */
|
|
1383
|
+
export declare const CRM_LEAD_WEBSITE_REFUSAL = "Enter a web address, like acme.com";
|
|
1384
|
+
/**
|
|
1385
|
+
* Tags as a lead stores them: lower-cased, trimmed, deduplicated, capped —
|
|
1386
|
+
* from an array or a comma-separated string, so a file cell and a form
|
|
1387
|
+
* field are one input.
|
|
1388
|
+
*/
|
|
1389
|
+
export declare function normalizeCrmLeadTags(value: unknown): string[];
|
|
1390
|
+
/**
|
|
1391
|
+
* Read a profile off an untrusted body, one field at a time.
|
|
1392
|
+
*
|
|
1393
|
+
* Every key the body names is answered: as a normalized value, as `null`
|
|
1394
|
+
* when the body cleared it (an empty string, `null`, an empty list, a blank
|
|
1395
|
+
* address), or as an error under the field when the value cannot be
|
|
1396
|
+
* stored as typed. Keys the body does not name are not in the answer, so
|
|
1397
|
+
* a caller can write exactly what was asked. A `website` is normalized the
|
|
1398
|
+
* way a company's is; a phone the way a contact's is; both refuse rather
|
|
1399
|
+
* than store a value the record would then render as a link to nowhere.
|
|
1400
|
+
*/
|
|
1401
|
+
export declare function normalizeCrmLeadProfile(input: Record<string, unknown> | null | undefined): {
|
|
1402
|
+
patch: CrmLeadProfilePatch;
|
|
1403
|
+
errors: CrmLeadProfileErrors;
|
|
1404
|
+
};
|
|
1405
|
+
/** The name a lead is listed under: the name it carries, else its address. */
|
|
1406
|
+
export declare function crmLeadDisplayName(lead: Record<string, unknown> | null | undefined): string;
|
|
1340
1407
|
/**
|
|
1341
1408
|
* A lead's status as the list and the filter should read it.
|
|
1342
1409
|
*
|