@aglyn/plugins-crm 1.0.0-beta.219 → 1.0.0-beta.220

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.
Files changed (136) hide show
  1. package/README.md +2 -0
  2. package/package.json +14 -15
  3. package/src/lib/components/company-import-drawer.d.ts +1 -1
  4. package/src/lib/components/contact-associations-card.js +4 -3
  5. package/src/lib/components/contact-associations-card.js.map +1 -1
  6. package/src/lib/components/crm-booking-zone.d.ts +8 -2
  7. package/src/lib/components/crm-booking-zone.js.map +1 -1
  8. package/src/lib/components/crm-glance-card.js +2 -1
  9. package/src/lib/components/crm-glance-card.js.map +1 -1
  10. package/src/lib/components/deal-products-card.js +27 -16
  11. package/src/lib/components/deal-products-card.js.map +1 -1
  12. package/src/lib/components/lead-campaigns-card.d.ts +4 -4
  13. package/src/lib/components/lead-campaigns-card.js +9 -8
  14. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  15. package/src/lib/components/lead-properties-card.js +4 -0
  16. package/src/lib/components/lead-properties-card.js.map +1 -1
  17. package/src/lib/components/lead-status-chip.js +3 -1
  18. package/src/lib/components/lead-status-chip.js.map +1 -1
  19. package/src/lib/components/leads-bulk-bar.js +7 -9
  20. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  21. package/src/lib/components/leads-section.js +5 -1
  22. package/src/lib/components/leads-section.js.map +1 -1
  23. package/src/lib/components/new-lead-drawer.js +5 -4
  24. package/src/lib/components/new-lead-drawer.js.map +1 -1
  25. package/src/lib/components/reports/activity-card.js +4 -3
  26. package/src/lib/components/reports/activity-card.js.map +1 -1
  27. package/src/lib/components/reports/closed-deals-card.js +4 -3
  28. package/src/lib/components/reports/closed-deals-card.js.map +1 -1
  29. package/src/lib/components/reports/contacts-mix-card.js +3 -2
  30. package/src/lib/components/reports/contacts-mix-card.js.map +1 -1
  31. package/src/lib/components/reports/contacts-trend-card.js +2 -1
  32. package/src/lib/components/reports/contacts-trend-card.js.map +1 -1
  33. package/src/lib/components/reports/forecast-card.js +3 -2
  34. package/src/lib/components/reports/forecast-card.js.map +1 -1
  35. package/src/lib/components/reports/lead-funnel-card.js +8 -6
  36. package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
  37. package/src/lib/components/reports/pipeline-card.js +4 -3
  38. package/src/lib/components/reports/pipeline-card.js.map +1 -1
  39. package/src/lib/components/reports/report-format.d.ts +1 -1
  40. package/src/lib/components/reports/report-format.js.map +1 -1
  41. package/src/lib/components/reports/report-scope.d.ts +1 -1
  42. package/src/lib/components/reports/report-scope.js.map +1 -1
  43. package/src/lib/components/reports/source-conversion-card.js +2 -1
  44. package/src/lib/components/reports/source-conversion-card.js.map +1 -1
  45. package/src/lib/components/reports/tasks-card.js +2 -1
  46. package/src/lib/components/reports/tasks-card.js.map +1 -1
  47. package/src/lib/components/reports/won-lost-by-owner-card.js +3 -2
  48. package/src/lib/components/reports/won-lost-by-owner-card.js.map +1 -1
  49. package/src/lib/components/reports-section.js +4 -4
  50. package/src/lib/components/reports-section.js.map +1 -1
  51. package/src/lib/constants/bundle-common.d.ts +2 -0
  52. package/src/lib/constants/bundle-common.js +1 -0
  53. package/src/lib/constants/bundle-common.js.map +1 -1
  54. package/src/lib/declarations.console-server.d.ts +4 -0
  55. package/src/lib/declarations.console-server.js +12 -0
  56. package/src/lib/declarations.console-server.js.map +1 -1
  57. package/src/lib/declarations.server.d.ts +6 -0
  58. package/src/lib/declarations.server.js +51 -1
  59. package/src/lib/declarations.server.js.map +1 -1
  60. package/src/lib/hooks/use-crm-campaigns.d.ts +2 -2
  61. package/src/lib/hooks/use-crm-campaigns.js +3 -3
  62. package/src/lib/hooks/use-crm-campaigns.js.map +1 -1
  63. package/src/lib/model/campaign-filing-activity.d.ts +1 -1
  64. package/src/lib/model/campaign-filing-activity.js +1 -1
  65. package/src/lib/model/campaign-filing-activity.js.map +1 -1
  66. package/src/lib/model/contact-record.js +1 -1
  67. package/src/lib/model/contact-record.js.map +1 -1
  68. package/src/lib/model/crm-digest.d.ts +45 -3
  69. package/src/lib/model/crm-digest.js +75 -11
  70. package/src/lib/model/crm-digest.js.map +1 -1
  71. package/src/lib/model/crm-inbound.d.ts +162 -0
  72. package/src/lib/model/crm-inbound.js +208 -0
  73. package/src/lib/model/crm-inbound.js.map +1 -0
  74. package/src/lib/model/crm-lead-import.d.ts +1 -1
  75. package/src/lib/model/crm-lead-import.js +4 -4
  76. package/src/lib/model/crm-lead-import.js.map +1 -1
  77. package/src/lib/model/crm-reports.d.ts +467 -0
  78. package/src/lib/model/crm-reports.js +667 -0
  79. package/src/lib/model/crm-reports.js.map +1 -0
  80. package/src/lib/model/lead-filters.d.ts +4 -3
  81. package/src/lib/model/lead-filters.js +7 -5
  82. package/src/lib/model/lead-filters.js.map +1 -1
  83. package/src/lib/plugin.js +36 -0
  84. package/src/lib/plugin.js.map +1 -1
  85. package/src/lib/server/api-v1/crm-leads.js +1 -0
  86. package/src/lib/server/api-v1/crm-leads.js.map +1 -1
  87. package/src/lib/server/api-v1/openapi.js +1 -1
  88. package/src/lib/server/api-v1/openapi.js.map +1 -1
  89. package/src/lib/server/campaign-filing-activity.d.ts +1 -1
  90. package/src/lib/server/campaign-filing-activity.js +7 -11
  91. package/src/lib/server/campaign-filing-activity.js.map +1 -1
  92. package/src/lib/server/capture-contact.js +15 -4
  93. package/src/lib/server/capture-contact.js.map +1 -1
  94. package/src/lib/server/contact-email-engagement.d.ts +33 -0
  95. package/src/lib/server/contact-email-engagement.js +146 -0
  96. package/src/lib/server/contact-email-engagement.js.map +1 -0
  97. package/src/lib/server/contact-update.js +3 -3
  98. package/src/lib/server/contact-update.js.map +1 -1
  99. package/src/lib/server/container-detach.d.ts +31 -0
  100. package/src/lib/server/container-detach.js +115 -0
  101. package/src/lib/server/container-detach.js.map +1 -0
  102. package/src/lib/server/crm-inbound-email.d.ts +162 -0
  103. package/src/lib/server/crm-inbound-email.js +259 -0
  104. package/src/lib/server/crm-inbound-email.js.map +1 -0
  105. package/src/lib/server/crm-records-meter.d.ts +50 -0
  106. package/src/lib/server/crm-records-meter.js +126 -0
  107. package/src/lib/server/crm-records-meter.js.map +1 -0
  108. package/src/lib/server/daily-digest-route.d.ts +2 -8
  109. package/src/lib/server/daily-digest-route.js +15 -25
  110. package/src/lib/server/daily-digest-route.js.map +1 -1
  111. package/src/lib/server/inbound-address.js +5 -3
  112. package/src/lib/server/inbound-address.js.map +1 -1
  113. package/src/lib/server/inbound-route.js +7 -4
  114. package/src/lib/server/inbound-route.js.map +1 -1
  115. package/src/lib/server/lead-campaign-carry.js +3 -3
  116. package/src/lib/server/lead-campaign-carry.js.map +1 -1
  117. package/src/lib/server/lead-create.d.ts +1 -1
  118. package/src/lib/server/lead-create.js +10 -15
  119. package/src/lib/server/lead-create.js.map +1 -1
  120. package/src/lib/server/lead-nurturing.d.ts +23 -0
  121. package/src/lib/server/lead-nurturing.js +82 -0
  122. package/src/lib/server/lead-nurturing.js.map +1 -0
  123. package/src/lib/server/leads-import.js +7 -7
  124. package/src/lib/server/leads-import.js.map +1 -1
  125. package/src/lib/server/record-email-state.js +23 -0
  126. package/src/lib/server/record-email-state.js.map +1 -1
  127. package/src/lib/server/record-timeline.d.ts +12 -0
  128. package/src/lib/server/record-timeline.js +127 -15
  129. package/src/lib/server/record-timeline.js.map +1 -1
  130. package/src/lib/server/task-reminders-route.js +4 -2
  131. package/src/lib/server/task-reminders-route.js.map +1 -1
  132. package/src/lib/server/task-routes.d.ts +2 -0
  133. package/src/lib/server/task-routes.js +22 -3
  134. package/src/lib/server/task-routes.js.map +1 -1
  135. package/src/lib/usage-axes.js +8 -1
  136. package/src/lib/usage-axes.js.map +1 -1
@@ -0,0 +1,162 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type CrmActivity, type CrmActivityLink, type CrmEmailDirection } from '@aglyn/aglyn/app-utils/crm';
18
+ import { type MemberAddresses } from '@aglyn/aglyn/app-utils/member-email-aliases';
19
+ /**
20
+ * EMAIL CAPTURE (AGL-2657): the pure half.
21
+ *
22
+ * A workspace gets one address — `crm+<token>@<capture domain>` — and a
23
+ * reply forwarded to it, or a message a teammate copies to it from their
24
+ * own mailbox, is filed on the record of whoever the message was with.
25
+ * This module is everything about that which needs no Firestore and no
26
+ * provider: the shape of the address, the token in it, which of a
27
+ * message's addresses is the correspondent, and the activity row the whole
28
+ * thing becomes. How a message is read — the address in a header, the
29
+ * reply above the quoted history — is the platform's (`email-text.ts`), and
30
+ * so is the domain mail arrives on (`inbound-mail-domain.ts`). The webhook
31
+ * route and the address route read it; the specs read all of it.
32
+ *
33
+ * ## The token is the whole secret
34
+ *
35
+ * The address is public the moment a member pastes it into a mailbox rule,
36
+ * and the domain is the platform's, so the token is the only thing that
37
+ * says which workspace a message is for. Thirty-two characters from a
38
+ * lowercase alphabet — mail servers may fold the local part's case, and a
39
+ * token that survived folding is one that never depended on case — drawn
40
+ * from the platform's random source, which is the same entropy an API key
41
+ * carries. Rotation replaces it; the old address then files nothing.
42
+ *
43
+ * ## Who the message was with
44
+ *
45
+ * A message names several addresses and exactly one of them is the person
46
+ * the record is about. The rule: the first address among From, To and Cc
47
+ * that is not the capture address and not a member of the workspace — the
48
+ * workspace's own people are never the correspondent — in that order,
49
+ * because a reply's From is the correspondent and a copied send's To is.
50
+ * A member is every address that is theirs: the one they sign in with and
51
+ * each alias they have confirmed (`member-email-aliases.ts`), so a send
52
+ * from an outbound-domain alias is read as the member's, not as a stranger.
53
+ * A message a teammate forwarded names the correspondent nowhere in its
54
+ * headers, only in the forwarded block of the body, so that block's `From:`
55
+ * is the last candidate. Which candidate matched decides the direction:
56
+ * one the correspondent wrote is `inbound`, one the workspace wrote is
57
+ * `outbound`.
58
+ */
59
+ /** The local part every capture address begins with: `crm+<token>@…`. */
60
+ export declare const CRM_INBOUND_LOCAL_PART = "crm";
61
+ /** How many characters a minted token has. */
62
+ export declare const CRM_INBOUND_TOKEN_LENGTH = 32;
63
+ /** What the org's feed says about a message that matched no record. */
64
+ export declare const CRM_INBOUND_UNMATCHED_ACTION = "Inbound email matched no record";
65
+ /** What the org's feed says about a message the record's ceiling refused. */
66
+ export declare const CRM_INBOUND_CEILING_ACTION = "Inbound email not filed: activity log full";
67
+ /**
68
+ * A fresh token from the platform's random source. One byte per character
69
+ * folded onto a 36-symbol alphabet: the fold is very slightly uneven and
70
+ * the token is thirty-two symbols long, which is more than an API key
71
+ * carries and far more than an address anyone guesses.
72
+ */
73
+ export declare function mintCrmInboundToken(): string;
74
+ export declare function isCrmInboundToken(value: unknown): value is string;
75
+ /** `crm+<token>@<domain>` — the address a member pastes into a mailbox. */
76
+ export declare function crmInboundAddress(token: string, domain: string): string;
77
+ /**
78
+ * The token a recipient address carries, or `null` when the address is not
79
+ * a capture address on `domain`. The local part is read case-folded — a
80
+ * relay may fold it and the alphabet has no case to lose — and the domain
81
+ * is compared the way domains compare.
82
+ */
83
+ export declare function crmInboundTokenOf(recipient: unknown, domain: string): string | null;
84
+ /** Every distinct token among a message's recipients, in the order met. */
85
+ export declare function crmInboundTokensIn(recipients: readonly unknown[], domain: string): string[];
86
+ export declare function isCrmInboundAddress(address: unknown, domain: string): boolean;
87
+ /** One address the message might be with, in the order the rule tries them. */
88
+ export interface CrmInboundCandidate {
89
+ email: string;
90
+ /** What a row filed on a match with this address says about the message. */
91
+ direction: CrmEmailDirection;
92
+ /** Which header the address came from, for the spec and the log. */
93
+ via: 'from' | 'to' | 'cc' | 'forwarded';
94
+ }
95
+ export interface CrmInboundCandidatesInput {
96
+ from: unknown;
97
+ to: readonly unknown[];
98
+ cc?: readonly unknown[];
99
+ /** The forwarded block's `From:`, when the body carried one. */
100
+ forwardedFrom?: unknown;
101
+ /** The capture domain, so the capture address itself is never a candidate. */
102
+ domain: string;
103
+ /**
104
+ * The workspace's roster. Every address that is a member's — the one
105
+ * they sign in with and each alias they have confirmed — is theirs, and
106
+ * none of them is ever a correspondent.
107
+ */
108
+ members: readonly MemberAddresses[];
109
+ }
110
+ export interface CrmInboundCandidates {
111
+ /** The normalized From, or `null` when the header carried no address. */
112
+ sender: string | null;
113
+ /** Whether a member of the workspace wrote the message. */
114
+ senderIsMember: boolean;
115
+ candidates: CrmInboundCandidate[];
116
+ }
117
+ /**
118
+ * The addresses to try against the workspace's records, in order — see the
119
+ * module comment — each already stamped with the direction a match would
120
+ * mean: a message a member wrote went OUT to whoever it matches, and one
121
+ * anybody else wrote came IN from them.
122
+ */
123
+ export declare function crmInboundCandidates(input: CrmInboundCandidatesInput): CrmInboundCandidates;
124
+ /**
125
+ * The key one message is filed under, so a second delivery of it is a
126
+ * no-op: its `Message-ID`, which every mail client stamps and every relay
127
+ * preserves, else the provider's own id, which a redelivery of one
128
+ * webhook event repeats. `null` when the message has neither — a message
129
+ * that cannot be told from its own redelivery is filed by a fresh id.
130
+ */
131
+ export declare function crmCapturedEmailKey(messageId: unknown, providerId: unknown): string | null;
132
+ /** What a captured message is logged from — see {@link buildCrmCapturedEmailActivity}. */
133
+ export interface CrmCapturedEmailInput {
134
+ direction: CrmEmailDirection;
135
+ subject: string;
136
+ /** The excerpt, already reduced by `emailExcerpt`. */
137
+ excerpt: string;
138
+ /** The address on the message's From. */
139
+ from: string;
140
+ /** The address the message was addressed to, when one is worth showing. */
141
+ to?: string | null;
142
+ messageId: string;
143
+ inReplyTo?: string | null;
144
+ /** When the message was received. */
145
+ atMs: number;
146
+ /** The member who wrote it, or `''` for a message a correspondent wrote. */
147
+ byUid: string;
148
+ byName?: string | null;
149
+ link: CrmActivityLink;
150
+ hostId: string;
151
+ visibleTo: readonly string[];
152
+ }
153
+ /**
154
+ * The activity row a captured message is filed as (AGL-2657): `kind:
155
+ * 'email'`, with the direction the match decided, the excerpt for a body,
156
+ * and the thread facts a later reader groups by. No delivery state — the
157
+ * platform did not send it and can say nothing about where it got to —
158
+ * which is also how the timeline tells a captured send from a platform
159
+ * send. The same shape `buildCrmEmailActivity` writes, so the timeline
160
+ * draws both with one row.
161
+ */
162
+ export declare function buildCrmCapturedEmailActivity(input: CrmCapturedEmailInput): CrmActivity;
@@ -0,0 +1,208 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { CRM_EMAIL_BODY_MAX, CRM_EMAIL_SUBJECT_MAX } from "@aglyn/aglyn/app-utils/crm";
18
+ import { emailAddressOf, threadSubject } from "@aglyn/aglyn/app-utils/email-text";
19
+ import { memberEmailAddresses } from "@aglyn/aglyn/app-utils/member-email-aliases";
20
+ /**
21
+ * EMAIL CAPTURE (AGL-2657): the pure half.
22
+ *
23
+ * A workspace gets one address — `crm+<token>@<capture domain>` — and a
24
+ * reply forwarded to it, or a message a teammate copies to it from their
25
+ * own mailbox, is filed on the record of whoever the message was with.
26
+ * This module is everything about that which needs no Firestore and no
27
+ * provider: the shape of the address, the token in it, which of a
28
+ * message's addresses is the correspondent, and the activity row the whole
29
+ * thing becomes. How a message is read — the address in a header, the
30
+ * reply above the quoted history — is the platform's (`email-text.ts`), and
31
+ * so is the domain mail arrives on (`inbound-mail-domain.ts`). The webhook
32
+ * route and the address route read it; the specs read all of it.
33
+ *
34
+ * ## The token is the whole secret
35
+ *
36
+ * The address is public the moment a member pastes it into a mailbox rule,
37
+ * and the domain is the platform's, so the token is the only thing that
38
+ * says which workspace a message is for. Thirty-two characters from a
39
+ * lowercase alphabet — mail servers may fold the local part's case, and a
40
+ * token that survived folding is one that never depended on case — drawn
41
+ * from the platform's random source, which is the same entropy an API key
42
+ * carries. Rotation replaces it; the old address then files nothing.
43
+ *
44
+ * ## Who the message was with
45
+ *
46
+ * A message names several addresses and exactly one of them is the person
47
+ * the record is about. The rule: the first address among From, To and Cc
48
+ * that is not the capture address and not a member of the workspace — the
49
+ * workspace's own people are never the correspondent — in that order,
50
+ * because a reply's From is the correspondent and a copied send's To is.
51
+ * A member is every address that is theirs: the one they sign in with and
52
+ * each alias they have confirmed (`member-email-aliases.ts`), so a send
53
+ * from an outbound-domain alias is read as the member's, not as a stranger.
54
+ * A message a teammate forwarded names the correspondent nowhere in its
55
+ * headers, only in the forwarded block of the body, so that block's `From:`
56
+ * is the last candidate. Which candidate matched decides the direction:
57
+ * one the correspondent wrote is `inbound`, one the workspace wrote is
58
+ * `outbound`.
59
+ */ /** The local part every capture address begins with: `crm+<token>@…`. */ export const CRM_INBOUND_LOCAL_PART = 'crm';
60
+ /** How many characters a minted token has. */ export const CRM_INBOUND_TOKEN_LENGTH = 32;
61
+ /** What the org's feed says about a message that matched no record. */ export const CRM_INBOUND_UNMATCHED_ACTION = 'Inbound email matched no record';
62
+ /** What the org's feed says about a message the record's ceiling refused. */ export const CRM_INBOUND_CEILING_ACTION = 'Inbound email not filed: activity log full';
63
+ const TOKEN_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
64
+ const TOKEN_PATTERN = /^[a-z0-9]{24,64}$/;
65
+ /**
66
+ * A fresh token from the platform's random source. One byte per character
67
+ * folded onto a 36-symbol alphabet: the fold is very slightly uneven and
68
+ * the token is thirty-two symbols long, which is more than an API key
69
+ * carries and far more than an address anyone guesses.
70
+ */ export function mintCrmInboundToken() {
71
+ const bytes = new Uint8Array(CRM_INBOUND_TOKEN_LENGTH);
72
+ globalThis.crypto.getRandomValues(bytes);
73
+ let token = '';
74
+ for (const byte of bytes)token += TOKEN_ALPHABET[byte % TOKEN_ALPHABET.length];
75
+ return token;
76
+ }
77
+ export function isCrmInboundToken(value) {
78
+ return typeof value === 'string' && TOKEN_PATTERN.test(value);
79
+ }
80
+ /** `crm+<token>@<domain>` — the address a member pastes into a mailbox. */ export function crmInboundAddress(token, domain) {
81
+ return `${CRM_INBOUND_LOCAL_PART}+${token}@${domain}`;
82
+ }
83
+ /**
84
+ * The token a recipient address carries, or `null` when the address is not
85
+ * a capture address on `domain`. The local part is read case-folded — a
86
+ * relay may fold it and the alphabet has no case to lose — and the domain
87
+ * is compared the way domains compare.
88
+ */ export function crmInboundTokenOf(recipient, domain) {
89
+ const raw = String(recipient != null ? recipient : '').trim();
90
+ const angled = /<([^<>]+)>\s*$/.exec(raw);
91
+ const address = (angled ? angled[1] : raw).trim();
92
+ const at = address.lastIndexOf('@');
93
+ if (at <= 0) return null;
94
+ if (address.slice(at + 1).toLowerCase() !== domain.toLowerCase()) return null;
95
+ const local = address.slice(0, at).toLowerCase();
96
+ const match = new RegExp(`^${CRM_INBOUND_LOCAL_PART}\\+([a-z0-9]{24,64})$`).exec(local);
97
+ return match ? match[1] : null;
98
+ }
99
+ /** Every distinct token among a message's recipients, in the order met. */ export function crmInboundTokensIn(recipients, domain) {
100
+ const tokens = [];
101
+ for (const recipient of recipients){
102
+ const token = crmInboundTokenOf(recipient, domain);
103
+ if (token && !tokens.includes(token)) tokens.push(token);
104
+ }
105
+ return tokens;
106
+ }
107
+ export function isCrmInboundAddress(address, domain) {
108
+ return crmInboundTokenOf(address, domain) !== null;
109
+ }
110
+ /**
111
+ * The addresses to try against the workspace's records, in order — see the
112
+ * module comment — each already stamped with the direction a match would
113
+ * mean: a message a member wrote went OUT to whoever it matches, and one
114
+ * anybody else wrote came IN from them.
115
+ */ export function crmInboundCandidates(input) {
116
+ var _input_cc;
117
+ const members = new Set();
118
+ for (const member of input.members){
119
+ for (const address of memberEmailAddresses(member))members.add(address);
120
+ }
121
+ const sender = emailAddressOf(input.from);
122
+ const senderIsMember = sender !== null && members.has(sender);
123
+ const seen = new Set();
124
+ const candidates = [];
125
+ const consider = (value, direction, via)=>{
126
+ const email = emailAddressOf(value);
127
+ if (!email || seen.has(email) || members.has(email)) return;
128
+ if (isCrmInboundAddress(email, input.domain)) return;
129
+ seen.add(email);
130
+ candidates.push({
131
+ email,
132
+ direction,
133
+ via
134
+ });
135
+ };
136
+ consider(input.from, 'inbound', 'from');
137
+ const headed = senderIsMember ? 'outbound' : 'inbound';
138
+ for (const value of input.to)consider(value, headed, 'to');
139
+ for (const value of (_input_cc = input.cc) != null ? _input_cc : [])consider(value, headed, 'cc');
140
+ consider(input.forwardedFrom, 'inbound', 'forwarded');
141
+ return {
142
+ sender,
143
+ senderIsMember,
144
+ candidates
145
+ };
146
+ }
147
+ /**
148
+ * The key one message is filed under, so a second delivery of it is a
149
+ * no-op: its `Message-ID`, which every mail client stamps and every relay
150
+ * preserves, else the provider's own id, which a redelivery of one
151
+ * webhook event repeats. `null` when the message has neither — a message
152
+ * that cannot be told from its own redelivery is filed by a fresh id.
153
+ */ export function crmCapturedEmailKey(messageId, providerId) {
154
+ const id = String(messageId != null ? messageId : '').trim();
155
+ if (id) return `mid:${id}`;
156
+ const provider = String(providerId != null ? providerId : '').trim();
157
+ return provider ? `provider:${provider}` : null;
158
+ }
159
+ /**
160
+ * The activity row a captured message is filed as (AGL-2657): `kind:
161
+ * 'email'`, with the direction the match decided, the excerpt for a body,
162
+ * and the thread facts a later reader groups by. No delivery state — the
163
+ * platform did not send it and can say nothing about where it got to —
164
+ * which is also how the timeline tells a captured send from a platform
165
+ * send. The same shape `buildCrmEmailActivity` writes, so the timeline
166
+ * draws both with one row.
167
+ */ export function buildCrmCapturedEmailActivity(input) {
168
+ var _input_subject, _input_inReplyTo, _input_to, _input_byName, _input_excerpt, _input_messageId;
169
+ const { link } = input;
170
+ const subject = String((_input_subject = input.subject) != null ? _input_subject : '').replace(/\s+/g, ' ').trim().slice(0, CRM_EMAIL_SUBJECT_MAX);
171
+ const inReplyTo = String((_input_inReplyTo = input.inReplyTo) != null ? _input_inReplyTo : '').trim();
172
+ const to = String((_input_to = input.to) != null ? _input_to : '').trim();
173
+ const byName = String((_input_byName = input.byName) != null ? _input_byName : '').trim();
174
+ return _extends({
175
+ kind: 'email',
176
+ subject,
177
+ body: String((_input_excerpt = input.excerpt) != null ? _input_excerpt : '').slice(0, CRM_EMAIL_BODY_MAX),
178
+ direction: input.direction,
179
+ from: input.from
180
+ }, to ? {
181
+ to
182
+ } : {}, {
183
+ messageId: String((_input_messageId = input.messageId) != null ? _input_messageId : '').trim()
184
+ }, inReplyTo ? {
185
+ inReplyTo
186
+ } : {}, {
187
+ threadSubject: threadSubject(subject),
188
+ atMs: input.atMs,
189
+ byUid: input.byUid
190
+ }, byName ? {
191
+ byName
192
+ } : {}, link.contactId ? {
193
+ contactId: String(link.contactId)
194
+ } : {}, link.companyId ? {
195
+ companyId: String(link.companyId)
196
+ } : {}, link.dealId ? {
197
+ dealId: String(link.dealId)
198
+ } : {}, link.leadId ? {
199
+ leadId: String(link.leadId)
200
+ } : {}, {
201
+ hostId: input.hostId,
202
+ visibleTo: [
203
+ ...input.visibleTo
204
+ ]
205
+ });
206
+ }
207
+
208
+ //# sourceMappingURL=crm-inbound.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-inbound.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n CRM_EMAIL_BODY_MAX,\n CRM_EMAIL_SUBJECT_MAX,\n type CrmActivity,\n type CrmActivityLink,\n type CrmEmailDirection,\n} from '@aglyn/aglyn/app-utils/crm'\nimport { emailAddressOf, threadSubject } from '@aglyn/aglyn/app-utils/email-text'\nimport {\n type MemberAddresses,\n memberEmailAddresses,\n} from '@aglyn/aglyn/app-utils/member-email-aliases'\n\n/**\n * EMAIL CAPTURE (AGL-2657): the pure half.\n *\n * A workspace gets one address — `crm+<token>@<capture domain>` — and a\n * reply forwarded to it, or a message a teammate copies to it from their\n * own mailbox, is filed on the record of whoever the message was with.\n * This module is everything about that which needs no Firestore and no\n * provider: the shape of the address, the token in it, which of a\n * message's addresses is the correspondent, and the activity row the whole\n * thing becomes. How a message is read — the address in a header, the\n * reply above the quoted history — is the platform's (`email-text.ts`), and\n * so is the domain mail arrives on (`inbound-mail-domain.ts`). The webhook\n * route and the address route read it; the specs read all of it.\n *\n * ## The token is the whole secret\n *\n * The address is public the moment a member pastes it into a mailbox rule,\n * and the domain is the platform's, so the token is the only thing that\n * says which workspace a message is for. Thirty-two characters from a\n * lowercase alphabet — mail servers may fold the local part's case, and a\n * token that survived folding is one that never depended on case — drawn\n * from the platform's random source, which is the same entropy an API key\n * carries. Rotation replaces it; the old address then files nothing.\n *\n * ## Who the message was with\n *\n * A message names several addresses and exactly one of them is the person\n * the record is about. The rule: the first address among From, To and Cc\n * that is not the capture address and not a member of the workspace — the\n * workspace's own people are never the correspondent — in that order,\n * because a reply's From is the correspondent and a copied send's To is.\n * A member is every address that is theirs: the one they sign in with and\n * each alias they have confirmed (`member-email-aliases.ts`), so a send\n * from an outbound-domain alias is read as the member's, not as a stranger.\n * A message a teammate forwarded names the correspondent nowhere in its\n * headers, only in the forwarded block of the body, so that block's `From:`\n * is the last candidate. Which candidate matched decides the direction:\n * one the correspondent wrote is `inbound`, one the workspace wrote is\n * `outbound`.\n */\n\n/** The local part every capture address begins with: `crm+<token>@…`. */\nexport const CRM_INBOUND_LOCAL_PART = 'crm'\n\n/** How many characters a minted token has. */\nexport const CRM_INBOUND_TOKEN_LENGTH = 32\n\n/** What the org's feed says about a message that matched no record. */\nexport const CRM_INBOUND_UNMATCHED_ACTION = 'Inbound email matched no record'\n\n/** What the org's feed says about a message the record's ceiling refused. */\nexport const CRM_INBOUND_CEILING_ACTION = 'Inbound email not filed: activity log full'\n\nconst TOKEN_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'\nconst TOKEN_PATTERN = /^[a-z0-9]{24,64}$/\n\n/**\n * A fresh token from the platform's random source. One byte per character\n * folded onto a 36-symbol alphabet: the fold is very slightly uneven and\n * the token is thirty-two symbols long, which is more than an API key\n * carries and far more than an address anyone guesses.\n */\nexport function mintCrmInboundToken(): string {\n const bytes = new Uint8Array(CRM_INBOUND_TOKEN_LENGTH)\n globalThis.crypto.getRandomValues(bytes)\n let token = ''\n for (const byte of bytes) token += TOKEN_ALPHABET[byte % TOKEN_ALPHABET.length]\n return token\n}\n\nexport function isCrmInboundToken(value: unknown): value is string {\n return typeof value === 'string' && TOKEN_PATTERN.test(value)\n}\n\n/** `crm+<token>@<domain>` — the address a member pastes into a mailbox. */\nexport function crmInboundAddress(token: string, domain: string): string {\n return `${CRM_INBOUND_LOCAL_PART}+${token}@${domain}`\n}\n\n/**\n * The token a recipient address carries, or `null` when the address is not\n * a capture address on `domain`. The local part is read case-folded — a\n * relay may fold it and the alphabet has no case to lose — and the domain\n * is compared the way domains compare.\n */\nexport function crmInboundTokenOf(recipient: unknown, domain: string): string | null {\n const raw = String(recipient ?? '').trim()\n const angled = /<([^<>]+)>\\s*$/.exec(raw)\n const address = (angled ? angled[1] : raw).trim()\n const at = address.lastIndexOf('@')\n if (at <= 0) return null\n if (address.slice(at + 1).toLowerCase() !== domain.toLowerCase()) return null\n const local = address.slice(0, at).toLowerCase()\n const match = new RegExp(`^${CRM_INBOUND_LOCAL_PART}\\\\+([a-z0-9]{24,64})$`).exec(local)\n return match ? match[1] : null\n}\n\n/** Every distinct token among a message's recipients, in the order met. */\nexport function crmInboundTokensIn(\n recipients: readonly unknown[],\n domain: string,\n): string[] {\n const tokens: string[] = []\n for (const recipient of recipients) {\n const token = crmInboundTokenOf(recipient, domain)\n if (token && !tokens.includes(token)) tokens.push(token)\n }\n return tokens\n}\n\nexport function isCrmInboundAddress(address: unknown, domain: string): boolean {\n return crmInboundTokenOf(address, domain) !== null\n}\n\n/** One address the message might be with, in the order the rule tries them. */\nexport interface CrmInboundCandidate {\n email: string\n /** What a row filed on a match with this address says about the message. */\n direction: CrmEmailDirection\n /** Which header the address came from, for the spec and the log. */\n via: 'from' | 'to' | 'cc' | 'forwarded'\n}\n\nexport interface CrmInboundCandidatesInput {\n from: unknown\n to: readonly unknown[]\n cc?: readonly unknown[]\n /** The forwarded block's `From:`, when the body carried one. */\n forwardedFrom?: unknown\n /** The capture domain, so the capture address itself is never a candidate. */\n domain: string\n /**\n * The workspace's roster. Every address that is a member's — the one\n * they sign in with and each alias they have confirmed — is theirs, and\n * none of them is ever a correspondent.\n */\n members: readonly MemberAddresses[]\n}\n\nexport interface CrmInboundCandidates {\n /** The normalized From, or `null` when the header carried no address. */\n sender: string | null\n /** Whether a member of the workspace wrote the message. */\n senderIsMember: boolean\n candidates: CrmInboundCandidate[]\n}\n\n/**\n * The addresses to try against the workspace's records, in order — see the\n * module comment — each already stamped with the direction a match would\n * mean: a message a member wrote went OUT to whoever it matches, and one\n * anybody else wrote came IN from them.\n */\nexport function crmInboundCandidates(input: CrmInboundCandidatesInput): CrmInboundCandidates {\n const members = new Set<string>()\n for (const member of input.members) {\n for (const address of memberEmailAddresses(member)) members.add(address)\n }\n const sender = emailAddressOf(input.from)\n const senderIsMember = sender !== null && members.has(sender)\n const seen = new Set<string>()\n const candidates: CrmInboundCandidate[] = []\n const consider = (\n value: unknown,\n direction: CrmEmailDirection,\n via: CrmInboundCandidate['via'],\n ) => {\n const email = emailAddressOf(value)\n if (!email || seen.has(email) || members.has(email)) return\n if (isCrmInboundAddress(email, input.domain)) return\n seen.add(email)\n candidates.push({ email, direction, via })\n }\n consider(input.from, 'inbound', 'from')\n const headed = senderIsMember ? 'outbound' : 'inbound'\n for (const value of input.to) consider(value, headed, 'to')\n for (const value of input.cc ?? []) consider(value, headed, 'cc')\n consider(input.forwardedFrom, 'inbound', 'forwarded')\n return { sender, senderIsMember, candidates }\n}\n\n/**\n * The key one message is filed under, so a second delivery of it is a\n * no-op: its `Message-ID`, which every mail client stamps and every relay\n * preserves, else the provider's own id, which a redelivery of one\n * webhook event repeats. `null` when the message has neither — a message\n * that cannot be told from its own redelivery is filed by a fresh id.\n */\nexport function crmCapturedEmailKey(messageId: unknown, providerId: unknown): string | null {\n const id = String(messageId ?? '').trim()\n if (id) return `mid:${id}`\n const provider = String(providerId ?? '').trim()\n return provider ? `provider:${provider}` : null\n}\n\n/** What a captured message is logged from — see {@link buildCrmCapturedEmailActivity}. */\nexport interface CrmCapturedEmailInput {\n direction: CrmEmailDirection\n subject: string\n /** The excerpt, already reduced by `emailExcerpt`. */\n excerpt: string\n /** The address on the message's From. */\n from: string\n /** The address the message was addressed to, when one is worth showing. */\n to?: string | null\n messageId: string\n inReplyTo?: string | null\n /** When the message was received. */\n atMs: number\n /** The member who wrote it, or `''` for a message a correspondent wrote. */\n byUid: string\n byName?: string | null\n link: CrmActivityLink\n hostId: string\n visibleTo: readonly string[]\n}\n\n/**\n * The activity row a captured message is filed as (AGL-2657): `kind:\n * 'email'`, with the direction the match decided, the excerpt for a body,\n * and the thread facts a later reader groups by. No delivery state — the\n * platform did not send it and can say nothing about where it got to —\n * which is also how the timeline tells a captured send from a platform\n * send. The same shape `buildCrmEmailActivity` writes, so the timeline\n * draws both with one row.\n */\nexport function buildCrmCapturedEmailActivity(input: CrmCapturedEmailInput): CrmActivity {\n const { link } = input\n const subject = String(input.subject ?? '')\n .replace(/\\s+/g, ' ')\n .trim()\n .slice(0, CRM_EMAIL_SUBJECT_MAX)\n const inReplyTo = String(input.inReplyTo ?? '').trim()\n const to = String(input.to ?? '').trim()\n const byName = String(input.byName ?? '').trim()\n return {\n kind: 'email',\n subject,\n body: String(input.excerpt ?? '').slice(0, CRM_EMAIL_BODY_MAX),\n direction: input.direction,\n from: input.from,\n ...(to ? { to } : {}),\n messageId: String(input.messageId ?? '').trim(),\n ...(inReplyTo ? { inReplyTo } : {}),\n threadSubject: threadSubject(subject),\n atMs: input.atMs,\n byUid: input.byUid,\n ...(byName ? { byName } : {}),\n ...(link.contactId ? { contactId: String(link.contactId) } : {}),\n ...(link.companyId ? { companyId: String(link.companyId) } : {}),\n ...(link.dealId ? { dealId: String(link.dealId) } : {}),\n ...(link.leadId ? { leadId: String(link.leadId) } : {}),\n hostId: input.hostId,\n visibleTo: [...input.visibleTo],\n }\n}\n"],"names":["CRM_EMAIL_BODY_MAX","CRM_EMAIL_SUBJECT_MAX","emailAddressOf","threadSubject","memberEmailAddresses","CRM_INBOUND_LOCAL_PART","CRM_INBOUND_TOKEN_LENGTH","CRM_INBOUND_UNMATCHED_ACTION","CRM_INBOUND_CEILING_ACTION","TOKEN_ALPHABET","TOKEN_PATTERN","mintCrmInboundToken","bytes","Uint8Array","globalThis","crypto","getRandomValues","token","byte","length","isCrmInboundToken","value","test","crmInboundAddress","domain","crmInboundTokenOf","recipient","raw","String","trim","angled","exec","address","at","lastIndexOf","slice","toLowerCase","local","match","RegExp","crmInboundTokensIn","recipients","tokens","includes","push","isCrmInboundAddress","crmInboundCandidates","input","members","Set","member","add","sender","from","senderIsMember","has","seen","candidates","consider","direction","via","email","headed","to","cc","forwardedFrom","crmCapturedEmailKey","messageId","providerId","id","provider","buildCrmCapturedEmailActivity","link","subject","replace","inReplyTo","byName","kind","body","excerpt","atMs","byUid","contactId","companyId","dealId","leadId","hostId","visibleTo"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,kBAAkB,EAClBC,qBAAqB,QAIhB,6BAA4B;AACnC,SAASC,cAAc,EAAEC,aAAa,QAAQ,oCAAmC;AACjF,SAEEC,oBAAoB,QACf,8CAA6C;AAEpD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuCC,GAED,uEAAuE,GACvE,OAAO,MAAMC,yBAAyB,MAAK;AAE3C,4CAA4C,GAC5C,OAAO,MAAMC,2BAA2B,GAAE;AAE1C,qEAAqE,GACrE,OAAO,MAAMC,+BAA+B,kCAAiC;AAE7E,2EAA2E,GAC3E,OAAO,MAAMC,6BAA6B,6CAA4C;AAEtF,MAAMC,iBAAiB;AACvB,MAAMC,gBAAgB;AAEtB;;;;;CAKC,GACD,OAAO,SAASC;IACd,MAAMC,QAAQ,IAAIC,WAAWP;IAC7BQ,WAAWC,MAAM,CAACC,eAAe,CAACJ;IAClC,IAAIK,QAAQ;IACZ,KAAK,MAAMC,QAAQN,MAAOK,SAASR,cAAc,CAACS,OAAOT,eAAeU,MAAM,CAAC;IAC/E,OAAOF;AACT;AAEA,OAAO,SAASG,kBAAkBC,KAAc;IAC9C,OAAO,OAAOA,UAAU,YAAYX,cAAcY,IAAI,CAACD;AACzD;AAEA,yEAAyE,GACzE,OAAO,SAASE,kBAAkBN,KAAa,EAAEO,MAAc;IAC7D,OAAO,GAAGnB,uBAAuB,CAAC,EAAEY,MAAM,CAAC,EAAEO,QAAQ;AACvD;AAEA;;;;;CAKC,GACD,OAAO,SAASC,kBAAkBC,SAAkB,EAAEF,MAAc;IAClE,MAAMG,MAAMC,OAAOF,oBAAAA,YAAa,IAAIG,IAAI;IACxC,MAAMC,SAAS,iBAAiBC,IAAI,CAACJ;IACrC,MAAMK,UAAU,AAACF,CAAAA,SAASA,MAAM,CAAC,EAAE,GAAGH,GAAE,EAAGE,IAAI;IAC/C,MAAMI,KAAKD,QAAQE,WAAW,CAAC;IAC/B,IAAID,MAAM,GAAG,OAAO;IACpB,IAAID,QAAQG,KAAK,CAACF,KAAK,GAAGG,WAAW,OAAOZ,OAAOY,WAAW,IAAI,OAAO;IACzE,MAAMC,QAAQL,QAAQG,KAAK,CAAC,GAAGF,IAAIG,WAAW;IAC9C,MAAME,QAAQ,IAAIC,OAAO,CAAC,CAAC,EAAElC,uBAAuB,qBAAqB,CAAC,EAAE0B,IAAI,CAACM;IACjF,OAAOC,QAAQA,KAAK,CAAC,EAAE,GAAG;AAC5B;AAEA,yEAAyE,GACzE,OAAO,SAASE,mBACdC,UAA8B,EAC9BjB,MAAc;IAEd,MAAMkB,SAAmB,EAAE;IAC3B,KAAK,MAAMhB,aAAae,WAAY;QAClC,MAAMxB,QAAQQ,kBAAkBC,WAAWF;QAC3C,IAAIP,SAAS,CAACyB,OAAOC,QAAQ,CAAC1B,QAAQyB,OAAOE,IAAI,CAAC3B;IACpD;IACA,OAAOyB;AACT;AAEA,OAAO,SAASG,oBAAoBb,OAAgB,EAAER,MAAc;IAClE,OAAOC,kBAAkBO,SAASR,YAAY;AAChD;AAmCA;;;;;CAKC,GACD,OAAO,SAASsB,qBAAqBC,KAAgC;QAuB/CA;IAtBpB,MAAMC,UAAU,IAAIC;IACpB,KAAK,MAAMC,UAAUH,MAAMC,OAAO,CAAE;QAClC,KAAK,MAAMhB,WAAW5B,qBAAqB8C,QAASF,QAAQG,GAAG,CAACnB;IAClE;IACA,MAAMoB,SAASlD,eAAe6C,MAAMM,IAAI;IACxC,MAAMC,iBAAiBF,WAAW,QAAQJ,QAAQO,GAAG,CAACH;IACtD,MAAMI,OAAO,IAAIP;IACjB,MAAMQ,aAAoC,EAAE;IAC5C,MAAMC,WAAW,CACfrC,OACAsC,WACAC;QAEA,MAAMC,QAAQ3D,eAAemB;QAC7B,IAAI,CAACwC,SAASL,KAAKD,GAAG,CAACM,UAAUb,QAAQO,GAAG,CAACM,QAAQ;QACrD,IAAIhB,oBAAoBgB,OAAOd,MAAMvB,MAAM,GAAG;QAC9CgC,KAAKL,GAAG,CAACU;QACTJ,WAAWb,IAAI,CAAC;YAAEiB;YAAOF;YAAWC;QAAI;IAC1C;IACAF,SAASX,MAAMM,IAAI,EAAE,WAAW;IAChC,MAAMS,SAASR,iBAAiB,aAAa;IAC7C,KAAK,MAAMjC,SAAS0B,MAAMgB,EAAE,CAAEL,SAASrC,OAAOyC,QAAQ;IACtD,KAAK,MAAMzC,UAAS0B,YAAAA,MAAMiB,EAAE,YAARjB,YAAY,EAAE,CAAEW,SAASrC,OAAOyC,QAAQ;IAC5DJ,SAASX,MAAMkB,aAAa,EAAE,WAAW;IACzC,OAAO;QAAEb;QAAQE;QAAgBG;IAAW;AAC9C;AAEA;;;;;;CAMC,GACD,OAAO,SAASS,oBAAoBC,SAAkB,EAAEC,UAAmB;IACzE,MAAMC,KAAKzC,OAAOuC,oBAAAA,YAAa,IAAItC,IAAI;IACvC,IAAIwC,IAAI,OAAO,CAAC,IAAI,EAAEA,IAAI;IAC1B,MAAMC,WAAW1C,OAAOwC,qBAAAA,aAAc,IAAIvC,IAAI;IAC9C,OAAOyC,WAAW,CAAC,SAAS,EAAEA,UAAU,GAAG;AAC7C;AAwBA;;;;;;;;CAQC,GACD,OAAO,SAASC,8BAA8BxB,KAA4B;QAEjDA,gBAIEA,kBACPA,WACIA,eAIPA,gBAIKA;IAfpB,MAAM,EAAEyB,IAAI,EAAE,GAAGzB;IACjB,MAAM0B,UAAU7C,QAAOmB,iBAAAA,MAAM0B,OAAO,YAAb1B,iBAAiB,IACrC2B,OAAO,CAAC,QAAQ,KAChB7C,IAAI,GACJM,KAAK,CAAC,GAAGlC;IACZ,MAAM0E,YAAY/C,QAAOmB,mBAAAA,MAAM4B,SAAS,YAAf5B,mBAAmB,IAAIlB,IAAI;IACpD,MAAMkC,KAAKnC,QAAOmB,YAAAA,MAAMgB,EAAE,YAARhB,YAAY,IAAIlB,IAAI;IACtC,MAAM+C,SAAShD,QAAOmB,gBAAAA,MAAM6B,MAAM,YAAZ7B,gBAAgB,IAAIlB,IAAI;IAC9C,OAAO;QACLgD,MAAM;QACNJ;QACAK,MAAMlD,QAAOmB,iBAAAA,MAAMgC,OAAO,YAAbhC,iBAAiB,IAAIZ,KAAK,CAAC,GAAGnC;QAC3C2D,WAAWZ,MAAMY,SAAS;QAC1BN,MAAMN,MAAMM,IAAI;OACZU,KAAK;QAAEA;IAAG,IAAI,CAAC;QACnBI,WAAWvC,QAAOmB,mBAAAA,MAAMoB,SAAS,YAAfpB,mBAAmB,IAAIlB,IAAI;OACzC8C,YAAY;QAAEA;IAAU,IAAI,CAAC;QACjCxE,eAAeA,cAAcsE;QAC7BO,MAAMjC,MAAMiC,IAAI;QAChBC,OAAOlC,MAAMkC,KAAK;OACdL,SAAS;QAAEA;IAAO,IAAI,CAAC,GACvBJ,KAAKU,SAAS,GAAG;QAAEA,WAAWtD,OAAO4C,KAAKU,SAAS;IAAE,IAAI,CAAC,GAC1DV,KAAKW,SAAS,GAAG;QAAEA,WAAWvD,OAAO4C,KAAKW,SAAS;IAAE,IAAI,CAAC,GAC1DX,KAAKY,MAAM,GAAG;QAAEA,QAAQxD,OAAO4C,KAAKY,MAAM;IAAE,IAAI,CAAC,GACjDZ,KAAKa,MAAM,GAAG;QAAEA,QAAQzD,OAAO4C,KAAKa,MAAM;IAAE,IAAI,CAAC;QACrDC,QAAQvC,MAAMuC,MAAM;QACpBC,WAAW;eAAIxC,MAAMwC,SAAS;SAAC;;AAEnC"}
@@ -27,7 +27,7 @@ export declare const LEAD_IMPORT_REASON_MAX = 500;
27
27
  /**
28
28
  * The statuses a file may set.
29
29
  *
30
- * The list's own status control offers exactly these three, and for the
30
+ * The list's own status control offers exactly these four, and for the
31
31
  * reason given above: `qualified` is written by the conversion, beside the
32
32
  * contact it produced.
33
33
  */
@@ -64,7 +64,7 @@ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_wi
64
64
  * to belong to, is left off the lead and REPORTED under
65
65
  * {@link LeadImportRow.dropped}, so the operator learns that a column went
66
66
  * nowhere rather than discovering it weeks later.
67
- */ import { CAMPAIGN_MEMBERSHIP_CAP } from "@aglyn/aglyn/app-utils/campaign-membership";
67
+ */ import { CONTAINER_MEMBERSHIP_CAP } from "@aglyn/aglyn/app-utils/container-membership";
68
68
  import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
69
69
  import { CRM_LEAD_STATUS_LABELS, CRM_LEAD_STATUSES, CRM_LEAD_TEXT_MAX, isCrmLeadStatus, judgeCrmLeadSource, normalizeCrmPicklistLabel, normalizeCrmLeadTags, normalizeCompanyWebsite } from "@aglyn/aglyn/app-utils/crm";
70
70
  import { normalizeAddress, normalizePhone } from "@aglyn/aglyn/foundation/definitions/contact.types";
@@ -79,7 +79,7 @@ export const LEAD_IMPORT_REASON_MAX = 500;
79
79
  /**
80
80
  * The statuses a file may set.
81
81
  *
82
- * The list's own status control offers exactly these three, and for the
82
+ * The list's own status control offers exactly these four, and for the
83
83
  * reason given above: `qualified` is written by the conversion, beside the
84
84
  * contact it produced.
85
85
  */ export const LEAD_IMPORT_STATUSES = CRM_LEAD_STATUSES.filter((status)=>status !== 'qualified');
@@ -121,7 +121,7 @@ export const LEAD_IMPORT_REASON_MAX = 500;
121
121
  phone: 'Phone',
122
122
  website: 'Website',
123
123
  leadSource: 'Lead source',
124
- status: 'Status (new, working, unqualified)',
124
+ status: 'Status (new, nurturing, working, unqualified)',
125
125
  ownerEmail: 'Owner (team member email)',
126
126
  addressLine1: 'Address line 1',
127
127
  addressLine2: 'Address line 2',
@@ -296,7 +296,7 @@ const FIELD_ALIAS_KEYS = importAliasKeys(LEAD_IMPORT_FIELDS, FIELD_ALIASES);
296
296
  'lead-source-unknown': "Names a lead source that isn't one of this organization's active values",
297
297
  'write-failed': 'Could not be saved'
298
298
  };
299
- /** The most campaigns one row may name — the membership field's own cap. */ export const LEAD_IMPORT_CAMPAIGNS_MAX = CAMPAIGN_MEMBERSHIP_CAP;
299
+ /** The most campaigns one row may name — the membership field's own cap. */ export const LEAD_IMPORT_CAMPAIGNS_MAX = CONTAINER_MEMBERSHIP_CAP;
300
300
  /**
301
301
  * A status cell by id or by label — `working`, `Working` and `WORKING` are
302
302
  * one status. `null` for a cell naming no status a file may set, which
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-lead-import.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * BRINGING A SPREADSHEET OF LEADS INTO THE CRM — the pure half (AGL-2701).\n *\n * The contact import's three stages (`crm-import.ts`) over the lead\n * vocabulary. A lead is the one CRM record a person does not type: it is\n * what a capture door wrote when somebody signed up, booked or submitted a\n * form, plus the working state the team wrote on top. A file therefore\n * carries HALF a lead — the person, and the team's annotations — and the\n * capture half is the door's to write.\n *\n * ## The address is the identity, because it is the document id\n *\n * A lead lives at `hosts/{hostId}/leads/{personKey}`, and `personKey` is\n * the sha256 of the normalized address. So the address is not merely the\n * one required cell: it is the row's name. A cell the normalizer cannot\n * read is refused as `invalid-email`, and two rows of one file that\n * normalize to the same address are one person — the second is skipped as\n * `duplicate` rather than merged onto the first, for the reason the\n * contacts import gives: one person reported once created and once merged\n * is a number the operator cannot reconcile against the rows they sent.\n *\n * ## What a file may NOT say\n *\n * The export's `Sources`, `First seen`, `Last seen` and `Captures` columns\n * are the capture door's own record of what the visitor did, and\n * `Converted` is stamped by `crm/lead-convert` after the contact exists.\n * None of them is a field here, so the mapping leaves those columns\n * unmapped rather than mis-mapping them — a file that could write\n * \"first seen\" could rewrite the history it is supposed to describe. The\n * organization-level file's `Site` column is left out for a second reason:\n * the site is the drawer's question and the route's permission is granted\n * for that ONE site, so a cell must not be able to redirect a row to\n * another.\n *\n * ## Qualified is a conversion, not a status a cell can claim\n *\n * `qualified` means a lead BECAME a contact, and `convertedContactId`\n * beside it names the contact that was really created. Only the convert\n * route writes the pair, so the list's own status control offers new,\n * working and unqualified and nothing else. A cell naming `qualified` is\n * dropped and reported here for the same reason: a status claiming a\n * conversion that never happened would show a contact link to nowhere.\n *\n * ## A bad cell is dropped and named; a bad row is skipped and named\n *\n * The address is the only cell that can make a row unstorable. An\n * unreadable status, or an unqualified reason with no unqualified status\n * to belong to, is left off the lead and REPORTED under\n * {@link LeadImportRow.dropped}, so the operator learns that a column went\n * nowhere rather than discovering it weeks later.\n */\n\nimport { CAMPAIGN_MEMBERSHIP_CAP } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport {\n CRM_LEAD_STATUS_LABELS,\n CRM_LEAD_STATUSES,\n CRM_LEAD_TEXT_MAX,\n type CrmLeadProfile,\n type CrmLeadStatus,\n type CrmPicklist,\n isCrmLeadStatus,\n judgeCrmLeadSource,\n normalizeCrmPicklistLabel,\n normalizeCrmLeadTags,\n normalizeCompanyWebsite,\n} from '@aglyn/aglyn/app-utils/crm'\nimport {\n normalizeAddress,\n normalizePhone,\n} from '@aglyn/aglyn/foundation/definitions/contact.types'\nimport {\n CSV_IMPORT_CHUNK_SIZE,\n CSV_IMPORT_MAX_BODY_BYTES,\n CSV_IMPORT_MAX_ROWS,\n CSV_IMPORT_PREVIEW_ROWS,\n emptyImportResult,\n guessImportMapping,\n importAliasKeys,\n type ImportChunkResult,\n type ImportDroppedValue,\n importSkippedCsv,\n importTextValue,\n mapImportRow,\n mergeImportResults,\n} from '@aglyn/aglyn/app-utils/csv-import'\n\n/** The shared ceilings, under this collection's names. */\nexport const LEAD_IMPORT_MAX_ROWS = CSV_IMPORT_MAX_ROWS\nexport const LEAD_IMPORT_CHUNK_SIZE = CSV_IMPORT_CHUNK_SIZE\nexport const LEAD_IMPORT_MAX_BODY_BYTES = CSV_IMPORT_MAX_BODY_BYTES\nexport const LEAD_IMPORT_PREVIEW_ROWS = CSV_IMPORT_PREVIEW_ROWS\n\n/** The same caps the lead's own cards hold a typed value to. */\nexport const LEAD_IMPORT_NOTES_MAX = 4000\nexport const LEAD_IMPORT_REASON_MAX = 500\n\n/** A person's name, capped where every other import caps one. */\nconst NAME_MAX = 120\n\n/**\n * The statuses a file may set.\n *\n * The list's own status control offers exactly these three, and for the\n * reason given above: `qualified` is written by the conversion, beside the\n * contact it produced.\n */\nexport const LEAD_IMPORT_STATUSES: readonly CrmLeadStatus[] =\n CRM_LEAD_STATUSES.filter((status) => status !== 'qualified')\n\n/**\n * The fields a column may be mapped to, in the order the mapping menu\n * lists them.\n *\n * The lead's own profile (AGL-3231) — company, title, phone, website, the\n * lead source, the address parts and tags — is what a list from another\n * tool actually carries, and the reason a lead can be worked without a\n * contact beside it. They read under the same labels the contacts import\n * uses, so one spreadsheet maps the same way into either.\n */\nexport const LEAD_IMPORT_FIELDS = [\n 'email',\n 'name',\n 'company',\n 'jobTitle',\n 'phone',\n 'website',\n 'leadSource',\n 'status',\n 'ownerEmail',\n 'addressLine1',\n 'addressLine2',\n 'addressCity',\n 'addressState',\n 'addressPostalCode',\n 'addressCountry',\n 'tags',\n 'campaigns',\n 'unqualifiedReason',\n 'notes',\n] as const\n\nexport type LeadImportField = (typeof LEAD_IMPORT_FIELDS)[number]\n\n/** How each field reads in the mapping menu. Typed so a field cannot ship unlabeled. */\nexport const LEAD_IMPORT_FIELD_LABELS: Record<LeadImportField, string> = {\n email: 'Email (required)',\n name: 'Name',\n company: 'Company name',\n jobTitle: 'Job title',\n phone: 'Phone',\n website: 'Website',\n leadSource: 'Lead source',\n status: 'Status (new, working, unqualified)',\n ownerEmail: 'Owner (team member email)',\n addressLine1: 'Address line 1',\n addressLine2: 'Address line 2',\n addressCity: 'City',\n addressState: 'State or region',\n addressPostalCode: 'Postal code',\n addressCountry: 'Country (two-letter code)',\n tags: 'Tags (comma or | separated)',\n campaigns: 'Campaigns (by name, comma or | separated)',\n unqualifiedReason: 'Unqualified reason',\n notes: 'Notes',\n}\n\n/**\n * Header aliases per field, matched after the shared header normalization.\n *\n * First in each list is the header this CRM's own leads export writes, so\n * an export re-imports without a hand mapping; the export's Sources, First\n * seen, Last seen, Captures, Converted and Site columns are deliberately\n * absent from every list, so they land on \"Do not import\".\n */\nconst FIELD_ALIASES: Record<LeadImportField, readonly string[]> = {\n email: ['email', 'email address', 'e mail', 'mail', 'contact email', 'work email'],\n name: ['name', 'full name', 'lead', 'lead name', 'contact', 'contact name', 'person'],\n company: [\n 'company',\n 'company name',\n 'organization',\n 'organisation',\n 'account',\n 'account name',\n 'employer',\n ],\n jobTitle: ['job title', 'title', 'position', 'role', 'headline'],\n phone: ['phone', 'phone number', 'mobile', 'mobile phone', 'telephone', 'tel', 'cell'],\n website: ['website', 'web site', 'url', 'company website', 'company domain', 'domain'],\n leadSource: ['lead source', 'source', 'origin', 'channel', 'campaign'],\n status: ['status', 'lead status', 'stage', 'lead stage'],\n ownerEmail: ['owner', 'owner email', 'assigned to', 'assignee', 'rep', 'sales rep'],\n addressLine1: ['address', 'address line 1', 'street', 'street address', 'address 1'],\n addressLine2: ['address line 2', 'address 2', 'suite', 'apartment'],\n addressCity: ['city', 'town', 'locality'],\n addressState: ['state', 'state or region', 'region', 'province', 'county'],\n addressPostalCode: ['postal code', 'postcode', 'zip', 'zip code'],\n addressCountry: ['country', 'country code'],\n tags: ['tags', 'tag', 'labels', 'lists'],\n // `campaign` alone stays the lead source's: another tool's \"Campaign\"\n // column names where the person came from, not one of this site's\n // containers (AGL-3254).\n campaigns: ['campaigns', 'campaign names', 'in campaigns', 'email campaigns'],\n unqualifiedReason: [\n 'unqualified reason',\n 'reason',\n 'disqualified reason',\n 'lost reason',\n 'reason lost',\n ],\n notes: ['notes', 'note', 'description', 'comments', 'details'],\n}\n\nconst FIELD_ALIAS_KEYS = importAliasKeys(LEAD_IMPORT_FIELDS, FIELD_ALIASES)\n\n/** Column index → field. A column absent from the map is not imported. */\nexport type LeadImportMapping = Record<number, LeadImportField>\n\n/** A proposed mapping from a file's header row, each field taken at most once. */\nexport function guessLeadImportMapping(columns: readonly string[]): LeadImportMapping {\n return guessImportMapping(columns, LEAD_IMPORT_FIELDS, FIELD_ALIAS_KEYS)\n}\n\n/**\n * What the browser posts for one line: the cells the mapping selected,\n * under the field they were mapped to, verbatim. `unknown` because the\n * server reads this off an untrusted body.\n */\nexport type LeadImportRawRow = Partial<Record<LeadImportField, unknown>>\n\n/** One parsed line under the mapping. Empty cells are left absent; a lead has no custom fields. */\nexport function mapLeadImportRow(\n cells: readonly string[],\n mapping: Record<number, LeadImportField | `custom:${string}`>,\n): LeadImportRawRow {\n const { custom: _custom, ...row } = mapImportRow(cells, mapping)\n return row\n}\n\nexport type LeadImportSkipReason =\n | 'invalid-email'\n | 'duplicate'\n | 'lead-ceiling'\n | 'campaign-unknown'\n | 'lead-source-unknown'\n | 'write-failed'\n\n/** How a skip reason reads on screen and in the downloaded file. */\nexport const LEAD_IMPORT_SKIP_LABELS: Record<LeadImportSkipReason, string> = {\n 'invalid-email': 'No usable email address',\n duplicate: 'The same address appears earlier in this file',\n 'lead-ceiling': 'This site is at the platform lead limit',\n 'campaign-unknown': 'Names a campaign this site does not have',\n 'lead-source-unknown':\n \"Names a lead source that isn't one of this organization's active values\",\n 'write-failed': 'Could not be saved',\n}\n\n/** The most campaigns one row may name — the membership field's own cap. */\nexport const LEAD_IMPORT_CAMPAIGNS_MAX = CAMPAIGN_MEMBERSHIP_CAP\n\n/** One row, ready for the server to resolve and write. */\nexport interface LeadImportRow {\n /** Normalized — the address `personKey` derives the document id from. */\n email: string\n name?: string\n /**\n * The lead's own profile, already normalized the way the record stores\n * it (AGL-3231): a key present is a value to write, a key absent leaves\n * an existing lead's value alone. Never a clear — a blank cell in a file\n * is a cell nobody filled, not a decision to erase what the site knows.\n */\n profile: CrmLeadProfile\n /**\n * The campaigns the row files the lead under (AGL-3254), by NAME as the\n * file wrote them: the server resolves each against the site's own\n * containers and refuses the row whole when one is not there, because a\n * row filed under half its campaigns is a row nobody asked for.\n */\n campaigns?: string[]\n /** Absent leaves an existing lead's status alone and a new one reading as `new`. */\n status?: CrmLeadStatus\n /** Normalized, for the server to resolve against the org's members. */\n ownerEmail?: string\n /** Only ever set beside `status: 'unqualified'` — the pair is one fact. */\n unqualifiedReason?: string\n notes?: string\n dropped: ImportDroppedValue[]\n}\n\nexport type LeadImportRowVerdict =\n | { ok: true; row: LeadImportRow }\n | { ok: false; reason: 'invalid-email'; input: string }\n\n/**\n * A status cell by id or by label — `working`, `Working` and `WORKING` are\n * one status. `null` for a cell naming no status a file may set, which\n * includes the well-spelled `Qualified` the export writes for a converted\n * lead.\n */\nexport function parseImportLeadStatus(value: unknown): CrmLeadStatus | null {\n const text = String(value ?? '')\n .trim()\n .toLowerCase()\n if (!text) return null\n const byId = isCrmLeadStatus(text) ? text : null\n const byLabel =\n LEAD_IMPORT_STATUSES.find(\n (status) => CRM_LEAD_STATUS_LABELS[status].toLowerCase() === text,\n ) ?? null\n const status = byId ?? byLabel\n return status && LEAD_IMPORT_STATUSES.includes(status) ? status : null\n}\n\n/**\n * One raw row as the values that will be written, or the reason it cannot\n * be. Refused here only for an unusable address, because the address is\n * the document id; the owner is refused by the server, which alone can\n * look them up.\n */\nexport function normalizeLeadImportRow(raw: LeadImportRawRow): LeadImportRowVerdict {\n const emailText = importTextValue(raw.email, 320) ?? ''\n const email = normalizeContactEmail(emailText)\n if (!email) {\n return { ok: false, reason: 'invalid-email', input: emailText }\n }\n const dropped: ImportDroppedValue[] = []\n const drop = (field: LeadImportField, value: unknown) => {\n dropped.push({ field, value: String(value ?? '').trim() })\n }\n const row: LeadImportRow = { email, profile: {}, dropped }\n\n const name = importTextValue(raw.name, NAME_MAX)?.replace(/\\s+/g, ' ')\n if (name) row.name = name\n\n /*\n * The profile (AGL-3231), field by field, each through the normalizer\n * the record's own card runs. A phone or a website the normalizer cannot\n * read is dropped and reported rather than stored as typed, because the\n * record renders both as links; the country is the one address part the\n * address normalizer drops silently — a typed name is not a code — so it\n * is the one part the report has to name.\n */\n const company = importTextValue(raw.company, CRM_LEAD_TEXT_MAX)?.replace(/\\s+/g, ' ')\n if (company) row.profile.company = company\n const jobTitle = importTextValue(raw.jobTitle, CRM_LEAD_TEXT_MAX)?.replace(/\\s+/g, ' ')\n if (jobTitle) row.profile.jobTitle = jobTitle\n const leadSource = importTextValue(raw.leadSource, CRM_LEAD_TEXT_MAX)?.replace(\n /\\s+/g,\n ' ',\n )\n if (leadSource) row.profile.leadSource = leadSource\n const phoneText = importTextValue(raw.phone, 64)\n if (phoneText) {\n const phone = normalizePhone(phoneText)\n if (phone) row.profile.phone = phone\n else drop('phone', phoneText)\n }\n const websiteText = importTextValue(raw.website, 320)\n if (websiteText) {\n const website = normalizeCompanyWebsite(websiteText)\n if (website) row.profile.website = website\n else drop('website', websiteText)\n }\n const address = normalizeAddress({\n line1: importTextValue(raw.addressLine1, 200),\n line2: importTextValue(raw.addressLine2, 200),\n city: importTextValue(raw.addressCity, 120),\n state: importTextValue(raw.addressState, 120),\n postalCode: importTextValue(raw.addressPostalCode, 32),\n country: importTextValue(raw.addressCountry, 8),\n })\n if (address) row.profile.address = address\n const countryText = importTextValue(raw.addressCountry, 64)\n if (countryText && !address?.country) drop('addressCountry', countryText)\n const tags = normalizeCrmLeadTags(\n String(raw.tags ?? '')\n .split(/[,|]/)\n .map((tag) => tag.trim()),\n )\n if (tags.length) row.profile.tags = tags\n\n // Names, trimmed and deduplicated as typed; the server matches them to\n // the site's campaigns without regard to case.\n const campaigns = [\n ...new Set(\n String(raw.campaigns ?? '')\n .split(/[,|]/)\n .map((name) => name.trim().replace(/\\s+/g, ' '))\n .filter(Boolean),\n ),\n ].slice(0, LEAD_IMPORT_CAMPAIGNS_MAX)\n if (campaigns.length) row.campaigns = campaigns\n\n const statusText = importTextValue(raw.status, 32)\n if (statusText) {\n const status = parseImportLeadStatus(statusText)\n if (status) row.status = status\n else drop('status', statusText)\n }\n\n const ownerText = importTextValue(raw.ownerEmail, 320)\n if (ownerText) {\n const owner = normalizeContactEmail(ownerText)\n if (owner) row.ownerEmail = owner\n else drop('ownerEmail', ownerText)\n }\n\n /*\n * The reason belongs to the status: it is what an unqualified lead was\n * closed FOR, and on a lead that is not unqualified it describes\n * nothing. A row that carries one without the other keeps the status and\n * reports the reason, so a mis-mapped column is visible rather than\n * stored where nothing reads it.\n */\n const reasonText = importTextValue(raw.unqualifiedReason, LEAD_IMPORT_REASON_MAX)\n if (reasonText) {\n if (row.status === 'unqualified') row.unqualifiedReason = reasonText\n else drop('unqualifiedReason', reasonText)\n }\n\n const notes = importTextValue(raw.notes, LEAD_IMPORT_NOTES_MAX)\n if (notes) row.notes = notes\n\n return { ok: true, row }\n}\n\n/** One row the server did not store, by its index in the request, named by the address. */\nexport interface LeadImportSkippedRow {\n index: number\n email: string\n reason: LeadImportSkipReason\n}\n\n/** What one request did. The drawer sums these across a file. */\nexport type LeadImportChunkResult = ImportChunkResult<LeadImportSkippedRow>\n\nexport function emptyLeadImportResult(): LeadImportChunkResult {\n return emptyImportResult<LeadImportSkippedRow>()\n}\n\nexport function mergeLeadImportResults(\n total: LeadImportChunkResult,\n chunk: LeadImportChunkResult,\n offset = 0,\n): LeadImportChunkResult {\n return mergeImportResults(total, chunk, offset)\n}\n\n/** The skipped rows as a file the operator can fix and re-import. */\nexport function leadImportSkippedCsv(\n columns: readonly string[],\n entries: readonly { cells: readonly string[]; reason: LeadImportSkipReason }[],\n): string {\n return importSkippedCsv(columns, entries, LEAD_IMPORT_SKIP_LABELS)\n}\n\n/**\n * The lead sources a file names that the org's list would refuse\n * (AGL-3298), each once, as the file spelled it — the drawer's warning\n * before anything is sent. Judged the way the server judges a NEW lead,\n * so a value the list holds but has deactivated is named too; a row\n * merging onto a lead that already holds that value would still pass.\n */\nexport function leadImportUnknownLeadSources(\n rows: readonly LeadImportRawRow[],\n picklist: CrmPicklist,\n): string[] {\n const unknown = new Map<string, string>()\n for (const row of rows) {\n const label = normalizeCrmPicklistLabel(row.leadSource)\n if (!label || unknown.has(label.toLowerCase())) continue\n if (!judgeCrmLeadSource(picklist, label).ok) unknown.set(label.toLowerCase(), label)\n }\n return [...unknown.values()]\n}\n"],"names":["CAMPAIGN_MEMBERSHIP_CAP","normalizeContactEmail","CRM_LEAD_STATUS_LABELS","CRM_LEAD_STATUSES","CRM_LEAD_TEXT_MAX","isCrmLeadStatus","judgeCrmLeadSource","normalizeCrmPicklistLabel","normalizeCrmLeadTags","normalizeCompanyWebsite","normalizeAddress","normalizePhone","CSV_IMPORT_CHUNK_SIZE","CSV_IMPORT_MAX_BODY_BYTES","CSV_IMPORT_MAX_ROWS","CSV_IMPORT_PREVIEW_ROWS","emptyImportResult","guessImportMapping","importAliasKeys","importSkippedCsv","importTextValue","mapImportRow","mergeImportResults","LEAD_IMPORT_MAX_ROWS","LEAD_IMPORT_CHUNK_SIZE","LEAD_IMPORT_MAX_BODY_BYTES","LEAD_IMPORT_PREVIEW_ROWS","LEAD_IMPORT_NOTES_MAX","LEAD_IMPORT_REASON_MAX","NAME_MAX","LEAD_IMPORT_STATUSES","filter","status","LEAD_IMPORT_FIELDS","LEAD_IMPORT_FIELD_LABELS","email","name","company","jobTitle","phone","website","leadSource","ownerEmail","addressLine1","addressLine2","addressCity","addressState","addressPostalCode","addressCountry","tags","campaigns","unqualifiedReason","notes","FIELD_ALIASES","FIELD_ALIAS_KEYS","guessLeadImportMapping","columns","mapLeadImportRow","cells","mapping","custom","_custom","row","LEAD_IMPORT_SKIP_LABELS","duplicate","LEAD_IMPORT_CAMPAIGNS_MAX","parseImportLeadStatus","value","text","String","trim","toLowerCase","byId","byLabel","find","includes","normalizeLeadImportRow","raw","emailText","ok","reason","input","dropped","drop","field","push","profile","replace","phoneText","websiteText","address","line1","line2","city","state","postalCode","country","countryText","split","map","tag","length","Set","Boolean","slice","statusText","ownerText","owner","reasonText","emptyLeadImportResult","mergeLeadImportResults","total","chunk","offset","leadImportSkippedCsv","entries","leadImportUnknownLeadSources","rows","picklist","unknown","Map","label","has","set","values"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-lead-import.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * BRINGING A SPREADSHEET OF LEADS INTO THE CRM — the pure half (AGL-2701).\n *\n * The contact import's three stages (`crm-import.ts`) over the lead\n * vocabulary. A lead is the one CRM record a person does not type: it is\n * what a capture door wrote when somebody signed up, booked or submitted a\n * form, plus the working state the team wrote on top. A file therefore\n * carries HALF a lead — the person, and the team's annotations — and the\n * capture half is the door's to write.\n *\n * ## The address is the identity, because it is the document id\n *\n * A lead lives at `hosts/{hostId}/leads/{personKey}`, and `personKey` is\n * the sha256 of the normalized address. So the address is not merely the\n * one required cell: it is the row's name. A cell the normalizer cannot\n * read is refused as `invalid-email`, and two rows of one file that\n * normalize to the same address are one person — the second is skipped as\n * `duplicate` rather than merged onto the first, for the reason the\n * contacts import gives: one person reported once created and once merged\n * is a number the operator cannot reconcile against the rows they sent.\n *\n * ## What a file may NOT say\n *\n * The export's `Sources`, `First seen`, `Last seen` and `Captures` columns\n * are the capture door's own record of what the visitor did, and\n * `Converted` is stamped by `crm/lead-convert` after the contact exists.\n * None of them is a field here, so the mapping leaves those columns\n * unmapped rather than mis-mapping them — a file that could write\n * \"first seen\" could rewrite the history it is supposed to describe. The\n * organization-level file's `Site` column is left out for a second reason:\n * the site is the drawer's question and the route's permission is granted\n * for that ONE site, so a cell must not be able to redirect a row to\n * another.\n *\n * ## Qualified is a conversion, not a status a cell can claim\n *\n * `qualified` means a lead BECAME a contact, and `convertedContactId`\n * beside it names the contact that was really created. Only the convert\n * route writes the pair, so the list's own status control offers new,\n * working and unqualified and nothing else. A cell naming `qualified` is\n * dropped and reported here for the same reason: a status claiming a\n * conversion that never happened would show a contact link to nowhere.\n *\n * ## A bad cell is dropped and named; a bad row is skipped and named\n *\n * The address is the only cell that can make a row unstorable. An\n * unreadable status, or an unqualified reason with no unqualified status\n * to belong to, is left off the lead and REPORTED under\n * {@link LeadImportRow.dropped}, so the operator learns that a column went\n * nowhere rather than discovering it weeks later.\n */\n\nimport { CONTAINER_MEMBERSHIP_CAP } from '@aglyn/aglyn/app-utils/container-membership'\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport {\n CRM_LEAD_STATUS_LABELS,\n CRM_LEAD_STATUSES,\n CRM_LEAD_TEXT_MAX,\n type CrmLeadProfile,\n type CrmLeadStatus,\n type CrmPicklist,\n isCrmLeadStatus,\n judgeCrmLeadSource,\n normalizeCrmPicklistLabel,\n normalizeCrmLeadTags,\n normalizeCompanyWebsite,\n} from '@aglyn/aglyn/app-utils/crm'\nimport {\n normalizeAddress,\n normalizePhone,\n} from '@aglyn/aglyn/foundation/definitions/contact.types'\nimport {\n CSV_IMPORT_CHUNK_SIZE,\n CSV_IMPORT_MAX_BODY_BYTES,\n CSV_IMPORT_MAX_ROWS,\n CSV_IMPORT_PREVIEW_ROWS,\n emptyImportResult,\n guessImportMapping,\n importAliasKeys,\n type ImportChunkResult,\n type ImportDroppedValue,\n importSkippedCsv,\n importTextValue,\n mapImportRow,\n mergeImportResults,\n} from '@aglyn/aglyn/app-utils/csv-import'\n\n/** The shared ceilings, under this collection's names. */\nexport const LEAD_IMPORT_MAX_ROWS = CSV_IMPORT_MAX_ROWS\nexport const LEAD_IMPORT_CHUNK_SIZE = CSV_IMPORT_CHUNK_SIZE\nexport const LEAD_IMPORT_MAX_BODY_BYTES = CSV_IMPORT_MAX_BODY_BYTES\nexport const LEAD_IMPORT_PREVIEW_ROWS = CSV_IMPORT_PREVIEW_ROWS\n\n/** The same caps the lead's own cards hold a typed value to. */\nexport const LEAD_IMPORT_NOTES_MAX = 4000\nexport const LEAD_IMPORT_REASON_MAX = 500\n\n/** A person's name, capped where every other import caps one. */\nconst NAME_MAX = 120\n\n/**\n * The statuses a file may set.\n *\n * The list's own status control offers exactly these four, and for the\n * reason given above: `qualified` is written by the conversion, beside the\n * contact it produced.\n */\nexport const LEAD_IMPORT_STATUSES: readonly CrmLeadStatus[] =\n CRM_LEAD_STATUSES.filter((status) => status !== 'qualified')\n\n/**\n * The fields a column may be mapped to, in the order the mapping menu\n * lists them.\n *\n * The lead's own profile (AGL-3231) — company, title, phone, website, the\n * lead source, the address parts and tags — is what a list from another\n * tool actually carries, and the reason a lead can be worked without a\n * contact beside it. They read under the same labels the contacts import\n * uses, so one spreadsheet maps the same way into either.\n */\nexport const LEAD_IMPORT_FIELDS = [\n 'email',\n 'name',\n 'company',\n 'jobTitle',\n 'phone',\n 'website',\n 'leadSource',\n 'status',\n 'ownerEmail',\n 'addressLine1',\n 'addressLine2',\n 'addressCity',\n 'addressState',\n 'addressPostalCode',\n 'addressCountry',\n 'tags',\n 'campaigns',\n 'unqualifiedReason',\n 'notes',\n] as const\n\nexport type LeadImportField = (typeof LEAD_IMPORT_FIELDS)[number]\n\n/** How each field reads in the mapping menu. Typed so a field cannot ship unlabeled. */\nexport const LEAD_IMPORT_FIELD_LABELS: Record<LeadImportField, string> = {\n email: 'Email (required)',\n name: 'Name',\n company: 'Company name',\n jobTitle: 'Job title',\n phone: 'Phone',\n website: 'Website',\n leadSource: 'Lead source',\n status: 'Status (new, nurturing, working, unqualified)',\n ownerEmail: 'Owner (team member email)',\n addressLine1: 'Address line 1',\n addressLine2: 'Address line 2',\n addressCity: 'City',\n addressState: 'State or region',\n addressPostalCode: 'Postal code',\n addressCountry: 'Country (two-letter code)',\n tags: 'Tags (comma or | separated)',\n campaigns: 'Campaigns (by name, comma or | separated)',\n unqualifiedReason: 'Unqualified reason',\n notes: 'Notes',\n}\n\n/**\n * Header aliases per field, matched after the shared header normalization.\n *\n * First in each list is the header this CRM's own leads export writes, so\n * an export re-imports without a hand mapping; the export's Sources, First\n * seen, Last seen, Captures, Converted and Site columns are deliberately\n * absent from every list, so they land on \"Do not import\".\n */\nconst FIELD_ALIASES: Record<LeadImportField, readonly string[]> = {\n email: ['email', 'email address', 'e mail', 'mail', 'contact email', 'work email'],\n name: ['name', 'full name', 'lead', 'lead name', 'contact', 'contact name', 'person'],\n company: [\n 'company',\n 'company name',\n 'organization',\n 'organisation',\n 'account',\n 'account name',\n 'employer',\n ],\n jobTitle: ['job title', 'title', 'position', 'role', 'headline'],\n phone: ['phone', 'phone number', 'mobile', 'mobile phone', 'telephone', 'tel', 'cell'],\n website: ['website', 'web site', 'url', 'company website', 'company domain', 'domain'],\n leadSource: ['lead source', 'source', 'origin', 'channel', 'campaign'],\n status: ['status', 'lead status', 'stage', 'lead stage'],\n ownerEmail: ['owner', 'owner email', 'assigned to', 'assignee', 'rep', 'sales rep'],\n addressLine1: ['address', 'address line 1', 'street', 'street address', 'address 1'],\n addressLine2: ['address line 2', 'address 2', 'suite', 'apartment'],\n addressCity: ['city', 'town', 'locality'],\n addressState: ['state', 'state or region', 'region', 'province', 'county'],\n addressPostalCode: ['postal code', 'postcode', 'zip', 'zip code'],\n addressCountry: ['country', 'country code'],\n tags: ['tags', 'tag', 'labels', 'lists'],\n // `campaign` alone stays the lead source's: another tool's \"Campaign\"\n // column names where the person came from, not one of this site's\n // containers (AGL-3254).\n campaigns: ['campaigns', 'campaign names', 'in campaigns', 'email campaigns'],\n unqualifiedReason: [\n 'unqualified reason',\n 'reason',\n 'disqualified reason',\n 'lost reason',\n 'reason lost',\n ],\n notes: ['notes', 'note', 'description', 'comments', 'details'],\n}\n\nconst FIELD_ALIAS_KEYS = importAliasKeys(LEAD_IMPORT_FIELDS, FIELD_ALIASES)\n\n/** Column index → field. A column absent from the map is not imported. */\nexport type LeadImportMapping = Record<number, LeadImportField>\n\n/** A proposed mapping from a file's header row, each field taken at most once. */\nexport function guessLeadImportMapping(columns: readonly string[]): LeadImportMapping {\n return guessImportMapping(columns, LEAD_IMPORT_FIELDS, FIELD_ALIAS_KEYS)\n}\n\n/**\n * What the browser posts for one line: the cells the mapping selected,\n * under the field they were mapped to, verbatim. `unknown` because the\n * server reads this off an untrusted body.\n */\nexport type LeadImportRawRow = Partial<Record<LeadImportField, unknown>>\n\n/** One parsed line under the mapping. Empty cells are left absent; a lead has no custom fields. */\nexport function mapLeadImportRow(\n cells: readonly string[],\n mapping: Record<number, LeadImportField | `custom:${string}`>,\n): LeadImportRawRow {\n const { custom: _custom, ...row } = mapImportRow(cells, mapping)\n return row\n}\n\nexport type LeadImportSkipReason =\n | 'invalid-email'\n | 'duplicate'\n | 'lead-ceiling'\n | 'campaign-unknown'\n | 'lead-source-unknown'\n | 'write-failed'\n\n/** How a skip reason reads on screen and in the downloaded file. */\nexport const LEAD_IMPORT_SKIP_LABELS: Record<LeadImportSkipReason, string> = {\n 'invalid-email': 'No usable email address',\n duplicate: 'The same address appears earlier in this file',\n 'lead-ceiling': 'This site is at the platform lead limit',\n 'campaign-unknown': 'Names a campaign this site does not have',\n 'lead-source-unknown':\n \"Names a lead source that isn't one of this organization's active values\",\n 'write-failed': 'Could not be saved',\n}\n\n/** The most campaigns one row may name — the membership field's own cap. */\nexport const LEAD_IMPORT_CAMPAIGNS_MAX = CONTAINER_MEMBERSHIP_CAP\n\n/** One row, ready for the server to resolve and write. */\nexport interface LeadImportRow {\n /** Normalized — the address `personKey` derives the document id from. */\n email: string\n name?: string\n /**\n * The lead's own profile, already normalized the way the record stores\n * it (AGL-3231): a key present is a value to write, a key absent leaves\n * an existing lead's value alone. Never a clear — a blank cell in a file\n * is a cell nobody filled, not a decision to erase what the site knows.\n */\n profile: CrmLeadProfile\n /**\n * The campaigns the row files the lead under (AGL-3254), by NAME as the\n * file wrote them: the server resolves each against the site's own\n * containers and refuses the row whole when one is not there, because a\n * row filed under half its campaigns is a row nobody asked for.\n */\n campaigns?: string[]\n /** Absent leaves an existing lead's status alone and a new one reading as `new`. */\n status?: CrmLeadStatus\n /** Normalized, for the server to resolve against the org's members. */\n ownerEmail?: string\n /** Only ever set beside `status: 'unqualified'` — the pair is one fact. */\n unqualifiedReason?: string\n notes?: string\n dropped: ImportDroppedValue[]\n}\n\nexport type LeadImportRowVerdict =\n | { ok: true; row: LeadImportRow }\n | { ok: false; reason: 'invalid-email'; input: string }\n\n/**\n * A status cell by id or by label — `working`, `Working` and `WORKING` are\n * one status. `null` for a cell naming no status a file may set, which\n * includes the well-spelled `Qualified` the export writes for a converted\n * lead.\n */\nexport function parseImportLeadStatus(value: unknown): CrmLeadStatus | null {\n const text = String(value ?? '')\n .trim()\n .toLowerCase()\n if (!text) return null\n const byId = isCrmLeadStatus(text) ? text : null\n const byLabel =\n LEAD_IMPORT_STATUSES.find(\n (status) => CRM_LEAD_STATUS_LABELS[status].toLowerCase() === text,\n ) ?? null\n const status = byId ?? byLabel\n return status && LEAD_IMPORT_STATUSES.includes(status) ? status : null\n}\n\n/**\n * One raw row as the values that will be written, or the reason it cannot\n * be. Refused here only for an unusable address, because the address is\n * the document id; the owner is refused by the server, which alone can\n * look them up.\n */\nexport function normalizeLeadImportRow(raw: LeadImportRawRow): LeadImportRowVerdict {\n const emailText = importTextValue(raw.email, 320) ?? ''\n const email = normalizeContactEmail(emailText)\n if (!email) {\n return { ok: false, reason: 'invalid-email', input: emailText }\n }\n const dropped: ImportDroppedValue[] = []\n const drop = (field: LeadImportField, value: unknown) => {\n dropped.push({ field, value: String(value ?? '').trim() })\n }\n const row: LeadImportRow = { email, profile: {}, dropped }\n\n const name = importTextValue(raw.name, NAME_MAX)?.replace(/\\s+/g, ' ')\n if (name) row.name = name\n\n /*\n * The profile (AGL-3231), field by field, each through the normalizer\n * the record's own card runs. A phone or a website the normalizer cannot\n * read is dropped and reported rather than stored as typed, because the\n * record renders both as links; the country is the one address part the\n * address normalizer drops silently — a typed name is not a code — so it\n * is the one part the report has to name.\n */\n const company = importTextValue(raw.company, CRM_LEAD_TEXT_MAX)?.replace(/\\s+/g, ' ')\n if (company) row.profile.company = company\n const jobTitle = importTextValue(raw.jobTitle, CRM_LEAD_TEXT_MAX)?.replace(/\\s+/g, ' ')\n if (jobTitle) row.profile.jobTitle = jobTitle\n const leadSource = importTextValue(raw.leadSource, CRM_LEAD_TEXT_MAX)?.replace(\n /\\s+/g,\n ' ',\n )\n if (leadSource) row.profile.leadSource = leadSource\n const phoneText = importTextValue(raw.phone, 64)\n if (phoneText) {\n const phone = normalizePhone(phoneText)\n if (phone) row.profile.phone = phone\n else drop('phone', phoneText)\n }\n const websiteText = importTextValue(raw.website, 320)\n if (websiteText) {\n const website = normalizeCompanyWebsite(websiteText)\n if (website) row.profile.website = website\n else drop('website', websiteText)\n }\n const address = normalizeAddress({\n line1: importTextValue(raw.addressLine1, 200),\n line2: importTextValue(raw.addressLine2, 200),\n city: importTextValue(raw.addressCity, 120),\n state: importTextValue(raw.addressState, 120),\n postalCode: importTextValue(raw.addressPostalCode, 32),\n country: importTextValue(raw.addressCountry, 8),\n })\n if (address) row.profile.address = address\n const countryText = importTextValue(raw.addressCountry, 64)\n if (countryText && !address?.country) drop('addressCountry', countryText)\n const tags = normalizeCrmLeadTags(\n String(raw.tags ?? '')\n .split(/[,|]/)\n .map((tag) => tag.trim()),\n )\n if (tags.length) row.profile.tags = tags\n\n // Names, trimmed and deduplicated as typed; the server matches them to\n // the site's campaigns without regard to case.\n const campaigns = [\n ...new Set(\n String(raw.campaigns ?? '')\n .split(/[,|]/)\n .map((name) => name.trim().replace(/\\s+/g, ' '))\n .filter(Boolean),\n ),\n ].slice(0, LEAD_IMPORT_CAMPAIGNS_MAX)\n if (campaigns.length) row.campaigns = campaigns\n\n const statusText = importTextValue(raw.status, 32)\n if (statusText) {\n const status = parseImportLeadStatus(statusText)\n if (status) row.status = status\n else drop('status', statusText)\n }\n\n const ownerText = importTextValue(raw.ownerEmail, 320)\n if (ownerText) {\n const owner = normalizeContactEmail(ownerText)\n if (owner) row.ownerEmail = owner\n else drop('ownerEmail', ownerText)\n }\n\n /*\n * The reason belongs to the status: it is what an unqualified lead was\n * closed FOR, and on a lead that is not unqualified it describes\n * nothing. A row that carries one without the other keeps the status and\n * reports the reason, so a mis-mapped column is visible rather than\n * stored where nothing reads it.\n */\n const reasonText = importTextValue(raw.unqualifiedReason, LEAD_IMPORT_REASON_MAX)\n if (reasonText) {\n if (row.status === 'unqualified') row.unqualifiedReason = reasonText\n else drop('unqualifiedReason', reasonText)\n }\n\n const notes = importTextValue(raw.notes, LEAD_IMPORT_NOTES_MAX)\n if (notes) row.notes = notes\n\n return { ok: true, row }\n}\n\n/** One row the server did not store, by its index in the request, named by the address. */\nexport interface LeadImportSkippedRow {\n index: number\n email: string\n reason: LeadImportSkipReason\n}\n\n/** What one request did. The drawer sums these across a file. */\nexport type LeadImportChunkResult = ImportChunkResult<LeadImportSkippedRow>\n\nexport function emptyLeadImportResult(): LeadImportChunkResult {\n return emptyImportResult<LeadImportSkippedRow>()\n}\n\nexport function mergeLeadImportResults(\n total: LeadImportChunkResult,\n chunk: LeadImportChunkResult,\n offset = 0,\n): LeadImportChunkResult {\n return mergeImportResults(total, chunk, offset)\n}\n\n/** The skipped rows as a file the operator can fix and re-import. */\nexport function leadImportSkippedCsv(\n columns: readonly string[],\n entries: readonly { cells: readonly string[]; reason: LeadImportSkipReason }[],\n): string {\n return importSkippedCsv(columns, entries, LEAD_IMPORT_SKIP_LABELS)\n}\n\n/**\n * The lead sources a file names that the org's list would refuse\n * (AGL-3298), each once, as the file spelled it — the drawer's warning\n * before anything is sent. Judged the way the server judges a NEW lead,\n * so a value the list holds but has deactivated is named too; a row\n * merging onto a lead that already holds that value would still pass.\n */\nexport function leadImportUnknownLeadSources(\n rows: readonly LeadImportRawRow[],\n picklist: CrmPicklist,\n): string[] {\n const unknown = new Map<string, string>()\n for (const row of rows) {\n const label = normalizeCrmPicklistLabel(row.leadSource)\n if (!label || unknown.has(label.toLowerCase())) continue\n if (!judgeCrmLeadSource(picklist, label).ok) unknown.set(label.toLowerCase(), label)\n }\n return [...unknown.values()]\n}\n"],"names":["CONTAINER_MEMBERSHIP_CAP","normalizeContactEmail","CRM_LEAD_STATUS_LABELS","CRM_LEAD_STATUSES","CRM_LEAD_TEXT_MAX","isCrmLeadStatus","judgeCrmLeadSource","normalizeCrmPicklistLabel","normalizeCrmLeadTags","normalizeCompanyWebsite","normalizeAddress","normalizePhone","CSV_IMPORT_CHUNK_SIZE","CSV_IMPORT_MAX_BODY_BYTES","CSV_IMPORT_MAX_ROWS","CSV_IMPORT_PREVIEW_ROWS","emptyImportResult","guessImportMapping","importAliasKeys","importSkippedCsv","importTextValue","mapImportRow","mergeImportResults","LEAD_IMPORT_MAX_ROWS","LEAD_IMPORT_CHUNK_SIZE","LEAD_IMPORT_MAX_BODY_BYTES","LEAD_IMPORT_PREVIEW_ROWS","LEAD_IMPORT_NOTES_MAX","LEAD_IMPORT_REASON_MAX","NAME_MAX","LEAD_IMPORT_STATUSES","filter","status","LEAD_IMPORT_FIELDS","LEAD_IMPORT_FIELD_LABELS","email","name","company","jobTitle","phone","website","leadSource","ownerEmail","addressLine1","addressLine2","addressCity","addressState","addressPostalCode","addressCountry","tags","campaigns","unqualifiedReason","notes","FIELD_ALIASES","FIELD_ALIAS_KEYS","guessLeadImportMapping","columns","mapLeadImportRow","cells","mapping","custom","_custom","row","LEAD_IMPORT_SKIP_LABELS","duplicate","LEAD_IMPORT_CAMPAIGNS_MAX","parseImportLeadStatus","value","text","String","trim","toLowerCase","byId","byLabel","find","includes","normalizeLeadImportRow","raw","emailText","ok","reason","input","dropped","drop","field","push","profile","replace","phoneText","websiteText","address","line1","line2","city","state","postalCode","country","countryText","split","map","tag","length","Set","Boolean","slice","statusText","ownerText","owner","reasonText","emptyLeadImportResult","mergeLeadImportResults","total","chunk","offset","leadImportSkippedCsv","entries","leadImportUnknownLeadSources","rows","picklist","unknown","Map","label","has","set","values"],"mappings":"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"}