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,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkedIn → acquire bridge (Phase 1, step 2).
|
|
3
|
+
*
|
|
4
|
+
* Connects the provider-agnostic LinkedIn discovery layer (connectors/linkedin.ts)
|
|
5
|
+
* to the `enrich acquire` spine: pull prospects from a LinkedIn source, map them
|
|
6
|
+
* to the canonical `Prospect`, and hand them to the existing ICP-score → dedup →
|
|
7
|
+
* create_record → meter → dry-run/apply pipeline unchanged.
|
|
8
|
+
*
|
|
9
|
+
* Kept out of cli.ts on purpose: the only remaining wiring is a ~3-line
|
|
10
|
+
* discovery branch in `acquireFromApi` (call `createLinkedInProvider` +
|
|
11
|
+
* `discoverLinkedInProspects`), a `builtinAcquirePreset('linkedin')` entry, the
|
|
12
|
+
* source allowlist/help, and a `login heyreach` credential. That lands once
|
|
13
|
+
* cli.ts is clean of other in-flight work — see docs/linkedin-connector-spec.md.
|
|
14
|
+
*
|
|
15
|
+
* HeyReach reads a pre-populated lead LIST (not an ICP-driven search), and the
|
|
16
|
+
* natural identity key is the LinkedIn profile URL — so acquire's matchKey is
|
|
17
|
+
* `linkedin`, which means the spine skips pipe0 email resolution automatically.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
createHeyReachProvider,
|
|
22
|
+
type HeyReachProviderOptions,
|
|
23
|
+
type LinkedInProspect,
|
|
24
|
+
type LinkedInProvider,
|
|
25
|
+
} from "./connectors/linkedin.ts";
|
|
26
|
+
import type { Prospect } from "./connectors/prospectSources.ts";
|
|
27
|
+
|
|
28
|
+
/** Map a normalized LinkedIn prospect into the canonical acquire `Prospect`. */
|
|
29
|
+
export function linkedInProspectToProspect(lp: LinkedInProspect): Prospect {
|
|
30
|
+
return {
|
|
31
|
+
firstName: lp.firstName || undefined,
|
|
32
|
+
lastName: lp.lastName || undefined,
|
|
33
|
+
fullName: lp.fullName,
|
|
34
|
+
// Title drives ICP scoring; fall back to the headline when no title is set.
|
|
35
|
+
jobTitle: lp.jobTitle ?? lp.headline,
|
|
36
|
+
companyName: lp.company,
|
|
37
|
+
linkedin: lp.profileUrl || undefined,
|
|
38
|
+
email: lp.email,
|
|
39
|
+
// The profile URL is LinkedIn's stable native id — use it for traceability.
|
|
40
|
+
sourceId: lp.profileUrl || undefined,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type DiscoverLinkedInOptions = {
|
|
45
|
+
/** Which source (HeyReach lead-list id) to read; provider may have a default. */
|
|
46
|
+
sourceId?: string;
|
|
47
|
+
/** Hard cap on prospects pulled. */
|
|
48
|
+
max?: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Discovery branch for `acquireFromApi`: read prospects from a LinkedIn source
|
|
53
|
+
* and return them as canonical `Prospect`s. Read-only — never sends. The
|
|
54
|
+
* provider is injected so this is fully testable against the fake (no key).
|
|
55
|
+
*/
|
|
56
|
+
export async function discoverLinkedInProspects(
|
|
57
|
+
provider: LinkedInProvider,
|
|
58
|
+
options: DiscoverLinkedInOptions = {},
|
|
59
|
+
): Promise<Prospect[]> {
|
|
60
|
+
const raw = await provider.fetchProspects({ sourceId: options.sourceId, max: options.max });
|
|
61
|
+
return raw.map(linkedInProspectToProspect);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type CreateLinkedInProviderOptions = Omit<HeyReachProviderOptions, "apiKey">;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build the execution provider for a LinkedIn acquire source. HeyReach is the
|
|
68
|
+
* default; `unipile` is the planned alternative (same interface). Called by the
|
|
69
|
+
* cli acquire branch with the stored/env API key.
|
|
70
|
+
*/
|
|
71
|
+
export function createLinkedInProvider(
|
|
72
|
+
source: string,
|
|
73
|
+
apiKey: string,
|
|
74
|
+
options: CreateLinkedInProviderOptions = {},
|
|
75
|
+
): LinkedInProvider {
|
|
76
|
+
switch (source) {
|
|
77
|
+
case "linkedin":
|
|
78
|
+
case "heyreach":
|
|
79
|
+
return createHeyReachProvider({ apiKey, ...options });
|
|
80
|
+
default:
|
|
81
|
+
throw new Error(`Unknown LinkedIn execution provider "${source}" (supported: heyreach).`);
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/assign.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AssignmentPolicy — the shared rule that decides which owner a record belongs
|
|
3
|
+
* to. One policy, two consumers:
|
|
4
|
+
* 1. `enrich acquire` stamps the resolved owner into each `create_record`
|
|
5
|
+
* payload, so a lead is never born ownerless, and
|
|
6
|
+
* 2. `reassign --assign-unowned` applies the SAME policy to backfill existing
|
|
7
|
+
* ownerless records.
|
|
8
|
+
*
|
|
9
|
+
* Pure + zero-dep: `resolveAssignment` takes plain data (the record's routing
|
|
10
|
+
* attributes + the candidate owners + the within-run index) and returns an
|
|
11
|
+
* ownerId or null. Round-robin distributes by the within-run index, so it needs
|
|
12
|
+
* no persisted rotation cursor — the same plan always resolves the same way
|
|
13
|
+
* (deterministic apply). Every result is gated by the set of KNOWN active
|
|
14
|
+
* owners: a policy that points at an unknown or inactive owner yields null, and
|
|
15
|
+
* the caller leaves that record unassigned + surfaces it — never a bad write.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type AssignmentStrategy = "fixed" | "round-robin" | "territory" | "account-owner";
|
|
19
|
+
|
|
20
|
+
/** The attributes a territory rule can route on, lifted from a record/prospect. */
|
|
21
|
+
export type AssignmentContext = {
|
|
22
|
+
/** ISO country code, lowercased (e.g. "us"). */
|
|
23
|
+
geo?: string;
|
|
24
|
+
/** human industry label, lowercased (e.g. "software"). */
|
|
25
|
+
industry?: string;
|
|
26
|
+
/** provider-agnostic employee band (e.g. "11-50"). */
|
|
27
|
+
employeeBand?: string;
|
|
28
|
+
/** department, lowercased (e.g. "sales"). */
|
|
29
|
+
department?: string;
|
|
30
|
+
/** raw job title, lowercased — matched against rule titleKeywords as substrings. */
|
|
31
|
+
title?: string;
|
|
32
|
+
/** owner of the associated account, if any — enables "account-owner" inherit. */
|
|
33
|
+
accountOwnerId?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One territory clause. Every present key must match (AND across keys); within a
|
|
38
|
+
* key the candidate value must be one of the listed values (OR). `titleKeywords`
|
|
39
|
+
* matches when the context title CONTAINS any keyword as a substring.
|
|
40
|
+
*/
|
|
41
|
+
export type TerritoryRule = {
|
|
42
|
+
when: Partial<{
|
|
43
|
+
geo: string[];
|
|
44
|
+
industry: string[];
|
|
45
|
+
employeeBand: string[];
|
|
46
|
+
department: string[];
|
|
47
|
+
titleKeywords: string[];
|
|
48
|
+
}>;
|
|
49
|
+
ownerId: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type AssignmentPolicy =
|
|
53
|
+
| { strategy: "fixed"; ownerId: string }
|
|
54
|
+
| { strategy: "round-robin"; ownerIds: string[] }
|
|
55
|
+
| { strategy: "territory"; rules: TerritoryRule[]; fallbackOwnerId?: string }
|
|
56
|
+
| { strategy: "account-owner"; fallbackOwnerId?: string };
|
|
57
|
+
|
|
58
|
+
export type AssignmentResult = {
|
|
59
|
+
/** the resolved owner, or null when the policy can't place this record. */
|
|
60
|
+
ownerId: string | null;
|
|
61
|
+
/** short human label for why (audit trail): "fixed", "round-robin", "territory:0", "account-owner", "fallback". */
|
|
62
|
+
rule?: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const STRATEGIES: AssignmentStrategy[] = ["fixed", "round-robin", "territory", "account-owner"];
|
|
66
|
+
|
|
67
|
+
function asStringArray(value: unknown, label: string): string[] {
|
|
68
|
+
if (!Array.isArray(value) || value.length === 0 || value.some((v) => typeof v !== "string" || !v.trim())) {
|
|
69
|
+
throw new Error(`assign: ${label} must be a non-empty array of strings`);
|
|
70
|
+
}
|
|
71
|
+
return value.map((v) => (v as string).trim());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Validate + normalize a raw assignment policy (from enrich.config.json or a
|
|
76
|
+
* CLI flag). Throws on shape errors so a misconfigured policy fails loud at
|
|
77
|
+
* plan time, never silently mis-routes.
|
|
78
|
+
*/
|
|
79
|
+
export function parseAssignmentPolicy(raw: unknown): AssignmentPolicy {
|
|
80
|
+
if (!raw || typeof raw !== "object") throw new Error("assign: expected a policy object");
|
|
81
|
+
const p = raw as Record<string, unknown>;
|
|
82
|
+
const strategy = p.strategy;
|
|
83
|
+
if (typeof strategy !== "string" || !STRATEGIES.includes(strategy as AssignmentStrategy)) {
|
|
84
|
+
throw new Error(`assign: "strategy" must be one of ${STRATEGIES.join(" | ")}`);
|
|
85
|
+
}
|
|
86
|
+
switch (strategy) {
|
|
87
|
+
case "fixed": {
|
|
88
|
+
if (typeof p.ownerId !== "string" || !p.ownerId.trim()) {
|
|
89
|
+
throw new Error('assign: fixed strategy needs a non-empty "ownerId"');
|
|
90
|
+
}
|
|
91
|
+
return { strategy, ownerId: p.ownerId.trim() };
|
|
92
|
+
}
|
|
93
|
+
case "round-robin": {
|
|
94
|
+
const ownerIds = asStringArray(p.ownerIds, "round-robin ownerIds");
|
|
95
|
+
return { strategy, ownerIds };
|
|
96
|
+
}
|
|
97
|
+
case "territory": {
|
|
98
|
+
if (!Array.isArray(p.rules) || p.rules.length === 0) {
|
|
99
|
+
throw new Error('assign: territory strategy needs a non-empty "rules" array');
|
|
100
|
+
}
|
|
101
|
+
const rules: TerritoryRule[] = p.rules.map((r, i) => {
|
|
102
|
+
if (!r || typeof r !== "object") throw new Error(`assign: territory rule ${i} must be an object`);
|
|
103
|
+
const rule = r as Record<string, unknown>;
|
|
104
|
+
if (typeof rule.ownerId !== "string" || !rule.ownerId.trim()) {
|
|
105
|
+
throw new Error(`assign: territory rule ${i} needs a non-empty "ownerId"`);
|
|
106
|
+
}
|
|
107
|
+
const whenRaw = (rule.when ?? {}) as Record<string, unknown>;
|
|
108
|
+
const when: TerritoryRule["when"] = {};
|
|
109
|
+
for (const key of ["geo", "industry", "employeeBand", "department", "titleKeywords"] as const) {
|
|
110
|
+
if (whenRaw[key] !== undefined) when[key] = asStringArray(whenRaw[key], `territory rule ${i} when.${key}`);
|
|
111
|
+
}
|
|
112
|
+
if (Object.keys(when).length === 0) {
|
|
113
|
+
throw new Error(`assign: territory rule ${i} "when" needs at least one clause (else it matches everything — use a fallbackOwnerId instead)`);
|
|
114
|
+
}
|
|
115
|
+
return { when, ownerId: rule.ownerId.trim() };
|
|
116
|
+
});
|
|
117
|
+
const fallbackOwnerId = typeof p.fallbackOwnerId === "string" && p.fallbackOwnerId.trim() ? p.fallbackOwnerId.trim() : undefined;
|
|
118
|
+
return { strategy, rules, ...(fallbackOwnerId ? { fallbackOwnerId } : {}) };
|
|
119
|
+
}
|
|
120
|
+
case "account-owner": {
|
|
121
|
+
const fallbackOwnerId = typeof p.fallbackOwnerId === "string" && p.fallbackOwnerId.trim() ? p.fallbackOwnerId.trim() : undefined;
|
|
122
|
+
return { strategy, ...(fallbackOwnerId ? { fallbackOwnerId } : {}) };
|
|
123
|
+
}
|
|
124
|
+
default:
|
|
125
|
+
throw new Error(`assign: unsupported strategy "${strategy}"`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function clauseMatches(values: string[], candidate: string | undefined): boolean {
|
|
130
|
+
if (candidate === undefined) return false;
|
|
131
|
+
return values.some((v) => v.toLowerCase() === candidate.toLowerCase());
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function titleMatches(keywords: string[], title: string | undefined): boolean {
|
|
135
|
+
if (!title) return false;
|
|
136
|
+
const lower = title.toLowerCase();
|
|
137
|
+
return keywords.some((k) => lower.includes(k.toLowerCase()));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Does the context satisfy every present clause of a territory rule? */
|
|
141
|
+
export function matchesTerritory(rule: TerritoryRule, ctx: AssignmentContext): boolean {
|
|
142
|
+
const { when } = rule;
|
|
143
|
+
if (when.geo && !clauseMatches(when.geo, ctx.geo)) return false;
|
|
144
|
+
if (when.industry && !clauseMatches(when.industry, ctx.industry)) return false;
|
|
145
|
+
if (when.employeeBand && !clauseMatches(when.employeeBand, ctx.employeeBand)) return false;
|
|
146
|
+
if (when.department && !clauseMatches(when.department, ctx.department)) return false;
|
|
147
|
+
if (when.titleKeywords && !titleMatches(when.titleKeywords, ctx.title)) return false;
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Gate a candidate ownerId by the known-active set: returns it, or null. */
|
|
152
|
+
function known(ownerId: string | undefined, knownOwnerIds: ReadonlySet<string>): string | null {
|
|
153
|
+
return ownerId && knownOwnerIds.has(ownerId) ? ownerId : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Resolve the owner for one record. `index` is the record's 0-based position
|
|
158
|
+
* within the run (used by round-robin for deterministic, even within-run
|
|
159
|
+
* distribution without persisted state). `knownOwnerIds` is the set of active
|
|
160
|
+
* owners from the snapshot; any result outside it collapses to null so callers
|
|
161
|
+
* never write an owner the CRM won't accept.
|
|
162
|
+
*/
|
|
163
|
+
export function resolveAssignment(
|
|
164
|
+
policy: AssignmentPolicy,
|
|
165
|
+
ctx: AssignmentContext,
|
|
166
|
+
index: number,
|
|
167
|
+
knownOwnerIds: ReadonlySet<string>,
|
|
168
|
+
): AssignmentResult {
|
|
169
|
+
switch (policy.strategy) {
|
|
170
|
+
case "fixed":
|
|
171
|
+
return { ownerId: known(policy.ownerId, knownOwnerIds), rule: "fixed" };
|
|
172
|
+
case "round-robin": {
|
|
173
|
+
const pool = policy.ownerIds.filter((id) => knownOwnerIds.has(id));
|
|
174
|
+
if (pool.length === 0) return { ownerId: null, rule: "round-robin" };
|
|
175
|
+
const safeIndex = ((index % pool.length) + pool.length) % pool.length;
|
|
176
|
+
return { ownerId: pool[safeIndex], rule: "round-robin" };
|
|
177
|
+
}
|
|
178
|
+
case "territory": {
|
|
179
|
+
for (let i = 0; i < policy.rules.length; i++) {
|
|
180
|
+
if (matchesTerritory(policy.rules[i], ctx)) {
|
|
181
|
+
const hit = known(policy.rules[i].ownerId, knownOwnerIds);
|
|
182
|
+
if (hit) return { ownerId: hit, rule: `territory:${i}` };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { ownerId: known(policy.fallbackOwnerId, knownOwnerIds), rule: "fallback" };
|
|
186
|
+
}
|
|
187
|
+
case "account-owner": {
|
|
188
|
+
const inherited = known(ctx.accountOwnerId, knownOwnerIds);
|
|
189
|
+
if (inherited) return { ownerId: inherited, rule: "account-owner" };
|
|
190
|
+
return { ownerId: known(policy.fallbackOwnerId, knownOwnerIds), rule: "fallback" };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
package/src/bin.ts
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { runCli } from "./cli.ts";
|
|
3
|
+
import { flushRunReport } from "./runReport.ts";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
const args = process.argv.slice(2);
|
|
6
|
+
const startedAt = Date.now();
|
|
7
|
+
|
|
8
|
+
runCli(args)
|
|
9
|
+
.then(async () => {
|
|
10
|
+
// exitCode 2 (audit findings / create-gate) or 1 (no value) = "partial":
|
|
11
|
+
// the command ran but flagged something. No exitCode = clean success.
|
|
12
|
+
const status = process.exitCode && process.exitCode !== 0 ? "partial" : "success";
|
|
13
|
+
await flushRunReport(args, status, startedAt);
|
|
14
|
+
})
|
|
15
|
+
.catch(async (error) => {
|
|
16
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
17
|
+
await flushRunReport(args, "error", startedAt, message);
|
|
18
|
+
console.error(message);
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -135,6 +135,8 @@ import {
|
|
|
135
135
|
type Prospect,
|
|
136
136
|
} from "./connectors/prospectSources.ts";
|
|
137
137
|
import { loadSeen, recordSeen } from "./acquireSeen.ts";
|
|
138
|
+
import { reportCounts, reportEvent } from "./runReport.ts";
|
|
139
|
+
import { createLinkedInProvider, discoverLinkedInProspects } from "./acquireLinkedIn.ts";
|
|
138
140
|
import {
|
|
139
141
|
fitThreshold,
|
|
140
142
|
icpFromAnswers,
|
|
@@ -197,7 +199,7 @@ Usage:
|
|
|
197
199
|
fullstackgtm login salesforce --instance-url <url> [--no-validate]
|
|
198
200
|
fullstackgtm login stripe [--no-validate]
|
|
199
201
|
fullstackgtm login anthropic | openai store an LLM API key for call parse/score
|
|
200
|
-
fullstackgtm login apollo store an Apollo API key for enrich pulls\n fullstackgtm login pipe0 | explorium store a discovery-provider key for enrich acquire\n fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|pipe0|explorium|broker>
|
|
202
|
+
fullstackgtm login apollo store an Apollo API key for enrich pulls\n fullstackgtm login pipe0 | explorium store a discovery-provider key for enrich acquire\n fullstackgtm login heyreach store a HeyReach key for enrich acquire --source linkedin\n fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|pipe0|explorium|heyreach|broker>
|
|
201
203
|
|
|
202
204
|
Secrets (tokens, client secrets) are NEVER passed as flags — they leak via
|
|
203
205
|
the process list and shell history. Pipe them on stdin or enter them at the
|
|
@@ -272,9 +274,10 @@ Usage:
|
|
|
272
274
|
deterministic survivor (richest = most populated data
|
|
273
275
|
fields, ties to lowest id; oldest = lowest id). Approve and
|
|
274
276
|
apply like any plan; merges are IRREVERSIBLE on apply.
|
|
275
|
-
fullstackgtm reassign --from <ownerId> --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--except-deal-stage <stage>] [--include-closed-deals] [source options] [--save] [--json] [--out <path>]
|
|
277
|
+
fullstackgtm reassign (--from <ownerId> | --assign-unowned) --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--except-deal-stage <stage>] [--include-closed-deals] [source options] [--save] [--json] [--out <path>]
|
|
276
278
|
ownership handoff playbook: one bulk-update-style plan per
|
|
277
|
-
object type (ownerId=<from> → <to>).
|
|
279
|
+
object type (ownerId=<from> → <to>). --assign-unowned instead
|
|
280
|
+
claims every ownerless record (ownerId:empty) for --to. Extra --where scoping
|
|
278
281
|
is account-lifted for deals/contacts (domain~.de becomes
|
|
279
282
|
account.domain~.de); --except-deal-stage <stage> excludes
|
|
280
283
|
deals in that stage AND every record whose account has an
|
|
@@ -530,7 +533,7 @@ const HELP: Record<string, HelpEntry> = {
|
|
|
530
533
|
reassign: {
|
|
531
534
|
summary: "ownership handoff: one plan per object type",
|
|
532
535
|
phase: "Remediate",
|
|
533
|
-
synopsis: ["fullstackgtm reassign --from <ownerId> --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--save] [--json]"],
|
|
536
|
+
synopsis: ["fullstackgtm reassign (--from <ownerId> | --assign-unowned) --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--save] [--json]"],
|
|
534
537
|
detail:
|
|
535
538
|
"Extra `--where` scoping is account-lifted for deals/contacts. `--except-deal-stage <stage>` excludes that stage and any record whose account has an open deal in it, re-verified per record at apply.",
|
|
536
539
|
seeAlso: ["bulk-update", "plans", "apply"],
|
|
@@ -929,6 +932,11 @@ async function audit(args: string[]) {
|
|
|
929
932
|
if (staleDealDays !== undefined) policy.staleDealDays = staleDealDays;
|
|
930
933
|
|
|
931
934
|
const plan = auditSnapshot(snapshot, policy, rules);
|
|
935
|
+
reportCounts({
|
|
936
|
+
findings: plan.findings.length,
|
|
937
|
+
critical: plan.findings.filter((f) => f.severity === "critical").length,
|
|
938
|
+
warning: plan.findings.filter((f) => f.severity === "warning").length,
|
|
939
|
+
});
|
|
932
940
|
const out = option(args, "--out");
|
|
933
941
|
if (out) {
|
|
934
942
|
writeFileSync(resolve(process.cwd(), out), `${JSON.stringify(plan, null, 2)}\n`);
|
|
@@ -1877,7 +1885,7 @@ enrich append [--source apollo] [--objects companies,contacts] [--save] [--conf
|
|
|
1877
1885
|
enrich refresh [--source apollo] [--stale-days <n>] [--save] [--config <path>]
|
|
1878
1886
|
[source options] [--run-label <label>] [--json]
|
|
1879
1887
|
enrich ingest <file.csv|payload.json> --source clay [--run-label <label>] [--objects companies|contacts] [--config <path>]
|
|
1880
|
-
enrich acquire [--source <id>] [--max <n>] [--save] [--config <path>] [--json]
|
|
1888
|
+
enrich acquire [--source <id>] [--max <n>] [--list <id>] [--assign-owner <id>] [--save] [--config <path>] [--json]
|
|
1881
1889
|
enrich status [--runs] [--source <id>] [--config <path>] [--json]
|
|
1882
1890
|
|
|
1883
1891
|
acquire creates NET-NEW leads from a staged prospect list (ingest first):
|
|
@@ -1887,6 +1895,16 @@ it dedupes each sourced row against the CRM, skips matches and ambiguities
|
|
|
1887
1895
|
remaining budget (records + spend, per day and per month; whichever is hit
|
|
1888
1896
|
first). Approval-gated like every write: \`--save\` → plans approve → apply.
|
|
1889
1897
|
The meter is charged only when a create actually lands at apply.
|
|
1898
|
+
Discovery sources (zero-config presets): explorium | pipe0 (ICP-driven search),
|
|
1899
|
+
and linkedin (reads a HeyReach lead list — pass --list <id>, key on the LinkedIn
|
|
1900
|
+
URL, $0/record; \`login heyreach\` or HEYREACH_API_KEY).
|
|
1901
|
+
|
|
1902
|
+
Leads are never born ownerless: set an \`acquire.assign\` policy (fixed /
|
|
1903
|
+
round-robin / territory / account-owner) to stamp an owner at create time, or
|
|
1904
|
+
pass \`--assign-owner <id>\`. With a single-owner portal and no policy, acquire
|
|
1905
|
+
defaults every lead to that owner; with several owners and no policy it warns
|
|
1906
|
+
and leaves them unassigned. Backfill existing ownerless records with
|
|
1907
|
+
\`reassign --assign-unowned --to <ownerId>\`.
|
|
1890
1908
|
|
|
1891
1909
|
append pulls from an api source (Apollo — BYO key via \`login apollo\` or
|
|
1892
1910
|
APOLLO_API_KEY) or reads data staged by \`enrich ingest\` (Clay CSV exports,
|
|
@@ -2021,6 +2039,34 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
|
|
|
2021
2039
|
let cap = headroom.maxRecords;
|
|
2022
2040
|
if (explicitMax !== undefined) cap = cap === null ? explicitMax : Math.min(cap, explicitMax);
|
|
2023
2041
|
|
|
2042
|
+
// Assignment: never create an ownerless lead. An explicit `acquire.assign`
|
|
2043
|
+
// policy wins; a `--assign-owner <id>` flag is a quick fixed override;
|
|
2044
|
+
// otherwise, when the portal has exactly one active owner, default every
|
|
2045
|
+
// lead to them. With multiple owners and no policy we refuse to guess —
|
|
2046
|
+
// leads are left unassigned and the operator is told to configure a rule.
|
|
2047
|
+
const assignOwnerFlag = option(rest, "--assign-owner");
|
|
2048
|
+
if (!config.acquire.assign) {
|
|
2049
|
+
if (assignOwnerFlag) {
|
|
2050
|
+
config.acquire.assign = { strategy: "fixed", ownerId: assignOwnerFlag };
|
|
2051
|
+
} else {
|
|
2052
|
+
const activeOwners = (snapshot.users ?? []).filter((u) => u.active !== false);
|
|
2053
|
+
if (activeOwners.length === 1) {
|
|
2054
|
+
const sole = activeOwners[0].crmId ?? activeOwners[0].id;
|
|
2055
|
+
config.acquire.assign = { strategy: "fixed", ownerId: sole };
|
|
2056
|
+
console.error(
|
|
2057
|
+
`Assignment: no policy set — defaulting every lead to the portal's sole owner ` +
|
|
2058
|
+
`${activeOwners[0].name} (${sole}). Configure "acquire.assign" to route across reps.`,
|
|
2059
|
+
);
|
|
2060
|
+
} else if (activeOwners.length > 1) {
|
|
2061
|
+
console.error(
|
|
2062
|
+
`⚠ Assignment: ${activeOwners.length} active owners and no "acquire.assign" policy — ` +
|
|
2063
|
+
`leads will be created OWNERLESS. Add an acquire.assign rule (fixed / round-robin / territory) ` +
|
|
2064
|
+
`or pass --assign-owner <id> so every lead lands with an owner.`,
|
|
2065
|
+
);
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2024
2070
|
const result = buildAcquirePlan({
|
|
2025
2071
|
config,
|
|
2026
2072
|
source,
|
|
@@ -2030,6 +2076,23 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
|
|
|
2030
2076
|
maxRecords: cap,
|
|
2031
2077
|
});
|
|
2032
2078
|
|
|
2079
|
+
if (result.counts.unassigned > 0 && result.counts.created > 0) {
|
|
2080
|
+
console.error(
|
|
2081
|
+
`⚠ ${result.counts.unassigned}/${result.counts.created} proposed lead(s) have no owner ` +
|
|
2082
|
+
`(policy could not place them). They will be created ownerless.`,
|
|
2083
|
+
);
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
// Observability: headline metrics for the web run timeline (paired users).
|
|
2087
|
+
reportCounts({
|
|
2088
|
+
sourced: result.counts.fetched,
|
|
2089
|
+
created: result.counts.created,
|
|
2090
|
+
withheldByMeter: result.counts.withheldByMeter,
|
|
2091
|
+
skippedInCrm: apiSkippedCrm,
|
|
2092
|
+
skippedSeen: apiSkippedSeen,
|
|
2093
|
+
estCostUsd: result.estCostUsd,
|
|
2094
|
+
});
|
|
2095
|
+
|
|
2033
2096
|
const meterLine = formatAcquireMeter(headroom, costPerRecord);
|
|
2034
2097
|
if (!save) {
|
|
2035
2098
|
if (rest.includes("--json")) {
|
|
@@ -2053,6 +2116,7 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
|
|
|
2053
2116
|
if (result.plan.operations.length > 0) {
|
|
2054
2117
|
await createFilePlanStore().save(result.plan);
|
|
2055
2118
|
planIds.push(result.plan.id);
|
|
2119
|
+
reportEvent("plan_saved", result.plan.id);
|
|
2056
2120
|
}
|
|
2057
2121
|
await store.update({
|
|
2058
2122
|
...run,
|
|
@@ -2178,6 +2242,13 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
|
|
|
2178
2242
|
result.counts.fetched += missCount;
|
|
2179
2243
|
result.counts.unmatched += missCount;
|
|
2180
2244
|
|
|
2245
|
+
reportCounts({
|
|
2246
|
+
fetched: result.counts.fetched,
|
|
2247
|
+
matched: result.counts.matched,
|
|
2248
|
+
unmatched: result.counts.unmatched,
|
|
2249
|
+
opsEmitted: result.counts.opsEmitted,
|
|
2250
|
+
});
|
|
2251
|
+
|
|
2181
2252
|
if (!save) {
|
|
2182
2253
|
if (rest.includes("--json")) {
|
|
2183
2254
|
console.log(JSON.stringify(result.plan, null, 2));
|
|
@@ -2227,8 +2298,9 @@ function formatEnrichCounts(counts: EnrichCounts, ambiguities: number) {
|
|
|
2227
2298
|
|
|
2228
2299
|
/** Best-effort enrich-config load for apply-time acquire-budget enforcement. */
|
|
2229
2300
|
/** Provider API key: env override first, then the credential store (`login`). */
|
|
2230
|
-
function providerKey(provider: "explorium" | "pipe0"): string {
|
|
2231
|
-
const envName =
|
|
2301
|
+
function providerKey(provider: "explorium" | "pipe0" | "heyreach"): string {
|
|
2302
|
+
const envName =
|
|
2303
|
+
provider === "explorium" ? "EXPLORIUM_API_KEY" : provider === "pipe0" ? "PIPE0_API_KEY" : "HEYREACH_API_KEY";
|
|
2232
2304
|
if (process.env[envName]) return process.env[envName] as string;
|
|
2233
2305
|
const stored = getCredential(provider);
|
|
2234
2306
|
if (stored) return stored.accessToken;
|
|
@@ -2347,8 +2419,19 @@ async function acquireFromApi(
|
|
|
2347
2419
|
} else if (disc.provider === "pipe0") {
|
|
2348
2420
|
const filters = icp ? icpToCrustdataFilters(icp) : (disc.filters ?? {});
|
|
2349
2421
|
prospects = await fetchPipe0CrustdataProspects({ apiKey: providerKey("pipe0"), filters, limit: size });
|
|
2422
|
+
} else if (disc.provider === "linkedin" || disc.provider === "heyreach") {
|
|
2423
|
+
// LinkedIn reads a pre-populated lead list (not an ICP-driven query); the ICP
|
|
2424
|
+
// scores the pulled list below. List id: disc.listId or --list <id>.
|
|
2425
|
+
const listId = disc.listId ?? option(rest, "--list") ?? undefined;
|
|
2426
|
+
if (!listId) {
|
|
2427
|
+
throw new Error(
|
|
2428
|
+
"enrich acquire --source linkedin needs a HeyReach lead-list id: pass --list <id> or set acquire.discovery.linkedin.listId.",
|
|
2429
|
+
);
|
|
2430
|
+
}
|
|
2431
|
+
const provider = createLinkedInProvider(disc.provider, providerKey("heyreach"));
|
|
2432
|
+
prospects = await discoverLinkedInProspects(provider, { sourceId: listId, max: size });
|
|
2350
2433
|
} else {
|
|
2351
|
-
throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (explorium | pipe0).`);
|
|
2434
|
+
throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (explorium | pipe0 | linkedin).`);
|
|
2352
2435
|
}
|
|
2353
2436
|
|
|
2354
2437
|
// 2. ICP fit scoring BEFORE paying for emails — only above-threshold prospects
|
|
@@ -3213,9 +3296,10 @@ async function dedupeCommand(args: string[]) {
|
|
|
3213
3296
|
async function reassignCommand(args: string[]) {
|
|
3214
3297
|
const from = option(args, "--from");
|
|
3215
3298
|
const to = option(args, "--to");
|
|
3216
|
-
|
|
3299
|
+
const assignUnowned = args.includes("--assign-unowned");
|
|
3300
|
+
if (!to || (!from && !assignUnowned)) {
|
|
3217
3301
|
throw new Error(
|
|
3218
|
-
"Usage: fullstackgtm reassign --from <ownerId> --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--except-deal-stage <stage>] [--include-closed-deals] [source options] [--reason <text>] [--max-operations <n>] [--save] [--out <path>] [--json]",
|
|
3302
|
+
"Usage: fullstackgtm reassign (--from <ownerId> | --assign-unowned) --to <ownerId> [--objects account,contact,deal] [--where <expr> …] [--except-deal-stage <stage>] [--include-closed-deals] [source options] [--reason <text>] [--max-operations <n>] [--save] [--out <path>] [--json]\n\n--assign-unowned claims every OWNERLESS record (ownerId:empty) for --to — the backfill twin of `enrich acquire`'s create-time assignment.",
|
|
3219
3303
|
);
|
|
3220
3304
|
}
|
|
3221
3305
|
const objects = option(args, "--objects")
|
|
@@ -3224,8 +3308,9 @@ async function reassignCommand(args: string[]) {
|
|
|
3224
3308
|
.filter(Boolean) as ReassignObjectType[] | undefined;
|
|
3225
3309
|
const snapshot = await readSnapshot(args);
|
|
3226
3310
|
const plans = buildReassignPlans(snapshot, {
|
|
3227
|
-
fromOwnerId: from,
|
|
3311
|
+
fromOwnerId: from ?? undefined,
|
|
3228
3312
|
toOwnerId: to,
|
|
3313
|
+
assignUnowned,
|
|
3229
3314
|
objects,
|
|
3230
3315
|
where: repeatedOption(args, "--where"),
|
|
3231
3316
|
exceptDealStage: option(args, "--except-deal-stage") ?? undefined,
|
|
@@ -3601,6 +3686,11 @@ async function apply(args: string[]) {
|
|
|
3601
3686
|
}
|
|
3602
3687
|
}
|
|
3603
3688
|
|
|
3689
|
+
// Observability: apply-outcome tallies for the web run timeline.
|
|
3690
|
+
const applyTally: Record<string, number> = { applied: 0, conflict: 0, skipped: 0, failed: 0 };
|
|
3691
|
+
for (const result of run.results) applyTally[result.status] = (applyTally[result.status] ?? 0) + 1;
|
|
3692
|
+
reportCounts(applyTally);
|
|
3693
|
+
|
|
3604
3694
|
if (args.includes("--json")) {
|
|
3605
3695
|
console.log(JSON.stringify(run, null, 2));
|
|
3606
3696
|
} else {
|
|
@@ -4097,7 +4187,7 @@ async function login(args: string[]) {
|
|
|
4097
4187
|
console.log(`Stored Apollo API key in ${credentialsPath()}. \`fullstackgtm enrich append|refresh\` use it automatically.`);
|
|
4098
4188
|
return;
|
|
4099
4189
|
}
|
|
4100
|
-
if (provider === "pipe0" || provider === "explorium") {
|
|
4190
|
+
if (provider === "pipe0" || provider === "explorium" || provider === "heyreach") {
|
|
4101
4191
|
rejectArgvSecret(args, "--token", "--key", "--api-key");
|
|
4102
4192
|
const key = await readSecret(`${provider} API key`);
|
|
4103
4193
|
if (!key) throw new Error(`No ${provider} key provided.`);
|
|
@@ -595,19 +595,28 @@ 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 } };
|
|
608
612
|
}
|
|
609
613
|
|
|
610
614
|
const properties: Record<string, string> = { ...payload.properties };
|
|
615
|
+
// Stamp ownership at create time so the lead is never born ownerless. The
|
|
616
|
+
// canonical ownerId maps to HubSpot's standard owner property.
|
|
617
|
+
if (payload.ownerId && !properties.hubspot_owner_id) {
|
|
618
|
+
properties.hubspot_owner_id = String(payload.ownerId);
|
|
619
|
+
}
|
|
611
620
|
let created;
|
|
612
621
|
try {
|
|
613
622
|
created = await request(`/crm/v3/objects/${objectPath}`, {
|