fullstackgtm 0.29.0 → 0.31.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/market.d.ts +8 -0
- package/dist/marketReport.js +16 -2
- 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/market.ts +8 -0
- package/src/marketReport.ts +16 -2
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.31.0] — 2026-06-18
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Vendor logos in the market-map report.** `MarketVendor` gains an optional
|
|
13
|
+
`logo` field; `marketMapToHtml` renders it beside the name in the legend and
|
|
14
|
+
above the matrix column headers, degrading to the numbered swatch / plain name
|
|
15
|
+
when absent. Only `data:image/…` URIs are honored — keeping the report
|
|
16
|
+
self-contained (no external requests, survives being saved or emailed) and safe
|
|
17
|
+
under a strict `img-src data:` CSP. The hosted service extracts a canonical logo
|
|
18
|
+
from each vendor's homepage; CLI users can set `logo` by hand.
|
|
19
|
+
|
|
20
|
+
## [0.30.0] — 2026-06-17
|
|
21
|
+
|
|
22
|
+
Connector fixes — the two concrete defects a real HubSpot/Salesforce shop hits.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **Salesforce `dedupe`/`merge_records` now works** (Accounts and Contacts) via
|
|
27
|
+
the SOAP `merge()` call — REST has no merge resource, but the OAuth token
|
|
28
|
+
doubles as the SOAP session id, so no extra auth. Groups larger than three are
|
|
29
|
+
merged in batches (master + 2 per call); failures are reported honestly with
|
|
30
|
+
what merged before the stop. **Opportunities remain unmergeable** (Salesforce
|
|
31
|
+
exposes no opportunity merge anywhere) and are refused with that explanation.
|
|
32
|
+
Merges still flow through the approval gate and the irreversible-op drift guard
|
|
33
|
+
(the survivor/loser existence check runs before the SOAP call). Exercised by
|
|
34
|
+
unit tests; validate against a sandbox before wiring into automation.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- **HubSpot closed/won detection is now pipeline-aware.** `isClosed`/`isWon`
|
|
39
|
+
were derived by substring-matching the stage against `closedwon`/`closedlost`,
|
|
40
|
+
so custom pipelines (opaque stage ids) read every deal as open and flooded
|
|
41
|
+
stale-deal / past-close findings with false positives. The connector now reads
|
|
42
|
+
the pipeline stage metadata (`/crm/v3/pipelines/deals`: `isClosed` +
|
|
43
|
+
`probability`) once per snapshot and derives closed/won from that, falling back
|
|
44
|
+
to the substring heuristic only when the pipelines read is unavailable.
|
|
45
|
+
|
|
8
46
|
## [0.29.0] — 2026-06-16
|
|
9
47
|
|
|
10
48
|
### Added
|
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/market.d.ts
CHANGED
|
@@ -81,6 +81,14 @@ export type MarketVendor = {
|
|
|
81
81
|
* obvious from the vendor's own pricing page (which the map captures).
|
|
82
82
|
*/
|
|
83
83
|
acvBand?: string;
|
|
84
|
+
/**
|
|
85
|
+
* Optional brand logo for the report (legend + matrix headers). A `data:` URI
|
|
86
|
+
* keeps the rendered report self-contained — no external requests, survives
|
|
87
|
+
* being saved or emailed. The hosted service extracts it from the vendor's
|
|
88
|
+
* homepage; CLI users can set it by hand. Renderers degrade gracefully to the
|
|
89
|
+
* numbered swatch when absent.
|
|
90
|
+
*/
|
|
91
|
+
logo?: string;
|
|
84
92
|
notes?: string;
|
|
85
93
|
};
|
|
86
94
|
export type MarketAxis = {
|
package/dist/marketReport.js
CHANGED
|
@@ -28,6 +28,17 @@ function escapeHtml(value) {
|
|
|
28
28
|
.replace(/>/g, ">")
|
|
29
29
|
.replace(/"/g, """);
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* A small brand logo for legend rows / matrix headers. Accepts only `data:image/`
|
|
33
|
+
* URIs — self-contained (no external request, survives save/email) and safe under
|
|
34
|
+
* the report's `img-src data:` CSP (an SVG loaded via <img> can't execute script).
|
|
35
|
+
* Returns "" when absent so callers fall back to the numbered swatch / plain name.
|
|
36
|
+
*/
|
|
37
|
+
function logoImg(logo, cls) {
|
|
38
|
+
if (typeof logo !== "string" || !logo.startsWith("data:image/"))
|
|
39
|
+
return "";
|
|
40
|
+
return `<img class="${cls}" src="${escapeHtml(logo)}" alt="" loading="lazy">`;
|
|
41
|
+
}
|
|
31
42
|
/**
|
|
32
43
|
* Serialize JSON for embedding inside an inline <script> block. JSON.stringify
|
|
33
44
|
* does not escape `<`, `>`, `&`, or the U+2028/U+2029 line separators, so a
|
|
@@ -291,6 +302,7 @@ function axisSectionsHtml(config, set) {
|
|
|
291
302
|
// The number inside each bubble resolves dense clusters that name labels
|
|
292
303
|
// never could; color is Okabe–Ito (colorblind-safe) keyed in the legend.
|
|
293
304
|
const points = pointsFor(px, py);
|
|
305
|
+
const logoByVendor = new Map(config.vendors.map((vendor) => [vendor.id, vendor.logo]));
|
|
294
306
|
const legendOrder = [...points].sort((a, b) => b.size - a.size || a.name.localeCompare(b.name));
|
|
295
307
|
const numberByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, index + 1]));
|
|
296
308
|
const colorByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, VENDOR_COLORS[index % VENDOR_COLORS.length]]));
|
|
@@ -305,7 +317,7 @@ function axisSectionsHtml(config, set) {
|
|
|
305
317
|
? `${(share * 100).toFixed(1)}%`
|
|
306
318
|
: "—"
|
|
307
319
|
: `${loudCounts.get(point.vendorId) ?? 0} loud`;
|
|
308
|
-
return `<tr data-v="${e(point.vendorId)}"${isAnchor ? ' class="anchor-row"' : ""}><td><span class="swatch" style="background:${color};color:${numeralColor(color)}">${number}</span></td><td>${e(point.name)}${isAnchor ? " · anchor" : ""}</td><td class="num">${measure}</td></tr>`;
|
|
320
|
+
return `<tr data-v="${e(point.vendorId)}"${isAnchor ? ' class="anchor-row"' : ""}><td><span class="swatch" style="background:${color};color:${numeralColor(color)}">${number}</span></td><td>${logoImg(logoByVendor.get(point.vendorId), "v-logo")}${e(point.name)}${isAnchor ? " · anchor" : ""}</td><td class="num">${measure}</td></tr>`;
|
|
309
321
|
})
|
|
310
322
|
.join("");
|
|
311
323
|
const legendMeasureHead = useScale ? "est. share" : "loud";
|
|
@@ -426,7 +438,7 @@ export function marketMapToHtml(config, set) {
|
|
|
426
438
|
`<td class="front"><span class="chip chip-${state}">${state.toUpperCase()}</span></td></tr>`);
|
|
427
439
|
};
|
|
428
440
|
const vendorHeads = config.vendors
|
|
429
|
-
.map((vendor) => `<th class="vh${vendor.id === anchor ? " anchor-col" : ""}"
|
|
441
|
+
.map((vendor) => `<th class="vh${vendor.id === anchor ? " anchor-col" : ""}">${logoImg(vendor.logo, "vh-logo")}<span>${e(vendor.name)}</span></th>`)
|
|
430
442
|
.join("");
|
|
431
443
|
// Claims grouped by front state, each group a collapsed <details> whose
|
|
432
444
|
// summary carries the stats a skimmer needs; the full matrix is one click
|
|
@@ -562,6 +574,8 @@ tr.front-open th .claim-cap { color:var(--accent); font-weight:600; }
|
|
|
562
574
|
figure.map { margin-top:16px; border:1px solid var(--line); position:relative; }
|
|
563
575
|
g.bubble { cursor:pointer; }
|
|
564
576
|
g.bubble.dim { opacity:0.25; transition:opacity .12s; }
|
|
577
|
+
img.v-logo { width:15px; height:15px; border-radius:3px; object-fit:contain; vertical-align:-3px; margin-right:6px; background:#fff; }
|
|
578
|
+
th.vh img.vh-logo { display:block; width:18px; height:18px; border-radius:3px; object-fit:contain; margin:0 auto 4px; background:#fff; }
|
|
565
579
|
table.legend tbody tr { cursor:default; }
|
|
566
580
|
table.legend tbody tr.hl td { background:var(--faint); }
|
|
567
581
|
.map-tip { position:absolute; z-index:5; background:#1c1c1c; color:#fff; font-size:11.5px; line-height:1.45;
|
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.31.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/market.ts
CHANGED
|
@@ -94,6 +94,14 @@ export type MarketVendor = {
|
|
|
94
94
|
* obvious from the vendor's own pricing page (which the map captures).
|
|
95
95
|
*/
|
|
96
96
|
acvBand?: string;
|
|
97
|
+
/**
|
|
98
|
+
* Optional brand logo for the report (legend + matrix headers). A `data:` URI
|
|
99
|
+
* keeps the rendered report self-contained — no external requests, survives
|
|
100
|
+
* being saved or emailed. The hosted service extracts it from the vendor's
|
|
101
|
+
* homepage; CLI users can set it by hand. Renderers degrade gracefully to the
|
|
102
|
+
* numbered swatch when absent.
|
|
103
|
+
*/
|
|
104
|
+
logo?: string;
|
|
97
105
|
notes?: string;
|
|
98
106
|
};
|
|
99
107
|
|
package/src/marketReport.ts
CHANGED
|
@@ -40,6 +40,17 @@ function escapeHtml(value: string): string {
|
|
|
40
40
|
.replace(/"/g, """);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* A small brand logo for legend rows / matrix headers. Accepts only `data:image/`
|
|
45
|
+
* URIs — self-contained (no external request, survives save/email) and safe under
|
|
46
|
+
* the report's `img-src data:` CSP (an SVG loaded via <img> can't execute script).
|
|
47
|
+
* Returns "" when absent so callers fall back to the numbered swatch / plain name.
|
|
48
|
+
*/
|
|
49
|
+
function logoImg(logo: string | undefined, cls: string): string {
|
|
50
|
+
if (typeof logo !== "string" || !logo.startsWith("data:image/")) return "";
|
|
51
|
+
return `<img class="${cls}" src="${escapeHtml(logo)}" alt="" loading="lazy">`;
|
|
52
|
+
}
|
|
53
|
+
|
|
43
54
|
/**
|
|
44
55
|
* Serialize JSON for embedding inside an inline <script> block. JSON.stringify
|
|
45
56
|
* does not escape `<`, `>`, `&`, or the U+2028/U+2029 line separators, so a
|
|
@@ -342,6 +353,7 @@ function axisSectionsHtml(
|
|
|
342
353
|
// The number inside each bubble resolves dense clusters that name labels
|
|
343
354
|
// never could; color is Okabe–Ito (colorblind-safe) keyed in the legend.
|
|
344
355
|
const points = pointsFor(px, py);
|
|
356
|
+
const logoByVendor = new Map(config.vendors.map((vendor) => [vendor.id, vendor.logo]));
|
|
345
357
|
const legendOrder = [...points].sort((a, b) => b.size - a.size || a.name.localeCompare(b.name));
|
|
346
358
|
const numberByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, index + 1]));
|
|
347
359
|
const colorByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, VENDOR_COLORS[index % VENDOR_COLORS.length]]));
|
|
@@ -356,7 +368,7 @@ function axisSectionsHtml(
|
|
|
356
368
|
? `${(share * 100).toFixed(1)}%`
|
|
357
369
|
: "—"
|
|
358
370
|
: `${loudCounts.get(point.vendorId) ?? 0} loud`;
|
|
359
|
-
return `<tr data-v="${e(point.vendorId)}"${isAnchor ? ' class="anchor-row"' : ""}><td><span class="swatch" style="background:${color};color:${numeralColor(color)}">${number}</span></td><td>${e(point.name)}${isAnchor ? " · anchor" : ""}</td><td class="num">${measure}</td></tr>`;
|
|
371
|
+
return `<tr data-v="${e(point.vendorId)}"${isAnchor ? ' class="anchor-row"' : ""}><td><span class="swatch" style="background:${color};color:${numeralColor(color)}">${number}</span></td><td>${logoImg(logoByVendor.get(point.vendorId), "v-logo")}${e(point.name)}${isAnchor ? " · anchor" : ""}</td><td class="num">${measure}</td></tr>`;
|
|
360
372
|
})
|
|
361
373
|
.join("");
|
|
362
374
|
const legendMeasureHead = useScale ? "est. share" : "loud";
|
|
@@ -488,7 +500,7 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
|
|
|
488
500
|
const vendorHeads = config.vendors
|
|
489
501
|
.map(
|
|
490
502
|
(vendor) =>
|
|
491
|
-
`<th class="vh${vendor.id === anchor ? " anchor-col" : ""}"
|
|
503
|
+
`<th class="vh${vendor.id === anchor ? " anchor-col" : ""}">${logoImg(vendor.logo, "vh-logo")}<span>${e(vendor.name)}</span></th>`,
|
|
492
504
|
)
|
|
493
505
|
.join("");
|
|
494
506
|
|
|
@@ -632,6 +644,8 @@ tr.front-open th .claim-cap { color:var(--accent); font-weight:600; }
|
|
|
632
644
|
figure.map { margin-top:16px; border:1px solid var(--line); position:relative; }
|
|
633
645
|
g.bubble { cursor:pointer; }
|
|
634
646
|
g.bubble.dim { opacity:0.25; transition:opacity .12s; }
|
|
647
|
+
img.v-logo { width:15px; height:15px; border-radius:3px; object-fit:contain; vertical-align:-3px; margin-right:6px; background:#fff; }
|
|
648
|
+
th.vh img.vh-logo { display:block; width:18px; height:18px; border-radius:3px; object-fit:contain; margin:0 auto 4px; background:#fff; }
|
|
635
649
|
table.legend tbody tr { cursor:default; }
|
|
636
650
|
table.legend tbody tr.hl td { background:var(--faint); }
|
|
637
651
|
.map-tip { position:absolute; z-index:5; background:#1c1c1c; color:#fff; font-size:11.5px; line-height:1.45;
|