fullstackgtm 0.34.0 → 0.38.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 +141 -0
- package/INSTALL_FOR_AGENTS.md +8 -0
- package/README.md +39 -0
- package/dist/acquireLinkedIn.d.ts +41 -0
- package/dist/acquireLinkedIn.js +57 -0
- package/dist/acquireMeter.d.ts +67 -0
- package/dist/acquireMeter.js +145 -0
- package/dist/acquireSeen.d.ts +5 -0
- package/dist/acquireSeen.js +54 -0
- package/dist/assign.d.ts +83 -0
- package/dist/assign.js +146 -0
- package/dist/bin.js +14 -2
- package/dist/cli.js +817 -26
- package/dist/connectors/hubspot.js +140 -0
- package/dist/connectors/linkedin.d.ts +78 -0
- package/dist/connectors/linkedin.js +199 -0
- package/dist/connectors/prospectSources.d.ts +91 -0
- package/dist/connectors/prospectSources.js +227 -0
- package/dist/enrich.d.ts +107 -0
- package/dist/enrich.js +315 -5
- package/dist/format.d.ts +3 -1
- package/dist/format.js +14 -2
- package/dist/health.d.ts +71 -0
- package/dist/health.js +172 -0
- package/dist/icp.d.ts +96 -0
- package/dist/icp.js +256 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mappings.js +3 -0
- package/dist/reassign.d.ts +11 -2
- package/dist/reassign.js +13 -6
- package/dist/runReport.d.ts +9 -0
- package/dist/runReport.js +66 -0
- package/dist/types.d.ts +25 -1
- package/docs/api.md +53 -3
- package/docs/architecture.md +11 -1
- package/docs/dx-punch-list.md +87 -0
- package/docs/linkedin-connector-spec.md +87 -0
- package/llms.txt +38 -1
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +5 -3
- package/src/acquireLinkedIn.ts +83 -0
- package/src/acquireMeter.ts +186 -0
- package/src/acquireSeen.ts +57 -0
- package/src/assign.ts +193 -0
- package/src/bin.ts +17 -4
- package/src/cli.ts +965 -25
- package/src/connectors/hubspot.ts +145 -0
- package/src/connectors/linkedin.ts +272 -0
- package/src/connectors/prospectSources.ts +324 -0
- package/src/enrich.ts +411 -5
- package/src/format.ts +20 -2
- package/src/health.ts +238 -0
- package/src/icp.ts +313 -0
- package/src/index.ts +18 -0
- package/src/mappings.ts +3 -0
- package/src/reassign.ts +24 -8
- package/src/runReport.ts +76 -0
- package/src/types.ts +32 -0
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
RecordProvenance,
|
|
14
14
|
CanonicalGtmSnapshot,
|
|
15
15
|
CanonicalUser,
|
|
16
|
+
CreateRecordPayload,
|
|
16
17
|
GtmConnector,
|
|
17
18
|
GtmObjectType,
|
|
18
19
|
PatchOperation,
|
|
@@ -59,6 +60,10 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
59
60
|
// search API is eventually consistent, so a just-created company is
|
|
60
61
|
// invisible to search — this map is the authoritative same-run record.
|
|
61
62
|
const createdCompaniesByName = new Map<string, string>();
|
|
63
|
+
// Same-run dedup for `create_record` contact creates, keyed by lowercased
|
|
64
|
+
// match value (email): HubSpot search is eventually consistent, so a contact
|
|
65
|
+
// created earlier in this apply run is invisible to a later search.
|
|
66
|
+
const createdContactsByMatch = new Map<string, string>();
|
|
62
67
|
|
|
63
68
|
async function request(path: string, init: RequestInit = {}): Promise<any> {
|
|
64
69
|
const token = await options.getAccessToken();
|
|
@@ -220,6 +225,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
220
225
|
email: stringOrUndefined(readMapped(props, "contacts", "email", "email")),
|
|
221
226
|
phone: stringOrUndefined(readMapped(props, "contacts", "phone", "phone")),
|
|
222
227
|
title: stringOrUndefined(readMapped(props, "contacts", "title", "jobtitle")),
|
|
228
|
+
linkedin: stringOrUndefined(readMapped(props, "contacts", "linkedin", "hs_linkedin_url")),
|
|
223
229
|
ownerId: stringOrUndefined(
|
|
224
230
|
readMapped(props, "contacts", "ownerId", "hubspot_owner_id"),
|
|
225
231
|
),
|
|
@@ -513,6 +519,143 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
513
519
|
return ((data?.results ?? []) as Array<{ id: string }>).map((row) => String(row.id));
|
|
514
520
|
}
|
|
515
521
|
|
|
522
|
+
/** Exact-value contact lookup (by any searchable property) for resolve-first creates. */
|
|
523
|
+
async function searchContactsBy(property: string, value: string): Promise<string[]> {
|
|
524
|
+
const data = await request(`/crm/v3/objects/contacts/search`, {
|
|
525
|
+
method: "POST",
|
|
526
|
+
body: JSON.stringify({
|
|
527
|
+
filterGroups: [{ filters: [{ propertyName: property, operator: "EQ", value }] }],
|
|
528
|
+
properties: [property],
|
|
529
|
+
limit: 3,
|
|
530
|
+
}),
|
|
531
|
+
});
|
|
532
|
+
return ((data?.results ?? []) as Array<{ id: string }>).map((row) => String(row.id));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Resolve a company by exact name, creating it on a confirmed miss. Returns
|
|
537
|
+
* the company id, or null on ambiguity (≥2 existing) so the caller skips the
|
|
538
|
+
* association rather than guessing. Same-run safe via createdCompaniesByName.
|
|
539
|
+
*/
|
|
540
|
+
async function resolveOrCreateCompanyByName(name: string, opId: string): Promise<string | null> {
|
|
541
|
+
const nameKey = name.toLowerCase();
|
|
542
|
+
const already = createdCompaniesByName.get(nameKey);
|
|
543
|
+
if (already) return already;
|
|
544
|
+
const matches = await searchCompaniesByName(name);
|
|
545
|
+
if (matches.length > 1) return null;
|
|
546
|
+
if (matches.length === 1) {
|
|
547
|
+
createdCompaniesByName.set(nameKey, matches[0]);
|
|
548
|
+
return matches[0];
|
|
549
|
+
}
|
|
550
|
+
let created;
|
|
551
|
+
try {
|
|
552
|
+
created = await request(`/crm/v3/objects/companies`, {
|
|
553
|
+
method: "POST",
|
|
554
|
+
body: JSON.stringify({
|
|
555
|
+
properties: { name, hs_object_source_detail_2: `fullstackgtm acquire (${opId})` },
|
|
556
|
+
}),
|
|
557
|
+
});
|
|
558
|
+
} catch {
|
|
559
|
+
created = await request(`/crm/v3/objects/companies`, {
|
|
560
|
+
method: "POST",
|
|
561
|
+
body: JSON.stringify({ properties: { name } }),
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
const id = String(created.id);
|
|
565
|
+
createdCompaniesByName.set(nameKey, id);
|
|
566
|
+
return id;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Create a NET-NEW record (a sourced lead). Resolve-first: re-checks the
|
|
571
|
+
* dedupe key against the live CRM (the plan-time snapshot can be stale) and
|
|
572
|
+
* creates ONLY on a confirmed miss — a record a concurrent writer already
|
|
573
|
+
* added is returned as `skipped`, never duplicated. Contacts can be linked
|
|
574
|
+
* to a resolved-or-created company in the same step.
|
|
575
|
+
*/
|
|
576
|
+
async function createRecord(operation: PatchOperation): Promise<PatchOperationResult> {
|
|
577
|
+
const payload = operation.afterValue as CreateRecordPayload | undefined;
|
|
578
|
+
if (!payload || typeof payload !== "object" || !payload.properties) {
|
|
579
|
+
return { operationId: operation.id, status: "skipped", detail: "create_record needs a CreateRecordPayload afterValue." };
|
|
580
|
+
}
|
|
581
|
+
const objectPath = OBJECT_PATHS[operation.objectType];
|
|
582
|
+
if (operation.objectType !== "contact" && operation.objectType !== "account") {
|
|
583
|
+
return { operationId: operation.id, status: "skipped", detail: "create_record supports contacts and accounts." };
|
|
584
|
+
}
|
|
585
|
+
const matchValue = String(payload.matchValue ?? "").trim();
|
|
586
|
+
if (!matchValue) {
|
|
587
|
+
return { operationId: operation.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (operation.objectType === "account") {
|
|
591
|
+
const id = await resolveOrCreateCompanyByName(matchValue, operation.id);
|
|
592
|
+
if (id === null) {
|
|
593
|
+
return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — multiple companies named "${matchValue}". Not creating.` };
|
|
594
|
+
}
|
|
595
|
+
return { operationId: operation.id, status: "applied", detail: `Resolved/created company "${matchValue}" (${id}).`, providerData: { id } };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// contact — resolve-first on the dedupe key (email by default)
|
|
599
|
+
const matchKey = payload.matchKey || "email";
|
|
600
|
+
const matchKeyLower = `${matchKey}:${matchValue.toLowerCase()}`;
|
|
601
|
+
if (createdContactsByMatch.has(matchKeyLower)) {
|
|
602
|
+
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
|
+
}
|
|
604
|
+
const existing = await searchContactsBy(matchKey, matchValue);
|
|
605
|
+
if (existing.length > 0) {
|
|
606
|
+
createdContactsByMatch.set(matchKeyLower, existing[0]);
|
|
607
|
+
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 } };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const properties: Record<string, string> = { ...payload.properties };
|
|
611
|
+
// Stamp ownership at create time so the lead is never born ownerless. The
|
|
612
|
+
// canonical ownerId maps to HubSpot's standard owner property.
|
|
613
|
+
if (payload.ownerId && !properties.hubspot_owner_id) {
|
|
614
|
+
properties.hubspot_owner_id = String(payload.ownerId);
|
|
615
|
+
}
|
|
616
|
+
let created;
|
|
617
|
+
try {
|
|
618
|
+
created = await request(`/crm/v3/objects/${objectPath}`, {
|
|
619
|
+
method: "POST",
|
|
620
|
+
body: JSON.stringify({ properties: { ...properties, hs_object_source_detail_2: `fullstackgtm acquire (${operation.id})` } }),
|
|
621
|
+
});
|
|
622
|
+
} catch {
|
|
623
|
+
// Some portals reject writes to source-detail properties — the provenance
|
|
624
|
+
// stamp is best-effort, the create is not.
|
|
625
|
+
created = await request(`/crm/v3/objects/${objectPath}`, {
|
|
626
|
+
method: "POST",
|
|
627
|
+
body: JSON.stringify({ properties }),
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
const contactId = String(created.id);
|
|
631
|
+
createdContactsByMatch.set(matchKeyLower, contactId);
|
|
632
|
+
|
|
633
|
+
let companyNote = "";
|
|
634
|
+
if (payload.associateCompanyName) {
|
|
635
|
+
try {
|
|
636
|
+
const companyId = await resolveOrCreateCompanyByName(payload.associateCompanyName, operation.id);
|
|
637
|
+
if (companyId) {
|
|
638
|
+
await request(
|
|
639
|
+
`/crm/v4/objects/contacts/${encodeURIComponent(contactId)}/associations/default/companies/${encodeURIComponent(companyId)}`,
|
|
640
|
+
{ method: "PUT" },
|
|
641
|
+
);
|
|
642
|
+
companyNote = ` Linked to company "${payload.associateCompanyName}" (${companyId}).`;
|
|
643
|
+
} else {
|
|
644
|
+
companyNote = ` Company "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
|
|
645
|
+
}
|
|
646
|
+
} catch (error) {
|
|
647
|
+
// Association is best-effort — the contact create already succeeded.
|
|
648
|
+
companyNote = ` (company link failed: ${error instanceof Error ? error.message : String(error)})`;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return {
|
|
652
|
+
operationId: operation.id,
|
|
653
|
+
status: "applied",
|
|
654
|
+
detail: `Created contact ${matchKey}=${matchValue} (${contactId}).${companyNote}`,
|
|
655
|
+
providerData: { id: contactId, created: true },
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
516
659
|
async function createTask(operation: PatchOperation): Promise<PatchOperationResult> {
|
|
517
660
|
const associationTypeId = TASK_ASSOCIATION_TYPE_IDS[operation.objectType];
|
|
518
661
|
if (associationTypeId === undefined) {
|
|
@@ -707,6 +850,8 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
707
850
|
return await linkRecord(operation);
|
|
708
851
|
case "create_task":
|
|
709
852
|
return await createTask(operation);
|
|
853
|
+
case "create_record":
|
|
854
|
+
return await createRecord(operation);
|
|
710
855
|
case "merge_records":
|
|
711
856
|
return await mergeRecords(operation);
|
|
712
857
|
case "archive_record":
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkedIn connector — Phase 1 discovery (read-only).
|
|
3
|
+
*
|
|
4
|
+
* A provider-agnostic surface for pulling prospects out of LinkedIn via a
|
|
5
|
+
* rented execution layer (default: HeyReach; Unipile is a drop-in alternative).
|
|
6
|
+
* Deliberately decoupled from the acquire spine — the `Icp`→filter and
|
|
7
|
+
* `LinkedInProspect`→`Prospect` bridges live in the `enrich acquire --source
|
|
8
|
+
* linkedin` wiring, not here — so this layer can land and be tested on its own.
|
|
9
|
+
*
|
|
10
|
+
* Phase 2 (future) extends `LinkedInProvider` with send actions
|
|
11
|
+
* (connect/message) routed through the plan/apply gate. Spec:
|
|
12
|
+
* docs/linkedin-connector-spec.md. Nothing here ever writes or sends.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type LinkedInProspect = {
|
|
16
|
+
firstName: string;
|
|
17
|
+
lastName: string;
|
|
18
|
+
fullName?: string;
|
|
19
|
+
headline?: string;
|
|
20
|
+
jobTitle?: string;
|
|
21
|
+
company?: string;
|
|
22
|
+
/** Canonical LinkedIn profile URL — the identity key dedup uses downstream. */
|
|
23
|
+
profileUrl: string;
|
|
24
|
+
location?: string;
|
|
25
|
+
email?: string;
|
|
26
|
+
emailStatus?: string;
|
|
27
|
+
/** The raw provider payload, preserved for debugging and later mapping. */
|
|
28
|
+
raw?: unknown;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type LinkedInSource = {
|
|
32
|
+
/** Provider-native id of a prospect source (HeyReach: a lead-list id). */
|
|
33
|
+
id: string;
|
|
34
|
+
name: string;
|
|
35
|
+
/** Approximate prospect count, when the provider reports it. */
|
|
36
|
+
count?: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type FetchProspectsOptions = {
|
|
40
|
+
/** Which source to read (HeyReach lead-list id); provider may have a default. */
|
|
41
|
+
sourceId?: string;
|
|
42
|
+
/** Hard cap on prospects returned; also bounds pagination. */
|
|
43
|
+
max?: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type ConnectionStatus = { ok: boolean; detail: string };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Provider-agnostic LinkedIn discovery surface (Phase 1). Read-only: it
|
|
50
|
+
* enumerates prospect sources and pulls normalized prospects, never sends.
|
|
51
|
+
*/
|
|
52
|
+
export interface LinkedInProvider {
|
|
53
|
+
readonly name: string;
|
|
54
|
+
/** Cheap auth/connectivity check; never throws — returns ok:false instead. */
|
|
55
|
+
checkConnection(): Promise<ConnectionStatus>;
|
|
56
|
+
/** Enumerate prospect sources (HeyReach lead lists). */
|
|
57
|
+
listSources(): Promise<LinkedInSource[]>;
|
|
58
|
+
/** Pull prospects from a source, normalized + capped. */
|
|
59
|
+
fetchProspects(options?: FetchProspectsOptions): Promise<LinkedInProspect[]>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── HeyReach adapter ────────────────────────────────────────────────────────
|
|
63
|
+
// Base + auth verified 2026-06-20: POST endpoints, `X-API-KEY` header, 300
|
|
64
|
+
// req/min (429). Confirmed live: /auth/CheckApiKey, /list/GetAll. The
|
|
65
|
+
// leads-from-list path has a documented gap in HeyReach's public docs and is
|
|
66
|
+
// marked to reconcile at live validation (step 3, needs a real key).
|
|
67
|
+
|
|
68
|
+
export const HEYREACH_BASE = "https://api.heyreach.io/api/public";
|
|
69
|
+
const HEYREACH_MAX_PAGE = 100;
|
|
70
|
+
|
|
71
|
+
export type HeyReachProviderOptions = {
|
|
72
|
+
apiKey: string;
|
|
73
|
+
fetchImpl?: typeof fetch;
|
|
74
|
+
baseUrl?: string;
|
|
75
|
+
/** Lead-list id used when fetchProspects is called without an explicit sourceId. */
|
|
76
|
+
defaultListId?: string;
|
|
77
|
+
/** Page size for paginated reads (≤ server max). Lower in tests. */
|
|
78
|
+
pageSize?: number;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export function createHeyReachProvider(options: HeyReachProviderOptions): LinkedInProvider {
|
|
82
|
+
const apiKey = options.apiKey;
|
|
83
|
+
if (!apiKey) {
|
|
84
|
+
throw new Error("HeyReach API key is required (`login heyreach` or HEYREACH_API_KEY).");
|
|
85
|
+
}
|
|
86
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
87
|
+
const baseUrl = (options.baseUrl ?? HEYREACH_BASE).replace(/\/$/, "");
|
|
88
|
+
const pageSize = Math.max(1, Math.min(options.pageSize ?? HEYREACH_MAX_PAGE, HEYREACH_MAX_PAGE));
|
|
89
|
+
|
|
90
|
+
async function call(path: string, method: "GET" | "POST", body?: unknown): Promise<any> {
|
|
91
|
+
let response: Response;
|
|
92
|
+
try {
|
|
93
|
+
response = await fetchImpl(`${baseUrl}${path}`, {
|
|
94
|
+
method,
|
|
95
|
+
// Secret travels in the header, never the URL (no leak via logs/history).
|
|
96
|
+
headers: {
|
|
97
|
+
"X-API-KEY": apiKey,
|
|
98
|
+
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
99
|
+
},
|
|
100
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
101
|
+
});
|
|
102
|
+
} catch {
|
|
103
|
+
throw new Error(`Cannot reach HeyReach at ${baseUrl}${path}. Check network access.`);
|
|
104
|
+
}
|
|
105
|
+
if (response.status === 429) {
|
|
106
|
+
const retry = response.headers.get("Retry-After");
|
|
107
|
+
throw new Error(
|
|
108
|
+
`HeyReach rate limit (429)${retry ? `; retry after ${retry}s` : ""}. The cap is 300 requests/min.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
await response.text().catch(() => undefined);
|
|
113
|
+
throw new Error(`HeyReach API error ${response.status} on ${path}. Check the API key and request.`);
|
|
114
|
+
}
|
|
115
|
+
if (response.status === 204) return undefined;
|
|
116
|
+
return response.json().catch(() => undefined);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
name: "heyreach",
|
|
121
|
+
|
|
122
|
+
async checkConnection() {
|
|
123
|
+
try {
|
|
124
|
+
await call("/auth/CheckApiKey", "GET");
|
|
125
|
+
return { ok: true, detail: "HeyReach API key valid." };
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
async listSources() {
|
|
132
|
+
const data = await call("/list/GetAll", "POST", { offset: 0, limit: HEYREACH_MAX_PAGE });
|
|
133
|
+
return toArray(data).map((item: any) => ({
|
|
134
|
+
id: String(item?.id ?? item?.listId ?? ""),
|
|
135
|
+
name: String(item?.name ?? item?.title ?? "(unnamed list)"),
|
|
136
|
+
count:
|
|
137
|
+
typeof item?.totalItemsCount === "number"
|
|
138
|
+
? item.totalItemsCount
|
|
139
|
+
: typeof item?.count === "number"
|
|
140
|
+
? item.count
|
|
141
|
+
: undefined,
|
|
142
|
+
}));
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
async fetchProspects(opts = {}) {
|
|
146
|
+
const listId = opts.sourceId ?? options.defaultListId;
|
|
147
|
+
if (!listId) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
"HeyReach fetchProspects needs a sourceId (lead-list id) or a configured defaultListId — list them with listSources().",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const max = opts.max ?? Number.POSITIVE_INFINITY;
|
|
153
|
+
const out: LinkedInProspect[] = [];
|
|
154
|
+
let offset = 0;
|
|
155
|
+
while (out.length < max) {
|
|
156
|
+
const data = await call("/list/GetLeadsFromList", "POST", { listId, offset, limit: pageSize });
|
|
157
|
+
const items = toArray(data);
|
|
158
|
+
if (items.length === 0) break;
|
|
159
|
+
for (const item of items) {
|
|
160
|
+
out.push(normalizeHeyReachLead(item));
|
|
161
|
+
if (out.length >= max) break;
|
|
162
|
+
}
|
|
163
|
+
if (items.length < pageSize) break;
|
|
164
|
+
offset += items.length;
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** First value that is a non-empty string, else undefined (treats "" as absent). */
|
|
172
|
+
function firstNonEmpty(...values: unknown[]): string | undefined {
|
|
173
|
+
for (const value of values) {
|
|
174
|
+
if (typeof value === "string" && value.trim() !== "") return value;
|
|
175
|
+
}
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** HeyReach wraps list payloads as `{ items: [...] }` on some routes, bare arrays on others. */
|
|
180
|
+
function toArray(data: any): any[] {
|
|
181
|
+
if (Array.isArray(data)) return data;
|
|
182
|
+
if (Array.isArray(data?.items)) return data.items;
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Map a HeyReach lead (nested `profile` or flat) into the normalized shape. */
|
|
187
|
+
export function normalizeHeyReachLead(raw: any): LinkedInProspect {
|
|
188
|
+
const p = (raw && typeof raw === "object" && raw.profile) || raw || {};
|
|
189
|
+
const first = p.firstName ?? raw?.firstName ?? "";
|
|
190
|
+
const last = p.lastName ?? raw?.lastName ?? "";
|
|
191
|
+
const full = (p.fullName ?? raw?.fullName ?? `${first} ${last}`.trim()) || undefined;
|
|
192
|
+
return {
|
|
193
|
+
firstName: String(first ?? ""),
|
|
194
|
+
lastName: String(last ?? ""),
|
|
195
|
+
fullName: full,
|
|
196
|
+
headline: p.headline ?? raw?.headline ?? undefined,
|
|
197
|
+
jobTitle: p.position ?? p.jobTitle ?? raw?.position ?? undefined,
|
|
198
|
+
company: p.companyName ?? p.company ?? raw?.companyName ?? undefined,
|
|
199
|
+
profileUrl: String(p.profileUrl ?? p.linkedInUrl ?? raw?.profileUrl ?? raw?.linkedin_url ?? ""),
|
|
200
|
+
location: p.location ?? raw?.location ?? undefined,
|
|
201
|
+
// HeyReach exposes the raw `emailAddress` plus its own enrichment outputs
|
|
202
|
+
// (`enrichedEmailAddress`, `customEmailAddress`) — verified against the live
|
|
203
|
+
// /list/GetLeadsFromList schema 2026-06-20. Unresolved emails come back as
|
|
204
|
+
// "", so pick the first NON-EMPTY value (?? would keep the empty string).
|
|
205
|
+
email: firstNonEmpty(p.emailAddress, p.enrichedEmailAddress, p.customEmailAddress, p.email, raw?.emailAddress),
|
|
206
|
+
emailStatus: p.emailStatus ?? raw?.emailStatus ?? undefined,
|
|
207
|
+
raw,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Fake provider (tests / dry-run with no key, no network) ─────────────────
|
|
212
|
+
|
|
213
|
+
export const FAKE_LINKEDIN_PROSPECTS: LinkedInProspect[] = [
|
|
214
|
+
{
|
|
215
|
+
firstName: "Dana",
|
|
216
|
+
lastName: "Okafor",
|
|
217
|
+
fullName: "Dana Okafor",
|
|
218
|
+
headline: "VP RevOps",
|
|
219
|
+
jobTitle: "VP Revenue Operations",
|
|
220
|
+
company: "Northwind Logistics",
|
|
221
|
+
profileUrl: "https://www.linkedin.com/in/dana-okafor",
|
|
222
|
+
location: "Chicago, IL",
|
|
223
|
+
email: "dana@northwind.example",
|
|
224
|
+
emailStatus: "verified",
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
firstName: "Priya",
|
|
228
|
+
lastName: "Raman",
|
|
229
|
+
fullName: "Priya Raman",
|
|
230
|
+
headline: "Head of Sales Ops",
|
|
231
|
+
jobTitle: "Head of Sales Operations",
|
|
232
|
+
company: "Cobalt Health",
|
|
233
|
+
profileUrl: "https://www.linkedin.com/in/priya-raman",
|
|
234
|
+
location: "Austin, TX",
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
firstName: "Marco",
|
|
238
|
+
lastName: "Bianchi",
|
|
239
|
+
fullName: "Marco Bianchi",
|
|
240
|
+
headline: "RevOps Manager",
|
|
241
|
+
jobTitle: "Revenue Operations Manager",
|
|
242
|
+
company: "Ferro Systems",
|
|
243
|
+
profileUrl: "https://www.linkedin.com/in/marco-bianchi",
|
|
244
|
+
location: "Remote",
|
|
245
|
+
email: "marco@ferro.example",
|
|
246
|
+
emailStatus: "guessed",
|
|
247
|
+
},
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
export type FakeLinkedInOptions = {
|
|
251
|
+
prospects?: LinkedInProspect[];
|
|
252
|
+
sources?: LinkedInSource[];
|
|
253
|
+
connection?: ConnectionStatus;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
export function createFakeLinkedInProvider(opts: FakeLinkedInOptions = {}): LinkedInProvider {
|
|
257
|
+
const prospects = opts.prospects ?? FAKE_LINKEDIN_PROSPECTS;
|
|
258
|
+
const sources = opts.sources ?? [{ id: "list_demo", name: "Demo ICP list", count: prospects.length }];
|
|
259
|
+
return {
|
|
260
|
+
name: "fake",
|
|
261
|
+
async checkConnection() {
|
|
262
|
+
return opts.connection ?? { ok: true, detail: "fake LinkedIn provider" };
|
|
263
|
+
},
|
|
264
|
+
async listSources() {
|
|
265
|
+
return sources.map((s) => ({ ...s }));
|
|
266
|
+
},
|
|
267
|
+
async fetchProspects(options = {}) {
|
|
268
|
+
const capped = options.max != null ? prospects.slice(0, options.max) : prospects;
|
|
269
|
+
return capped.map((p) => ({ ...p }));
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|