fullstackgtm 0.46.0 → 0.48.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 +44 -6
- package/INSTALL_FOR_AGENTS.md +3 -3
- package/README.md +16 -8
- package/dist/audit.js +7 -0
- package/dist/backfill.js +2 -16
- package/dist/cli/fix.d.ts +3 -0
- package/dist/cli/fix.js +70 -1
- package/dist/cli/help.d.ts +6 -0
- package/dist/cli/help.js +76 -2
- package/dist/cli/suggest.d.ts +2 -1
- package/dist/cli/suggest.js +49 -3
- package/dist/cli.js +40 -15
- package/dist/connector.js +96 -25
- package/dist/connectors/hubspot.d.ts +2 -1
- package/dist/connectors/hubspot.js +170 -4
- package/dist/connectors/salesforce.d.ts +2 -1
- package/dist/connectors/salesforce.js +135 -0
- package/dist/connectors/signalSources.js +2 -11
- package/dist/format.js +31 -5
- package/dist/freeEmailDomains.d.ts +1 -0
- package/dist/freeEmailDomains.js +14 -0
- package/dist/hierarchy.d.ts +29 -0
- package/dist/hierarchy.js +164 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +5 -1
- package/dist/mcp.js +19 -15
- package/dist/relationships.d.ts +39 -0
- package/dist/relationships.js +101 -0
- package/dist/route.d.ts +46 -0
- package/dist/route.js +228 -0
- package/dist/rules.d.ts +1 -0
- package/dist/rules.js +172 -12
- package/dist/types.d.ts +30 -0
- package/docs/api.md +22 -4
- package/docs/architecture.md +5 -2
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +2 -2
- package/src/audit.ts +7 -0
- package/src/backfill.ts +2 -17
- package/src/cli/fix.ts +68 -1
- package/src/cli/help.ts +83 -2
- package/src/cli/suggest.ts +58 -4
- package/src/cli.ts +38 -13
- package/src/connector.ts +94 -20
- package/src/connectors/hubspot.ts +164 -5
- package/src/connectors/salesforce.ts +136 -1
- package/src/connectors/signalSources.ts +2 -12
- package/src/format.ts +33 -5
- package/src/freeEmailDomains.ts +14 -0
- package/src/hierarchy.ts +185 -0
- package/src/index.ts +25 -0
- package/src/mcp.ts +22 -16
- package/src/relationships.ts +115 -0
- package/src/route.ts +278 -0
- package/src/rules.ts +192 -12
- package/src/types.ts +32 -0
|
@@ -24,6 +24,8 @@ import { SNAPSHOT_PULL_STAGES, type ProgressEmitter } from "../progress.ts";
|
|
|
24
24
|
|
|
25
25
|
const DEFAULT_API_BASE_URL = "https://api.hubapi.com";
|
|
26
26
|
|
|
27
|
+
export type HubspotWritableConnector = GtmConnector & Required<Pick<GtmConnector, "applyOperation" | "readField">>;
|
|
28
|
+
|
|
27
29
|
export type HubspotConnectorOptions = {
|
|
28
30
|
/** Returns a HubSpot access token (private app token or OAuth access token). */
|
|
29
31
|
getAccessToken: () => string | Promise<string>;
|
|
@@ -71,7 +73,7 @@ const PULL_STAGE_BY_TYPE: Record<SnapshotProgress["objectType"], (typeof SNAPSHO
|
|
|
71
73
|
* amountless deals — so audit rules can surface the gaps instead of hiding
|
|
72
74
|
* them.
|
|
73
75
|
*/
|
|
74
|
-
export function createHubspotConnector(options: HubspotConnectorOptions):
|
|
76
|
+
export function createHubspotConnector(options: HubspotConnectorOptions): HubspotWritableConnector {
|
|
75
77
|
const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
|
|
76
78
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
77
79
|
const mappings = options.fieldMappings;
|
|
@@ -1195,6 +1197,164 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
1195
1197
|
};
|
|
1196
1198
|
}
|
|
1197
1199
|
|
|
1200
|
+
function fieldMapping(operation: PatchOperation): { objectPath: string; property: string } | null {
|
|
1201
|
+
const objectPath = OBJECT_PATHS[operation.objectType];
|
|
1202
|
+
const mappingType = MAPPING_OBJECT_TYPES[operation.objectType];
|
|
1203
|
+
if (!objectPath || !mappingType || !operation.field) return null;
|
|
1204
|
+
const defaults = HUBSPOT_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
|
|
1205
|
+
return { objectPath, property: mappedField(mappings, mappingType, operation.field, defaults[operation.field] ?? operation.field) };
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
function normalizeHubspotReadValue(field: string | undefined, value: unknown): unknown {
|
|
1209
|
+
if (field === "closeDate" && typeof value === "string") return value.split("T")[0];
|
|
1210
|
+
return value ?? null;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
function normalizeForComparison(value: unknown): string | null {
|
|
1214
|
+
if (value === undefined || value === null || value === "") return null;
|
|
1215
|
+
return String(value);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function batchErrorIds(error: any): string[] {
|
|
1219
|
+
const context = error?.context ?? {};
|
|
1220
|
+
const ids = context.ids ?? context.id ?? error?.id ?? error?.objectId;
|
|
1221
|
+
if (Array.isArray(ids)) return ids.map(String);
|
|
1222
|
+
if (ids !== undefined && ids !== null) return [String(ids)];
|
|
1223
|
+
return [];
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function batchErrorDetail(error: any): string {
|
|
1227
|
+
const category = error?.category ? `${String(error.category)}: ` : "";
|
|
1228
|
+
return `${category}${String(error?.message ?? "HubSpot batch row failed.")}`;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
async function applySetFieldBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
|
|
1232
|
+
const out = new Map<string, PatchOperationResult>();
|
|
1233
|
+
const mapped = operations.map((operation) => ({ operation, mapping: fieldMapping(operation) }));
|
|
1234
|
+
for (const item of mapped) {
|
|
1235
|
+
if (!item.mapping) {
|
|
1236
|
+
out.set(item.operation.id, {
|
|
1237
|
+
operationId: item.operation.id,
|
|
1238
|
+
status: "skipped",
|
|
1239
|
+
detail: "Field writes are only supported for accounts, contacts, and deals with an explicit field.",
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
const clean = mapped.filter((item): item is { operation: PatchOperation; mapping: { objectPath: string; property: string } } => Boolean(item.mapping));
|
|
1244
|
+
if (clean.length === 0) return operations.map((operation) => out.get(operation.id)!);
|
|
1245
|
+
const objectPath = clean[0].mapping.objectPath;
|
|
1246
|
+
const properties = [...new Set(clean.map((item) => item.mapping.property))];
|
|
1247
|
+
const liveById = new Map<string, Record<string, unknown>>();
|
|
1248
|
+
const ids = [...new Set(clean.map((item) => item.operation.objectId))];
|
|
1249
|
+
for (let i = 0; i < ids.length; i += 100) {
|
|
1250
|
+
const chunkIds = ids.slice(i, i + 100);
|
|
1251
|
+
const data = await request(`/crm/v3/objects/${objectPath}/batch/read`, {
|
|
1252
|
+
method: "POST",
|
|
1253
|
+
body: JSON.stringify({ properties, inputs: chunkIds.map((id) => ({ id })) }),
|
|
1254
|
+
});
|
|
1255
|
+
for (const row of data?.results ?? []) {
|
|
1256
|
+
if (row?.id) liveById.set(String(row.id), row.properties ?? {});
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
const toWrite: Array<{ operation: PatchOperation; property: string }> = [];
|
|
1261
|
+
for (const { operation, mapping } of clean) {
|
|
1262
|
+
const props = liveById.get(operation.objectId);
|
|
1263
|
+
const current = normalizeHubspotReadValue(operation.field, props?.[mapping.property] ?? null);
|
|
1264
|
+
const expected = normalizeForComparison(operation.beforeValue);
|
|
1265
|
+
const found = normalizeForComparison(current);
|
|
1266
|
+
if (!props || expected !== found) {
|
|
1267
|
+
out.set(operation.id, {
|
|
1268
|
+
operationId: operation.id,
|
|
1269
|
+
status: "conflict",
|
|
1270
|
+
detail: `Value drifted since the plan was proposed: expected ${expected ?? "∅"}, found ${found ?? "∅"}. Re-run the audit.`,
|
|
1271
|
+
providerData: { currentValue: current ?? null },
|
|
1272
|
+
});
|
|
1273
|
+
continue;
|
|
1274
|
+
}
|
|
1275
|
+
toWrite.push({ operation, property: mapping.property });
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
for (let i = 0; i < toWrite.length; i += 100) {
|
|
1279
|
+
const chunk = toWrite.slice(i, i + 100);
|
|
1280
|
+
let data: any;
|
|
1281
|
+
try {
|
|
1282
|
+
data = await request(`/crm/v3/objects/${objectPath}/batch/update`, {
|
|
1283
|
+
method: "POST",
|
|
1284
|
+
body: JSON.stringify({
|
|
1285
|
+
inputs: chunk.map(({ operation, property }) => ({
|
|
1286
|
+
id: operation.objectId,
|
|
1287
|
+
properties: { [property]: operation.operation === "clear_field" ? "" : String(operation.afterValue ?? "") },
|
|
1288
|
+
})),
|
|
1289
|
+
}),
|
|
1290
|
+
});
|
|
1291
|
+
} catch (error) {
|
|
1292
|
+
for (const { operation } of chunk) out.set(operation.id, { operationId: operation.id, status: "failed", detail: error instanceof Error ? error.message : String(error) });
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
const opByObjectId = new Map(chunk.map(({ operation }) => [operation.objectId, operation]));
|
|
1296
|
+
for (const error of data?.errors ?? []) {
|
|
1297
|
+
for (const id of batchErrorIds(error)) {
|
|
1298
|
+
const operation = opByObjectId.get(id);
|
|
1299
|
+
if (operation) out.set(operation.id, { operationId: operation.id, status: "failed", detail: batchErrorDetail(error) });
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
for (const result of data?.results ?? []) {
|
|
1303
|
+
const operation = opByObjectId.get(String(result?.id));
|
|
1304
|
+
if (operation && !out.has(operation.id)) out.set(operation.id, { operationId: operation.id, status: "applied", detail: `Set ${chunk.find((item) => item.operation.id === operation.id)?.property ?? "field"} on ${objectPath}/${operation.objectId}.`, providerData: { id: result?.id } });
|
|
1305
|
+
}
|
|
1306
|
+
for (const { operation, property } of chunk) {
|
|
1307
|
+
if (!out.has(operation.id)) out.set(operation.id, { operationId: operation.id, status: "applied", detail: `Set ${property} on ${objectPath}/${operation.objectId}.` });
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
return operations.map((operation) => out.get(operation.id) ?? { operationId: operation.id, status: "failed", detail: "Batch update did not process this operation." });
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
async function applyArchiveBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
|
|
1314
|
+
const objectPath = OBJECT_PATHS[operations[0]?.objectType];
|
|
1315
|
+
if (!objectPath) return operations.map((operation) => ({ operationId: operation.id, status: "skipped", detail: "archive_record is supported for accounts, contacts, and deals." }));
|
|
1316
|
+
const out = new Map<string, PatchOperationResult>();
|
|
1317
|
+
for (let i = 0; i < operations.length; i += 100) {
|
|
1318
|
+
const chunk = operations.slice(i, i + 100);
|
|
1319
|
+
let data: any;
|
|
1320
|
+
try {
|
|
1321
|
+
data = await request(`/crm/v3/objects/${objectPath}/batch/archive`, {
|
|
1322
|
+
method: "POST",
|
|
1323
|
+
body: JSON.stringify({ inputs: chunk.map((operation) => ({ id: operation.objectId })) }),
|
|
1324
|
+
});
|
|
1325
|
+
} catch (error) {
|
|
1326
|
+
for (const operation of chunk) out.set(operation.id, { operationId: operation.id, status: "failed", detail: error instanceof Error ? error.message : String(error) });
|
|
1327
|
+
continue;
|
|
1328
|
+
}
|
|
1329
|
+
const opByObjectId = new Map(chunk.map((operation) => [operation.objectId, operation]));
|
|
1330
|
+
for (const error of data?.errors ?? []) {
|
|
1331
|
+
for (const id of batchErrorIds(error)) {
|
|
1332
|
+
const operation = opByObjectId.get(id);
|
|
1333
|
+
if (operation) out.set(operation.id, { operationId: operation.id, status: "failed", detail: batchErrorDetail(error) });
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
for (const result of data?.results ?? []) {
|
|
1337
|
+
const operation = opByObjectId.get(String(result?.id));
|
|
1338
|
+
if (operation && !out.has(operation.id)) out.set(operation.id, { operationId: operation.id, status: "applied", detail: `Archived ${objectPath}/${operation.objectId}.` });
|
|
1339
|
+
}
|
|
1340
|
+
for (const operation of chunk) {
|
|
1341
|
+
if (!out.has(operation.id)) out.set(operation.id, { operationId: operation.id, status: "applied", detail: `Archived ${objectPath}/${operation.objectId}.` });
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
return operations.map((operation) => out.get(operation.id) ?? { operationId: operation.id, status: "failed", detail: "Batch archive did not process this operation." });
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
async function applyBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
|
|
1348
|
+
if (operations.length === 0) return [];
|
|
1349
|
+
if (operations.every((operation) => (operation.operation === "set_field" || operation.operation === "clear_field") && operation.objectType === operations[0].objectType)) {
|
|
1350
|
+
return applySetFieldBatch(operations);
|
|
1351
|
+
}
|
|
1352
|
+
if (operations.every((operation) => operation.operation === "archive_record" && operation.objectType === operations[0].objectType)) {
|
|
1353
|
+
return applyArchiveBatch(operations);
|
|
1354
|
+
}
|
|
1355
|
+
return operations.map((operation) => ({ operationId: operation.id, status: "skipped", detail: "This mixed operation batch is not supported." }));
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1198
1358
|
/**
|
|
1199
1359
|
* Merge a duplicate group into the approved survivor via HubSpot's v3
|
|
1200
1360
|
* merge API (supported for contacts, companies, deals, and tickets).
|
|
@@ -1335,10 +1495,7 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
1335
1495
|
// single-object read here returns HubSpot's raw "2026-03-07T00:00:00Z", which
|
|
1336
1496
|
// made compare-and-set see a spurious drift and refuse every date-field write.
|
|
1337
1497
|
// Mirror the snapshot's normalization so the comparison is apples-to-apples.
|
|
1338
|
-
|
|
1339
|
-
return value.split("T")[0];
|
|
1340
|
-
}
|
|
1341
|
-
return value;
|
|
1498
|
+
return normalizeHubspotReadValue(field, value);
|
|
1342
1499
|
}
|
|
1343
1500
|
|
|
1344
1501
|
return {
|
|
@@ -1347,6 +1504,8 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
1347
1504
|
fetchChanges,
|
|
1348
1505
|
applyOperation,
|
|
1349
1506
|
applyCreateContactsBatch,
|
|
1507
|
+
applyBatch,
|
|
1508
|
+
applyBatchLimit: 100,
|
|
1350
1509
|
readField,
|
|
1351
1510
|
};
|
|
1352
1511
|
}
|
|
@@ -29,6 +29,8 @@ export type SalesforceConnection = {
|
|
|
29
29
|
instanceUrl: string;
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
+
export type SalesforceWritableConnector = GtmConnector & Required<Pick<GtmConnector, "applyOperation" | "readField">>;
|
|
33
|
+
|
|
32
34
|
export type SalesforceConnectorOptions = {
|
|
33
35
|
/** Returns an access token plus the instance URL it belongs to. */
|
|
34
36
|
getConnection: () => SalesforceConnection | Promise<SalesforceConnection>;
|
|
@@ -86,7 +88,7 @@ const PULL_STAGE_BY_TYPE: Record<SnapshotProgress["objectType"], (typeof SNAPSHO
|
|
|
86
88
|
*/
|
|
87
89
|
export function createSalesforceConnector(
|
|
88
90
|
options: SalesforceConnectorOptions,
|
|
89
|
-
):
|
|
91
|
+
): SalesforceWritableConnector {
|
|
90
92
|
const apiVersion = options.apiVersion ?? DEFAULT_API_VERSION;
|
|
91
93
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
92
94
|
const mappings = options.fieldMappings;
|
|
@@ -489,6 +491,137 @@ export function createSalesforceConnector(
|
|
|
489
491
|
};
|
|
490
492
|
}
|
|
491
493
|
|
|
494
|
+
function normalizeSalesforceValue(value: unknown): string | null {
|
|
495
|
+
if (value === undefined || value === null || value === "") return null;
|
|
496
|
+
return String(value).split("T")[0];
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function fieldMappingFor(operation: PatchOperation): { sobjectType: string; field: string } | null {
|
|
500
|
+
const sobjectType = SOBJECT_TYPES[operation.objectType];
|
|
501
|
+
const mappingType = MAPPING_OBJECT_TYPES[operation.objectType];
|
|
502
|
+
if (!sobjectType || !mappingType || !operation.field) return null;
|
|
503
|
+
const defaults = SALESFORCE_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
|
|
504
|
+
return {
|
|
505
|
+
sobjectType,
|
|
506
|
+
field: mappedField(mappings, mappingType, operation.field, defaults[operation.field] ?? operation.field),
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function compositeErrorDetail(result: any): string {
|
|
511
|
+
const errors = Array.isArray(result?.errors) ? result.errors : [];
|
|
512
|
+
const message = errors
|
|
513
|
+
.map((error: any) => [error?.statusCode, error?.message].filter(Boolean).join(": "))
|
|
514
|
+
.filter(Boolean)
|
|
515
|
+
.join("; ");
|
|
516
|
+
return message ? `Salesforce rejected this record: ${message.slice(0, 300)}.` : "Salesforce rejected this record.";
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function applySetFieldBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
|
|
520
|
+
const out = new Map<string, PatchOperationResult>();
|
|
521
|
+
const mapped = operations.map((operation) => ({ operation, mapping: fieldMappingFor(operation) }));
|
|
522
|
+
for (const item of mapped) {
|
|
523
|
+
if (!item.mapping) {
|
|
524
|
+
out.set(item.operation.id, {
|
|
525
|
+
operationId: item.operation.id,
|
|
526
|
+
status: "skipped",
|
|
527
|
+
detail: "Field writes are only supported for accounts, contacts, and deals with an explicit field.",
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
const clean = mapped.filter((item): item is { operation: PatchOperation; mapping: { sobjectType: string; field: string } } => Boolean(item.mapping));
|
|
532
|
+
if (clean.length === 0) return operations.map((operation) => out.get(operation.id)!);
|
|
533
|
+
const sobjectType = clean[0].mapping.sobjectType;
|
|
534
|
+
const fields = [...new Set(clean.map((item) => item.mapping.field))];
|
|
535
|
+
const liveById = new Map<string, Record<string, unknown>>();
|
|
536
|
+
const ids = [...new Set(clean.map((item) => item.operation.objectId))];
|
|
537
|
+
for (let i = 0; i < ids.length; i += 200) {
|
|
538
|
+
const idList = ids.slice(i, i + 200).map((id) => `'${id.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`).join(",");
|
|
539
|
+
const rows = await query(`SELECT Id, ${fields.join(", ")} FROM ${sobjectType} WHERE Id IN (${idList})`);
|
|
540
|
+
for (const row of rows) liveById.set(String(row.Id), row);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const toWrite: Array<{ operation: PatchOperation; field: string }> = [];
|
|
544
|
+
for (const { operation, mapping } of clean) {
|
|
545
|
+
const row = liveById.get(operation.objectId);
|
|
546
|
+
const current = row?.[mapping.field] ?? null;
|
|
547
|
+
const expected = normalizeSalesforceValue(operation.beforeValue);
|
|
548
|
+
const found = normalizeSalesforceValue(current);
|
|
549
|
+
if (!row || expected !== found) {
|
|
550
|
+
out.set(operation.id, {
|
|
551
|
+
operationId: operation.id,
|
|
552
|
+
status: "conflict",
|
|
553
|
+
detail: `Value drifted since the plan was proposed: expected ${expected ?? "∅"}, found ${found ?? "∅"}. Re-run the audit.`,
|
|
554
|
+
providerData: { currentValue: current ?? null },
|
|
555
|
+
});
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
toWrite.push({ operation, field: mapping.field });
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
for (let i = 0; i < toWrite.length; i += 200) {
|
|
562
|
+
const chunk = toWrite.slice(i, i + 200);
|
|
563
|
+
const records = chunk.map(({ operation, field }) => ({
|
|
564
|
+
attributes: { type: sobjectType },
|
|
565
|
+
Id: operation.objectId,
|
|
566
|
+
[field]: operation.operation === "clear_field" ? null : String(operation.afterValue ?? ""),
|
|
567
|
+
}));
|
|
568
|
+
let response: any[];
|
|
569
|
+
try {
|
|
570
|
+
response = (await request(`/services/data/${apiVersion}/composite/sobjects`, {
|
|
571
|
+
method: "PATCH",
|
|
572
|
+
body: JSON.stringify({ allOrNone: false, records }),
|
|
573
|
+
})) as any[];
|
|
574
|
+
} catch (error) {
|
|
575
|
+
for (const { operation } of chunk) out.set(operation.id, { operationId: operation.id, status: "failed", detail: error instanceof Error ? error.message : String(error) });
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
579
|
+
const { operation, field } = chunk[j];
|
|
580
|
+
const result = Array.isArray(response) ? response[j] : undefined;
|
|
581
|
+
out.set(operation.id, result?.success
|
|
582
|
+
? { operationId: operation.id, status: "applied", detail: `Set ${field} on ${sobjectType}/${operation.objectId}.`, providerData: { sobjectType, field } }
|
|
583
|
+
: { operationId: operation.id, status: "failed", detail: compositeErrorDetail(result) });
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return operations.map((operation) => out.get(operation.id) ?? { operationId: operation.id, status: "failed", detail: "Batch update did not process this operation." });
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function applyArchiveBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
|
|
590
|
+
const sobjectType = SOBJECT_TYPES[operations[0]?.objectType];
|
|
591
|
+
if (!sobjectType) return operations.map((operation) => ({ operationId: operation.id, status: "skipped", detail: "archive_record is supported for accounts, contacts, and deals." }));
|
|
592
|
+
const out = new Map<string, PatchOperationResult>();
|
|
593
|
+
for (let i = 0; i < operations.length; i += 200) {
|
|
594
|
+
const chunk = operations.slice(i, i + 200);
|
|
595
|
+
const ids = chunk.map((operation) => encodeURIComponent(operation.objectId)).join(",");
|
|
596
|
+
let response: any[];
|
|
597
|
+
try {
|
|
598
|
+
response = (await request(`/services/data/${apiVersion}/composite/sobjects?ids=${ids}&allOrNone=false`, { method: "DELETE" })) as any[];
|
|
599
|
+
} catch (error) {
|
|
600
|
+
for (const operation of chunk) out.set(operation.id, { operationId: operation.id, status: "failed", detail: error instanceof Error ? error.message : String(error) });
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
604
|
+
const operation = chunk[j];
|
|
605
|
+
const result = Array.isArray(response) ? response[j] : undefined;
|
|
606
|
+
out.set(operation.id, result?.success
|
|
607
|
+
? { operationId: operation.id, status: "applied", detail: `Deleted ${sobjectType}/${operation.objectId}.` }
|
|
608
|
+
: { operationId: operation.id, status: "failed", detail: compositeErrorDetail(result) });
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return operations.map((operation) => out.get(operation.id) ?? { operationId: operation.id, status: "failed", detail: "Batch delete did not process this operation." });
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function applyBatch(operations: PatchOperation[]): Promise<PatchOperationResult[]> {
|
|
615
|
+
if (operations.length === 0) return [];
|
|
616
|
+
if (operations.every((operation) => (operation.operation === "set_field" || operation.operation === "clear_field") && operation.objectType === operations[0].objectType)) {
|
|
617
|
+
return applySetFieldBatch(operations);
|
|
618
|
+
}
|
|
619
|
+
if (operations.every((operation) => operation.operation === "archive_record" && operation.objectType === operations[0].objectType)) {
|
|
620
|
+
return applyArchiveBatch(operations);
|
|
621
|
+
}
|
|
622
|
+
return operations.map((operation) => ({ operationId: operation.id, status: "skipped", detail: "This mixed operation batch is not supported." }));
|
|
623
|
+
}
|
|
624
|
+
|
|
492
625
|
function escapeXml(value: string): string {
|
|
493
626
|
return value
|
|
494
627
|
.replace(/&/g, "&")
|
|
@@ -869,6 +1002,8 @@ export function createSalesforceConnector(
|
|
|
869
1002
|
fetchChanges,
|
|
870
1003
|
applyOperation,
|
|
871
1004
|
applyCreateContactsBatch,
|
|
1005
|
+
applyBatch,
|
|
1006
|
+
applyBatchLimit: 200,
|
|
872
1007
|
readField,
|
|
873
1008
|
};
|
|
874
1009
|
}
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import { readFileSync, statSync } from "node:fs";
|
|
23
23
|
import { resolve } from "node:path";
|
|
24
|
+
import { FREE_EMAIL_DOMAINS } from "../freeEmailDomains.ts";
|
|
24
25
|
import type { SignalBucket, StagedSignalRow } from "../signals.ts";
|
|
25
26
|
import { SIGNAL_BUCKETS } from "../signals.ts";
|
|
26
27
|
import { parseSpoolText, spoolFilesIn } from "../spoolFiles.ts";
|
|
@@ -294,21 +295,10 @@ function fieldValue(values: Array<{ name?: string; value?: string }> | undefined
|
|
|
294
295
|
return "";
|
|
295
296
|
}
|
|
296
297
|
|
|
297
|
-
const FREE_MAIL = new Set([
|
|
298
|
-
"gmail.com",
|
|
299
|
-
"yahoo.com",
|
|
300
|
-
"hotmail.com",
|
|
301
|
-
"outlook.com",
|
|
302
|
-
"icloud.com",
|
|
303
|
-
"aol.com",
|
|
304
|
-
"proton.me",
|
|
305
|
-
"protonmail.com",
|
|
306
|
-
]);
|
|
307
|
-
|
|
308
298
|
/** Company domain from an email, or "" for free-mail / no email. */
|
|
309
299
|
function corporateDomain(email: string): string {
|
|
310
300
|
if (!email.includes("@")) return "";
|
|
311
301
|
const domain = email.split("@").at(-1)!.trim().toLowerCase();
|
|
312
|
-
if (!domain ||
|
|
302
|
+
if (!domain || FREE_EMAIL_DOMAINS.has(domain)) return "";
|
|
313
303
|
return domain;
|
|
314
304
|
}
|
package/src/format.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import type { PatchPlan, PatchPlanRun } from "./types.ts";
|
|
2
2
|
|
|
3
|
-
// `summary: true` renders
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// where you approve specific operations — keep every operation's detail.
|
|
3
|
+
// `summary: true` renders the compact audit view: header, findings summary,
|
|
4
|
+
// assumptions/open questions, and an operation count. Defaults to the full view
|
|
5
|
+
// so write-preview callers (bulk-update, fix, dedupe, plans show, MCP) — where
|
|
6
|
+
// you approve specific operations — keep every operation's detail.
|
|
8
7
|
export function patchPlanToMarkdown(plan: PatchPlan, opts: { summary?: boolean } = {}) {
|
|
9
8
|
const lines = [
|
|
10
9
|
`# ${plan.title}`,
|
|
@@ -29,6 +28,8 @@ export function patchPlanToMarkdown(plan: PatchPlan, opts: { summary?: boolean }
|
|
|
29
28
|
lines.push("");
|
|
30
29
|
}
|
|
31
30
|
|
|
31
|
+
appendAssumptionsAndDecisionPoints(lines, plan);
|
|
32
|
+
|
|
32
33
|
if (opts.summary) {
|
|
33
34
|
const ops = plan.operations.length;
|
|
34
35
|
lines.push(
|
|
@@ -155,6 +156,33 @@ export function formatPatchPlanRun(run: PatchPlanRun) {
|
|
|
155
156
|
return `${lines.join("\n")}\n`;
|
|
156
157
|
}
|
|
157
158
|
|
|
159
|
+
|
|
160
|
+
function appendAssumptionsAndDecisionPoints(lines: string[], plan: PatchPlan) {
|
|
161
|
+
const assumptions = [...(plan.assumptions ?? [])].sort((left, right) => {
|
|
162
|
+
if (left.confidence === "guess" && right.confidence !== "guess") return -1;
|
|
163
|
+
if (left.confidence !== "guess" && right.confidence === "guess") return 1;
|
|
164
|
+
return 0;
|
|
165
|
+
});
|
|
166
|
+
const openQuestions = plan.openQuestions ?? [];
|
|
167
|
+
if (assumptions.length === 0 && openQuestions.length === 0) return;
|
|
168
|
+
|
|
169
|
+
lines.push("## Assumptions & decision points", "");
|
|
170
|
+
for (const assumption of assumptions) {
|
|
171
|
+
const warning = assumption.confidence === "guess" ? "⚠️ " : "";
|
|
172
|
+
lines.push(`- ${warning}${assumption.text}${assumption.source ? ` _(source: ${assumption.source})_` : ""}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (openQuestions.length > 0) {
|
|
176
|
+
if (assumptions.length > 0) lines.push("");
|
|
177
|
+
lines.push("### Open questions", "");
|
|
178
|
+
for (const question of openQuestions) {
|
|
179
|
+
lines.push(`- ${question}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
lines.push("");
|
|
184
|
+
}
|
|
185
|
+
|
|
158
186
|
function formatValue(value: unknown) {
|
|
159
187
|
if (value === undefined || value === null || value === "") return "unset";
|
|
160
188
|
if (typeof value === "string") return value;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Shared free-mail domains that should never be treated as company domains.
|
|
2
|
+
export const FREE_EMAIL_DOMAINS = new Set([
|
|
3
|
+
"gmail.com",
|
|
4
|
+
"googlemail.com",
|
|
5
|
+
"yahoo.com",
|
|
6
|
+
"outlook.com",
|
|
7
|
+
"hotmail.com",
|
|
8
|
+
"live.com",
|
|
9
|
+
"icloud.com",
|
|
10
|
+
"me.com",
|
|
11
|
+
"aol.com",
|
|
12
|
+
"proton.me",
|
|
13
|
+
"protonmail.com",
|
|
14
|
+
]);
|
package/src/hierarchy.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { normalizeDomain } from "./merge.ts";
|
|
2
|
+
import type { CanonicalAccount, CanonicalGtmSnapshot } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export type AccountHierarchyNode = {
|
|
5
|
+
accountId: string;
|
|
6
|
+
name: string;
|
|
7
|
+
domain?: string;
|
|
8
|
+
parentAccountId?: string;
|
|
9
|
+
parentReason?: string;
|
|
10
|
+
children: AccountHierarchyNode[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type AccountHierarchyConflict = {
|
|
14
|
+
type: "duplicate_domain" | "ambiguous_parent" | "cycle";
|
|
15
|
+
accountIds: string[];
|
|
16
|
+
key: string;
|
|
17
|
+
detail: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type AccountHierarchyReport = {
|
|
21
|
+
generatedAt: string;
|
|
22
|
+
roots: AccountHierarchyNode[];
|
|
23
|
+
orphanAccounts: string[];
|
|
24
|
+
conflicts: AccountHierarchyConflict[];
|
|
25
|
+
counts: { accounts: number; roots: number; linkedChildren: number; conflicts: number };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function rawString(account: CanonicalAccount, keys: string[]): string | undefined {
|
|
29
|
+
if (!account.raw || typeof account.raw !== "object") return undefined;
|
|
30
|
+
const raw = account.raw as Record<string, unknown>;
|
|
31
|
+
for (const key of keys) {
|
|
32
|
+
const value = raw[key];
|
|
33
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
34
|
+
if (value && typeof value === "object") {
|
|
35
|
+
const nested = value as Record<string, unknown>;
|
|
36
|
+
for (const nestedKey of ["id", "value", "name"]) {
|
|
37
|
+
if (typeof nested[nestedKey] === "string" && String(nested[nestedKey]).trim()) return String(nested[nestedKey]).trim();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parentHint(account: CanonicalAccount): string | undefined {
|
|
45
|
+
return rawString(account, ["parentAccountId", "parent_account_id", "ParentId", "parentCompanyId", "hs_parent_company_id"]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function cloneNode(account: CanonicalAccount): AccountHierarchyNode {
|
|
49
|
+
return { accountId: String(account.id), name: account.name, ...(account.domain ? { domain: account.domain } : {}), children: [] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function looksLikePublicSuffix(domain: string): boolean {
|
|
53
|
+
const parts = domain.split(".");
|
|
54
|
+
if (parts.length !== 2) return false;
|
|
55
|
+
const [label, tld] = parts;
|
|
56
|
+
return tld.length === 2 && ["ac", "co", "com", "edu", "gov", "net", "org"].includes(label);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function domainParent(childDomain: string, domains: Map<string, CanonicalAccount[]>): CanonicalAccount | "ambiguous" | null {
|
|
60
|
+
const parts = childDomain.split(".");
|
|
61
|
+
for (let i = 1; i < parts.length - 1; i++) {
|
|
62
|
+
const candidate = parts.slice(i).join(".");
|
|
63
|
+
if (candidate.split(".").length < 2 || looksLikePublicSuffix(candidate)) continue;
|
|
64
|
+
const unique = new Map((domains.get(candidate) ?? []).map((account) => [String(account.id), account]));
|
|
65
|
+
if (unique.size > 1) return "ambiguous";
|
|
66
|
+
if (unique.size === 1) return [...unique.values()][0];
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function detectCycles(parentByChild: Map<string, { parentId: string; reason: string }>): { accountIds: Set<string>; cycles: string[][] } {
|
|
72
|
+
const cyclic = new Set<string>();
|
|
73
|
+
const cycles: string[][] = [];
|
|
74
|
+
const emitted = new Set<string>();
|
|
75
|
+
|
|
76
|
+
for (const start of parentByChild.keys()) {
|
|
77
|
+
const path: string[] = [];
|
|
78
|
+
const indexById = new Map<string, number>();
|
|
79
|
+
let cursor: string | undefined = start;
|
|
80
|
+
while (cursor && parentByChild.has(cursor)) {
|
|
81
|
+
const seenAt = indexById.get(cursor);
|
|
82
|
+
if (seenAt !== undefined) {
|
|
83
|
+
const cycle = path.slice(seenAt);
|
|
84
|
+
for (const id of cycle) cyclic.add(id);
|
|
85
|
+
const key = [...cycle].sort().join("|");
|
|
86
|
+
if (!emitted.has(key)) {
|
|
87
|
+
emitted.add(key);
|
|
88
|
+
cycles.push(cycle);
|
|
89
|
+
}
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
indexById.set(cursor, path.length);
|
|
93
|
+
path.push(cursor);
|
|
94
|
+
cursor = parentByChild.get(cursor)?.parentId;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { accountIds: cyclic, cycles };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function buildAccountHierarchy(snapshot: CanonicalGtmSnapshot): AccountHierarchyReport {
|
|
102
|
+
const byId = new Map(snapshot.accounts.map((account) => [String(account.id), account]));
|
|
103
|
+
const accounts = [...byId.values()];
|
|
104
|
+
const domains = new Map<string, CanonicalAccount[]>();
|
|
105
|
+
for (const account of accounts) {
|
|
106
|
+
const domain = normalizeDomain(account.domain);
|
|
107
|
+
if (domain) domains.set(domain, [...(domains.get(domain) ?? []), account]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const conflicts: AccountHierarchyConflict[] = [];
|
|
111
|
+
for (const [domain, accounts] of domains) {
|
|
112
|
+
const ids = [...new Set(accounts.map((account) => String(account.id)))];
|
|
113
|
+
if (ids.length > 1) conflicts.push({ type: "duplicate_domain", accountIds: ids.sort(), key: domain, detail: `${ids.length} accounts share domain ${domain}; hierarchy inference will not choose between them.` });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const nodes = new Map(accounts.map((account) => [String(account.id), cloneNode(account)]));
|
|
117
|
+
const parentByChild = new Map<string, { parentId: string; reason: string }>();
|
|
118
|
+
for (const account of accounts) {
|
|
119
|
+
const id = String(account.id);
|
|
120
|
+
const hint = parentHint(account);
|
|
121
|
+
if (hint && byId.has(hint) && hint !== id) {
|
|
122
|
+
parentByChild.set(id, { parentId: hint, reason: "provider-parent" });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const domain = normalizeDomain(account.domain);
|
|
126
|
+
if (!domain) continue;
|
|
127
|
+
const inferred = domainParent(domain, domains);
|
|
128
|
+
if (inferred === "ambiguous") conflicts.push({ type: "ambiguous_parent", accountIds: [id], key: domain, detail: `Multiple possible parent accounts found for ${domain}; leaving account as a root.` });
|
|
129
|
+
else if (inferred && String(inferred.id) !== id) parentByChild.set(id, { parentId: String(inferred.id), reason: "domain-subdomain" });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const cyclicAccounts = detectCycles(parentByChild);
|
|
133
|
+
for (const cycle of cyclicAccounts.cycles) {
|
|
134
|
+
const ordered = [...cycle].sort();
|
|
135
|
+
const key = ordered.join(" -> ");
|
|
136
|
+
conflicts.push({
|
|
137
|
+
type: "cycle",
|
|
138
|
+
accountIds: ordered,
|
|
139
|
+
key,
|
|
140
|
+
detail: `Cyclic parent hints detected among ${ordered.join(", ")}; leaving those accounts as roots instead of hiding them in a loop.`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
for (const accountId of cyclicAccounts.accountIds) parentByChild.delete(accountId);
|
|
144
|
+
|
|
145
|
+
const roots: AccountHierarchyNode[] = [];
|
|
146
|
+
const orphanAccounts: string[] = [];
|
|
147
|
+
const linkedChildren = new Set<string>();
|
|
148
|
+
for (const account of accounts) {
|
|
149
|
+
const id = String(account.id);
|
|
150
|
+
const node = nodes.get(id)!;
|
|
151
|
+
const parent = parentByChild.get(id);
|
|
152
|
+
if (parent && nodes.has(parent.parentId)) {
|
|
153
|
+
node.parentAccountId = parent.parentId;
|
|
154
|
+
node.parentReason = parent.reason;
|
|
155
|
+
nodes.get(parent.parentId)!.children.push(node);
|
|
156
|
+
linkedChildren.add(id);
|
|
157
|
+
} else {
|
|
158
|
+
roots.push(node);
|
|
159
|
+
if (!account.domain && !parentHint(account)) orphanAccounts.push(id);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const sortTree = (list: AccountHierarchyNode[]) => {
|
|
163
|
+
list.sort((a, b) => a.name.localeCompare(b.name) || a.accountId.localeCompare(b.accountId));
|
|
164
|
+
for (const node of list) sortTree(node.children);
|
|
165
|
+
};
|
|
166
|
+
sortTree(roots);
|
|
167
|
+
return { generatedAt: snapshot.generatedAt, roots, orphanAccounts: orphanAccounts.sort(), conflicts, counts: { accounts: accounts.length, roots: roots.length, linkedChildren: linkedChildren.size, conflicts: conflicts.length } };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function accountHierarchyToMarkdown(report: AccountHierarchyReport): string {
|
|
171
|
+
const lines = ["# Account hierarchy", "", `${report.counts.accounts} account(s), ${report.counts.roots} root(s), ${report.counts.linkedChildren} inferred child link(s), ${report.counts.conflicts} conflict(s).`, ""];
|
|
172
|
+
const walk = (nodes: AccountHierarchyNode[], depth: number) => {
|
|
173
|
+
for (const node of nodes) {
|
|
174
|
+
const suffix = [node.domain, node.parentReason ? `via ${node.parentReason}` : undefined].filter(Boolean).join("; ");
|
|
175
|
+
lines.push(`${" ".repeat(depth)}- ${node.name} (${node.accountId}${suffix ? ` — ${suffix}` : ""})`);
|
|
176
|
+
walk(node.children, depth + 1);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
walk(report.roots, 0);
|
|
180
|
+
if (report.conflicts.length) {
|
|
181
|
+
lines.push("", "## Conflicts");
|
|
182
|
+
for (const conflict of report.conflicts) lines.push(`- ${conflict.type} ${conflict.key}: ${conflict.detail}`);
|
|
183
|
+
}
|
|
184
|
+
return lines.join("\n");
|
|
185
|
+
}
|