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.
Files changed (77) hide show
  1. package/CHANGELOG.md +381 -0
  2. package/INSTALL_FOR_AGENTS.md +87 -0
  3. package/LICENSE +202 -0
  4. package/README.md +230 -0
  5. package/dist/audit.d.ts +7 -0
  6. package/dist/audit.js +202 -0
  7. package/dist/bin.d.ts +2 -0
  8. package/dist/bin.js +6 -0
  9. package/dist/cli.d.ts +38 -0
  10. package/dist/cli.js +915 -0
  11. package/dist/config.d.ts +36 -0
  12. package/dist/config.js +85 -0
  13. package/dist/connector.d.ts +30 -0
  14. package/dist/connector.js +94 -0
  15. package/dist/connectors/hubspot.d.ts +20 -0
  16. package/dist/connectors/hubspot.js +409 -0
  17. package/dist/connectors/hubspotAuth.d.ts +42 -0
  18. package/dist/connectors/hubspotAuth.js +189 -0
  19. package/dist/connectors/salesforce.d.ts +26 -0
  20. package/dist/connectors/salesforce.js +318 -0
  21. package/dist/connectors/salesforceAuth.d.ts +44 -0
  22. package/dist/connectors/salesforceAuth.js +120 -0
  23. package/dist/connectors/stripe.d.ts +27 -0
  24. package/dist/connectors/stripe.js +176 -0
  25. package/dist/credentials.d.ts +75 -0
  26. package/dist/credentials.js +197 -0
  27. package/dist/demo.d.ts +20 -0
  28. package/dist/demo.js +169 -0
  29. package/dist/diff.d.ts +46 -0
  30. package/dist/diff.js +107 -0
  31. package/dist/format.d.ts +3 -0
  32. package/dist/format.js +109 -0
  33. package/dist/index.d.ts +18 -0
  34. package/dist/index.js +17 -0
  35. package/dist/mappings.d.ts +8 -0
  36. package/dist/mappings.js +123 -0
  37. package/dist/mcp-bin.d.ts +2 -0
  38. package/dist/mcp-bin.js +33 -0
  39. package/dist/mcp.d.ts +1 -0
  40. package/dist/mcp.js +140 -0
  41. package/dist/merge.d.ts +48 -0
  42. package/dist/merge.js +145 -0
  43. package/dist/planStore.d.ts +31 -0
  44. package/dist/planStore.js +116 -0
  45. package/dist/rules.d.ts +24 -0
  46. package/dist/rules.js +512 -0
  47. package/dist/sampleData.d.ts +2 -0
  48. package/dist/sampleData.js +115 -0
  49. package/dist/types.d.ts +294 -0
  50. package/dist/types.js +8 -0
  51. package/docs/api.md +72 -0
  52. package/docs/roadmap-to-1.0.md +121 -0
  53. package/llms.txt +25 -0
  54. package/package.json +76 -0
  55. package/src/audit.ts +242 -0
  56. package/src/bin.ts +7 -0
  57. package/src/cli.ts +1042 -0
  58. package/src/config.ts +113 -0
  59. package/src/connector.ts +140 -0
  60. package/src/connectors/hubspot.ts +528 -0
  61. package/src/connectors/hubspotAuth.ts +246 -0
  62. package/src/connectors/salesforce.ts +420 -0
  63. package/src/connectors/salesforceAuth.ts +167 -0
  64. package/src/connectors/stripe.ts +215 -0
  65. package/src/credentials.ts +282 -0
  66. package/src/demo.ts +200 -0
  67. package/src/diff.ts +158 -0
  68. package/src/format.ts +162 -0
  69. package/src/index.ts +129 -0
  70. package/src/mappings.ts +157 -0
  71. package/src/mcp-bin.ts +32 -0
  72. package/src/mcp.ts +185 -0
  73. package/src/merge.ts +235 -0
  74. package/src/planStore.ts +155 -0
  75. package/src/rules.ts +539 -0
  76. package/src/sampleData.ts +117 -0
  77. package/src/types.ts +372 -0
@@ -0,0 +1,246 @@
1
+ import { createServer } from "node:http";
2
+ import { randomBytes } from "node:crypto";
3
+
4
+ /**
5
+ * HubSpot CLI authentication.
6
+ *
7
+ * Two paths, ordered by how little web they need:
8
+ * 1. Private app token — no OAuth at all; created once in HubSpot settings.
9
+ * 2. Bring-your-own-app OAuth with an RFC 8252 loopback redirect — the
10
+ * browser is used for exactly one thing (the consent grant); the CLI
11
+ * captures the code on 127.0.0.1 and performs the exchange itself.
12
+ *
13
+ * HubSpot does not support the device-authorization grant or PKCE-only
14
+ * public clients, so the loopback flow requires the user's own app client
15
+ * id/secret, which are kept locally for silent refresh.
16
+ */
17
+
18
+ const HS_AUTH_URL = "https://app.hubspot.com/oauth/authorize";
19
+ const HS_TOKEN_URL = "https://api.hubapi.com/oauth/v1/token";
20
+ const HS_API_URL = "https://api.hubapi.com";
21
+
22
+ export const DEFAULT_OAUTH_SCOPES = [
23
+ "crm.objects.deals.read",
24
+ "crm.objects.owners.read",
25
+ "crm.objects.companies.read",
26
+ "crm.objects.contacts.read",
27
+ "crm.objects.deals.write",
28
+ ];
29
+
30
+ export const DEFAULT_LOOPBACK_PORT = 8763;
31
+
32
+ /**
33
+ * OAuth error responses can echo request parameters (client_secret, code).
34
+ * Surface only the standard `error`/`error_description` fields, never the raw
35
+ * body, so secrets never reach logs.
36
+ */
37
+ async function safeTokenError(response: Response): Promise<string> {
38
+ let description: string | undefined;
39
+ try {
40
+ const data = await response.json();
41
+ description = data?.error_description ?? data?.error ?? data?.message;
42
+ } catch {
43
+ // Non-JSON body — withhold it entirely.
44
+ }
45
+ return description
46
+ ? `${response.status} (${String(description).slice(0, 200)})`
47
+ : `HTTP ${response.status} ${response.statusText}`.trim();
48
+ }
49
+
50
+ export type HubspotTokenSet = {
51
+ accessToken: string;
52
+ refreshToken?: string;
53
+ expiresAt: number;
54
+ };
55
+
56
+ export async function validateHubspotToken(
57
+ token: string,
58
+ fetchImpl: typeof fetch = fetch,
59
+ ): Promise<{ ok: boolean; detail: string }> {
60
+ const response = await fetchImpl(`${HS_API_URL}/crm/v3/owners?limit=1`, {
61
+ headers: { Authorization: `Bearer ${token}` },
62
+ });
63
+ if (response.ok) {
64
+ return { ok: true, detail: "Token accepted by the HubSpot CRM API." };
65
+ }
66
+ const body = await response.text();
67
+ return { ok: false, detail: `HubSpot rejected the token (${response.status}): ${body}` };
68
+ }
69
+
70
+ async function tokenRequest(
71
+ params: Record<string, string>,
72
+ fetchImpl: typeof fetch,
73
+ ): Promise<HubspotTokenSet> {
74
+ const response = await fetchImpl(HS_TOKEN_URL, {
75
+ method: "POST",
76
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
77
+ body: new URLSearchParams(params).toString(),
78
+ });
79
+ if (!response.ok) {
80
+ throw new Error(`HubSpot token request failed: ${await safeTokenError(response)}`);
81
+ }
82
+ const data = await response.json();
83
+ if (!data.access_token) throw new Error("HubSpot token response had no access_token.");
84
+ return {
85
+ accessToken: data.access_token,
86
+ refreshToken: data.refresh_token,
87
+ expiresAt: Date.now() + (data.expires_in ?? 1800) * 1000,
88
+ };
89
+ }
90
+
91
+ export async function exchangeHubspotCode(options: {
92
+ clientId: string;
93
+ clientSecret: string;
94
+ redirectUri: string;
95
+ code: string;
96
+ fetchImpl?: typeof fetch;
97
+ }): Promise<HubspotTokenSet> {
98
+ return tokenRequest(
99
+ {
100
+ grant_type: "authorization_code",
101
+ client_id: options.clientId,
102
+ client_secret: options.clientSecret,
103
+ redirect_uri: options.redirectUri,
104
+ code: options.code,
105
+ },
106
+ options.fetchImpl ?? fetch,
107
+ );
108
+ }
109
+
110
+ export async function refreshHubspotToken(options: {
111
+ clientId: string;
112
+ clientSecret: string;
113
+ refreshToken: string;
114
+ fetchImpl?: typeof fetch;
115
+ }): Promise<HubspotTokenSet> {
116
+ return tokenRequest(
117
+ {
118
+ grant_type: "refresh_token",
119
+ client_id: options.clientId,
120
+ client_secret: options.clientSecret,
121
+ refresh_token: options.refreshToken,
122
+ },
123
+ options.fetchImpl ?? fetch,
124
+ );
125
+ }
126
+
127
+ export type LoopbackLoginOptions = {
128
+ clientId: string;
129
+ clientSecret: string;
130
+ /** Must match a redirect URL registered on the HubSpot app: http://localhost:<port>/callback */
131
+ port?: number;
132
+ scopes?: string[];
133
+ timeoutMs?: number;
134
+ fetchImpl?: typeof fetch;
135
+ /** Receives the authorize URL; default prints it and best-effort opens a browser. */
136
+ openUrl?: (url: string) => void | Promise<void>;
137
+ log?: (message: string) => void;
138
+ };
139
+
140
+ /**
141
+ * RFC 8252 loopback OAuth: serve one request on 127.0.0.1, send the user to
142
+ * the provider consent page, capture the code locally, exchange it directly.
143
+ */
144
+ export async function runHubspotLoopbackLogin(
145
+ options: LoopbackLoginOptions,
146
+ ): Promise<HubspotTokenSet> {
147
+ const port = options.port ?? DEFAULT_LOOPBACK_PORT;
148
+ const scopes = options.scopes ?? DEFAULT_OAUTH_SCOPES;
149
+ const log = options.log ?? ((message: string) => console.error(message));
150
+ const redirectUri = `http://localhost:${port}/callback`;
151
+ const state = randomBytes(16).toString("hex");
152
+
153
+ const code = await new Promise<string>((resolve, reject) => {
154
+ const server = createServer((request, response) => {
155
+ const url = new URL(request.url ?? "/", `http://localhost:${port}`);
156
+ if (url.pathname !== "/callback") {
157
+ response.writeHead(404).end();
158
+ return;
159
+ }
160
+ const receivedState = url.searchParams.get("state");
161
+ const receivedCode = url.searchParams.get("code");
162
+ const error = url.searchParams.get("error");
163
+ response.writeHead(200, { "Content-Type": "text/html" });
164
+ response.end(
165
+ "<html><body><p>FullStackGTM CLI login complete. You can close this tab.</p></body></html>",
166
+ );
167
+ server.close();
168
+ clearTimeout(timer);
169
+ if (error) {
170
+ reject(new Error(`HubSpot authorization failed: ${error}`));
171
+ } else if (receivedState !== state) {
172
+ reject(new Error("OAuth state mismatch; aborting login."));
173
+ } else if (!receivedCode) {
174
+ reject(new Error("HubSpot redirected without an authorization code."));
175
+ } else {
176
+ resolve(receivedCode);
177
+ }
178
+ });
179
+
180
+ const timer = setTimeout(() => {
181
+ server.close();
182
+ reject(new Error("Timed out waiting for the OAuth redirect."));
183
+ }, options.timeoutMs ?? 5 * 60 * 1000);
184
+
185
+ server.on("error", (error) => {
186
+ clearTimeout(timer);
187
+ reject(error);
188
+ });
189
+
190
+ server.listen(port, "127.0.0.1", () => {
191
+ const authorizeUrl =
192
+ `${HS_AUTH_URL}?` +
193
+ new URLSearchParams({
194
+ response_type: "code",
195
+ client_id: options.clientId,
196
+ redirect_uri: redirectUri,
197
+ scope: scopes.join(" "),
198
+ state,
199
+ }).toString();
200
+ log(`Open this URL to authorize (waiting on ${redirectUri}):\n\n ${authorizeUrl}\n`);
201
+ void (options.openUrl ?? openInBrowser)(authorizeUrl);
202
+ });
203
+ });
204
+
205
+ return exchangeHubspotCode({
206
+ clientId: options.clientId,
207
+ clientSecret: options.clientSecret,
208
+ redirectUri,
209
+ code,
210
+ fetchImpl: options.fetchImpl,
211
+ });
212
+ }
213
+
214
+ export async function openInBrowser(url: string) {
215
+ // The URL may come from an external source (e.g. a broker deployment's
216
+ // verification URL). Only ever hand a well-formed http(s) URL to the OS
217
+ // opener — this prevents a leading `-` from being read as a flag by
218
+ // xdg-open and refuses non-web schemes outright.
219
+ let parsed: URL;
220
+ try {
221
+ parsed = new URL(url);
222
+ } catch {
223
+ return;
224
+ }
225
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return;
226
+ const safeUrl = parsed.toString();
227
+
228
+ const { spawn } = await import("node:child_process");
229
+ try {
230
+ if (process.platform === "darwin") {
231
+ spawn("open", [safeUrl], { stdio: "ignore", detached: true }).on("error", () => {});
232
+ } else if (process.platform === "win32") {
233
+ // `start` is a cmd builtin, not an executable; the empty "" is the
234
+ // window title so a URL with characters isn't mis-parsed as the title.
235
+ spawn("cmd", ["/c", "start", "", safeUrl], { stdio: "ignore", detached: true }).on(
236
+ "error",
237
+ () => {},
238
+ );
239
+ } else {
240
+ // `--` ends option parsing so the URL is never treated as a flag.
241
+ spawn("xdg-open", ["--", safeUrl], { stdio: "ignore", detached: true }).on("error", () => {});
242
+ }
243
+ } catch {
244
+ // Printing the URL is the contract; opening a browser is best-effort.
245
+ }
246
+ }
@@ -0,0 +1,420 @@
1
+ import {
2
+ SALESFORCE_DEFAULT_FIELD_MAPPINGS,
3
+ mappedField,
4
+ mappedFields,
5
+ readMappedValue,
6
+ type CrmObjectType,
7
+ type FieldMappings,
8
+ } from "../mappings.ts";
9
+ import type {
10
+ CanonicalAccount,
11
+ CanonicalContact,
12
+ CanonicalDeal,
13
+ CanonicalGtmSnapshot,
14
+ CanonicalUser,
15
+ GtmConnector,
16
+ GtmObjectType,
17
+ PatchOperation,
18
+ PatchOperationResult,
19
+ } from "../types.ts";
20
+
21
+ const DEFAULT_API_VERSION = "v59.0";
22
+
23
+ export type SalesforceConnection = {
24
+ accessToken: string;
25
+ /** e.g. https://yourorg.my.salesforce.com */
26
+ instanceUrl: string;
27
+ };
28
+
29
+ export type SalesforceConnectorOptions = {
30
+ /** Returns an access token plus the instance URL it belongs to. */
31
+ getConnection: () => SalesforceConnection | Promise<SalesforceConnection>;
32
+ /** Per-org canonical-to-provider field overrides. Defaults cover standard fields. */
33
+ fieldMappings?: FieldMappings;
34
+ apiVersion?: string;
35
+ /** Injectable fetch for testing. */
36
+ fetchImpl?: typeof fetch;
37
+ };
38
+
39
+ const SOBJECT_TYPES: Partial<Record<GtmObjectType, string>> = {
40
+ account: "Account",
41
+ contact: "Contact",
42
+ deal: "Opportunity",
43
+ };
44
+
45
+ const MAPPING_OBJECT_TYPES: Partial<Record<GtmObjectType, Exclude<CrmObjectType, "owners">>> = {
46
+ account: "accounts",
47
+ contact: "contacts",
48
+ deal: "deals",
49
+ };
50
+
51
+ /**
52
+ * Reference connector for Salesforce.
53
+ *
54
+ * Reads run SOQL with cursor pagination; writes PATCH sobjects directly.
55
+ * Like the HubSpot connector, it never drops records it cannot fully resolve
56
+ * (ownerless or amountless opportunities are returned so audit rules can
57
+ * surface the gaps). Probabilities are normalized to 0..1 to match the
58
+ * canonical model.
59
+ */
60
+ export function createSalesforceConnector(
61
+ options: SalesforceConnectorOptions,
62
+ ): Required<GtmConnector> {
63
+ const apiVersion = options.apiVersion ?? DEFAULT_API_VERSION;
64
+ const fetchImpl = options.fetchImpl ?? fetch;
65
+ const mappings = options.fieldMappings;
66
+
67
+ async function request(path: string, init: RequestInit = {}): Promise<any> {
68
+ const connection = await options.getConnection();
69
+ const url = path.startsWith("http")
70
+ ? path
71
+ : `${connection.instanceUrl.replace(/\/$/, "")}${path}`;
72
+ const response = await fetchImpl(url, {
73
+ ...init,
74
+ headers: {
75
+ Authorization: `Bearer ${connection.accessToken}`,
76
+ "Content-Type": "application/json",
77
+ ...(init.headers ?? {}),
78
+ },
79
+ });
80
+ if (!response.ok) {
81
+ const body = await response.text();
82
+ throw new Error(`Salesforce API error ${response.status}: ${body}`);
83
+ }
84
+ // Salesforce PATCH returns 204 No Content on success.
85
+ const text = await response.text();
86
+ return text ? JSON.parse(text) : null;
87
+ }
88
+
89
+ async function query(soql: string): Promise<any[]> {
90
+ const records: any[] = [];
91
+ let next: string | undefined = `/services/data/${apiVersion}/query?q=${encodeURIComponent(soql)}`;
92
+ const seen = new Set<string>();
93
+ while (next) {
94
+ // Defend against a repeated nextRecordsUrl (would loop forever).
95
+ if (seen.has(next)) break;
96
+ seen.add(next);
97
+ const data = await request(next);
98
+ records.push(...(data?.records ?? []));
99
+ next = data?.nextRecordsUrl ?? undefined;
100
+ }
101
+ return records;
102
+ }
103
+
104
+ function readMapped(
105
+ source: Record<string, unknown>,
106
+ objectType: CrmObjectType,
107
+ targetField: string,
108
+ fallbackField: string,
109
+ ) {
110
+ return readMappedValue(source, mappings, objectType, targetField, fallbackField);
111
+ }
112
+
113
+ function selectFields(objectType: CrmObjectType) {
114
+ return mappedFields(
115
+ mappings,
116
+ objectType,
117
+ SALESFORCE_DEFAULT_FIELD_MAPPINGS[objectType],
118
+ ).join(", ");
119
+ }
120
+
121
+ async function assembleSnapshot(whereClause: string): Promise<CanonicalGtmSnapshot> {
122
+ const sfUsers = await query(`SELECT ${selectFields("owners")} FROM User${whereClause}`);
123
+ const users: CanonicalUser[] = sfUsers.map((user) => {
124
+ const id = String(readMapped(user, "owners", "id", "Id"));
125
+ const email = stringOrUndefined(readMapped(user, "owners", "email", "Email"));
126
+ return {
127
+ id,
128
+ provider: "salesforce",
129
+ crmId: id,
130
+ identities: [{ provider: "salesforce", externalId: id }],
131
+ name: stringOrFallback(readMapped(user, "owners", "name", "Name"), email ?? `User ${id}`),
132
+ email,
133
+ title: stringOrUndefined(readMapped(user, "owners", "title", "Title")),
134
+ active: Boolean(readMapped(user, "owners", "isActive", "IsActive")),
135
+ };
136
+ });
137
+
138
+ const sfAccounts = await query(
139
+ `SELECT ${selectFields("accounts")} FROM Account${whereClause}`,
140
+ );
141
+ const accounts: CanonicalAccount[] = sfAccounts.map((account) => {
142
+ const id = String(readMapped(account, "accounts", "id", "Id"));
143
+ return {
144
+ id,
145
+ provider: "salesforce",
146
+ crmId: id,
147
+ identities: [{ provider: "salesforce", externalId: id }],
148
+ name: stringOrFallback(readMapped(account, "accounts", "name", "Name"), "Unknown Account"),
149
+ domain: stringOrUndefined(readMapped(account, "accounts", "domain", "Website")),
150
+ industry: stringOrUndefined(readMapped(account, "accounts", "industry", "Industry")),
151
+ employeeCount: numberOrUndefined(
152
+ readMapped(account, "accounts", "employeeCount", "NumberOfEmployees"),
153
+ ),
154
+ annualRevenue: numberOrUndefined(
155
+ readMapped(account, "accounts", "annualRevenue", "AnnualRevenue"),
156
+ ),
157
+ ownerId: stringOrUndefined(readMapped(account, "accounts", "ownerId", "OwnerId")),
158
+ raw: account,
159
+ };
160
+ });
161
+
162
+ const sfContacts = await query(
163
+ `SELECT ${selectFields("contacts")} FROM Contact${whereClause}`,
164
+ );
165
+ const contacts: CanonicalContact[] = sfContacts.map((contact) => {
166
+ const id = String(readMapped(contact, "contacts", "id", "Id"));
167
+ return {
168
+ id,
169
+ provider: "salesforce",
170
+ crmId: id,
171
+ identities: [{ provider: "salesforce", externalId: id }],
172
+ accountId: stringOrUndefined(readMapped(contact, "contacts", "accountId", "AccountId")),
173
+ firstName: stringOrUndefined(readMapped(contact, "contacts", "firstName", "FirstName")),
174
+ lastName: stringOrUndefined(readMapped(contact, "contacts", "lastName", "LastName")),
175
+ email: stringOrUndefined(readMapped(contact, "contacts", "email", "Email")),
176
+ phone: stringOrUndefined(readMapped(contact, "contacts", "phone", "Phone")),
177
+ title: stringOrUndefined(readMapped(contact, "contacts", "title", "Title")),
178
+ ownerId: stringOrUndefined(readMapped(contact, "contacts", "ownerId", "OwnerId")),
179
+ raw: contact,
180
+ };
181
+ });
182
+
183
+ const sfOpportunities = await query(
184
+ `SELECT ${selectFields("deals")} FROM Opportunity${whereClause}`,
185
+ );
186
+ const deals: CanonicalDeal[] = sfOpportunities.map((opportunity) => {
187
+ const id = String(readMapped(opportunity, "deals", "id", "Id"));
188
+ const probability = numberOrUndefined(
189
+ readMapped(opportunity, "deals", "probability", "Probability"),
190
+ );
191
+ const isClosed = Boolean(readMapped(opportunity, "deals", "isClosed", "IsClosed"));
192
+ const isWon = Boolean(readMapped(opportunity, "deals", "isWon", "IsWon"));
193
+ const forecastCategoryName = stringOrUndefined(
194
+ readMapped(opportunity, "deals", "forecastCategoryName", "ForecastCategory"),
195
+ );
196
+ const forecastCategory = isWon
197
+ ? "closed_won"
198
+ : isClosed
199
+ ? "closed_lost"
200
+ : // Map Salesforce's native ForecastCategory (e.g. "BestCase",
201
+ // "Commit", "Pipeline") to the canonical snake_case form, falling
202
+ // back to "pipeline" when absent.
203
+ (forecastCategoryName
204
+ ? forecastCategoryName
205
+ .replace(/([a-z])([A-Z])/g, "$1_$2")
206
+ .toLowerCase()
207
+ : "pipeline");
208
+ return {
209
+ id,
210
+ provider: "salesforce",
211
+ crmId: id,
212
+ identities: [{ provider: "salesforce", externalId: id }],
213
+ accountId: stringOrUndefined(readMapped(opportunity, "deals", "accountId", "AccountId")),
214
+ ownerId: stringOrUndefined(readMapped(opportunity, "deals", "ownerId", "OwnerId")),
215
+ name: stringOrFallback(readMapped(opportunity, "deals", "name", "Name"), "Untitled Opportunity"),
216
+ amount: numberOrUndefined(readMapped(opportunity, "deals", "amount", "Amount")),
217
+ stage: stringOrUndefined(readMapped(opportunity, "deals", "stage", "StageName")),
218
+ closeDate: stringOrUndefined(
219
+ readMapped(opportunity, "deals", "closeDate", "CloseDate"),
220
+ )?.split("T")[0],
221
+ dealType: stringOrUndefined(readMapped(opportunity, "deals", "dealType", "Type")),
222
+ forecastCategory,
223
+ // Salesforce probabilities are 0..100; canonical is 0..1.
224
+ probability: probability === undefined ? undefined : probability / 100,
225
+ isClosed,
226
+ isWon,
227
+ lastActivityAt: stringOrUndefined(
228
+ readMapped(opportunity, "deals", "lastActivityAt", "LastActivityDate"),
229
+ ),
230
+ raw: opportunity,
231
+ };
232
+ });
233
+
234
+ return {
235
+ generatedAt: new Date().toISOString(),
236
+ provider: "salesforce",
237
+ users,
238
+ accounts,
239
+ contacts,
240
+ deals,
241
+ // Task/Event reads come with engagement support; staleness falls back
242
+ // to close dates until then.
243
+ activities: [],
244
+ };
245
+ }
246
+
247
+ async function fetchSnapshot(): Promise<CanonicalGtmSnapshot> {
248
+ return assembleSnapshot("");
249
+ }
250
+
251
+ /** Records modified since `sinceIso`, filtered on `SystemModstamp`. */
252
+ async function fetchChanges(sinceIso: string): Promise<CanonicalGtmSnapshot> {
253
+ const sinceMs = Date.parse(sinceIso);
254
+ if (!Number.isFinite(sinceMs)) throw new Error(`Invalid since timestamp: ${sinceIso}`);
255
+ return assembleSnapshot(` WHERE SystemModstamp >= ${new Date(sinceMs).toISOString()}`);
256
+ }
257
+
258
+ function humanizeField(field: string): string {
259
+ return field
260
+ .replace(/_task$/, "")
261
+ .replace(/[_-]+/g, " ")
262
+ .replace(/\b\w/g, (c) => c.toUpperCase())
263
+ .trim();
264
+ }
265
+
266
+ async function setField(operation: PatchOperation): Promise<PatchOperationResult> {
267
+ const sobjectType = SOBJECT_TYPES[operation.objectType];
268
+ const mappingType = MAPPING_OBJECT_TYPES[operation.objectType];
269
+ if (!sobjectType || !mappingType || !operation.field) {
270
+ return {
271
+ operationId: operation.id,
272
+ status: "skipped",
273
+ detail: "Field writes are only supported for accounts, contacts, and deals with an explicit field.",
274
+ };
275
+ }
276
+ const defaults = SALESFORCE_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
277
+ const field = mappedField(
278
+ mappings,
279
+ mappingType,
280
+ operation.field,
281
+ defaults[operation.field] ?? operation.field,
282
+ );
283
+ const value =
284
+ operation.operation === "clear_field" ? null : String(operation.afterValue ?? "");
285
+ await request(
286
+ `/services/data/${apiVersion}/sobjects/${sobjectType}/${encodeURIComponent(operation.objectId)}`,
287
+ { method: "PATCH", body: JSON.stringify({ [field]: value }) },
288
+ );
289
+ return {
290
+ operationId: operation.id,
291
+ status: "applied",
292
+ detail: `Set ${field} on ${sobjectType}/${operation.objectId}.`,
293
+ providerData: { sobjectType, field },
294
+ };
295
+ }
296
+
297
+ async function createTask(operation: PatchOperation): Promise<PatchOperationResult> {
298
+ // Salesforce activities relate via WhatId (account/opportunity) or WhoId
299
+ // (contact). Subject is required.
300
+ const reference =
301
+ operation.objectType === "contact"
302
+ ? { WhoId: operation.objectId }
303
+ : operation.objectType === "account" || operation.objectType === "deal"
304
+ ? { WhatId: operation.objectId }
305
+ : null;
306
+ if (!reference) {
307
+ return {
308
+ operationId: operation.id,
309
+ status: "skipped",
310
+ detail: "Tasks can be attached to accounts, contacts, and deals.",
311
+ };
312
+ }
313
+ const response = await request(`/services/data/${apiVersion}/sobjects/Task`, {
314
+ method: "POST",
315
+ body: JSON.stringify({
316
+ Subject: operation.field ? humanizeField(operation.field) : "Follow up",
317
+ Description: String(operation.afterValue ?? operation.reason ?? ""),
318
+ Status: "Not Started",
319
+ Priority: "Normal",
320
+ ...reference,
321
+ }),
322
+ });
323
+ return {
324
+ operationId: operation.id,
325
+ status: "applied",
326
+ detail: `Created task on ${operation.objectType}/${operation.objectId}.`,
327
+ providerData: { id: (response as { id?: string })?.id },
328
+ };
329
+ }
330
+
331
+ async function archiveRecord(operation: PatchOperation): Promise<PatchOperationResult> {
332
+ const sobjectType = SOBJECT_TYPES[operation.objectType];
333
+ if (!sobjectType) {
334
+ return {
335
+ operationId: operation.id,
336
+ status: "skipped",
337
+ detail: "archive_record is supported for accounts, contacts, and deals.",
338
+ };
339
+ }
340
+ await request(
341
+ `/services/data/${apiVersion}/sobjects/${sobjectType}/${encodeURIComponent(operation.objectId)}`,
342
+ { method: "DELETE" },
343
+ );
344
+ return {
345
+ operationId: operation.id,
346
+ status: "applied",
347
+ detail: `Deleted ${sobjectType}/${operation.objectId}.`,
348
+ };
349
+ }
350
+
351
+ async function applyOperation(operation: PatchOperation): Promise<PatchOperationResult> {
352
+ try {
353
+ switch (operation.operation) {
354
+ case "set_field":
355
+ case "clear_field":
356
+ // link_record on a deal is just setting AccountId in Salesforce.
357
+ return await setField(operation);
358
+ case "link_record":
359
+ return await setField({ ...operation, operation: "set_field" });
360
+ case "create_task":
361
+ return await createTask(operation);
362
+ case "archive_record":
363
+ return await archiveRecord(operation);
364
+ default:
365
+ return {
366
+ operationId: operation.id,
367
+ status: "skipped",
368
+ detail: `Unknown operation ${operation.operation}.`,
369
+ };
370
+ }
371
+ } catch (error) {
372
+ return {
373
+ operationId: operation.id,
374
+ status: "failed",
375
+ detail: error instanceof Error ? error.message : String(error),
376
+ };
377
+ }
378
+ }
379
+
380
+ async function readField(
381
+ objectType: GtmObjectType,
382
+ objectId: string,
383
+ field: string,
384
+ ): Promise<unknown> {
385
+ const sobjectType = SOBJECT_TYPES[objectType];
386
+ const mappingType = MAPPING_OBJECT_TYPES[objectType];
387
+ if (!sobjectType || !mappingType) {
388
+ throw new Error(`Field reads are only supported for accounts, contacts, and deals.`);
389
+ }
390
+ const defaults = SALESFORCE_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
391
+ const sfField = mappedField(mappings, mappingType, field, defaults[field] ?? field);
392
+ const data = await request(
393
+ `/services/data/${apiVersion}/sobjects/${sobjectType}/${encodeURIComponent(objectId)}?fields=${encodeURIComponent(sfField)}`,
394
+ );
395
+ return data?.[sfField] ?? null;
396
+ }
397
+
398
+ return {
399
+ provider: "salesforce",
400
+ fetchSnapshot,
401
+ fetchChanges,
402
+ applyOperation,
403
+ readField,
404
+ };
405
+ }
406
+
407
+ function stringOrUndefined(value: unknown): string | undefined {
408
+ if (value === undefined || value === null || value === "") return undefined;
409
+ return String(value);
410
+ }
411
+
412
+ function stringOrFallback(value: unknown, fallback: string): string {
413
+ return stringOrUndefined(value) ?? fallback;
414
+ }
415
+
416
+ function numberOrUndefined(value: unknown): number | undefined {
417
+ if (value === undefined || value === null || value === "") return undefined;
418
+ const parsed = typeof value === "number" ? value : Number.parseFloat(String(value));
419
+ return Number.isFinite(parsed) ? parsed : undefined;
420
+ }