6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
193 lines
7.0 KiB
TypeScript
193 lines
7.0 KiB
TypeScript
import { truncate } from '@sim/utils/string'
|
|
|
|
/**
|
|
* Zoho data centers Sim supports.
|
|
*
|
|
* Zoho is multi-DC: a user's account lives in exactly one region, and every API
|
|
* host is region-scoped. Zoho's guidance is to never hardcode a single region —
|
|
* the token response carries an `api_domain` naming the caller's CRM host.
|
|
* Sim cannot persist that value (the shared Better Auth `account` table has no
|
|
* column for provider-specific token fields), so the region is selected on the
|
|
* block instead and resolved to hosts here.
|
|
*
|
|
* Only regions whose CRM *and* Desk hosts are confirmed in Zoho's own
|
|
* documentation are listed. CA and SA are deliberately absent: Zoho's accounts
|
|
* docs give `accounts.zohocloud.ca` while the Desk SDK uses `accounts.zoho.ca`,
|
|
* and neither source states their CRM API host.
|
|
*
|
|
* @see https://www.zoho.com/crm/developer/docs/api/v8/multi-dc.html
|
|
*/
|
|
export const ZOHO_DATA_CENTERS = {
|
|
us: { crm: 'https://www.zohoapis.com', desk: 'https://desk.zoho.com' },
|
|
eu: { crm: 'https://www.zohoapis.eu', desk: 'https://desk.zoho.eu' },
|
|
in: { crm: 'https://www.zohoapis.in', desk: 'https://desk.zoho.in' },
|
|
au: { crm: 'https://www.zohoapis.com.au', desk: 'https://desk.zoho.com.au' },
|
|
jp: { crm: 'https://www.zohoapis.jp', desk: 'https://desk.zoho.jp' },
|
|
} as const
|
|
|
|
export type ZohoDataCenter = keyof typeof ZOHO_DATA_CENTERS
|
|
|
|
const DEFAULT_DATA_CENTER: ZohoDataCenter = 'us'
|
|
|
|
/** Zoho CRM API version pinned across every CRM tool. */
|
|
export const ZOHO_CRM_API_VERSION = 'v8'
|
|
|
|
function resolveDataCenter(dataCenter?: string): ZohoDataCenter {
|
|
const normalized = dataCenter?.trim().toLowerCase()
|
|
if (normalized && normalized in ZOHO_DATA_CENTERS) {
|
|
return normalized as ZohoDataCenter
|
|
}
|
|
return DEFAULT_DATA_CENTER
|
|
}
|
|
|
|
/**
|
|
* Returns the CRM API base URL (including version segment) for a data center.
|
|
* Falls back to the US region when unset or unrecognized.
|
|
*/
|
|
export function getCrmBaseUrl(dataCenter?: string): string {
|
|
return `${ZOHO_DATA_CENTERS[resolveDataCenter(dataCenter)].crm}/crm/${ZOHO_CRM_API_VERSION}`
|
|
}
|
|
|
|
/**
|
|
* Returns the Desk API base URL (including the `/api/v1` segment) for a data
|
|
* center. Falls back to the US region when unset or unrecognized.
|
|
*/
|
|
export function getDeskBaseUrl(dataCenter?: string): string {
|
|
return `${ZOHO_DATA_CENTERS[resolveDataCenter(dataCenter)].desk}/api/v1`
|
|
}
|
|
|
|
/**
|
|
* Zoho authenticates with a bespoke scheme rather than `Bearer`.
|
|
* @see https://www.zoho.com/crm/developer/docs/api/v8/access-refresh.html
|
|
*/
|
|
export function buildZohoHeaders(accessToken: string, orgId?: string): Record<string, string> {
|
|
const headers: Record<string, string> = {
|
|
Authorization: `Zoho-oauthtoken ${accessToken}`,
|
|
'Content-Type': 'application/json',
|
|
}
|
|
if (orgId?.trim()) {
|
|
headers.orgId = orgId.trim()
|
|
}
|
|
return headers
|
|
}
|
|
|
|
/**
|
|
* Trims a required identifier and throws when it is missing or whitespace-only,
|
|
* so a blank value can never collapse into an empty URL path segment and send a
|
|
* malformed request to Zoho.
|
|
*/
|
|
export function requireZohoId(value: string | undefined, label: string): string {
|
|
const trimmed = value?.trim()
|
|
if (!trimmed) {
|
|
throw new Error(`${label} is required.`)
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
/**
|
|
* Parses a JSON object supplied either as an object (from the LLM) or as a JSON
|
|
* string (from a block input), and rejects anything that is not a plain object.
|
|
*/
|
|
export function parseJsonObject(
|
|
value: unknown,
|
|
label: string
|
|
): Record<string, unknown> | undefined {
|
|
if (value === undefined || value === null || value === '') return undefined
|
|
let parsed: unknown = value
|
|
if (typeof value === 'string') {
|
|
try {
|
|
parsed = JSON.parse(value)
|
|
} catch {
|
|
throw new Error(`${label} must be valid JSON.`)
|
|
}
|
|
}
|
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
throw new Error(`${label} must be a JSON object.`)
|
|
}
|
|
return parsed as Record<string, unknown>
|
|
}
|
|
|
|
/**
|
|
* Coerces a numeric-ish param into a positive integer, clamped to `max`.
|
|
* Returns undefined when unset or unparseable so callers can omit the param.
|
|
*/
|
|
export function toPositiveInt(value: unknown, max: number): number | undefined {
|
|
if (value === undefined || value === null || value === '') return undefined
|
|
const parsed = Number(value)
|
|
if (!Number.isFinite(parsed) || parsed < 1) return undefined
|
|
return Math.min(Math.floor(parsed), max)
|
|
}
|
|
|
|
/**
|
|
* Appends only the defined entries of `params` to a URL's query string, so unset
|
|
* optional params never surface as empty values Zoho would reject.
|
|
*/
|
|
export function buildQuery(params: Record<string, string | number | undefined>): string {
|
|
const search = new URLSearchParams()
|
|
for (const [key, value] of Object.entries(params)) {
|
|
if (value === undefined || value === '') continue
|
|
search.set(key, String(value))
|
|
}
|
|
const query = search.toString()
|
|
return query ? `?${query}` : ''
|
|
}
|
|
|
|
/**
|
|
* Reads a Zoho response body as JSON, tolerating the empty bodies Zoho returns
|
|
* for "no matching records" — CRM answers `204 No Content` on an empty list or
|
|
* search, and Desk does the same, so a bare `response.json()` would throw on a
|
|
* perfectly successful call.
|
|
*/
|
|
export async function readZohoJson(response: Response): Promise<any> {
|
|
if (response.status === 204) return {}
|
|
const text = await response.text()
|
|
if (!text.trim()) return {}
|
|
try {
|
|
return JSON.parse(text)
|
|
} catch {
|
|
return { message: truncate(text, 500) }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extracts a descriptive message from a Zoho error payload.
|
|
*
|
|
* CRM returns `{ code, message, status, details }`; Desk returns
|
|
* `{ errorCode, message }`. Both are handled, with an HTTP-status fallback.
|
|
*/
|
|
export function extractZohoErrorMessage(
|
|
data: unknown,
|
|
status: number,
|
|
defaultMessage: string
|
|
): string {
|
|
const payload = data as
|
|
| { message?: unknown; code?: unknown; errorCode?: unknown; data?: unknown }
|
|
| undefined
|
|
|
|
const nested = Array.isArray(payload?.data) ? payload.data[0] : undefined
|
|
const record = (nested ?? payload) as
|
|
| { message?: unknown; code?: unknown; errorCode?: unknown }
|
|
| undefined
|
|
|
|
if (record && typeof record.message === 'string' && record.message.trim()) {
|
|
const code = record.code ?? record.errorCode
|
|
const suffix = typeof code === 'string' && code.trim() ? ` [${code}]` : ''
|
|
return `Zoho API Error (${status}): ${record.message}${suffix}`
|
|
}
|
|
|
|
switch (status) {
|
|
case 400:
|
|
return `Zoho API Error (400): Bad Request — the request was malformed or missing required parameters.`
|
|
case 401:
|
|
return `Zoho API Error (401): Unauthorized — the access token is invalid or expired. Please reconnect your Zoho account.`
|
|
case 403:
|
|
return `Zoho API Error (403): Forbidden — your Zoho account lacks permission, or the required OAuth scope was not granted.`
|
|
case 404:
|
|
return `Zoho API Error (404): Not Found — the requested record does not exist or is not visible to you.`
|
|
case 429:
|
|
return `Zoho API Error (429): Rate limit exceeded — too many API calls. Please retry later.`
|
|
default:
|
|
return `${defaultMessage} (HTTP ${status})`
|
|
}
|
|
}
|