fullstackgtm 0.41.0 → 0.43.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 +97 -0
- package/dist/calls.d.ts +13 -0
- package/dist/calls.js +14 -3
- package/dist/cli.js +85 -5
- package/dist/connector.js +34 -0
- package/dist/connectors/hubspot.js +182 -24
- package/dist/connectors/salesforce.js +152 -15
- package/dist/draft.js +27 -5
- package/dist/enrich.d.ts +6 -0
- package/dist/enrich.js +47 -1
- package/dist/health.d.ts +12 -0
- package/dist/health.js +29 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/init.d.ts +47 -0
- package/dist/init.js +140 -0
- package/dist/judge.d.ts +36 -3
- package/dist/judge.js +46 -10
- package/dist/signals.d.ts +4 -0
- package/dist/signals.js +1 -0
- package/dist/types.d.ts +23 -0
- package/docs/api.md +16 -1
- package/docs/recipes.md +158 -0
- package/llms.txt +25 -0
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +16 -1
- package/src/calls.ts +24 -3
- package/src/cli.ts +96 -5
- package/src/connector.ts +37 -0
- package/src/connectors/hubspot.ts +180 -23
- package/src/connectors/salesforce.ts +152 -14
- package/src/draft.ts +26 -5
- package/src/enrich.ts +51 -1
- package/src/health.ts +47 -2
- package/src/index.ts +10 -0
- package/src/init.ts +163 -0
- package/src/judge.ts +85 -11
- package/src/signals.ts +5 -0
- package/src/types.ts +23 -0
package/src/calls.ts
CHANGED
|
@@ -369,6 +369,15 @@ export type CallDealSuggestion = {
|
|
|
369
369
|
dealName?: string;
|
|
370
370
|
accountId?: string;
|
|
371
371
|
accountName?: string;
|
|
372
|
+
/**
|
|
373
|
+
* HOW the deal's account was matched — `account_domain` (the company-wide,
|
|
374
|
+
* reliable signal) vs `contact_email` (a specific person, possibly stale) vs
|
|
375
|
+
* `both`. An agent needs this to weight the match; the bare confidence hides it.
|
|
376
|
+
*/
|
|
377
|
+
resolvedVia?: "account_domain" | "contact_email" | "both";
|
|
378
|
+
/** The attendee contacts that matched (the email path), so the contact↔account
|
|
379
|
+
* hop is visible, not just the resolved account. */
|
|
380
|
+
matchedContacts?: { id: string; email?: string; accountId?: string }[];
|
|
372
381
|
confidence: "high" | "low" | "none";
|
|
373
382
|
reason: string;
|
|
374
383
|
};
|
|
@@ -393,17 +402,27 @@ export function suggestCallDeal(
|
|
|
393
402
|
}
|
|
394
403
|
|
|
395
404
|
const accountIds = new Set<string>();
|
|
405
|
+
let viaDomain = false;
|
|
406
|
+
let viaEmail = false;
|
|
407
|
+
const matchedContacts: { id: string; email?: string; accountId?: string }[] = [];
|
|
396
408
|
for (const account of snapshot.accounts) {
|
|
397
409
|
const domain = account.domain?.trim().toLowerCase().replace(/^www\./, "");
|
|
398
|
-
if (domain && domains.has(domain))
|
|
410
|
+
if (domain && domains.has(domain)) {
|
|
411
|
+
accountIds.add(account.id);
|
|
412
|
+
viaDomain = true;
|
|
413
|
+
}
|
|
399
414
|
}
|
|
400
415
|
for (const contact of snapshot.contacts) {
|
|
401
416
|
const email = contact.email?.trim().toLowerCase();
|
|
402
417
|
const at = email ? email.indexOf("@") : -1;
|
|
403
418
|
if (email && at > 0 && domains.has(email.slice(at + 1)) && contact.accountId) {
|
|
404
419
|
accountIds.add(contact.accountId);
|
|
420
|
+
viaEmail = true;
|
|
421
|
+
matchedContacts.push({ id: contact.id, ...(contact.email ? { email: contact.email } : {}), accountId: contact.accountId });
|
|
405
422
|
}
|
|
406
423
|
}
|
|
424
|
+
const resolvedVia = viaDomain && viaEmail ? "both" : viaDomain ? "account_domain" : "contact_email";
|
|
425
|
+
const via = { resolvedVia, ...(matchedContacts.length ? { matchedContacts } : {}) } as const;
|
|
407
426
|
if (accountIds.size === 0) {
|
|
408
427
|
return {
|
|
409
428
|
dealId: null,
|
|
@@ -432,8 +451,9 @@ export function suggestCallDeal(
|
|
|
432
451
|
dealName: top.name,
|
|
433
452
|
accountId: top.accountId,
|
|
434
453
|
accountName: account?.name,
|
|
454
|
+
...via,
|
|
435
455
|
confidence: "high",
|
|
436
|
-
reason: `"${top.name}" is the only open deal on matched account "${account?.name ?? top.accountId}".`,
|
|
456
|
+
reason: `"${top.name}" is the only open deal on matched account "${account?.name ?? top.accountId}" (via ${resolvedVia}).`,
|
|
437
457
|
};
|
|
438
458
|
}
|
|
439
459
|
return {
|
|
@@ -441,8 +461,9 @@ export function suggestCallDeal(
|
|
|
441
461
|
dealName: top.name,
|
|
442
462
|
accountId: top.accountId,
|
|
443
463
|
accountName: account?.name,
|
|
464
|
+
...via,
|
|
444
465
|
confidence: "low",
|
|
445
|
-
reason: `${openDeals.length} open deals on matched account(s); "${top.name}" has the most recent activity. Confirm before writing.`,
|
|
466
|
+
reason: `${openDeals.length} open deals on matched account(s); "${top.name}" has the most recent activity (matched via ${resolvedVia}). Confirm before writing.`,
|
|
446
467
|
};
|
|
447
468
|
}
|
|
448
469
|
|
package/src/cli.ts
CHANGED
|
@@ -125,6 +125,11 @@ import {
|
|
|
125
125
|
remaining,
|
|
126
126
|
type AcquireRemaining,
|
|
127
127
|
} from "./acquireMeter.ts";
|
|
128
|
+
import {
|
|
129
|
+
scaffoldWorkspace,
|
|
130
|
+
type InitProvider,
|
|
131
|
+
type InitSource,
|
|
132
|
+
} from "./init.ts";
|
|
128
133
|
import {
|
|
129
134
|
crmContactKeys,
|
|
130
135
|
fetchExploriumProspects,
|
|
@@ -246,6 +251,10 @@ Usage:
|
|
|
246
251
|
the process list and shell history. Pipe them on stdin or enter them at the
|
|
247
252
|
interactive prompt:
|
|
248
253
|
echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot
|
|
254
|
+
fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
|
|
255
|
+
cold start: scaffold icp.json + enrich.config.json + a
|
|
256
|
+
PLAYBOOK wired for this workspace (the CLI ships primitives,
|
|
257
|
+
your agent is the orchestrator — see docs/recipes.md)
|
|
249
258
|
fullstackgtm snapshot [source options] [--since <iso>] [--out <path> | --archive <dir>]
|
|
250
259
|
fullstackgtm audit [source options] [audit options] [--save]
|
|
251
260
|
fullstackgtm report [source options] [audit options] [report options]
|
|
@@ -466,6 +475,17 @@ type HelpEntry = {
|
|
|
466
475
|
};
|
|
467
476
|
|
|
468
477
|
const HELP: Record<string, HelpEntry> = {
|
|
478
|
+
// Get started
|
|
479
|
+
init: {
|
|
480
|
+
summary: "scaffold a workspace (icp.json + enrich.config.json + PLAYBOOK)",
|
|
481
|
+
phase: "Setup",
|
|
482
|
+
synopsis: [
|
|
483
|
+
"fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]",
|
|
484
|
+
],
|
|
485
|
+
detail:
|
|
486
|
+
"Cold start: writes a starter ICP, an acquire-ready enrich.config.json (with a visible assign seam so leads are never ownerless), and a PLAYBOOK wired with the cold-start + outbound-loop recipes for this workspace. Pure file-writer — no network, keeps existing files unless --force. The CLI ships governed primitives; your coding agent is the orchestrator (see docs/recipes.md).",
|
|
487
|
+
seeAlso: ["icp", "enrich", "signals"],
|
|
488
|
+
},
|
|
469
489
|
// Setup & health
|
|
470
490
|
login: {
|
|
471
491
|
summary: "connect a provider or LLM key (secrets via stdin/env, never argv)",
|
|
@@ -726,12 +746,13 @@ const HELP: Record<string, HelpEntry> = {
|
|
|
726
746
|
|
|
727
747
|
// Verbs that print their own richer multi-subcommand help; runCli routes their
|
|
728
748
|
// `--help` to themselves, so commandHelp() only renders these via `help <verb>`.
|
|
729
|
-
const BESPOKE_HELP = ["call", "market", "enrich", "bulk-update", "schedule", "signals", "icp", "draft"];
|
|
749
|
+
const BESPOKE_HELP = ["init", "call", "market", "enrich", "bulk-update", "schedule", "signals", "icp", "draft"];
|
|
730
750
|
|
|
731
751
|
// Lifecycle-grouped front door. One line per verb, organized by the
|
|
732
752
|
// Prevent→Detect→Remediate→Verify loop so a new user sees ~6 jobs, not 22 verbs.
|
|
733
753
|
function shortUsage() {
|
|
734
754
|
const groups: Array<[string, string[]]> = [
|
|
755
|
+
["Get started", ["init"]],
|
|
735
756
|
["Setup & health", ["login", "logout", "doctor", "profiles", "health"]],
|
|
736
757
|
["Detect — read-only", ["audit", "report", "snapshot", "diff", "rules"]],
|
|
737
758
|
["Prevent — gate writes", ["resolve"]],
|
|
@@ -2659,7 +2680,8 @@ NEVER emits a patch plan; --save persists only the local signal ledger (used by
|
|
|
2659
2680
|
throw new Error(`signals outcome: --result must be one of ${valid.join(", ")}.`);
|
|
2660
2681
|
}
|
|
2661
2682
|
const touchId = option(rest, "--touch") ?? undefined;
|
|
2662
|
-
const
|
|
2683
|
+
const contactId = option(rest, "--contact") ?? undefined;
|
|
2684
|
+
const outcome = makeOutcome({ accountDomain: accountArg, touchId, contactId, result: result as SignalOutcomeResult });
|
|
2663
2685
|
await createFileSignalStore().appendOutcome(outcome);
|
|
2664
2686
|
console.error(
|
|
2665
2687
|
`Recorded outcome "${outcome.result}" for ${outcome.accountDomain} (${outcome.id}). ` +
|
|
@@ -2840,6 +2862,63 @@ LLM key it emits a clearly-labeled stub rather than fake authored copy.`);
|
|
|
2840
2862
|
}
|
|
2841
2863
|
}
|
|
2842
2864
|
|
|
2865
|
+
/**
|
|
2866
|
+
* `init` — scaffold a GTM workspace from cold scratch: a starter icp.json, an
|
|
2867
|
+
* enrich.config.json (acquire preset + a visible assign seam), and a PLAYBOOK
|
|
2868
|
+
* wired with the chosen source/provider that points at the recipes. Pure
|
|
2869
|
+
* file-writer: no network, never overwrites without --force.
|
|
2870
|
+
*/
|
|
2871
|
+
function initCommand(args: string[]) {
|
|
2872
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
2873
|
+
console.log(`Usage:
|
|
2874
|
+
fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
|
|
2875
|
+
|
|
2876
|
+
Scaffolds a workspace so the first acquire/signals/judge/draft commands work:
|
|
2877
|
+
icp.json starter ICP (edit, or rebuild with \`icp interview\`)
|
|
2878
|
+
enrich.config.json acquire preset for --source + a placeholder assign policy
|
|
2879
|
+
PLAYBOOK.md the cold-start + outbound-loop recipes wired for this workspace
|
|
2880
|
+
|
|
2881
|
+
The CLI ships governed primitives; your coding agent is the orchestrator. See
|
|
2882
|
+
docs/recipes.md for the full play set. Existing files are kept unless --force.`);
|
|
2883
|
+
return;
|
|
2884
|
+
}
|
|
2885
|
+
const source = (option(args, "--source") ?? "pipe0") as InitSource;
|
|
2886
|
+
if (!["pipe0", "explorium", "linkedin"].includes(source)) {
|
|
2887
|
+
throw new Error(`init: --source must be pipe0|explorium|linkedin (got "${source}")`);
|
|
2888
|
+
}
|
|
2889
|
+
const provider = (option(args, "--provider") ?? "hubspot") as InitProvider;
|
|
2890
|
+
if (!["hubspot", "salesforce"].includes(provider)) {
|
|
2891
|
+
throw new Error(`init: --provider must be hubspot|salesforce (got "${provider}")`);
|
|
2892
|
+
}
|
|
2893
|
+
const outDir = resolve(process.cwd(), option(args, "--out") ?? ".");
|
|
2894
|
+
const force = args.includes("--force");
|
|
2895
|
+
const current = activeProfile();
|
|
2896
|
+
const profile = current === DEFAULT_PROFILE ? undefined : current;
|
|
2897
|
+
|
|
2898
|
+
const files = scaffoldWorkspace({ source, provider, profile });
|
|
2899
|
+
const wrote: string[] = [];
|
|
2900
|
+
const kept: string[] = [];
|
|
2901
|
+
for (const file of files) {
|
|
2902
|
+
const path = resolve(outDir, file.path);
|
|
2903
|
+
if (existsSync(path) && !force) {
|
|
2904
|
+
kept.push(file.path);
|
|
2905
|
+
continue;
|
|
2906
|
+
}
|
|
2907
|
+
writeFileSync(path, file.content);
|
|
2908
|
+
wrote.push(file.path);
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
console.log(
|
|
2912
|
+
`Scaffolded workspace (${provider}, discovery via ${source}${profile ? `, profile ${profile}` : ""}).`,
|
|
2913
|
+
);
|
|
2914
|
+
if (wrote.length) console.log(` wrote: ${wrote.join(", ")}`);
|
|
2915
|
+
if (kept.length) console.log(` kept (use --force to overwrite): ${kept.join(", ")}`);
|
|
2916
|
+
console.log(
|
|
2917
|
+
"\nNext: edit icp.json + set acquire.assign.ownerId in enrich.config.json, then follow PLAYBOOK.md\n" +
|
|
2918
|
+
"(full recipe set: docs/recipes.md).",
|
|
2919
|
+
);
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2843
2922
|
/**
|
|
2844
2923
|
* `icp` — develop and inspect the Ideal Customer Profile that targets acquire.
|
|
2845
2924
|
* The CLI can't run AskUserQuestion itself; `icp interview` emits the question
|
|
@@ -2929,12 +3008,20 @@ ops. Develop one by interview, then \`enrich acquire\` picks up ./icp.json.
|
|
|
2929
3008
|
const unjudged = signalRun.signals.filter((s) => !s.judgedBy);
|
|
2930
3009
|
if (unjudged.length === 0) throw new Error(`Signal run "${signalRun.runLabel}" has no unjudged signals.`);
|
|
2931
3010
|
|
|
2932
|
-
// 2) Optional inputs: ICP (may be undefined), snapshot
|
|
2933
|
-
//
|
|
3011
|
+
// 2) Optional inputs: ICP (may be undefined), snapshot, outcomes + config.
|
|
3012
|
+
// The snapshot is loaded whenever a source is given (--provider/--input/
|
|
3013
|
+
// --demo/--sample) — it resolves each decision's CRM target (accountId +
|
|
3014
|
+
// best contact) so `draft` can write against a real record. --with-history
|
|
3015
|
+
// additionally enables the memory + fit scoring inputs.
|
|
2934
3016
|
const icp = loadIcp(rest);
|
|
2935
3017
|
const config: SignalsConfig = DEFAULT_SIGNALS_CONFIG;
|
|
2936
3018
|
const outcomes = await signalStore.listOutcomes();
|
|
2937
|
-
const
|
|
3019
|
+
const hasSnapshotSource =
|
|
3020
|
+
Boolean(option(rest, "--provider")) ||
|
|
3021
|
+
Boolean(option(rest, "--input")) ||
|
|
3022
|
+
rest.includes("--demo") ||
|
|
3023
|
+
rest.includes("--sample");
|
|
3024
|
+
const snapshot = withHistory || hasSnapshotSource ? await readSnapshot(rest) : undefined;
|
|
2938
3025
|
|
|
2939
3026
|
// 3) Resolve the LLM seam ONLY if a key already exists (deterministic baseline
|
|
2940
3027
|
// otherwise — never PROMPT here; judge must run key-free).
|
|
@@ -5206,6 +5293,10 @@ export async function runCli(argv: string[]) {
|
|
|
5206
5293
|
await enrichCommand(args);
|
|
5207
5294
|
return;
|
|
5208
5295
|
}
|
|
5296
|
+
if (command === "init") {
|
|
5297
|
+
initCommand(args);
|
|
5298
|
+
return;
|
|
5299
|
+
}
|
|
5209
5300
|
if (command === "icp") {
|
|
5210
5301
|
await icpCommand(args);
|
|
5211
5302
|
return;
|
package/src/connector.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { dedupeKey } from "./dedupe.ts";
|
|
|
2
2
|
import { requiresHumanInput } from "./rules.ts";
|
|
3
3
|
import type {
|
|
4
4
|
CanonicalGtmSnapshot,
|
|
5
|
+
CreateRecordPayload,
|
|
5
6
|
GtmConnector,
|
|
6
7
|
PatchOperation,
|
|
7
8
|
PatchOperationResult,
|
|
@@ -283,7 +284,43 @@ export async function applyPatchPlan(
|
|
|
283
284
|
}
|
|
284
285
|
}
|
|
285
286
|
|
|
287
|
+
// Bulk fast-path: route independent, approved create_record CONTACT ops
|
|
288
|
+
// through the connector's batch API (batched resolve-first read + batched
|
|
289
|
+
// writes) instead of one round-trip per record. Only ops that are safe to
|
|
290
|
+
// batch are eligible — every other op (grouped, needs an override, carries a
|
|
291
|
+
// company association, conflicted, or any non-create op) stays on the serial
|
|
292
|
+
// path below, so the safety contract is unchanged. Results are merged in by
|
|
293
|
+
// id; the serial loop short-circuits anything already resolved here.
|
|
294
|
+
const batched = new Map<string, PatchOperationResult>();
|
|
295
|
+
if (connector.applyCreateContactsBatch && !guardFailure) {
|
|
296
|
+
const eligible = plan.operations.filter(
|
|
297
|
+
(operation) =>
|
|
298
|
+
approved.has(operation.id) &&
|
|
299
|
+
operation.operation === "create_record" &&
|
|
300
|
+
operation.objectType === "contact" &&
|
|
301
|
+
options.valueOverrides?.[operation.id] === undefined &&
|
|
302
|
+
!requiresHumanInput(operation.afterValue) &&
|
|
303
|
+
!operation.groupId &&
|
|
304
|
+
!(operation.afterValue as CreateRecordPayload | undefined)?.associateCompanyName &&
|
|
305
|
+
!conflicts.has(operation.id) &&
|
|
306
|
+
!irreversibleStale.has(operation.id) &&
|
|
307
|
+
!(staleByFilter && staleByFilter.has(operation.objectId)),
|
|
308
|
+
);
|
|
309
|
+
// Only worth the batch machinery for more than a couple of creates.
|
|
310
|
+
if (eligible.length > 2) {
|
|
311
|
+
const batchResults = await connector.applyCreateContactsBatch(eligible);
|
|
312
|
+
for (const result of batchResults) batched.set(result.operationId, result);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
286
316
|
for (const operation of plan.operations) {
|
|
317
|
+
const batchedResult = batched.get(operation.id);
|
|
318
|
+
if (batchedResult) {
|
|
319
|
+
results.push(batchedResult);
|
|
320
|
+
attempted += 1;
|
|
321
|
+
if (batchedResult.status === "applied") applied += 1;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
287
324
|
if (!approved.has(operation.id)) {
|
|
288
325
|
results.push({
|
|
289
326
|
operationId: operation.id,
|
|
@@ -506,18 +506,34 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
506
506
|
};
|
|
507
507
|
}
|
|
508
508
|
|
|
509
|
-
/** Exact-
|
|
510
|
-
async function
|
|
509
|
+
/** Exact-value company lookup (by `name` or `domain`) for resolve-first creates. */
|
|
510
|
+
async function searchCompaniesBy(property: string, value: string): Promise<string[]> {
|
|
511
511
|
const data = await request(`/crm/v3/objects/companies/search`, {
|
|
512
512
|
method: "POST",
|
|
513
513
|
body: JSON.stringify({
|
|
514
|
-
filterGroups: [{ filters: [{ propertyName:
|
|
515
|
-
properties: [
|
|
514
|
+
filterGroups: [{ filters: [{ propertyName: property, operator: "EQ", value }] }],
|
|
515
|
+
properties: [property],
|
|
516
516
|
limit: 3,
|
|
517
517
|
}),
|
|
518
518
|
});
|
|
519
519
|
return ((data?.results ?? []) as Array<{ id: string }>).map((row) => String(row.id));
|
|
520
520
|
}
|
|
521
|
+
const searchCompaniesByName = (name: string) => searchCompaniesBy("name", name);
|
|
522
|
+
|
|
523
|
+
/** Stamp `domain` on a company only when it has none (fill-blank, never clobber). */
|
|
524
|
+
async function fillCompanyDomainIfEmpty(companyId: string, domain: string): Promise<void> {
|
|
525
|
+
try {
|
|
526
|
+
const data = await request(`/crm/v3/objects/companies/${encodeURIComponent(companyId)}?properties=domain`);
|
|
527
|
+
const current = data?.properties?.domain;
|
|
528
|
+
if (typeof current === "string" && current.trim()) return; // already has one — leave it
|
|
529
|
+
await request(`/crm/v3/objects/companies/${encodeURIComponent(companyId)}`, {
|
|
530
|
+
method: "PATCH",
|
|
531
|
+
body: JSON.stringify({ properties: { domain } }),
|
|
532
|
+
});
|
|
533
|
+
} catch {
|
|
534
|
+
// Best-effort: a failed domain fill must not sink the contact create.
|
|
535
|
+
}
|
|
536
|
+
}
|
|
521
537
|
|
|
522
538
|
/** Exact-value contact lookup (by any searchable property) for resolve-first creates. */
|
|
523
539
|
async function searchContactsBy(property: string, value: string): Promise<string[]> {
|
|
@@ -533,36 +549,51 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
533
549
|
}
|
|
534
550
|
|
|
535
551
|
/**
|
|
536
|
-
* Resolve a company
|
|
537
|
-
*
|
|
538
|
-
*
|
|
552
|
+
* Resolve a company to a real account record, creating it on a confirmed miss.
|
|
553
|
+
* Matches by DOMAIN first (the accurate key) and falls back to exact name;
|
|
554
|
+
* creates with the domain stamped, and fills the domain on a name-matched
|
|
555
|
+
* account that lacks one — so the account is signal-watchable. Returns null on
|
|
556
|
+
* ambiguity (≥2 matches) so the caller skips the association rather than
|
|
557
|
+
* guessing. Same-run safe via createdCompaniesByName.
|
|
539
558
|
*/
|
|
540
|
-
async function
|
|
541
|
-
const
|
|
542
|
-
const already = createdCompaniesByName.get(
|
|
559
|
+
async function resolveOrCreateCompany(name: string, domain: string | undefined, opId: string): Promise<string | null> {
|
|
560
|
+
const cacheKey = domain ? `d:${domain.toLowerCase()}` : `n:${name.toLowerCase()}`;
|
|
561
|
+
const already = createdCompaniesByName.get(cacheKey);
|
|
543
562
|
if (already) return already;
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
if (
|
|
547
|
-
|
|
548
|
-
return
|
|
563
|
+
|
|
564
|
+
// 1. Domain-first match (accurate; an account matched here already has it).
|
|
565
|
+
if (domain) {
|
|
566
|
+
const byDomain = await searchCompaniesBy("domain", domain);
|
|
567
|
+
if (byDomain.length > 1) return null;
|
|
568
|
+
if (byDomain.length === 1) {
|
|
569
|
+
createdCompaniesByName.set(cacheKey, byDomain[0]);
|
|
570
|
+
return byDomain[0];
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
// 2. Exact-name match; stamp the domain if the account has none.
|
|
574
|
+
const byName = await searchCompaniesByName(name);
|
|
575
|
+
if (byName.length > 1) return null;
|
|
576
|
+
if (byName.length === 1) {
|
|
577
|
+
if (domain) await fillCompanyDomainIfEmpty(byName[0], domain);
|
|
578
|
+
createdCompaniesByName.set(cacheKey, byName[0]);
|
|
579
|
+
return byName[0];
|
|
549
580
|
}
|
|
581
|
+
// 3. Create with name + domain (provenance is best-effort).
|
|
582
|
+
const props: Record<string, string> = { name, ...(domain ? { domain } : {}) };
|
|
550
583
|
let created;
|
|
551
584
|
try {
|
|
552
585
|
created = await request(`/crm/v3/objects/companies`, {
|
|
553
586
|
method: "POST",
|
|
554
|
-
body: JSON.stringify({
|
|
555
|
-
properties: { name, hs_object_source_detail_2: `fullstackgtm acquire (${opId})` },
|
|
556
|
-
}),
|
|
587
|
+
body: JSON.stringify({ properties: { ...props, hs_object_source_detail_2: `fullstackgtm acquire (${opId})` } }),
|
|
557
588
|
});
|
|
558
589
|
} catch {
|
|
559
590
|
created = await request(`/crm/v3/objects/companies`, {
|
|
560
591
|
method: "POST",
|
|
561
|
-
body: JSON.stringify({ properties:
|
|
592
|
+
body: JSON.stringify({ properties: props }),
|
|
562
593
|
});
|
|
563
594
|
}
|
|
564
595
|
const id = String(created.id);
|
|
565
|
-
createdCompaniesByName.set(
|
|
596
|
+
createdCompaniesByName.set(cacheKey, id);
|
|
566
597
|
return id;
|
|
567
598
|
}
|
|
568
599
|
|
|
@@ -588,7 +619,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
588
619
|
}
|
|
589
620
|
|
|
590
621
|
if (operation.objectType === "account") {
|
|
591
|
-
const id = await
|
|
622
|
+
const id = await resolveOrCreateCompany(matchValue, stringOrUndefined(payload.properties?.domain), operation.id);
|
|
592
623
|
if (id === null) {
|
|
593
624
|
return { operationId: operation.id, status: "skipped", detail: `create_record: ambiguous — multiple companies named "${matchValue}". Not creating.` };
|
|
594
625
|
}
|
|
@@ -637,13 +668,14 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
637
668
|
let companyNote = "";
|
|
638
669
|
if (payload.associateCompanyName) {
|
|
639
670
|
try {
|
|
640
|
-
const companyId = await
|
|
671
|
+
const companyId = await resolveOrCreateCompany(payload.associateCompanyName, payload.associateCompanyDomain, operation.id);
|
|
641
672
|
if (companyId) {
|
|
642
673
|
await request(
|
|
643
674
|
`/crm/v4/objects/contacts/${encodeURIComponent(contactId)}/associations/default/companies/${encodeURIComponent(companyId)}`,
|
|
644
675
|
{ method: "PUT" },
|
|
645
676
|
);
|
|
646
|
-
|
|
677
|
+
const domainNote = payload.associateCompanyDomain ? ` (${payload.associateCompanyDomain})` : "";
|
|
678
|
+
companyNote = ` Linked to company "${payload.associateCompanyName}"${domainNote} (${companyId}).`;
|
|
647
679
|
} else {
|
|
648
680
|
companyNote = ` Company "${payload.associateCompanyName}" was ambiguous; left unlinked.`;
|
|
649
681
|
}
|
|
@@ -660,6 +692,130 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
660
692
|
};
|
|
661
693
|
}
|
|
662
694
|
|
|
695
|
+
/** Bulk resolve-first lookup: which match values already exist (value→id). */
|
|
696
|
+
async function searchContactsByValues(property: string, values: string[]): Promise<Map<string, string>> {
|
|
697
|
+
const found = new Map<string, string>();
|
|
698
|
+
for (let i = 0; i < values.length; i += 100) {
|
|
699
|
+
const chunk = values.slice(i, i + 100);
|
|
700
|
+
const data = await request(`/crm/v3/objects/contacts/search`, {
|
|
701
|
+
method: "POST",
|
|
702
|
+
body: JSON.stringify({
|
|
703
|
+
filterGroups: [{ filters: [{ propertyName: property, operator: "IN", values: chunk }] }],
|
|
704
|
+
properties: [property],
|
|
705
|
+
limit: 100,
|
|
706
|
+
}),
|
|
707
|
+
});
|
|
708
|
+
for (const rec of (data?.results ?? []) as Array<{ id?: string; properties?: Record<string, string> }>) {
|
|
709
|
+
const v = rec?.properties?.[property];
|
|
710
|
+
if (typeof v === "string" && rec.id) found.set(v.toLowerCase(), String(rec.id));
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return found;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Batch create_record for contacts: bulk resolve-first (search IN), then
|
|
718
|
+
* bulk-create the genuine misses (batch/create, 100/call) — two API calls per
|
|
719
|
+
* ~100 leads instead of two per lead. On a batch-create rejection (e.g. an
|
|
720
|
+
* email collision HubSpot 409s the whole batch over), it falls back to
|
|
721
|
+
* per-record createRecord for that chunk, so one bad row never sinks the rest
|
|
722
|
+
* and resolve-first is preserved. Returns one result per input op.
|
|
723
|
+
*/
|
|
724
|
+
async function applyCreateContactsBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
|
|
725
|
+
const byId = new Map<string, PatchOperationResult>();
|
|
726
|
+
// Group by the HubSpot search property (matchKey is usually uniform across a
|
|
727
|
+
// plan, but a mixed plan stays correct this way).
|
|
728
|
+
const groups = new Map<string, PatchOperation[]>();
|
|
729
|
+
for (const op of operations) {
|
|
730
|
+
const matchKey = (op.afterValue as CreateRecordPayload | undefined)?.matchKey || "email";
|
|
731
|
+
const searchProperty = HUBSPOT_DEFAULT_FIELD_MAPPINGS.contacts[matchKey] ?? matchKey;
|
|
732
|
+
let arr = groups.get(searchProperty);
|
|
733
|
+
if (!arr) groups.set(searchProperty, (arr = []));
|
|
734
|
+
arr.push(op);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
for (const [searchProperty, ops] of groups) {
|
|
738
|
+
const wanted = ops
|
|
739
|
+
.map((op) => String((op.afterValue as CreateRecordPayload).matchValue ?? "").trim())
|
|
740
|
+
.filter(Boolean);
|
|
741
|
+
const existing = await searchContactsByValues(searchProperty, [...new Set(wanted.map((v) => v.toLowerCase()))]);
|
|
742
|
+
|
|
743
|
+
const toCreate: PatchOperation[] = [];
|
|
744
|
+
const seenInBatch = new Set<string>();
|
|
745
|
+
for (const op of ops) {
|
|
746
|
+
const payload = op.afterValue as CreateRecordPayload;
|
|
747
|
+
const matchKey = payload.matchKey || "email";
|
|
748
|
+
const matchValue = String(payload.matchValue ?? "").trim();
|
|
749
|
+
if (!matchValue) {
|
|
750
|
+
byId.set(op.id, { operationId: op.id, status: "skipped", detail: "create_record needs a non-empty matchValue to resolve-first." });
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
const lower = matchValue.toLowerCase();
|
|
754
|
+
const dedupeKey = `${matchKey}:${lower}`;
|
|
755
|
+
const already = createdContactsByMatch.get(dedupeKey);
|
|
756
|
+
if (already) {
|
|
757
|
+
byId.set(op.id, { operationId: op.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already created earlier in this run; not duplicating.`, providerData: { id: already, existing: true } });
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
const existingId = existing.get(lower);
|
|
761
|
+
if (existingId) {
|
|
762
|
+
createdContactsByMatch.set(dedupeKey, existingId);
|
|
763
|
+
byId.set(op.id, { operationId: op.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} already exists (${existingId}); resolve-first declined to create.`, providerData: { id: existingId, existing: true } });
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
if (seenInBatch.has(lower)) {
|
|
767
|
+
byId.set(op.id, { operationId: op.id, status: "skipped", detail: `Contact ${matchKey}=${matchValue} duplicated within this batch; not duplicating.` });
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
seenInBatch.add(lower);
|
|
771
|
+
toCreate.push(op);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
for (let i = 0; i < toCreate.length; i += 100) {
|
|
775
|
+
const chunk = toCreate.slice(i, i + 100);
|
|
776
|
+
const inputs = chunk.map((op) => {
|
|
777
|
+
const payload = op.afterValue as CreateRecordPayload;
|
|
778
|
+
const properties: Record<string, string> = { ...payload.properties };
|
|
779
|
+
if (payload.ownerId && !properties.hubspot_owner_id) properties.hubspot_owner_id = String(payload.ownerId);
|
|
780
|
+
properties.hs_object_source_detail_2 = `fullstackgtm acquire (${op.id})`;
|
|
781
|
+
return { properties };
|
|
782
|
+
});
|
|
783
|
+
try {
|
|
784
|
+
const data = await request(`/crm/v3/objects/contacts/batch/create`, {
|
|
785
|
+
method: "POST",
|
|
786
|
+
body: JSON.stringify({ inputs }),
|
|
787
|
+
});
|
|
788
|
+
const created = (data?.results ?? []) as Array<{ id?: string; properties?: Record<string, string> }>;
|
|
789
|
+
const idByValue = new Map<string, string>();
|
|
790
|
+
for (const rec of created) {
|
|
791
|
+
const v = rec?.properties?.[searchProperty];
|
|
792
|
+
if (typeof v === "string" && rec.id) idByValue.set(v.toLowerCase(), String(rec.id));
|
|
793
|
+
}
|
|
794
|
+
for (const op of chunk) {
|
|
795
|
+
const payload = op.afterValue as CreateRecordPayload;
|
|
796
|
+
const matchKey = payload.matchKey || "email";
|
|
797
|
+
const matchValue = String(payload.matchValue).trim();
|
|
798
|
+
const id = idByValue.get(matchValue.toLowerCase());
|
|
799
|
+
if (id) {
|
|
800
|
+
createdContactsByMatch.set(`${matchKey}:${matchValue.toLowerCase()}`, id);
|
|
801
|
+
byId.set(op.id, { operationId: op.id, status: "applied", detail: `Created contact ${matchKey}=${matchValue} (${id}).`, providerData: { id, created: true } });
|
|
802
|
+
} else {
|
|
803
|
+
// Result didn't echo this value — resolve it per-record to be safe.
|
|
804
|
+
byId.set(op.id, await createRecord(op));
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
} catch {
|
|
808
|
+
// Whole-chunk rejection (e.g. a collision). Isolate per record.
|
|
809
|
+
for (const op of chunk) byId.set(op.id, await createRecord(op));
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return operations.map(
|
|
815
|
+
(op) => byId.get(op.id) ?? { operationId: op.id, status: "skipped", detail: "create_record was not processed." },
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
663
819
|
async function createTask(operation: PatchOperation): Promise<PatchOperationResult> {
|
|
664
820
|
const associationTypeId = TASK_ASSOCIATION_TYPE_IDS[operation.objectType];
|
|
665
821
|
if (associationTypeId === undefined) {
|
|
@@ -926,6 +1082,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
926
1082
|
fetchSnapshot,
|
|
927
1083
|
fetchChanges,
|
|
928
1084
|
applyOperation,
|
|
1085
|
+
applyCreateContactsBatch,
|
|
929
1086
|
readField,
|
|
930
1087
|
};
|
|
931
1088
|
}
|