@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +264 -8
  2. package/dist/{adc-BFrzpMHq.d.mts → adc-BoEoogyo.d.mts} +2 -3
  3. package/dist/adc-BoEoogyo.d.mts.map +1 -0
  4. package/dist/ads/index.d.mts +50 -17
  5. package/dist/ads/index.d.mts.map +1 -1
  6. package/dist/ads/index.mjs +75 -7
  7. package/dist/ads/index.mjs.map +1 -1
  8. package/dist/cloudflare-BiK61Oq4.d.mts.map +1 -1
  9. package/dist/color/index.d.mts +34 -35
  10. package/dist/color/index.d.mts.map +1 -1
  11. package/dist/color/index.mjs +3 -1
  12. package/dist/color/index.mjs.map +1 -1
  13. package/dist/crm/index.d.mts +288 -0
  14. package/dist/crm/index.d.mts.map +1 -0
  15. package/dist/crm/index.mjs +731 -0
  16. package/dist/crm/index.mjs.map +1 -0
  17. package/dist/email/index.d.mts +216 -0
  18. package/dist/email/index.d.mts.map +1 -0
  19. package/dist/email/index.mjs +331 -0
  20. package/dist/email/index.mjs.map +1 -0
  21. package/dist/favicon.d.mts +8 -9
  22. package/dist/favicon.d.mts.map +1 -1
  23. package/dist/git/index.d.mts +65 -0
  24. package/dist/git/index.d.mts.map +1 -0
  25. package/dist/git/index.mjs +107 -0
  26. package/dist/git/index.mjs.map +1 -0
  27. package/dist/hosting/index.d.mts +29 -29
  28. package/dist/hosting/index.d.mts.map +1 -1
  29. package/dist/hosting/index.mjs +7 -4
  30. package/dist/hosting/index.mjs.map +1 -1
  31. package/dist/identity-BoRddTx3.mjs +35 -0
  32. package/dist/identity-BoRddTx3.mjs.map +1 -0
  33. package/dist/index.d.mts +24 -25
  34. package/dist/index.d.mts.map +1 -1
  35. package/dist/index.mjs +5 -5
  36. package/dist/index.mjs.map +1 -1
  37. package/dist/indexing/index.d.mts +23 -24
  38. package/dist/indexing/index.d.mts.map +1 -1
  39. package/dist/media/index.d.mts +24 -24
  40. package/dist/media/index.d.mts.map +1 -1
  41. package/dist/media/index.mjs +3 -3
  42. package/dist/media/index.mjs.map +1 -1
  43. package/dist/notify/index.d.mts +26 -15
  44. package/dist/notify/index.d.mts.map +1 -1
  45. package/dist/notify/index.mjs +50 -20
  46. package/dist/notify/index.mjs.map +1 -1
  47. package/dist/onboarding/index.d.mts +10 -141
  48. package/dist/onboarding/index.d.mts.map +1 -1
  49. package/dist/onboarding/index.mjs +1 -144
  50. package/dist/onboarding/index.mjs.map +1 -1
  51. package/dist/plan-BLKikoOG.mjs +150 -0
  52. package/dist/plan-BLKikoOG.mjs.map +1 -0
  53. package/dist/plan-Hq8Pb0Uw.d.mts +133 -0
  54. package/dist/plan-Hq8Pb0Uw.d.mts.map +1 -0
  55. package/dist/reviews/index.d.mts +12 -13
  56. package/dist/reviews/index.d.mts.map +1 -1
  57. package/dist/schema/index.d.mts +7 -7
  58. package/dist/schema/index.d.mts.map +1 -1
  59. package/dist/security/index.d.mts +2 -2
  60. package/dist/security/index.d.mts.map +1 -1
  61. package/dist/{token-Bz_-igw-.d.mts → token-DmmoQ7um.d.mts} +6 -3
  62. package/dist/token-DmmoQ7um.d.mts.map +1 -0
  63. package/dist/tracking/index.d.mts +43 -17
  64. package/dist/tracking/index.d.mts.map +1 -1
  65. package/dist/tracking/index.mjs +2 -1
  66. package/dist/tracking/index.mjs.map +1 -1
  67. package/dist/{types-CHHjzRrv.d.mts → types-7ELci8RQ.d.mts} +4 -2
  68. package/dist/types-7ELci8RQ.d.mts.map +1 -0
  69. package/package.json +14 -4
  70. package/dist/adc-BFrzpMHq.d.mts.map +0 -1
  71. package/dist/token-Bz_-igw-.d.mts.map +0 -1
  72. 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