@getvitops/utils 8.1.0 → 8.2.0-alpha.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 +264 -8
- package/dist/{adc-BFrzpMHq.d.mts → adc-BoEoogyo.d.mts} +2 -3
- package/dist/adc-BoEoogyo.d.mts.map +1 -0
- package/dist/ads/index.d.mts +50 -17
- package/dist/ads/index.d.mts.map +1 -1
- package/dist/ads/index.mjs +75 -7
- package/dist/ads/index.mjs.map +1 -1
- package/dist/cloudflare-BiK61Oq4.d.mts.map +1 -1
- package/dist/color/index.d.mts +34 -35
- package/dist/color/index.d.mts.map +1 -1
- package/dist/color/index.mjs +3 -1
- package/dist/color/index.mjs.map +1 -1
- package/dist/crm/index.d.mts +288 -0
- package/dist/crm/index.d.mts.map +1 -0
- package/dist/crm/index.mjs +731 -0
- package/dist/crm/index.mjs.map +1 -0
- package/dist/email/index.d.mts +216 -0
- package/dist/email/index.d.mts.map +1 -0
- package/dist/email/index.mjs +331 -0
- package/dist/email/index.mjs.map +1 -0
- package/dist/favicon.d.mts +8 -9
- package/dist/favicon.d.mts.map +1 -1
- package/dist/git/index.d.mts +65 -0
- package/dist/git/index.d.mts.map +1 -0
- package/dist/git/index.mjs +107 -0
- package/dist/git/index.mjs.map +1 -0
- package/dist/hosting/index.d.mts +29 -29
- package/dist/hosting/index.d.mts.map +1 -1
- package/dist/hosting/index.mjs +7 -4
- package/dist/hosting/index.mjs.map +1 -1
- package/dist/identity-BoRddTx3.mjs +35 -0
- package/dist/identity-BoRddTx3.mjs.map +1 -0
- package/dist/index.d.mts +24 -25
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +5 -5
- package/dist/index.mjs.map +1 -1
- package/dist/indexing/index.d.mts +23 -24
- package/dist/indexing/index.d.mts.map +1 -1
- package/dist/media/index.d.mts +24 -24
- package/dist/media/index.d.mts.map +1 -1
- package/dist/media/index.mjs +3 -3
- package/dist/media/index.mjs.map +1 -1
- package/dist/notify/index.d.mts +26 -15
- package/dist/notify/index.d.mts.map +1 -1
- package/dist/notify/index.mjs +50 -20
- package/dist/notify/index.mjs.map +1 -1
- package/dist/onboarding/index.d.mts +10 -141
- package/dist/onboarding/index.d.mts.map +1 -1
- package/dist/onboarding/index.mjs +1 -144
- package/dist/onboarding/index.mjs.map +1 -1
- package/dist/plan-BLKikoOG.mjs +150 -0
- package/dist/plan-BLKikoOG.mjs.map +1 -0
- package/dist/plan-Hq8Pb0Uw.d.mts +133 -0
- package/dist/plan-Hq8Pb0Uw.d.mts.map +1 -0
- package/dist/reviews/index.d.mts +12 -13
- package/dist/reviews/index.d.mts.map +1 -1
- package/dist/schema/index.d.mts +7 -7
- package/dist/schema/index.d.mts.map +1 -1
- package/dist/security/index.d.mts +2 -2
- package/dist/security/index.d.mts.map +1 -1
- package/dist/{token-Bz_-igw-.d.mts → token-DmmoQ7um.d.mts} +6 -3
- package/dist/token-DmmoQ7um.d.mts.map +1 -0
- package/dist/tracking/index.d.mts +43 -17
- package/dist/tracking/index.d.mts.map +1 -1
- package/dist/tracking/index.mjs +2 -1
- package/dist/tracking/index.mjs.map +1 -1
- package/dist/{types-CHHjzRrv.d.mts → types-7ELci8RQ.d.mts} +4 -2
- package/dist/types-7ELci8RQ.d.mts.map +1 -0
- package/package.json +14 -4
- package/dist/adc-BFrzpMHq.d.mts.map +0 -1
- package/dist/token-Bz_-igw-.d.mts.map +0 -1
- package/dist/types-CHHjzRrv.d.mts.map +0 -1
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
import { t as identifySubmitter } from "../identity-BoRddTx3.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/crm/map.ts
|
|
4
|
+
/**
|
|
5
|
+
* Turning one submission into a provider-neutral `CrmRecord`.
|
|
6
|
+
*
|
|
7
|
+
* Pure, and separate from both executors for the reason `notify/render.ts` is
|
|
8
|
+
* separate from `email.ts`: shaping the data is where the two subtle rules
|
|
9
|
+
* that must not regress live, and pinning them here means a test can assert
|
|
10
|
+
* them without a network.
|
|
11
|
+
*
|
|
12
|
+
* `fieldMap`/`trackingFields` are drop-if-unmapped, not guess-if-unmapped — an
|
|
13
|
+
* unmapped field silently disappearing from the CRM record is the same shape
|
|
14
|
+
* of decision `describeEvent()` makes for `OMIT_FIELDS`: an absent mapping is
|
|
15
|
+
* a stated design choice (the operator hasn't decided this field matters to
|
|
16
|
+
* the CRM yet), never an error.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Bigin requires `Last_Name`; a form that only asked for a full name or an
|
|
20
|
+
* email needs one derived rather than 400ing on insert.
|
|
21
|
+
*
|
|
22
|
+
* The fallback chain, in order: an explicit last name field → the last space-
|
|
23
|
+
* separated token of a full name → the local part of the email address →
|
|
24
|
+
* refuse, with a stated reason, rather than fabricate a name from nothing.
|
|
25
|
+
* `map.ts` never emits an empty `lastName` for Bigin — `deliverToCrm` reads
|
|
26
|
+
* the refusal and skips the write instead of letting the CRM reject it.
|
|
27
|
+
*/
|
|
28
|
+
function deriveLastName(identity) {
|
|
29
|
+
if (identity.lastName) return { lastName: identity.lastName };
|
|
30
|
+
const nameParts = identity.fullName.trim().split(/\s+/);
|
|
31
|
+
if (nameParts.length > 1) return { lastName: nameParts[nameParts.length - 1] };
|
|
32
|
+
if (identity.email) {
|
|
33
|
+
const local = identity.email.split("@")[0];
|
|
34
|
+
if (local) return { lastName: local };
|
|
35
|
+
}
|
|
36
|
+
return { refusal: "Could not derive a last name from the submission (no last-name field, no space in the full name, and no email to fall back to)." };
|
|
37
|
+
}
|
|
38
|
+
function mapExtra(formData, fieldMap) {
|
|
39
|
+
const out = {};
|
|
40
|
+
if (!fieldMap) return out;
|
|
41
|
+
for (const [field, property] of Object.entries(fieldMap)) {
|
|
42
|
+
const value = formData[field];
|
|
43
|
+
if (value) out[property] = value;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
function mapAttribution(event, trackingFields) {
|
|
48
|
+
const out = {};
|
|
49
|
+
if (!event.tracking || !trackingFields) return out;
|
|
50
|
+
for (const [key, property] of Object.entries(trackingFields)) {
|
|
51
|
+
const value = event.tracking[key];
|
|
52
|
+
if (value !== void 0 && value !== "") out[property] = String(value);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
function mapSubmission(event, config) {
|
|
57
|
+
const formData = event.formData ?? {};
|
|
58
|
+
const identity = identifySubmitter(formData);
|
|
59
|
+
return {
|
|
60
|
+
firstName: identity.firstName,
|
|
61
|
+
lastName: identity.lastName,
|
|
62
|
+
email: identity.email,
|
|
63
|
+
phone: identity.phone ?? event.phone,
|
|
64
|
+
company: identity.company,
|
|
65
|
+
message: formData["message"] ?? formData["comments"] ?? formData["comment"],
|
|
66
|
+
extra: mapExtra(formData, config.fieldMap),
|
|
67
|
+
attribution: mapAttribution(event, config.trackingFields)
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/crm/zoho-token.ts
|
|
73
|
+
const ZOHO_ACCOUNTS_HOSTS = {
|
|
74
|
+
com: "accounts.zoho.com",
|
|
75
|
+
ca: "accounts.zohocloud.ca",
|
|
76
|
+
eu: "accounts.zoho.eu",
|
|
77
|
+
in: "accounts.zoho.in",
|
|
78
|
+
"com.au": "accounts.zoho.com.au",
|
|
79
|
+
jp: "accounts.zoho.jp"
|
|
80
|
+
};
|
|
81
|
+
function zohoAccountsHost(dataCenter) {
|
|
82
|
+
return ZOHO_ACCOUNTS_HOSTS[dataCenter] ?? ZOHO_ACCOUNTS_HOSTS.com;
|
|
83
|
+
}
|
|
84
|
+
/** Refresh a few seconds before Zoho actually expires it, so a token that's
|
|
85
|
+
* about to lapse mid-request is never handed out. */
|
|
86
|
+
const EXPIRY_MARGIN_MS$1 = 6e4;
|
|
87
|
+
const cache$1 = /* @__PURE__ */ new Map();
|
|
88
|
+
/**
|
|
89
|
+
* Exchange a refresh token for a short-lived Bigin access token, reusing a
|
|
90
|
+
* cached one while it's still valid.
|
|
91
|
+
*
|
|
92
|
+
* `now` is a parameter so cache expiry is testable without a fake clock, the
|
|
93
|
+
* same convention `google/token.ts`'s `googleAccessToken` uses.
|
|
94
|
+
*/
|
|
95
|
+
async function zohoAccessToken(oauth, dataCenter, fetchImpl = fetch, now = Date.now()) {
|
|
96
|
+
const cached = cache$1.get(oauth.refreshToken);
|
|
97
|
+
if (cached && cached.expiresAt > now) return cached.accessToken;
|
|
98
|
+
const body = new URLSearchParams({
|
|
99
|
+
grant_type: "refresh_token",
|
|
100
|
+
client_id: oauth.clientId,
|
|
101
|
+
client_secret: oauth.clientSecret,
|
|
102
|
+
refresh_token: oauth.refreshToken
|
|
103
|
+
});
|
|
104
|
+
const res = await fetchImpl(`https://${zohoAccountsHost(dataCenter)}/oauth/v2/token`, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
107
|
+
body: body.toString()
|
|
108
|
+
});
|
|
109
|
+
if (!res.ok) throw new Error(`Zoho token exchange failed (${res.status}): ${await res.text()}`);
|
|
110
|
+
const json = await res.json();
|
|
111
|
+
if (!json.access_token) throw new Error(`Zoho token exchange returned no access_token: ${json.error ?? "unknown error"}`);
|
|
112
|
+
const expiresAt = now + (json.expires_in ?? 3600) * 1e3 - EXPIRY_MARGIN_MS$1;
|
|
113
|
+
cache$1.set(oauth.refreshToken, {
|
|
114
|
+
accessToken: json.access_token,
|
|
115
|
+
expiresAt
|
|
116
|
+
});
|
|
117
|
+
return json.access_token;
|
|
118
|
+
}
|
|
119
|
+
/** Test-only: clear the module-level cache between cases. */
|
|
120
|
+
function clearZohoTokenCache() {
|
|
121
|
+
cache$1.clear();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
//#endregion
|
|
125
|
+
//#region src/crm/bigin.ts
|
|
126
|
+
/**
|
|
127
|
+
* Zoho Bigin executor — decides nothing except the one thing it must refuse
|
|
128
|
+
* to send: a record with no derivable last name (see `deriveLastName` in
|
|
129
|
+
* `map.ts`). Everything else is "build the request, post it, classify the
|
|
130
|
+
* response."
|
|
131
|
+
*
|
|
132
|
+
* `upsert` with `duplicate_check_fields: ['Email']` (configurable) rather than
|
|
133
|
+
* plain `Contacts` create — a visitor resubmitting the same form (a second
|
|
134
|
+
* enquiry, a retried failed submission) should update one contact, not create
|
|
135
|
+
* a duplicate every time.
|
|
136
|
+
*/
|
|
137
|
+
const ZOHO_API_HOSTS = {
|
|
138
|
+
com: "www.zohoapis.com",
|
|
139
|
+
ca: "www.zohoapis.ca",
|
|
140
|
+
eu: "www.zohoapis.eu",
|
|
141
|
+
in: "www.zohoapis.in",
|
|
142
|
+
"com.au": "www.zohoapis.com.au",
|
|
143
|
+
jp: "www.zohoapis.jp"
|
|
144
|
+
};
|
|
145
|
+
function zohoApiHost(dataCenter) {
|
|
146
|
+
return ZOHO_API_HOSTS[dataCenter] ?? ZOHO_API_HOSTS.com;
|
|
147
|
+
}
|
|
148
|
+
function biginFields(record, lastName) {
|
|
149
|
+
return {
|
|
150
|
+
Last_Name: lastName,
|
|
151
|
+
...record.firstName ? { First_Name: record.firstName } : {},
|
|
152
|
+
...record.email ? { Email: record.email } : {},
|
|
153
|
+
...record.phone ? { Phone: record.phone } : {},
|
|
154
|
+
...record.company ? { Account_Name: record.company } : {},
|
|
155
|
+
...record.message ? { Description: record.message } : {},
|
|
156
|
+
...record.extra,
|
|
157
|
+
...record.attribution,
|
|
158
|
+
Lead_Source: "Website"
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Write one record to Bigin. Never throws — a token-exchange failure, a
|
|
163
|
+
* network error and a rejected response all come back as `CrmResult`.
|
|
164
|
+
*/
|
|
165
|
+
async function submitToBigin(record, config, credentials, fetchImpl = fetch, timeoutMs = 5e3) {
|
|
166
|
+
const derived = deriveLastName({
|
|
167
|
+
firstName: record.firstName,
|
|
168
|
+
lastName: record.lastName,
|
|
169
|
+
fullName: [record.firstName, record.lastName].filter(Boolean).join(" ") || record.email || "",
|
|
170
|
+
email: record.email
|
|
171
|
+
});
|
|
172
|
+
if ("refusal" in derived) return {
|
|
173
|
+
ok: false,
|
|
174
|
+
provider: "bigin",
|
|
175
|
+
reason: derived.refusal
|
|
176
|
+
};
|
|
177
|
+
let token;
|
|
178
|
+
try {
|
|
179
|
+
token = await zohoAccessToken(credentials, config.dataCenter, fetchImpl);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
provider: "bigin",
|
|
184
|
+
reason: `Zoho token exchange failed: ${err instanceof Error ? err.message : String(err)}`
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const host = zohoApiHost(config.dataCenter);
|
|
188
|
+
const url = `https://${host}/bigin/v2/Contacts/upsert`;
|
|
189
|
+
const body = {
|
|
190
|
+
data: [biginFields(record, derived.lastName)],
|
|
191
|
+
duplicate_check_fields: [config.duplicateCheckField ?? "Email"],
|
|
192
|
+
trigger: ["workflow"]
|
|
193
|
+
};
|
|
194
|
+
let res;
|
|
195
|
+
try {
|
|
196
|
+
res = await fetchImpl(url, {
|
|
197
|
+
method: "POST",
|
|
198
|
+
headers: {
|
|
199
|
+
"content-type": "application/json",
|
|
200
|
+
authorization: `Zoho-oauthtoken ${token}`
|
|
201
|
+
},
|
|
202
|
+
body: JSON.stringify(body),
|
|
203
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
204
|
+
});
|
|
205
|
+
} catch (err) {
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
provider: "bigin",
|
|
209
|
+
reason: `Bigin request failed: ${err instanceof Error ? err.message : String(err)}`
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const text = await res.text().catch(() => "");
|
|
213
|
+
if (!res.ok) return {
|
|
214
|
+
ok: false,
|
|
215
|
+
provider: "bigin",
|
|
216
|
+
code: String(res.status),
|
|
217
|
+
reason: `Bigin responded ${res.status}: ${text}`
|
|
218
|
+
};
|
|
219
|
+
let json;
|
|
220
|
+
try {
|
|
221
|
+
json = JSON.parse(text);
|
|
222
|
+
} catch {
|
|
223
|
+
return {
|
|
224
|
+
ok: false,
|
|
225
|
+
provider: "bigin",
|
|
226
|
+
reason: `Bigin returned an unparsable response: ${text}`
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
const entry = json.data?.[0];
|
|
230
|
+
if (!entry || entry.status !== "success") return {
|
|
231
|
+
ok: false,
|
|
232
|
+
provider: "bigin",
|
|
233
|
+
code: entry?.code,
|
|
234
|
+
reason: `Bigin upsert did not succeed: ${text}`
|
|
235
|
+
};
|
|
236
|
+
const contactId = entry.details?.id;
|
|
237
|
+
if (config.pipeline?.enabled && contactId) await createPipelineRecord(record, config, contactId, host, token, fetchImpl, timeoutMs).catch(() => void 0);
|
|
238
|
+
return {
|
|
239
|
+
ok: true,
|
|
240
|
+
provider: "bigin",
|
|
241
|
+
recordId: contactId
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
async function createPipelineRecord(record, config, contactId, host, token, fetchImpl, timeoutMs) {
|
|
245
|
+
const pipeline = config.pipeline;
|
|
246
|
+
const body = {
|
|
247
|
+
data: [{
|
|
248
|
+
Pipeline_Name: record.company || record.email || "Website enquiry",
|
|
249
|
+
Contact_Name: contactId,
|
|
250
|
+
...pipeline.subPipeline ? { Sub_Pipeline: pipeline.subPipeline } : {},
|
|
251
|
+
...pipeline.stage ? { Stage: pipeline.stage } : {}
|
|
252
|
+
}],
|
|
253
|
+
trigger: ["workflow"]
|
|
254
|
+
};
|
|
255
|
+
await fetchImpl(`https://${host}/bigin/v2/Pipelines`, {
|
|
256
|
+
method: "POST",
|
|
257
|
+
headers: {
|
|
258
|
+
"content-type": "application/json",
|
|
259
|
+
authorization: `Zoho-oauthtoken ${token}`
|
|
260
|
+
},
|
|
261
|
+
body: JSON.stringify(body),
|
|
262
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
//#endregion
|
|
267
|
+
//#region src/crm/hubspot.ts
|
|
268
|
+
const HUBSPOT_HOSTS = {
|
|
269
|
+
na: "api.hsforms.com",
|
|
270
|
+
eu: "api-eu1.hsforms.com"
|
|
271
|
+
};
|
|
272
|
+
function fieldsFor(record) {
|
|
273
|
+
const fields = [];
|
|
274
|
+
const push = (name, value) => {
|
|
275
|
+
if (value) fields.push({
|
|
276
|
+
objectTypeId: "0-1",
|
|
277
|
+
name,
|
|
278
|
+
value
|
|
279
|
+
});
|
|
280
|
+
};
|
|
281
|
+
push("firstname", record.firstName);
|
|
282
|
+
push("lastname", record.lastName);
|
|
283
|
+
push("email", record.email);
|
|
284
|
+
push("phone", record.phone);
|
|
285
|
+
push("company", record.company);
|
|
286
|
+
push("message", record.message);
|
|
287
|
+
for (const [name, value] of Object.entries(record.extra)) push(name, value);
|
|
288
|
+
for (const [name, value] of Object.entries(record.attribution)) push(name, value);
|
|
289
|
+
return fields;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Submit one record to HubSpot's secure forms endpoint.
|
|
293
|
+
*
|
|
294
|
+
* Never throws — a network failure or an unparsable response both come back
|
|
295
|
+
* as a `CrmResult` with `ok: false`, the same "structured result, not an
|
|
296
|
+
* exception" rule `security/turnstile.ts` and `indexing/gsc.ts` follow. Status
|
|
297
|
+
* classification: 401 is a rejected/expired token; 400 naming
|
|
298
|
+
* `MISSING_REQUIRED_FIELDS` or an unrecognised field name is a mapping
|
|
299
|
+
* problem (`fieldMap`/`trackingFields` naming a property that isn't on the
|
|
300
|
+
* form); 429 is the per-portal rate limit.
|
|
301
|
+
*/
|
|
302
|
+
async function submitToHubSpot(record, config, credentials, context = {}, fetchImpl = fetch, timeoutMs = 5e3) {
|
|
303
|
+
const url = `https://${HUBSPOT_HOSTS[config.dataCenter ?? "na"]}/submissions/v3/integration/secure/submit/${config.portalId}/${config.formGuid}`;
|
|
304
|
+
const body = {
|
|
305
|
+
fields: fieldsFor(record),
|
|
306
|
+
context: {
|
|
307
|
+
...context.hutk ? { hutk: context.hutk } : {},
|
|
308
|
+
...context.pageUri ? { pageUri: context.pageUri } : {},
|
|
309
|
+
...context.pageName ? { pageName: context.pageName } : {}
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
let res;
|
|
313
|
+
try {
|
|
314
|
+
res = await fetchImpl(url, {
|
|
315
|
+
method: "POST",
|
|
316
|
+
headers: {
|
|
317
|
+
"content-type": "application/json",
|
|
318
|
+
authorization: `Bearer ${credentials.accessToken}`
|
|
319
|
+
},
|
|
320
|
+
body: JSON.stringify(body),
|
|
321
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
322
|
+
});
|
|
323
|
+
} catch (err) {
|
|
324
|
+
return {
|
|
325
|
+
ok: false,
|
|
326
|
+
provider: "hubspot",
|
|
327
|
+
reason: `HubSpot request failed: ${err instanceof Error ? err.message : String(err)}`
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
if (res.ok) return {
|
|
331
|
+
ok: true,
|
|
332
|
+
provider: "hubspot"
|
|
333
|
+
};
|
|
334
|
+
const text = await res.text().catch(() => "");
|
|
335
|
+
if (res.status === 401) return {
|
|
336
|
+
ok: false,
|
|
337
|
+
provider: "hubspot",
|
|
338
|
+
code: "401",
|
|
339
|
+
reason: `HubSpot rejected the access token: ${text}`
|
|
340
|
+
};
|
|
341
|
+
if (res.status === 429) return {
|
|
342
|
+
ok: false,
|
|
343
|
+
provider: "hubspot",
|
|
344
|
+
code: "429",
|
|
345
|
+
reason: "HubSpot rate limit exceeded for this portal."
|
|
346
|
+
};
|
|
347
|
+
if (res.status === 400 && /MISSING_REQUIRED_FIELDS|not.*(a|an).*(valid|known).*field/i.test(text)) return {
|
|
348
|
+
ok: false,
|
|
349
|
+
provider: "hubspot",
|
|
350
|
+
code: "400",
|
|
351
|
+
reason: `HubSpot rejected the mapped fields — check \`fieldMap\`/\`trackingFields\` against the form's own fields: ${text}`
|
|
352
|
+
};
|
|
353
|
+
return {
|
|
354
|
+
ok: false,
|
|
355
|
+
provider: "hubspot",
|
|
356
|
+
code: String(res.status),
|
|
357
|
+
reason: `HubSpot responded ${res.status}: ${text}`
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
//#endregion
|
|
362
|
+
//#region src/crm/jobber-token.ts
|
|
363
|
+
/**
|
|
364
|
+
* Jobber OAuth2 refresh-token exchange.
|
|
365
|
+
*
|
|
366
|
+
* A plain `application/x-www-form-urlencoded` POST, same shape as Zoho's —
|
|
367
|
+
* see `zoho-token.ts` for why that keeps this Worker-safe (no `node:crypto`,
|
|
368
|
+
* unlike `google/token.ts`'s service-account JWT).
|
|
369
|
+
*
|
|
370
|
+
* One thing Zoho's grant never does: Jobber's Refresh Token Rotation is ON by
|
|
371
|
+
* default for a new app, and when it's on, every exchange returns a *new*
|
|
372
|
+
* refresh token and invalidates the old one. A Worker reading a static env
|
|
373
|
+
* var (`VITOPS_JOBBER_REFRESH_TOKEN`) cannot persist that new value, so this
|
|
374
|
+
* integration requires rotation turned OFF in the app's Developer Center
|
|
375
|
+
* settings — a legitimate setup requirement, since a per-client private app
|
|
376
|
+
* is never published to Jobber's Marketplace (where rotation ON is
|
|
377
|
+
* mandatory). `zohoAccessToken`'s Zoho equivalent has no such failure mode to
|
|
378
|
+
* detect, because Zoho's grant is long-lived by default.
|
|
379
|
+
*
|
|
380
|
+
* Cached per isolate, keyed on the refresh token, with an expiry margin —
|
|
381
|
+
* same rationale as `zoho-token.ts`.
|
|
382
|
+
*/
|
|
383
|
+
const JOBBER_OAUTH_TOKEN_URL = "https://api.getjobber.com/api/oauth/token";
|
|
384
|
+
/** Refresh a few seconds before Jobber actually expires it (access tokens
|
|
385
|
+
* last 60 minutes), so a token that's about to lapse mid-request is never
|
|
386
|
+
* handed out. */
|
|
387
|
+
const EXPIRY_MARGIN_MS = 6e4;
|
|
388
|
+
const cache = /* @__PURE__ */ new Map();
|
|
389
|
+
/**
|
|
390
|
+
* Exchange a refresh token for a short-lived Jobber access token, reusing a
|
|
391
|
+
* cached one while it's still valid.
|
|
392
|
+
*
|
|
393
|
+
* Throws a specific, actionable error when Jobber's response carries a
|
|
394
|
+
* *different* refresh token than the one sent — the rotation case above.
|
|
395
|
+
* Without this check, rotation surfaces an hour later as an opaque 401 on a
|
|
396
|
+
* lead that is already lost; with it, the first submission after rotation
|
|
397
|
+
* turns on names the fix.
|
|
398
|
+
*
|
|
399
|
+
* `now` is a parameter so cache expiry is testable without a fake clock, the
|
|
400
|
+
* same convention `zoho-token.ts`'s `zohoAccessToken` uses.
|
|
401
|
+
*/
|
|
402
|
+
async function jobberAccessToken(oauth, fetchImpl = fetch, now = Date.now()) {
|
|
403
|
+
const cached = cache.get(oauth.refreshToken);
|
|
404
|
+
if (cached && cached.expiresAt > now) return cached.accessToken;
|
|
405
|
+
const body = new URLSearchParams({
|
|
406
|
+
grant_type: "refresh_token",
|
|
407
|
+
client_id: oauth.clientId,
|
|
408
|
+
client_secret: oauth.clientSecret,
|
|
409
|
+
refresh_token: oauth.refreshToken
|
|
410
|
+
});
|
|
411
|
+
const res = await fetchImpl(JOBBER_OAUTH_TOKEN_URL, {
|
|
412
|
+
method: "POST",
|
|
413
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
414
|
+
body: body.toString()
|
|
415
|
+
});
|
|
416
|
+
if (!res.ok) throw new Error(`Jobber token exchange failed (${res.status}): ${await res.text()}`);
|
|
417
|
+
const json = await res.json();
|
|
418
|
+
if (!json.access_token) throw new Error(`Jobber token exchange returned no access_token: ${json.error ?? "unknown error"}`);
|
|
419
|
+
if (json.refresh_token && json.refresh_token !== oauth.refreshToken) throw new Error("Jobber returned a rotated refresh token. Turn Refresh Token Rotation off for this app in Jobber's Developer Center — a rotated token cannot be persisted from a stateless Worker reading a static VITOPS_JOBBER_REFRESH_TOKEN.");
|
|
420
|
+
const expiresAt = now + (json.expires_in ?? 3600) * 1e3 - EXPIRY_MARGIN_MS;
|
|
421
|
+
cache.set(oauth.refreshToken, {
|
|
422
|
+
accessToken: json.access_token,
|
|
423
|
+
expiresAt
|
|
424
|
+
});
|
|
425
|
+
return json.access_token;
|
|
426
|
+
}
|
|
427
|
+
/** Test-only: clear the module-level cache between cases. */
|
|
428
|
+
function clearJobberTokenCache() {
|
|
429
|
+
cache.clear();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
//#endregion
|
|
433
|
+
//#region src/crm/jobber.ts
|
|
434
|
+
const JOBBER_GRAPHQL_ENDPOINT = "https://api.getjobber.com/api/graphql";
|
|
435
|
+
/** Pinned to the version this integration was built and tested against.
|
|
436
|
+
* Jobber's `X-JOBBER-GRAPHQL-VERSION` header is mandatory — an unversioned
|
|
437
|
+
* request is rejected outright — so this is code, not something left to
|
|
438
|
+
* config to supply by default. `config.jobber.apiVersion` is the escape
|
|
439
|
+
* hatch for a client's app pinned to a different one. */
|
|
440
|
+
const JOBBER_API_VERSION = "2025-04-16";
|
|
441
|
+
function clientCreateInput(record) {
|
|
442
|
+
return {
|
|
443
|
+
...record.firstName ? { firstName: record.firstName } : {},
|
|
444
|
+
...record.lastName ? { lastName: record.lastName } : {},
|
|
445
|
+
...record.company ? { companyName: record.company } : {},
|
|
446
|
+
...record.email ? { emails: [{
|
|
447
|
+
description: "MAIN",
|
|
448
|
+
primary: true,
|
|
449
|
+
address: record.email
|
|
450
|
+
}] } : {},
|
|
451
|
+
...record.phone ? { phones: [{
|
|
452
|
+
description: "MAIN",
|
|
453
|
+
primary: true,
|
|
454
|
+
number: record.phone
|
|
455
|
+
}] } : {}
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
/** Note body: the submitted message plus every attribution/extra field that
|
|
459
|
+
* has no dedicated Jobber field, so nothing is lost to the note-vs-field
|
|
460
|
+
* split above. Empty sections are omitted rather than rendered blank. */
|
|
461
|
+
function noteBody(record, pageUri) {
|
|
462
|
+
const lines = [];
|
|
463
|
+
if (record.message) lines.push(record.message);
|
|
464
|
+
const extraEntries = Object.entries(record.extra);
|
|
465
|
+
if (extraEntries.length) lines.push("", ...extraEntries.map(([k, v]) => `${k}: ${v}`));
|
|
466
|
+
const attributionEntries = Object.entries(record.attribution);
|
|
467
|
+
if (attributionEntries.length) lines.push("", "Attribution:", ...attributionEntries.map(([k, v]) => `${k}: ${v}`));
|
|
468
|
+
if (pageUri) lines.push("", `Submitted from ${pageUri}`);
|
|
469
|
+
return lines.join("\n").trim();
|
|
470
|
+
}
|
|
471
|
+
async function graphql(query, variables, token, apiVersion, fetchImpl, timeoutMs) {
|
|
472
|
+
return fetchImpl(JOBBER_GRAPHQL_ENDPOINT, {
|
|
473
|
+
method: "POST",
|
|
474
|
+
headers: {
|
|
475
|
+
"content-type": "application/json",
|
|
476
|
+
authorization: `Bearer ${token}`,
|
|
477
|
+
"x-jobber-graphql-version": apiVersion
|
|
478
|
+
},
|
|
479
|
+
body: JSON.stringify({
|
|
480
|
+
query,
|
|
481
|
+
variables
|
|
482
|
+
}),
|
|
483
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
const CLIENT_CREATE_MUTATION = `
|
|
487
|
+
mutation CrmClientCreate($input: ClientCreateInput!) {
|
|
488
|
+
clientCreate(input: $input) {
|
|
489
|
+
client { id }
|
|
490
|
+
userErrors { message }
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
`;
|
|
494
|
+
/** Best-effort — see the module docblock's note on the unverified
|
|
495
|
+
* `noteCreate` shape. Any failure (transport, GraphQL error, or the mutation
|
|
496
|
+
* simply not existing under this name) is swallowed by the caller. */
|
|
497
|
+
const NOTE_CREATE_MUTATION = `
|
|
498
|
+
mutation CrmNoteCreate($input: NoteCreateInput!) {
|
|
499
|
+
noteCreate(input: $input) {
|
|
500
|
+
note { id }
|
|
501
|
+
userErrors { message }
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
`;
|
|
505
|
+
async function createNote(clientId, message, token, apiVersion, fetchImpl, timeoutMs) {
|
|
506
|
+
if (!message) return;
|
|
507
|
+
const json = await (await graphql(NOTE_CREATE_MUTATION, { input: {
|
|
508
|
+
clientId,
|
|
509
|
+
message
|
|
510
|
+
} }, token, apiVersion, fetchImpl, timeoutMs)).json();
|
|
511
|
+
if (json.errors?.length) throw new Error(json.errors.map((e) => e.message).join("; "));
|
|
512
|
+
const userErrors = json.data?.noteCreate?.userErrors;
|
|
513
|
+
if (userErrors?.length) throw new Error(userErrors.map((e) => e.message).join("; "));
|
|
514
|
+
}
|
|
515
|
+
/** Best-effort — see the module docblock's note on the unverified `clients`
|
|
516
|
+
* filter shape. A failure (or the field simply not matching this account's
|
|
517
|
+
* schema) means "could not dedupe", not "stop the submission" — falls
|
|
518
|
+
* through to creating a new client. */
|
|
519
|
+
const FIND_CLIENT_BY_EMAIL_QUERY = `
|
|
520
|
+
query CrmFindClientByEmail($searchTerm: String!) {
|
|
521
|
+
clients(first: 1, searchTerm: $searchTerm) {
|
|
522
|
+
nodes { id }
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
`;
|
|
526
|
+
async function findExistingClientId(email, token, apiVersion, fetchImpl, timeoutMs) {
|
|
527
|
+
const json = await (await graphql(FIND_CLIENT_BY_EMAIL_QUERY, { searchTerm: email }, token, apiVersion, fetchImpl, timeoutMs)).json();
|
|
528
|
+
if (json.errors?.length) throw new Error(json.errors.map((e) => e.message).join("; "));
|
|
529
|
+
return json.data?.clients?.nodes?.[0]?.id;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Write one record to Jobber. Never throws — a token-exchange failure, a
|
|
533
|
+
* network error, a top-level GraphQL `errors[]`, and a `userErrors[]` all
|
|
534
|
+
* come back as a `CrmResult` with `ok: false`.
|
|
535
|
+
*/
|
|
536
|
+
async function submitToJobber(record, config, credentials, pageUri, fetchImpl = fetch, timeoutMs = 5e3) {
|
|
537
|
+
const apiVersion = config.apiVersion ?? JOBBER_API_VERSION;
|
|
538
|
+
let token;
|
|
539
|
+
try {
|
|
540
|
+
token = await jobberAccessToken(credentials, fetchImpl);
|
|
541
|
+
} catch (err) {
|
|
542
|
+
return {
|
|
543
|
+
ok: false,
|
|
544
|
+
provider: "jobber",
|
|
545
|
+
reason: `Jobber token exchange failed: ${err instanceof Error ? err.message : String(err)}`
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
if (config.dedupe === true && record.email) try {
|
|
549
|
+
const existingId = await findExistingClientId(record.email, token, apiVersion, fetchImpl, timeoutMs);
|
|
550
|
+
if (existingId) {
|
|
551
|
+
if (config.note?.enabled !== false) await createNote(existingId, noteBody(record, pageUri), token, apiVersion, fetchImpl, timeoutMs).catch(() => void 0);
|
|
552
|
+
return {
|
|
553
|
+
ok: true,
|
|
554
|
+
provider: "jobber",
|
|
555
|
+
recordId: existingId
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
} catch {}
|
|
559
|
+
let res;
|
|
560
|
+
try {
|
|
561
|
+
res = await graphql(CLIENT_CREATE_MUTATION, { input: clientCreateInput(record) }, token, apiVersion, fetchImpl, timeoutMs);
|
|
562
|
+
} catch (err) {
|
|
563
|
+
return {
|
|
564
|
+
ok: false,
|
|
565
|
+
provider: "jobber",
|
|
566
|
+
reason: `Jobber request failed: ${err instanceof Error ? err.message : String(err)}`
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
if (res.status === 401) return {
|
|
570
|
+
ok: false,
|
|
571
|
+
provider: "jobber",
|
|
572
|
+
code: "401",
|
|
573
|
+
reason: `Jobber rejected the access token: ${await res.text().catch(() => "")}`
|
|
574
|
+
};
|
|
575
|
+
if (res.status === 429) return {
|
|
576
|
+
ok: false,
|
|
577
|
+
provider: "jobber",
|
|
578
|
+
code: "429",
|
|
579
|
+
reason: "Jobber rate limit exceeded (2,500 requests per 5 minutes)."
|
|
580
|
+
};
|
|
581
|
+
if (!res.ok) return {
|
|
582
|
+
ok: false,
|
|
583
|
+
provider: "jobber",
|
|
584
|
+
code: String(res.status),
|
|
585
|
+
reason: `Jobber responded ${res.status}: ${await res.text().catch(() => "")}`
|
|
586
|
+
};
|
|
587
|
+
let json;
|
|
588
|
+
try {
|
|
589
|
+
json = await res.json();
|
|
590
|
+
} catch {
|
|
591
|
+
return {
|
|
592
|
+
ok: false,
|
|
593
|
+
provider: "jobber",
|
|
594
|
+
reason: "Jobber returned an unparsable response."
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
if (json.errors?.length) return {
|
|
598
|
+
ok: false,
|
|
599
|
+
provider: "jobber",
|
|
600
|
+
reason: `Jobber rejected the request: ${json.errors.map((e) => e.message).join("; ")}`
|
|
601
|
+
};
|
|
602
|
+
const userErrors = json.data?.clientCreate?.userErrors;
|
|
603
|
+
if (userErrors?.length) return {
|
|
604
|
+
ok: false,
|
|
605
|
+
provider: "jobber",
|
|
606
|
+
reason: `Jobber rejected the mapped fields: ${userErrors.map((e) => e.message).join("; ")}`
|
|
607
|
+
};
|
|
608
|
+
const clientId = json.data?.clientCreate?.client?.id;
|
|
609
|
+
if (!clientId) return {
|
|
610
|
+
ok: false,
|
|
611
|
+
provider: "jobber",
|
|
612
|
+
reason: "Jobber did not return a client id."
|
|
613
|
+
};
|
|
614
|
+
if (config.note?.enabled !== false) await createNote(clientId, noteBody(record, pageUri), token, apiVersion, fetchImpl, timeoutMs).catch(() => void 0);
|
|
615
|
+
return {
|
|
616
|
+
ok: true,
|
|
617
|
+
provider: "jobber",
|
|
618
|
+
recordId: clientId
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
//#endregion
|
|
623
|
+
//#region src/crm/plan.ts
|
|
624
|
+
/**
|
|
625
|
+
* What each provider requires to be considered configured, as one table
|
|
626
|
+
* rather than a per-provider `if`. A provider missing here is a type error —
|
|
627
|
+
* `Record<CrmProvider, …>` — rather than a silently-enabled channel with no
|
|
628
|
+
* config block, which is what the equivalent hand-written checks would do for
|
|
629
|
+
* a provider nobody added a branch for.
|
|
630
|
+
*/
|
|
631
|
+
const PROVIDER_REQUIREMENTS = {
|
|
632
|
+
hubspot: {
|
|
633
|
+
block: "hubspot",
|
|
634
|
+
credentialHint: "No HubSpot access token configured."
|
|
635
|
+
},
|
|
636
|
+
bigin: {
|
|
637
|
+
block: "bigin",
|
|
638
|
+
credentialHint: "No Zoho OAuth credentials configured."
|
|
639
|
+
},
|
|
640
|
+
jobber: {
|
|
641
|
+
block: "jobber",
|
|
642
|
+
credentialHint: "No Jobber OAuth credentials configured."
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
function planCrm(config, credentials) {
|
|
646
|
+
if (!config) return {
|
|
647
|
+
enabled: false,
|
|
648
|
+
skip: "No `crm` configured."
|
|
649
|
+
};
|
|
650
|
+
if (config.enabled === false) return {
|
|
651
|
+
enabled: false,
|
|
652
|
+
provider: config.provider,
|
|
653
|
+
skip: "`crm.enabled` is false."
|
|
654
|
+
};
|
|
655
|
+
const requirement = PROVIDER_REQUIREMENTS[config.provider];
|
|
656
|
+
if (!config[requirement.block]) return {
|
|
657
|
+
enabled: false,
|
|
658
|
+
provider: config.provider,
|
|
659
|
+
skip: `\`crm.provider\` is "${config.provider}" but \`crm.${requirement.block}\` is missing.`
|
|
660
|
+
};
|
|
661
|
+
if (!credentials) return {
|
|
662
|
+
enabled: false,
|
|
663
|
+
provider: config.provider,
|
|
664
|
+
skip: requirement.credentialHint
|
|
665
|
+
};
|
|
666
|
+
if (credentials.provider !== config.provider) return {
|
|
667
|
+
enabled: false,
|
|
668
|
+
provider: config.provider,
|
|
669
|
+
skip: `\`crm.provider\` is "${config.provider}" but the supplied credentials are for "${credentials.provider}".`
|
|
670
|
+
};
|
|
671
|
+
return {
|
|
672
|
+
enabled: true,
|
|
673
|
+
provider: config.provider
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
//#endregion
|
|
678
|
+
//#region src/crm/types.ts
|
|
679
|
+
/** Human-readable label per provider, for the notification email's Delivery
|
|
680
|
+
* line (`describeCrmResult`) and anywhere else a provider must be named
|
|
681
|
+
* without a hand-written ternary. Exhaustive by type — a provider missing
|
|
682
|
+
* here is a compile error, not a mislabelled email. */
|
|
683
|
+
const PROVIDER_LABELS = {
|
|
684
|
+
hubspot: "HubSpot",
|
|
685
|
+
bigin: "Bigin",
|
|
686
|
+
jobber: "Jobber"
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
//#endregion
|
|
690
|
+
//#region src/crm/index.ts
|
|
691
|
+
async function deliverToCrm(event, config, deps = {}) {
|
|
692
|
+
const plan = planCrm(config, deps.credentials);
|
|
693
|
+
if (!plan.enabled || !config || !deps.credentials) return config ? {
|
|
694
|
+
ok: false,
|
|
695
|
+
provider: config.provider,
|
|
696
|
+
reason: plan.skip
|
|
697
|
+
} : null;
|
|
698
|
+
const record = mapSubmission(event, config);
|
|
699
|
+
const fetchImpl = deps.fetch ?? fetch;
|
|
700
|
+
const timeoutMs = deps.timeoutMs ?? 5e3;
|
|
701
|
+
const credentials = deps.credentials;
|
|
702
|
+
switch (config.provider) {
|
|
703
|
+
case "hubspot":
|
|
704
|
+
if (credentials.provider !== "hubspot") break;
|
|
705
|
+
return submitToHubSpot(record, config.hubspot, credentials.hubspot, deps.pageContext, fetchImpl, timeoutMs);
|
|
706
|
+
case "bigin":
|
|
707
|
+
if (credentials.provider !== "bigin") break;
|
|
708
|
+
return submitToBigin(record, config.bigin, credentials.bigin, fetchImpl, timeoutMs);
|
|
709
|
+
case "jobber":
|
|
710
|
+
if (credentials.provider !== "jobber") break;
|
|
711
|
+
return submitToJobber(record, config.jobber ?? {}, credentials.jobber, deps.pageContext?.pageUri, fetchImpl, timeoutMs);
|
|
712
|
+
default: return config.provider;
|
|
713
|
+
}
|
|
714
|
+
return {
|
|
715
|
+
ok: false,
|
|
716
|
+
provider: config.provider,
|
|
717
|
+
reason: plan.skip ?? "Credentials/provider mismatch."
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
/** A one-line, human-readable delivery note — what `conversion.ts` folds into
|
|
721
|
+
* the notification email's "Delivery" section. */
|
|
722
|
+
function describeCrmResult(result) {
|
|
723
|
+
if (!result) return null;
|
|
724
|
+
const name = PROVIDER_LABELS[result.provider];
|
|
725
|
+
if (result.ok) return result.recordId ? `Written to ${name} (contact ${result.recordId})` : `Written to ${name}`;
|
|
726
|
+
return `⚠ Not written to ${name}: ${result.reason ?? "unknown reason"}`;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
//#endregion
|
|
730
|
+
export { PROVIDER_LABELS, clearJobberTokenCache, clearZohoTokenCache, deliverToCrm, deriveLastName, describeCrmResult, jobberAccessToken, mapSubmission, planCrm, submitToBigin, submitToHubSpot, submitToJobber, zohoAccessToken, zohoAccountsHost, zohoApiHost };
|
|
731
|
+
//# sourceMappingURL=index.mjs.map
|