fullstackgtm 0.38.0 → 0.39.0
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/CHANGELOG.md +38 -0
- package/dist/acquireLinkedIn.js +3 -0
- package/dist/cli.js +13 -4
- package/dist/connectors/hubspot.js +16 -3
- package/dist/connectors/prospectSources.d.ts +20 -0
- package/dist/connectors/prospectSources.js +67 -0
- package/dist/connectors/salesforce.js +120 -25
- package/dist/enrich.js +1 -1
- package/dist/icp.d.ts +1 -0
- package/dist/icp.js +5 -1
- package/docs/api.md +8 -0
- package/package.json +1 -1
- package/src/acquireLinkedIn.ts +3 -0
- package/src/cli.ts +13 -3
- package/src/connectors/hubspot.ts +16 -3
- package/src/connectors/prospectSources.ts +75 -0
- package/src/connectors/salesforce.ts +123 -26
- package/src/enrich.ts +1 -1
- package/src/icp.ts +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,44 @@ The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md)
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.39.0] — 2026-06-23
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **LinkedIn acquire: configurable + email-resolving.** `linkedin` is now a
|
|
15
|
+
first-class `SUPPORTED_API_SOURCES` entry, so an explicit `enrich.config.json`
|
|
16
|
+
can drive it (set discovery `size` to read a whole list, not the preset's 25).
|
|
17
|
+
Email resolution is no longer gated on the dedupe key being `email`: any source
|
|
18
|
+
can opt in with `acquire.discovery.<src>.resolveEmailsWith: "pipe0"`, so a
|
|
19
|
+
LinkedIn source (which keys on the profile URL) can still resolve real work
|
|
20
|
+
emails (name + company → pipe0 waterfall) and create outreach-ready contacts.
|
|
21
|
+
- **Company-domain resolution for email enrichment** (`pipe0ResolveCompanyDomains`,
|
|
22
|
+
`hostFromUrl`). The work-email waterfall requires a company domain, but a
|
|
23
|
+
LinkedIn/HeyReach list supplies only company names — so the email step now
|
|
24
|
+
first resolves each unique company's domain via pipe0 `company:identity@1`
|
|
25
|
+
(name → website → bare host) before the waterfall. Unresolved companies are
|
|
26
|
+
left untouched (never a guessed domain), and the lookup is deduped per company.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- **ICP scoring reads the LinkedIn headline, not just the formal title.**
|
|
31
|
+
`scoreProspectAgainstIcp` now matches title keywords across `jobTitle` *and*
|
|
32
|
+
`headline`. On LinkedIn-sourced prospects the role signal often lives only in
|
|
33
|
+
the headline (position "Founder", headline "… | RevOps | …"), which scoring on
|
|
34
|
+
the title alone would miss. `Prospect` gains a `headline` field.
|
|
35
|
+
|
|
36
|
+
## [0.38.1] — 2026-06-23
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- **`enrich acquire` LinkedIn creates failed with HubSpot 400.** The resolve-first
|
|
41
|
+
search before a `create_record` filtered on the *canonical* match key
|
|
42
|
+
(`linkedin`) instead of the HubSpot property name (`hs_linkedin_url`); HubSpot
|
|
43
|
+
400s on a filter against a property it doesn't have, so every LinkedIn-sourced
|
|
44
|
+
lead failed at apply. The connector now translates the match key through
|
|
45
|
+
`HUBSPOT_DEFAULT_FIELD_MAPPINGS.contacts` before searching (email is unaffected
|
|
46
|
+
— its canonical name already equals the property name). Regression test added.
|
|
47
|
+
|
|
10
48
|
## [0.38.0] — 2026-06-23
|
|
11
49
|
|
|
12
50
|
### Added
|
package/dist/acquireLinkedIn.js
CHANGED
|
@@ -25,6 +25,9 @@ export function linkedInProspectToProspect(lp) {
|
|
|
25
25
|
fullName: lp.fullName,
|
|
26
26
|
// Title drives ICP scoring; fall back to the headline when no title is set.
|
|
27
27
|
jobTitle: lp.jobTitle ?? lp.headline,
|
|
28
|
+
// Keep the headline distinct too: the scorer matches keywords across both,
|
|
29
|
+
// so a role named only in the headline (not the position) still scores.
|
|
30
|
+
headline: lp.headline,
|
|
28
31
|
companyName: lp.company,
|
|
29
32
|
linkedin: lp.profileUrl || undefined,
|
|
30
33
|
email: lp.email,
|
package/dist/cli.js
CHANGED
|
@@ -32,7 +32,7 @@ import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
|
|
|
32
32
|
import { DEFAULT_RUBRIC, classifyCallLlm, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
|
|
33
33
|
import { buildAcquirePlan, buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, builtinAcquirePreset, builtinEnrichPreset, enrichRunId, inferIngestObjectType, latestStamps, loadEnrichConfig, parseCsv, resolveCrmField, selectStaleWork, stagedSourceRecords, staleDaysFor, } from "./enrich.js";
|
|
34
34
|
import { loadMeter, recordConsumption, remaining, } from "./acquireMeter.js";
|
|
35
|
-
import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspects, partitionFreshProspects, pipe0ResolveWorkEmails, prospectIdentityKeys, } from "./connectors/prospectSources.js";
|
|
35
|
+
import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspects, partitionFreshProspects, pipe0ResolveCompanyDomains, pipe0ResolveWorkEmails, prospectIdentityKeys, } from "./connectors/prospectSources.js";
|
|
36
36
|
import { loadSeen, recordSeen } from "./acquireSeen.js";
|
|
37
37
|
import { reportCounts, reportEvent } from "./runReport.js";
|
|
38
38
|
import { createLinkedInProvider, discoverLinkedInProspects } from "./acquireLinkedIn.js";
|
|
@@ -2096,9 +2096,18 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen) {
|
|
|
2096
2096
|
// and apply-time resolve-first remain the precise backstop.
|
|
2097
2097
|
const { fresh, skippedCrm, skippedSeen } = partitionFreshProspects(prospects, crmContactKeys(snapshot), seen);
|
|
2098
2098
|
prospects = fresh;
|
|
2099
|
-
// 3. Resolve real work emails
|
|
2100
|
-
// hashed, Crustdata
|
|
2101
|
-
|
|
2099
|
+
// 3. Resolve real work emails. Triggered either when email IS the dedupe key
|
|
2100
|
+
// (Explorium's email is hashed, Crustdata returns none) or when a source
|
|
2101
|
+
// that keys on something else opts in via `resolveEmailsWith: "pipe0"`
|
|
2102
|
+
// (e.g. LinkedIn keys on the profile URL but still wants outreach emails).
|
|
2103
|
+
// pipe0 waterfall, chunked; resolves from name + company domain/name.
|
|
2104
|
+
if (matchKey === "email" || disc.resolveEmailsWith === "pipe0") {
|
|
2105
|
+
// The waterfall needs a company DOMAIN; LinkedIn/HeyReach lists carry only
|
|
2106
|
+
// names. Resolve domains first (pipe0 company:identity) so resolution can
|
|
2107
|
+
// actually land — without it, name-only resolution fails for every lead.
|
|
2108
|
+
if (prospects.some((p) => !p.companyDomain && p.companyName)) {
|
|
2109
|
+
prospects = await pipe0ResolveCompanyDomains({ apiKey: providerKey("pipe0"), prospects });
|
|
2110
|
+
}
|
|
2102
2111
|
prospects = await pipe0ResolveWorkEmails({ apiKey: providerKey("pipe0"), prospects });
|
|
2103
2112
|
}
|
|
2104
2113
|
const processedKeys = prospects.flatMap(prospectIdentityKeys);
|
|
@@ -493,13 +493,17 @@ export function createHubspotConnector(options) {
|
|
|
493
493
|
}
|
|
494
494
|
return { operationId: operation.id, status: "applied", detail: `Resolved/created company "${matchValue}" (${id}).`, providerData: { id } };
|
|
495
495
|
}
|
|
496
|
-
// contact — resolve-first on the dedupe key (email by default)
|
|
496
|
+
// contact — resolve-first on the dedupe key (email by default). The match
|
|
497
|
+
// key is canonical (e.g. "linkedin"); translate it to the HubSpot property
|
|
498
|
+
// name ("hs_linkedin_url") before searching — HubSpot 400s on a filter
|
|
499
|
+
// against a property it doesn't have (there is no property named "linkedin").
|
|
497
500
|
const matchKey = payload.matchKey || "email";
|
|
501
|
+
const searchProperty = HUBSPOT_DEFAULT_FIELD_MAPPINGS.contacts[matchKey] ?? matchKey;
|
|
498
502
|
const matchKeyLower = `${matchKey}:${matchValue.toLowerCase()}`;
|
|
499
503
|
if (createdContactsByMatch.has(matchKeyLower)) {
|
|
500
504
|
return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already created earlier in this run; not duplicating.`, providerData: { id: createdContactsByMatch.get(matchKeyLower), existing: true } };
|
|
501
505
|
}
|
|
502
|
-
const existing = await searchContactsBy(
|
|
506
|
+
const existing = await searchContactsBy(searchProperty, matchValue);
|
|
503
507
|
if (existing.length > 0) {
|
|
504
508
|
createdContactsByMatch.set(matchKeyLower, existing[0]);
|
|
505
509
|
return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already exists (${existing.join(", ")}); resolve-first declined to create.`, providerData: { id: existing[0], existing: true } };
|
|
@@ -781,7 +785,16 @@ export function createHubspotConnector(options) {
|
|
|
781
785
|
const defaults = HUBSPOT_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
|
|
782
786
|
const property = mappedField(mappings, mappingType, field, defaults[field] ?? field);
|
|
783
787
|
const data = await request(`/crm/v3/objects/${objectPath}/${encodeURIComponent(objectId)}?properties=${encodeURIComponent(property)}`);
|
|
784
|
-
|
|
788
|
+
const value = data?.properties?.[property] ?? null;
|
|
789
|
+
// fetchSnapshot normalizes closeDate to a date with `?.split("T")[0]`, so the
|
|
790
|
+
// value baked into an op's beforeValue is date-only ("2026-03-07"). The
|
|
791
|
+
// single-object read here returns HubSpot's raw "2026-03-07T00:00:00Z", which
|
|
792
|
+
// made compare-and-set see a spurious drift and refuse every date-field write.
|
|
793
|
+
// Mirror the snapshot's normalization so the comparison is apples-to-apples.
|
|
794
|
+
if (field === "closeDate" && typeof value === "string") {
|
|
795
|
+
return value.split("T")[0];
|
|
796
|
+
}
|
|
797
|
+
return value;
|
|
785
798
|
}
|
|
786
799
|
return {
|
|
787
800
|
provider: "hubspot",
|
|
@@ -19,6 +19,9 @@ export type Prospect = {
|
|
|
19
19
|
lastName?: string;
|
|
20
20
|
fullName?: string;
|
|
21
21
|
jobTitle?: string;
|
|
22
|
+
/** LinkedIn headline — secondary role signal for ICP scoring (the title
|
|
23
|
+
* keyword may live here, not in the formal jobTitle). */
|
|
24
|
+
headline?: string;
|
|
22
25
|
/** normalized seniority, e.g. "cxo","vp","director" (Explorium job_level_main) */
|
|
23
26
|
jobLevel?: string;
|
|
24
27
|
/** normalized department, e.g. "sales" (Explorium job_department_main) */
|
|
@@ -68,6 +71,23 @@ export declare function pipe0ResolveWorkEmails(opts: {
|
|
|
68
71
|
* for every row, so one big batch is all-or-nothing). Default 3. */
|
|
69
72
|
chunkSize?: number;
|
|
70
73
|
}): Promise<Prospect[]>;
|
|
74
|
+
/** Bare registrable host from a URL or domain string ("https://www.x.com/a" → "x.com"). */
|
|
75
|
+
export declare function hostFromUrl(url: string | undefined): string | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* Fill `companyDomain` for prospects that have a company NAME but no domain,
|
|
78
|
+
* using pipe0's `company:identity@1` pipe (name → website URL). The work-email
|
|
79
|
+
* waterfall requires a domain — a LinkedIn/HeyReach list supplies only names, so
|
|
80
|
+
* without this step every resolution fails. Resolves each unique company once
|
|
81
|
+
* (companies repeat across a list); leaves a prospect untouched when the domain
|
|
82
|
+
* can't be found, so the waterfall simply skips it rather than guessing.
|
|
83
|
+
*/
|
|
84
|
+
export declare function pipe0ResolveCompanyDomains(opts: {
|
|
85
|
+
apiKey: string;
|
|
86
|
+
prospects: Prospect[];
|
|
87
|
+
apiBaseUrl?: string;
|
|
88
|
+
fetchImpl?: FetchImpl;
|
|
89
|
+
chunkSize?: number;
|
|
90
|
+
}): Promise<Prospect[]>;
|
|
71
91
|
/**
|
|
72
92
|
* Stable identity keys for a prospect (prefixed by kind). A match on ANY key
|
|
73
93
|
* means "same person". LinkedIn is strongest; name+domain is the only key
|
|
@@ -144,6 +144,73 @@ export async function pipe0ResolveWorkEmails(opts) {
|
|
|
144
144
|
function personKey(name, company) {
|
|
145
145
|
return `${(name ?? "").trim().toLowerCase()}|${(company ?? "").trim().toLowerCase()}`;
|
|
146
146
|
}
|
|
147
|
+
/** Bare registrable host from a URL or domain string ("https://www.x.com/a" → "x.com"). */
|
|
148
|
+
export function hostFromUrl(url) {
|
|
149
|
+
if (!url || !url.trim())
|
|
150
|
+
return undefined;
|
|
151
|
+
try {
|
|
152
|
+
const u = url.includes("://") ? url : `https://${url}`;
|
|
153
|
+
const host = new URL(u).hostname.replace(/^www\./, "");
|
|
154
|
+
return host || undefined;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Fill `companyDomain` for prospects that have a company NAME but no domain,
|
|
162
|
+
* using pipe0's `company:identity@1` pipe (name → website URL). The work-email
|
|
163
|
+
* waterfall requires a domain — a LinkedIn/HeyReach list supplies only names, so
|
|
164
|
+
* without this step every resolution fails. Resolves each unique company once
|
|
165
|
+
* (companies repeat across a list); leaves a prospect untouched when the domain
|
|
166
|
+
* can't be found, so the waterfall simply skips it rather than guessing.
|
|
167
|
+
*/
|
|
168
|
+
export async function pipe0ResolveCompanyDomains(opts) {
|
|
169
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
170
|
+
const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
|
|
171
|
+
const chunkSize = Math.max(1, opts.chunkSize ?? 5);
|
|
172
|
+
const uniqueNames = [
|
|
173
|
+
...new Set(opts.prospects
|
|
174
|
+
.filter((p) => !p.companyDomain && p.companyName)
|
|
175
|
+
.map((p) => p.companyName.trim())
|
|
176
|
+
.filter(Boolean)),
|
|
177
|
+
];
|
|
178
|
+
if (uniqueNames.length === 0)
|
|
179
|
+
return opts.prospects;
|
|
180
|
+
const domainByName = new Map();
|
|
181
|
+
for (let i = 0; i < uniqueNames.length; i += chunkSize) {
|
|
182
|
+
const chunk = uniqueNames.slice(i, i + chunkSize);
|
|
183
|
+
try {
|
|
184
|
+
const response = await fetchImpl(`${base}/v1/pipes/run/sync`, {
|
|
185
|
+
method: "POST",
|
|
186
|
+
headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
|
|
187
|
+
body: JSON.stringify({
|
|
188
|
+
pipes: [{ pipe_id: "company:identity@1" }],
|
|
189
|
+
input: chunk.map((company_name) => ({ company_name })),
|
|
190
|
+
}),
|
|
191
|
+
});
|
|
192
|
+
if (!response.ok)
|
|
193
|
+
continue;
|
|
194
|
+
const body = (await response.json());
|
|
195
|
+
for (const recordId of body.order ?? []) {
|
|
196
|
+
const fields = body.records?.[recordId]?.fields ?? {};
|
|
197
|
+
const name = fieldValue(fields.company_name) ?? fieldValue(fields.cleaned_company_name);
|
|
198
|
+
const domain = hostFromUrl(fieldValue(fields.company_website_url));
|
|
199
|
+
if (name && domain)
|
|
200
|
+
domainByName.set(name.trim().toLowerCase(), domain);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return opts.prospects.map((p) => {
|
|
208
|
+
if (p.companyDomain || !p.companyName)
|
|
209
|
+
return p;
|
|
210
|
+
const domain = domainByName.get(p.companyName.trim().toLowerCase());
|
|
211
|
+
return domain ? { ...p, companyDomain: domain } : p;
|
|
212
|
+
});
|
|
213
|
+
}
|
|
147
214
|
function fieldValue(field) {
|
|
148
215
|
const v = field?.value;
|
|
149
216
|
return typeof v === "string" && v.trim() ? v : undefined;
|
|
@@ -31,6 +31,37 @@ export function createSalesforceConnector(options) {
|
|
|
31
31
|
const mappings = options.fieldMappings;
|
|
32
32
|
// create:<Name> dedup within one connector lifetime (one apply run).
|
|
33
33
|
const createdAccountsByName = new Map();
|
|
34
|
+
// create_record contact dedup (keyed by matchKey:matchValue), same lifetime.
|
|
35
|
+
const createdContactsByMatch = new Map();
|
|
36
|
+
// Resolve an Account by exact name: a unique existing match is reused, a
|
|
37
|
+
// confirmed miss is created, and an ambiguous name (>1 match) is refused.
|
|
38
|
+
// Caches within the run so the same name is never created twice — shared by
|
|
39
|
+
// `link_record`'s create:<Name> path and `create_record`'s company linking.
|
|
40
|
+
async function resolveOrCreateAccountByName(name) {
|
|
41
|
+
const nameKey = name.toLowerCase();
|
|
42
|
+
const cached = createdAccountsByName.get(nameKey);
|
|
43
|
+
if (cached)
|
|
44
|
+
return { id: cached, createdNew: false };
|
|
45
|
+
const soqlName = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
46
|
+
const matches = await query(`SELECT Id FROM Account WHERE Name = '${soqlName}' LIMIT 3`);
|
|
47
|
+
if (matches.length > 1)
|
|
48
|
+
return { ambiguous: matches.length };
|
|
49
|
+
let id;
|
|
50
|
+
let createdNew = false;
|
|
51
|
+
if (matches.length === 1) {
|
|
52
|
+
id = String(matches[0].Id);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
body: JSON.stringify({ Name: name }),
|
|
58
|
+
});
|
|
59
|
+
id = String(created.id);
|
|
60
|
+
createdNew = true;
|
|
61
|
+
}
|
|
62
|
+
createdAccountsByName.set(nameKey, id);
|
|
63
|
+
return { id, createdNew };
|
|
64
|
+
}
|
|
34
65
|
async function request(path, init = {}) {
|
|
35
66
|
const connection = await options.getConnection();
|
|
36
67
|
const url = path.startsWith("http")
|
|
@@ -379,6 +410,85 @@ export function createSalesforceConnector(options) {
|
|
|
379
410
|
providerData: { survivorId: master, mergedRecordIds: merged },
|
|
380
411
|
};
|
|
381
412
|
}
|
|
413
|
+
// Resolve-first net-new lead creation (the `enrich acquire` path). Re-resolves
|
|
414
|
+
// on matchKey/matchValue at apply time — search is the source of truth — and
|
|
415
|
+
// creates only on a confirmed miss, so concurrent writers never get duplicated.
|
|
416
|
+
// Mirrors the HubSpot connector's createRecord contract.
|
|
417
|
+
async function createRecord(operation) {
|
|
418
|
+
const payload = operation.afterValue;
|
|
419
|
+
if (!payload || typeof payload !== "object" || !payload.properties) {
|
|
420
|
+
return { operationId: operation.id, status: "skipped", detail: "create_record needs a CreateRecordPayload afterValue." };
|
|
421
|
+
}
|
|
422
|
+
if (operation.objectType !== "contact" && operation.objectType !== "account") {
|
|
423
|
+
return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts and accounts." };
|
|
424
|
+
}
|
|
425
|
+
const matchValue = String(payload.matchValue ?? "").trim();
|
|
426
|
+
if (!matchValue) {
|
|
427
|
+
return { operationId: operation.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." };
|
|
428
|
+
}
|
|
429
|
+
if (operation.objectType === "account") {
|
|
430
|
+
const resolved = await resolveOrCreateAccountByName(matchValue);
|
|
431
|
+
if ("ambiguous" in resolved) {
|
|
432
|
+
return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — ${resolved.ambiguous} accounts named "${matchValue}". Not creating.` };
|
|
433
|
+
}
|
|
434
|
+
return { operationId: operation.id, status: "applied", detail: `Resolved/created account "${matchValue}" (${resolved.id}).`, providerData: { id: resolved.id, created: resolved.createdNew } };
|
|
435
|
+
}
|
|
436
|
+
// contact — resolve-first on the dedupe key (email by default). matchKey is
|
|
437
|
+
// canonical; translate it to the Salesforce field before the SOQL search.
|
|
438
|
+
const matchKey = payload.matchKey || "email";
|
|
439
|
+
const searchField = mappedField(mappings, "contacts", matchKey, SALESFORCE_DEFAULT_FIELD_MAPPINGS.contacts[matchKey] ?? matchKey);
|
|
440
|
+
const matchKeyLower = `${matchKey}:${matchValue.toLowerCase()}`;
|
|
441
|
+
if (createdContactsByMatch.has(matchKeyLower)) {
|
|
442
|
+
return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already created earlier in this run; not duplicating.`, providerData: { id: createdContactsByMatch.get(matchKeyLower), existing: true } };
|
|
443
|
+
}
|
|
444
|
+
const soqlValue = matchValue.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
445
|
+
const existing = await query(`SELECT Id FROM Contact WHERE ${searchField} = '${soqlValue}' LIMIT 5`);
|
|
446
|
+
if (existing.length > 0) {
|
|
447
|
+
const existingId = String(existing[0].Id);
|
|
448
|
+
createdContactsByMatch.set(matchKeyLower, existingId);
|
|
449
|
+
return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already exists (${existing.map((r) => String(r.Id)).join(", ")}); resolve-first declined to create.`, providerData: { id: existingId, existing: true } };
|
|
450
|
+
}
|
|
451
|
+
const fields = { ...payload.properties };
|
|
452
|
+
// Stamp ownership at create time so the lead is never born ownerless. The
|
|
453
|
+
// canonical ownerId maps to Salesforce's OwnerId.
|
|
454
|
+
if (payload.ownerId) {
|
|
455
|
+
const ownerField = mappedField(mappings, "contacts", "ownerId", SALESFORCE_DEFAULT_FIELD_MAPPINGS.contacts.ownerId ?? "OwnerId");
|
|
456
|
+
if (!fields[ownerField])
|
|
457
|
+
fields[ownerField] = String(payload.ownerId);
|
|
458
|
+
}
|
|
459
|
+
const created = await request(`/services/data/${apiVersion}/sobjects/Contact`, {
|
|
460
|
+
method: "POST",
|
|
461
|
+
body: JSON.stringify(fields),
|
|
462
|
+
});
|
|
463
|
+
const contactId = String(created.id);
|
|
464
|
+
createdContactsByMatch.set(matchKeyLower, contactId);
|
|
465
|
+
let companyNote = "";
|
|
466
|
+
if (payload.associateCompanyName) {
|
|
467
|
+
try {
|
|
468
|
+
const resolved = await resolveOrCreateAccountByName(payload.associateCompanyName);
|
|
469
|
+
if ("ambiguous" in resolved) {
|
|
470
|
+
companyNote = ` Account "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
await request(`/services/data/${apiVersion}/sobjects/Contact/${encodeURIComponent(contactId)}`, {
|
|
474
|
+
method: "PATCH",
|
|
475
|
+
body: JSON.stringify({ AccountId: resolved.id }),
|
|
476
|
+
});
|
|
477
|
+
companyNote = ` Linked to account "${payload.associateCompanyName}" (${resolved.id}).`;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
// Association is best-effort — the contact create already succeeded.
|
|
482
|
+
companyNote = ` (account link failed: ${error instanceof Error ? error.message : String(error)})`;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
operationId: operation.id,
|
|
487
|
+
status: "applied",
|
|
488
|
+
detail: `Created contact ${matchKey}=${matchValue} (${contactId}).${companyNote}`,
|
|
489
|
+
providerData: { id: contactId, created: true },
|
|
490
|
+
};
|
|
491
|
+
}
|
|
382
492
|
async function applyOperation(operation) {
|
|
383
493
|
try {
|
|
384
494
|
switch (operation.operation) {
|
|
@@ -396,32 +506,15 @@ export function createSalesforceConnector(options) {
|
|
|
396
506
|
if (!name) {
|
|
397
507
|
return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
|
|
398
508
|
}
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
return {
|
|
407
|
-
operationId: operation.id,
|
|
408
|
-
status: "skipped",
|
|
409
|
-
detail: `create:${name} is ambiguous — ${matches.length} accounts already named "${name}". Link an explicit account id instead.`,
|
|
410
|
-
};
|
|
411
|
-
}
|
|
412
|
-
if (matches.length === 1) {
|
|
413
|
-
accountId = String(matches[0].Id);
|
|
414
|
-
}
|
|
415
|
-
else {
|
|
416
|
-
const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
|
|
417
|
-
method: "POST",
|
|
418
|
-
body: JSON.stringify({ Name: name }),
|
|
419
|
-
});
|
|
420
|
-
accountId = String(created.id);
|
|
421
|
-
createdNew = true;
|
|
422
|
-
}
|
|
423
|
-
createdAccountsByName.set(nameKey, accountId);
|
|
509
|
+
const resolved = await resolveOrCreateAccountByName(name);
|
|
510
|
+
if ("ambiguous" in resolved) {
|
|
511
|
+
return {
|
|
512
|
+
operationId: operation.id,
|
|
513
|
+
status: "skipped",
|
|
514
|
+
detail: `create:${name} is ambiguous — ${resolved.ambiguous} accounts already named "${name}". Link an explicit account id instead.`,
|
|
515
|
+
};
|
|
424
516
|
}
|
|
517
|
+
const { id: accountId, createdNew } = resolved;
|
|
425
518
|
const result = await setField({ ...operation, operation: "set_field", afterValue: accountId });
|
|
426
519
|
return result.status === "applied"
|
|
427
520
|
? {
|
|
@@ -441,6 +534,8 @@ export function createSalesforceConnector(options) {
|
|
|
441
534
|
return await mergeRecords(operation);
|
|
442
535
|
case "archive_record":
|
|
443
536
|
return await archiveRecord(operation);
|
|
537
|
+
case "create_record":
|
|
538
|
+
return await createRecord(operation);
|
|
444
539
|
default:
|
|
445
540
|
return {
|
|
446
541
|
operationId: operation.id,
|
package/dist/enrich.js
CHANGED
|
@@ -12,7 +12,7 @@ const MATCH_KEYS = {
|
|
|
12
12
|
contact: ["email", "name", "linkedin"],
|
|
13
13
|
};
|
|
14
14
|
/** API source ids the MVP can pull from. */
|
|
15
|
-
export const SUPPORTED_API_SOURCES = ["apollo", "explorium", "pipe0"];
|
|
15
|
+
export const SUPPORTED_API_SOURCES = ["apollo", "explorium", "pipe0", "linkedin"];
|
|
16
16
|
/**
|
|
17
17
|
* Canonical fields enrich may target, plus the HubSpot property spellings the
|
|
18
18
|
* config may use for them (so `"crm": "numberofemployees"` and
|
package/dist/icp.d.ts
CHANGED
|
@@ -69,6 +69,7 @@ export declare function scoreProspectAgainstIcp(prospect: {
|
|
|
69
69
|
jobTitle?: string;
|
|
70
70
|
jobLevel?: string;
|
|
71
71
|
jobDepartment?: string;
|
|
72
|
+
headline?: string;
|
|
72
73
|
}, icp: Icp): IcpFit;
|
|
73
74
|
export declare function fitThreshold(icp: Icp): number;
|
|
74
75
|
export type IcpInterviewQuestion = {
|
package/dist/icp.js
CHANGED
|
@@ -131,7 +131,11 @@ function titleCase(value) {
|
|
|
131
131
|
*/
|
|
132
132
|
export function scoreProspectAgainstIcp(prospect, icp) {
|
|
133
133
|
const reasons = [];
|
|
134
|
-
|
|
134
|
+
// Match title keywords across BOTH the formal title and the headline: on
|
|
135
|
+
// LinkedIn-sourced prospects the role signal often lives only in the headline
|
|
136
|
+
// (e.g. position "Founder" but headline "… | RevOps | …"), so scoring on the
|
|
137
|
+
// title alone would miss genuine in-persona leads.
|
|
138
|
+
const title = `${prospect.jobTitle ?? ""} ${prospect.headline ?? ""}`.trim().toLowerCase();
|
|
135
139
|
const keywords = (icp.persona.titleKeywords ?? []).map((k) => k.toLowerCase());
|
|
136
140
|
const levels = (icp.persona.jobLevels ?? []).map((l) => l.toLowerCase());
|
|
137
141
|
const depts = (icp.persona.departments ?? []).map((d) => d.toLowerCase());
|
package/docs/api.md
CHANGED
|
@@ -149,6 +149,14 @@ zero-config `EnrichConfig.acquire` (budget, provider, create-mapping) so
|
|
|
149
149
|
pulls a HeyReach lead list and `linkedInProspectToProspect` maps each lead to the
|
|
150
150
|
canonical `Prospect` (match key = LinkedIn URL), so ICP scoring / dedup / meter /
|
|
151
151
|
apply are reused unchanged. Phase 1 is discovery-only — read-only, never sends.
|
|
152
|
+
`linkedin` is a `SUPPORTED_API_SOURCES` source, so an explicit config can set
|
|
153
|
+
discovery `size` (read a whole list, not the preset's 25). A LinkedIn list
|
|
154
|
+
carries no emails; opt into resolution with
|
|
155
|
+
`acquire.discovery.linkedin.resolveEmailsWith: "pipe0"` — email resolution is
|
|
156
|
+
no longer gated on the dedupe key being `email`, so a profile-URL-keyed source
|
|
157
|
+
still creates outreach-ready, emailed contacts. ICP scoring matches title
|
|
158
|
+
keywords across `Prospect.jobTitle` **and** `headline` (LinkedIn roles often
|
|
159
|
+
live in the headline, not the formal title).
|
|
152
160
|
- **Meter** (`acquireMeter.ts`): `AcquireBudget`, `loadMeter` / `remaining` /
|
|
153
161
|
`recordConsumption` — per-profile windowed record+spend budget, charged only
|
|
154
162
|
for landed creates.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.0",
|
|
4
4
|
"description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
|
package/src/acquireLinkedIn.ts
CHANGED
|
@@ -33,6 +33,9 @@ export function linkedInProspectToProspect(lp: LinkedInProspect): Prospect {
|
|
|
33
33
|
fullName: lp.fullName,
|
|
34
34
|
// Title drives ICP scoring; fall back to the headline when no title is set.
|
|
35
35
|
jobTitle: lp.jobTitle ?? lp.headline,
|
|
36
|
+
// Keep the headline distinct too: the scorer matches keywords across both,
|
|
37
|
+
// so a role named only in the headline (not the position) still scores.
|
|
38
|
+
headline: lp.headline,
|
|
36
39
|
companyName: lp.company,
|
|
37
40
|
linkedin: lp.profileUrl || undefined,
|
|
38
41
|
email: lp.email,
|
package/src/cli.ts
CHANGED
|
@@ -130,6 +130,7 @@ import {
|
|
|
130
130
|
fetchExploriumProspects,
|
|
131
131
|
fetchPipe0CrustdataProspects,
|
|
132
132
|
partitionFreshProspects,
|
|
133
|
+
pipe0ResolveCompanyDomains,
|
|
133
134
|
pipe0ResolveWorkEmails,
|
|
134
135
|
prospectIdentityKeys,
|
|
135
136
|
type Prospect,
|
|
@@ -2450,9 +2451,18 @@ async function acquireFromApi(
|
|
|
2450
2451
|
const { fresh, skippedCrm, skippedSeen } = partitionFreshProspects(prospects, crmContactKeys(snapshot), seen);
|
|
2451
2452
|
prospects = fresh;
|
|
2452
2453
|
|
|
2453
|
-
// 3. Resolve real work emails
|
|
2454
|
-
// hashed, Crustdata
|
|
2455
|
-
|
|
2454
|
+
// 3. Resolve real work emails. Triggered either when email IS the dedupe key
|
|
2455
|
+
// (Explorium's email is hashed, Crustdata returns none) or when a source
|
|
2456
|
+
// that keys on something else opts in via `resolveEmailsWith: "pipe0"`
|
|
2457
|
+
// (e.g. LinkedIn keys on the profile URL but still wants outreach emails).
|
|
2458
|
+
// pipe0 waterfall, chunked; resolves from name + company domain/name.
|
|
2459
|
+
if (matchKey === "email" || disc.resolveEmailsWith === "pipe0") {
|
|
2460
|
+
// The waterfall needs a company DOMAIN; LinkedIn/HeyReach lists carry only
|
|
2461
|
+
// names. Resolve domains first (pipe0 company:identity) so resolution can
|
|
2462
|
+
// actually land — without it, name-only resolution fails for every lead.
|
|
2463
|
+
if (prospects.some((p) => !p.companyDomain && p.companyName)) {
|
|
2464
|
+
prospects = await pipe0ResolveCompanyDomains({ apiKey: providerKey("pipe0"), prospects });
|
|
2465
|
+
}
|
|
2456
2466
|
prospects = await pipe0ResolveWorkEmails({ apiKey: providerKey("pipe0"), prospects });
|
|
2457
2467
|
}
|
|
2458
2468
|
|
|
@@ -595,13 +595,17 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
595
595
|
return { operationId: operation.id, status: "applied", detail: `Resolved/created company "${matchValue}" (${id}).`, providerData: { id } };
|
|
596
596
|
}
|
|
597
597
|
|
|
598
|
-
// contact — resolve-first on the dedupe key (email by default)
|
|
598
|
+
// contact — resolve-first on the dedupe key (email by default). The match
|
|
599
|
+
// key is canonical (e.g. "linkedin"); translate it to the HubSpot property
|
|
600
|
+
// name ("hs_linkedin_url") before searching — HubSpot 400s on a filter
|
|
601
|
+
// against a property it doesn't have (there is no property named "linkedin").
|
|
599
602
|
const matchKey = payload.matchKey || "email";
|
|
603
|
+
const searchProperty = HUBSPOT_DEFAULT_FIELD_MAPPINGS.contacts[matchKey] ?? matchKey;
|
|
600
604
|
const matchKeyLower = `${matchKey}:${matchValue.toLowerCase()}`;
|
|
601
605
|
if (createdContactsByMatch.has(matchKeyLower)) {
|
|
602
606
|
return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already created earlier in this run; not duplicating.`, providerData: { id: createdContactsByMatch.get(matchKeyLower), existing: true } };
|
|
603
607
|
}
|
|
604
|
-
const existing = await searchContactsBy(
|
|
608
|
+
const existing = await searchContactsBy(searchProperty, matchValue);
|
|
605
609
|
if (existing.length > 0) {
|
|
606
610
|
createdContactsByMatch.set(matchKeyLower, existing[0]);
|
|
607
611
|
return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already exists (${existing.join(", ")}); resolve-first declined to create.`, providerData: { id: existing[0], existing: true } };
|
|
@@ -905,7 +909,16 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
905
909
|
const data = await request(
|
|
906
910
|
`/crm/v3/objects/${objectPath}/${encodeURIComponent(objectId)}?properties=${encodeURIComponent(property)}`,
|
|
907
911
|
);
|
|
908
|
-
|
|
912
|
+
const value = data?.properties?.[property] ?? null;
|
|
913
|
+
// fetchSnapshot normalizes closeDate to a date with `?.split("T")[0]`, so the
|
|
914
|
+
// value baked into an op's beforeValue is date-only ("2026-03-07"). The
|
|
915
|
+
// single-object read here returns HubSpot's raw "2026-03-07T00:00:00Z", which
|
|
916
|
+
// made compare-and-set see a spurious drift and refuse every date-field write.
|
|
917
|
+
// Mirror the snapshot's normalization so the comparison is apples-to-apples.
|
|
918
|
+
if (field === "closeDate" && typeof value === "string") {
|
|
919
|
+
return value.split("T")[0];
|
|
920
|
+
}
|
|
921
|
+
return value;
|
|
909
922
|
}
|
|
910
923
|
|
|
911
924
|
return {
|
|
@@ -20,6 +20,9 @@ export type Prospect = {
|
|
|
20
20
|
lastName?: string;
|
|
21
21
|
fullName?: string;
|
|
22
22
|
jobTitle?: string;
|
|
23
|
+
/** LinkedIn headline — secondary role signal for ICP scoring (the title
|
|
24
|
+
* keyword may live here, not in the formal jobTitle). */
|
|
25
|
+
headline?: string;
|
|
23
26
|
/** normalized seniority, e.g. "cxo","vp","director" (Explorium job_level_main) */
|
|
24
27
|
jobLevel?: string;
|
|
25
28
|
/** normalized department, e.g. "sales" (Explorium job_department_main) */
|
|
@@ -233,6 +236,78 @@ function personKey(name: string | undefined, company: string | undefined): strin
|
|
|
233
236
|
return `${(name ?? "").trim().toLowerCase()}|${(company ?? "").trim().toLowerCase()}`;
|
|
234
237
|
}
|
|
235
238
|
|
|
239
|
+
/** Bare registrable host from a URL or domain string ("https://www.x.com/a" → "x.com"). */
|
|
240
|
+
export function hostFromUrl(url: string | undefined): string | undefined {
|
|
241
|
+
if (!url || !url.trim()) return undefined;
|
|
242
|
+
try {
|
|
243
|
+
const u = url.includes("://") ? url : `https://${url}`;
|
|
244
|
+
const host = new URL(u).hostname.replace(/^www\./, "");
|
|
245
|
+
return host || undefined;
|
|
246
|
+
} catch {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Fill `companyDomain` for prospects that have a company NAME but no domain,
|
|
253
|
+
* using pipe0's `company:identity@1` pipe (name → website URL). The work-email
|
|
254
|
+
* waterfall requires a domain — a LinkedIn/HeyReach list supplies only names, so
|
|
255
|
+
* without this step every resolution fails. Resolves each unique company once
|
|
256
|
+
* (companies repeat across a list); leaves a prospect untouched when the domain
|
|
257
|
+
* can't be found, so the waterfall simply skips it rather than guessing.
|
|
258
|
+
*/
|
|
259
|
+
export async function pipe0ResolveCompanyDomains(opts: {
|
|
260
|
+
apiKey: string;
|
|
261
|
+
prospects: Prospect[];
|
|
262
|
+
apiBaseUrl?: string;
|
|
263
|
+
fetchImpl?: FetchImpl;
|
|
264
|
+
chunkSize?: number;
|
|
265
|
+
}): Promise<Prospect[]> {
|
|
266
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
267
|
+
const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
|
|
268
|
+
const chunkSize = Math.max(1, opts.chunkSize ?? 5);
|
|
269
|
+
|
|
270
|
+
const uniqueNames = [
|
|
271
|
+
...new Set(
|
|
272
|
+
opts.prospects
|
|
273
|
+
.filter((p) => !p.companyDomain && p.companyName)
|
|
274
|
+
.map((p) => p.companyName!.trim())
|
|
275
|
+
.filter(Boolean),
|
|
276
|
+
),
|
|
277
|
+
];
|
|
278
|
+
if (uniqueNames.length === 0) return opts.prospects;
|
|
279
|
+
|
|
280
|
+
const domainByName = new Map<string, string>();
|
|
281
|
+
for (let i = 0; i < uniqueNames.length; i += chunkSize) {
|
|
282
|
+
const chunk = uniqueNames.slice(i, i + chunkSize);
|
|
283
|
+
try {
|
|
284
|
+
const response = await fetchImpl(`${base}/v1/pipes/run/sync`, {
|
|
285
|
+
method: "POST",
|
|
286
|
+
headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
|
|
287
|
+
body: JSON.stringify({
|
|
288
|
+
pipes: [{ pipe_id: "company:identity@1" }],
|
|
289
|
+
input: chunk.map((company_name) => ({ company_name })),
|
|
290
|
+
}),
|
|
291
|
+
});
|
|
292
|
+
if (!response.ok) continue;
|
|
293
|
+
const body = (await response.json()) as Pipe0RunResponse;
|
|
294
|
+
for (const recordId of body.order ?? []) {
|
|
295
|
+
const fields = body.records?.[recordId]?.fields ?? {};
|
|
296
|
+
const name = fieldValue(fields.company_name) ?? fieldValue(fields.cleaned_company_name);
|
|
297
|
+
const domain = hostFromUrl(fieldValue(fields.company_website_url));
|
|
298
|
+
if (name && domain) domainByName.set(name.trim().toLowerCase(), domain);
|
|
299
|
+
}
|
|
300
|
+
} catch {
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return opts.prospects.map((p) => {
|
|
305
|
+
if (p.companyDomain || !p.companyName) return p;
|
|
306
|
+
const domain = domainByName.get(p.companyName.trim().toLowerCase());
|
|
307
|
+
return domain ? { ...p, companyDomain: domain } : p;
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
236
311
|
type Pipe0Field = { value?: unknown; status?: string };
|
|
237
312
|
type Pipe0RunResponse = {
|
|
238
313
|
order?: string[];
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
CanonicalDeal,
|
|
13
13
|
CanonicalGtmSnapshot,
|
|
14
14
|
CanonicalUser,
|
|
15
|
+
CreateRecordPayload,
|
|
15
16
|
GtmConnector,
|
|
16
17
|
GtmObjectType,
|
|
17
18
|
PatchOperation,
|
|
@@ -72,6 +73,37 @@ export function createSalesforceConnector(
|
|
|
72
73
|
const mappings = options.fieldMappings;
|
|
73
74
|
// create:<Name> dedup within one connector lifetime (one apply run).
|
|
74
75
|
const createdAccountsByName = new Map<string, string>();
|
|
76
|
+
// create_record contact dedup (keyed by matchKey:matchValue), same lifetime.
|
|
77
|
+
const createdContactsByMatch = new Map<string, string>();
|
|
78
|
+
|
|
79
|
+
// Resolve an Account by exact name: a unique existing match is reused, a
|
|
80
|
+
// confirmed miss is created, and an ambiguous name (>1 match) is refused.
|
|
81
|
+
// Caches within the run so the same name is never created twice — shared by
|
|
82
|
+
// `link_record`'s create:<Name> path and `create_record`'s company linking.
|
|
83
|
+
async function resolveOrCreateAccountByName(
|
|
84
|
+
name: string,
|
|
85
|
+
): Promise<{ id: string; createdNew: boolean } | { ambiguous: number }> {
|
|
86
|
+
const nameKey = name.toLowerCase();
|
|
87
|
+
const cached = createdAccountsByName.get(nameKey);
|
|
88
|
+
if (cached) return { id: cached, createdNew: false };
|
|
89
|
+
const soqlName = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
90
|
+
const matches = await query(`SELECT Id FROM Account WHERE Name = '${soqlName}' LIMIT 3`);
|
|
91
|
+
if (matches.length > 1) return { ambiguous: matches.length };
|
|
92
|
+
let id: string;
|
|
93
|
+
let createdNew = false;
|
|
94
|
+
if (matches.length === 1) {
|
|
95
|
+
id = String(matches[0].Id);
|
|
96
|
+
} else {
|
|
97
|
+
const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
body: JSON.stringify({ Name: name }),
|
|
100
|
+
});
|
|
101
|
+
id = String(created.id);
|
|
102
|
+
createdNew = true;
|
|
103
|
+
}
|
|
104
|
+
createdAccountsByName.set(nameKey, id);
|
|
105
|
+
return { id, createdNew };
|
|
106
|
+
}
|
|
75
107
|
|
|
76
108
|
async function request(path: string, init: RequestInit = {}): Promise<any> {
|
|
77
109
|
const connection = await options.getConnection();
|
|
@@ -484,6 +516,87 @@ export function createSalesforceConnector(
|
|
|
484
516
|
};
|
|
485
517
|
}
|
|
486
518
|
|
|
519
|
+
// Resolve-first net-new lead creation (the `enrich acquire` path). Re-resolves
|
|
520
|
+
// on matchKey/matchValue at apply time — search is the source of truth — and
|
|
521
|
+
// creates only on a confirmed miss, so concurrent writers never get duplicated.
|
|
522
|
+
// Mirrors the HubSpot connector's createRecord contract.
|
|
523
|
+
async function createRecord(operation: PatchOperation): Promise<PatchOperationResult> {
|
|
524
|
+
const payload = operation.afterValue as CreateRecordPayload | undefined;
|
|
525
|
+
if (!payload || typeof payload !== "object" || !payload.properties) {
|
|
526
|
+
return { operationId: operation.id, status: "skipped", detail: "create_record needs a CreateRecordPayload afterValue." };
|
|
527
|
+
}
|
|
528
|
+
if (operation.objectType !== "contact" && operation.objectType !== "account") {
|
|
529
|
+
return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts and accounts." };
|
|
530
|
+
}
|
|
531
|
+
const matchValue = String(payload.matchValue ?? "").trim();
|
|
532
|
+
if (!matchValue) {
|
|
533
|
+
return { operationId: operation.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (operation.objectType === "account") {
|
|
537
|
+
const resolved = await resolveOrCreateAccountByName(matchValue);
|
|
538
|
+
if ("ambiguous" in resolved) {
|
|
539
|
+
return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — ${resolved.ambiguous} accounts named "${matchValue}". Not creating.` };
|
|
540
|
+
}
|
|
541
|
+
return { operationId: operation.id, status: "applied", detail: `Resolved/created account "${matchValue}" (${resolved.id}).`, providerData: { id: resolved.id, created: resolved.createdNew } };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// contact — resolve-first on the dedupe key (email by default). matchKey is
|
|
545
|
+
// canonical; translate it to the Salesforce field before the SOQL search.
|
|
546
|
+
const matchKey = payload.matchKey || "email";
|
|
547
|
+
const searchField = mappedField(mappings, "contacts", matchKey, SALESFORCE_DEFAULT_FIELD_MAPPINGS.contacts[matchKey] ?? matchKey);
|
|
548
|
+
const matchKeyLower = `${matchKey}:${matchValue.toLowerCase()}`;
|
|
549
|
+
if (createdContactsByMatch.has(matchKeyLower)) {
|
|
550
|
+
return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already created earlier in this run; not duplicating.`, providerData: { id: createdContactsByMatch.get(matchKeyLower), existing: true } };
|
|
551
|
+
}
|
|
552
|
+
const soqlValue = matchValue.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
553
|
+
const existing = await query(`SELECT Id FROM Contact WHERE ${searchField} = '${soqlValue}' LIMIT 5`);
|
|
554
|
+
if (existing.length > 0) {
|
|
555
|
+
const existingId = String(existing[0].Id);
|
|
556
|
+
createdContactsByMatch.set(matchKeyLower, existingId);
|
|
557
|
+
return { operationId: operation.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already exists (${existing.map((r) => String(r.Id)).join(", ")}); resolve-first declined to create.`, providerData: { id: existingId, existing: true } };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const fields: Record<string, string> = { ...payload.properties };
|
|
561
|
+
// Stamp ownership at create time so the lead is never born ownerless. The
|
|
562
|
+
// canonical ownerId maps to Salesforce's OwnerId.
|
|
563
|
+
if (payload.ownerId) {
|
|
564
|
+
const ownerField = mappedField(mappings, "contacts", "ownerId", SALESFORCE_DEFAULT_FIELD_MAPPINGS.contacts.ownerId ?? "OwnerId");
|
|
565
|
+
if (!fields[ownerField]) fields[ownerField] = String(payload.ownerId);
|
|
566
|
+
}
|
|
567
|
+
const created = await request(`/services/data/${apiVersion}/sobjects/Contact`, {
|
|
568
|
+
method: "POST",
|
|
569
|
+
body: JSON.stringify(fields),
|
|
570
|
+
});
|
|
571
|
+
const contactId = String(created.id);
|
|
572
|
+
createdContactsByMatch.set(matchKeyLower, contactId);
|
|
573
|
+
|
|
574
|
+
let companyNote = "";
|
|
575
|
+
if (payload.associateCompanyName) {
|
|
576
|
+
try {
|
|
577
|
+
const resolved = await resolveOrCreateAccountByName(payload.associateCompanyName);
|
|
578
|
+
if ("ambiguous" in resolved) {
|
|
579
|
+
companyNote = ` Account "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
|
|
580
|
+
} else {
|
|
581
|
+
await request(`/services/data/${apiVersion}/sobjects/Contact/${encodeURIComponent(contactId)}`, {
|
|
582
|
+
method: "PATCH",
|
|
583
|
+
body: JSON.stringify({ AccountId: resolved.id }),
|
|
584
|
+
});
|
|
585
|
+
companyNote = ` Linked to account "${payload.associateCompanyName}" (${resolved.id}).`;
|
|
586
|
+
}
|
|
587
|
+
} catch (error) {
|
|
588
|
+
// Association is best-effort — the contact create already succeeded.
|
|
589
|
+
companyNote = ` (account link failed: ${error instanceof Error ? error.message : String(error)})`;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return {
|
|
593
|
+
operationId: operation.id,
|
|
594
|
+
status: "applied",
|
|
595
|
+
detail: `Created contact ${matchKey}=${matchValue} (${contactId}).${companyNote}`,
|
|
596
|
+
providerData: { id: contactId, created: true },
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
487
600
|
async function applyOperation(operation: PatchOperation): Promise<PatchOperationResult> {
|
|
488
601
|
try {
|
|
489
602
|
switch (operation.operation) {
|
|
@@ -501,33 +614,15 @@ export function createSalesforceConnector(
|
|
|
501
614
|
if (!name) {
|
|
502
615
|
return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
|
|
503
616
|
}
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
);
|
|
512
|
-
if (matches.length > 1) {
|
|
513
|
-
return {
|
|
514
|
-
operationId: operation.id,
|
|
515
|
-
status: "skipped",
|
|
516
|
-
detail: `create:${name} is ambiguous — ${matches.length} accounts already named "${name}". Link an explicit account id instead.`,
|
|
517
|
-
};
|
|
518
|
-
}
|
|
519
|
-
if (matches.length === 1) {
|
|
520
|
-
accountId = String(matches[0].Id);
|
|
521
|
-
} else {
|
|
522
|
-
const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
|
|
523
|
-
method: "POST",
|
|
524
|
-
body: JSON.stringify({ Name: name }),
|
|
525
|
-
});
|
|
526
|
-
accountId = String(created.id);
|
|
527
|
-
createdNew = true;
|
|
528
|
-
}
|
|
529
|
-
createdAccountsByName.set(nameKey, accountId);
|
|
617
|
+
const resolved = await resolveOrCreateAccountByName(name);
|
|
618
|
+
if ("ambiguous" in resolved) {
|
|
619
|
+
return {
|
|
620
|
+
operationId: operation.id,
|
|
621
|
+
status: "skipped",
|
|
622
|
+
detail: `create:${name} is ambiguous — ${resolved.ambiguous} accounts already named "${name}". Link an explicit account id instead.`,
|
|
623
|
+
};
|
|
530
624
|
}
|
|
625
|
+
const { id: accountId, createdNew } = resolved;
|
|
531
626
|
const result = await setField({ ...operation, operation: "set_field", afterValue: accountId });
|
|
532
627
|
return result.status === "applied"
|
|
533
628
|
? {
|
|
@@ -547,6 +642,8 @@ export function createSalesforceConnector(
|
|
|
547
642
|
return await mergeRecords(operation);
|
|
548
643
|
case "archive_record":
|
|
549
644
|
return await archiveRecord(operation);
|
|
645
|
+
case "create_record":
|
|
646
|
+
return await createRecord(operation);
|
|
550
647
|
default:
|
|
551
648
|
return {
|
|
552
649
|
operationId: operation.id,
|
package/src/enrich.ts
CHANGED
|
@@ -139,7 +139,7 @@ const MATCH_KEYS: Record<EnrichObjectType, string[]> = {
|
|
|
139
139
|
};
|
|
140
140
|
|
|
141
141
|
/** API source ids the MVP can pull from. */
|
|
142
|
-
export const SUPPORTED_API_SOURCES = ["apollo", "explorium", "pipe0"];
|
|
142
|
+
export const SUPPORTED_API_SOURCES = ["apollo", "explorium", "pipe0", "linkedin"];
|
|
143
143
|
|
|
144
144
|
/**
|
|
145
145
|
* Canonical fields enrich may target, plus the HubSpot property spellings the
|
package/src/icp.ts
CHANGED
|
@@ -161,11 +161,15 @@ export type IcpFit = { score: number; reasons: string[] };
|
|
|
161
161
|
* Firmographics aren't re-scored here — discovery already filtered on them.
|
|
162
162
|
*/
|
|
163
163
|
export function scoreProspectAgainstIcp(
|
|
164
|
-
prospect: { jobTitle?: string; jobLevel?: string; jobDepartment?: string },
|
|
164
|
+
prospect: { jobTitle?: string; jobLevel?: string; jobDepartment?: string; headline?: string },
|
|
165
165
|
icp: Icp,
|
|
166
166
|
): IcpFit {
|
|
167
167
|
const reasons: string[] = [];
|
|
168
|
-
|
|
168
|
+
// Match title keywords across BOTH the formal title and the headline: on
|
|
169
|
+
// LinkedIn-sourced prospects the role signal often lives only in the headline
|
|
170
|
+
// (e.g. position "Founder" but headline "… | RevOps | …"), so scoring on the
|
|
171
|
+
// title alone would miss genuine in-persona leads.
|
|
172
|
+
const title = `${prospect.jobTitle ?? ""} ${prospect.headline ?? ""}`.trim().toLowerCase();
|
|
169
173
|
const keywords = (icp.persona.titleKeywords ?? []).map((k) => k.toLowerCase());
|
|
170
174
|
const levels = (icp.persona.jobLevels ?? []).map((l) => l.toLowerCase());
|
|
171
175
|
const depts = (icp.persona.departments ?? []).map((d) => d.toLowerCase());
|