fullstackgtm 0.28.3 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +2 -2
- package/dist/connectors/hubspot.js +41 -7
- package/dist/connectors/salesforce.js +98 -8
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/docs/roadmap-to-1.0.md +0 -5
- package/package.json +1 -1
- package/src/connectors/hubspot.ts +40 -7
- package/src/connectors/salesforce.ts +107 -9
- package/src/index.ts +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,44 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
5
5
|
and the project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
|
|
7
7
|
|
|
8
|
+
## [0.30.0] — 2026-06-17
|
|
9
|
+
|
|
10
|
+
Connector fixes — the two concrete defects a real HubSpot/Salesforce shop hits.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Salesforce `dedupe`/`merge_records` now works** (Accounts and Contacts) via
|
|
15
|
+
the SOAP `merge()` call — REST has no merge resource, but the OAuth token
|
|
16
|
+
doubles as the SOAP session id, so no extra auth. Groups larger than three are
|
|
17
|
+
merged in batches (master + 2 per call); failures are reported honestly with
|
|
18
|
+
what merged before the stop. **Opportunities remain unmergeable** (Salesforce
|
|
19
|
+
exposes no opportunity merge anywhere) and are refused with that explanation.
|
|
20
|
+
Merges still flow through the approval gate and the irreversible-op drift guard
|
|
21
|
+
(the survivor/loser existence check runs before the SOAP call). Exercised by
|
|
22
|
+
unit tests; validate against a sandbox before wiring into automation.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **HubSpot closed/won detection is now pipeline-aware.** `isClosed`/`isWon`
|
|
27
|
+
were derived by substring-matching the stage against `closedwon`/`closedlost`,
|
|
28
|
+
so custom pipelines (opaque stage ids) read every deal as open and flooded
|
|
29
|
+
stale-deal / past-close findings with false positives. The connector now reads
|
|
30
|
+
the pipeline stage metadata (`/crm/v3/pipelines/deals`: `isClosed` +
|
|
31
|
+
`probability`) once per snapshot and derives closed/won from that, falling back
|
|
32
|
+
to the substring heuristic only when the pipelines read is unavailable.
|
|
33
|
+
|
|
34
|
+
## [0.29.0] — 2026-06-16
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- **`suggestMarketConfig` and its taxonomy types (`SeedVendor`,
|
|
39
|
+
`SuggestTaxonomyOptions`, `SuggestTaxonomyResult`) are now exported from the
|
|
40
|
+
package root.** The cold-start claim-taxonomy bootstrap behind `market init
|
|
41
|
+
--auto` (shipped in 0.26.0) is now usable as a library API, not just the CLI —
|
|
42
|
+
a consumer can run it with an injected `fetchPage` (e.g. a browser-rendered
|
|
43
|
+
capture) and compose it with the already-exported `captureMarket` /
|
|
44
|
+
`classifyMarket` / `marketMapToHtml`. No behaviour change to the CLI.
|
|
45
|
+
|
|
8
46
|
## [0.28.3] — 2026-06-16
|
|
9
47
|
|
|
10
48
|
Fix broken links in the published mirror. CONTRIBUTING.md and CLAUDE.md
|
package/README.md
CHANGED
|
@@ -281,9 +281,9 @@ Connectors differ in what the provider's API allows — stated up front so nothi
|
|
|
281
281
|
| Field writes (`set_field`, `clear_field`, `link_record`) | ✅ | ✅ | — |
|
|
282
282
|
| `create_task` | ✅ | ✅ | — |
|
|
283
283
|
| `archive_record` | ✅ | ✅ | — |
|
|
284
|
-
| `merge_records` (`dedupe`) | ✅ | ❌
|
|
284
|
+
| `merge_records` (`dedupe`) | ✅ | ✅ Account/Contact (SOAP); ❌ Opportunity | — |
|
|
285
285
|
|
|
286
|
-
**Salesforce merge** has no
|
|
286
|
+
**Salesforce merge** uses the SOAP `merge()` call (REST has no merge resource), so `dedupe` works on Salesforce **Accounts and Contacts** — the OAuth token doubles as the SOAP session, and groups larger than three records are merged in batches (master + 2 per call). **Opportunities cannot be merged** (Salesforce exposes no opportunity merge in the API or the UI) — for duplicate opportunities, pick a survivor and close/archive the rest. As always, merges are irreversible and go through the same approval gate + drift guard as every other write. (Validate against a sandbox first if you're wiring it into automation — the SOAP path is exercised by unit tests but a live Salesforce org is the real proof.)
|
|
287
287
|
|
|
288
288
|
### HubSpot: create a private app (~2 minutes, needs super-admin)
|
|
289
289
|
|
|
@@ -54,6 +54,37 @@ export function createHubspotConnector(options) {
|
|
|
54
54
|
const text = await response.text();
|
|
55
55
|
return text ? JSON.parse(text) : null;
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Map every deal stage id → its actual closed/won semantics, read from the
|
|
59
|
+
* pipeline definitions. Custom pipelines use opaque stage ids (e.g.
|
|
60
|
+
* "1234567"), so deriving closed/won by substring-matching the stage value
|
|
61
|
+
* against "closedwon"/"closedlost" mis-reads every custom-pipeline deal as
|
|
62
|
+
* open. The pipeline's own stage metadata is authoritative: `isClosed` marks
|
|
63
|
+
* a closed stage and `probability` 1.0 = won, 0.0 = lost. Best-effort — if
|
|
64
|
+
* the read is unavailable (e.g. missing scope) callers fall back to the
|
|
65
|
+
* substring heuristic so a partial-scope token still produces a snapshot.
|
|
66
|
+
*/
|
|
67
|
+
async function fetchDealStageClose() {
|
|
68
|
+
const map = new Map();
|
|
69
|
+
try {
|
|
70
|
+
const data = await request("/crm/v3/pipelines/deals");
|
|
71
|
+
for (const pipeline of data?.results ?? []) {
|
|
72
|
+
for (const stage of pipeline?.stages ?? []) {
|
|
73
|
+
if (!stage?.id)
|
|
74
|
+
continue;
|
|
75
|
+
const meta = stage.metadata ?? {};
|
|
76
|
+
const isClosed = String(meta.isClosed).toLowerCase() === "true";
|
|
77
|
+
const isWon = isClosed && Number(meta.probability) === 1;
|
|
78
|
+
map.set(String(stage.id), { isClosed, isWon });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Pipeline metadata unavailable — leave the map empty; the caller falls
|
|
84
|
+
// back to the substring heuristic.
|
|
85
|
+
}
|
|
86
|
+
return map;
|
|
87
|
+
}
|
|
57
88
|
async function list(path) {
|
|
58
89
|
const results = [];
|
|
59
90
|
let after;
|
|
@@ -137,6 +168,7 @@ export function createHubspotConnector(options) {
|
|
|
137
168
|
};
|
|
138
169
|
});
|
|
139
170
|
const dealProperties = `${mappedFields(mappings, "deals", HUBSPOT_DEFAULT_FIELD_MAPPINGS.deals).join(",")},${PROVENANCE_PROPERTIES}`;
|
|
171
|
+
const stageClose = await fetchDealStageClose();
|
|
140
172
|
const hubspotDeals = await fetchObjects("deals", dealProperties, true);
|
|
141
173
|
const deals = hubspotDeals
|
|
142
174
|
.filter((deal) => deal.id)
|
|
@@ -145,13 +177,15 @@ export function createHubspotConnector(options) {
|
|
|
145
177
|
const companyId = deal.associations?.companies?.results?.[0]?.id;
|
|
146
178
|
const stage = stringOrUndefined(readMapped(props, "deals", "stage", "dealstage"));
|
|
147
179
|
const normalizedStage = (stage ?? "").toLowerCase();
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
180
|
+
// Prefer the pipeline stage's actual closed/won metadata; fall back to
|
|
181
|
+
// the substring heuristic only when the stage isn't in the pipeline map
|
|
182
|
+
// (pipelines unreadable, or a deal on a deleted stage).
|
|
183
|
+
const stageMeta = stage ? stageClose.get(stage) : undefined;
|
|
184
|
+
const isWon = stageMeta ? stageMeta.isWon : normalizedStage.includes("closedwon");
|
|
185
|
+
const isClosed = stageMeta
|
|
186
|
+
? stageMeta.isClosed
|
|
187
|
+
: isWon || normalizedStage.includes("closedlost");
|
|
188
|
+
const forecastCategory = isWon ? "closed_won" : isClosed ? "closed_lost" : "pipeline";
|
|
155
189
|
const lastActivityAt = stringOrUndefined(readMapped(props, "deals", "lastActivityAt", "hs_last_sales_activity_timestamp"));
|
|
156
190
|
return {
|
|
157
191
|
id: String(deal.id),
|
|
@@ -5,6 +5,12 @@ const SOBJECT_TYPES = {
|
|
|
5
5
|
contact: "Contact",
|
|
6
6
|
deal: "Opportunity",
|
|
7
7
|
};
|
|
8
|
+
// SObjects the SOAP merge() call supports. Opportunity is deliberately absent —
|
|
9
|
+
// Salesforce has no opportunity merge at all (API or UI).
|
|
10
|
+
const SOAP_MERGEABLE = {
|
|
11
|
+
account: "Account",
|
|
12
|
+
contact: "Contact",
|
|
13
|
+
};
|
|
8
14
|
const MAPPING_OBJECT_TYPES = {
|
|
9
15
|
account: "accounts",
|
|
10
16
|
contact: "contacts",
|
|
@@ -282,6 +288,97 @@ export function createSalesforceConnector(options) {
|
|
|
282
288
|
detail: `Deleted ${sobjectType}/${operation.objectId}.`,
|
|
283
289
|
};
|
|
284
290
|
}
|
|
291
|
+
function escapeXml(value) {
|
|
292
|
+
return value
|
|
293
|
+
.replace(/&/g, "&")
|
|
294
|
+
.replace(/</g, "<")
|
|
295
|
+
.replace(/>/g, ">")
|
|
296
|
+
.replace(/"/g, """)
|
|
297
|
+
.replace(/'/g, "'");
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* One SOAP merge() call: master + up to 2 records to merge. Salesforce has no
|
|
301
|
+
* REST merge resource, but the Partner SOAP API's merge() works for Account,
|
|
302
|
+
* Contact, Lead, and Case, and accepts the OAuth access token as the SOAP
|
|
303
|
+
* session id. Returns ok/detail (the SOAP fault/error message is operational
|
|
304
|
+
* — MALFORMED_ID, entity-locked — not a data echo, so it's surfaced, capped).
|
|
305
|
+
*/
|
|
306
|
+
async function soapMerge(sobjectType, masterId, loserIds) {
|
|
307
|
+
const connection = await options.getConnection();
|
|
308
|
+
const version = apiVersion.replace(/^v/, ""); // SOAP path uses "59.0", not "v59.0"
|
|
309
|
+
const url = `${connection.instanceUrl.replace(/\/$/, "")}/services/Soap/u/${version}`;
|
|
310
|
+
const toMerge = loserIds.map((id) => `<urn:recordToMergeIds>${escapeXml(id)}</urn:recordToMergeIds>`).join("");
|
|
311
|
+
const envelope = `<?xml version="1.0" encoding="UTF-8"?>` +
|
|
312
|
+
`<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:urn="urn:partner.soap.sforce.com" xmlns:urn1="urn:sobject.partner.soap.sforce.com">` +
|
|
313
|
+
`<soapenv:Header><urn:SessionHeader><urn:sessionId>${escapeXml(connection.accessToken)}</urn:sessionId></urn:SessionHeader></soapenv:Header>` +
|
|
314
|
+
`<soapenv:Body><urn:merge><urn:request>` +
|
|
315
|
+
`<urn:masterRecord><urn1:type>${sobjectType}</urn1:type><urn1:Id>${escapeXml(masterId)}</urn1:Id></urn:masterRecord>` +
|
|
316
|
+
toMerge +
|
|
317
|
+
`</urn:request></urn:merge></soapenv:Body></soapenv:Envelope>`;
|
|
318
|
+
let response;
|
|
319
|
+
try {
|
|
320
|
+
response = await fetchImpl(url, {
|
|
321
|
+
method: "POST",
|
|
322
|
+
headers: { "Content-Type": "text/xml; charset=UTF-8", SOAPAction: '""' },
|
|
323
|
+
body: envelope,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
328
|
+
return { ok: false, detail: `Cannot reach the Salesforce SOAP endpoint${cause}.` };
|
|
329
|
+
}
|
|
330
|
+
const text = await response.text().catch(() => "");
|
|
331
|
+
if (!response.ok) {
|
|
332
|
+
const fault = /<faultstring>([\s\S]*?)<\/faultstring>/.exec(text)?.[1]?.trim();
|
|
333
|
+
return { ok: false, detail: `Salesforce SOAP merge failed (HTTP ${response.status})${fault ? `: ${fault.slice(0, 200)}` : ""}.` };
|
|
334
|
+
}
|
|
335
|
+
if (!/<success>\s*true\s*<\/success>/i.test(text)) {
|
|
336
|
+
const message = /<message>([\s\S]*?)<\/message>/.exec(text)?.[1]?.trim();
|
|
337
|
+
return { ok: false, detail: `Salesforce rejected the merge${message ? `: ${message.slice(0, 200)}` : ""}.` };
|
|
338
|
+
}
|
|
339
|
+
return { ok: true };
|
|
340
|
+
}
|
|
341
|
+
async function mergeRecords(operation) {
|
|
342
|
+
const sobjectType = SOAP_MERGEABLE[operation.objectType];
|
|
343
|
+
if (!sobjectType) {
|
|
344
|
+
return {
|
|
345
|
+
operationId: operation.id,
|
|
346
|
+
status: "skipped",
|
|
347
|
+
detail: operation.objectType === "deal"
|
|
348
|
+
? "Salesforce opportunities cannot be merged (no merge exists in the API or the UI). Pick a survivor and close/archive the duplicate opportunities instead."
|
|
349
|
+
: `Salesforce merge supports Account and Contact only (not ${operation.objectType}).`,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
const master = operation.objectId;
|
|
353
|
+
const group = Array.isArray(operation.beforeValue) ? operation.beforeValue.map(String) : [];
|
|
354
|
+
const losers = group.filter((id) => id !== master);
|
|
355
|
+
if (losers.length === 0) {
|
|
356
|
+
return { operationId: operation.id, status: "skipped", detail: "Nothing to merge — no records besides the survivor." };
|
|
357
|
+
}
|
|
358
|
+
// SOAP merge() takes the master + at most 2 records per call; batch larger
|
|
359
|
+
// duplicate groups. A mid-batch failure is reported with what was merged so
|
|
360
|
+
// far (merges are irreversible).
|
|
361
|
+
const merged = [];
|
|
362
|
+
for (let i = 0; i < losers.length; i += 2) {
|
|
363
|
+
const batch = losers.slice(i, i + 2);
|
|
364
|
+
const result = await soapMerge(sobjectType, master, batch);
|
|
365
|
+
if (!result.ok) {
|
|
366
|
+
return {
|
|
367
|
+
operationId: operation.id,
|
|
368
|
+
status: "failed",
|
|
369
|
+
detail: `${result.detail} Merged ${merged.length} of ${losers.length} into ${master} before failing — IRREVERSIBLE; the remaining duplicates were not merged.`,
|
|
370
|
+
providerData: { survivorId: master, mergedRecordIds: merged },
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
merged.push(...batch);
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
operationId: operation.id,
|
|
377
|
+
status: "applied",
|
|
378
|
+
detail: `Merged ${merged.length} ${sobjectType} record(s) into ${master} (irreversible).`,
|
|
379
|
+
providerData: { survivorId: master, mergedRecordIds: merged },
|
|
380
|
+
};
|
|
381
|
+
}
|
|
285
382
|
async function applyOperation(operation) {
|
|
286
383
|
try {
|
|
287
384
|
switch (operation.operation) {
|
|
@@ -341,14 +438,7 @@ export function createSalesforceConnector(options) {
|
|
|
341
438
|
case "create_task":
|
|
342
439
|
return await createTask(operation);
|
|
343
440
|
case "merge_records":
|
|
344
|
-
|
|
345
|
-
// Contact, Account, Case; max 3 records) — there is no REST merge
|
|
346
|
-
// resource. Surface that honestly instead of half-merging.
|
|
347
|
-
return {
|
|
348
|
-
operationId: operation.id,
|
|
349
|
-
status: "skipped",
|
|
350
|
-
detail: "Salesforce merge requires the SOAP API or Apex (Lead/Contact/Account/Case only) — this REST connector cannot merge. Merge in the Salesforce UI, or archive the duplicates explicitly.",
|
|
351
|
-
};
|
|
441
|
+
return await mergeRecords(operation);
|
|
352
442
|
case "archive_record":
|
|
353
443
|
return await archiveRecord(operation);
|
|
354
444
|
default:
|
package/dist/index.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ export { sampleSnapshot } from "./sampleData.ts";
|
|
|
27
27
|
export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, forcedToolCall, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, type CallScorecard, type LlmCredential, type LlmExtractedInsight, type LlmProvider, type Rubric, type ScoredDimension, } from "./llm.ts";
|
|
28
28
|
export { resolveRecord, type ResolveCandidate, type ResolveMatch, type ResolveResult } from "./resolve.ts";
|
|
29
29
|
export { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, extractReadableText, loadCaptureTexts, loadMarketConfig, marketHome, normalizeForMatch, observationId, parseMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, type CaptureEntry, type CaptureOptions, type ClaimFront, type ClaimIntensity, type FrontDrift, type FrontState, type MarketAxis, type MarketClaim, type MarketConfig, type MarketObservation, type MarketVendor, type ObservationConfidence, type ObservationSet, type ObservationStore, type ScaleSignal, type SpanVerificationFailure, } from "./market.ts";
|
|
30
|
+
export { suggestMarketConfig, type SeedVendor, type SuggestTaxonomyOptions, type SuggestTaxonomyResult, } from "./marketTaxonomy.ts";
|
|
30
31
|
export { assessAxes, axesReportToText, axisPosition, messageBreadth, pcaTop2, pearson, type AxesReport, type AxisAssessment, type AxisPairing, type PrincipalComponent, } from "./marketAxes.ts";
|
|
31
32
|
export { buildWorksheet, classifyMarket, type ClassifyMarketOptions, type ClassifyMarketResult, type MarketWorksheet, } from "./marketClassify.ts";
|
|
32
33
|
export { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, type CallDocument, type ClaimMentionStats, type DirectiveStat, type DirectiveType, type MarketDirective, type OverlayOptions, type OverlayStats, type VendorMentionStats, } from "./marketOverlay.ts";
|
package/dist/index.js
CHANGED
|
@@ -27,6 +27,7 @@ export { sampleSnapshot } from "./sampleData.js";
|
|
|
27
27
|
export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, forcedToolCall, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
|
|
28
28
|
export { resolveRecord } from "./resolve.js";
|
|
29
29
|
export { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, extractReadableText, loadCaptureTexts, loadMarketConfig, marketHome, normalizeForMatch, observationId, parseMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, } from "./market.js";
|
|
30
|
+
export { suggestMarketConfig, } from "./marketTaxonomy.js";
|
|
30
31
|
export { assessAxes, axesReportToText, axisPosition, messageBreadth, pcaTop2, pearson, } from "./marketAxes.js";
|
|
31
32
|
export { buildWorksheet, classifyMarket, } from "./marketClassify.js";
|
|
32
33
|
export { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, } from "./marketOverlay.js";
|
package/docs/roadmap-to-1.0.md
CHANGED
|
@@ -148,11 +148,6 @@ against the 0.28 surface: still accurate, still open.
|
|
|
148
148
|
Found by exercising the published package as a fresh RevOps user with a real
|
|
149
149
|
CRM (2026-06):
|
|
150
150
|
|
|
151
|
-
- **Pipeline-aware closed-deal detection (HubSpot).** `isClosed`/`isWon` are
|
|
152
|
-
derived by substring-matching the raw `dealstage` value against
|
|
153
|
-
`closedwon`/`closedlost`. Custom pipelines use opaque stage ids, so closed
|
|
154
|
-
deals read as open and flood stale-deal/past-close findings. Fix: resolve
|
|
155
|
-
stage metadata from `/crm/v3/pipelines` once per snapshot.
|
|
156
151
|
- **Rate-limit resilience.** No 429/retry/backoff handling anywhere; a
|
|
157
152
|
mid-size portal snapshot is ~1,000+ sequential page requests and one
|
|
158
153
|
transient failure aborts the run. Fix: honor `Retry-After`, retry
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
|
|
@@ -88,6 +88,36 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
88
88
|
return text ? JSON.parse(text) : null;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Map every deal stage id → its actual closed/won semantics, read from the
|
|
93
|
+
* pipeline definitions. Custom pipelines use opaque stage ids (e.g.
|
|
94
|
+
* "1234567"), so deriving closed/won by substring-matching the stage value
|
|
95
|
+
* against "closedwon"/"closedlost" mis-reads every custom-pipeline deal as
|
|
96
|
+
* open. The pipeline's own stage metadata is authoritative: `isClosed` marks
|
|
97
|
+
* a closed stage and `probability` 1.0 = won, 0.0 = lost. Best-effort — if
|
|
98
|
+
* the read is unavailable (e.g. missing scope) callers fall back to the
|
|
99
|
+
* substring heuristic so a partial-scope token still produces a snapshot.
|
|
100
|
+
*/
|
|
101
|
+
async function fetchDealStageClose(): Promise<Map<string, { isClosed: boolean; isWon: boolean }>> {
|
|
102
|
+
const map = new Map<string, { isClosed: boolean; isWon: boolean }>();
|
|
103
|
+
try {
|
|
104
|
+
const data = await request("/crm/v3/pipelines/deals");
|
|
105
|
+
for (const pipeline of data?.results ?? []) {
|
|
106
|
+
for (const stage of pipeline?.stages ?? []) {
|
|
107
|
+
if (!stage?.id) continue;
|
|
108
|
+
const meta = stage.metadata ?? {};
|
|
109
|
+
const isClosed = String(meta.isClosed).toLowerCase() === "true";
|
|
110
|
+
const isWon = isClosed && Number(meta.probability) === 1;
|
|
111
|
+
map.set(String(stage.id), { isClosed, isWon });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// Pipeline metadata unavailable — leave the map empty; the caller falls
|
|
116
|
+
// back to the substring heuristic.
|
|
117
|
+
}
|
|
118
|
+
return map;
|
|
119
|
+
}
|
|
120
|
+
|
|
91
121
|
async function list(path: string): Promise<any[]> {
|
|
92
122
|
const results: any[] = [];
|
|
93
123
|
let after: string | undefined;
|
|
@@ -204,6 +234,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
204
234
|
"deals",
|
|
205
235
|
HUBSPOT_DEFAULT_FIELD_MAPPINGS.deals,
|
|
206
236
|
).join(",")},${PROVENANCE_PROPERTIES}`;
|
|
237
|
+
const stageClose = await fetchDealStageClose();
|
|
207
238
|
const hubspotDeals = await fetchObjects("deals", dealProperties, true);
|
|
208
239
|
const deals: CanonicalDeal[] = hubspotDeals
|
|
209
240
|
.filter((deal) => deal.id)
|
|
@@ -212,13 +243,15 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
212
243
|
const companyId = deal.associations?.companies?.results?.[0]?.id;
|
|
213
244
|
const stage = stringOrUndefined(readMapped(props, "deals", "stage", "dealstage"));
|
|
214
245
|
const normalizedStage = (stage ?? "").toLowerCase();
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
246
|
+
// Prefer the pipeline stage's actual closed/won metadata; fall back to
|
|
247
|
+
// the substring heuristic only when the stage isn't in the pipeline map
|
|
248
|
+
// (pipelines unreadable, or a deal on a deleted stage).
|
|
249
|
+
const stageMeta = stage ? stageClose.get(stage) : undefined;
|
|
250
|
+
const isWon = stageMeta ? stageMeta.isWon : normalizedStage.includes("closedwon");
|
|
251
|
+
const isClosed = stageMeta
|
|
252
|
+
? stageMeta.isClosed
|
|
253
|
+
: isWon || normalizedStage.includes("closedlost");
|
|
254
|
+
const forecastCategory = isWon ? "closed_won" : isClosed ? "closed_lost" : "pipeline";
|
|
222
255
|
const lastActivityAt = stringOrUndefined(
|
|
223
256
|
readMapped(props, "deals", "lastActivityAt", "hs_last_sales_activity_timestamp"),
|
|
224
257
|
);
|
|
@@ -42,6 +42,13 @@ const SOBJECT_TYPES: Partial<Record<GtmObjectType, string>> = {
|
|
|
42
42
|
deal: "Opportunity",
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
+
// SObjects the SOAP merge() call supports. Opportunity is deliberately absent —
|
|
46
|
+
// Salesforce has no opportunity merge at all (API or UI).
|
|
47
|
+
const SOAP_MERGEABLE: Partial<Record<GtmObjectType, string>> = {
|
|
48
|
+
account: "Account",
|
|
49
|
+
contact: "Contact",
|
|
50
|
+
};
|
|
51
|
+
|
|
45
52
|
const MAPPING_OBJECT_TYPES: Partial<Record<GtmObjectType, Exclude<CrmObjectType, "owners">>> = {
|
|
46
53
|
account: "accounts",
|
|
47
54
|
contact: "contacts",
|
|
@@ -378,6 +385,105 @@ export function createSalesforceConnector(
|
|
|
378
385
|
};
|
|
379
386
|
}
|
|
380
387
|
|
|
388
|
+
function escapeXml(value: string): string {
|
|
389
|
+
return value
|
|
390
|
+
.replace(/&/g, "&")
|
|
391
|
+
.replace(/</g, "<")
|
|
392
|
+
.replace(/>/g, ">")
|
|
393
|
+
.replace(/"/g, """)
|
|
394
|
+
.replace(/'/g, "'");
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* One SOAP merge() call: master + up to 2 records to merge. Salesforce has no
|
|
399
|
+
* REST merge resource, but the Partner SOAP API's merge() works for Account,
|
|
400
|
+
* Contact, Lead, and Case, and accepts the OAuth access token as the SOAP
|
|
401
|
+
* session id. Returns ok/detail (the SOAP fault/error message is operational
|
|
402
|
+
* — MALFORMED_ID, entity-locked — not a data echo, so it's surfaced, capped).
|
|
403
|
+
*/
|
|
404
|
+
async function soapMerge(
|
|
405
|
+
sobjectType: string,
|
|
406
|
+
masterId: string,
|
|
407
|
+
loserIds: string[],
|
|
408
|
+
): Promise<{ ok: boolean; detail?: string }> {
|
|
409
|
+
const connection = await options.getConnection();
|
|
410
|
+
const version = apiVersion.replace(/^v/, ""); // SOAP path uses "59.0", not "v59.0"
|
|
411
|
+
const url = `${connection.instanceUrl.replace(/\/$/, "")}/services/Soap/u/${version}`;
|
|
412
|
+
const toMerge = loserIds.map((id) => `<urn:recordToMergeIds>${escapeXml(id)}</urn:recordToMergeIds>`).join("");
|
|
413
|
+
const envelope =
|
|
414
|
+
`<?xml version="1.0" encoding="UTF-8"?>` +
|
|
415
|
+
`<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:urn="urn:partner.soap.sforce.com" xmlns:urn1="urn:sobject.partner.soap.sforce.com">` +
|
|
416
|
+
`<soapenv:Header><urn:SessionHeader><urn:sessionId>${escapeXml(connection.accessToken)}</urn:sessionId></urn:SessionHeader></soapenv:Header>` +
|
|
417
|
+
`<soapenv:Body><urn:merge><urn:request>` +
|
|
418
|
+
`<urn:masterRecord><urn1:type>${sobjectType}</urn1:type><urn1:Id>${escapeXml(masterId)}</urn1:Id></urn:masterRecord>` +
|
|
419
|
+
toMerge +
|
|
420
|
+
`</urn:request></urn:merge></soapenv:Body></soapenv:Envelope>`;
|
|
421
|
+
let response: Response;
|
|
422
|
+
try {
|
|
423
|
+
response = await fetchImpl(url, {
|
|
424
|
+
method: "POST",
|
|
425
|
+
headers: { "Content-Type": "text/xml; charset=UTF-8", SOAPAction: '""' },
|
|
426
|
+
body: envelope,
|
|
427
|
+
});
|
|
428
|
+
} catch (error) {
|
|
429
|
+
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
430
|
+
return { ok: false, detail: `Cannot reach the Salesforce SOAP endpoint${cause}.` };
|
|
431
|
+
}
|
|
432
|
+
const text = await response.text().catch(() => "");
|
|
433
|
+
if (!response.ok) {
|
|
434
|
+
const fault = /<faultstring>([\s\S]*?)<\/faultstring>/.exec(text)?.[1]?.trim();
|
|
435
|
+
return { ok: false, detail: `Salesforce SOAP merge failed (HTTP ${response.status})${fault ? `: ${fault.slice(0, 200)}` : ""}.` };
|
|
436
|
+
}
|
|
437
|
+
if (!/<success>\s*true\s*<\/success>/i.test(text)) {
|
|
438
|
+
const message = /<message>([\s\S]*?)<\/message>/.exec(text)?.[1]?.trim();
|
|
439
|
+
return { ok: false, detail: `Salesforce rejected the merge${message ? `: ${message.slice(0, 200)}` : ""}.` };
|
|
440
|
+
}
|
|
441
|
+
return { ok: true };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function mergeRecords(operation: PatchOperation): Promise<PatchOperationResult> {
|
|
445
|
+
const sobjectType = SOAP_MERGEABLE[operation.objectType];
|
|
446
|
+
if (!sobjectType) {
|
|
447
|
+
return {
|
|
448
|
+
operationId: operation.id,
|
|
449
|
+
status: "skipped",
|
|
450
|
+
detail:
|
|
451
|
+
operation.objectType === "deal"
|
|
452
|
+
? "Salesforce opportunities cannot be merged (no merge exists in the API or the UI). Pick a survivor and close/archive the duplicate opportunities instead."
|
|
453
|
+
: `Salesforce merge supports Account and Contact only (not ${operation.objectType}).`,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
const master = operation.objectId;
|
|
457
|
+
const group = Array.isArray(operation.beforeValue) ? (operation.beforeValue as unknown[]).map(String) : [];
|
|
458
|
+
const losers = group.filter((id) => id !== master);
|
|
459
|
+
if (losers.length === 0) {
|
|
460
|
+
return { operationId: operation.id, status: "skipped", detail: "Nothing to merge — no records besides the survivor." };
|
|
461
|
+
}
|
|
462
|
+
// SOAP merge() takes the master + at most 2 records per call; batch larger
|
|
463
|
+
// duplicate groups. A mid-batch failure is reported with what was merged so
|
|
464
|
+
// far (merges are irreversible).
|
|
465
|
+
const merged: string[] = [];
|
|
466
|
+
for (let i = 0; i < losers.length; i += 2) {
|
|
467
|
+
const batch = losers.slice(i, i + 2);
|
|
468
|
+
const result = await soapMerge(sobjectType, master, batch);
|
|
469
|
+
if (!result.ok) {
|
|
470
|
+
return {
|
|
471
|
+
operationId: operation.id,
|
|
472
|
+
status: "failed",
|
|
473
|
+
detail: `${result.detail} Merged ${merged.length} of ${losers.length} into ${master} before failing — IRREVERSIBLE; the remaining duplicates were not merged.`,
|
|
474
|
+
providerData: { survivorId: master, mergedRecordIds: merged },
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
merged.push(...batch);
|
|
478
|
+
}
|
|
479
|
+
return {
|
|
480
|
+
operationId: operation.id,
|
|
481
|
+
status: "applied",
|
|
482
|
+
detail: `Merged ${merged.length} ${sobjectType} record(s) into ${master} (irreversible).`,
|
|
483
|
+
providerData: { survivorId: master, mergedRecordIds: merged },
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
381
487
|
async function applyOperation(operation: PatchOperation): Promise<PatchOperationResult> {
|
|
382
488
|
try {
|
|
383
489
|
switch (operation.operation) {
|
|
@@ -438,15 +544,7 @@ export function createSalesforceConnector(
|
|
|
438
544
|
case "create_task":
|
|
439
545
|
return await createTask(operation);
|
|
440
546
|
case "merge_records":
|
|
441
|
-
|
|
442
|
-
// Contact, Account, Case; max 3 records) — there is no REST merge
|
|
443
|
-
// resource. Surface that honestly instead of half-merging.
|
|
444
|
-
return {
|
|
445
|
-
operationId: operation.id,
|
|
446
|
-
status: "skipped",
|
|
447
|
-
detail:
|
|
448
|
-
"Salesforce merge requires the SOAP API or Apex (Lead/Contact/Account/Case only) — this REST connector cannot merge. Merge in the Salesforce UI, or archive the duplicates explicitly.",
|
|
449
|
-
};
|
|
547
|
+
return await mergeRecords(operation);
|
|
450
548
|
case "archive_record":
|
|
451
549
|
return await archiveRecord(operation);
|
|
452
550
|
default:
|
package/src/index.ts
CHANGED
|
@@ -226,6 +226,12 @@ export {
|
|
|
226
226
|
type ScaleSignal,
|
|
227
227
|
type SpanVerificationFailure,
|
|
228
228
|
} from "./market.ts";
|
|
229
|
+
export {
|
|
230
|
+
suggestMarketConfig,
|
|
231
|
+
type SeedVendor,
|
|
232
|
+
type SuggestTaxonomyOptions,
|
|
233
|
+
type SuggestTaxonomyResult,
|
|
234
|
+
} from "./marketTaxonomy.ts";
|
|
229
235
|
export {
|
|
230
236
|
assessAxes,
|
|
231
237
|
axesReportToText,
|