fullstackgtm 0.37.0 → 0.38.1
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 +62 -0
- package/INSTALL_FOR_AGENTS.md +8 -0
- package/README.md +14 -0
- package/dist/acquireLinkedIn.d.ts +41 -0
- package/dist/acquireLinkedIn.js +57 -0
- package/dist/assign.d.ts +83 -0
- package/dist/assign.js +146 -0
- package/dist/bin.js +14 -2
- package/dist/cli.js +91 -11
- package/dist/connectors/hubspot.js +11 -2
- package/dist/connectors/linkedin.d.ts +78 -0
- package/dist/connectors/linkedin.js +199 -0
- package/dist/enrich.d.ts +21 -1
- package/dist/enrich.js +127 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -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 +8 -0
- package/docs/api.md +24 -1
- package/docs/architecture.md +3 -0
- package/docs/linkedin-connector-spec.md +87 -0
- package/llms.txt +22 -1
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +3 -2
- package/src/acquireLinkedIn.ts +83 -0
- package/src/assign.ts +193 -0
- package/src/bin.ts +17 -4
- package/src/cli.ts +102 -12
- package/src/connectors/hubspot.ts +11 -2
- package/src/connectors/linkedin.ts +272 -0
- package/src/enrich.ts +137 -3
- package/src/index.ts +10 -0
- package/src/reassign.ts +24 -8
- package/src/runReport.ts +76 -0
- package/src/types.ts +8 -0
|
@@ -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
|
+
}
|
package/src/enrich.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { join } from "node:path";
|
|
|
3
3
|
import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.ts";
|
|
4
4
|
import { HUBSPOT_DEFAULT_FIELD_MAPPINGS } from "./mappings.ts";
|
|
5
5
|
import type { AcquireBudget } from "./acquireMeter.ts";
|
|
6
|
+
import { parseAssignmentPolicy, resolveAssignment } from "./assign.ts";
|
|
7
|
+
import type { AssignmentContext, AssignmentPolicy } from "./assign.ts";
|
|
6
8
|
import type {
|
|
7
9
|
CanonicalGtmSnapshot,
|
|
8
10
|
CreateRecordPayload,
|
|
@@ -99,10 +101,12 @@ export type AcquireCreateMap = {
|
|
|
99
101
|
* real emails (e.g. explorium discovers, pipe0 resolves the work email).
|
|
100
102
|
*/
|
|
101
103
|
export type AcquireDiscoveryConfig = {
|
|
102
|
-
provider: "explorium" | "pipe0";
|
|
104
|
+
provider: "explorium" | "pipe0" | "linkedin" | "heyreach";
|
|
103
105
|
filters?: Record<string, unknown>;
|
|
104
106
|
size?: number;
|
|
105
107
|
resolveEmailsWith?: "pipe0";
|
|
108
|
+
/** LinkedIn sources only: the provider-native list id to read (HeyReach lead-list id). */
|
|
109
|
+
listId?: string;
|
|
106
110
|
};
|
|
107
111
|
|
|
108
112
|
export type AcquireConfig = {
|
|
@@ -114,6 +118,12 @@ export type AcquireConfig = {
|
|
|
114
118
|
create: Partial<Record<EnrichObjectType, AcquireCreateMap>>;
|
|
115
119
|
/** Net-new discovery params for API sources, by source id. */
|
|
116
120
|
discovery?: Record<string, AcquireDiscoveryConfig>;
|
|
121
|
+
/**
|
|
122
|
+
* Ownership rule stamped onto every created lead so it is never born
|
|
123
|
+
* ownerless. Absent = no auto-assignment (the CLI may still default to the
|
|
124
|
+
* portal's sole active owner). Shared with `reassign --assign-unowned`.
|
|
125
|
+
*/
|
|
126
|
+
assign?: AssignmentPolicy;
|
|
117
127
|
};
|
|
118
128
|
|
|
119
129
|
export const ENRICH_CONFIG_FILE_NAME = "enrich.config.json";
|
|
@@ -125,7 +135,7 @@ const OBJECT_TYPES: EnrichObjectType[] = ["company", "contact"];
|
|
|
125
135
|
/** Match keys the matcher knows how to read off canonical snapshot records. */
|
|
126
136
|
const MATCH_KEYS: Record<EnrichObjectType, string[]> = {
|
|
127
137
|
company: ["domain", "name"],
|
|
128
|
-
contact: ["email", "name"],
|
|
138
|
+
contact: ["email", "name", "linkedin"],
|
|
129
139
|
};
|
|
130
140
|
|
|
131
141
|
/** API source ids the MVP can pull from. */
|
|
@@ -311,6 +321,16 @@ export function parseEnrichConfig(raw: string): EnrichConfig {
|
|
|
311
321
|
fail('"fields" maps nothing — add at least one field entry (or an "acquire.create" mapping)');
|
|
312
322
|
}
|
|
313
323
|
|
|
324
|
+
// Normalize + validate the assignment policy (fails loud on shape errors so a
|
|
325
|
+
// misconfigured rule never silently mis-routes a created lead).
|
|
326
|
+
if (config.acquire?.assign !== undefined) {
|
|
327
|
+
try {
|
|
328
|
+
config.acquire.assign = parseAssignmentPolicy(config.acquire.assign);
|
|
329
|
+
} catch (error) {
|
|
330
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
314
334
|
return config as EnrichConfig;
|
|
315
335
|
}
|
|
316
336
|
|
|
@@ -348,6 +368,35 @@ export function builtinEnrichPreset(source: string | undefined): EnrichConfig |
|
|
|
348
368
|
*/
|
|
349
369
|
export function builtinAcquirePreset(source: string | undefined): EnrichConfig | undefined {
|
|
350
370
|
const provider = source ?? "pipe0";
|
|
371
|
+
if (provider === "linkedin" || provider === "heyreach") {
|
|
372
|
+
// LinkedIn discovery reads our OWN HeyReach lead list, so it costs $0/record
|
|
373
|
+
// (no spend budget) and keys on the LinkedIn URL — leads are sparse on email,
|
|
374
|
+
// so email-keying would drop most of them. listId is supplied via --list.
|
|
375
|
+
return {
|
|
376
|
+
sources: { linkedin: { kind: "api" } },
|
|
377
|
+
match: { contact: { keys: ["linkedin", "email"], onAmbiguous: "skip" } },
|
|
378
|
+
fields: {},
|
|
379
|
+
policy: { overwrite: "never" },
|
|
380
|
+
acquire: {
|
|
381
|
+
budget: { records: { perDay: 50, perMonth: 500 } },
|
|
382
|
+
costPerRecord: { linkedin: 0 },
|
|
383
|
+
discovery: { linkedin: { provider: "linkedin", size: 25 } },
|
|
384
|
+
create: {
|
|
385
|
+
contact: {
|
|
386
|
+
matchKey: "linkedin",
|
|
387
|
+
properties: {
|
|
388
|
+
hs_linkedin_url: "linkedin",
|
|
389
|
+
firstname: "firstName",
|
|
390
|
+
lastname: "lastName",
|
|
391
|
+
jobtitle: "jobTitle",
|
|
392
|
+
company: "companyName",
|
|
393
|
+
email: "email",
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
}
|
|
351
400
|
if (provider !== "pipe0" && provider !== "explorium") return undefined;
|
|
352
401
|
return {
|
|
353
402
|
sources: { [provider]: { kind: "api" } },
|
|
@@ -542,12 +591,16 @@ function normalizeKeyValue(key: string, value: unknown): string {
|
|
|
542
591
|
.replace(/^www\./, "")
|
|
543
592
|
.replace(/\/.*$/, "");
|
|
544
593
|
}
|
|
594
|
+
if (key === "linkedin") {
|
|
595
|
+
// Match crmContactKeys' `li:` normalization (lowercased, trailing slash stripped).
|
|
596
|
+
return text.trim().replace(/\/+$/, "");
|
|
597
|
+
}
|
|
545
598
|
return text.replace(/\s+/g, " ");
|
|
546
599
|
}
|
|
547
600
|
|
|
548
601
|
function crmKeyValue(
|
|
549
602
|
objectType: EnrichObjectType,
|
|
550
|
-
record: { name?: string; domain?: string; email?: string; firstName?: string; lastName?: string },
|
|
603
|
+
record: { name?: string; domain?: string; email?: string; firstName?: string; lastName?: string; linkedin?: string },
|
|
551
604
|
key: string,
|
|
552
605
|
): string {
|
|
553
606
|
if (objectType === "company") {
|
|
@@ -556,6 +609,7 @@ function crmKeyValue(
|
|
|
556
609
|
return "";
|
|
557
610
|
}
|
|
558
611
|
if (key === "email") return normalizeKeyValue("email", record.email);
|
|
612
|
+
if (key === "linkedin") return normalizeKeyValue("linkedin", record.linkedin);
|
|
559
613
|
if (key === "name") {
|
|
560
614
|
return normalizeKeyValue("name", `${record.firstName ?? ""} ${record.lastName ?? ""}`.trim());
|
|
561
615
|
}
|
|
@@ -922,6 +976,10 @@ export type AcquireCounts = {
|
|
|
922
976
|
created: number;
|
|
923
977
|
/** unmatched rows that would have been created but for the meter ceiling. */
|
|
924
978
|
withheldByMeter: number;
|
|
979
|
+
/** created ops that got an owner stamped (a subset of `created`). */
|
|
980
|
+
assigned: number;
|
|
981
|
+
/** created ops left ownerless (no policy, or policy could not place them). */
|
|
982
|
+
unassigned: number;
|
|
925
983
|
};
|
|
926
984
|
|
|
927
985
|
export type AcquirePlanResult = {
|
|
@@ -941,6 +999,44 @@ export type BuildAcquirePlanOptions = {
|
|
|
941
999
|
now?: () => Date;
|
|
942
1000
|
};
|
|
943
1001
|
|
|
1002
|
+
/** First non-empty value among several candidate paths in a source payload. */
|
|
1003
|
+
function firstSourceValue(payload: Record<string, unknown>, paths: string[]): string | undefined {
|
|
1004
|
+
for (const path of paths) {
|
|
1005
|
+
const value = sourceValueAt(payload, path);
|
|
1006
|
+
if (!isEmptyValue(value)) return String(value).trim();
|
|
1007
|
+
}
|
|
1008
|
+
return undefined;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Lift a prospect/source payload into the routing attributes a territory rule
|
|
1013
|
+
* reads. Tolerant of provider spellings (explorium vs pipe0); absent attributes
|
|
1014
|
+
* stay undefined, so a rule that routes on them simply won't match (falling to
|
|
1015
|
+
* the policy fallback) instead of mis-routing.
|
|
1016
|
+
*/
|
|
1017
|
+
export function acquireAssignmentContext(
|
|
1018
|
+
payload: Record<string, unknown>,
|
|
1019
|
+
companyName: string | undefined,
|
|
1020
|
+
accountOwnerByName: ReadonlyMap<string, string>,
|
|
1021
|
+
): AssignmentContext {
|
|
1022
|
+
const ctx: AssignmentContext = {};
|
|
1023
|
+
const title = firstSourceValue(payload, ["jobTitle", "job_title", "title"]);
|
|
1024
|
+
if (title) ctx.title = title.toLowerCase();
|
|
1025
|
+
const geo = firstSourceValue(payload, ["geo", "country", "company_country_code", "companyCountry"]);
|
|
1026
|
+
if (geo) ctx.geo = geo.toLowerCase();
|
|
1027
|
+
const industry = firstSourceValue(payload, ["industry", "company_industry", "companyIndustry"]);
|
|
1028
|
+
if (industry) ctx.industry = industry.toLowerCase();
|
|
1029
|
+
const employeeBand = firstSourceValue(payload, ["employeeBand", "company_size", "companySize"]);
|
|
1030
|
+
if (employeeBand) ctx.employeeBand = employeeBand;
|
|
1031
|
+
const department = firstSourceValue(payload, ["department", "job_department", "jobDepartment"]);
|
|
1032
|
+
if (department) ctx.department = department.toLowerCase();
|
|
1033
|
+
if (companyName) {
|
|
1034
|
+
const owner = accountOwnerByName.get(companyName.trim().toLowerCase());
|
|
1035
|
+
if (owner) ctx.accountOwnerId = owner;
|
|
1036
|
+
}
|
|
1037
|
+
return ctx;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
944
1040
|
/**
|
|
945
1041
|
* Match each sourced record against the snapshot and route it: matched =
|
|
946
1042
|
* already in the CRM (skip), ambiguous = a possible duplicate exists (skip —
|
|
@@ -969,9 +1065,30 @@ export function buildAcquirePlan(options: BuildAcquirePlanOptions): AcquirePlanR
|
|
|
969
1065
|
unmatched: 0,
|
|
970
1066
|
created: 0,
|
|
971
1067
|
withheldByMeter: 0,
|
|
1068
|
+
assigned: 0,
|
|
1069
|
+
unassigned: 0,
|
|
972
1070
|
};
|
|
973
1071
|
let estCostUsd = 0;
|
|
974
1072
|
|
|
1073
|
+
// Assignment context (built once): the set of owner ids the CRM will accept
|
|
1074
|
+
// (active users), and a company-name → account-owner lookup for the
|
|
1075
|
+
// "account-owner" inherit strategy. Empty when no assign policy is set.
|
|
1076
|
+
const assignPolicy = acquire.assign;
|
|
1077
|
+
const knownOwnerIds = new Set<string>();
|
|
1078
|
+
const accountOwnerByName = new Map<string, string>();
|
|
1079
|
+
if (assignPolicy) {
|
|
1080
|
+
for (const user of snapshot.users ?? []) {
|
|
1081
|
+
if (user.active === false) continue;
|
|
1082
|
+
if (user.crmId) knownOwnerIds.add(user.crmId);
|
|
1083
|
+
if (user.id) knownOwnerIds.add(user.id);
|
|
1084
|
+
}
|
|
1085
|
+
for (const account of snapshot.accounts ?? []) {
|
|
1086
|
+
if (account.name && account.ownerId) {
|
|
1087
|
+
accountOwnerByName.set(account.name.trim().toLowerCase(), account.ownerId);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
975
1092
|
for (const record of records) {
|
|
976
1093
|
const createMap = acquire.create[record.objectType];
|
|
977
1094
|
const match = config.match[record.objectType];
|
|
@@ -1024,6 +1141,20 @@ export function buildAcquirePlan(options: BuildAcquirePlanOptions): AcquirePlanR
|
|
|
1024
1141
|
);
|
|
1025
1142
|
evidence.push(recordEvidence);
|
|
1026
1143
|
|
|
1144
|
+
// Resolve ownership BEFORE the meter-charged create lands, so the lead is
|
|
1145
|
+
// never born ownerless. `counts.created` is the within-run index, giving
|
|
1146
|
+
// round-robin deterministic, even distribution with no persisted cursor.
|
|
1147
|
+
let ownerId: string | undefined;
|
|
1148
|
+
let assignedBy: string | undefined;
|
|
1149
|
+
if (assignPolicy) {
|
|
1150
|
+
const ctx = acquireAssignmentContext(record.payload, associateCompanyName, accountOwnerByName);
|
|
1151
|
+
const resolved = resolveAssignment(assignPolicy, ctx, counts.created, knownOwnerIds);
|
|
1152
|
+
if (resolved.ownerId) {
|
|
1153
|
+
ownerId = resolved.ownerId;
|
|
1154
|
+
assignedBy = resolved.rule;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1027
1158
|
const payload: CreateRecordPayload = {
|
|
1028
1159
|
properties,
|
|
1029
1160
|
matchKey: createMap.matchKey,
|
|
@@ -1031,6 +1162,7 @@ export function buildAcquirePlan(options: BuildAcquirePlanOptions): AcquirePlanR
|
|
|
1031
1162
|
source,
|
|
1032
1163
|
estCostUsd: costPerRecord,
|
|
1033
1164
|
associateCompanyName,
|
|
1165
|
+
...(ownerId ? { ownerId, assignedBy } : {}),
|
|
1034
1166
|
};
|
|
1035
1167
|
operations.push({
|
|
1036
1168
|
id: `op_acq_${fnv1a(`${source}:${record.objectType}:${matchValue}`)}`,
|
|
@@ -1048,6 +1180,8 @@ export function buildAcquirePlan(options: BuildAcquirePlanOptions): AcquirePlanR
|
|
|
1048
1180
|
rollback: "Archive the created record (it was net-new).",
|
|
1049
1181
|
evidenceIds: [recordEvidence.id],
|
|
1050
1182
|
});
|
|
1183
|
+
if (ownerId) counts.assigned += 1;
|
|
1184
|
+
else counts.unassigned += 1;
|
|
1051
1185
|
counts.created += 1;
|
|
1052
1186
|
estCostUsd += costPerRecord;
|
|
1053
1187
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
export { auditSnapshot, defaultPolicy } from "./audit.ts";
|
|
2
|
+
export {
|
|
3
|
+
matchesTerritory,
|
|
4
|
+
parseAssignmentPolicy,
|
|
5
|
+
resolveAssignment,
|
|
6
|
+
type AssignmentContext,
|
|
7
|
+
type AssignmentPolicy,
|
|
8
|
+
type AssignmentResult,
|
|
9
|
+
type AssignmentStrategy,
|
|
10
|
+
type TerritoryRule,
|
|
11
|
+
} from "./assign.ts";
|
|
2
12
|
export { buildBulkUpdatePlan, isFilterableField, parseWhere, type BulkUpdateOptions } from "./bulkUpdate.ts";
|
|
3
13
|
export { buildDedupePlan, dedupeKey, type DedupeOptions } from "./dedupe.ts";
|
|
4
14
|
export { buildReassignPlans, type ReassignObjectType, type ReassignOptions } from "./reassign.ts";
|
package/src/reassign.ts
CHANGED
|
@@ -28,10 +28,19 @@ import type { CanonicalGtmSnapshot, PatchPlan } from "./types.ts";
|
|
|
28
28
|
export type ReassignObjectType = "account" | "contact" | "deal";
|
|
29
29
|
|
|
30
30
|
export type ReassignOptions = {
|
|
31
|
-
/**
|
|
32
|
-
|
|
31
|
+
/**
|
|
32
|
+
* the owner whose records are being handed off. Ignored (and not required)
|
|
33
|
+
* when `assignUnowned` is set — that mode targets ownerless records instead.
|
|
34
|
+
*/
|
|
35
|
+
fromOwnerId?: string;
|
|
33
36
|
/** the receiving owner — must be a known user in the snapshot */
|
|
34
37
|
toOwnerId: string;
|
|
38
|
+
/**
|
|
39
|
+
* Backfill mode: instead of moving records from one owner to another, claim
|
|
40
|
+
* every OWNERLESS record (ownerId:empty) for `toOwnerId`. Shares the create-
|
|
41
|
+
* time assignment intent with `enrich acquire`, applied to existing debt.
|
|
42
|
+
*/
|
|
43
|
+
assignUnowned?: boolean;
|
|
35
44
|
/** which object types to compile plans for (default all three) */
|
|
36
45
|
objects?: ReassignObjectType[];
|
|
37
46
|
/** extra --where scoping, AND-ed into every plan (account fields lifted) */
|
|
@@ -65,11 +74,16 @@ export function buildReassignPlans(
|
|
|
65
74
|
snapshot: CanonicalGtmSnapshot,
|
|
66
75
|
options: ReassignOptions,
|
|
67
76
|
): PatchPlan[] {
|
|
68
|
-
if (!options.
|
|
69
|
-
throw new Error("reassign requires
|
|
77
|
+
if (!options.toOwnerId) {
|
|
78
|
+
throw new Error("reassign requires --to <ownerId>.");
|
|
70
79
|
}
|
|
71
|
-
if (options.
|
|
72
|
-
|
|
80
|
+
if (!options.assignUnowned) {
|
|
81
|
+
if (!options.fromOwnerId) {
|
|
82
|
+
throw new Error("reassign requires --from <ownerId> (or --assign-unowned to claim ownerless records).");
|
|
83
|
+
}
|
|
84
|
+
if (options.fromOwnerId === options.toOwnerId) {
|
|
85
|
+
throw new Error("reassign --from and --to are the same owner — nothing to hand off.");
|
|
86
|
+
}
|
|
73
87
|
}
|
|
74
88
|
// The receiving owner must exist: a typo'd --to would otherwise write an
|
|
75
89
|
// invalid owner onto every matched record.
|
|
@@ -88,7 +102,7 @@ export function buildReassignPlans(
|
|
|
88
102
|
}
|
|
89
103
|
|
|
90
104
|
return objects.map((objectType) => {
|
|
91
|
-
const where = [`ownerId=${options.fromOwnerId}`];
|
|
105
|
+
const where = [options.assignUnowned ? "ownerId:empty" : `ownerId=${options.fromOwnerId}`];
|
|
92
106
|
if (objectType === "deal" && !options.includeClosedDeals) {
|
|
93
107
|
where.push("isClosed=false"); // closed deals keep their historical owner
|
|
94
108
|
}
|
|
@@ -110,7 +124,9 @@ export function buildReassignPlans(
|
|
|
110
124
|
set: { ownerId: options.toOwnerId },
|
|
111
125
|
reason:
|
|
112
126
|
options.reason ??
|
|
113
|
-
|
|
127
|
+
(options.assignUnowned
|
|
128
|
+
? `reassign: claim ownerless ${objectType}s for owner ${options.toOwnerId}`
|
|
129
|
+
: `reassign: hand off ${objectType}s from owner ${options.fromOwnerId} to ${options.toOwnerId}`),
|
|
114
130
|
maxOperations: options.maxOperations,
|
|
115
131
|
});
|
|
116
132
|
});
|
package/src/runReport.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in CLI → hosted-app observability. When the user has paired the CLI
|
|
3
|
+
* (`login --via <url>` → broker token), each command fire-and-forgets a run
|
|
4
|
+
* record to the hosted app's `/api/cli/run`, authenticated by the broker token.
|
|
5
|
+
*
|
|
6
|
+
* Invariants: the CLI never requires web login; this does nothing when unpaired;
|
|
7
|
+
* it never throws and never changes the CLI's exit code or output. Reporting is
|
|
8
|
+
* pure added value for users who granted it.
|
|
9
|
+
*/
|
|
10
|
+
import { getCredential } from "./credentials.ts";
|
|
11
|
+
|
|
12
|
+
type RunEvent = { ts: number; type: string; detail?: string };
|
|
13
|
+
|
|
14
|
+
let counts: Record<string, unknown> | undefined;
|
|
15
|
+
const events: RunEvent[] = [];
|
|
16
|
+
|
|
17
|
+
/** A command annotates its headline metrics (merged). */
|
|
18
|
+
export function reportCounts(values: Record<string, unknown>): void {
|
|
19
|
+
counts = { ...(counts ?? {}), ...values };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A command annotates a structured event (plan saved, meter charged, …). */
|
|
23
|
+
export function reportEvent(type: string, detail?: string): void {
|
|
24
|
+
events.push({ ts: Date.now(), type, detail });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Setup/inspection verbs aren't interesting as "runs" — skip the noise.
|
|
28
|
+
const SKIP_COMMANDS = new Set([
|
|
29
|
+
"login",
|
|
30
|
+
"logout",
|
|
31
|
+
"doctor",
|
|
32
|
+
"help",
|
|
33
|
+
"profiles",
|
|
34
|
+
"--help",
|
|
35
|
+
"-h",
|
|
36
|
+
"--version",
|
|
37
|
+
"-v",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Send the run record if the CLI is paired. Best-effort: a 4s-capped POST that
|
|
42
|
+
* swallows every error. Call once per process from the entry point.
|
|
43
|
+
*/
|
|
44
|
+
export async function flushRunReport(
|
|
45
|
+
args: string[],
|
|
46
|
+
status: "success" | "partial" | "error",
|
|
47
|
+
startedAt: number,
|
|
48
|
+
error?: string,
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
const command = args[0];
|
|
51
|
+
if (!command || SKIP_COMMANDS.has(command)) return;
|
|
52
|
+
const broker = getCredential("broker");
|
|
53
|
+
if (!broker?.baseUrl || !broker.accessToken) return; // opt-in: only when paired
|
|
54
|
+
|
|
55
|
+
const sub = args[1] && !args[1].startsWith("-") ? ` ${args[1]}` : "";
|
|
56
|
+
const finishedAt = Date.now();
|
|
57
|
+
try {
|
|
58
|
+
await fetch(`${broker.baseUrl.replace(/\/+$/, "")}/api/cli/run`, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: { Authorization: `Bearer ${broker.accessToken}`, "Content-Type": "application/json" },
|
|
61
|
+
body: JSON.stringify({
|
|
62
|
+
command: `${command}${sub}`,
|
|
63
|
+
status,
|
|
64
|
+
startedAt,
|
|
65
|
+
finishedAt,
|
|
66
|
+
durationMs: finishedAt - startedAt,
|
|
67
|
+
counts,
|
|
68
|
+
events: events.length ? events : undefined,
|
|
69
|
+
error,
|
|
70
|
+
}),
|
|
71
|
+
signal: AbortSignal.timeout(4000),
|
|
72
|
+
});
|
|
73
|
+
} catch {
|
|
74
|
+
// Observability is best-effort; never affect the CLI outcome.
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -68,6 +68,14 @@ export type CreateRecordPayload = {
|
|
|
68
68
|
source: string;
|
|
69
69
|
estCostUsd?: number;
|
|
70
70
|
associateCompanyName?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Owner to stamp on the new record (canonical owner id). The connector maps
|
|
73
|
+
* it to the CRM's owner property (HubSpot `hubspot_owner_id`) at create time
|
|
74
|
+
* so a lead is never born ownerless. Absent = leave unassigned.
|
|
75
|
+
*/
|
|
76
|
+
ownerId?: string;
|
|
77
|
+
/** Audit label for how the owner was chosen (e.g. "fixed", "territory:0"). */
|
|
78
|
+
assignedBy?: string;
|
|
71
79
|
};
|
|
72
80
|
|
|
73
81
|
export type AuditFindingSeverity = "info" | "warning" | "critical";
|