fullstackgtm 0.10.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 +381 -0
- package/INSTALL_FOR_AGENTS.md +87 -0
- package/LICENSE +202 -0
- package/README.md +230 -0
- package/dist/audit.d.ts +7 -0
- package/dist/audit.js +202 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +6 -0
- package/dist/cli.d.ts +38 -0
- package/dist/cli.js +915 -0
- package/dist/config.d.ts +36 -0
- package/dist/config.js +85 -0
- package/dist/connector.d.ts +30 -0
- package/dist/connector.js +94 -0
- package/dist/connectors/hubspot.d.ts +20 -0
- package/dist/connectors/hubspot.js +409 -0
- package/dist/connectors/hubspotAuth.d.ts +42 -0
- package/dist/connectors/hubspotAuth.js +189 -0
- package/dist/connectors/salesforce.d.ts +26 -0
- package/dist/connectors/salesforce.js +318 -0
- package/dist/connectors/salesforceAuth.d.ts +44 -0
- package/dist/connectors/salesforceAuth.js +120 -0
- package/dist/connectors/stripe.d.ts +27 -0
- package/dist/connectors/stripe.js +176 -0
- package/dist/credentials.d.ts +75 -0
- package/dist/credentials.js +197 -0
- package/dist/demo.d.ts +20 -0
- package/dist/demo.js +169 -0
- package/dist/diff.d.ts +46 -0
- package/dist/diff.js +107 -0
- package/dist/format.d.ts +3 -0
- package/dist/format.js +109 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +17 -0
- package/dist/mappings.d.ts +8 -0
- package/dist/mappings.js +123 -0
- package/dist/mcp-bin.d.ts +2 -0
- package/dist/mcp-bin.js +33 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +140 -0
- package/dist/merge.d.ts +48 -0
- package/dist/merge.js +145 -0
- package/dist/planStore.d.ts +31 -0
- package/dist/planStore.js +116 -0
- package/dist/rules.d.ts +24 -0
- package/dist/rules.js +512 -0
- package/dist/sampleData.d.ts +2 -0
- package/dist/sampleData.js +115 -0
- package/dist/types.d.ts +294 -0
- package/dist/types.js +8 -0
- package/docs/api.md +72 -0
- package/docs/roadmap-to-1.0.md +121 -0
- package/llms.txt +25 -0
- package/package.json +76 -0
- package/src/audit.ts +242 -0
- package/src/bin.ts +7 -0
- package/src/cli.ts +1042 -0
- package/src/config.ts +113 -0
- package/src/connector.ts +140 -0
- package/src/connectors/hubspot.ts +528 -0
- package/src/connectors/hubspotAuth.ts +246 -0
- package/src/connectors/salesforce.ts +420 -0
- package/src/connectors/salesforceAuth.ts +167 -0
- package/src/connectors/stripe.ts +215 -0
- package/src/credentials.ts +282 -0
- package/src/demo.ts +200 -0
- package/src/diff.ts +158 -0
- package/src/format.ts +162 -0
- package/src/index.ts +129 -0
- package/src/mappings.ts +157 -0
- package/src/mcp-bin.ts +32 -0
- package/src/mcp.ts +185 -0
- package/src/merge.ts +235 -0
- package/src/planStore.ts +155 -0
- package/src/rules.ts +539 -0
- package/src/sampleData.ts +117 -0
- package/src/types.ts +372 -0
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { GtmAuditRule, GtmPolicy } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Project-level configuration: `fullstackgtm.config.json` in the working
|
|
4
|
+
* directory (or an explicit `--config` path). Policy thresholds, rule
|
|
5
|
+
* selection, and rule packages — third-party modules exporting additional
|
|
6
|
+
* `GtmAuditRule`s — all live here so a team's audit standard is reviewable
|
|
7
|
+
* in version control.
|
|
8
|
+
*/
|
|
9
|
+
export declare const CONFIG_FILE_NAME = "fullstackgtm.config.json";
|
|
10
|
+
export type FullstackgtmConfig = {
|
|
11
|
+
policy?: Partial<GtmPolicy>;
|
|
12
|
+
rules?: {
|
|
13
|
+
/** If set, only these rule ids run (after rule packages are loaded). */
|
|
14
|
+
enabled?: string[];
|
|
15
|
+
/** Removed from the effective set after `enabled` is applied. */
|
|
16
|
+
disabled?: string[];
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Module specifiers resolved relative to the config file. Each module must
|
|
20
|
+
* export `rules: GtmAuditRule[]`. Use compiled .js/.mjs modules so they
|
|
21
|
+
* load under plain Node.
|
|
22
|
+
*/
|
|
23
|
+
rulePackages?: string[];
|
|
24
|
+
};
|
|
25
|
+
export type LoadedConfig = {
|
|
26
|
+
config: FullstackgtmConfig;
|
|
27
|
+
path: string;
|
|
28
|
+
};
|
|
29
|
+
export declare function loadConfig(explicitPath?: string, cwd?: string): LoadedConfig | null;
|
|
30
|
+
/** Overlay config policy values onto a base policy; defined values win. */
|
|
31
|
+
export declare function mergePolicy(base: GtmPolicy, config?: FullstackgtmConfig): GtmPolicy;
|
|
32
|
+
/**
|
|
33
|
+
* Build the effective rule set: built-ins plus rule-package exports, then
|
|
34
|
+
* `enabled` (allow-list) and `disabled` filters.
|
|
35
|
+
*/
|
|
36
|
+
export declare function resolveConfiguredRules(loaded?: LoadedConfig | null, baseRules?: GtmAuditRule[]): Promise<GtmAuditRule[]>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
11
|
+
import { pathToFileURL } from "node:url";
|
|
12
|
+
import { builtinAuditRules } from "./rules.js";
|
|
13
|
+
/**
|
|
14
|
+
* Project-level configuration: `fullstackgtm.config.json` in the working
|
|
15
|
+
* directory (or an explicit `--config` path). Policy thresholds, rule
|
|
16
|
+
* selection, and rule packages — third-party modules exporting additional
|
|
17
|
+
* `GtmAuditRule`s — all live here so a team's audit standard is reviewable
|
|
18
|
+
* in version control.
|
|
19
|
+
*/
|
|
20
|
+
export const CONFIG_FILE_NAME = "fullstackgtm.config.json";
|
|
21
|
+
export function loadConfig(explicitPath, cwd = process.cwd()) {
|
|
22
|
+
const path = explicitPath ? resolve(cwd, explicitPath) : resolve(cwd, CONFIG_FILE_NAME);
|
|
23
|
+
let raw;
|
|
24
|
+
try {
|
|
25
|
+
raw = readFileSync(path, "utf8");
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
if (explicitPath)
|
|
29
|
+
throw new Error(`Could not read config file: ${path}`);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const config = JSON.parse(raw);
|
|
33
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
|
34
|
+
throw new Error(`${path} must contain a JSON object.`);
|
|
35
|
+
}
|
|
36
|
+
return { config, path };
|
|
37
|
+
}
|
|
38
|
+
/** Overlay config policy values onto a base policy; defined values win. */
|
|
39
|
+
export function mergePolicy(base, config) {
|
|
40
|
+
if (!config?.policy)
|
|
41
|
+
return base;
|
|
42
|
+
const merged = { ...base };
|
|
43
|
+
for (const [key, value] of Object.entries(config.policy)) {
|
|
44
|
+
if (value !== undefined) {
|
|
45
|
+
merged[key] = value;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return merged;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Build the effective rule set: built-ins plus rule-package exports, then
|
|
52
|
+
* `enabled` (allow-list) and `disabled` filters.
|
|
53
|
+
*/
|
|
54
|
+
export async function resolveConfiguredRules(loaded, baseRules = builtinAuditRules) {
|
|
55
|
+
let rules = [...baseRules];
|
|
56
|
+
for (const specifier of loaded?.config.rulePackages ?? []) {
|
|
57
|
+
const resolvedSpecifier = specifier.startsWith(".") || isAbsolute(specifier)
|
|
58
|
+
? pathToFileURL(resolve(dirname(loaded.path), specifier)).href
|
|
59
|
+
: specifier;
|
|
60
|
+
const rulePackage = await import(__rewriteRelativeImportExtension(resolvedSpecifier));
|
|
61
|
+
const imported = rulePackage.rules ?? rulePackage.default?.rules;
|
|
62
|
+
if (!Array.isArray(imported)) {
|
|
63
|
+
throw new Error(`Rule package ${specifier} must export \`rules: GtmAuditRule[]\`.`);
|
|
64
|
+
}
|
|
65
|
+
for (const rule of imported) {
|
|
66
|
+
if (!rule?.id || typeof rule.evaluate !== "function") {
|
|
67
|
+
throw new Error(`Rule package ${specifier} exported an invalid rule.`);
|
|
68
|
+
}
|
|
69
|
+
rules.push(rule);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const enabled = loaded?.config.rules?.enabled;
|
|
73
|
+
if (enabled?.length) {
|
|
74
|
+
const known = new Map(rules.map((rule) => [rule.id, rule]));
|
|
75
|
+
rules = enabled.map((id) => {
|
|
76
|
+
const rule = known.get(id);
|
|
77
|
+
if (!rule) {
|
|
78
|
+
throw new Error(`Config enables unknown rule: ${id}. Known rules: ${Array.from(known.keys()).join(", ")}`);
|
|
79
|
+
}
|
|
80
|
+
return rule;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const disabled = new Set(loaded?.config.rules?.disabled ?? []);
|
|
84
|
+
return rules.filter((rule) => !disabled.has(rule.id));
|
|
85
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { GtmConnector, PatchPlan, PatchPlanRun } from "./types.ts";
|
|
2
|
+
export type ApplyPatchPlanOptions = {
|
|
3
|
+
/**
|
|
4
|
+
* Explicit allow-list of operation ids the human approved. Operations not
|
|
5
|
+
* listed are skipped without any provider call. An empty list rejects the
|
|
6
|
+
* whole run.
|
|
7
|
+
*/
|
|
8
|
+
approvedOperationIds: string[];
|
|
9
|
+
/**
|
|
10
|
+
* Concrete values supplied at approval time for operations whose
|
|
11
|
+
* `afterValue` is a `requires_human_*` placeholder, keyed by operation id.
|
|
12
|
+
*/
|
|
13
|
+
valueOverrides?: Record<string, unknown>;
|
|
14
|
+
/**
|
|
15
|
+
* Compare-and-set: before each field write, read the live provider value
|
|
16
|
+
* and refuse the write (`conflict` result) if it no longer matches the
|
|
17
|
+
* plan's `beforeValue`. Defaults to on when the connector supports
|
|
18
|
+
* `readField`.
|
|
19
|
+
*/
|
|
20
|
+
checkConflicts?: boolean;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Apply an approved subset of a patch plan through a connector.
|
|
24
|
+
*
|
|
25
|
+
* The safety contract lives here, not in connectors:
|
|
26
|
+
* - nothing is written unless the operation id was explicitly approved
|
|
27
|
+
* - placeholder values are never written; they require an override
|
|
28
|
+
* - every operation produces a result, and the run is the audit record
|
|
29
|
+
*/
|
|
30
|
+
export declare function applyPatchPlan(connector: GtmConnector, plan: PatchPlan, options: ApplyPatchPlanOptions): Promise<PatchPlanRun>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { requiresHumanInput } from "./rules.js";
|
|
2
|
+
const FIELD_WRITE_OPERATIONS = new Set(["set_field", "clear_field", "link_record"]);
|
|
3
|
+
function normalizeForComparison(value) {
|
|
4
|
+
if (value === undefined || value === null || value === "")
|
|
5
|
+
return null;
|
|
6
|
+
return String(value);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Apply an approved subset of a patch plan through a connector.
|
|
10
|
+
*
|
|
11
|
+
* The safety contract lives here, not in connectors:
|
|
12
|
+
* - nothing is written unless the operation id was explicitly approved
|
|
13
|
+
* - placeholder values are never written; they require an override
|
|
14
|
+
* - every operation produces a result, and the run is the audit record
|
|
15
|
+
*/
|
|
16
|
+
export async function applyPatchPlan(connector, plan, options) {
|
|
17
|
+
if (!connector.applyOperation) {
|
|
18
|
+
throw new Error(`The ${connector.provider} connector is read-only.`);
|
|
19
|
+
}
|
|
20
|
+
const startedAt = new Date().toISOString();
|
|
21
|
+
const approved = new Set(options.approvedOperationIds);
|
|
22
|
+
const checkConflicts = options.checkConflicts ?? typeof connector.readField === "function";
|
|
23
|
+
const results = [];
|
|
24
|
+
let attempted = 0;
|
|
25
|
+
let applied = 0;
|
|
26
|
+
for (const operation of plan.operations) {
|
|
27
|
+
if (!approved.has(operation.id)) {
|
|
28
|
+
results.push({
|
|
29
|
+
operationId: operation.id,
|
|
30
|
+
status: "skipped",
|
|
31
|
+
detail: "Operation was not approved.",
|
|
32
|
+
});
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const override = options.valueOverrides?.[operation.id];
|
|
36
|
+
if (requiresHumanInput(operation.afterValue) && override === undefined) {
|
|
37
|
+
results.push({
|
|
38
|
+
operationId: operation.id,
|
|
39
|
+
status: "skipped",
|
|
40
|
+
detail: "Operation needs a concrete value; supply a value override.",
|
|
41
|
+
});
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (checkConflicts &&
|
|
45
|
+
connector.readField &&
|
|
46
|
+
operation.field &&
|
|
47
|
+
FIELD_WRITE_OPERATIONS.has(operation.operation)) {
|
|
48
|
+
const current = await connector.readField(operation.objectType, operation.objectId, operation.field);
|
|
49
|
+
const expected = normalizeForComparison(operation.beforeValue);
|
|
50
|
+
const found = normalizeForComparison(current);
|
|
51
|
+
if (expected !== found) {
|
|
52
|
+
results.push({
|
|
53
|
+
operationId: operation.id,
|
|
54
|
+
status: "conflict",
|
|
55
|
+
detail: `Value drifted since the plan was proposed: expected ${expected ?? "∅"}, found ${found ?? "∅"}. Re-run the audit.`,
|
|
56
|
+
providerData: { currentValue: current ?? null },
|
|
57
|
+
});
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const resolved = override === undefined ? operation : { ...operation, afterValue: override };
|
|
62
|
+
attempted += 1;
|
|
63
|
+
try {
|
|
64
|
+
const result = await connector.applyOperation(resolved);
|
|
65
|
+
results.push(result);
|
|
66
|
+
if (result.status === "applied")
|
|
67
|
+
applied += 1;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
results.push({
|
|
71
|
+
operationId: operation.id,
|
|
72
|
+
status: "failed",
|
|
73
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
planId: plan.id,
|
|
79
|
+
provider: connector.provider,
|
|
80
|
+
startedAt,
|
|
81
|
+
finishedAt: new Date().toISOString(),
|
|
82
|
+
status: runStatus(attempted, applied),
|
|
83
|
+
results,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function runStatus(attempted, applied) {
|
|
87
|
+
if (attempted === 0)
|
|
88
|
+
return "rejected";
|
|
89
|
+
if (applied === attempted)
|
|
90
|
+
return "applied";
|
|
91
|
+
if (applied > 0)
|
|
92
|
+
return "partial";
|
|
93
|
+
return "failed";
|
|
94
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type FieldMappings } from "../mappings.ts";
|
|
2
|
+
import type { GtmConnector } from "../types.ts";
|
|
3
|
+
export type HubspotConnectorOptions = {
|
|
4
|
+
/** Returns a HubSpot access token (private app token or OAuth access token). */
|
|
5
|
+
getAccessToken: () => string | Promise<string>;
|
|
6
|
+
/** Per-org canonical-to-provider field overrides. Defaults cover standard properties. */
|
|
7
|
+
fieldMappings?: FieldMappings;
|
|
8
|
+
apiBaseUrl?: string;
|
|
9
|
+
/** Injectable fetch for testing. */
|
|
10
|
+
fetchImpl?: typeof fetch;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Reference connector for HubSpot.
|
|
14
|
+
*
|
|
15
|
+
* Unlike sync pipelines that drop records they cannot fully resolve, the
|
|
16
|
+
* connector returns every record it can read — including ownerless or
|
|
17
|
+
* amountless deals — so audit rules can surface the gaps instead of hiding
|
|
18
|
+
* them.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createHubspotConnector(options: HubspotConnectorOptions): Required<GtmConnector>;
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { HUBSPOT_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, readMappedValue, } from "../mappings.js";
|
|
2
|
+
const DEFAULT_API_BASE_URL = "https://api.hubapi.com";
|
|
3
|
+
const OBJECT_PATHS = {
|
|
4
|
+
account: "companies",
|
|
5
|
+
contact: "contacts",
|
|
6
|
+
deal: "deals",
|
|
7
|
+
};
|
|
8
|
+
const MAPPING_OBJECT_TYPES = {
|
|
9
|
+
account: "accounts",
|
|
10
|
+
contact: "contacts",
|
|
11
|
+
deal: "deals",
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Reference connector for HubSpot.
|
|
15
|
+
*
|
|
16
|
+
* Unlike sync pipelines that drop records they cannot fully resolve, the
|
|
17
|
+
* connector returns every record it can read — including ownerless or
|
|
18
|
+
* amountless deals — so audit rules can surface the gaps instead of hiding
|
|
19
|
+
* them.
|
|
20
|
+
*/
|
|
21
|
+
export function createHubspotConnector(options) {
|
|
22
|
+
const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
|
|
23
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
24
|
+
const mappings = options.fieldMappings;
|
|
25
|
+
async function request(path, init = {}) {
|
|
26
|
+
const token = await options.getAccessToken();
|
|
27
|
+
const response = await fetchImpl(`${baseUrl}${path}`, {
|
|
28
|
+
...init,
|
|
29
|
+
headers: {
|
|
30
|
+
Authorization: `Bearer ${token}`,
|
|
31
|
+
"Content-Type": "application/json",
|
|
32
|
+
...(init.headers ?? {}),
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
const body = await response.text();
|
|
37
|
+
throw new Error(`HubSpot API error ${response.status}: ${body}`);
|
|
38
|
+
}
|
|
39
|
+
// DELETE and some association writes return 204 with an empty body.
|
|
40
|
+
const text = await response.text();
|
|
41
|
+
return text ? JSON.parse(text) : null;
|
|
42
|
+
}
|
|
43
|
+
async function list(path) {
|
|
44
|
+
const results = [];
|
|
45
|
+
let after;
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
do {
|
|
48
|
+
// Guard against a provider returning a repeating cursor (would loop
|
|
49
|
+
// forever): stop if the same `after` is handed back twice.
|
|
50
|
+
if (after) {
|
|
51
|
+
if (seen.has(after))
|
|
52
|
+
break;
|
|
53
|
+
seen.add(after);
|
|
54
|
+
}
|
|
55
|
+
const separator = path.includes("?") ? "&" : "?";
|
|
56
|
+
const data = await request(`${path}${after ? `${separator}after=${encodeURIComponent(after)}` : ""}`);
|
|
57
|
+
results.push(...(data.results ?? []));
|
|
58
|
+
after = data.paging?.next?.after;
|
|
59
|
+
} while (after);
|
|
60
|
+
return results;
|
|
61
|
+
}
|
|
62
|
+
async function assembleSnapshot(fetchObjects) {
|
|
63
|
+
const owners = await list("/crm/v3/owners?limit=100");
|
|
64
|
+
const users = owners
|
|
65
|
+
.filter((owner) => owner.id)
|
|
66
|
+
.map((owner) => ({
|
|
67
|
+
id: String(owner.id),
|
|
68
|
+
provider: "hubspot",
|
|
69
|
+
crmId: String(owner.id),
|
|
70
|
+
identities: [{ provider: "hubspot", externalId: String(owner.id) }],
|
|
71
|
+
name: [owner.firstName, owner.lastName].filter(Boolean).join(" ") ||
|
|
72
|
+
stringOrFallback(owner.email, `Owner ${owner.id}`),
|
|
73
|
+
email: stringOrUndefined(owner.email),
|
|
74
|
+
active: owner.archived !== true,
|
|
75
|
+
}));
|
|
76
|
+
const companyProperties = mappedFields(mappings, "accounts", HUBSPOT_DEFAULT_FIELD_MAPPINGS.accounts).join(",");
|
|
77
|
+
const companies = await fetchObjects("companies", companyProperties, false);
|
|
78
|
+
const accounts = companies
|
|
79
|
+
.filter((company) => company.id)
|
|
80
|
+
.map((company) => {
|
|
81
|
+
const props = company.properties ?? {};
|
|
82
|
+
return {
|
|
83
|
+
id: String(company.id),
|
|
84
|
+
provider: "hubspot",
|
|
85
|
+
crmId: String(company.id),
|
|
86
|
+
identities: [{ provider: "hubspot", externalId: String(company.id) }],
|
|
87
|
+
name: stringOrFallback(readMapped(props, "accounts", "name", "name"), "Unknown Company"),
|
|
88
|
+
domain: stringOrUndefined(readMapped(props, "accounts", "domain", "domain")),
|
|
89
|
+
industry: stringOrUndefined(readMapped(props, "accounts", "industry", "industry")),
|
|
90
|
+
employeeCount: numberOrUndefined(readMapped(props, "accounts", "employeeCount", "numberofemployees")),
|
|
91
|
+
annualRevenue: numberOrUndefined(readMapped(props, "accounts", "annualRevenue", "annualrevenue")),
|
|
92
|
+
ownerId: stringOrUndefined(readMapped(props, "accounts", "ownerId", "hubspot_owner_id")),
|
|
93
|
+
lastSyncAt: stringOrUndefined(company.updatedAt),
|
|
94
|
+
raw: company,
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
const contactProperties = mappedFields(mappings, "contacts", HUBSPOT_DEFAULT_FIELD_MAPPINGS.contacts).join(",");
|
|
98
|
+
const hubspotContacts = await fetchObjects("contacts", contactProperties, true);
|
|
99
|
+
const contacts = hubspotContacts
|
|
100
|
+
.filter((contact) => contact.id)
|
|
101
|
+
.map((contact) => {
|
|
102
|
+
const props = contact.properties ?? {};
|
|
103
|
+
const companyId = contact.associations?.companies?.results?.[0]?.id;
|
|
104
|
+
return {
|
|
105
|
+
id: String(contact.id),
|
|
106
|
+
provider: "hubspot",
|
|
107
|
+
crmId: String(contact.id),
|
|
108
|
+
identities: [{ provider: "hubspot", externalId: String(contact.id) }],
|
|
109
|
+
accountId: companyId ? String(companyId) : undefined,
|
|
110
|
+
firstName: stringOrUndefined(readMapped(props, "contacts", "firstName", "firstname")),
|
|
111
|
+
lastName: stringOrUndefined(readMapped(props, "contacts", "lastName", "lastname")),
|
|
112
|
+
email: stringOrUndefined(readMapped(props, "contacts", "email", "email")),
|
|
113
|
+
phone: stringOrUndefined(readMapped(props, "contacts", "phone", "phone")),
|
|
114
|
+
title: stringOrUndefined(readMapped(props, "contacts", "title", "jobtitle")),
|
|
115
|
+
ownerId: stringOrUndefined(readMapped(props, "contacts", "ownerId", "hubspot_owner_id")),
|
|
116
|
+
lastSyncAt: stringOrUndefined(contact.updatedAt),
|
|
117
|
+
raw: contact,
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
const dealProperties = mappedFields(mappings, "deals", HUBSPOT_DEFAULT_FIELD_MAPPINGS.deals).join(",");
|
|
121
|
+
const hubspotDeals = await fetchObjects("deals", dealProperties, true);
|
|
122
|
+
const deals = hubspotDeals
|
|
123
|
+
.filter((deal) => deal.id)
|
|
124
|
+
.map((deal) => {
|
|
125
|
+
const props = deal.properties ?? {};
|
|
126
|
+
const companyId = deal.associations?.companies?.results?.[0]?.id;
|
|
127
|
+
const stage = stringOrUndefined(readMapped(props, "deals", "stage", "dealstage"));
|
|
128
|
+
const normalizedStage = (stage ?? "").toLowerCase();
|
|
129
|
+
const isWon = normalizedStage.includes("closedwon");
|
|
130
|
+
const isClosed = isWon || normalizedStage.includes("closedlost");
|
|
131
|
+
const forecastCategory = isWon
|
|
132
|
+
? "closed_won"
|
|
133
|
+
: normalizedStage.includes("closedlost")
|
|
134
|
+
? "closed_lost"
|
|
135
|
+
: "pipeline";
|
|
136
|
+
const lastActivityAt = stringOrUndefined(readMapped(props, "deals", "lastActivityAt", "hs_last_sales_activity_timestamp"));
|
|
137
|
+
return {
|
|
138
|
+
id: String(deal.id),
|
|
139
|
+
provider: "hubspot",
|
|
140
|
+
crmId: String(deal.id),
|
|
141
|
+
identities: [{ provider: "hubspot", externalId: String(deal.id) }],
|
|
142
|
+
accountId: companyId ? String(companyId) : undefined,
|
|
143
|
+
ownerId: stringOrUndefined(readMapped(props, "deals", "ownerId", "hubspot_owner_id")),
|
|
144
|
+
name: stringOrFallback(readMapped(props, "deals", "name", "dealname"), "Untitled Deal"),
|
|
145
|
+
amount: numberOrUndefined(readMapped(props, "deals", "amount", "amount")),
|
|
146
|
+
stage,
|
|
147
|
+
closeDate: stringOrUndefined(readMapped(props, "deals", "closeDate", "closedate"))?.split("T")[0],
|
|
148
|
+
dealType: stringOrUndefined(readMapped(props, "deals", "dealType", "dealtype")),
|
|
149
|
+
// hs_deal_stage_probability is already a 0..1 fraction in HubSpot,
|
|
150
|
+
// so it maps straight to the canonical 0..1 unit (no /100, unlike
|
|
151
|
+
// Salesforce's 0..100 Probability field).
|
|
152
|
+
probability: numberOrUndefined(readMapped(props, "deals", "probability", "hs_deal_stage_probability")),
|
|
153
|
+
forecastCategory,
|
|
154
|
+
isClosed,
|
|
155
|
+
isWon,
|
|
156
|
+
// hs_last_sales_activity_timestamp is an ISO timestamp; keep the
|
|
157
|
+
// date portion when it looks like one, otherwise pass through (e.g.
|
|
158
|
+
// an epoch-millis string).
|
|
159
|
+
lastActivityAt: lastActivityAt?.includes("T")
|
|
160
|
+
? lastActivityAt.split("T")[0]
|
|
161
|
+
: lastActivityAt,
|
|
162
|
+
lastSyncAt: stringOrUndefined(deal.updatedAt),
|
|
163
|
+
raw: deal,
|
|
164
|
+
};
|
|
165
|
+
});
|
|
166
|
+
return {
|
|
167
|
+
generatedAt: new Date().toISOString(),
|
|
168
|
+
provider: "hubspot",
|
|
169
|
+
users,
|
|
170
|
+
accounts,
|
|
171
|
+
contacts,
|
|
172
|
+
deals,
|
|
173
|
+
// Engagement reads need additional scopes; activity staleness falls back
|
|
174
|
+
// to record sync timestamps until engagements are supported.
|
|
175
|
+
activities: [],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
async function fetchSnapshot() {
|
|
179
|
+
return assembleSnapshot((objectType, properties, withAssociations) => list(`/crm/v3/objects/${objectType}?limit=100&properties=${properties}${withAssociations ? "&associations=companies" : ""}`));
|
|
180
|
+
}
|
|
181
|
+
const MODIFIED_DATE_PROPERTIES = {
|
|
182
|
+
companies: "hs_lastmodifieddate",
|
|
183
|
+
contacts: "lastmodifieddate",
|
|
184
|
+
deals: "hs_lastmodifieddate",
|
|
185
|
+
};
|
|
186
|
+
async function searchList(objectType, properties, sinceMs) {
|
|
187
|
+
const results = [];
|
|
188
|
+
let after;
|
|
189
|
+
// HubSpot's search API hard-caps at 10,000 results (100 pages of 100) and
|
|
190
|
+
// 400s past it. Stop at the boundary rather than throwing mid-sync; an
|
|
191
|
+
// incremental window this large should fall back to a full snapshot.
|
|
192
|
+
const MAX_PAGES = 100;
|
|
193
|
+
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
194
|
+
const data = await request(`/crm/v3/objects/${objectType}/search`, {
|
|
195
|
+
method: "POST",
|
|
196
|
+
body: JSON.stringify({
|
|
197
|
+
filterGroups: [
|
|
198
|
+
{
|
|
199
|
+
filters: [
|
|
200
|
+
{
|
|
201
|
+
propertyName: MODIFIED_DATE_PROPERTIES[objectType],
|
|
202
|
+
operator: "GTE",
|
|
203
|
+
value: String(sinceMs),
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
properties: properties.split(","),
|
|
209
|
+
limit: 100,
|
|
210
|
+
...(after ? { after } : {}),
|
|
211
|
+
}),
|
|
212
|
+
});
|
|
213
|
+
results.push(...(data.results ?? []));
|
|
214
|
+
const next = data.paging?.next?.after;
|
|
215
|
+
if (!next || next === after)
|
|
216
|
+
break;
|
|
217
|
+
after = next;
|
|
218
|
+
}
|
|
219
|
+
return results;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Records modified since `sinceIso`, via the CRM search API. HubSpot search
|
|
223
|
+
* results carry no associations, so contact/deal accountIds are absent in
|
|
224
|
+
* change feeds — consumers merging deltas must preserve associations from
|
|
225
|
+
* the last full snapshot.
|
|
226
|
+
*/
|
|
227
|
+
async function fetchChanges(sinceIso) {
|
|
228
|
+
const sinceMs = Date.parse(sinceIso);
|
|
229
|
+
if (!Number.isFinite(sinceMs))
|
|
230
|
+
throw new Error(`Invalid since timestamp: ${sinceIso}`);
|
|
231
|
+
return assembleSnapshot((objectType, properties) => searchList(objectType, properties, sinceMs));
|
|
232
|
+
}
|
|
233
|
+
// HubSpot-defined association type ids from a task engagement to its parent.
|
|
234
|
+
const TASK_ASSOCIATION_TYPE_IDS = {
|
|
235
|
+
contact: 204,
|
|
236
|
+
account: 192,
|
|
237
|
+
deal: 216,
|
|
238
|
+
};
|
|
239
|
+
function humanizeField(field) {
|
|
240
|
+
return field
|
|
241
|
+
.replace(/_task$/, "")
|
|
242
|
+
.replace(/[_-]+/g, " ")
|
|
243
|
+
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
244
|
+
.trim();
|
|
245
|
+
}
|
|
246
|
+
async function setField(operation) {
|
|
247
|
+
const objectPath = OBJECT_PATHS[operation.objectType];
|
|
248
|
+
const mappingType = MAPPING_OBJECT_TYPES[operation.objectType];
|
|
249
|
+
if (!objectPath || !mappingType || !operation.field) {
|
|
250
|
+
return {
|
|
251
|
+
operationId: operation.id,
|
|
252
|
+
status: "skipped",
|
|
253
|
+
detail: "Field writes are only supported for accounts, contacts, and deals with an explicit field.",
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const defaults = HUBSPOT_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
|
|
257
|
+
const property = mappedField(mappings, mappingType, operation.field, defaults[operation.field] ?? operation.field);
|
|
258
|
+
const value = operation.operation === "clear_field" ? "" : String(operation.afterValue ?? "");
|
|
259
|
+
const response = await request(`/crm/v3/objects/${objectPath}/${encodeURIComponent(operation.objectId)}`, { method: "PATCH", body: JSON.stringify({ properties: { [property]: value } }) });
|
|
260
|
+
return {
|
|
261
|
+
operationId: operation.id,
|
|
262
|
+
status: "applied",
|
|
263
|
+
detail: `Set ${property} on ${objectPath}/${operation.objectId}.`,
|
|
264
|
+
providerData: { id: response?.id, property },
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
async function linkRecord(operation) {
|
|
268
|
+
// Associate a deal or contact with a company (the only link the built-in
|
|
269
|
+
// rules emit — missing-deal-account). afterValue is the target company id.
|
|
270
|
+
const fromPath = OBJECT_PATHS[operation.objectType];
|
|
271
|
+
if ((operation.objectType !== "deal" && operation.objectType !== "contact") || !fromPath) {
|
|
272
|
+
return {
|
|
273
|
+
operationId: operation.id,
|
|
274
|
+
status: "skipped",
|
|
275
|
+
detail: "link_record is supported for deals and contacts (to a company).",
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
const companyId = String(operation.afterValue ?? "");
|
|
279
|
+
if (!companyId) {
|
|
280
|
+
return { operationId: operation.id, status: "skipped", detail: "link_record needs a target company id." };
|
|
281
|
+
}
|
|
282
|
+
await request(`/crm/v4/objects/${fromPath}/${encodeURIComponent(operation.objectId)}/associations/default/companies/${encodeURIComponent(companyId)}`, { method: "PUT" });
|
|
283
|
+
return {
|
|
284
|
+
operationId: operation.id,
|
|
285
|
+
status: "applied",
|
|
286
|
+
detail: `Linked ${fromPath}/${operation.objectId} to company ${companyId}.`,
|
|
287
|
+
providerData: { companyId },
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
async function createTask(operation) {
|
|
291
|
+
const associationTypeId = TASK_ASSOCIATION_TYPE_IDS[operation.objectType];
|
|
292
|
+
if (associationTypeId === undefined) {
|
|
293
|
+
return {
|
|
294
|
+
operationId: operation.id,
|
|
295
|
+
status: "skipped",
|
|
296
|
+
detail: "Tasks can be attached to accounts, contacts, and deals.",
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
const subject = operation.field ? humanizeField(operation.field) : "Follow up";
|
|
300
|
+
const body = String(operation.afterValue ?? operation.reason ?? "");
|
|
301
|
+
const response = await request(`/crm/v3/objects/tasks`, {
|
|
302
|
+
method: "POST",
|
|
303
|
+
body: JSON.stringify({
|
|
304
|
+
properties: {
|
|
305
|
+
hs_task_subject: subject,
|
|
306
|
+
hs_task_body: body,
|
|
307
|
+
hs_task_status: "NOT_STARTED",
|
|
308
|
+
hs_task_priority: "MEDIUM",
|
|
309
|
+
hs_timestamp: Date.now(),
|
|
310
|
+
},
|
|
311
|
+
associations: [
|
|
312
|
+
{
|
|
313
|
+
to: { id: operation.objectId },
|
|
314
|
+
types: [
|
|
315
|
+
{ associationCategory: "HUBSPOT_DEFINED", associationTypeId },
|
|
316
|
+
],
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
}),
|
|
320
|
+
});
|
|
321
|
+
return {
|
|
322
|
+
operationId: operation.id,
|
|
323
|
+
status: "applied",
|
|
324
|
+
detail: `Created task "${subject}" on ${operation.objectType}/${operation.objectId}.`,
|
|
325
|
+
providerData: { id: response?.id },
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
async function archiveRecord(operation) {
|
|
329
|
+
const objectPath = OBJECT_PATHS[operation.objectType];
|
|
330
|
+
if (!objectPath) {
|
|
331
|
+
return {
|
|
332
|
+
operationId: operation.id,
|
|
333
|
+
status: "skipped",
|
|
334
|
+
detail: "archive_record is supported for accounts, contacts, and deals.",
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
await request(`/crm/v3/objects/${objectPath}/${encodeURIComponent(operation.objectId)}`, {
|
|
338
|
+
method: "DELETE",
|
|
339
|
+
});
|
|
340
|
+
return {
|
|
341
|
+
operationId: operation.id,
|
|
342
|
+
status: "applied",
|
|
343
|
+
detail: `Archived ${objectPath}/${operation.objectId}.`,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
async function applyOperation(operation) {
|
|
347
|
+
try {
|
|
348
|
+
switch (operation.operation) {
|
|
349
|
+
case "set_field":
|
|
350
|
+
case "clear_field":
|
|
351
|
+
return await setField(operation);
|
|
352
|
+
case "link_record":
|
|
353
|
+
return await linkRecord(operation);
|
|
354
|
+
case "create_task":
|
|
355
|
+
return await createTask(operation);
|
|
356
|
+
case "archive_record":
|
|
357
|
+
return await archiveRecord(operation);
|
|
358
|
+
default:
|
|
359
|
+
return {
|
|
360
|
+
operationId: operation.id,
|
|
361
|
+
status: "skipped",
|
|
362
|
+
detail: `Unknown operation ${operation.operation}.`,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
return {
|
|
368
|
+
operationId: operation.id,
|
|
369
|
+
status: "failed",
|
|
370
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
function readMapped(source, objectType, targetField, fallbackField) {
|
|
375
|
+
return readMappedValue(source, mappings, objectType, targetField, fallbackField);
|
|
376
|
+
}
|
|
377
|
+
async function readField(objectType, objectId, field) {
|
|
378
|
+
const objectPath = OBJECT_PATHS[objectType];
|
|
379
|
+
const mappingType = MAPPING_OBJECT_TYPES[objectType];
|
|
380
|
+
if (!objectPath || !mappingType) {
|
|
381
|
+
throw new Error(`Field reads are only supported for accounts, contacts, and deals.`);
|
|
382
|
+
}
|
|
383
|
+
const defaults = HUBSPOT_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
|
|
384
|
+
const property = mappedField(mappings, mappingType, field, defaults[field] ?? field);
|
|
385
|
+
const data = await request(`/crm/v3/objects/${objectPath}/${encodeURIComponent(objectId)}?properties=${encodeURIComponent(property)}`);
|
|
386
|
+
return data?.properties?.[property] ?? null;
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
provider: "hubspot",
|
|
390
|
+
fetchSnapshot,
|
|
391
|
+
fetchChanges,
|
|
392
|
+
applyOperation,
|
|
393
|
+
readField,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
function stringOrUndefined(value) {
|
|
397
|
+
if (value === undefined || value === null || value === "")
|
|
398
|
+
return undefined;
|
|
399
|
+
return String(value);
|
|
400
|
+
}
|
|
401
|
+
function stringOrFallback(value, fallback) {
|
|
402
|
+
return stringOrUndefined(value) ?? fallback;
|
|
403
|
+
}
|
|
404
|
+
function numberOrUndefined(value) {
|
|
405
|
+
if (value === undefined || value === null || value === "")
|
|
406
|
+
return undefined;
|
|
407
|
+
const parsed = typeof value === "number" ? value : Number.parseFloat(String(value));
|
|
408
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
409
|
+
}
|