mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-21 21:15:56 +08:00
feat(zoho-desk): add Zoho Desk integration (#6157)
* feat(zoho-desk): add Zoho Desk integration
Add a full Zoho Desk integration: tools, block, icon, and a webhook trigger.
Tools (tools/zoho_desk): list/get/update tickets, list/add comments,
list/get threads, get contact, list organizations, and download attachments
as UserFiles via an internal route. Registered in tools/registry.ts.
Block (blocks/blocks/zoho-desk.ts): operation dropdown, OAuth credential,
an organization selector backed by GET /organizations, per-operation fields,
and BlockMeta templates. Wires the Zoho Desk trigger.
OAuth (zoho-desk provider): authorize/token at accounts.zoho.com with
access_type=offline + prompt=consent; the Desk REST base is derived from the
token response api_domain and persisted so calls honor data residency instead
of assuming desk.zoho.com. Every call sends Authorization: Zoho-oauthtoken and
the orgId header.
Trigger + webhook handler (triggers/zoho_desk, lib/webhooks/providers/zoho-desk.ts):
Sim creates and tears down the Zoho Desk webhook subscription. Inbound events
are verified with JWT RS256 (X-ZDesk-JWT) against the data-center JWKS, ACKed
via the durable queue to meet Zoho's 5s deadline, and fail loudly on
Free/Standard editions that cannot create webhooks.
* fix(zoho-desk): OAuth PKCE, DC scope-marker parsing, SSRF, and e2e fixes
OAuth: forward code_verifier in the custom getToken (PKCE is enabled, so the
exchange must echo the verifier or Zoho rejects the request with invalid_request).
Surface Zoho's error/error_description, which it returns in the JSON body with
HTTP 200, instead of collapsing every failure into "no access token".
Data-center base parsing: better-auth persists Zoho's scopes comma-joined with no
spaces, so the greedy \S+ marker regex swallowed the whole scope list into the
host. Stop the capture at a comma or whitespace in both read sites (token route
and webhook handler), so apiDomain resolves to the real Desk host.
Attachment SSRF: replace the permissive host regex (which accepted attacker
domains like zoho.attacker.com) with a strict Zoho-apex suffix allowlist.
Block: guard Number() pagination so a non-numeric typo can't send NaN; add the
ignoreSourceId -> sourceId loop-guard header to update_ticket (matching add_comment).
Organizations route: surface fetch/Zoho failures with a real status instead of a
200 with an empty list, so the org selector no longer fails silently.
* fix(zoho-desk): webhook creation, attachment naming, and HTML content handling
Webhook trigger (verified end-to-end against a live Enterprise org):
- Omit ignoreSourceId; Zoho rejects a non-Zoho UUID with INVALID_DATA. Drop
the generateId() fallback and its providerConfig persistence.
- Answer Zoho's create-time notification-URL probe via the existing pending
webhook verification mechanism (GET/HEAD matchers) so subscription creation
no longer 405s.
- mapZohoWebhookError now surfaces Zoho's real errorCode / message / field
errors instead of a catch-all edition message, and attaches an HTTP status so
4xx flow through NonRetryableDeploymentError while 429/5xx stay retryable.
- Propagate the real status through deploy.ts so failed creates don't retry-loop.
get_attachment polish:
- Return the downloaded file's name under `name` (ToolFileData key) instead of
`filename`, and derive it (explicit -> Content-Disposition -> URL segment ->
fallback) so attachments are no longer stored as "untitled".
- Gate the add_comment-only `contentType` param so it isn't sent to get_attachment.
HTML content handling (Zoho content fields emit raw HTML):
- Add a Zoho-local html-to-text converter mirroring the Outlook dual-field
pattern: when contentType is 'html', derive a plain-text `contentText`
alongside the untouched raw `content` + `contentType`; plainText mirrors.
- Apply to comments (list/add), threads (list/get), the ticket description
(descriptionText), and the webhook trigger payload.
Trigger org selector: Organization is now a credential-scoped combobox that
lists the connected account's Zoho Desk organizations.
* fix(zoho-desk): review round - DC-base derivation, org-loader resilience, batched-event visibility
- deriveZohoDeskBaseFromApiDomain: preserve an already-regional desk.zoho.<tld>
api_domain instead of falling back to the US (.com) data center, and map the
DC TLD from any zoho(apis).<tld> host - keeps Desk calls in the right data
center for residency.
- fetchZohoDeskOrganizationOptions: wrap the token/org fetch in try/catch and
degrade to an empty list (the org field is a free-text combobox, so manual
entry still works) instead of hard-failing the selector on token/DC/network
errors.
- formatInput: warn (not silently drop) if Zoho ever delivers more than one
event in a single payload.
* fix(zoho-desk): harden attachment download against redirect-based SSRF/token leak
Replace the raw fetch in the attachment route with secureFetchWithValidation
(the same guarded fetch the copilot file-download tool uses). The download URL
is user/LLM-influenced and Zoho may redirect, so auto-following redirects could
send the OAuth token / orgId to an untrusted or internal host. The guarded fetch
pins the resolved IP, blocks private/reserved targets on every hop, drops the
Authorization header if a redirect leaves the origin (stripAuthOnRedirect), and
enforces the 50MB cap while streaming. The strict Zoho apex allowlist still
gates the initial origin as defense in depth.
* fix(zoho-desk): only add the edition hint when Zoho's error indicates it
mapZohoWebhookError appended the "requires Professional edition or higher"
guidance to every 403, but a 403 can also mean a wrong org, a missing scope, or
a bad token. Gate the hint on Zoho's own errorCode / message matching the
permission/edition pattern instead of the bare status, so unrelated 403s surface
Zoho's real reason without the misleading suffix. Adds a test for the
non-edition 403 path.
* fix(zoho-desk): stop duplicating /api/v1 when resolving a relative attachment href
A relative attachment href that already starts with `api/v1` (as Zoho's hrefs
often do) was concatenated onto getZohoDeskApiBase (which ends in /api/v1),
producing `/api/v1/api/v1/...` and a failing download. Extract a tested
resolveZohoAttachmentUrl helper that uses absolute hrefs as-is and strips a
leading slash + `api/v1/` prefix from relative ones before joining, so the path
is correct for absolute, root-relative, and api/v1-prefixed hrefs alike.
* fix(zoho-desk): reject an empty update_ticket PATCH with a clear error
update_ticket built its PATCH body from optional fields via filterUndefined, so
a call with no fields set sent `{}` and surfaced an opaque Zoho failure. Guard
the body builder to throw an actionable "provide at least one field" error
before the request. Adds a test for the empty and populated body paths.
* fix(zoho-desk): fall back to the credential Desk domain in webhook JWT verify
verifyAuth chose the JWKS host from providerConfig.apiDomain and otherwise
defaulted to the US host (desk.zoho.com), so a non-US webhook row missing
apiDomain would verify against the wrong JWKS and reject legitimate events. When
apiDomain is absent, resolve it from the OAuth credential's __zoho_domain__ scope
marker (mirroring deleteSubscription). The persisted-apiDomain fast path stays
DB-free to respect the 5s delivery deadline. Adds tests for both paths.
* fix(zoho-desk): apply the Zoho host allowlist to the organizations route
The organizations route built its URL from the client-supplied apiDomain and
attached the OAuth token without the https-Zoho-host allowlist the attachment
route already enforced, so a session-access caller could point the server at an
arbitrary origin and leak the token. Extract the shared isZohoHost allowlist and
an assertZohoUrl guard into tools/zoho_desk/utils (two consumers now), guard the
organizations URL before fetching, and refactor the attachment route to reuse
the shared helper. Adds tests for the allowlist and guard.
* fix(zoho-desk): propagate provider 4xx in the stable webhook prepare path
The v2 stable deploy preparation flattened every registration failure (except
path conflicts) to HTTP 500, so a provider-attached permanent 4xx - e.g. Zoho's
edition/validation failures from createSubscription - retried instead of failing
the deploy terminally. Propagate the attached status (`?? 500`), matching the
legacy save path's status-aware mapping so both deploy paths route 4xx through
NonRetryableDeploymentError.
* fix(zoho-desk): make createSubscription config failures non-retryable
createSubscription threw plain Errors (no status) for missing orgId, event type,
or credentials, and for a Zoho success with no webhook id - so the deploy outbox
mapped them to 500 and retried permanent configuration failures. Attach a 4xx
via statusError (400 for missing config/credentials; 422 for the no-id anomaly,
where a retry risks duplicate webhooks) so they fail the deploy terminally like
the mapped Zoho API 4xx responses. Tests assert the 400 status on the guard paths.
* fix(zoho-desk): enrich prevState with contentText symmetrically with payload
formatInput derived plain-text contentText only on payload, so an update event
for a comment/thread left prevState as raw HTML while payload carried
contentText - inconsistent shapes for before/after comparisons. Apply
withDerivedContentText to prevState too. Test asserts both are enriched.
* docs(zoho-desk): regenerate integration docs
Regenerate zoho_desk.mdx from the current tool definitions: removes the stale
add_comment `ignoreSourceId` input row (the field was dropped because Zoho
rejects arbitrary values) and adds the derived `contentText` / `descriptionText`
plain-text fields on comments, threads, and tickets.
* fix(zoho-desk): validate the persisted Desk base against the strict host allowlist
deriveZohoDeskBaseFromApiDomain trusted any host matching `desk.zoho.[a-z.]+`,
so a crafted api_domain like `desk.zoho.com.attacker.com` passed and was
persisted as the credential's `__zoho_domain__` REST base - later receiving the
OAuth token on every Desk tool/webhook call. Gate the derivation on the strict
isZohoHost apex allowlist (which rejects that lookalike), extracted with
assertZohoUrl into a dependency-free host-allowlist module so the auth
token-exchange path validates hosts without pulling in the tool utilities. The
attachment and organizations routes now import the shared guard from there.
Also: formatInput now emits the normalized null trigger shape for an empty/
malformed event array instead of leaking a raw `[]` to downstream steps. Tests
cover the empty-array shape and the lookalike-host rejection.
* fix(zoho-desk): correct API field names, scopes, and host validation
Validation pass against Zoho's published Desk API surfaced six defects that
typecheck, lint, and the existing suite all passed over, because each one fails
silently against the live API rather than erroring.
Wire-name mismatches (Zoho ignores unknown keys, so all three were silent):
- update_ticket sent `customFields`; the ticket PATCH body names it `cf`.
`customFields` exists only as a deprecated alias on other Desk resources and
on the separate validate-field-updates endpoint, so updates reported success
and applied nothing.
- ZOHO_DESK_TICKET_PROPERTIES and ZOHO_DESK_CONTACT_PROPERTIES advertised a
`customFields` output; both resources return `cf`. The declared field always
resolved undefined and the real one was undeclared.
- list_tickets sent `departmentId`; the query param is `departmentIds`, so the
department filter was dropped and every department's tickets came back.
Content handling:
- deriveZohoContentText matched `contentType === 'html'`, but Zoho spells the
discriminator per resource: comments use `html`, threads use the MIME form
`text/html`. Every thread's `contentText` was therefore raw markup - the exact
opposite of the field's purpose. Now normalized across both spellings,
parameterized values, and casing, with regression tests.
Scopes (least privilege):
- Desk.tickets.ALL -> Desk.tickets.READ + Desk.tickets.UPDATE. No tool creates
or deletes a ticket; ALL additionally granted ticket DELETE.
- Dropped Desk.search.READ (no search tool exists) and Desk.webhooks.READ /
.UPDATE (the provider only creates and deletes), plus their orphaned
SCOPE_DESCRIPTIONS entries.
Host validation - the webhook provider was the only token-carrying path not
anchored to the Zoho apex allowlist, including the JWKS fetch, where an
unrecognized host would have stood in as the JWT issuer:
- createSubscription, deleteSubscription, and verifyAuth now route their base
through a shared allowlist check.
- getZohoDeskApiBase validates rather than trusting injection precedence.
- The organizations route uses secureFetchWithValidation with
stripAuthOnRedirect, matching the attachment route it had diverged from.
Block and trigger:
- The trigger's department field is renamed `triggerDepartmentIds`; sharing the
`departmentIds` id let a value typed as a list_tickets filter become the
webhook subscription's filter when switching modes.
- `isPublic` no longer serializes onto all ten operations, matching the existing
gating for `contentType`.
- from/limit reject negatives and fractions instead of forwarding them.
- update_ticket gains description, resolution, and classification (all already
declared as outputs), and a departmentId input so a ticket can be moved.
Accuracy corrections to user-facing text, all against the published parameter
tables: `from` is 0-based (0-4999, default 0), not 1-based; per-endpoint limits
are tickets 1-100/10, comments 1-100/50, threads 1-200/100; sortBy lists Zoho's
actual allowed values; the two `include` sets genuinely differ per endpoint;
status and priority accept comma-separated lists.
Also: path IDs are trimmed via requireZohoDeskId so a pasted trailing space
fails with a clear message instead of a %20 404; comment `commenter` and thread
`status`/`isDescriptionThread`/`visibility`/`canReply` are now declared;
ZOHO_CLIENT_ID/SECRET added to the oauth test env; docs page gains a
MANUAL-CONTENT intro covering capabilities, the Professional-edition webhook
requirement, and the US-data-center limitation.
Not verified from documentation, needs a live account before merge:
- the OAuth scope for the attachment content sub-path (Zoho publishes none, and
there is an unanswered SCOPE_MISMATCH report against it)
- 12 of the 17 offered webhook event ids (5 are confirmed); Ticket_Delete is
documented but not offered
- the ticket `descriptionContentType` key, and the POST /api/v1/webhooks body
shape, neither of which appears in any reachable Zoho reference
* chore(zoho-desk): regenerate tool metadata
The param and description corrections in the previous commit changed the
generated tool surface, so tool-metadata:check failed in CI. Regenerated;
the diff is two Zoho-only lines.
* fix(zoho-desk): stop posting null for untouched update_ticket fields
`filterUndefined` strips only `undefined`, but an untouched subBlock never
arrives as `undefined`: the workflow serializer initializes every subBlock value
to `null` (stores/workflows/utils.ts) and extractBlockParams writes those nulls
straight into tool params, with nothing between the serializer and request.body
filtering them.
Reproduced against the real serializer and block with only `status` set:
basic {"subject":null,"status":"Closed"}
advanced {"subject":null,"status":"Closed","priority":null,...,"cf":null}
`subject` leaks even in basic mode because it declares no `mode`, so
shouldSerializeSubBlock never drops it. Zoho documents subject as a writable
field, so every status-only edit either failed the PATCH or blanked the ticket's
subject; in advanced mode the whole update surface nulled out, including `cf`.
Two things hid this. The empty-PATCH guard was unreachable from the block (the
body always carried at least `subject`), and the existing test called buildBody
with fields *absent* rather than null - the shape the block never produces - so
it could not fail on the real path.
Replaces filterUndefined with a local omitUnset that drops undefined, null, and
'' (a cleared input means "leave unchanged", not "set to empty"). Adds three
tests using the real serializer shape, all verified to fail before the fix.
Also fixes the same null-blindness in the block's param mapping, where
Number(null) === 0 injected from=0 on every operation, and corrects the shared
limit placeholder, which claimed max 100 while list_threads allows 200.
* feat(zoho-desk): add Self Client service-account credential
Adds a second way to connect Zoho Desk, alongside the interactive OAuth flow: a
Zoho Self Client, pasted as client id + client secret + organization id. Built
on the existing client-credential-accounts framework rather than a new credential
path, so it behaves like the Zoom Server-to-Server and Box CCG accounts already
in the repo - a short-lived token minted on demand, no refresh token.
Two Zoho behaviors the generic framework does not cover:
- `scope` must be COMMA-separated on Zoho's token endpoint; a space-separated
list is rejected as an invalid scope. The list comes from
getCanonicalScopesForProvider('zoho-desk'), so the Self Client and the OAuth
flow can never drift apart on scopes.
- Zoho reports OAuth failures in the JSON body, frequently with HTTP 200
(e.g. {"error":"invalid_client"}), so the success body is inspected for an
`error` field before the token is read - a status-only check would accept a
failed mint.
deriveZohoDeskBaseFromApiDomain moves out of auth.ts into the dependency-free
host-allowlist module so the minter and the OAuth path share one derivation
instead of duplicating it, and the mint response's api_domain now flows through
to tools as `apiDomain` (the SA branch of the token route previously returned
none, so SA calls would have assumed desk.zoho.com).
Docs: hand-authored zoho-desk-service-account.mdx following the existing
*-service-account.mdx pages, registered in meta.json and in the generator's
keep-list so stale-page cleanup does not delete it.
Known limitation, documented in the descriptor helpText and the docs page:
webhook triggers still require an OAuth connection. Webhook provisioning resolves
credentials through getCredentialOwner/refreshAccessTokenIfNeeded, which is
OAuth-account-only for every provider in the repo - not a Zoho-specific gap.
Unverified from documentation, needs a live Zoho org before merge:
- the `ZohoDesk.` soid prefix. Zoho documents only the syntax
{servicename}.{zsoid} with a single CRM example; no first-party doc states the
Desk prefix. normalizeZohoDeskSoid passes through any value already containing
a '.', so an operator can paste a corrected full soid without a code change.
- whether zsoid is the same identifier as the Desk orgId header value.
- whether the client-credentials endpoint accepts Desk.webhooks.CREATE/DELETE
for a Self Client.
- whether the mint response populates api_domain for Desk (documented for CRM);
if absent the derivation falls back to the US Desk host.
* fix(zoho-desk): derive descriptionText for ticket-shaped payloads
Cursor Bugbot: webhook ticket events reached workflows as raw HTML with no
plain-text sibling. `withDerivedContentText` only looked at `content` /
`contentType`, but ticket resources carry their body on `description` /
`descriptionContentType`, so trigger output disagreed with get_ticket.
The helper now derives both, which also removed two inconsistencies on the tool
side: get_ticket had its own inline copy of the derivation (now one shared
implementation that cannot drift), and update_ticket returned its PATCH response
raw despite the shared output map declaring descriptionText.
`descriptionContentType` remains the one field name unconfirmed in any Zoho
reference. It degrades safely - an absent key makes deriveZohoContentText return
the value unchanged, so descriptionText mirrors description rather than breaking,
exactly as get_ticket already behaved - and it is now one helper to correct if
Zoho names it differently.
* feat(zoho-desk): let the service account pick its data center
Zoho's accounts server is per region, and the integration pinned every call to
the US host. For the interactive OAuth flow that is currently unavoidable -
better-auth's authorize/token URLs are static per provider - but the service
account mints its own token, so the region can simply be chosen. This makes the
Self Client the only way a non-US Zoho org can connect.
Adds an optional `dataCenter` field to the client-credential framework. Optional
matters: ClientCredentialAccountFieldId and ClientCredentialAccountFields are
shared with Zoom, Box and Salesforce, whose descriptors and minters are
unchanged. Blank keeps the previous behavior (US), so existing credentials are
unaffected.
Only us/eu/in/au are offered - the four regions where both the accounts server
and the Desk REST host are confirmed. CA is deliberately absent: Zoho's accounts
docs say accounts.zohocloud.ca while Zoho's own Desk SDK says accounts.zoho.ca,
and the two cannot both be right. JP/SA/CN/UK lack a confirmed Desk host.
The Desk base is now derived from the selected region rather than inferred from
the mint response, which also removes a dependency on `api_domain` being
populated for Desk (Zoho documents it for CRM only). When `api_domain` IS present
and disagrees with the region, it wins - it is authoritative about where the
token actually works - and the mismatch is logged so a mis-selected region is
diagnosable. deriveZohoDeskBaseFromApiDomain gains a `try` variant returning
undefined so an untrusted api_domain can no longer masquerade as an authoritative
US answer and silently override a correct region.
A wrong region fails loudly rather than silently: the minter runs as verification
on both create and reconnect, so the credential is never persisted in a broken
state. Because Zoho reports it as `invalid_client` - a Self Client only exists on
its own region's accounts server - the operator hint for that code now names the
data center as a candidate cause.
Copy is scoped per path rather than blanket "US only": the OAuth service
description, trigger setup instructions, and the docs intro now say which path
each limitation applies to, and the service-account page documents the four
regions with a sign-in-domain to region-code table.
* fix(zoho-desk): strip ticket description HTML, classify body-reported refresh failures
Final validation pass findings.
descriptionText never stripped anything. It was gated on a
`descriptionContentType` discriminator that Zoho does not send: the Ticket_Add
webhook sample ships `"description": "<div>Description</div>"` with no such key,
and the ticket GET/PATCH response field lists have no content-type sibling
either. So get_ticket, update_ticket, and every webhook ticket payload emitted
descriptionText as a byte-identical copy of the raw HTML, while the declared
output promised stripped text.
The tests did not catch it because they fabricated the shape - both fixtures
constructed `descriptionContentType: 'html'`, a key Zoho never emits, proving the
branch works without proving it is ever taken. Ticket descriptions are HTML by
convention, so the strip is now unconditional (html-to-text is a near-identity on
genuinely plain text), an explicit descriptionContentType is still honored if
Zoho ever adds one, and the fixtures now use Zoho's real shape with no
content-type key anywhere.
A body-reported refresh failure was unclassified. Zoho answers a revoked refresh
token with HTTP 200 and `{"error":"invalid_client"}`; refreshOAuthToken only
checked `data.ok === false` (a Slack-ism), so the request fell through to the
"no access token" guard and returned no errorCode. isTerminalRefreshError could
therefore never recognize invalid_client as terminal, the credential was never
marked dead, and every later execution retried a refresh that cannot succeed -
with the user shown "No access token in refresh response" instead of a reconnect
prompt. The body is now classified before the status is trusted, matching what
the token exchange and the service-account mint already did. That guard also
stopped logging the whole response body, which carries live tokens on a partial
success.
Also: an unrecognized dataCenter now fails with a named error instead of quietly
resolving to US and surfacing as an opaque invalid_client (blank still means US);
the webhook JWKS cache is bounded, since its key derives from a providerConfig
field that SYSTEM_MANAGED_FIELDS protects from diffing but not from being
written; and the attachment `size` output no longer asserts bytes, a unit Zoho
documents as KB.
* feat(zoho-desk): canonical selectors and BlockMeta skills
The block picked its organization with an ad-hoc `combobox` + `fetchOptions`.
Only five blocks in the repo did that, and the other four are core blocks
(agent/credential/function/logs) - no other OAuth integration used it. Every
other resource a user has to identify was a bare short-input taking an opaque
numeric id.
Zoho Desk now uses the same machinery as the other 25 selector providers:
hooks/selectors/providers/zoho-desk/selectors.ts registered in the selector
registry, consumed from the block as basic selector + advanced manual input
sharing one canonicalParamId, for organization, update-ticket department, and
the list-tickets department filter. The trigger's org field moves to the same
selector. zoho-desk-org-options.ts is deleted rather than left beside the new
path, so blocks/ has zero fetchOptions usages outside the core blocks.
Wire params are unchanged (orgId, departmentId, departmentIds, assigneeId,
ticketId, contactId) - this is a UI change, not an API change.
The organizations route now resolves the credential server-side. It previously
had the browser fetch an access token and POST it back, which an earlier audit
flagged as the one place a Zoho token left the server; the new selector-credential
resolver keeps it server-side for both the OAuth and service-account credential
types and re-anchors every outbound host to the Zoho apex allowlist.
No agents selector: the endpoint is documented but its OAuth scope is not, and
the nearest evidence points at Desk.agents.READ, which we do not request. Adding
it would force every existing Zoho Desk user to reconnect for a convenience
field, so assigneeId stays a manual input until the scope can be confirmed
against a live org.
Adds the skills array BlockMeta was missing - 227 of 300 blocks declare one and
this did not. Seven skills, each grounded in a use case Zoho or the ecosystem
actually advertises (auto-triage, SLA escalation, digest, AI draft reply,
customer context, engineering handoff, knowledge-gap report) and each exercising
only tools in tools.access. CSAT surveys, ticket creation, dedup and keyword
search were deliberately left out: the integration has no tool for them, and a
skill implying an unsupported action is worse than a shorter list.
* feat(zoho-desk): agents selector and free-text trigger organization
Three improvements that were previously deferred only to avoid forcing existing
users to reconnect or orphaning saved workflows. This integration is unmerged and
has no users, so the constraint does not apply and the better option wins.
assigneeId was the last field still asking for an opaque numeric id. It is now a
canonical selector pair backed by a new zoho_desk.agents selector, which required
adding the Desk.agents.READ scope - the reason it was skipped before. Route
follows the departments one exactly: auth before parseRequest, host anchored to
the Zoho apex allowlist, secureFetchWithValidation with stripAuthOnRedirect, and
a page drain capped at 20 pages with 204 treated as end-of-list.
Scope caveat: Zoho publishes no explicit scope line for the list-all
GET /api/v1/agents. Every other endpoint in the Agents module documents
Desk.agents.READ (get by id, get by email, roles/{id}/agents), and it is the only
agents-module scope Zoho defines, so that is the basis. Inference across a module
rather than a direct quote - worth one live call before merge, same as the
existing attachment-scope note.
The trigger regained free-text organization entry, lost when the org field became
a selector. The earlier concern - that a manual value would land under its raw
subBlock id and never reach the provider - turned out not to hold: buildProviderConfig
already collapses canonical pairs and writes the active member under the canonical
key. The real gap is narrower and does exist: when canonicalModes pins the group
to basic while only the manual field has a value, the collapse deletes the
canonical key even though the required-field check passes, so the deploy succeeds
and then fails at subscription time. resolveConfigOrgId closes that, with a test.
The block/trigger `orgId` id overlap stays shared, now with a comment. Two earlier
audits disagreed; renaming turns out to be the wrong call. buildCanonicalIndex has
an explicit guard for trigger-mode reuse and blocks.test.ts codifies it as a valid
pattern, orgId means the same portal in both modes (unlike departmentIds, which is
correctly distinct), and a separate triggerManualOrgId would put two advanced
members in one canonical group - getCanonicalValues takes the first non-empty, so
a stale tool-mode value could silently supply the trigger's organization.
* fix(zoho-desk): make the attachment cap reachable, unbreak selector paging
Final audit round.
The 50 MB attachment ceiling could never be hit. This route returns the file as
base64 inside its JSON body, and the executor reads internal tool responses
through readToolResponseBody, capped at 10 MB. Base64 inflates 4/3, so ~7.5 MB
of raw bytes is the real ceiling - and the old limit meant a larger attachment
was downloaded, encoded and serialized in full (peaking near 250 MB of live
allocation, with nothing bounding concurrent downloads) purely to be rejected
afterwards. The cap is now the reachable size, so the limit enforces itself while
the bytes are still streaming, and an overflow returns 413 with the actual
ceiling instead of a generic 500. Raising it properly means uploading in the
route and returning a file reference, as the WhatsApp media route does - not a
bigger constant.
Selector paging assumed a 0-based `from`. Zoho's docs contradict themselves:
the pagination section says "range 0-4999, default 0" while the listing examples
read as 1-based ("from=5 and limit=50 retrieves records 5 to 54"). Under the
1-based reading, stepping by exactly the page size re-fetches the boundary record
and the dropdown shows a duplicate per page. Rather than pick a base that cannot
be confirmed without a live tenant, the department and agent drains dedupe by id,
which is correct under either reading.
The organization list was unpaginated, and Zoho's listing APIs default to ten per
page. An account with more accessible portals silently got a truncated dropdown,
and since every other selector and every tool call is gated on orgId, a missing
portal was unreachable except through the advanced manual field. Both the
selector route and list_organizations now request the documented maximum.
Docs: regenerated so the trigger table includes manualOrgId, and two
service-account claims are hedged to match what the code already says it cannot
verify - that zsoid equals the Desk orgId header value, and that every tool works
under the requested scopes (Zoho publishes no scope for the attachment content
sub-path).
Also: status and priority move out of advanced mode - they are the fields most
often changed on a ticket update; the custom-fields wand prompt now ends with the
required "Return ONLY" clause; and the shared-orgId rationale comment cites the
mechanism that actually applies (buildCanonicalIndex dedupe plus the first-non-
empty rule in getCanonicalValues) rather than a blocks.test.ts branch that never
evaluates this pair.
* fix(zoho-desk): five-audit round - serializer trigger-advanced leak, scopes, paging
Five independent audits (OAuth/scopes, tools-vs-docs, block/selectors,
blast-radius, /validate-trigger). Findings, most severe first.
A trigger-mode field was a live tool-mode required param. `shouldSerializeSubBlock`
excluded `mode: 'trigger'` but not `'trigger-advanced'`, so the trigger's required
`manualOrgId` validated on every tool operation. Reproduced against the real
serializer: with the Organization field pinned to advanced, running
List Organizations failed with "Missing required fields: Organization ID" - a
field that operation does not even render, and which the user could not clear
without switching operations. Fixed in the serializer rather than locally,
because the Google Sheets/Drive/Calendar pollers have the identical shape.
`limit=200` on /organizations was an undocumented parameter I added by
extrapolating from /departments and /agents. Zoho documents NO parameters for
that endpoint and its sample is a bare GET; the other siblings cap at 100 and
Zoho answers out-of-range with 422. Since orgId gates every tool and both other
selectors, a 422 there would have made the whole integration unreachable. Reverted
to Zoho's documented shape.
`descriptionText` was HTML-stripping plain text. The previous round made the strip
unconditional after finding Zoho sends no `descriptionContentType`, but Zoho's REST
samples show plain descriptions while only the webhook payload is HTML - and the
webhook path runs this over contact/account/department bodies too. html-to-text is
not identity on plain text: it decodes entities and deletes tag-shaped content
("a < b > c", XML snippets). Now sniffs for markup first.
`omitUnset` made every documented field-clear impossible. Zoho's own PATCH sample
uses `"classification": ""` and `"productId": ""` to clear. Dropping `''` meant no
scalar field could be cleared. Now drops only undefined/null - the serializer-null
case it was written for - and forwards `''`.
status/priority leaked between operations. One shared subBlock served both the
list_tickets filter and the update_ticket value, and subBlock values survive an
operation switch, so a filter of "Open,On Hold" could be PATCHed onto a ticket and
an update value could silently filter a later list. Split per operation.
Auth: `invalid_code` added to TERMINAL_ERRORS - it is Zoho's code for a revoked
refresh token, so without it the previous round's refresh fix never actually
dead-flagged the credential it was written for. The shared refresh body-error
branch now also requires `!data.access_token`, so no provider can have a
successful refresh misclassified. The token route now uses the validating
`extractZohoDeskBaseFromScope` instead of a private regex with no https/allowlist
check - that value is injected into every tool call. Scope list falls back to the
requested scopes when Zoho omits `scope`, which would otherwise flag every
credential as needing reconnect. The Self Client mint no longer sends
`aaaserver.profile.READ`, a scope that grant never uses.
Trigger: `includePrevState` now set for every *_Update event, not just tickets -
it defaults to false, so prevState was permanently null for contact/agent/task/
article updates while the trigger advertised it. `departmentIds` is only sent for
events Zoho documents as accepting it, and the field is conditioned accordingly.
Empty filters serialize as `null`, matching Zoho's examples, rather than `{}`.
JWKS fetch bounded to 1.5s - jose's default is 5000ms, exactly Zoho's whole
delivery deadline, and Zoho publishes no retry. The create-time validation POST
fallback is now matched by the pending-verification probe. Ticket_Delete added.
All 17 webhook event ids, the POST /api/v1/webhooks body contract, and the JWT
claim/JWKS specifics are now confirmed verbatim against Zoho's webhook
documentation - previously 12 of 17 events and the entire subscription contract
were unverified.
* revert(zoho-desk): back out both shared lib/oauth changes
Reverting two changes to shared OAuth code because their premise is inferred
rather than proven, and neither meets the bar for touching a path every provider
runs.
`refreshOAuthToken` body-error branch. The premise was that Zoho reports refresh
failures with HTTP 200 and an `error` body. That is documented and empirically
confirmed for the authorization-code EXCHANGE (see the comment on getToken in
auth.ts), but I never confirmed it for the REFRESH grant specifically - and if
Zoho returns a proper 4xx there, the existing `!response.ok` path already
classifies it via extractErrorCode, making the branch dead code that every one
of the ~34 providers still executes on each refresh. A shared branch whose only
justification is an unverified inference about one provider is not worth its
blast radius.
`invalid_code` in TERMINAL_ERRORS. Same problem, worse downside: the code is
sourced from a Zoho community post rather than official docs, TERMINAL_ERRORS is
consulted for every provider, and a false positive marks a credential dead for an
hour. Not adding it simply preserves today's behavior (retry rather than
dead-flag), so reverting costs nothing that was previously working.
Both are cheap to reinstate, correctly scoped, once a live Zoho account shows
what a revoked refresh token actually returns.
Kept: the token-redaction on the "no access token" warn, which is an unambiguous
improvement independent of Zoho.
Also kept, deliberately, is the serializer `trigger-advanced` exclusion - that one
rests on a reproduced bug rather than an inference, and it aligns the serializer
with the convention the rest of the codebase already follows (blocks.test.ts
treats `trigger` and `trigger-advanced` identically in six places, as does the
copilot block-metadata tool, and blocks/types.ts documents trigger-advanced as
"the advanced side of a trigger field").
* fix(zoho-desk): carry the stored data center through a credential reconnect
A reconnect rebuilds the service-account secret blob from the submitted fields
only, and the connect modal never prefills - correctly, since for every other
field in this family the stored value is a secret the admin must retype. The
data center is the first non-secret member of that set, so it was being silently
dropped: rotating a client secret on an EU/IN/AU credential moved it back to the
US accounts server, where the next mint fails with an opaque invalid_client.
performUpdateCredential now reads the stored dataCenter out of the existing blob
when the caller does not supply one. The read is failure-tolerant - an
undecryptable or unparseable blob yields undefined rather than throwing, so it
can never block a reconnect, and the provider default applies as before.
Raised independently by three reviewers; I twice argued it was acceptable because
the mint fails loudly rather than corrupting silently. That was true and beside
the point - the operator still had to guess why.
* fix(zoho-desk): delta-audit findings - prevState scope, status leak, HTML sniffer
An audit of the commits the earlier five audits never saw. All four findings are
in code written as fixes for those audits, which is where this branch has
repeatedly introduced new problems.
`includePrevState` was sent for Ticket_Comment_Update. The previous commit gated
it on an `_Update` suffix and claimed Zoho supports it on every update event.
Zoho's webhook doc lists the attribute on Ticket/Contact/Agent/Task/Article update
events but NOT on Ticket_Comment_Update, which documents only `departmentIds`.
That made it an undocumented filter key on a live subscription create - the same
class of risk the same commit reverted `limit=200` for, so it failed that commit's
own stated bar. Now an explicit set rather than a suffix rule.
The status/priority split did not stop the leak it was written for. The mapping
used `operation === 'list_tickets' ? filterValue : updateValue`, whose bare else
covers all eight other operations - so a stale Update Ticket status was forwarded
into get_ticket, list_comments and the rest. Harmless on the wire (those tools
ignore it) but exactly the stale-value pattern the neighbouring gates exist to
prevent. Both fields are now scoped to the two operations that declare them.
The HTML sniffer destroyed plain text. `/<[a-z!\/][^>]*>/` fires on any `<`
followed by a letter with a later `>`, so realistic ticket bodies lost content:
"if x<y then z>0" became "if x0", and "replace <username> with the real name"
lost the placeholder. It now requires a real element - a paired tag, a
self-closing tag, a comment/doctype - or an entity, and the entity arm covers hex
references it previously missed. Regression tests verified by reverting to the
loose pattern and watching them go red.
The reconnect data-center carry-forward is scoped to client-credential providers.
As written it added a DB read plus a decrypt to every service-account reconnect
for every provider - Slack, Atlassian, all token-paste providers - to carry a
field only Zoho has.
Also: the JWKS cache-bound TSDoc had been orphaned onto the wrong constant by an
earlier insertion, and `cooldownDuration` was dropped since it restated jose's
default while only `timeoutDuration` needed justifying.
* test(zoho-desk): cover the webhook subscription filter rules
The subscription filter logic had no test coverage at all, and it is where the
last two rounds both found bugs - includePrevState on an event Zoho does not
document it for, and departmentIds sent to events that accept no filters.
Adds six cases against the real createSubscription: includePrevState is set for
each of the five documented update events and NOT for Ticket_Comment_Update,
departmentIds is kept for a filterable event and dropped for one that is not, and
an event with no filters serializes as null rather than an empty object.
Verified the guard bites: reverting PREV_STATE_EVENTS to the `endsWith('_Update')`
rule turns the Ticket_Comment_Update case red.
The Ticket_Comment_Update assertion checks the with-departments case as well as
the bare one - asserting only `not.toHaveProperty` on the bare filter would pass
vacuously, since that filter is legitimately null.
---------
Co-authored-by: Waleed Latif <walif6@gmail.com>
This commit is contained in:
co-authored by
Waleed Latif
parent
69289d26e7
commit
87aeca6f0c
@@ -2221,21 +2221,108 @@ export function EyeIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function ConfluenceIcon(props: SVGProps<SVGSVGElement>) {
|
||||
/**
|
||||
* Corporate Atlassian mark, used for family-wide Atlassian credentials — one
|
||||
* API token authenticates Jira, Jira Service Management, and Confluence, so no
|
||||
* single product mark represents it. Individual products keep their own icons.
|
||||
*/
|
||||
export function AtlassianIcon(props: SVGProps<SVGSVGElement>) {
|
||||
const id = useId()
|
||||
const gradientId = `atlassian_gradient_${id}`
|
||||
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 3 21 24'
|
||||
/*
|
||||
* The mark's artwork spans ~66 units; the box is padded to 84.5 so it
|
||||
* fills ~78% of its viewBox, matching the inset Atlassian ships on the
|
||||
* Jira and Confluence marks (artwork 16→116 inside 128). Without the
|
||||
* padding this renders ~30% heavier than its siblings in the same tile.
|
||||
*/
|
||||
viewBox='-9.2 -8.9 84.5 84.5'
|
||||
focusable='false'
|
||||
fill='none'
|
||||
aria-hidden='true'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={gradientId}
|
||||
gradientUnits='userSpaceOnUse'
|
||||
x1='28.536019'
|
||||
y1='35.528544'
|
||||
x2='11.406018'
|
||||
y2='65.208544'
|
||||
>
|
||||
<stop offset='0' stopColor='#0052cc' />
|
||||
<stop offset='0.92' stopColor='#2684ff' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
fill='#1868DB'
|
||||
d='M20.6 20.23c-6.58-3.18-8.51-3.66-11.28-3.66-3.25 0-6.03 1.36-8.51 5.16l-.407.62c-.333.51-.407.7-.407.92s.111.4.518.66l4.18 2.6c.221.15.406.22.59.22.22 0 .37-.11.59-.44l.666-1.02c1.03-1.57 1.96-2.09 3.14-2.09 1.03 0 2.26.293 3.77 1.02l4.37 2.05c.444.22.93.11 1.15-.403l2.07-4.54c.222-.512.07-.842-.444-1.1M1.41 12.22c6.58 3.18 8.51 3.66 11.28 3.66 3.26 0 6.03-1.35 8.51-5.16l.407-.622c.332-.512.41-.695.41-.915s-.11-.402-.518-.658L17.31 5.93c-.222-.147-.407-.22-.592-.22-.222 0-.37.11-.592.44l-.665 1.02c-1.04 1.57-1.96 2.09-3.14 2.09-1.04 0-2.26-.293-3.77-1.02L4.18 6.18c-.444-.22-.925-.11-1.15.402L.962 11.12c-.222.51-.74.84.444 1.1'
|
||||
fill={`url(#${gradientId})`}
|
||||
d='m 19.636018,30.518546 a 1.88,1.88 0 0 0 -3.2,0.35 l -16.2299998,32.46 a 1.94,1.94 0 0 0 1.73,2.81 H 24.536018 a 1.87,1.87 0 0 0 1.74,-1.1 c 4.87,-10 1.92,-25.37 -6.64,-34.52 z'
|
||||
/>
|
||||
<path
|
||||
fill='#2684ff'
|
||||
d='m 31.546018,1.038546 a 42.81,42.81 0 0 0 -2.5,42.27 l 10.95,21.73 a 1.94,1.94 0 0 0 1.73,1.08 h 22.6 a 2,2 0 0 0 1.67,-2.79 l -31.15,-62.29 a 1.83,1.83 0 0 0 -3.3,0 z'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ConfluenceIcon(props: SVGProps<SVGSVGElement>) {
|
||||
const id = useId()
|
||||
const topGradientId = `confluence_top_${id}`
|
||||
const bottomGradientId = `confluence_bottom_${id}`
|
||||
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 128 128'
|
||||
focusable='false'
|
||||
fill='none'
|
||||
aria-hidden='true'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={bottomGradientId}
|
||||
gradientUnits='userSpaceOnUse'
|
||||
x1='26.791'
|
||||
y1='28.467'
|
||||
x2='11.792'
|
||||
y2='19.855'
|
||||
gradientTransform='scale(4)'
|
||||
>
|
||||
<stop offset='0' stopColor='#0052cc' />
|
||||
<stop offset='0.918' stopColor='#2380fb' />
|
||||
<stop offset='1' stopColor='#2684ff' />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id={topGradientId}
|
||||
gradientUnits='userSpaceOnUse'
|
||||
x1='5.209'
|
||||
y1='2.523'
|
||||
x2='20.208'
|
||||
y2='11.136'
|
||||
gradientTransform='scale(4)'
|
||||
>
|
||||
<stop offset='0' stopColor='#0052cc' />
|
||||
<stop offset='0.918' stopColor='#2380fb' />
|
||||
<stop offset='1' stopColor='#2684ff' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
fill={`url(#${bottomGradientId})`}
|
||||
d='M19.492 86.227a249.047 249.047 0 00-3.047 4.933c-.867 1.45-.433 3.336 1.016 4.207l19.863 12.188c1.45.87 3.332.433 4.203-1.016a139.349 139.349 0 012.899-4.934c7.832-12.91 15.804-11.46 30.011-4.64l19.72 9.281c1.593.727 3.335 0 4.058-1.45l9.426-21.323c.722-1.453 0-3.336-1.454-4.063-4.203-1.887-12.464-5.805-19.714-9.43-26.82-12.914-49.586-12.043-66.98 16.247zm0 0'
|
||||
/>
|
||||
<path
|
||||
fill={`url(#${topGradientId})`}
|
||||
d='M108.508 37.773a249.047 249.047 0 003.047-4.933c.87-1.45.433-3.336-1.016-4.207L90.676 16.445c-1.45-.87-3.332-.433-4.203 1.016a133.55 133.55 0 01-2.899 4.934c-7.832 12.91-15.804 11.46-30.011 4.64l-19.72-9.281c-1.593-.727-3.331 0-4.058 1.45l-9.422 21.323c-.726 1.453 0 3.34 1.45 4.063 4.203 1.887 12.468 5.805 19.714 9.43 26.825 12.77 49.586 12.042 66.98-16.247zm0 0'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
@@ -2768,19 +2855,58 @@ export function LinkupIcon(props: SVGProps<SVGSVGElement>) {
|
||||
}
|
||||
|
||||
export function JiraIcon(props: SVGProps<SVGSVGElement>) {
|
||||
const id = useId()
|
||||
const middleGradientId = `jira_middle_${id}`
|
||||
const bottomGradientId = `jira_bottom_${id}`
|
||||
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 30 30'
|
||||
viewBox='0 0 128 128'
|
||||
width='24'
|
||||
height='24'
|
||||
focusable='false'
|
||||
fill='none'
|
||||
aria-hidden='true'
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={middleGradientId}
|
||||
gradientUnits='userSpaceOnUse'
|
||||
x1='22.034'
|
||||
y1='9.773'
|
||||
x2='17.118'
|
||||
y2='14.842'
|
||||
gradientTransform='scale(4)'
|
||||
>
|
||||
<stop offset='0.176' stopColor='#0052cc' />
|
||||
<stop offset='1' stopColor='#2684ff' />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id={bottomGradientId}
|
||||
gradientUnits='userSpaceOnUse'
|
||||
x1='16.641'
|
||||
y1='15.564'
|
||||
x2='10.957'
|
||||
y2='21.094'
|
||||
gradientTransform='scale(4)'
|
||||
>
|
||||
<stop offset='0.176' stopColor='#0052cc' />
|
||||
<stop offset='1' stopColor='#2684ff' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
fill='#1868DB'
|
||||
d='M11.03 21.99h-2.22c-3.35 0-5.75-2.05-5.75-5.05h11.93c.619 0 1.02.44 1.02 1.06v12.01c-2.98 0-4.98-2.42-4.98-5.78zm5.89-5.97h-2.22c-3.35 0-5.75-2.01-5.75-5.01h11.93c.618 0 1.06.402 1.06 1.02V24.04c-2.98 0-5.02-2.42-5.02-5.78zm5.93-5.93h-2.22c-3.35 0-5.75-2.05-5.75-5.05h11.93c.618 0 1.02.439 1.02 1.02v12.01c-2.98 0-4.98-2.42-4.98-5.78z'
|
||||
fill='#2684ff'
|
||||
d='M108.023 16H61.805c0 11.52 9.324 20.848 20.847 20.848h8.5v8.226c0 11.52 9.328 20.848 20.848 20.848V19.977A3.98 3.98 0 00108.023 16zm0 0'
|
||||
/>
|
||||
<path
|
||||
fill={`url(#${middleGradientId})`}
|
||||
d='M85.121 39.04H38.902c0 11.519 9.325 20.847 20.844 20.847h8.504v8.226c0 11.52 9.328 20.848 20.848 20.848V43.016a3.983 3.983 0 00-3.977-3.977zm0 0'
|
||||
/>
|
||||
<path
|
||||
fill={`url(#${bottomGradientId})`}
|
||||
d='M62.219 62.078H16c0 11.524 9.324 20.848 20.848 20.848h8.5v8.23c0 11.52 9.328 20.844 20.847 20.844V66.059a3.984 3.984 0 00-3.976-3.98zm0 0'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
@@ -7558,6 +7684,31 @@ export function SixtyfourIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The "sim" brand wordmark (v1.0 brand guide simLogotype paths — the same mark
|
||||
* the navbar/login header renders), inked with the theme-adaptive
|
||||
* `--text-body`. Used as the icon for the Auto model option; the wide viewBox
|
||||
* letterboxes itself inside square icon slots.
|
||||
*/
|
||||
export function SimAutoIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox='0 0 441 212'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
aria-hidden='true'
|
||||
>
|
||||
<g fill='var(--text-body)'>
|
||||
<path d='M0 160.9H29.51C29.51 169.08 32.46 175.61 38.37 180.48C44.27 185.12 52.25 187.44 62.31 187.44C73.24 187.44 81.65 185.34 87.56 181.14C93.46 176.71 96.41 170.85 96.41 163.55C96.41 158.24 94.77 153.82 91.49 150.28C88.43 146.74 82.75 143.86 74.44 141.65L46.24 135.01C32.03 131.47 21.42 126.05 14.43 118.75C7.65 111.45 4.26 101.83 4.26 89.88C4.26 79.93 6.78 71.3 11.81 64C17.05 56.7 24.16 51.06 33.12 47.08C42.3 43.09 52.8 41.1 64.6 41.1C76.41 41.1 86.57 43.2 95.1 47.41C103.84 51.61 110.62 57.47 115.43 64.99C120.46 72.52 123.08 81.48 123.3 91.87H93.79C93.57 83.47 90.84 76.94 85.59 72.3C80.34 67.65 73.02 65.33 63.62 65.33C54 65.33 46.57 67.43 41.32 71.63C36.07 75.83 33.45 81.59 33.45 88.89C33.45 99.73 41.32 107.14 57.06 111.12L85.26 118.09C98.81 121.19 108.98 126.28 115.76 133.35C122.53 140.21 125.92 149.61 125.92 161.56C125.92 171.74 123.19 180.7 117.73 188.44C112.26 195.96 104.72 201.82 95.1 206.03C85.7 210.01 74.55 212 61.65 212C42.85 212 27.87 207.35 16.72 198.06C5.57 188.77 0 176.38 0 160.9Z' />
|
||||
<path d='M232.8 212H202.13L202.13 49.76H229.54V77.39C232.8 68.34 239.11 60.66 247.81 54.7C256.73 48.52 267.5 45.43 280.12 45.43C294.26 45.43 306.01 49.29 315.36 57.02C324.72 64.75 330.81 75.01 333.64 87.82H328.09C330.27 75.01 336.25 64.75 346.04 57.02C355.83 49.29 367.9 45.43 382.26 45.43C400.54 45.43 414.89 50.84 425.34 61.66C435.78 72.47 441 87.26 441 106.03V212H410.98V113.65C410.98 100.84 407.71 91.02 401.19 84.17C394.88 77.11 386.29 73.58 375.41 73.58C367.79 73.58 361.05 75.34 355.17 78.88C349.52 82.19 345.06 87.04 341.8 93.45C338.53 99.85 336.9 107.36 336.9 115.97V212H306.55V113.32C306.55 100.51 303.4 90.8 297.09 84.17C290.78 77.33 282.19 73.91 271.31 73.91C263.69 73.91 256.95 75.67 251.08 79.21C245.42 82.52 240.96 87.38 237.7 93.78C234.43 99.96 232.8 107.36 232.8 115.97V212Z' />
|
||||
<path d='M184.83 20.55C184.83 31.9 175.64 41.1 164.29 41.1C152.95 41.1 143.76 31.9 143.76 20.55C143.76 9.2 152.95 0 164.29 0C175.64 0 184.83 9.2 184.83 20.55Z' />
|
||||
<path d='M179.43 212H149.16V49.76C153.76 51.91 158.88 53.12 164.29 53.12C169.7 53.12 174.83 51.91 179.43 49.76V212Z' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SimTriggerIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
@@ -8776,3 +8927,20 @@ export function LogfireIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ZohoDeskIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
aria-hidden='true'
|
||||
>
|
||||
<path
|
||||
d='M12 2.75c-4.28 0-7.75 3.47-7.75 7.75v3.1A2.6 2.6 0 0 0 3 16.35v1.3A2.6 2.6 0 0 0 5.6 20.25h1.15a.9.9 0 0 0 .9-.9v-4.9a.9.9 0 0 0-.9-.9H6.05v-2.15a5.95 5.95 0 0 1 11.9 0v2.15h-.7a.9.9 0 0 0-.9.9v4.9c0 .17.05.33.13.47-.5.6-1.24.98-2.08.98h-1.02a1.4 1.4 0 0 0-1.31-.9h-1a1.4 1.4 0 0 0 0 2.8h1a1.4 1.4 0 0 0 1.31-.9h1.02c2.06 0 3.74-1.63 3.83-3.67a2.6 2.6 0 0 0 1.44-2.33v-1.3a2.6 2.6 0 0 0-1.25-2.22v-3.1c0-4.28-3.47-7.75-7.75-7.75Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -248,6 +248,7 @@ import {
|
||||
ZendeskIcon,
|
||||
ZepIcon,
|
||||
ZeroBounceIcon,
|
||||
ZohoDeskIcon,
|
||||
ZoomIcon,
|
||||
ZoomInfoIcon,
|
||||
} from '@/components/icons'
|
||||
@@ -535,6 +536,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
zendesk: ZendeskIcon,
|
||||
zep: ZepIcon,
|
||||
zerobounce: ZeroBounceIcon,
|
||||
zoho_desk: ZohoDeskIcon,
|
||||
zoom: ZoomIcon,
|
||||
zoominfo: ZoomInfoIcon,
|
||||
}
|
||||
|
||||
@@ -261,6 +261,8 @@
|
||||
"zendesk",
|
||||
"zep",
|
||||
"zerobounce",
|
||||
"zoho-desk-service-account",
|
||||
"zoho_desk",
|
||||
"zoom",
|
||||
"zoom-service-account",
|
||||
"zoominfo"
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
---
|
||||
title: Zoho Desk Self Clients
|
||||
description: Set up a Zoho Self Client so your workflows can call Zoho Desk without a personal OAuth login
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps'
|
||||
import { FAQ } from '@/components/ui/faq'
|
||||
|
||||
A Zoho **Self Client** is an OAuth client that has no redirect URL and no end user. Instead of sending someone through a consent screen, your workflows authenticate to Zoho Desk with the client's own ID and secret, and Sim mints a short-lived access token on demand — no user consent to expire, and no personal login that breaks when someone leaves the team.
|
||||
|
||||
This is the recommended way to use Zoho Desk in production workflows: the credential belongs to your organization rather than a person, the granted scopes are explicit, and tokens are minted fresh whenever a workflow runs.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You need a Zoho account with access to the [Zoho API Console](https://api-console.zoho.com) for the same organization your Zoho Desk portal belongs to, and the Zoho Desk **organization ID** for that portal.
|
||||
|
||||
<Callout type="info">
|
||||
A Self Client can authenticate against the **US, EU, IN, or AU** accounts server — pick your region with the **Data center** field when you add the credential, or leave it blank for US. Organizations in the JP, CA, SA, CN, or UK data centers are not supported yet. API calls are then routed to the Desk host for that same region, so data residency is honored end to end.
|
||||
|
||||
The interactive **OAuth** connection is a separate path and remains **US-only** (`accounts.zoho.com`), so a non-US organization can connect only through a Self Client.
|
||||
</Callout>
|
||||
|
||||
<Callout type="warn">
|
||||
Zoho Desk **webhooks are a Professional-edition and above feature**. The Zoho Desk trigger in Sim provisions a webhook subscription, so it does not work on Free or Standard plans. The trigger also requires a personal **OAuth** connection rather than a Self Client — see [Triggers](#triggers-still-need-oauth) below.
|
||||
</Callout>
|
||||
|
||||
## Setting Up the Self Client
|
||||
|
||||
### 1. Create the Self Client
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
Sign in at [api-console.zoho.com](https://api-console.zoho.com) with the Zoho account that owns the Desk portal
|
||||
</Step>
|
||||
<Step>
|
||||
Click **Add Client**, choose **Self Client**, and click **Create** — then **OK** on the confirmation
|
||||
|
||||
{/* TODO(screenshot): Zoho API Console Add Client dialog with Self Client selected */}
|
||||
</Step>
|
||||
<Step>
|
||||
Open the new client and switch to the **Client Secret** tab. Copy the **Client ID** and **Client Secret** — these are two of the three values you'll paste into Sim
|
||||
|
||||
{/* TODO(screenshot): Self Client Client Secret tab showing Client ID and Client Secret */}
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Callout type="info">
|
||||
You do **not** need the **Generate Code** tab. That tab produces a one-time authorization code for the code-exchange flow; the client-credentials flow Sim uses needs only the client ID, the client secret, and your organization ID.
|
||||
</Callout>
|
||||
|
||||
### 2. Find Your Organization ID
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
Open Zoho Desk and go to **Setup** (the gear icon) → **Developer Space** → **API**
|
||||
</Step>
|
||||
<Step>
|
||||
Copy the numeric **Organization ID** (also called the Org ID or portal ID) shown there — for example `600123456`
|
||||
|
||||
{/* TODO(screenshot): Zoho Desk Setup > Developer Space > API showing the Organization ID */}
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Callout type="warn">
|
||||
This must be the organization ID of the Desk portal you want the workflows to act on. If your Zoho account has more than one Desk portal, using the wrong ID makes Zoho either reject the token request or issue a token scoped to the wrong portal.
|
||||
</Callout>
|
||||
|
||||
### 3. Know Your Data Center
|
||||
|
||||
Zoho hosts each organization in one data center, and the accounts server that issues tokens is per region. Look at the URL you use to sign in to Zoho Desk and pick the matching code:
|
||||
|
||||
| Data center | Sign-in domain | Code to enter |
|
||||
| --- | --- | --- |
|
||||
| United States | `zoho.com` | `us` (or leave blank) |
|
||||
| Europe | `zoho.eu` | `eu` |
|
||||
| India | `zoho.in` | `in` |
|
||||
| Australia | `zoho.com.au` | `au` |
|
||||
|
||||
Organizations in the JP, CA, SA, CN, and UK data centers cannot be connected yet.
|
||||
|
||||
### 4. Scopes
|
||||
|
||||
Sim requests exactly the scopes its Zoho Desk tools and trigger exercise:
|
||||
|
||||
```
|
||||
Desk.tickets.READ
|
||||
Desk.tickets.UPDATE
|
||||
Desk.contacts.READ
|
||||
Desk.agents.READ
|
||||
Desk.basic.READ
|
||||
Desk.webhooks.CREATE
|
||||
Desk.webhooks.DELETE
|
||||
aaaserver.profile.READ
|
||||
```
|
||||
|
||||
Sim sends this list on every token request, so there is nothing to pre-configure on the Self Client itself. If Zoho rejects the request with an invalid-scope error, the Self Client's owner does not have access to one of the Desk modules above in that organization.
|
||||
|
||||
A scope that is granted but insufficient surfaces at run time as a `4xx` from the Zoho Desk API naming the scope problem.
|
||||
|
||||
### 5. Protect the Client Secret
|
||||
|
||||
The client secret is bearer material for your Zoho Desk organization, limited only by the scopes above. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts it at rest.
|
||||
|
||||
<Callout type="info">
|
||||
Regenerating or revoking the Self Client in the Zoho API Console invalidates the stored pair immediately. If you rotate it, update the credential in Sim right away.
|
||||
</Callout>
|
||||
|
||||
## Adding the Self Client to Sim
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
Open **Integrations** from your workspace sidebar
|
||||
</Step>
|
||||
<Step>
|
||||
Search for "Zoho Desk" and open it, then click **Add to Sim** and choose **Add Self Client**
|
||||
|
||||
{/* TODO(screenshot): Zoho Desk integration page with the Add Self Client connect option */}
|
||||
</Step>
|
||||
<Step>
|
||||
In the **Add Zoho Desk Self Client** dialog, paste the **Client ID**, the **Client secret**, and the numeric **Organization ID**. Set **Data center** to your region (`us`, `eu`, `in`, or `au`) — leave it blank for US. Optionally set a display name and description
|
||||
|
||||
{/* TODO(screenshot): Add Zoho Desk Self Client dialog with all fields filled in */}
|
||||
</Step>
|
||||
<Step>
|
||||
Click **Add Self Client**. Sim verifies the credentials by minting a real access token from Zoho — if it fails, the error tells you whether Zoho rejected the credentials or couldn't be reached.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Using the Self Client in Workflows
|
||||
|
||||
Add a Zoho Desk block to your workflow. In the credential dropdown, your Self Client appears alongside any OAuth credentials. Select it and configure the block as you normally would.
|
||||
|
||||
{/* TODO(screenshot): Zoho Desk block in a workflow with the Self Client selected as the credential */}
|
||||
|
||||
The block calls the Zoho Desk REST API with a freshly minted access token — the same requests as the OAuth flow, so the Zoho Desk tools work the same way, subject to the scopes above.
|
||||
|
||||
One exception is worth knowing: Zoho publishes no OAuth scope for the attachment download sub-path, so **Get Attachment** is the one operation whose scope requirement we could not confirm from Zoho's documentation. If it returns a scope error, the requested scope list needs widening.
|
||||
|
||||
### Triggers still need OAuth
|
||||
|
||||
The Zoho Desk **trigger** provisions and tears down its own webhook subscription against your Desk organization, and that provisioning path currently runs only against a personal OAuth connection. Connect a Zoho Desk account through OAuth for triggers, and use the Self Client for the blocks that read and update tickets.
|
||||
|
||||
Because the OAuth flow is US-only, an organization outside the US data center can use Zoho Desk blocks through a Self Client but cannot use the Zoho Desk trigger.
|
||||
|
||||
## Token Behavior
|
||||
|
||||
Access tokens minted from a Self Client live for one hour and there is **no refresh token** — Sim mints a new token whenever one is needed, and caches the current one until it is close to expiry. Two events invalidate the stored credential:
|
||||
|
||||
- **Revoking or deleting the Self Client** in the Zoho API Console — no new tokens can be minted
|
||||
- **Regenerating the client secret** — the stored pair stops working; paste the new secret into the credential in Sim
|
||||
|
||||
<FAQ items={[
|
||||
{ question: "Why a Self Client instead of OAuth?", answer: "A Self Client authenticates as your Zoho organization, not as a person — nothing expires when someone leaves or their login lapses. Sim mints short-lived tokens from the stored client ID and secret whenever a workflow runs." },
|
||||
{ question: "Where do I find the Organization ID?", answer: "In Zoho Desk, go to Setup (gear icon) → Developer Space → API. The numeric Organization ID shown there is the value to paste. This is expected to be the same ID that Zoho Desk API calls send in the orgId header; if Zoho rejects it with missing_org_info, paste the full ZohoDesk.<your-org-id> value instead." },
|
||||
{ question: "Zoho rejects my credentials with invalid_client — why?", answer: "Either the client ID or secret was mistyped, or the client you created is not a Self Client. Only Self Clients support the client-credentials grant — in the Zoho API Console, Add Client → Self Client. Copy both values from the client's Client Secret tab." },
|
||||
{ question: "Zoho returns missing_org_info or rejects the organization — why?", answer: "Zoho could not resolve a Desk organization from the ID you pasted. Re-copy the numeric Organization ID from Setup → Developer Space → API in the Desk portal you want to use. If your Zoho account has multiple Desk portals, make sure it is the ID of the right one." },
|
||||
{ question: "Can I use a Self Client with a non-US Zoho account?", answer: "Yes, for the US, EU, IN, and AU data centers. Set the Data center field to us, eu, in, or au when you add the credential, and Sim mints tokens against that region's accounts server and calls the Desk host in the same region. Leaving it blank means US. The JP, CA, SA, CN, and UK data centers are not supported yet, and the interactive OAuth connection remains US-only." },
|
||||
{ question: "I picked the wrong data center — what happens?", answer: "The region's accounts server does not know your organization, so Zoho rejects the token request and Sim reports that it could not authenticate. Edit the credential and set the Data center to the region whose domain you sign in to Zoho Desk with." },
|
||||
{ question: "Why doesn't my Zoho Desk trigger work with the Self Client?", answer: "The trigger provisions a webhook subscription in your Desk organization, and that path runs against a personal OAuth connection only. Connect Zoho Desk through OAuth for triggers. Separately, Zoho Desk webhooks require a Professional-edition plan or above — they are unavailable on Free and Standard." },
|
||||
{ question: "How do I rotate the credentials?", answer: "Regenerate the client secret on the Self Client's Client Secret tab in the Zoho API Console, then update the credential in Sim with the new secret. The old secret stops working as soon as it's regenerated, so update Sim promptly." },
|
||||
]} />
|
||||
@@ -0,0 +1,515 @@
|
||||
---
|
||||
title: Zoho Desk
|
||||
description: Manage Zoho Desk tickets, comments, threads, and contacts
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="zoho_desk"
|
||||
color="#E42527"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Zoho Desk](https://www.zoho.com/desk/) is Zoho's customer support help desk. Support teams use it to receive tickets from email, web forms, chat, phone, and social channels, route them to the right department and agent, and track every customer conversation through to resolution.
|
||||
|
||||
With the Sim Zoho Desk integration, you can:
|
||||
|
||||
- **Read and filter tickets**: List tickets across an organization filtered by department, status, or priority, or fetch a single ticket by ID with its related contact, assignee, and department.
|
||||
- **Update tickets**: Change subject, status, priority, assignee, department, category, due date, and custom fields — useful for AI triage that classifies an incoming ticket and writes the result back.
|
||||
- **Work with conversations**: List and read ticket threads (the customer-facing email/chat exchange) and comments (internal agent notes), then add your own comment as public or private.
|
||||
- **Look up contacts**: Retrieve the contact behind a ticket to enrich it with data from your CRM or knowledge base.
|
||||
- **Download attachments**: Pull an attachment from a thread or comment into a Sim file you can pass to downstream blocks.
|
||||
- **Trigger on events**: Start a workflow when a ticket, comment, thread, contact, agent, task, or article changes in Zoho Desk.
|
||||
|
||||
**How it works in Sim:**
|
||||
Add a Zoho Desk block to your workflow, connect your Zoho account, and pick the Organization (portal) to work in — Sim loads the list for you from the connected account. Choose an operation and fill in its parameters; the block calls the Zoho Desk API and returns structured data for downstream blocks. For comment and thread bodies, Sim adds a derived plain-text `contentText` field alongside Zoho's raw HTML `content`, so an AI agent can read the message without HTML markup.
|
||||
|
||||
To trigger on Zoho Desk activity instead, use the block's trigger mode. Sim creates the webhook subscription in Zoho Desk for you and removes it automatically when the workflow is undeployed.
|
||||
|
||||
**Requirements and limitations**
|
||||
|
||||
> Zoho Desk webhooks require a Zoho Desk edition of **Professional or higher** — Free and Standard plans cannot create webhook subscriptions, so the trigger will fail to deploy on those plans.
|
||||
>
|
||||
> Connecting a Zoho account with **OAuth** — which the trigger requires — works only for the **US data center** (`accounts.zoho.com`). To use Zoho Desk blocks from the EU, India, or Australia data centers, connect a [Self Client](/integrations/zoho-desk-service-account) instead and set its data center. The Japan, Canada, Saudi Arabia, China, and UK data centers are not supported by either path.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Read and update Zoho Desk tickets, manage comments and threads, look up contacts, and download attachments. Can also trigger workflows from Zoho Desk webhook events.
|
||||
|
||||
|
||||
|
||||
## Actions
|
||||
|
||||
### `zoho_desk_list_tickets`
|
||||
|
||||
List tickets from a Zoho Desk organization with optional filters. Returns a list projection: description, resolution, statusType and classification are only available from Get Ticket.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiDomain` | string | No | Zoho Desk data-center REST base URL |
|
||||
| `orgId` | string | Yes | Zoho Desk organization ID |
|
||||
| `from` | number | No | Pagination start index \(0-based, max 4999\) |
|
||||
| `limit` | number | No | Number of tickets to return \(1-100, default 10\) |
|
||||
| `departmentIds` | string | No | Filter by department ID \(comma-separated for multiple\) |
|
||||
| `status` | string | No | Filter by status, including custom statuses. Comma-separate to match multiple \(e.g. "Open,On Hold"\) |
|
||||
| `priority` | string | No | Filter by priority. Comma-separate to match multiple \(e.g. "High,Urgent"\) |
|
||||
| `sortBy` | string | No | Sort field: createdTime, customerResponseTime, or responseDueDate. Prefix with - for descending. |
|
||||
| `include` | string | No | Comma-separated related data to embed. Allowed: contacts, products, departments, team, isRead, assignee |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tickets` | array | List of tickets |
|
||||
| ↳ `id` | string | Ticket ID |
|
||||
| ↳ `ticketNumber` | string | Human-readable ticket number |
|
||||
| ↳ `subject` | string | Ticket subject |
|
||||
| ↳ `description` | string | Ticket description \(raw; may be HTML\) |
|
||||
| ↳ `descriptionText` | string | Plain-text rendering of the description: HTML stripped when the body contains markup, otherwise the description verbatim |
|
||||
| ↳ `status` | string | Ticket status |
|
||||
| ↳ `statusType` | string | Status category \(Open/Closed/On Hold\) |
|
||||
| ↳ `priority` | string | Ticket priority |
|
||||
| ↳ `category` | string | Ticket category |
|
||||
| ↳ `subCategory` | string | Ticket sub-category |
|
||||
| ↳ `classification` | string | Ticket classification |
|
||||
| ↳ `channel` | string | Origin channel |
|
||||
| ↳ `departmentId` | string | Department ID |
|
||||
| ↳ `contactId` | string | Contact ID |
|
||||
| ↳ `accountId` | string | Account ID |
|
||||
| ↳ `assigneeId` | string | Assignee ID |
|
||||
| ↳ `email` | string | Contact email |
|
||||
| ↳ `phone` | string | Contact phone |
|
||||
| ↳ `dueDate` | string | Due date |
|
||||
| ↳ `responseDueDate` | string | Response due date |
|
||||
| ↳ `createdTime` | string | Created timestamp |
|
||||
| ↳ `modifiedTime` | string | Last modified timestamp |
|
||||
| ↳ `closedTime` | string | Closed timestamp |
|
||||
| ↳ `resolution` | string | Resolution text |
|
||||
| ↳ `threadCount` | string | Number of threads |
|
||||
| ↳ `commentCount` | string | Number of comments |
|
||||
| ↳ `webUrl` | string | Web URL to the ticket |
|
||||
| ↳ `isEscalated` | boolean | Whether the ticket is escalated |
|
||||
| ↳ `isOverDue` | boolean | Whether the ticket is overdue |
|
||||
| ↳ `isSpam` | boolean | Whether the ticket is marked spam |
|
||||
| ↳ `cf` | json | Custom field values, keyed by custom field API name |
|
||||
| `count` | number | Number of tickets returned |
|
||||
|
||||
### `zoho_desk_get_ticket`
|
||||
|
||||
Retrieve a single Zoho Desk ticket by ID.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiDomain` | string | No | Zoho Desk data-center REST base URL |
|
||||
| `orgId` | string | Yes | Zoho Desk organization ID |
|
||||
| `ticketId` | string | Yes | Ticket ID to retrieve |
|
||||
| `include` | string | No | Comma-separated related data to embed. Allowed: contacts, products, assignee, departments, contract, isRead, team, skills |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ticket` | object | The ticket |
|
||||
| ↳ `id` | string | Ticket ID |
|
||||
| ↳ `ticketNumber` | string | Human-readable ticket number |
|
||||
| ↳ `subject` | string | Ticket subject |
|
||||
| ↳ `description` | string | Ticket description \(raw; may be HTML\) |
|
||||
| ↳ `descriptionText` | string | Plain-text rendering of the description: HTML stripped when the body contains markup, otherwise the description verbatim |
|
||||
| ↳ `status` | string | Ticket status |
|
||||
| ↳ `statusType` | string | Status category \(Open/Closed/On Hold\) |
|
||||
| ↳ `priority` | string | Ticket priority |
|
||||
| ↳ `category` | string | Ticket category |
|
||||
| ↳ `subCategory` | string | Ticket sub-category |
|
||||
| ↳ `classification` | string | Ticket classification |
|
||||
| ↳ `channel` | string | Origin channel |
|
||||
| ↳ `departmentId` | string | Department ID |
|
||||
| ↳ `contactId` | string | Contact ID |
|
||||
| ↳ `accountId` | string | Account ID |
|
||||
| ↳ `assigneeId` | string | Assignee ID |
|
||||
| ↳ `email` | string | Contact email |
|
||||
| ↳ `phone` | string | Contact phone |
|
||||
| ↳ `dueDate` | string | Due date |
|
||||
| ↳ `responseDueDate` | string | Response due date |
|
||||
| ↳ `createdTime` | string | Created timestamp |
|
||||
| ↳ `modifiedTime` | string | Last modified timestamp |
|
||||
| ↳ `closedTime` | string | Closed timestamp |
|
||||
| ↳ `resolution` | string | Resolution text |
|
||||
| ↳ `threadCount` | string | Number of threads |
|
||||
| ↳ `commentCount` | string | Number of comments |
|
||||
| ↳ `webUrl` | string | Web URL to the ticket |
|
||||
| ↳ `isEscalated` | boolean | Whether the ticket is escalated |
|
||||
| ↳ `isOverDue` | boolean | Whether the ticket is overdue |
|
||||
| ↳ `isSpam` | boolean | Whether the ticket is marked spam |
|
||||
| ↳ `cf` | json | Custom field values, keyed by custom field API name |
|
||||
|
||||
### `zoho_desk_update_ticket`
|
||||
|
||||
Update fields on an existing Zoho Desk ticket.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiDomain` | string | No | Zoho Desk data-center REST base URL |
|
||||
| `orgId` | string | Yes | Zoho Desk organization ID |
|
||||
| `ticketId` | string | Yes | Ticket ID to update |
|
||||
| `subject` | string | No | Ticket subject |
|
||||
| `status` | string | No | Ticket status \(e.g. Open, Closed\) |
|
||||
| `priority` | string | No | Ticket priority \(e.g. High\) |
|
||||
| `assigneeId` | string | No | Assignee \(agent\) ID |
|
||||
| `departmentId` | string | No | Department ID |
|
||||
| `category` | string | No | Ticket category |
|
||||
| `subCategory` | string | No | Ticket sub-category |
|
||||
| `dueDate` | string | No | Due date \(ISO 8601\) |
|
||||
| `description` | string | No | Ticket description |
|
||||
| `resolution` | string | No | Resolution notes recorded on the ticket |
|
||||
| `classification` | string | No | Ticket classification: Problem, Request, Question, or Others |
|
||||
| `customFields` | json | No | Custom field values as a JSON object, keyed by custom field API name |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ticket` | object | The updated ticket |
|
||||
| ↳ `id` | string | Ticket ID |
|
||||
| ↳ `ticketNumber` | string | Human-readable ticket number |
|
||||
| ↳ `subject` | string | Ticket subject |
|
||||
| ↳ `description` | string | Ticket description \(raw; may be HTML\) |
|
||||
| ↳ `descriptionText` | string | Plain-text rendering of the description: HTML stripped when the body contains markup, otherwise the description verbatim |
|
||||
| ↳ `status` | string | Ticket status |
|
||||
| ↳ `statusType` | string | Status category \(Open/Closed/On Hold\) |
|
||||
| ↳ `priority` | string | Ticket priority |
|
||||
| ↳ `category` | string | Ticket category |
|
||||
| ↳ `subCategory` | string | Ticket sub-category |
|
||||
| ↳ `classification` | string | Ticket classification |
|
||||
| ↳ `channel` | string | Origin channel |
|
||||
| ↳ `departmentId` | string | Department ID |
|
||||
| ↳ `contactId` | string | Contact ID |
|
||||
| ↳ `accountId` | string | Account ID |
|
||||
| ↳ `assigneeId` | string | Assignee ID |
|
||||
| ↳ `email` | string | Contact email |
|
||||
| ↳ `phone` | string | Contact phone |
|
||||
| ↳ `dueDate` | string | Due date |
|
||||
| ↳ `responseDueDate` | string | Response due date |
|
||||
| ↳ `createdTime` | string | Created timestamp |
|
||||
| ↳ `modifiedTime` | string | Last modified timestamp |
|
||||
| ↳ `closedTime` | string | Closed timestamp |
|
||||
| ↳ `resolution` | string | Resolution text |
|
||||
| ↳ `threadCount` | string | Number of threads |
|
||||
| ↳ `commentCount` | string | Number of comments |
|
||||
| ↳ `webUrl` | string | Web URL to the ticket |
|
||||
| ↳ `isEscalated` | boolean | Whether the ticket is escalated |
|
||||
| ↳ `isOverDue` | boolean | Whether the ticket is overdue |
|
||||
| ↳ `isSpam` | boolean | Whether the ticket is marked spam |
|
||||
| ↳ `cf` | json | Custom field values, keyed by custom field API name |
|
||||
|
||||
### `zoho_desk_list_comments`
|
||||
|
||||
List comments on a Zoho Desk ticket.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiDomain` | string | No | Zoho Desk data-center REST base URL |
|
||||
| `orgId` | string | Yes | Zoho Desk organization ID |
|
||||
| `ticketId` | string | Yes | Ticket ID |
|
||||
| `from` | number | No | Pagination start index \(0-based\) |
|
||||
| `limit` | number | No | Number of comments to return \(1-100, default 50\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `comments` | array | List of comments |
|
||||
| ↳ `id` | string | Comment ID |
|
||||
| ↳ `content` | string | Comment content \(raw; may be HTML\) |
|
||||
| ↳ `contentType` | string | Content type \(plainText/html\) |
|
||||
| ↳ `contentText` | string | Plain-text rendering of content \(HTML stripped when contentType is html\) |
|
||||
| ↳ `isPublic` | boolean | Whether the comment is public |
|
||||
| ↳ `commenterId` | string | Commenter ID |
|
||||
| ↳ `commenter` | object | Who wrote the comment |
|
||||
| ↳ `name` | string | Display name |
|
||||
| ↳ `firstName` | string | First name |
|
||||
| ↳ `lastName` | string | Last name |
|
||||
| ↳ `email` | string | Email address |
|
||||
| ↳ `type` | string | Commenter type \(AGENT/END_USER\) |
|
||||
| ↳ `roleName` | string | Role name |
|
||||
| ↳ `photoURL` | string | Avatar URL |
|
||||
| ↳ `commentedTime` | string | Commented timestamp |
|
||||
| ↳ `modifiedTime` | string | Modified timestamp |
|
||||
| ↳ `attachments` | array | Comment attachments |
|
||||
| ↳ `id` | string | Attachment ID |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `size` | string | File size as reported by Zoho |
|
||||
| ↳ `href` | string | Download href |
|
||||
| `count` | number | Number of comments returned |
|
||||
|
||||
### `zoho_desk_add_comment`
|
||||
|
||||
Add a comment to a Zoho Desk ticket.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiDomain` | string | No | Zoho Desk data-center REST base URL |
|
||||
| `orgId` | string | Yes | Zoho Desk organization ID |
|
||||
| `ticketId` | string | Yes | Ticket ID |
|
||||
| `content` | string | Yes | Comment content |
|
||||
| `contentType` | string | No | Content type: plainText or html. Defaults to plainText so agent-written text posts literally; pass 'html' to send markup \(Zoho's own API default is html\). |
|
||||
| `isPublic` | boolean | No | Whether the comment is public |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `comment` | object | The created comment |
|
||||
| ↳ `id` | string | Comment ID |
|
||||
| ↳ `content` | string | Comment content \(raw; may be HTML\) |
|
||||
| ↳ `contentType` | string | Content type \(plainText/html\) |
|
||||
| ↳ `contentText` | string | Plain-text rendering of content \(HTML stripped when contentType is html\) |
|
||||
| ↳ `isPublic` | boolean | Whether the comment is public |
|
||||
| ↳ `commenterId` | string | Commenter ID |
|
||||
| ↳ `commenter` | object | Who wrote the comment |
|
||||
| ↳ `name` | string | Display name |
|
||||
| ↳ `firstName` | string | First name |
|
||||
| ↳ `lastName` | string | Last name |
|
||||
| ↳ `email` | string | Email address |
|
||||
| ↳ `type` | string | Commenter type \(AGENT/END_USER\) |
|
||||
| ↳ `roleName` | string | Role name |
|
||||
| ↳ `photoURL` | string | Avatar URL |
|
||||
| ↳ `commentedTime` | string | Commented timestamp |
|
||||
| ↳ `modifiedTime` | string | Modified timestamp |
|
||||
| ↳ `attachments` | array | Comment attachments |
|
||||
| ↳ `id` | string | Attachment ID |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `size` | string | File size as reported by Zoho |
|
||||
| ↳ `href` | string | Download href |
|
||||
|
||||
### `zoho_desk_list_threads`
|
||||
|
||||
List conversation threads on a Zoho Desk ticket, newest first (Zoho sorts by sendDateTime descending by default). Returns a list projection: message bodies (content, summary, to/cc/bcc) come back only from Get Thread.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiDomain` | string | No | Zoho Desk data-center REST base URL |
|
||||
| `orgId` | string | Yes | Zoho Desk organization ID |
|
||||
| `ticketId` | string | Yes | Ticket ID |
|
||||
| `from` | number | No | Pagination start index \(0-based\) |
|
||||
| `limit` | number | No | Number of threads to return \(1-200, default 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `threads` | array | List of threads |
|
||||
| ↳ `id` | string | Thread ID |
|
||||
| ↳ `channel` | string | Thread channel |
|
||||
| ↳ `direction` | string | Direction \(in/out\) |
|
||||
| ↳ `content` | string | Thread content \(raw; may be HTML\) |
|
||||
| ↳ `contentType` | string | Content type |
|
||||
| ↳ `contentText` | string | Plain-text rendering of content \(HTML stripped when contentType is html\) |
|
||||
| ↳ `summary` | string | Thread summary |
|
||||
| ↳ `responderId` | string | Responder ID |
|
||||
| ↳ `createdTime` | string | Created timestamp |
|
||||
| ↳ `hasAttach` | boolean | Whether the thread has attachments |
|
||||
| ↳ `attachmentCount` | string | Number of attachments |
|
||||
| ↳ `fromEmailAddress` | string | From email address |
|
||||
| ↳ `to` | string | To email address |
|
||||
| ↳ `cc` | string | CC email address |
|
||||
| ↳ `bcc` | string | BCC email address |
|
||||
| ↳ `replyTo` | string | Reply-to email address |
|
||||
| ↳ `isForward` | boolean | Whether the thread is a forward |
|
||||
| ↳ `isContentTruncated` | boolean | Whether Zoho truncated the thread content; fetch fullContentURL for the rest |
|
||||
| ↳ `fullContentURL` | string | URL returning the untruncated thread content |
|
||||
| ↳ `plainText` | string | Zoho's own plain-text rendering of the thread, when it supplies one |
|
||||
| ↳ `status` | string | Delivery status of an outgoing thread \(SUCCESS/FAILED/DRAFT\) |
|
||||
| ↳ `isDescriptionThread` | boolean | Whether this thread is the ticket's original description |
|
||||
| ↳ `visibility` | string | Thread visibility \(e.g. public\) |
|
||||
| ↳ `canReply` | boolean | Whether the thread can be replied to |
|
||||
| ↳ `author` | object | Who sent the thread |
|
||||
| ↳ `name` | string | Display name |
|
||||
| ↳ `firstName` | string | First name |
|
||||
| ↳ `lastName` | string | Last name |
|
||||
| ↳ `email` | string | Email address |
|
||||
| ↳ `type` | string | Author type \(AGENT/END_USER\) |
|
||||
| ↳ `photoURL` | string | Avatar URL |
|
||||
| ↳ `attachments` | array | Thread attachments |
|
||||
| ↳ `id` | string | Attachment ID |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `size` | string | File size as reported by Zoho |
|
||||
| ↳ `href` | string | Download href |
|
||||
| `count` | number | Number of threads returned |
|
||||
|
||||
### `zoho_desk_get_thread`
|
||||
|
||||
Retrieve the full content of a single Zoho Desk ticket thread.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiDomain` | string | No | Zoho Desk data-center REST base URL |
|
||||
| `orgId` | string | Yes | Zoho Desk organization ID |
|
||||
| `ticketId` | string | Yes | Ticket ID |
|
||||
| `threadId` | string | Yes | Thread ID |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `thread` | object | The thread |
|
||||
| ↳ `id` | string | Thread ID |
|
||||
| ↳ `channel` | string | Thread channel |
|
||||
| ↳ `direction` | string | Direction \(in/out\) |
|
||||
| ↳ `content` | string | Thread content \(raw; may be HTML\) |
|
||||
| ↳ `contentType` | string | Content type |
|
||||
| ↳ `contentText` | string | Plain-text rendering of content \(HTML stripped when contentType is html\) |
|
||||
| ↳ `summary` | string | Thread summary |
|
||||
| ↳ `responderId` | string | Responder ID |
|
||||
| ↳ `createdTime` | string | Created timestamp |
|
||||
| ↳ `hasAttach` | boolean | Whether the thread has attachments |
|
||||
| ↳ `attachmentCount` | string | Number of attachments |
|
||||
| ↳ `fromEmailAddress` | string | From email address |
|
||||
| ↳ `to` | string | To email address |
|
||||
| ↳ `cc` | string | CC email address |
|
||||
| ↳ `bcc` | string | BCC email address |
|
||||
| ↳ `replyTo` | string | Reply-to email address |
|
||||
| ↳ `isForward` | boolean | Whether the thread is a forward |
|
||||
| ↳ `isContentTruncated` | boolean | Whether Zoho truncated the thread content; fetch fullContentURL for the rest |
|
||||
| ↳ `fullContentURL` | string | URL returning the untruncated thread content |
|
||||
| ↳ `plainText` | string | Zoho's own plain-text rendering of the thread, when it supplies one |
|
||||
| ↳ `status` | string | Delivery status of an outgoing thread \(SUCCESS/FAILED/DRAFT\) |
|
||||
| ↳ `isDescriptionThread` | boolean | Whether this thread is the ticket's original description |
|
||||
| ↳ `visibility` | string | Thread visibility \(e.g. public\) |
|
||||
| ↳ `canReply` | boolean | Whether the thread can be replied to |
|
||||
| ↳ `author` | object | Who sent the thread |
|
||||
| ↳ `name` | string | Display name |
|
||||
| ↳ `firstName` | string | First name |
|
||||
| ↳ `lastName` | string | Last name |
|
||||
| ↳ `email` | string | Email address |
|
||||
| ↳ `type` | string | Author type \(AGENT/END_USER\) |
|
||||
| ↳ `photoURL` | string | Avatar URL |
|
||||
| ↳ `attachments` | array | Thread attachments |
|
||||
| ↳ `id` | string | Attachment ID |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `size` | string | File size as reported by Zoho |
|
||||
| ↳ `href` | string | Download href |
|
||||
|
||||
### `zoho_desk_get_contact`
|
||||
|
||||
Retrieve a Zoho Desk contact by ID.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiDomain` | string | No | Zoho Desk data-center REST base URL |
|
||||
| `orgId` | string | Yes | Zoho Desk organization ID |
|
||||
| `contactId` | string | Yes | Contact ID to retrieve |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `contact` | object | The contact |
|
||||
| ↳ `id` | string | Contact ID |
|
||||
| ↳ `firstName` | string | First name |
|
||||
| ↳ `lastName` | string | Last name |
|
||||
| ↳ `email` | string | Primary email |
|
||||
| ↳ `secondaryEmail` | string | Secondary email |
|
||||
| ↳ `phone` | string | Phone number |
|
||||
| ↳ `mobile` | string | Mobile number |
|
||||
| ↳ `accountId` | string | Associated account ID |
|
||||
| ↳ `ownerId` | string | Owner ID |
|
||||
| ↳ `type` | string | Contact type |
|
||||
| ↳ `title` | string | Job title |
|
||||
| ↳ `street` | string | Street |
|
||||
| ↳ `city` | string | City |
|
||||
| ↳ `state` | string | State |
|
||||
| ↳ `country` | string | Country |
|
||||
| ↳ `zip` | string | ZIP / postal code |
|
||||
| ↳ `description` | string | Description |
|
||||
| ↳ `cf` | json | Custom field values, keyed by custom field API name |
|
||||
|
||||
### `zoho_desk_get_attachment`
|
||||
|
||||
Download a Zoho Desk ticket attachment (from its href) as a file.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiDomain` | string | No | Zoho Desk data-center REST base URL |
|
||||
| `orgId` | string | Yes | Zoho Desk organization ID |
|
||||
| `href` | string | Yes | Attachment download href \(from a thread or comment attachment\) |
|
||||
| `fileName` | string | No | Optional file name for the downloaded file |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `file` | file | The downloaded attachment file |
|
||||
|
||||
### `zoho_desk_list_organizations`
|
||||
|
||||
List the Zoho Desk organizations (portals) the connected account can access.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiDomain` | string | No | Zoho Desk data-center REST base URL |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `organizations` | array | Accessible organizations |
|
||||
| ↳ `id` | string | Organization ID |
|
||||
| ↳ `companyName` | string | Company name |
|
||||
| ↳ `portalName` | string | Portal name |
|
||||
| `count` | number | Number of organizations returned |
|
||||
|
||||
|
||||
|
||||
## Triggers
|
||||
|
||||
A **Trigger** is a block that starts a workflow when an event happens in this service.
|
||||
|
||||
### Zoho Desk Event
|
||||
|
||||
Trigger a workflow when a Zoho Desk event occurs (ticket, comment, thread, contact, agent, task, or article changes).
|
||||
|
||||
#### Configuration
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `triggerCredentials` | string | Yes | This trigger creates and manages a webhook subscription in your Zoho Desk account. |
|
||||
| `orgId` | project-selector | Yes | The Zoho Desk organization \(portal\) to subscribe in. |
|
||||
| `manualOrgId` | string | Yes | Type an organization ID instead of picking one from the list. |
|
||||
| `eventType` | string | Yes | Event |
|
||||
| `triggerDepartmentIds` | string | No | Restrict events to these departments. Leave empty for all departments. |
|
||||
| `fields` | string | No | For Ticket Updated: only fire when one of these fields changes \(max 5\). Previous values are included in the payload. |
|
||||
| `direction` | string | No | Thread Direction |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `eventType` | string | The Zoho Desk event type \(e.g. Ticket_Add\) |
|
||||
| `eventTime` | string | Event time in milliseconds since epoch |
|
||||
| `orgId` | string | Zoho Desk organization ID |
|
||||
| `payload` | json | The full resource that changed \(ticket, comment, thread, etc.\). Comment and thread events gain a derived plain-text `contentText` alongside the raw `content` + `contentType`; ticket events gain `descriptionText` alongside `description`. |
|
||||
| `prevState` | json | Previous state of the resource \(update events only\) |
|
||||
|
||||
@@ -20,12 +20,16 @@ import {
|
||||
resolveOAuthAccountId,
|
||||
resolveServiceAccountToken,
|
||||
} from '@/app/api/auth/oauth/utils'
|
||||
import { extractZohoDeskBaseFromScope } from '@/tools/zoho_desk/host-allowlist'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('OAuthTokenAPI')
|
||||
|
||||
const SALESFORCE_INSTANCE_URL_REGEX = /__sf_instance__:([^\s]+)/
|
||||
// Stop at a comma or whitespace: better-auth persists Zoho's scopes comma-joined
|
||||
// (no spaces), so a greedy `\S+` would swallow the whole scope list into the host.
|
||||
// The Desk base URL itself never contains a comma or space.
|
||||
|
||||
/**
|
||||
* Get an access token for a specific credential
|
||||
@@ -182,6 +186,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
cloudId: result.cloudId,
|
||||
domain: result.domain,
|
||||
instanceUrl: result.instanceUrl,
|
||||
apiDomain: result.apiDomain,
|
||||
authStyle: result.authStyle,
|
||||
},
|
||||
{ status: 200 }
|
||||
@@ -290,11 +295,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Zoho Desk persists its data-center-specific REST base URL in the scope
|
||||
// string (derived from the token response api_domain) so callers never
|
||||
// assume a host. Surface it as apiDomain for tool param injection.
|
||||
let apiDomain: string | undefined
|
||||
if (credential.providerId === 'zoho-desk' && credential.scope) {
|
||||
// Use the shared extractor, not a local regex: it also enforces https +
|
||||
// the Zoho apex allowlist. This value is injected into EVERY tool call,
|
||||
// so an unvalidated host here would receive the OAuth token.
|
||||
apiDomain = extractZohoDeskBaseFromScope(credential.scope)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
accessToken,
|
||||
idToken: credential.idToken || undefined,
|
||||
...(instanceUrl && { instanceUrl }),
|
||||
...(apiDomain && { apiDomain }),
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
@@ -402,11 +419,23 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Zoho Desk persists its data-center-specific REST base URL in the scope
|
||||
// string (derived from the token response api_domain) so callers never
|
||||
// assume a host. Surface it as apiDomain for tool param injection.
|
||||
let apiDomain: string | undefined
|
||||
if (credential.providerId === 'zoho-desk' && credential.scope) {
|
||||
// Use the shared extractor, not a local regex: it also enforces https +
|
||||
// the Zoho apex allowlist. This value is injected into EVERY tool call,
|
||||
// so an unvalidated host here would receive the OAuth token.
|
||||
apiDomain = extractZohoDeskBaseFromScope(credential.scope)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
accessToken,
|
||||
idToken: credential.idToken || undefined,
|
||||
...(instanceUrl && { instanceUrl }),
|
||||
...(apiDomain && { apiDomain }),
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
|
||||
@@ -354,6 +354,11 @@ export interface ServiceAccountTokenResult {
|
||||
domain?: string
|
||||
/** Salesforce only — the org's instance URL the token must be used against. */
|
||||
instanceUrl?: string
|
||||
/**
|
||||
* Zoho Desk only — the data-center-scoped Desk REST base the token must be
|
||||
* used against, forwarded to tools as their `apiDomain` param.
|
||||
*/
|
||||
apiDomain?: string
|
||||
/**
|
||||
* Set when the token must be sent in an `x-api-token` header instead of
|
||||
* `Authorization: Bearer` (e.g. Pipedrive personal API tokens). Absent means
|
||||
@@ -397,6 +402,8 @@ interface CachedClientCredentialToken {
|
||||
secretFingerprint: string
|
||||
/** Salesforce only — the instance URL returned alongside the minted token. */
|
||||
instanceUrl?: string
|
||||
/** Zoho Desk only — the Desk REST base derived from the token's api_domain. */
|
||||
apiDomain?: string
|
||||
}
|
||||
|
||||
interface FailedClientCredentialMint {
|
||||
@@ -488,7 +495,11 @@ async function resolveClientCredentialAccountToken(
|
||||
cached.secretFingerprint === secretFingerprint &&
|
||||
cached.expiresAtMs - Date.now() > CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS
|
||||
) {
|
||||
return { accessToken: cached.accessToken, instanceUrl: cached.instanceUrl }
|
||||
return {
|
||||
accessToken: cached.accessToken,
|
||||
instanceUrl: cached.instanceUrl,
|
||||
apiDomain: cached.apiDomain,
|
||||
}
|
||||
}
|
||||
|
||||
const failed = clientCredentialMintFailureCache.get(credentialId)
|
||||
@@ -514,6 +525,7 @@ async function resolveClientCredentialAccountToken(
|
||||
clientId: blob.clientId,
|
||||
clientSecret: blob.clientSecret,
|
||||
orgId: blob.orgId,
|
||||
dataCenter: blob.dataCenter,
|
||||
},
|
||||
{ skipIdentity: true }
|
||||
)
|
||||
@@ -522,8 +534,13 @@ async function resolveClientCredentialAccountToken(
|
||||
expiresAtMs: Date.now() + mint.expiresInSeconds * 1000,
|
||||
secretFingerprint,
|
||||
instanceUrl: mint.instanceUrl,
|
||||
apiDomain: mint.apiDomain,
|
||||
})
|
||||
return { accessToken: mint.accessToken, instanceUrl: mint.instanceUrl }
|
||||
return {
|
||||
accessToken: mint.accessToken,
|
||||
instanceUrl: mint.instanceUrl,
|
||||
apiDomain: mint.apiDomain,
|
||||
}
|
||||
} catch (error) {
|
||||
clientCredentialMintFailureCache.set(credentialId, {
|
||||
error,
|
||||
|
||||
@@ -89,6 +89,7 @@ export const PUT = withRouteHandler(
|
||||
clientId: body.clientId,
|
||||
clientSecret: body.clientSecret,
|
||||
orgId: body.orgId,
|
||||
dataCenter: body.dataCenter,
|
||||
request,
|
||||
})
|
||||
if (!result.success) {
|
||||
|
||||
@@ -318,6 +318,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
clientId,
|
||||
clientSecret,
|
||||
orgId,
|
||||
dataCenter,
|
||||
} = parsed.data.body
|
||||
|
||||
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
|
||||
@@ -378,6 +379,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
clientId,
|
||||
clientSecret,
|
||||
orgId,
|
||||
dataCenter,
|
||||
})
|
||||
resolvedProviderId = secret.providerId
|
||||
resolvedAccountId = null
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { zohoDeskAgentsSelectorContract } from '@/lib/api/contracts/selectors'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { resolveZohoDeskSelectorCredential } from '@/app/api/tools/zoho_desk/selector-credential'
|
||||
import { assertZohoUrl } from '@/tools/zoho_desk/host-allowlist'
|
||||
import { buildZohoDeskHeaders, getZohoDeskErrorMessage } from '@/tools/zoho_desk/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('ZohoDeskAgentsAPI')
|
||||
|
||||
/**
|
||||
* `GET /api/v1/agents` is index-paginated exactly like `/departments`: `from` is
|
||||
* an index offset and `limit` caps at 200 (default 10). A short page means the
|
||||
* list is exhausted. The page cap bounds the drain so a provider that keeps
|
||||
* returning full pages cannot loop forever.
|
||||
*/
|
||||
const AGENT_PAGE_SIZE = 200
|
||||
const MAX_AGENT_PAGES = 20
|
||||
|
||||
/**
|
||||
* Only active agents can own a ticket, so a disabled or deleted agent in the
|
||||
* picker would only produce an assignment the API rejects.
|
||||
*/
|
||||
const AGENT_STATUS = 'ACTIVE'
|
||||
|
||||
interface ZohoAgent {
|
||||
id?: string | number
|
||||
name?: string
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
emailId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoho returns `name` for most agents but leaves it (and `firstName`) empty on
|
||||
* some rows, so fall back through the name parts and finally the email before
|
||||
* showing a bare numeric id the user cannot recognize.
|
||||
*/
|
||||
function getAgentLabel(agent: ZohoAgent): string {
|
||||
if (agent.name?.trim()) return agent.name.trim()
|
||||
const fullName = [agent.firstName, agent.lastName]
|
||||
.map((part) => part?.trim())
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
if (fullName) return fullName
|
||||
return agent.emailId?.trim() || String(agent.id)
|
||||
}
|
||||
|
||||
/** Backs the `zoho_desk.agents` selector. */
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
const parsed = await parseRequest(zohoDeskAgentsSelectorContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { credential, workflowId, orgId } = parsed.data.body
|
||||
|
||||
const resolved = await resolveZohoDeskSelectorCredential(request, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
requestId,
|
||||
})
|
||||
if (!resolved.ok) return resolved.response
|
||||
const { accessToken, apiBase } = resolved.credential
|
||||
|
||||
const headers = buildZohoDeskHeaders({ accessToken, orgId })
|
||||
const agents: Array<{ id: string; name: string }> = []
|
||||
// Zoho's docs disagree on whether `from` is 0- or 1-based (pagination section
|
||||
// says "range 0-4999, default 0"; listing examples read as 1-based). Deduping
|
||||
// by id is correct under both, so the drain never yields a repeated agent.
|
||||
const seenIds = new Set<string>()
|
||||
|
||||
try {
|
||||
for (let page = 0; page < MAX_AGENT_PAGES; page++) {
|
||||
let agentsUrl: URL
|
||||
try {
|
||||
agentsUrl = assertZohoUrl(`${apiBase}/agents`)
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential resolved to a non-Zoho host' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
agentsUrl.searchParams.set('from', String(page * AGENT_PAGE_SIZE))
|
||||
agentsUrl.searchParams.set('limit', String(AGENT_PAGE_SIZE))
|
||||
agentsUrl.searchParams.set('status', AGENT_STATUS)
|
||||
|
||||
// Same rationale as the organizations/departments/attachment routes: pin
|
||||
// the resolved IP, block private/reserved hops, and drop the token if a
|
||||
// Zoho-side redirect leaves the original origin.
|
||||
const response = await secureFetchWithValidation(agentsUrl.toString(), {
|
||||
method: 'GET',
|
||||
headers,
|
||||
timeout: 15_000,
|
||||
stripAuthOnRedirect: true,
|
||||
})
|
||||
|
||||
const body: { data?: unknown } = await response
|
||||
.json()
|
||||
.then((json) => (json && typeof json === 'object' ? (json as { data?: unknown }) : {}))
|
||||
.catch(() => ({}))
|
||||
|
||||
// Zoho answers 204 with no body once the offset runs past the last agent,
|
||||
// which is a successful end-of-list, not an error.
|
||||
if (response.status === 204) break
|
||||
|
||||
if (!response.ok) {
|
||||
const message = getZohoDeskErrorMessage(
|
||||
body,
|
||||
`Failed to list agents (HTTP ${response.status})`
|
||||
)
|
||||
logger.warn('Failed to list Zoho Desk agents', { status: response.status, message })
|
||||
return NextResponse.json(
|
||||
{ error: message },
|
||||
{ status: response.status >= 400 && response.status < 500 ? response.status : 502 }
|
||||
)
|
||||
}
|
||||
|
||||
const pageItems = Array.isArray(body.data) ? (body.data as ZohoAgent[]) : []
|
||||
for (const agent of pageItems) {
|
||||
if (agent.id === undefined || agent.id === null) continue
|
||||
const id = String(agent.id)
|
||||
if (seenIds.has(id)) continue
|
||||
seenIds.add(id)
|
||||
agents.push({ id, name: getAgentLabel(agent) })
|
||||
}
|
||||
|
||||
if (pageItems.length < AGENT_PAGE_SIZE) break
|
||||
if (page === MAX_AGENT_PAGES - 1) {
|
||||
logger.warn('Zoho Desk agents listing hit the page cap; list may be incomplete', {
|
||||
pages: MAX_AGENT_PAGES,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ agents })
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, 'Failed to list agents')
|
||||
logger.error('Error listing Zoho Desk agents', { error: message })
|
||||
return NextResponse.json({ error: message }, { status: 502 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { zohoDeskGetAttachmentContract } from '@/lib/api/contracts/tools/zoho-desk'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
|
||||
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { isZohoHost } from '@/tools/zoho_desk/host-allowlist'
|
||||
import {
|
||||
buildZohoDeskHeaders,
|
||||
deriveAttachmentName,
|
||||
getZohoDeskApiBase,
|
||||
resolveZohoAttachmentUrl,
|
||||
} from '@/tools/zoho_desk/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('ZohoDeskAttachmentAPI')
|
||||
|
||||
/**
|
||||
* Ceiling on a downloaded attachment.
|
||||
*
|
||||
* This route returns the file base64-encoded inside its JSON body, and the
|
||||
* executor reads an internal tool response through `readToolResponseBody`, which
|
||||
* caps at `MAX_TOOL_RESPONSE_BODY_BYTES` (10 MB). Base64 inflates by 4/3, so the
|
||||
* largest attachment that can actually survive the round trip is ~7.5 MB of raw
|
||||
* bytes. A larger ceiling here is not merely useless - it is actively harmful:
|
||||
* the route would download, encode, and serialize the whole file (peaking around
|
||||
* 250 MB of live allocation for a 50 MB attachment, with nothing limiting
|
||||
* concurrent downloads) only for the executor to reject the oversized body
|
||||
* afterwards. Capping at the reachable size makes the transport limit enforce
|
||||
* itself early, while the bytes are still being streamed.
|
||||
*
|
||||
* Raising this requires uploading in the route and returning a file reference
|
||||
* instead of inline base64, the way the WhatsApp media and Typeform file routes
|
||||
* do - not a bigger number here.
|
||||
*/
|
||||
const MAX_ATTACHMENT_BYTES = 7 * 1024 * 1024
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(zohoDeskGetAttachmentContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { accessToken, apiDomain, orgId, href, fileName } = parsed.data.body
|
||||
|
||||
let downloadUrl: URL
|
||||
try {
|
||||
downloadUrl = resolveZohoAttachmentUrl(
|
||||
href,
|
||||
getZohoDeskApiBase({ apiDomain: apiDomain ?? undefined })
|
||||
)
|
||||
} catch {
|
||||
return NextResponse.json({ success: false, error: 'Invalid attachment href' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (downloadUrl.protocol !== 'https:' || !isZohoHost(downloadUrl.hostname)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Attachment href must be an https Zoho URL' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
// Even though the initial host is allowlisted, the download URL is
|
||||
// user/LLM-influenced and Zoho may redirect. secureFetchWithValidation pins
|
||||
// the resolved IP, blocks private/reserved targets on every hop, and
|
||||
// (stripAuthOnRedirect) drops the OAuth token if a redirect leaves the
|
||||
// original origin, so the credential never reaches an untrusted host.
|
||||
// maxResponseBytes enforces the size cap while streaming.
|
||||
const response = await secureFetchWithValidation(downloadUrl.toString(), {
|
||||
method: 'GET',
|
||||
headers: buildZohoDeskHeaders({ accessToken, orgId }),
|
||||
timeout: 30_000,
|
||||
maxResponseBytes: MAX_ATTACHMENT_BYTES,
|
||||
stripAuthOnRedirect: true,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('Failed to download Zoho Desk attachment', { status: response.status })
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Failed to download attachment (HTTP ${response.status})` },
|
||||
{ status: response.status >= 400 && response.status < 500 ? response.status : 502 }
|
||||
)
|
||||
}
|
||||
|
||||
// A 204 (or any non-200 success) carries no body, so arrayBuffer() would
|
||||
// yield zero bytes and the route would report success with an empty file.
|
||||
if (response.status !== 200) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Attachment returned no content (HTTP ${response.status})` },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
|
||||
// ToolFileData (consumed by FileToolProcessor) keys the file name as `name`.
|
||||
const name = deriveAttachmentName(
|
||||
fileName,
|
||||
response.headers.get('content-disposition'),
|
||||
downloadUrl.pathname
|
||||
)
|
||||
const mimeType = response.headers.get('content-type') || 'application/octet-stream'
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
file: {
|
||||
data: Buffer.from(arrayBuffer).toString('base64'),
|
||||
mimeType,
|
||||
name,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
// An oversized attachment is a client-visible limit, not a server fault -
|
||||
// surface it as 413 with the actual ceiling, mirroring the WhatsApp media
|
||||
// route, instead of collapsing it into a generic 500.
|
||||
if (isPayloadSizeLimitError(error)) {
|
||||
logger.warn('Zoho Desk attachment exceeds the download limit', {
|
||||
maxBytes: MAX_ATTACHMENT_BYTES,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Attachment exceeds the ${Math.floor(MAX_ATTACHMENT_BYTES / (1024 * 1024))} MB download limit`,
|
||||
},
|
||||
{ status: 413 }
|
||||
)
|
||||
}
|
||||
logger.error('Error downloading Zoho Desk attachment', { error: getErrorMessage(error) })
|
||||
return NextResponse.json(
|
||||
{ success: false, error: getErrorMessage(error, 'Failed to download attachment') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { zohoDeskDepartmentsSelectorContract } from '@/lib/api/contracts/selectors'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { resolveZohoDeskSelectorCredential } from '@/app/api/tools/zoho_desk/selector-credential'
|
||||
import { assertZohoUrl } from '@/tools/zoho_desk/host-allowlist'
|
||||
import { buildZohoDeskHeaders, getZohoDeskErrorMessage } from '@/tools/zoho_desk/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('ZohoDeskDepartmentsAPI')
|
||||
|
||||
/**
|
||||
* `GET /api/v1/departments` is index-paginated with `limit` capped at 200
|
||||
* (default 10). A short page means the list is exhausted.
|
||||
*
|
||||
* Zoho's docs contradict themselves on whether `from` is 0- or 1-based: the
|
||||
* pagination section documents "range 0-4999, default 0", while the listing
|
||||
* examples read "from=5 and limit=50 retrieves records 5 to 54" (1-based). Under
|
||||
* the 1-based reading, stepping by exactly PAGE_SIZE re-fetches the boundary
|
||||
* record. Rather than guess a base we cannot confirm without a live tenant, the
|
||||
* accumulator dedupes by id, which is correct under BOTH readings — the worst
|
||||
* case is one redundant record per page boundary, never a duplicate entry or a
|
||||
* skipped one.
|
||||
*
|
||||
* The page cap bounds the drain so a provider that keeps returning full pages
|
||||
* cannot loop forever — 20 x 200 covers any realistic Desk portal and keeps the
|
||||
* maximum `from` inside Zoho's documented 4999 ceiling.
|
||||
*/
|
||||
const DEPARTMENT_PAGE_SIZE = 200
|
||||
const MAX_DEPARTMENT_PAGES = 20
|
||||
|
||||
interface ZohoDepartment {
|
||||
id?: string | number
|
||||
name?: string
|
||||
nameInCustomerPortal?: string
|
||||
}
|
||||
|
||||
/** Backs the `zoho_desk.departments` selector. */
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
const parsed = await parseRequest(zohoDeskDepartmentsSelectorContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { credential, workflowId, orgId } = parsed.data.body
|
||||
|
||||
const resolved = await resolveZohoDeskSelectorCredential(request, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
requestId,
|
||||
})
|
||||
if (!resolved.ok) return resolved.response
|
||||
const { accessToken, apiBase } = resolved.credential
|
||||
|
||||
const headers = buildZohoDeskHeaders({ accessToken, orgId })
|
||||
const departments: Array<{ id: string; name: string }> = []
|
||||
const seenIds = new Set<string>()
|
||||
|
||||
try {
|
||||
for (let page = 0; page < MAX_DEPARTMENT_PAGES; page++) {
|
||||
let departmentsUrl: URL
|
||||
try {
|
||||
departmentsUrl = assertZohoUrl(`${apiBase}/departments`)
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credential resolved to a non-Zoho host' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
departmentsUrl.searchParams.set('from', String(page * DEPARTMENT_PAGE_SIZE))
|
||||
departmentsUrl.searchParams.set('limit', String(DEPARTMENT_PAGE_SIZE))
|
||||
|
||||
// Same rationale as the organizations/attachment routes: pin the resolved
|
||||
// IP, block private/reserved hops, and drop the token if a Zoho-side
|
||||
// redirect leaves the original origin.
|
||||
const response = await secureFetchWithValidation(departmentsUrl.toString(), {
|
||||
method: 'GET',
|
||||
headers,
|
||||
timeout: 15_000,
|
||||
stripAuthOnRedirect: true,
|
||||
})
|
||||
|
||||
const body: { data?: unknown } = await response
|
||||
.json()
|
||||
.then((json) => (json && typeof json === 'object' ? (json as { data?: unknown }) : {}))
|
||||
.catch(() => ({}))
|
||||
|
||||
// Zoho answers 204 with no body once the offset runs past the last
|
||||
// department, which is a successful end-of-list, not an error.
|
||||
if (response.status === 204) break
|
||||
|
||||
if (!response.ok) {
|
||||
const message = getZohoDeskErrorMessage(
|
||||
body,
|
||||
`Failed to list departments (HTTP ${response.status})`
|
||||
)
|
||||
logger.warn('Failed to list Zoho Desk departments', { status: response.status, message })
|
||||
return NextResponse.json(
|
||||
{ error: message },
|
||||
{ status: response.status >= 400 && response.status < 500 ? response.status : 502 }
|
||||
)
|
||||
}
|
||||
|
||||
const pageItems = Array.isArray(body.data) ? (body.data as ZohoDepartment[]) : []
|
||||
for (const department of pageItems) {
|
||||
if (department.id === undefined || department.id === null) continue
|
||||
const id = String(department.id)
|
||||
// Dedupe: see the pagination note above — a 1-based `from` would repeat
|
||||
// the boundary record on every page after the first.
|
||||
if (seenIds.has(id)) continue
|
||||
seenIds.add(id)
|
||||
departments.push({
|
||||
id,
|
||||
name: department.name || department.nameInCustomerPortal || String(department.id),
|
||||
})
|
||||
}
|
||||
|
||||
if (pageItems.length < DEPARTMENT_PAGE_SIZE) break
|
||||
if (page === MAX_DEPARTMENT_PAGES - 1) {
|
||||
logger.warn('Zoho Desk departments listing hit the page cap; list may be incomplete', {
|
||||
pages: MAX_DEPARTMENT_PAGES,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ departments })
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, 'Failed to list departments')
|
||||
logger.error('Error listing Zoho Desk departments', { error: message })
|
||||
return NextResponse.json({ error: message }, { status: 502 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { zohoDeskOrganizationsSelectorContract } from '@/lib/api/contracts/selectors'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { resolveZohoDeskSelectorCredential } from '@/app/api/tools/zoho_desk/selector-credential'
|
||||
import { assertZohoUrl } from '@/tools/zoho_desk/host-allowlist'
|
||||
import { getZohoDeskErrorMessage } from '@/tools/zoho_desk/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('ZohoDeskOrganizationsAPI')
|
||||
|
||||
interface ZohoOrganization {
|
||||
id?: string | number
|
||||
companyName?: string
|
||||
portalName?: string
|
||||
}
|
||||
|
||||
/** Backs the `zoho_desk.organizations` selector. */
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
const parsed = await parseRequest(zohoDeskOrganizationsSelectorContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { credential, workflowId } = parsed.data.body
|
||||
|
||||
const resolved = await resolveZohoDeskSelectorCredential(request, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
requestId,
|
||||
})
|
||||
if (!resolved.ok) return resolved.response
|
||||
const { accessToken, apiBase } = resolved.credential
|
||||
|
||||
// apiBase is already anchored by getZohoDeskApiBase; assert again so the URL
|
||||
// that finally receives the OAuth token is validated at the point of use.
|
||||
let organizationsUrl: URL
|
||||
try {
|
||||
organizationsUrl = assertZohoUrl(`${apiBase}/organizations`)
|
||||
// Deliberately sends NO query parameters. Zoho's `/organizations` doc block
|
||||
// lists none at all - not `from`, not `limit` - and its own sample is a bare
|
||||
// GET. An earlier revision passed `limit=200` by extrapolating from
|
||||
// /departments and /agents, but the other siblings (/tickets, /contacts,
|
||||
// /comments) cap at 100 and Zoho answers an out-of-range value with 422
|
||||
// INVALID_DATA. Since `orgId` gates every tool and both other selectors, a
|
||||
// 422 here would make the whole integration unreachable except through the
|
||||
// manual field - a far worse failure than the known downside of sending
|
||||
// nothing, which is Zoho's default page size (10 portals).
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Credential resolved to a non-Zoho host' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// The organizations endpoint is the one Desk call that does not require an
|
||||
// orgId header, so it can bootstrap the organization selector before a
|
||||
// portal has been chosen.
|
||||
// Mirrors the attachment route: the initial host is allowlisted, but a
|
||||
// Zoho-side redirect would otherwise be followed with the OAuth token
|
||||
// attached and no IP pinning. secureFetchWithValidation pins the resolved
|
||||
// IP, blocks private/reserved targets on every hop, and drops the token if
|
||||
// a redirect leaves the original origin.
|
||||
const response = await secureFetchWithValidation(organizationsUrl.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Zoho-oauthtoken ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 15_000,
|
||||
stripAuthOnRedirect: true,
|
||||
})
|
||||
|
||||
// secureFetchWithValidation types the body as `unknown`; Zoho wraps the list
|
||||
// in `{ data: [...] }`, which is narrowed below before use.
|
||||
const data: { data?: unknown } = await response
|
||||
.json()
|
||||
.then((body) => (body && typeof body === 'object' ? (body as { data?: unknown }) : {}))
|
||||
.catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
// Surface the failure instead of returning an empty 200, which would make
|
||||
// the org dropdown silently render empty on an auth/connectivity error.
|
||||
const message = getZohoDeskErrorMessage(
|
||||
data,
|
||||
`Failed to list organizations (HTTP ${response.status})`
|
||||
)
|
||||
logger.warn('Failed to list Zoho Desk organizations', { status: response.status, message })
|
||||
return NextResponse.json(
|
||||
{ error: message },
|
||||
{ status: response.status >= 400 && response.status < 500 ? response.status : 502 }
|
||||
)
|
||||
}
|
||||
|
||||
const organizations = (Array.isArray(data.data) ? (data.data as ZohoOrganization[]) : [])
|
||||
.filter((org) => org.id !== undefined && org.id !== null)
|
||||
.map((org) => ({
|
||||
id: String(org.id),
|
||||
name: org.companyName || org.portalName || String(org.id),
|
||||
}))
|
||||
|
||||
return NextResponse.json({ organizations })
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, 'Failed to list organizations')
|
||||
logger.error('Error listing Zoho Desk organizations', { error: message })
|
||||
return NextResponse.json({ error: message }, { status: 502 })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { resolveCredentialAccessToken, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
import { extractZohoDeskBaseFromScope } from '@/tools/zoho_desk/host-allowlist'
|
||||
import { getZohoDeskApiBase } from '@/tools/zoho_desk/utils'
|
||||
|
||||
const logger = createLogger('ZohoDeskSelectorCredential')
|
||||
|
||||
interface ResolvedZohoDeskCredential {
|
||||
accessToken: string
|
||||
/** Desk REST base including the `/api/v1` suffix, anchored to the Zoho apex allowlist. */
|
||||
apiBase: string
|
||||
}
|
||||
|
||||
type ResolveResult =
|
||||
| { ok: true; credential: ResolvedZohoDeskCredential }
|
||||
| { ok: false; response: NextResponse }
|
||||
|
||||
/**
|
||||
* Resolve a Zoho Desk credential id into an access token plus the data-center
|
||||
* Desk REST base, for the selector routes.
|
||||
*
|
||||
* Both credential kinds are covered by one path:
|
||||
* - `zoho-desk-service-account` (client credentials): the minter returns the
|
||||
* data center's Desk base as `apiDomain` on every mint, so it comes straight
|
||||
* off the token result.
|
||||
* - OAuth connection: the token exchange persists the derived Desk base in the
|
||||
* credential's scope string, so it is read back from the `account` row.
|
||||
*
|
||||
* The token never leaves the server — unlike the previous combobox, which
|
||||
* fetched it into the browser before posting it back.
|
||||
*/
|
||||
export async function resolveZohoDeskSelectorCredential(
|
||||
request: NextRequest,
|
||||
params: { credentialId: string; workflowId?: string; requestId: string }
|
||||
): Promise<ResolveResult> {
|
||||
const { credentialId, workflowId, requestId } = params
|
||||
|
||||
const authz = await authorizeCredentialUse(request, { credentialId, workflowId })
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }),
|
||||
}
|
||||
}
|
||||
|
||||
const tokenResult = await resolveCredentialAccessToken(
|
||||
credentialId,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!tokenResult?.accessToken) {
|
||||
logger.error('Failed to get Zoho Desk access token', { credentialId })
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{ error: 'Could not retrieve access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// Service-account mints carry `apiDomain`; an OAuth connection stores the same
|
||||
// value on its account row instead. Falling through to `undefined` lets
|
||||
// getZohoDeskApiBase apply the US default rather than guessing a host.
|
||||
const apiDomain = tokenResult.apiDomain ?? (await readOAuthApiDomain(credentialId))
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
credential: {
|
||||
accessToken: tokenResult.accessToken,
|
||||
apiBase: getZohoDeskApiBase({ apiDomain }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function readOAuthApiDomain(credentialId: string): Promise<string | undefined> {
|
||||
try {
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved?.accountId) return undefined
|
||||
const [row] = await db
|
||||
.select({ scope: account.scope })
|
||||
.from(account)
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
return extractZohoDeskBaseFromScope(row?.scope)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to resolve Zoho Desk data center from credential', { error })
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -35,7 +35,10 @@ function messageForClientCredentialError(
|
||||
descriptor: ClientCredentialAccountDescriptor
|
||||
): string {
|
||||
if (isApiClientError(err) && err.code) {
|
||||
const fieldLabels = descriptor.fields.map((field) => field.label).join(', ')
|
||||
const fieldLabels = descriptor.fields
|
||||
.filter((field) => !field.optional)
|
||||
.map((field) => field.label)
|
||||
.join(', ')
|
||||
switch (err.code) {
|
||||
case 'invalid_credentials':
|
||||
return `We couldn't authenticate with those credentials. Check that the ${fieldLabels} all belong to the same ${descriptor.serviceLabel} app and that the app is authorized.`
|
||||
@@ -92,6 +95,7 @@ export function ClientCredentialAccountModal({
|
||||
const [clientId, setClientId] = useState('')
|
||||
const [clientSecret, setClientSecret] = useState('')
|
||||
const [orgId, setOrgId] = useState('')
|
||||
const [dataCenter, setDataCenter] = useState('')
|
||||
const [displayName, setDisplayName] = useState(initialDisplayName ?? '')
|
||||
const [description, setDescription] = useState(initialDescription ?? '')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -104,6 +108,7 @@ export function ClientCredentialAccountModal({
|
||||
setClientId('')
|
||||
setClientSecret('')
|
||||
setOrgId('')
|
||||
setDataCenter('')
|
||||
setDisplayName(initialDisplayName ?? '')
|
||||
setDescription(initialDescription ?? '')
|
||||
setError(null)
|
||||
@@ -112,10 +117,12 @@ export function ClientCredentialAccountModal({
|
||||
const clientIdField = descriptor.fields.find((field) => field.id === 'clientId')
|
||||
const clientSecretField = descriptor.fields.find((field) => field.id === 'clientSecret')
|
||||
const orgIdField = descriptor.fields.find((field) => field.id === 'orgId')
|
||||
const dataCenterField = descriptor.fields.find((field) => field.id === 'dataCenter')
|
||||
|
||||
const trimmedClientId = clientId.trim()
|
||||
const trimmedClientSecret = clientSecret.trim()
|
||||
const trimmedOrgId = orgId.trim()
|
||||
const trimmedDataCenter = dataCenter.trim()
|
||||
const isPending = createCredential.isPending || updateCredential.isPending
|
||||
const isDisabled = !trimmedClientId || !trimmedClientSecret || !trimmedOrgId || isPending
|
||||
|
||||
@@ -136,6 +143,7 @@ export function ClientCredentialAccountModal({
|
||||
clientId: trimmedClientId,
|
||||
clientSecret: trimmedClientSecret,
|
||||
orgId: trimmedOrgId,
|
||||
dataCenter: trimmedDataCenter || undefined,
|
||||
}
|
||||
if (credentialId) {
|
||||
await updateCredential.mutateAsync({
|
||||
@@ -227,6 +235,21 @@ export function ClientCredentialAccountModal({
|
||||
/>
|
||||
)}
|
||||
|
||||
{dataCenterField && (
|
||||
<ChipModalField
|
||||
type='input'
|
||||
title={dataCenterField.label}
|
||||
value={dataCenter}
|
||||
onChange={(value) => {
|
||||
setDataCenter(value)
|
||||
if (error) setError(null)
|
||||
}}
|
||||
placeholder={dataCenterField.placeholder}
|
||||
autoComplete='off'
|
||||
hint={hintFor(dataCenterField, trimmedDataCenter) ?? dataCenterField.hintMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ChipModalField
|
||||
type='input'
|
||||
title='Display name'
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
import { ZohoDeskIcon } from '@/components/icons'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import type { BlockConfig, BlockMeta } from '@/blocks/types'
|
||||
import { AuthMode, IntegrationType } from '@/blocks/types'
|
||||
import type { ZohoDeskResponse } from '@/tools/zoho_desk/types'
|
||||
import { getTrigger } from '@/triggers'
|
||||
|
||||
/** Operations that require an organization to be selected. */
|
||||
const OPERATIONS_NEEDING_ORG = [
|
||||
'list_tickets',
|
||||
'get_ticket',
|
||||
'update_ticket',
|
||||
'list_comments',
|
||||
'add_comment',
|
||||
'list_threads',
|
||||
'get_thread',
|
||||
'get_contact',
|
||||
'get_attachment',
|
||||
]
|
||||
|
||||
export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
|
||||
type: 'zoho_desk',
|
||||
name: 'Zoho Desk',
|
||||
description: 'Manage Zoho Desk tickets, comments, threads, and contacts',
|
||||
authMode: AuthMode.OAuth,
|
||||
triggerAllowed: true,
|
||||
longDescription:
|
||||
'Read and update Zoho Desk tickets, manage comments and threads, look up contacts, and download attachments. Can also trigger workflows from Zoho Desk webhook events.',
|
||||
docsLink: 'https://docs.sim.ai/integrations/zoho_desk',
|
||||
category: 'tools',
|
||||
integrationType: IntegrationType.Support,
|
||||
bgColor: '#E42527',
|
||||
icon: ZohoDeskIcon,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'List Tickets', id: 'list_tickets' },
|
||||
{ label: 'Get Ticket', id: 'get_ticket' },
|
||||
{ label: 'Update Ticket', id: 'update_ticket' },
|
||||
{ label: 'List Comments', id: 'list_comments' },
|
||||
{ label: 'Add Comment', id: 'add_comment' },
|
||||
{ label: 'List Threads', id: 'list_threads' },
|
||||
{ label: 'Get Thread', id: 'get_thread' },
|
||||
{ label: 'Get Contact', id: 'get_contact' },
|
||||
{ label: 'Get Attachment', id: 'get_attachment' },
|
||||
{ label: 'List Organizations', id: 'list_organizations' },
|
||||
],
|
||||
value: () => 'list_tickets',
|
||||
},
|
||||
{
|
||||
id: 'credential',
|
||||
title: 'Zoho Desk Account',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'zoho-desk',
|
||||
requiredScopes: getScopesForService('zoho-desk'),
|
||||
placeholder: 'Select Zoho Desk account',
|
||||
},
|
||||
{
|
||||
id: 'manualCredential',
|
||||
title: 'Zoho Desk Account',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'advanced',
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'orgId',
|
||||
title: 'Organization',
|
||||
type: 'project-selector',
|
||||
canonicalParamId: 'orgId',
|
||||
serviceId: 'zoho-desk',
|
||||
selectorKey: 'zoho_desk.organizations',
|
||||
placeholder: 'Select an organization',
|
||||
dependsOn: ['credential'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: OPERATIONS_NEEDING_ORG },
|
||||
required: { field: 'operation', value: OPERATIONS_NEEDING_ORG },
|
||||
},
|
||||
{
|
||||
id: 'manualOrgId',
|
||||
title: 'Organization ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'orgId',
|
||||
placeholder: 'Enter organization ID',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: OPERATIONS_NEEDING_ORG },
|
||||
required: { field: 'operation', value: OPERATIONS_NEEDING_ORG },
|
||||
},
|
||||
// Ticket ID (shared by several operations)
|
||||
{
|
||||
id: 'ticketId',
|
||||
title: 'Ticket ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter ticket ID',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'get_ticket',
|
||||
'update_ticket',
|
||||
'list_comments',
|
||||
'add_comment',
|
||||
'list_threads',
|
||||
'get_thread',
|
||||
],
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'get_ticket',
|
||||
'update_ticket',
|
||||
'list_comments',
|
||||
'add_comment',
|
||||
'list_threads',
|
||||
'get_thread',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'threadId',
|
||||
title: 'Thread ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter thread ID',
|
||||
condition: { field: 'operation', value: 'get_thread' },
|
||||
required: { field: 'operation', value: 'get_thread' },
|
||||
},
|
||||
{
|
||||
id: 'contactId',
|
||||
title: 'Contact ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter contact ID',
|
||||
condition: { field: 'operation', value: 'get_contact' },
|
||||
required: { field: 'operation', value: 'get_contact' },
|
||||
},
|
||||
// Add comment
|
||||
{
|
||||
id: 'content',
|
||||
title: 'Comment',
|
||||
type: 'long-input',
|
||||
placeholder: 'Comment content',
|
||||
condition: { field: 'operation', value: 'add_comment' },
|
||||
required: { field: 'operation', value: 'add_comment' },
|
||||
},
|
||||
{
|
||||
id: 'contentType',
|
||||
title: 'Content Type',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Plain text', id: 'plainText' },
|
||||
{ label: 'HTML', id: 'html' },
|
||||
],
|
||||
value: () => 'plainText',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'add_comment' },
|
||||
},
|
||||
{
|
||||
id: 'isPublic',
|
||||
title: 'Public Comment',
|
||||
type: 'switch',
|
||||
defaultValue: false,
|
||||
condition: { field: 'operation', value: 'add_comment' },
|
||||
},
|
||||
// Update ticket
|
||||
{
|
||||
id: 'subject',
|
||||
title: 'Subject',
|
||||
type: 'short-input',
|
||||
placeholder: 'New subject',
|
||||
condition: { field: 'operation', value: 'update_ticket' },
|
||||
},
|
||||
// status/priority are deliberately split per operation rather than shared.
|
||||
// A subBlock keeps its value when the operation changes, and the two uses are
|
||||
// semantically opposite: on list_tickets they are filters (comma-separated,
|
||||
// matching), on update_ticket they are the new value written to the ticket.
|
||||
// Sharing one field meant a filter of "Open,On Hold" could be PATCHed onto a
|
||||
// ticket, and an update value of "Closed" could silently filter a later list.
|
||||
{
|
||||
id: 'status',
|
||||
title: 'Status',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g. Closed',
|
||||
condition: { field: 'operation', value: 'update_ticket' },
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
title: 'Priority',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g. High',
|
||||
condition: { field: 'operation', value: 'update_ticket' },
|
||||
},
|
||||
{
|
||||
id: 'statusFilter',
|
||||
title: 'Status',
|
||||
type: 'short-input',
|
||||
placeholder: 'Filter, e.g. Open,On Hold',
|
||||
condition: { field: 'operation', value: 'list_tickets' },
|
||||
},
|
||||
{
|
||||
id: 'priorityFilter',
|
||||
title: 'Priority',
|
||||
type: 'short-input',
|
||||
placeholder: 'Filter, e.g. High,Urgent',
|
||||
condition: { field: 'operation', value: 'list_tickets' },
|
||||
},
|
||||
{
|
||||
id: 'assigneeId',
|
||||
title: 'Assignee',
|
||||
type: 'project-selector',
|
||||
canonicalParamId: 'assigneeId',
|
||||
serviceId: 'zoho-desk',
|
||||
selectorKey: 'zoho_desk.agents',
|
||||
placeholder: 'Assign the ticket to this agent',
|
||||
dependsOn: ['credential', 'orgId'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: 'update_ticket' },
|
||||
},
|
||||
{
|
||||
id: 'manualAssigneeId',
|
||||
title: 'Assignee ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'assigneeId',
|
||||
placeholder: 'Agent ID',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'update_ticket' },
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
title: 'Description',
|
||||
type: 'long-input',
|
||||
placeholder: 'Replace the ticket description',
|
||||
condition: { field: 'operation', value: 'update_ticket' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'resolution',
|
||||
title: 'Resolution',
|
||||
type: 'long-input',
|
||||
placeholder: 'Resolution notes',
|
||||
condition: { field: 'operation', value: 'update_ticket' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'classification',
|
||||
title: 'Classification',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Problem', id: 'Problem' },
|
||||
{ label: 'Request', id: 'Request' },
|
||||
{ label: 'Question', id: 'Question' },
|
||||
{ label: 'Others', id: 'Others' },
|
||||
],
|
||||
condition: { field: 'operation', value: 'update_ticket' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'departmentId',
|
||||
title: 'Department',
|
||||
type: 'project-selector',
|
||||
canonicalParamId: 'departmentId',
|
||||
serviceId: 'zoho-desk',
|
||||
selectorKey: 'zoho_desk.departments',
|
||||
placeholder: 'Move ticket to this department',
|
||||
dependsOn: ['credential', 'orgId'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: 'update_ticket' },
|
||||
},
|
||||
{
|
||||
id: 'manualDepartmentId',
|
||||
title: 'Department ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'departmentId',
|
||||
placeholder: 'Move ticket to this department',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'update_ticket' },
|
||||
},
|
||||
{
|
||||
id: 'category',
|
||||
title: 'Category',
|
||||
type: 'short-input',
|
||||
condition: { field: 'operation', value: 'update_ticket' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'subCategory',
|
||||
title: 'Sub-category',
|
||||
type: 'short-input',
|
||||
condition: { field: 'operation', value: 'update_ticket' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'dueDate',
|
||||
title: 'Due Date',
|
||||
type: 'short-input',
|
||||
placeholder: 'ISO 8601 timestamp',
|
||||
condition: { field: 'operation', value: 'update_ticket' },
|
||||
mode: 'advanced',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt: 'Generate an ISO 8601 timestamp. Return ONLY the timestamp string.',
|
||||
generationType: 'timestamp',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'customFields',
|
||||
title: 'Custom Fields',
|
||||
type: 'long-input',
|
||||
placeholder: '{"cf_severity": "High"}',
|
||||
condition: { field: 'operation', value: 'update_ticket' },
|
||||
mode: 'advanced',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt:
|
||||
'Generate a JSON object mapping Zoho Desk custom field API names (they start with cf_) to values. Return ONLY the JSON object - no explanations, no extra text.',
|
||||
generationType: 'json-object',
|
||||
},
|
||||
},
|
||||
// Get attachment
|
||||
{
|
||||
id: 'href',
|
||||
title: 'Attachment href',
|
||||
type: 'short-input',
|
||||
placeholder: 'Attachment download href from a thread or comment',
|
||||
condition: { field: 'operation', value: 'get_attachment' },
|
||||
required: { field: 'operation', value: 'get_attachment' },
|
||||
},
|
||||
{
|
||||
id: 'fileName',
|
||||
title: 'File Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'Optional file name',
|
||||
condition: { field: 'operation', value: 'get_attachment' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
// Shared filters / options
|
||||
{
|
||||
id: 'departmentIds',
|
||||
title: 'Departments',
|
||||
type: 'project-selector',
|
||||
canonicalParamId: 'departmentIds',
|
||||
serviceId: 'zoho-desk',
|
||||
selectorKey: 'zoho_desk.departments',
|
||||
multiSelect: true,
|
||||
placeholder: 'Filter by department',
|
||||
dependsOn: ['credential', 'orgId'],
|
||||
mode: 'basic',
|
||||
condition: { field: 'operation', value: 'list_tickets' },
|
||||
},
|
||||
{
|
||||
id: 'manualDepartmentIds',
|
||||
title: 'Department IDs',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'departmentIds',
|
||||
placeholder: 'Comma-separated department IDs',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'list_tickets' },
|
||||
},
|
||||
{
|
||||
id: 'include',
|
||||
title: 'Include',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g. contacts,assignee',
|
||||
condition: { field: 'operation', value: ['list_tickets', 'get_ticket'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'sortBy',
|
||||
title: 'Sort By',
|
||||
type: 'short-input',
|
||||
placeholder: 'createdTime, customerResponseTime, or responseDueDate',
|
||||
condition: { field: 'operation', value: 'list_tickets' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'from',
|
||||
title: 'From',
|
||||
type: 'short-input',
|
||||
placeholder: 'Start index (0-based)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['list_tickets', 'list_comments', 'list_threads'],
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'limit',
|
||||
title: 'Limit',
|
||||
type: 'short-input',
|
||||
placeholder: 'Max results (tickets/comments 100, threads 200)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['list_tickets', 'list_comments', 'list_threads'],
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
...getTrigger('zoho_desk').subBlocks,
|
||||
],
|
||||
tools: {
|
||||
access: [
|
||||
'zoho_desk_list_tickets',
|
||||
'zoho_desk_get_ticket',
|
||||
'zoho_desk_update_ticket',
|
||||
'zoho_desk_list_comments',
|
||||
'zoho_desk_add_comment',
|
||||
'zoho_desk_list_threads',
|
||||
'zoho_desk_get_thread',
|
||||
'zoho_desk_get_contact',
|
||||
'zoho_desk_get_attachment',
|
||||
'zoho_desk_list_organizations',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => `zoho_desk_${params.operation}`,
|
||||
params: (params) => {
|
||||
// Pull raw pagination out of the spread so invalid values never reach the
|
||||
// tool; only re-add them when Number() yields a finite value (a non-numeric
|
||||
// typo would otherwise become NaN and produce an invalid Zoho query param).
|
||||
const {
|
||||
oauthCredential,
|
||||
from: rawFrom,
|
||||
limit: rawLimit,
|
||||
contentType,
|
||||
isPublic,
|
||||
status: rawStatus,
|
||||
priority: rawPriority,
|
||||
statusFilter: rawStatusFilter,
|
||||
priorityFilter: rawPriorityFilter,
|
||||
customFields: rawCustomFields,
|
||||
departmentIds: rawDepartmentIds,
|
||||
...rest
|
||||
} = params
|
||||
const result: Record<string, unknown> = { ...rest, oauthCredential }
|
||||
|
||||
// The basic-mode department picker is multi-select, so it stores an
|
||||
// array while the advanced manual field stores the raw comma-separated
|
||||
// string Zoho's `departmentIds` query param expects. Normalize both to
|
||||
// that string so the wire format never depends on which mode was used.
|
||||
const departmentIds = Array.isArray(rawDepartmentIds)
|
||||
? rawDepartmentIds
|
||||
.map((id) => String(id).trim())
|
||||
.filter(Boolean)
|
||||
.join(',')
|
||||
: typeof rawDepartmentIds === 'string'
|
||||
? rawDepartmentIds.trim()
|
||||
: ''
|
||||
if (departmentIds) result.departmentIds = departmentIds
|
||||
|
||||
// contentType is the comment's content type; its default would otherwise
|
||||
// serialize for every operation (e.g. get_attachment, which has no such
|
||||
// param). Only forward it for add_comment so the UI can't imply an option
|
||||
// that has no effect elsewhere.
|
||||
if (params.operation === 'add_comment' && typeof contentType === 'string' && contentType) {
|
||||
result.contentType = contentType
|
||||
}
|
||||
|
||||
// Zoho documents from >= 0 and limit >= 1 as integers; a negative or
|
||||
// fractional value reaches the API as an opaque provider error, so drop
|
||||
// anything outside those bounds here rather than round-tripping it.
|
||||
// `null` is checked explicitly: the serializer initializes untouched
|
||||
// subBlocks to null, and Number(null) is 0 — which would otherwise inject
|
||||
// from=0 on every operation instead of leaving the param unset.
|
||||
if (rawFrom !== undefined && rawFrom !== null && rawFrom !== '') {
|
||||
const from = Number(rawFrom)
|
||||
if (Number.isInteger(from) && from >= 0) result.from = from
|
||||
}
|
||||
if (rawLimit !== undefined && rawLimit !== null && rawLimit !== '') {
|
||||
const limit = Number(rawLimit)
|
||||
if (Number.isInteger(limit) && limit >= 1) result.limit = limit
|
||||
}
|
||||
// Gated for the same reason as contentType above: isPublic carries a
|
||||
// defaultValue, so forwarding it unconditionally would serialize a
|
||||
// comment-only field onto every other operation's params. Destructured
|
||||
// out of `rest` so the default never reaches non-comment operations.
|
||||
if (params.operation === 'add_comment' && isPublic !== undefined) {
|
||||
result.isPublic = isPublic === true || isPublic === 'true'
|
||||
}
|
||||
// Gated to update_ticket for the same reason as contentType and isPublic
|
||||
// above: the subBlock keeps its value when the operation changes, so
|
||||
// stale (or half-typed) JSON left behind after switching away from
|
||||
// Update Ticket would otherwise fail every unrelated operation with
|
||||
// "Invalid JSON provided for custom fields" - on runs that never send it.
|
||||
// Only list_tickets and update_ticket declare status/priority. The other
|
||||
// eight operations must receive neither - a ternary with a bare `else`
|
||||
// would forward a stale Update Ticket value into e.g. get_ticket.
|
||||
const activeStatus =
|
||||
params.operation === 'list_tickets'
|
||||
? rawStatusFilter
|
||||
: params.operation === 'update_ticket'
|
||||
? rawStatus
|
||||
: undefined
|
||||
const activePriority =
|
||||
params.operation === 'list_tickets'
|
||||
? rawPriorityFilter
|
||||
: params.operation === 'update_ticket'
|
||||
? rawPriority
|
||||
: undefined
|
||||
if (activeStatus !== undefined && activeStatus !== null && activeStatus !== '') {
|
||||
result.status = activeStatus
|
||||
}
|
||||
if (activePriority !== undefined && activePriority !== null && activePriority !== '') {
|
||||
result.priority = activePriority
|
||||
}
|
||||
|
||||
if (params.operation === 'update_ticket' && rawCustomFields !== undefined) {
|
||||
if (typeof rawCustomFields === 'string') {
|
||||
if (rawCustomFields.trim()) {
|
||||
try {
|
||||
result.customFields = JSON.parse(rawCustomFields)
|
||||
} catch {
|
||||
throw new Error('Invalid JSON provided for custom fields')
|
||||
}
|
||||
}
|
||||
} else if (rawCustomFields !== null) {
|
||||
// Already an object when an agent supplies it directly.
|
||||
result.customFields = rawCustomFields
|
||||
}
|
||||
}
|
||||
return result
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
oauthCredential: { type: 'string', description: 'Zoho Desk credential' },
|
||||
orgId: { type: 'string', description: 'Zoho Desk organization ID' },
|
||||
ticketId: { type: 'string', description: 'Ticket ID' },
|
||||
threadId: { type: 'string', description: 'Thread ID' },
|
||||
contactId: { type: 'string', description: 'Contact ID' },
|
||||
content: { type: 'string', description: 'Comment content' },
|
||||
contentType: { type: 'string', description: 'Comment content type (plainText/html)' },
|
||||
isPublic: { type: 'boolean', description: 'Whether a comment is public' },
|
||||
subject: { type: 'string', description: 'Ticket subject' },
|
||||
status: { type: 'string', description: 'Ticket status to set' },
|
||||
statusFilter: { type: 'string', description: 'Status filter for listing tickets' },
|
||||
priorityFilter: { type: 'string', description: 'Priority filter for listing tickets' },
|
||||
priority: { type: 'string', description: 'Ticket priority' },
|
||||
assigneeId: { type: 'string', description: 'Assignee (agent) ID' },
|
||||
description: { type: 'string', description: 'Ticket description' },
|
||||
resolution: { type: 'string', description: 'Resolution notes' },
|
||||
classification: { type: 'string', description: 'Ticket classification' },
|
||||
departmentId: { type: 'string', description: 'Department ID to move a ticket to' },
|
||||
departmentIds: { type: 'string', description: 'Department IDs to filter by (comma-separated)' },
|
||||
category: { type: 'string', description: 'Ticket category' },
|
||||
subCategory: { type: 'string', description: 'Ticket sub-category' },
|
||||
dueDate: { type: 'string', description: 'Ticket due date' },
|
||||
customFields: { type: 'json', description: 'Custom field values' },
|
||||
href: { type: 'string', description: 'Attachment download href' },
|
||||
fileName: { type: 'string', description: 'Downloaded file name' },
|
||||
include: { type: 'string', description: 'Related data to include' },
|
||||
sortBy: { type: 'string', description: 'Sort field' },
|
||||
from: { type: 'number', description: 'Pagination start index' },
|
||||
limit: { type: 'number', description: 'Maximum results' },
|
||||
},
|
||||
outputs: {
|
||||
tickets: { type: 'array', description: 'List of tickets' },
|
||||
ticket: { type: 'json', description: 'A single ticket' },
|
||||
comments: { type: 'array', description: 'List of comments' },
|
||||
comment: { type: 'json', description: 'A single comment' },
|
||||
threads: { type: 'array', description: 'List of threads' },
|
||||
thread: { type: 'json', description: 'A single thread' },
|
||||
contact: { type: 'json', description: 'A contact' },
|
||||
organizations: { type: 'array', description: 'Accessible organizations' },
|
||||
file: { type: 'file', description: 'Downloaded attachment file' },
|
||||
count: { type: 'number', description: 'Number of items returned' },
|
||||
},
|
||||
triggers: {
|
||||
enabled: true,
|
||||
available: ['zoho_desk'],
|
||||
},
|
||||
}
|
||||
|
||||
export const ZohoDeskBlockMeta = {
|
||||
tags: ['customer-support', 'ticketing', 'automation'],
|
||||
url: 'https://www.zoho.com/desk/',
|
||||
templates: [
|
||||
{
|
||||
icon: ZohoDeskIcon,
|
||||
title: 'Zoho Desk new-ticket Slack alert',
|
||||
prompt:
|
||||
'When a new ticket is created in Zoho Desk, send a formatted Slack message to my support channel with the subject, priority, requester, and a link to the ticket.',
|
||||
modules: ['agent', 'workflows'],
|
||||
category: 'support',
|
||||
tags: ['automation', 'communication'],
|
||||
alsoIntegrations: ['slack'],
|
||||
},
|
||||
{
|
||||
icon: ZohoDeskIcon,
|
||||
title: 'Zoho Desk AI ticket triage',
|
||||
prompt:
|
||||
'When a Zoho Desk ticket is created, read the subject and description, classify the priority and category, and update the ticket with the suggested priority and a triage comment.',
|
||||
modules: ['agent', 'workflows'],
|
||||
category: 'support',
|
||||
tags: ['automation', 'ai'],
|
||||
},
|
||||
{
|
||||
icon: ZohoDeskIcon,
|
||||
title: 'Zoho Desk AI draft reply',
|
||||
prompt:
|
||||
'When a customer adds a new thread to a Zoho Desk ticket, fetch the full thread, draft a helpful reply grounded in my knowledge base, and post it as a private comment for an agent to review.',
|
||||
modules: ['agent', 'knowledge-base', 'workflows'],
|
||||
category: 'support',
|
||||
tags: ['ai', 'automation'],
|
||||
},
|
||||
{
|
||||
icon: ZohoDeskIcon,
|
||||
title: 'Zoho Desk escalation watcher',
|
||||
prompt:
|
||||
'Create a scheduled workflow that lists open high-priority Zoho Desk tickets with no agent response, and pings the on-call engineer in Slack with the ticket details.',
|
||||
modules: ['scheduled', 'agent', 'workflows'],
|
||||
category: 'support',
|
||||
tags: ['monitoring', 'automation'],
|
||||
alsoIntegrations: ['slack'],
|
||||
},
|
||||
{
|
||||
icon: ZohoDeskIcon,
|
||||
title: 'Zoho Desk daily support digest',
|
||||
prompt:
|
||||
'Build a scheduled workflow that pulls all Zoho Desk tickets updated in the last 24 hours, summarizes volume by status and priority, and emails a digest to the support lead.',
|
||||
modules: ['scheduled', 'agent', 'workflows'],
|
||||
category: 'support',
|
||||
tags: ['reporting', 'automation'],
|
||||
},
|
||||
{
|
||||
icon: ZohoDeskIcon,
|
||||
title: 'Zoho Desk contact enrichment',
|
||||
prompt:
|
||||
'When a Zoho Desk ticket is created, look up the contact, enrich it with data from our CRM, and add a comment summarizing the customer context for the agent.',
|
||||
modules: ['agent', 'workflows'],
|
||||
category: 'support',
|
||||
tags: ['automation', 'ai'],
|
||||
},
|
||||
{
|
||||
icon: ZohoDeskIcon,
|
||||
title: 'Zoho Desk knowledge search',
|
||||
prompt:
|
||||
'Create a knowledge base from my resolved Zoho Desk tickets and threads so I can ask an agent questions like "how did we resolve the billing sync issue?" and get answers with ticket citations.',
|
||||
modules: ['knowledge-base', 'agent'],
|
||||
category: 'support',
|
||||
tags: ['research', 'ai'],
|
||||
},
|
||||
{
|
||||
icon: ZohoDeskIcon,
|
||||
title: 'Zoho Desk attachment archiver',
|
||||
prompt:
|
||||
'When a Zoho Desk ticket thread includes an attachment, download the file and upload it to Google Drive in a folder named after the ticket number.',
|
||||
modules: ['agent', 'files', 'workflows'],
|
||||
category: 'support',
|
||||
tags: ['automation', 'files'],
|
||||
alsoIntegrations: ['google_drive'],
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
name: 'triage-new-ticket',
|
||||
description:
|
||||
'Read an incoming Zoho Desk ticket, classify it, and set priority, classification, category, and assignee.',
|
||||
content:
|
||||
'# Triage a Zoho Desk Ticket\n\nClassify a newly created ticket and route it to the right owner.\n\n## Steps\n1. If the organization ID is unknown, List Organizations and pick the portal to work in.\n2. Get Ticket for the ticket ID and read subject, descriptionText, channel, and status.\n3. Decide the urgency and the owning team from the content, and pick a classification of Problem, Request, Question, or Others.\n4. Update Ticket to set priority, classification, category and subCategory, and the assigneeId or departmentId that should own it.\n5. Add Comment as an internal note explaining the triage decision so the agent who picks it up has the reasoning.\n\n## Output\nReport the ticket ID and number, the classification and priority set, the assignee or department it was routed to, and anything ambiguous that needs a human decision.',
|
||||
},
|
||||
{
|
||||
name: 'escalate-overdue-tickets',
|
||||
description:
|
||||
'Find Zoho Desk tickets past or near their response due date and escalate them with a higher priority and an owner.',
|
||||
content:
|
||||
'# Escalate Overdue Tickets\n\nCatch tickets that are breaching or about to breach their response commitment.\n\n## Steps\n1. List Tickets filtered to open statuses, sorted by responseDueDate so the soonest-due tickets come first. Page with from and limit if the queue is large.\n2. Compare responseDueDate and dueDate against the current time to separate already-breached tickets from ones due shortly.\n3. For each breached ticket, Update Ticket to raise priority and reassign to the escalation owner via assigneeId or departmentId.\n4. Add Comment as an internal note recording that the ticket was escalated, how overdue it was, and who now owns it.\n\n## Output\nA list of escalated tickets with ticket number, how far past due each was, the new priority, and the new owner. Include a separate at-risk list of tickets due soon but not yet breached.',
|
||||
},
|
||||
{
|
||||
name: 'daily-ticket-digest',
|
||||
description:
|
||||
'Summarize the current Zoho Desk queue by status, priority, and age into a digest for the support team.',
|
||||
content:
|
||||
'# Daily Ticket Digest\n\nProduce a morning read on where the support queue stands.\n\n## Steps\n1. List Tickets for the department, filtering by the statuses you care about such as "Open,On Hold". Page with from and limit until the queue is covered.\n2. Group the results by status, priority, and assignee, and compute counts for each group.\n3. Flag unassigned tickets, tickets past responseDueDate, and anything that has sat untouched since createdTime.\n4. Write a short narrative of what changed and what needs attention today.\n\n## Output\nA digest with counts by status and priority, load per assignee, and a callout list of unassigned and overdue tickets by ticket number. State the filters used so the numbers are reproducible.',
|
||||
},
|
||||
{
|
||||
name: 'draft-reply-as-internal-note',
|
||||
description:
|
||||
'Read a Zoho Desk ticket conversation and post a suggested reply as a private comment for an agent to review.',
|
||||
content:
|
||||
'# Draft a Reply as an Internal Note\n\nPrepare a response an agent can review and send, without messaging the customer directly.\n\n## Steps\n1. Get Ticket to read the subject, descriptionText, status, and priority.\n2. List Threads for the ticket and Get Thread on the most recent customer message to read its full content.\n3. List Comments to check what has already been discussed internally so the draft does not repeat prior advice.\n4. Write a reply that answers the latest customer message, and Add Comment with isPublic set to false so it posts as an internal draft. Use plainText unless the draft genuinely contains markup.\n\n## Output\nThe ticket number, the comment ID of the posted draft, and a note on any facts the draft assumes that an agent must verify before sending. This posts an internal note only, never a reply to the customer.',
|
||||
},
|
||||
{
|
||||
name: 'enrich-ticket-with-customer-context',
|
||||
description:
|
||||
'Pull the contact behind a Zoho Desk ticket and post a customer context summary as an internal note.',
|
||||
content:
|
||||
'# Enrich a Ticket With Customer Context\n\nGive the assigned agent the customer background before they start working the ticket.\n\n## Steps\n1. Get Ticket with include set to contacts so the related contact record comes back with the ticket.\n2. Get Contact for the ticket contactId to read the full contact record, including accountId, job title, email, and phone.\n3. List Tickets filtered to the same department and scan for other tickets from the same contact or account to spot repeat issues.\n4. Add Comment as an internal note summarizing who the customer is, their account, and any related open or recent tickets.\n\n## Output\nThe ticket number, the contact and account identified, a list of related tickets by number, and the internal note that was posted. Say explicitly if no contact is linked to the ticket.',
|
||||
},
|
||||
{
|
||||
name: 'package-ticket-for-engineering',
|
||||
description:
|
||||
'Assemble a Zoho Desk ticket conversation and its attachments into a bug report, then record the escalation on the ticket.',
|
||||
content:
|
||||
'# Package a Ticket for Engineering\n\nTurn a support ticket into a developer-ready bug report.\n\n## Steps\n1. Get Ticket to read the subject, descriptionText, priority, and classification.\n2. List Threads and Get Thread on the relevant messages to extract reproduction steps, error text, and environment details.\n3. For each attachment referenced on a thread or comment, Get Attachment with its href to download the screenshot or log.\n4. Write the bug report with a summary, steps to reproduce, expected versus actual behavior, and the attached evidence.\n5. Update Ticket to set classification to Problem and record the tracker key in a custom field via customFields, then Add Comment as an internal note linking the escalation.\n\n## Output\nThe assembled bug report, the ticket number it came from, the attachments downloaded, and confirmation of the custom field and internal note written back to the ticket.',
|
||||
},
|
||||
{
|
||||
name: 'ticket-knowledge-gap-report',
|
||||
description:
|
||||
'Scan recent Zoho Desk tickets for recurring themes and propose the knowledge base articles that would deflect them.',
|
||||
content:
|
||||
'# Ticket Knowledge Gap Report\n\nFind the questions customers keep asking that no article answers.\n\n## Steps\n1. List Tickets across the statuses and departments in scope, paging with from and limit to cover a meaningful window.\n2. Read subject, descriptionText, category, and subCategory on each and group tickets into recurring themes.\n3. Rank themes by ticket volume, and for the top ones Get Ticket and List Threads on a couple of examples to understand what the customer actually needed.\n4. For each theme, propose a knowledge base article with a working title and the questions it must answer.\n\n## Output\nA ranked table of themes with ticket counts and example ticket numbers, plus the proposed article titles and outlines. Note which themes are one-off incidents rather than genuine documentation gaps.',
|
||||
},
|
||||
],
|
||||
} as const satisfies BlockMeta
|
||||
@@ -338,6 +338,7 @@ import { YouTubeBlock, YouTubeBlockMeta } from '@/blocks/blocks/youtube'
|
||||
import { ZendeskBlock, ZendeskBlockMeta } from '@/blocks/blocks/zendesk'
|
||||
import { ZepBlock, ZepBlockMeta } from '@/blocks/blocks/zep'
|
||||
import { ZeroBounceBlock, ZeroBounceBlockMeta } from '@/blocks/blocks/zerobounce'
|
||||
import { ZohoDeskBlock, ZohoDeskBlockMeta } from '@/blocks/blocks/zoho-desk'
|
||||
import { ZoomBlock, ZoomBlockMeta } from '@/blocks/blocks/zoom'
|
||||
import { ZoomInfoBlock, ZoomInfoBlockMeta } from '@/blocks/blocks/zoominfo'
|
||||
import type { BlockConfig, BlockMeta } from '@/blocks/types'
|
||||
@@ -657,6 +658,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
|
||||
youtube: YouTubeBlock,
|
||||
zendesk: ZendeskBlock,
|
||||
zep: ZepBlock,
|
||||
zoho_desk: ZohoDeskBlock,
|
||||
zoom: ZoomBlock,
|
||||
zoominfo: ZoomInfoBlock,
|
||||
}
|
||||
@@ -922,6 +924,7 @@ export const BLOCK_META_REGISTRY: Record<string, BlockMeta> = {
|
||||
zendesk: ZendeskBlockMeta,
|
||||
zep: ZepBlockMeta,
|
||||
zerobounce: ZeroBounceBlockMeta,
|
||||
zoho_desk: ZohoDeskBlockMeta,
|
||||
zoom: ZoomBlockMeta,
|
||||
zoominfo: ZoomInfoBlockMeta,
|
||||
}
|
||||
|
||||
@@ -8927,3 +8927,20 @@ export function LogfireIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ZohoDeskIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
aria-hidden='true'
|
||||
>
|
||||
<path
|
||||
d='M12 2.75c-4.28 0-7.75 3.47-7.75 7.75v3.1A2.6 2.6 0 0 0 3 16.35v1.3A2.6 2.6 0 0 0 5.6 20.25h1.15a.9.9 0 0 0 .9-.9v-4.9a.9.9 0 0 0-.9-.9H6.05v-2.15a5.95 5.95 0 0 1 11.9 0v2.15h-.7a.9.9 0 0 0-.9.9v4.9c0 .17.05.33.13.47-.5.6-1.24.98-2.08.98h-1.02a1.4 1.4 0 0 0-1.31-.9h-1a1.4 1.4 0 0 0 0 2.8h1a1.4 1.4 0 0 0 1.31-.9h1.02c2.06 0 3.74-1.63 3.83-3.67a2.6 2.6 0 0 0 1.44-2.33v-1.3a2.6 2.6 0 0 0-1.25-2.22v-3.1c0-4.28-3.47-7.75-7.75-7.75Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -147,6 +147,7 @@ export function useUpdateWorkspaceCredential() {
|
||||
clientId: payload.clientId,
|
||||
clientSecret: payload.clientSecret,
|
||||
orgId: payload.orgId,
|
||||
dataCenter: payload.dataCenter,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface OAuthTokenBundle {
|
||||
accessToken: string
|
||||
cloudId?: string
|
||||
domain?: string
|
||||
apiDomain?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,5 +25,6 @@ export async function fetchOAuthToken(
|
||||
accessToken: token.accessToken,
|
||||
cloudId: token.cloudId,
|
||||
domain: token.domain,
|
||||
apiDomain: token.apiDomain,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockRequestJson } = vi.hoisted(() => ({ mockRequestJson: vi.fn() }))
|
||||
|
||||
vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson }))
|
||||
|
||||
import { getSelectorDefinition } from '@/hooks/selectors/registry'
|
||||
import type { SelectorQueryArgs } from '@/hooks/selectors/types'
|
||||
|
||||
const organizations = getSelectorDefinition('zoho_desk.organizations')
|
||||
const departments = getSelectorDefinition('zoho_desk.departments')
|
||||
const agents = getSelectorDefinition('zoho_desk.agents')
|
||||
|
||||
const orgArgs = (overrides: Partial<SelectorQueryArgs['context']> = {}): SelectorQueryArgs => ({
|
||||
key: 'zoho_desk.organizations',
|
||||
context: { oauthCredential: 'cred-1', workflowId: 'wf-1', ...overrides },
|
||||
})
|
||||
|
||||
const deptArgs = (overrides: Partial<SelectorQueryArgs['context']> = {}): SelectorQueryArgs => ({
|
||||
key: 'zoho_desk.departments',
|
||||
context: { oauthCredential: 'cred-1', workflowId: 'wf-1', orgId: 'org-9', ...overrides },
|
||||
})
|
||||
|
||||
const agentArgs = (overrides: Partial<SelectorQueryArgs['context']> = {}): SelectorQueryArgs => ({
|
||||
key: 'zoho_desk.agents',
|
||||
context: { oauthCredential: 'cred-1', workflowId: 'wf-1', orgId: 'org-9', ...overrides },
|
||||
})
|
||||
|
||||
describe('zoho_desk.organizations selector', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('is enabled only once a credential is selected', () => {
|
||||
expect(organizations.enabled?.(orgArgs())).toBe(true)
|
||||
expect(organizations.enabled?.(orgArgs({ oauthCredential: undefined }))).toBe(false)
|
||||
})
|
||||
|
||||
it('keys the query by credential', () => {
|
||||
expect(organizations.getQueryKey(orgArgs())).toEqual([
|
||||
'selectors',
|
||||
'zoho_desk.organizations',
|
||||
'cred-1',
|
||||
])
|
||||
expect(organizations.getQueryKey(orgArgs({ oauthCredential: undefined }))).toEqual([
|
||||
'selectors',
|
||||
'zoho_desk.organizations',
|
||||
'none',
|
||||
])
|
||||
})
|
||||
|
||||
it('posts the credential id (never a token) and maps organizations to options', async () => {
|
||||
mockRequestJson.mockResolvedValue({
|
||||
organizations: [
|
||||
{ id: '700123', name: 'Zylker' },
|
||||
{ id: '700124', name: 'zPad' },
|
||||
],
|
||||
})
|
||||
|
||||
const options = await organizations.fetchList?.(orgArgs())
|
||||
|
||||
expect(mockRequestJson).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: '/api/tools/zoho_desk/organizations' }),
|
||||
expect.objectContaining({ body: { credential: 'cred-1', workflowId: 'wf-1' } })
|
||||
)
|
||||
expect(options).toEqual([
|
||||
{ id: '700123', label: 'Zylker' },
|
||||
{ id: '700124', label: 'zPad' },
|
||||
])
|
||||
})
|
||||
|
||||
it('throws when the credential is missing rather than calling the route', async () => {
|
||||
await expect(
|
||||
organizations.fetchList?.(orgArgs({ oauthCredential: undefined }))
|
||||
).rejects.toThrow(/Missing credential/)
|
||||
expect(mockRequestJson).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('zoho_desk.departments selector', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('stays disabled until both the credential and the organization are set', () => {
|
||||
expect(departments.enabled?.(deptArgs())).toBe(true)
|
||||
expect(departments.enabled?.(deptArgs({ orgId: undefined }))).toBe(false)
|
||||
expect(departments.enabled?.(deptArgs({ oauthCredential: undefined }))).toBe(false)
|
||||
})
|
||||
|
||||
it('keys the query by credential and organization so switching portals refetches', () => {
|
||||
expect(departments.getQueryKey(deptArgs())).toEqual([
|
||||
'selectors',
|
||||
'zoho_desk.departments',
|
||||
'cred-1',
|
||||
'org-9',
|
||||
])
|
||||
expect(departments.getQueryKey(deptArgs({ orgId: undefined }))).toEqual([
|
||||
'selectors',
|
||||
'zoho_desk.departments',
|
||||
'cred-1',
|
||||
'none',
|
||||
])
|
||||
})
|
||||
|
||||
it('forwards the organization id and maps departments to options', async () => {
|
||||
mockRequestJson.mockResolvedValue({
|
||||
departments: [{ id: '1892000000082069', name: 'Zylker' }],
|
||||
})
|
||||
|
||||
const options = await departments.fetchList?.(deptArgs())
|
||||
|
||||
expect(mockRequestJson).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: '/api/tools/zoho_desk/departments' }),
|
||||
expect.objectContaining({
|
||||
body: { credential: 'cred-1', orgId: 'org-9', workflowId: 'wf-1' },
|
||||
})
|
||||
)
|
||||
expect(options).toEqual([{ id: '1892000000082069', label: 'Zylker' }])
|
||||
})
|
||||
|
||||
it('throws when the organization is missing rather than calling the route unscoped', async () => {
|
||||
await expect(departments.fetchList?.(deptArgs({ orgId: undefined }))).rejects.toThrow(
|
||||
/Missing organization ID/
|
||||
)
|
||||
expect(mockRequestJson).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('zoho_desk.agents selector', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('stays disabled until both the credential and the organization are set', () => {
|
||||
expect(agents.enabled?.(agentArgs())).toBe(true)
|
||||
expect(agents.enabled?.(agentArgs({ orgId: undefined }))).toBe(false)
|
||||
expect(agents.enabled?.(agentArgs({ oauthCredential: undefined }))).toBe(false)
|
||||
})
|
||||
|
||||
it('keys the query by credential and organization so switching portals refetches', () => {
|
||||
expect(agents.getQueryKey(agentArgs())).toEqual([
|
||||
'selectors',
|
||||
'zoho_desk.agents',
|
||||
'cred-1',
|
||||
'org-9',
|
||||
])
|
||||
expect(agents.getQueryKey(agentArgs({ orgId: undefined }))).toEqual([
|
||||
'selectors',
|
||||
'zoho_desk.agents',
|
||||
'cred-1',
|
||||
'none',
|
||||
])
|
||||
})
|
||||
|
||||
it('forwards the organization id and maps agents to options', async () => {
|
||||
mockRequestJson.mockResolvedValue({
|
||||
agents: [
|
||||
{ id: '1892000000056007', name: 'zyl case' },
|
||||
{ id: '1892000000042001', name: 'jade' },
|
||||
],
|
||||
})
|
||||
|
||||
const options = await agents.fetchList?.(agentArgs())
|
||||
|
||||
expect(mockRequestJson).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: '/api/tools/zoho_desk/agents' }),
|
||||
expect.objectContaining({
|
||||
body: { credential: 'cred-1', orgId: 'org-9', workflowId: 'wf-1' },
|
||||
})
|
||||
)
|
||||
expect(options).toEqual([
|
||||
{ id: '1892000000056007', label: 'zyl case' },
|
||||
{ id: '1892000000042001', label: 'jade' },
|
||||
])
|
||||
})
|
||||
|
||||
it('throws when the organization is missing rather than calling the route unscoped', async () => {
|
||||
await expect(agents.fetchList?.(agentArgs({ orgId: undefined }))).rejects.toThrow(
|
||||
/Missing organization ID/
|
||||
)
|
||||
expect(mockRequestJson).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws when the credential is missing rather than calling the route', async () => {
|
||||
await expect(agents.fetchList?.(agentArgs({ oauthCredential: undefined }))).rejects.toThrow(
|
||||
/Missing credential/
|
||||
)
|
||||
expect(mockRequestJson).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import * as selectorContracts from '@/lib/api/contracts/selectors'
|
||||
import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
|
||||
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
|
||||
|
||||
export const zohoDeskSelectors = {
|
||||
'zoho_desk.organizations': {
|
||||
key: 'zoho_desk.organizations',
|
||||
contracts: [selectorContracts.zohoDeskOrganizationsSelectorContract],
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'zoho_desk.organizations',
|
||||
context.oauthCredential ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) => Boolean(context.oauthCredential),
|
||||
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'zoho_desk.organizations')
|
||||
const data = await requestJson(selectorContracts.zohoDeskOrganizationsSelectorContract, {
|
||||
body: { credential: credentialId, workflowId: context.workflowId },
|
||||
signal,
|
||||
})
|
||||
return (data.organizations || []).map((organization) => ({
|
||||
id: organization.id,
|
||||
label: organization.name,
|
||||
}))
|
||||
},
|
||||
},
|
||||
'zoho_desk.departments': {
|
||||
key: 'zoho_desk.departments',
|
||||
contracts: [selectorContracts.zohoDeskDepartmentsSelectorContract],
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'zoho_desk.departments',
|
||||
context.oauthCredential ?? 'none',
|
||||
context.orgId ?? 'none',
|
||||
],
|
||||
// Every Desk call but `/organizations` is scoped by the `orgId` header, so
|
||||
// the organization must be chosen before departments can be listed.
|
||||
enabled: ({ context }) => Boolean(context.oauthCredential && context.orgId),
|
||||
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'zoho_desk.departments')
|
||||
if (!context.orgId) {
|
||||
throw new Error('Missing organization ID for zoho_desk.departments selector')
|
||||
}
|
||||
const data = await requestJson(selectorContracts.zohoDeskDepartmentsSelectorContract, {
|
||||
body: {
|
||||
credential: credentialId,
|
||||
orgId: context.orgId,
|
||||
workflowId: context.workflowId,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
return (data.departments || []).map((department) => ({
|
||||
id: department.id,
|
||||
label: department.name,
|
||||
}))
|
||||
},
|
||||
},
|
||||
'zoho_desk.agents': {
|
||||
key: 'zoho_desk.agents',
|
||||
contracts: [selectorContracts.zohoDeskAgentsSelectorContract],
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'zoho_desk.agents',
|
||||
context.oauthCredential ?? 'none',
|
||||
context.orgId ?? 'none',
|
||||
],
|
||||
// Same `orgId` header scoping as departments: the organization must be
|
||||
// chosen before agents can be listed.
|
||||
enabled: ({ context }) => Boolean(context.oauthCredential && context.orgId),
|
||||
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
|
||||
const credentialId = ensureCredential(context, 'zoho_desk.agents')
|
||||
if (!context.orgId) {
|
||||
throw new Error('Missing organization ID for zoho_desk.agents selector')
|
||||
}
|
||||
const data = await requestJson(selectorContracts.zohoDeskAgentsSelectorContract, {
|
||||
body: {
|
||||
credential: credentialId,
|
||||
orgId: context.orgId,
|
||||
workflowId: context.workflowId,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
return (data.agents || []).map((agent) => ({
|
||||
id: agent.id,
|
||||
label: agent.name,
|
||||
}))
|
||||
},
|
||||
},
|
||||
} satisfies Record<
|
||||
Extract<SelectorKey, 'zoho_desk.organizations' | 'zoho_desk.departments' | 'zoho_desk.agents'>,
|
||||
SelectorDefinition
|
||||
>
|
||||
@@ -21,6 +21,7 @@ import { slackSelectors } from '@/hooks/selectors/providers/slack/selectors'
|
||||
import { trelloSelectors } from '@/hooks/selectors/providers/trello/selectors'
|
||||
import { wealthboxSelectors } from '@/hooks/selectors/providers/wealthbox/selectors'
|
||||
import { webflowSelectors } from '@/hooks/selectors/providers/webflow/selectors'
|
||||
import { zohoDeskSelectors } from '@/hooks/selectors/providers/zoho-desk/selectors'
|
||||
import { zoomSelectors } from '@/hooks/selectors/providers/zoom/selectors'
|
||||
import type {
|
||||
SelectorDefinition,
|
||||
@@ -43,6 +44,7 @@ export const selectorRegistry = {
|
||||
...pipedriveSelectors,
|
||||
...sharepointSelectors,
|
||||
...trelloSelectors,
|
||||
...zohoDeskSelectors,
|
||||
...zoomSelectors,
|
||||
...slackSelectors,
|
||||
...wealthboxSelectors,
|
||||
|
||||
@@ -26,6 +26,9 @@ export type SelectorKey =
|
||||
| 'pipedrive.pipelines'
|
||||
| 'sharepoint.lists'
|
||||
| 'trello.boards'
|
||||
| 'zoho_desk.organizations'
|
||||
| 'zoho_desk.departments'
|
||||
| 'zoho_desk.agents'
|
||||
| 'zoom.meetings'
|
||||
| 'slack.channels'
|
||||
| 'slack.users'
|
||||
@@ -101,6 +104,8 @@ export interface SelectorContext {
|
||||
logGroupName?: string
|
||||
mcpServerId?: string
|
||||
tableId?: string
|
||||
/** Zoho Desk organization (portal) id — the `orgId` header every Desk call but `/organizations` requires. */
|
||||
orgId?: string
|
||||
}
|
||||
|
||||
export interface SelectorQueryArgs {
|
||||
|
||||
@@ -133,6 +133,8 @@ export const createCredentialBodySchema = z
|
||||
clientId: z.string().trim().min(1).max(512).optional(),
|
||||
clientSecret: z.string().trim().min(1).max(1024).optional(),
|
||||
orgId: z.string().trim().min(1).max(255).optional(),
|
||||
/** Optional provider region selector (Zoho Desk data center). */
|
||||
dataCenter: z.string().trim().min(1).max(32).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.type === 'oauth') {
|
||||
@@ -207,6 +209,7 @@ export const updateCredentialByIdBodySchema = z
|
||||
clientId: z.string().trim().min(1).max(512).optional(),
|
||||
clientSecret: z.string().trim().min(1).max(1024).optional(),
|
||||
orgId: z.string().trim().min(1).max(255).optional(),
|
||||
dataCenter: z.string().trim().min(1).max(32).optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
@@ -220,7 +223,8 @@ export const updateCredentialByIdBodySchema = z
|
||||
data.domain !== undefined ||
|
||||
data.clientId !== undefined ||
|
||||
data.clientSecret !== undefined ||
|
||||
data.orgId !== undefined,
|
||||
data.orgId !== undefined ||
|
||||
data.dataCenter !== undefined,
|
||||
{
|
||||
message: 'At least one field must be provided',
|
||||
path: ['displayName'],
|
||||
|
||||
@@ -86,6 +86,8 @@ const oauthTokenResponseSchema = z.object({
|
||||
accessToken: z.string(),
|
||||
idToken: z.string().optional(),
|
||||
instanceUrl: z.string().optional(),
|
||||
/** Zoho Desk — the data-center-scoped Desk REST base for this credential. */
|
||||
apiDomain: z.string().optional(),
|
||||
cloudId: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
authStyle: z.enum(['x-api-token']).optional(),
|
||||
|
||||
@@ -107,6 +107,11 @@ import {
|
||||
webflowItemsSelectorContract,
|
||||
webflowSitesSelectorContract,
|
||||
} from '@/lib/api/contracts/selectors/webflow'
|
||||
import {
|
||||
zohoDeskAgentsSelectorContract,
|
||||
zohoDeskDepartmentsSelectorContract,
|
||||
zohoDeskOrganizationsSelectorContract,
|
||||
} from '@/lib/api/contracts/selectors/zoho-desk'
|
||||
import { zoomMeetingsSelectorContract } from '@/lib/api/contracts/selectors/zoom'
|
||||
|
||||
export * from '@/lib/api/contracts/selectors/airtable'
|
||||
@@ -133,6 +138,7 @@ export * from '@/lib/api/contracts/selectors/slack'
|
||||
export * from '@/lib/api/contracts/selectors/trello'
|
||||
export * from '@/lib/api/contracts/selectors/wealthbox'
|
||||
export * from '@/lib/api/contracts/selectors/webflow'
|
||||
export * from '@/lib/api/contracts/selectors/zoho-desk'
|
||||
export * from '@/lib/api/contracts/selectors/zoom'
|
||||
|
||||
export const selectorContractsByPath = {
|
||||
@@ -162,6 +168,9 @@ export const selectorContractsByPath = {
|
||||
'/api/tools/sharepoint/site': sharepointSiteSelectorContract,
|
||||
'/api/tools/sharepoint/sites': sharepointSitesSelectorContract,
|
||||
'/api/tools/trello/boards': trelloBoardsSelectorContract,
|
||||
'/api/tools/zoho_desk/organizations': zohoDeskOrganizationsSelectorContract,
|
||||
'/api/tools/zoho_desk/departments': zohoDeskDepartmentsSelectorContract,
|
||||
'/api/tools/zoho_desk/agents': zohoDeskAgentsSelectorContract,
|
||||
'/api/tools/zoom/meetings': zoomMeetingsSelectorContract,
|
||||
'/api/tools/slack/channels': slackChannelsSelectorContract,
|
||||
'/api/tools/slack/users': slackUsersSelectorContract,
|
||||
|
||||
@@ -10,6 +10,7 @@ const oauthTokenResponseSchema = z
|
||||
instanceUrl: z.string().optional(),
|
||||
cloudId: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
apiDomain: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
credentialWorkflowBodySchema,
|
||||
definePostSelector,
|
||||
idNameSchema,
|
||||
} from '@/lib/api/contracts/selectors/shared'
|
||||
import type { ContractJsonResponse } from '@/lib/api/contracts/types'
|
||||
|
||||
/**
|
||||
* Zoho Desk scopes every call except `GET /organizations` to a single portal via
|
||||
* the `orgId` header, so every downstream selector must carry the organization
|
||||
* the user picked.
|
||||
*/
|
||||
const zohoDeskOrgIdSchema = z.string().min(1, 'orgId is required')
|
||||
|
||||
export const zohoDeskOrganizationsSelectorContract = definePostSelector(
|
||||
'/api/tools/zoho_desk/organizations',
|
||||
credentialWorkflowBodySchema,
|
||||
z.object({ organizations: z.array(idNameSchema) })
|
||||
)
|
||||
|
||||
export const zohoDeskDepartmentsSelectorContract = definePostSelector(
|
||||
'/api/tools/zoho_desk/departments',
|
||||
credentialWorkflowBodySchema.extend({ orgId: zohoDeskOrgIdSchema }),
|
||||
z.object({ departments: z.array(idNameSchema) })
|
||||
)
|
||||
|
||||
export const zohoDeskAgentsSelectorContract = definePostSelector(
|
||||
'/api/tools/zoho_desk/agents',
|
||||
credentialWorkflowBodySchema.extend({ orgId: zohoDeskOrgIdSchema }),
|
||||
z.object({ agents: z.array(idNameSchema) })
|
||||
)
|
||||
|
||||
export type ZohoDeskOrganizationsSelectorResponse = ContractJsonResponse<
|
||||
typeof zohoDeskOrganizationsSelectorContract
|
||||
>
|
||||
export type ZohoDeskDepartmentsSelectorResponse = ContractJsonResponse<
|
||||
typeof zohoDeskDepartmentsSelectorContract
|
||||
>
|
||||
export type ZohoDeskAgentsSelectorResponse = ContractJsonResponse<
|
||||
typeof zohoDeskAgentsSelectorContract
|
||||
>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from 'zod'
|
||||
import type { ContractBodyInput, ContractJsonResponse } from '@/lib/api/contracts/types'
|
||||
import { defineRouteContract } from '@/lib/api/contracts/types'
|
||||
|
||||
const zohoAccessTokenSchema = z.string().min(1, 'Access token is required')
|
||||
const zohoApiDomainSchema = z.string().optional().nullable()
|
||||
const zohoOrgIdSchema = z.string().min(1, 'Organization ID is required')
|
||||
|
||||
export const zohoDeskGetAttachmentBodySchema = z.object({
|
||||
accessToken: zohoAccessTokenSchema,
|
||||
apiDomain: zohoApiDomainSchema,
|
||||
orgId: zohoOrgIdSchema,
|
||||
// The documented download reference from an attachment object (thread/comment
|
||||
// attachments expose `href`). Absolute Zoho URLs are used as-is; a relative
|
||||
// path is resolved against the Desk API base.
|
||||
href: z.string().min(1, 'Attachment href is required'),
|
||||
fileName: z.string().optional().nullable(),
|
||||
})
|
||||
|
||||
const zohoDeskFileSchema = z.object({
|
||||
data: z.string(),
|
||||
mimeType: z.string(),
|
||||
// FileToolProcessor (ToolFileData) reads the file name from `name`.
|
||||
name: z.string(),
|
||||
})
|
||||
|
||||
export const zohoDeskGetAttachmentResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
output: z.object({ file: zohoDeskFileSchema }).optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
|
||||
export const zohoDeskGetAttachmentContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/tools/zoho_desk/attachment',
|
||||
body: zohoDeskGetAttachmentBodySchema,
|
||||
response: { mode: 'json', schema: zohoDeskGetAttachmentResponseSchema },
|
||||
})
|
||||
|
||||
export type ZohoDeskGetAttachmentBody = ContractBodyInput<typeof zohoDeskGetAttachmentContract>
|
||||
export type ZohoDeskGetAttachmentResponse = ContractJsonResponse<
|
||||
typeof zohoDeskGetAttachmentContract
|
||||
>
|
||||
@@ -109,6 +109,7 @@ import { joinInstanceOrganization } from '@/lib/organizations/instance-org'
|
||||
import { captureServerEvent, getPostHogClient } from '@/lib/posthog/server'
|
||||
import { disableUserResources } from '@/lib/workflows/lifecycle'
|
||||
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
|
||||
import { deriveZohoDeskBaseFromApiDomain } from '@/tools/zoho_desk/host-allowlist'
|
||||
|
||||
const logger = createLogger('Auth')
|
||||
|
||||
@@ -2028,6 +2029,161 @@ export const auth = betterAuth({
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
providerId: 'zoho-desk',
|
||||
clientId: env.ZOHO_CLIENT_ID as string,
|
||||
clientSecret: env.ZOHO_CLIENT_SECRET as string,
|
||||
authorizationUrl: 'https://accounts.zoho.com/oauth/v2/auth',
|
||||
tokenUrl: 'https://accounts.zoho.com/oauth/v2/token',
|
||||
scopes: getCanonicalScopesForProvider('zoho-desk'),
|
||||
responseType: 'code',
|
||||
pkce: true,
|
||||
accessType: 'offline',
|
||||
prompt: 'consent',
|
||||
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/zoho-desk`,
|
||||
// Zoho only issues a refresh token when access_type=offline AND
|
||||
// prompt=consent are present on the authorize request, and it expects
|
||||
// comma-separated scopes rather than the default space-delimited list.
|
||||
authorizationUrlParams: {
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
scope: getCanonicalScopesForProvider('zoho-desk').join(','),
|
||||
},
|
||||
getToken: async ({ code, redirectURI, codeVerifier }) => {
|
||||
const tokenParams = new URLSearchParams({
|
||||
client_id: env.ZOHO_CLIENT_ID as string,
|
||||
client_secret: env.ZOHO_CLIENT_SECRET as string,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: redirectURI,
|
||||
})
|
||||
// PKCE is enabled, so better-auth sent a code_challenge on the authorize
|
||||
// request. The exchange MUST echo the matching code_verifier or Zoho
|
||||
// rejects the request shape (invalid_request). Verified by isolating
|
||||
// pkce:false (which connected) then restoring pkce:true + this verifier.
|
||||
if (codeVerifier) tokenParams.set('code_verifier', codeVerifier)
|
||||
|
||||
const response = await fetch('https://accounts.zoho.com/oauth/v2/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: tokenParams,
|
||||
})
|
||||
const data = await readResponseJsonWithLimit<Record<string, unknown>>(response, {
|
||||
maxBytes: 1024 * 1024,
|
||||
label: 'Zoho Desk OAuth token response',
|
||||
})
|
||||
|
||||
// Zoho signals OAuth failures in the JSON body, usually with HTTP 200,
|
||||
// e.g. { error: 'invalid_code' } or { error: 'invalid_client',
|
||||
// error_description: '...' }. The status-only guard therefore never
|
||||
// fires, so surface the actual error/description instead of collapsing
|
||||
// every failure into one opaque "no access token" string.
|
||||
const errorObj =
|
||||
data && typeof data === 'object' && !Array.isArray(data)
|
||||
? (data as { error?: unknown; error_description?: unknown })
|
||||
: {}
|
||||
const zohoError = typeof errorObj.error === 'string' ? errorObj.error : undefined
|
||||
const zohoErrorDescription =
|
||||
typeof errorObj.error_description === 'string'
|
||||
? errorObj.error_description
|
||||
: undefined
|
||||
if (
|
||||
!response.ok ||
|
||||
!data ||
|
||||
typeof data !== 'object' ||
|
||||
Array.isArray(data) ||
|
||||
zohoError
|
||||
) {
|
||||
logger.error('Zoho Desk OAuth token exchange failed', {
|
||||
status: response.status,
|
||||
zohoError: zohoError ?? null,
|
||||
zohoErrorDescription: zohoErrorDescription ?? null,
|
||||
})
|
||||
throw new Error(
|
||||
`Zoho Desk OAuth token exchange failed (HTTP ${response.status}${
|
||||
zohoError ? `, ${zohoError}` : ''
|
||||
}${zohoErrorDescription ? `: ${zohoErrorDescription}` : ''})`
|
||||
)
|
||||
}
|
||||
|
||||
const tokens = getOAuth2Tokens(data)
|
||||
if (!tokens.accessToken) {
|
||||
logger.error('Zoho Desk OAuth token response had no access token', {
|
||||
status: response.status,
|
||||
bodyKeys: Object.keys(data),
|
||||
})
|
||||
throw new Error('Zoho Desk OAuth token response did not include an access token')
|
||||
}
|
||||
|
||||
// Persist the data-center-scoped Desk REST base derived from the
|
||||
// token response api_domain so every API call targets the correct
|
||||
// host instead of assuming desk.zoho.com. Stored inside the scope
|
||||
// string (survives refreshes, which never rewrite scope) and read
|
||||
// back in /api/auth/oauth/token as `apiDomain`.
|
||||
const deskBase = deriveZohoDeskBaseFromApiDomain(
|
||||
typeof data.api_domain === 'string' ? data.api_domain : undefined
|
||||
)
|
||||
// Zoho's docs are inconsistent about whether the Desk token response
|
||||
// carries `scope` (the Mail sample has it; the CRM/Creator samples do
|
||||
// not). If it is absent, fall back to the scopes we requested and were
|
||||
// granted by completing the flow - otherwise the stored scope list is
|
||||
// just the domain marker, and the credential picker would show a
|
||||
// permanent "needs update / reconnect" badge on every connection.
|
||||
// Mirrors the existing Box fallback in this file.
|
||||
const reportedScopes =
|
||||
typeof data.scope === 'string' ? data.scope.split(/[\s,]+/).filter(Boolean) : []
|
||||
const grantedScopes = reportedScopes.length
|
||||
? reportedScopes
|
||||
: getCanonicalScopesForProvider('zoho-desk')
|
||||
tokens.scopes = [`__zoho_domain__:${deskBase}`, ...grantedScopes]
|
||||
return tokens
|
||||
},
|
||||
getUserInfo: async (tokens) => {
|
||||
try {
|
||||
const response = await fetch('https://accounts.zoho.com/oauth/user/info', {
|
||||
headers: { Authorization: `Zoho-oauthtoken ${tokens.accessToken}` },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
await readResponseTextWithLimit(response, {
|
||||
maxBytes: 1024 * 1024,
|
||||
label: 'Zoho Desk profile error response',
|
||||
}).catch(() => {})
|
||||
logger.error('Error fetching Zoho Desk user info:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const profile = await readResponseJsonWithLimit<{
|
||||
ZUID?: number | string
|
||||
Display_Name?: string
|
||||
Email?: string
|
||||
}>(response, { maxBytes: 1024 * 1024, label: 'Zoho Desk profile response' })
|
||||
|
||||
const zuid = profile.ZUID?.toString()
|
||||
if (!zuid) {
|
||||
logger.error('Invalid Zoho Desk profile response:', profile)
|
||||
return null
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
return {
|
||||
id: `${zuid}-${generateId()}`,
|
||||
name: profile.Display_Name || 'Zoho User',
|
||||
email: profile.Email || `zoho-${zuid}@zoho.user`,
|
||||
emailVerified: Boolean(profile.Email),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in Zoho Desk getUserInfo:', { error })
|
||||
return null
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
providerId: 'x',
|
||||
clientId: env.X_CLIENT_ID as string,
|
||||
|
||||
@@ -411,6 +411,8 @@ export const env = createEnv({
|
||||
HUBSPOT_CLIENT_SECRET: z.string().optional(), // HubSpot OAuth client secret
|
||||
SALESFORCE_CLIENT_ID: z.string().optional(), // Salesforce OAuth client ID
|
||||
SALESFORCE_CLIENT_SECRET: z.string().optional(), // Salesforce OAuth client secret
|
||||
ZOHO_CLIENT_ID: z.string().optional(), // Zoho OAuth client ID (Zoho Desk)
|
||||
ZOHO_CLIENT_SECRET: z.string().optional(), // Zoho OAuth client secret (Zoho Desk)
|
||||
WEALTHBOX_CLIENT_ID: z.string().optional(), // WealthBox OAuth client ID
|
||||
WEALTHBOX_CLIENT_SECRET: z.string().optional(), // WealthBox OAuth client secret
|
||||
PIPEDRIVE_CLIENT_ID: z.string().optional(), // Pipedrive OAuth client ID
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
export const CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE = 'client_credential_account' as const
|
||||
|
||||
/** Contract field ids a client-credential connect modal collects. */
|
||||
export type ClientCredentialAccountFieldId = 'clientId' | 'clientSecret' | 'orgId'
|
||||
export type ClientCredentialAccountFieldId = 'clientId' | 'clientSecret' | 'orgId' | 'dataCenter'
|
||||
|
||||
export interface ClientCredentialAccountField {
|
||||
id: ClientCredentialAccountFieldId
|
||||
@@ -23,6 +23,12 @@ export interface ClientCredentialAccountField {
|
||||
placeholder: string
|
||||
/** Rendered with SecretInput and never echoed back. */
|
||||
secret: boolean
|
||||
/**
|
||||
* Field the connect modal may submit empty; excluded from
|
||||
* {@link CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS} so create/reconnect
|
||||
* validation never demands it. Omitted (default) means required.
|
||||
*/
|
||||
optional?: boolean
|
||||
/** Soft-format hint shown while the current value doesn't match `hintPattern`. */
|
||||
hintPattern?: RegExp
|
||||
hintMessage?: string
|
||||
@@ -54,11 +60,13 @@ export interface ClientCredentialAccountDescriptor {
|
||||
export const ZOOM_SERVICE_ACCOUNT_PROVIDER_ID = 'zoom-service-account' as const
|
||||
export const BOX_SERVICE_ACCOUNT_PROVIDER_ID = 'box-service-account' as const
|
||||
export const SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID = 'salesforce-service-account' as const
|
||||
export const ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID = 'zoho-desk-service-account' as const
|
||||
|
||||
export type ClientCredentialAccountProviderId =
|
||||
| typeof ZOOM_SERVICE_ACCOUNT_PROVIDER_ID
|
||||
| typeof BOX_SERVICE_ACCOUNT_PROVIDER_ID
|
||||
| typeof SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID
|
||||
| typeof ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID
|
||||
|
||||
/**
|
||||
* Allowed My Domain host shapes: one org label (optionally with a
|
||||
@@ -85,6 +93,95 @@ export function normalizeSalesforceMyDomainHost(rawHost: string): string {
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoho's `soid` token parameter is documented only as the syntax
|
||||
* `{servicename}.{zsoid}` with a single Zoho CRM example
|
||||
* (`ZohoCRM.600*****434`). Two things are NOT confirmed by Zoho's own docs:
|
||||
*
|
||||
* 1. that the Desk service name is literally `ZohoDesk` (inferred from the
|
||||
* documented syntax and corroborated only by community posts), and
|
||||
* 2. that `zsoid` is the same value as the Desk `orgId` sent in the `orgId`
|
||||
* request header, rather than a distinct Zoho ServiceOrg id.
|
||||
*
|
||||
* Both need live verification against a real Zoho Desk org before this flow is
|
||||
* relied on. The normalization is therefore deliberately permissive: a value
|
||||
* that already carries a `{servicename}.` prefix (any dot) is passed through
|
||||
* untouched, so an operator who learns the correct prefix or id can paste the
|
||||
* full `soid` and bypass the inference entirely. A bare id is prefixed with
|
||||
* `ZohoDesk.`.
|
||||
*
|
||||
* Shared by the connect modal's format hint and the server-side minter so both
|
||||
* judge the same normalized value.
|
||||
*/
|
||||
export function normalizeZohoDeskSoid(rawOrgId: string): string {
|
||||
const trimmed = rawOrgId.trim()
|
||||
if (!trimmed || trimmed.includes('.')) return trimmed
|
||||
return `ZohoDesk.${trimmed}`
|
||||
}
|
||||
|
||||
/** A normalized `soid`: a service-name prefix plus a numeric Zoho org id. */
|
||||
export const ZOHO_DESK_SOID_REGEX = /^[A-Za-z]+\.\d+$/
|
||||
|
||||
/**
|
||||
* Zoho data centers the Self Client (client-credentials) flow supports. Each
|
||||
* entry pairs the accounts server that mints the token with the Desk REST host
|
||||
* in the same region, so the minter never has to guess either one.
|
||||
*
|
||||
* Only regions where BOTH hosts are confirmed are listed. CA, SA, JP, CN, and
|
||||
* UK are deliberately absent: Zoho's accounts documentation and Zoho's own Desk
|
||||
* SDK disagree on Canada (`accounts.zohocloud.ca` vs `accounts.zoho.ca`), and
|
||||
* the Desk hosts for the others are not confirmed. More regions can be added
|
||||
* once both the accounts server and the Desk host are confirmed for them.
|
||||
*
|
||||
* This applies to the service account only — the interactive OAuth flow's
|
||||
* authorize/token URLs are static per provider and remain US-only.
|
||||
*/
|
||||
export const ZOHO_DESK_DATA_CENTERS = {
|
||||
us: { accountsBase: 'https://accounts.zoho.com', deskBase: 'https://desk.zoho.com' },
|
||||
eu: { accountsBase: 'https://accounts.zoho.eu', deskBase: 'https://desk.zoho.eu' },
|
||||
in: { accountsBase: 'https://accounts.zoho.in', deskBase: 'https://desk.zoho.in' },
|
||||
au: { accountsBase: 'https://accounts.zoho.com.au', deskBase: 'https://desk.zoho.com.au' },
|
||||
} as const satisfies Record<string, { accountsBase: string; deskBase: string }>
|
||||
|
||||
export type ZohoDeskDataCenterId = keyof typeof ZOHO_DESK_DATA_CENTERS
|
||||
|
||||
/** Region used when the admin leaves the field blank, so existing credentials are unaffected. */
|
||||
export const DEFAULT_ZOHO_DESK_DATA_CENTER: ZohoDeskDataCenterId = 'us'
|
||||
|
||||
export const ZOHO_DESK_DATA_CENTER_IDS = Object.keys(
|
||||
ZOHO_DESK_DATA_CENTERS
|
||||
) as ZohoDeskDataCenterId[]
|
||||
|
||||
/** Accepts exactly the supported region codes, case-insensitively after normalization. */
|
||||
export const ZOHO_DESK_DATA_CENTER_REGEX = new RegExp(`^(${ZOHO_DESK_DATA_CENTER_IDS.join('|')})$`)
|
||||
|
||||
/**
|
||||
* Normalizes a pasted data-center value to its lowercase region code. Shared by
|
||||
* the connect modal's format hint and the server-side minter so both judge the
|
||||
* same normalized value.
|
||||
*/
|
||||
export function normalizeZohoDeskDataCenter(rawDataCenter: string): string {
|
||||
return rawDataCenter.trim().toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a stored data-center value to its accounts/Desk host pair. A blank,
|
||||
* absent, or unrecognized value falls back to {@link DEFAULT_ZOHO_DESK_DATA_CENTER}
|
||||
* so credentials created before this field existed keep minting against the US
|
||||
* accounts server exactly as they did.
|
||||
*/
|
||||
export function resolveZohoDeskDataCenter(rawDataCenter?: string): {
|
||||
id: ZohoDeskDataCenterId
|
||||
accountsBase: string
|
||||
deskBase: string
|
||||
} {
|
||||
const normalized = normalizeZohoDeskDataCenter(rawDataCenter ?? '')
|
||||
const id: ZohoDeskDataCenterId = Object.hasOwn(ZOHO_DESK_DATA_CENTERS, normalized)
|
||||
? (normalized as ZohoDeskDataCenterId)
|
||||
: DEFAULT_ZOHO_DESK_DATA_CENTER
|
||||
return { id, ...ZOHO_DESK_DATA_CENTERS[id] }
|
||||
}
|
||||
|
||||
export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record<
|
||||
ClientCredentialAccountProviderId,
|
||||
ClientCredentialAccountDescriptor
|
||||
@@ -179,12 +276,55 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record<
|
||||
helpText:
|
||||
'The Connected App must have "Enable Client Credentials Flow" checked with a "Run As" integration user set under Edit Policies — every call executes with that user\'s permissions, and deactivating or freezing the user stops all runs.',
|
||||
},
|
||||
[ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID]: {
|
||||
providerId: ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
serviceLabel: 'Zoho Desk',
|
||||
connectNoun: 'Self Client',
|
||||
fields: [
|
||||
{
|
||||
id: 'clientId',
|
||||
label: 'Client ID',
|
||||
placeholder: "Client ID from the Self Client's Client Secret tab",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
id: 'clientSecret',
|
||||
label: 'Client secret',
|
||||
placeholder: 'Paste the client secret',
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
id: 'orgId',
|
||||
label: 'Organization ID',
|
||||
placeholder: '600123456',
|
||||
secret: false,
|
||||
hintPattern: ZOHO_DESK_SOID_REGEX,
|
||||
hintNormalize: normalizeZohoDeskSoid,
|
||||
hintMessage:
|
||||
'Paste the numeric Zoho Desk organization ID from Setup → Developer Space → API, or the full ZohoDesk.<orgId> value.',
|
||||
},
|
||||
{
|
||||
id: 'dataCenter',
|
||||
label: 'Data center',
|
||||
placeholder: 'us',
|
||||
secret: false,
|
||||
optional: true,
|
||||
hintPattern: ZOHO_DESK_DATA_CENTER_REGEX,
|
||||
hintNormalize: normalizeZohoDeskDataCenter,
|
||||
hintMessage: `Enter one of ${ZOHO_DESK_DATA_CENTER_IDS.join(', ')}. Leave blank for us (accounts.zoho.com).`,
|
||||
},
|
||||
],
|
||||
docsUrl: 'https://docs.sim.ai/integrations/zoho-desk-service-account',
|
||||
helpText:
|
||||
'Create the Self Client in the Zoho API Console, add the Zoho Desk scopes, and use the organization ID from Setup → Developer Space → API. Self Clients work with the US, EU, IN, and AU data centers — leave the data center blank for US. Zoho Desk triggers still require an OAuth connection, which is US-only.',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Required contract fields per client-credential provider, consumed by the
|
||||
* `createCredentialBodySchema` superRefine so validation errors name the exact
|
||||
* missing field. Derived from each descriptor's field list.
|
||||
* missing field. Derived from each descriptor's field list, minus the fields
|
||||
* marked `optional`.
|
||||
*/
|
||||
export const CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS: Record<
|
||||
string,
|
||||
@@ -192,7 +332,7 @@ export const CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS: Record<
|
||||
> = Object.fromEntries(
|
||||
Object.values(CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS).map((descriptor) => [
|
||||
descriptor.providerId,
|
||||
descriptor.fields.map((field) => field.id),
|
||||
descriptor.fields.filter((field) => !field.optional).map((field) => field.id),
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetCanonicalScopesForProvider, mockLoggerWarn } = vi.hoisted(() => ({
|
||||
mockGetCanonicalScopesForProvider: vi.fn(),
|
||||
mockLoggerWarn: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/oauth/utils', () => ({
|
||||
getCanonicalScopesForProvider: mockGetCanonicalScopesForProvider,
|
||||
}))
|
||||
|
||||
/**
|
||||
* Overrides the global `@sim/logger` mock (which hands out a fresh logger per
|
||||
* `createLogger` call) with one whose `warn` is a shared spy, so the
|
||||
* api_domain/region mismatch warning can be asserted.
|
||||
*/
|
||||
vi.mock('@sim/logger', () => {
|
||||
const createLogger = () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: mockLoggerWarn,
|
||||
error: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
child: vi.fn(() => createLogger()),
|
||||
withMetadata: vi.fn(() => createLogger()),
|
||||
})
|
||||
return {
|
||||
createLogger: vi.fn(createLogger),
|
||||
logger: createLogger(),
|
||||
runWithRequestContext: vi.fn(<T>(_ctx: unknown, fn: () => T): T => fn()),
|
||||
getRequestContext: vi.fn(() => undefined),
|
||||
}
|
||||
})
|
||||
|
||||
import { mintZohoDeskServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoho-desk'
|
||||
|
||||
const TOKEN_URL = 'https://accounts.zoho.com/oauth/v2/token'
|
||||
|
||||
/**
|
||||
* The four supported data centers, each with the accounts server the mint must
|
||||
* POST to and the Desk REST base the result must carry.
|
||||
*/
|
||||
const DATA_CENTERS = [
|
||||
{ id: 'us', tokenUrl: TOKEN_URL, deskBase: 'https://desk.zoho.com' },
|
||||
{
|
||||
id: 'eu',
|
||||
tokenUrl: 'https://accounts.zoho.eu/oauth/v2/token',
|
||||
deskBase: 'https://desk.zoho.eu',
|
||||
},
|
||||
{
|
||||
id: 'in',
|
||||
tokenUrl: 'https://accounts.zoho.in/oauth/v2/token',
|
||||
deskBase: 'https://desk.zoho.in',
|
||||
},
|
||||
{
|
||||
id: 'au',
|
||||
tokenUrl: 'https://accounts.zoho.com.au/oauth/v2/token',
|
||||
deskBase: 'https://desk.zoho.com.au',
|
||||
},
|
||||
] as const
|
||||
|
||||
const SCOPES = ['Desk.tickets.READ', 'Desk.contacts.READ', 'aaaserver.profile.READ']
|
||||
|
||||
const FIELDS = { clientId: 'zoho-cid', clientSecret: 'zoho-secret', orgId: '600123456' }
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: '',
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
function htmlResponse(status: number, body: string): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: '',
|
||||
json: async () => {
|
||||
throw new SyntaxError('Unexpected token < in JSON')
|
||||
},
|
||||
text: async () => body,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
|
||||
function mintBody(expectedUrl: string = TOKEN_URL): URLSearchParams {
|
||||
const [url, init] = mockFetch.mock.calls[0]
|
||||
expect(url).toBe(expectedUrl)
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded')
|
||||
return new URLSearchParams(init.body as string)
|
||||
}
|
||||
|
||||
describe('mintZohoDeskServiceAccountToken', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
mockGetCanonicalScopesForProvider.mockReturnValue(SCOPES)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns the minted token, derived Desk base, scopes, and identity on success', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse(200, {
|
||||
access_token: 'zoho-access',
|
||||
api_domain: 'https://www.zohoapis.com',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
scope: 'Desk.tickets.READ,Desk.contacts.READ',
|
||||
})
|
||||
)
|
||||
|
||||
const result = await mintZohoDeskServiceAccountToken(FIELDS)
|
||||
|
||||
expect(result).toEqual({
|
||||
accessToken: 'zoho-access',
|
||||
expiresInSeconds: 3600,
|
||||
apiDomain: 'https://desk.zoho.com',
|
||||
grantedScopes: ['Desk.tickets.READ', 'Desk.contacts.READ'],
|
||||
identity: {
|
||||
displayName: 'Zoho Desk org 600123456',
|
||||
auditMetadata: {
|
||||
zohoDeskSoid: 'ZohoDesk.600123456',
|
||||
zohoDeskClientId: 'zoho-cid',
|
||||
},
|
||||
storedMetadata: {
|
||||
soid: 'ZohoDesk.600123456',
|
||||
apiDomain: 'https://desk.zoho.com',
|
||||
dataCenter: 'us',
|
||||
grantedScopes: 'Desk.tickets.READ Desk.contacts.READ',
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('sends client_credentials with a COMMA-separated scope list and a ZohoDesk-prefixed soid', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(200, { access_token: 'zoho-access' }))
|
||||
|
||||
await mintZohoDeskServiceAccountToken(FIELDS)
|
||||
|
||||
const body = mintBody()
|
||||
expect(body.get('grant_type')).toBe('client_credentials')
|
||||
expect(body.get('client_id')).toBe('zoho-cid')
|
||||
expect(body.get('client_secret')).toBe('zoho-secret')
|
||||
// Comma-separated, and Desk-only: `aaaserver.profile.READ` belongs to the
|
||||
// interactive OAuth flow's getUserInfo call, which this grant never makes.
|
||||
expect(body.get('scope')).toBe('Desk.tickets.READ,Desk.contacts.READ')
|
||||
expect(body.get('scope')).not.toContain(' ')
|
||||
expect(body.get('scope')).not.toContain('aaaserver')
|
||||
expect(body.get('soid')).toBe('ZohoDesk.600123456')
|
||||
expect(mockGetCanonicalScopesForProvider).toHaveBeenCalledWith('zoho-desk')
|
||||
})
|
||||
|
||||
it('passes an already-prefixed soid through unchanged', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(200, { access_token: 'zoho-access' }))
|
||||
|
||||
await mintZohoDeskServiceAccountToken({ ...FIELDS, orgId: ' ZohoCRM.600123456 ' })
|
||||
|
||||
expect(mintBody().get('soid')).toBe('ZohoCRM.600123456')
|
||||
})
|
||||
|
||||
it('derives the data-center Desk base from a non-US api_domain', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse(200, { access_token: 'zoho-access', api_domain: 'https://www.zohoapis.eu' })
|
||||
)
|
||||
|
||||
const result = await mintZohoDeskServiceAccountToken(FIELDS, { skipIdentity: true })
|
||||
|
||||
expect(result).toEqual({
|
||||
accessToken: 'zoho-access',
|
||||
expiresInSeconds: 3600,
|
||||
apiDomain: 'https://desk.zoho.eu',
|
||||
grantedScopes: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the US Desk base when api_domain is absent or not a Zoho host', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse(200, { access_token: 'a', api_domain: 'https://zoho.attacker.com' })
|
||||
)
|
||||
|
||||
const result = await mintZohoDeskServiceAccountToken(FIELDS, { skipIdentity: true })
|
||||
|
||||
expect(result.apiDomain).toBe('https://desk.zoho.com')
|
||||
})
|
||||
|
||||
describe.each(DATA_CENTERS)('data center $id', ({ id, tokenUrl, deskBase }) => {
|
||||
it('posts to the region accounts server and returns the region Desk base', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(200, { access_token: 'zoho-access' }))
|
||||
|
||||
const result = await mintZohoDeskServiceAccountToken(
|
||||
{ ...FIELDS, dataCenter: id },
|
||||
{ skipIdentity: true }
|
||||
)
|
||||
|
||||
expect(mintBody(tokenUrl).get('soid')).toBe('ZohoDesk.600123456')
|
||||
expect(result.apiDomain).toBe(deskBase)
|
||||
expect(mockLoggerWarn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts the region code with surrounding whitespace and mixed case', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(200, { access_token: 'zoho-access' }))
|
||||
|
||||
const result = await mintZohoDeskServiceAccountToken(
|
||||
{ ...FIELDS, dataCenter: ` ${id.toUpperCase()} ` },
|
||||
{ skipIdentity: true }
|
||||
)
|
||||
|
||||
mintBody(tokenUrl)
|
||||
expect(result.apiDomain).toBe(deskBase)
|
||||
})
|
||||
})
|
||||
|
||||
it.each([undefined, '', ' '])(
|
||||
'defaults to the US data center when dataCenter is %j',
|
||||
async (dataCenter) => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(200, { access_token: 'zoho-access' }))
|
||||
|
||||
const result = await mintZohoDeskServiceAccountToken(
|
||||
{ ...FIELDS, dataCenter },
|
||||
{ skipIdentity: true }
|
||||
)
|
||||
|
||||
mintBody(TOKEN_URL)
|
||||
expect(result.apiDomain).toBe('https://desk.zoho.com')
|
||||
}
|
||||
)
|
||||
|
||||
// A typed-but-unrecognized region must NOT quietly resolve to US and then fail
|
||||
// against Zoho as an opaque invalid_client — the operator would have no way to
|
||||
// tell a wrong region from wrong credentials. Blank still means US (see above).
|
||||
it('rejects an unrecognized data center instead of silently using US', async () => {
|
||||
await expect(
|
||||
mintZohoDeskServiceAccountToken({ ...FIELDS, dataCenter: 'jp' })
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_credentials',
|
||||
status: 400,
|
||||
logDetail: expect.objectContaining({ step: 'data_center_validation', dataCenter: 'jp' }),
|
||||
})
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records the resolved data center in the stored metadata', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(200, { access_token: 'zoho-access' }))
|
||||
|
||||
const result = await mintZohoDeskServiceAccountToken({ ...FIELDS, dataCenter: 'eu' })
|
||||
|
||||
expect(result.identity?.storedMetadata).toMatchObject({
|
||||
dataCenter: 'eu',
|
||||
apiDomain: 'https://desk.zoho.eu',
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers api_domain over the selected region and warns when the two disagree', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse(200, { access_token: 'zoho-access', api_domain: 'https://www.zohoapis.in' })
|
||||
)
|
||||
|
||||
const result = await mintZohoDeskServiceAccountToken(
|
||||
{ ...FIELDS, dataCenter: 'eu' },
|
||||
{ skipIdentity: true }
|
||||
)
|
||||
|
||||
mintBody('https://accounts.zoho.eu/oauth/v2/token')
|
||||
expect(result.apiDomain).toBe('https://desk.zoho.in')
|
||||
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
||||
'Zoho api_domain disagrees with the selected data center',
|
||||
expect.objectContaining({
|
||||
selectedDataCenter: 'eu',
|
||||
selectedDeskBase: 'https://desk.zoho.eu',
|
||||
reportedDeskBase: 'https://desk.zoho.in',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the selected region when api_domain is not an allowlisted Zoho host', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse(200, { access_token: 'zoho-access', api_domain: 'https://zoho.attacker.com' })
|
||||
)
|
||||
|
||||
const result = await mintZohoDeskServiceAccountToken(
|
||||
{ ...FIELDS, dataCenter: 'eu' },
|
||||
{ skipIdentity: true }
|
||||
)
|
||||
|
||||
expect(result.apiDomain).toBe('https://desk.zoho.eu')
|
||||
expect(mockLoggerWarn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('names the data center in the failure detail so a wrong region is diagnosable', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(200, { error: 'invalid_client' }))
|
||||
|
||||
await expect(
|
||||
mintZohoDeskServiceAccountToken({ ...FIELDS, dataCenter: 'au' })
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_credentials',
|
||||
logDetail: expect.objectContaining({ dataCenter: 'au' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a non-numeric organization ID before any network call', async () => {
|
||||
await expect(
|
||||
mintZohoDeskServiceAccountToken({ ...FIELDS, orgId: 'my-org' })
|
||||
).rejects.toMatchObject({
|
||||
name: 'TokenServiceAccountValidationError',
|
||||
code: 'invalid_credentials',
|
||||
status: 400,
|
||||
logDetail: expect.objectContaining({ step: 'soid_validation' }),
|
||||
})
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws invalid_credentials on an HTTP 200 body carrying an error field', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(200, { error: 'invalid_client' }))
|
||||
|
||||
await expect(mintZohoDeskServiceAccountToken(FIELDS)).rejects.toMatchObject({
|
||||
name: 'TokenServiceAccountValidationError',
|
||||
code: 'invalid_credentials',
|
||||
status: 200,
|
||||
logDetail: expect.objectContaining({
|
||||
zohoError: 'invalid_client',
|
||||
soid: 'ZohoDesk.600123456',
|
||||
// Matched loosely: the hint's exact wording is operator-facing copy, but
|
||||
// it must keep naming the data center, since a wrong region is a leading
|
||||
// cause of invalid_client on reconnect.
|
||||
hint: expect.stringContaining('data center'),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('names the soid in the failure detail when Zoho cannot resolve the org', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(200, { error: 'missing_org_info' }))
|
||||
|
||||
await expect(mintZohoDeskServiceAccountToken(FIELDS)).rejects.toMatchObject({
|
||||
code: 'invalid_credentials',
|
||||
logDetail: expect.objectContaining({
|
||||
soid: 'ZohoDesk.600123456',
|
||||
hint: expect.stringContaining('Organization ID'),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('throws invalid_credentials on a 400 with an error body', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(400, { error: 'invalid_scope' }))
|
||||
|
||||
await expect(mintZohoDeskServiceAccountToken(FIELDS)).rejects.toMatchObject({
|
||||
code: 'invalid_credentials',
|
||||
status: 400,
|
||||
logDetail: expect.objectContaining({
|
||||
hint: 'the Self Client was not granted the Zoho Desk scopes Sim requests',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('throws invalid_credentials on a 401', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(401, { error: 'unauthorized' }))
|
||||
|
||||
await expect(mintZohoDeskServiceAccountToken(FIELDS)).rejects.toMatchObject({
|
||||
code: 'invalid_credentials',
|
||||
status: 401,
|
||||
})
|
||||
})
|
||||
|
||||
it('throws provider_unavailable (not invalid_credentials) on a 429 rate limit', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(429, { error: 'too_many_requests' }))
|
||||
|
||||
await expect(mintZohoDeskServiceAccountToken(FIELDS)).rejects.toMatchObject({
|
||||
code: 'provider_unavailable',
|
||||
status: 429,
|
||||
})
|
||||
})
|
||||
|
||||
it('throws provider_unavailable on a 503', async () => {
|
||||
mockFetch.mockResolvedValueOnce(htmlResponse(503, '<html>unavailable</html>'))
|
||||
|
||||
await expect(mintZohoDeskServiceAccountToken(FIELDS)).rejects.toMatchObject({
|
||||
code: 'provider_unavailable',
|
||||
status: 503,
|
||||
})
|
||||
})
|
||||
|
||||
it('throws provider_unavailable on a 200 with a non-JSON body', async () => {
|
||||
mockFetch.mockResolvedValueOnce(htmlResponse(200, '<html>proxy page</html>'))
|
||||
|
||||
await expect(mintZohoDeskServiceAccountToken(FIELDS)).rejects.toMatchObject({
|
||||
code: 'provider_unavailable',
|
||||
status: 502,
|
||||
})
|
||||
})
|
||||
|
||||
it('throws provider_unavailable when the success body is missing access_token', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(200, { token_type: 'Bearer' }))
|
||||
|
||||
await expect(mintZohoDeskServiceAccountToken(FIELDS)).rejects.toMatchObject({
|
||||
code: 'provider_unavailable',
|
||||
status: 502,
|
||||
})
|
||||
})
|
||||
|
||||
it('throws provider_unavailable on a network error', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new TypeError('fetch failed'))
|
||||
|
||||
await expect(mintZohoDeskServiceAccountToken(FIELDS)).rejects.toMatchObject({
|
||||
code: 'provider_unavailable',
|
||||
status: 502,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,274 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
normalizeZohoDeskDataCenter,
|
||||
normalizeZohoDeskSoid,
|
||||
resolveZohoDeskDataCenter,
|
||||
ZOHO_DESK_DATA_CENTER_IDS,
|
||||
ZOHO_DESK_DATA_CENTER_REGEX,
|
||||
ZOHO_DESK_SOID_REGEX,
|
||||
} from '@/lib/credentials/client-credential-accounts/descriptors'
|
||||
import type {
|
||||
ClientCredentialAccountFields,
|
||||
ClientCredentialAccountMintOptions,
|
||||
ClientCredentialAccountMintResult,
|
||||
} from '@/lib/credentials/client-credential-accounts/server'
|
||||
import {
|
||||
fetchProvider,
|
||||
isTransientProviderStatus,
|
||||
parseProviderJson,
|
||||
readProviderErrorSnippet,
|
||||
TokenServiceAccountValidationError,
|
||||
} from '@/lib/credentials/token-service-accounts/errors'
|
||||
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
|
||||
import { tryDeriveZohoDeskBaseFromApiDomain } from '@/tools/zoho_desk/host-allowlist'
|
||||
|
||||
const logger = createLogger('ZohoDeskServiceAccountMinter')
|
||||
|
||||
const STEP = 'zoho_desk_token_mint'
|
||||
|
||||
/** Fallback token lifetime; Zoho documents one hour for this grant. */
|
||||
const ZOHO_DEFAULT_TOKEN_TTL_SECONDS = 3600
|
||||
|
||||
interface ZohoTokenResponse {
|
||||
access_token?: string
|
||||
api_domain?: string
|
||||
token_type?: string
|
||||
expires_in?: number
|
||||
scope?: string
|
||||
error?: string
|
||||
error_description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a Zoho token-endpoint `error` code to an operator-facing hint for server
|
||||
* logs. Every value here means "fix the pasted credentials or the Self Client
|
||||
* config", so all of them classify as `invalid_credentials`.
|
||||
*/
|
||||
function zohoErrorHint(error: string): string | undefined {
|
||||
const normalized = error.toLowerCase()
|
||||
if (normalized === 'invalid_client') {
|
||||
// Also the symptom of a wrong data center: a Self Client only exists on its
|
||||
// own region's accounts server, so an EU client presented to accounts.zoho.com
|
||||
// reads as an unknown client. Reconnect re-mints from the submitted fields, so
|
||||
// an admin who re-enters the secrets but leaves the region blank lands here.
|
||||
return 'invalid client_id or client_secret, the client is not a Self Client, or the wrong data center was selected for it'
|
||||
}
|
||||
if (normalized === 'invalid_scope' || normalized === 'invalid_scopes') {
|
||||
return 'the Self Client was not granted the Zoho Desk scopes Sim requests'
|
||||
}
|
||||
if (normalized === 'missing_org_info') {
|
||||
return 'Zoho could not resolve the organization from soid — check the Organization ID (Setup > Developer Space > API)'
|
||||
}
|
||||
if (normalized === 'invalid_soid' || normalized === 'invalid_org') {
|
||||
return 'the soid did not match a Zoho Desk organization — check the Organization ID, and try pasting the full ZohoDesk.<orgId> value'
|
||||
}
|
||||
if (normalized === 'invalid_grant' || normalized === 'unsupported_grant_type') {
|
||||
return 'the client does not support the client_credentials grant — create a Self Client in the Zoho API Console'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the `error` field from a Zoho token-endpoint body. Returns
|
||||
* `undefined` for a body that is not JSON or carries no `error`.
|
||||
*/
|
||||
function zohoBodyError(body: string): string | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(body) as { error?: unknown }
|
||||
return typeof parsed.error === 'string' && parsed.error ? parsed.error : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `invalid_credentials` failure for a rejected mint, naming the
|
||||
* `soid` and the data center that were used so a wrong organization ID or a
|
||||
* wrong region is diagnosable from the server log without echoing the client
|
||||
* secret.
|
||||
*/
|
||||
function invalidCredentials(
|
||||
status: number,
|
||||
soid: string,
|
||||
dataCenterId: string,
|
||||
body: string,
|
||||
error?: string
|
||||
): TokenServiceAccountValidationError {
|
||||
const hint = error ? zohoErrorHint(error) : undefined
|
||||
return new TokenServiceAccountValidationError('invalid_credentials', status, {
|
||||
step: STEP,
|
||||
soid,
|
||||
dataCenter: dataCenterId,
|
||||
body,
|
||||
...(error ? { zohoError: error } : {}),
|
||||
...(hint ? { hint } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints a Zoho Desk access token via the Self Client `client_credentials`
|
||||
* grant: POST `<accountsBase>/oauth/v2/token` with `client_id`,
|
||||
* `client_secret`, `scope`, and `soid` in the form body. Tokens live one hour
|
||||
* and there is no refresh token — re-mint instead of refreshing (same shape as
|
||||
* Zoom Server-to-Server).
|
||||
*
|
||||
* Zoho's accounts server is per data center. Unlike the interactive OAuth flow
|
||||
* (whose authorize/token URLs are static per provider and therefore US-only),
|
||||
* this path owns its token URL, so `fields.dataCenter` selects the region's
|
||||
* accounts server and the matching Desk REST host deterministically. A blank or
|
||||
* unrecognized value resolves to US, so credentials created before the field
|
||||
* existed behave exactly as before.
|
||||
*
|
||||
* Two Zoho-specific behaviors drive the error handling:
|
||||
*
|
||||
* - Zoho reports OAuth failures in the JSON body, frequently with HTTP 200
|
||||
* (e.g. `{"error":"invalid_client"}`), so a status-only check would accept a
|
||||
* failed mint. The success body is inspected for an `error` field before the
|
||||
* token is read — mirroring the OAuth token exchange in `lib/auth/auth.ts`.
|
||||
* - `scope` must be COMMA-separated for Zoho, not space-separated. The list is
|
||||
* sourced from the `zoho-desk` OAuth service so the Self Client and the OAuth
|
||||
* flow can never request different scopes.
|
||||
*
|
||||
* 4xx maps to `invalid_credentials` except transient 429/408 throttling, which
|
||||
* maps to `provider_unavailable` alongside 5xx and network failures — provider
|
||||
* throttling is never blamed on the credentials.
|
||||
*/
|
||||
export async function mintZohoDeskServiceAccountToken(
|
||||
fields: ClientCredentialAccountFields,
|
||||
options?: ClientCredentialAccountMintOptions
|
||||
): Promise<ClientCredentialAccountMintResult> {
|
||||
const soid = normalizeZohoDeskSoid(fields.orgId)
|
||||
if (!ZOHO_DESK_SOID_REGEX.test(soid)) {
|
||||
throw new TokenServiceAccountValidationError('invalid_credentials', 400, {
|
||||
step: 'soid_validation',
|
||||
soid,
|
||||
reason:
|
||||
'organization ID is not a numeric Zoho Desk org id (or a full ZohoDesk.<orgId> value)',
|
||||
})
|
||||
}
|
||||
|
||||
// A blank data center legitimately means "US" (the pre-field default), but a
|
||||
// value that was typed and not recognized must not silently resolve to US and
|
||||
// then fail against Zoho as an opaque `invalid_client`. Reject it here, next to
|
||||
// the soid check, so the connect modal names the real problem. This runs on
|
||||
// every execution-time re-mint too, where there is no UI to show a format hint.
|
||||
const rawDataCenter = normalizeZohoDeskDataCenter(fields.dataCenter ?? '')
|
||||
if (rawDataCenter && !ZOHO_DESK_DATA_CENTER_REGEX.test(rawDataCenter)) {
|
||||
throw new TokenServiceAccountValidationError('invalid_credentials', 400, {
|
||||
step: 'data_center_validation',
|
||||
dataCenter: rawDataCenter,
|
||||
reason: `unrecognized data center; expected one of ${ZOHO_DESK_DATA_CENTER_IDS.join(', ')} (or blank for us)`,
|
||||
})
|
||||
}
|
||||
|
||||
const dataCenter = resolveZohoDeskDataCenter(rawDataCenter)
|
||||
|
||||
// Zoho requires a comma-separated scope list on this endpoint; a
|
||||
// space-separated list is rejected as an invalid scope.
|
||||
// Only the Desk.* scopes. `aaaserver.profile.READ` exists for the interactive
|
||||
// OAuth flow's getUserInfo call; this grant never hits the Accounts profile
|
||||
// endpoint (identity is synthesized from orgId, and skipIdentity bypasses it
|
||||
// entirely at execution time). Sending an Accounts-server scope on the Desk
|
||||
// soid grant risks an opaque invalid_scope rejection for no benefit.
|
||||
const scope = getCanonicalScopesForProvider('zoho-desk')
|
||||
.filter((s) => s.startsWith('Desk.'))
|
||||
.join(',')
|
||||
|
||||
const res = await fetchProvider(
|
||||
`${dataCenter.accountsBase}/oauth/v2/token`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: fields.clientId,
|
||||
client_secret: fields.clientSecret,
|
||||
scope,
|
||||
soid,
|
||||
}).toString(),
|
||||
},
|
||||
STEP
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await readProviderErrorSnippet(res)
|
||||
if (res.status >= 400 && res.status < 500 && !isTransientProviderStatus(res.status)) {
|
||||
throw invalidCredentials(res.status, soid, dataCenter.id, body, zohoBodyError(body))
|
||||
}
|
||||
throw new TokenServiceAccountValidationError('provider_unavailable', res.status, {
|
||||
step: STEP,
|
||||
soid,
|
||||
dataCenter: dataCenter.id,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
const payload = await parseProviderJson<ZohoTokenResponse>(res, STEP)
|
||||
|
||||
// Zoho signals OAuth failures in the body, usually with HTTP 200 — classify
|
||||
// on the body before trusting the status.
|
||||
if (typeof payload.error === 'string' && payload.error) {
|
||||
throw invalidCredentials(
|
||||
res.status,
|
||||
soid,
|
||||
dataCenter.id,
|
||||
JSON.stringify(payload),
|
||||
payload.error
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof payload.access_token !== 'string' || !payload.access_token) {
|
||||
throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
|
||||
step: STEP,
|
||||
soid,
|
||||
dataCenter: dataCenter.id,
|
||||
reason: 'token response missing access_token',
|
||||
})
|
||||
}
|
||||
|
||||
// The Desk REST host for the token's data center, allowlist-anchored. Tools
|
||||
// receive this as `apiDomain` so calls never assume desk.zoho.com. The
|
||||
// selected region's Desk host is the deterministic answer; Zoho documents
|
||||
// `api_domain` for CRM but not for Desk, so it is only an override. When it
|
||||
// IS present and disagrees, it wins — it is authoritative about where the
|
||||
// token actually works — but the mismatch is logged so a wrong region
|
||||
// selection is diagnosable. An `api_domain` that is absent or fails the Zoho
|
||||
// apex allowlist yields `undefined` here and never overrides the region.
|
||||
const reportedDeskBase = tryDeriveZohoDeskBaseFromApiDomain(payload.api_domain)
|
||||
const apiDomain = reportedDeskBase ?? dataCenter.deskBase
|
||||
if (reportedDeskBase && reportedDeskBase !== dataCenter.deskBase) {
|
||||
logger.warn('Zoho api_domain disagrees with the selected data center', {
|
||||
soid,
|
||||
selectedDataCenter: dataCenter.id,
|
||||
selectedDeskBase: dataCenter.deskBase,
|
||||
reportedDeskBase,
|
||||
})
|
||||
}
|
||||
const expiresInSeconds =
|
||||
typeof payload.expires_in === 'number' && payload.expires_in > 0
|
||||
? payload.expires_in
|
||||
: ZOHO_DEFAULT_TOKEN_TTL_SECONDS
|
||||
const grantedScopes =
|
||||
typeof payload.scope === 'string' ? payload.scope.split(/[\s,]+/).filter(Boolean) : undefined
|
||||
|
||||
if (options?.skipIdentity) {
|
||||
return { accessToken: payload.access_token, expiresInSeconds, apiDomain, grantedScopes }
|
||||
}
|
||||
|
||||
const storedMetadata: Record<string, string> = { soid, apiDomain, dataCenter: dataCenter.id }
|
||||
if (grantedScopes?.length) {
|
||||
storedMetadata.grantedScopes = grantedScopes.join(' ')
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: payload.access_token,
|
||||
expiresInSeconds,
|
||||
apiDomain,
|
||||
grantedScopes,
|
||||
identity: {
|
||||
displayName: `Zoho Desk org ${fields.orgId.trim()}`,
|
||||
auditMetadata: { zohoDeskSoid: soid, zohoDeskClientId: fields.clientId },
|
||||
storedMetadata,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -4,18 +4,29 @@ import {
|
||||
type ClientCredentialAccountProviderId,
|
||||
isClientCredentialAccountProviderId,
|
||||
SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
ZOOM_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
} from '@/lib/credentials/client-credential-accounts/descriptors'
|
||||
import { mintBoxServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/box'
|
||||
import { mintSalesforceServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/salesforce'
|
||||
import { mintZohoDeskServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoho-desk'
|
||||
import { mintZoomServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoom'
|
||||
|
||||
/** Raw fields a client-credential minter receives (already trimmed). */
|
||||
export interface ClientCredentialAccountFields {
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
/** Provider-specific org identifier (Zoom Account ID, Box Enterprise ID, Salesforce My Domain host). */
|
||||
/**
|
||||
* Provider-specific org identifier (Zoom Account ID, Box Enterprise ID,
|
||||
* Salesforce My Domain host, Zoho Desk organization ID).
|
||||
*/
|
||||
orgId: string
|
||||
/**
|
||||
* Optional provider region selector. Only Zoho Desk uses it (the Self Client
|
||||
* mints against a per-data-center accounts server); every other provider
|
||||
* ignores it, and a blank value keeps the provider's default region.
|
||||
*/
|
||||
dataCenter?: string
|
||||
}
|
||||
|
||||
/** Identity derived from a successful mint, used at connect time. */
|
||||
@@ -40,6 +51,12 @@ export interface ClientCredentialAccountMintResult {
|
||||
* `instance_url`), forwarded to tools alongside the token.
|
||||
*/
|
||||
instanceUrl?: string
|
||||
/**
|
||||
* Data-center-scoped REST API base the minted token must be used against
|
||||
* (Zoho Desk), forwarded to tools as their `apiDomain` param. Distinct from
|
||||
* {@link instanceUrl} because the two reach different tool params.
|
||||
*/
|
||||
apiDomain?: string
|
||||
/** Scopes granted to the app, when the provider reports them. */
|
||||
grantedScopes?: string[]
|
||||
identity?: ClientCredentialAccountIdentity
|
||||
@@ -74,6 +91,7 @@ const CLIENT_CREDENTIAL_ACCOUNT_MINTERS: Record<
|
||||
[ZOOM_SERVICE_ACCOUNT_PROVIDER_ID]: mintZoomServiceAccountToken,
|
||||
[BOX_SERVICE_ACCOUNT_PROVIDER_ID]: mintBoxServiceAccountToken,
|
||||
[SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID]: mintSalesforceServiceAccountToken,
|
||||
[ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID]: mintZohoDeskServiceAccountToken,
|
||||
}
|
||||
|
||||
export function getClientCredentialAccountMinter(
|
||||
@@ -95,6 +113,8 @@ export interface ClientCredentialAccountSecretBlob {
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
orgId: string
|
||||
/** Optional region selector; absent on every credential created before it existed. */
|
||||
dataCenter?: string
|
||||
metadata?: Record<string, string>
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { getCredentialActorContext } from '@/lib/credentials/access'
|
||||
import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account'
|
||||
import { isClientCredentialAccountProviderId } from '@/lib/credentials/client-credential-accounts/descriptors'
|
||||
import { type CredentialDeleteReason, deleteCredential } from '@/lib/credentials/deletion'
|
||||
import {
|
||||
deleteWorkspaceEnvCredentials,
|
||||
@@ -22,6 +23,30 @@ import { captureServerEvent } from '@/lib/posthog/server'
|
||||
|
||||
const logger = createLogger('CredentialOrchestration')
|
||||
|
||||
/**
|
||||
* Read the `dataCenter` already stored in a service-account credential's
|
||||
* encrypted blob. Used on reconnect so a non-secret regional selector survives a
|
||||
* secret rotation that does not resubmit it. Returns undefined on any failure -
|
||||
* a blob that cannot be read must not block the reconnect, and the provider's
|
||||
* own default then applies.
|
||||
*/
|
||||
async function readStoredDataCenter(credentialId: string): Promise<string | undefined> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({ key: credential.encryptedServiceAccountKey })
|
||||
.from(credential)
|
||||
.where(eq(credential.id, credentialId))
|
||||
.limit(1)
|
||||
const key = rows[0]?.key
|
||||
if (!key) return undefined
|
||||
const { decrypted } = await decryptSecret(key)
|
||||
const blob = JSON.parse(decrypted) as { dataCenter?: unknown }
|
||||
return typeof blob.dataCenter === 'string' && blob.dataCenter ? blob.dataCenter : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export type CredentialOrchestrationErrorCode =
|
||||
| 'not_found'
|
||||
| 'forbidden'
|
||||
@@ -53,6 +78,7 @@ export interface PerformUpdateCredentialParams extends CredentialActorParams {
|
||||
clientId?: string
|
||||
clientSecret?: string
|
||||
orgId?: string
|
||||
dataCenter?: string
|
||||
}
|
||||
|
||||
export interface PerformCredentialResult {
|
||||
@@ -133,9 +159,25 @@ export async function performUpdateCredential(
|
||||
params.domain !== undefined ||
|
||||
params.clientId !== undefined ||
|
||||
params.clientSecret !== undefined ||
|
||||
params.orgId !== undefined
|
||||
params.orgId !== undefined ||
|
||||
params.dataCenter !== undefined
|
||||
let rotatedSlackBotUserId: string | undefined
|
||||
if (hasRotationSecret && access.credential.type === 'service_account') {
|
||||
// A reconnect rebuilds the secret blob from the submitted fields only, and
|
||||
// the modal never prefills (secrets are never echoed back). For an actual
|
||||
// secret that is correct - the admin retypes it. But a non-secret selector
|
||||
// like the Zoho data center would be silently dropped, moving an EU/IN/AU
|
||||
// credential back to the US accounts server. Carry the stored value forward
|
||||
// when the caller did not supply one.
|
||||
// Scoped to the providers that actually have a dataCenter field, so no
|
||||
// other service-account reconnect (Slack, Atlassian, every token-paste
|
||||
// provider) pays for a DB read plus a decrypt it can never use.
|
||||
const carriedDataCenter =
|
||||
params.dataCenter === undefined &&
|
||||
isClientCredentialAccountProviderId(access.credential.providerId ?? '')
|
||||
? await readStoredDataCenter(access.credential.id)
|
||||
: params.dataCenter
|
||||
|
||||
try {
|
||||
const secret = await verifyAndBuildServiceAccountSecret(
|
||||
access.credential.providerId ?? '',
|
||||
@@ -147,6 +189,7 @@ export async function performUpdateCredential(
|
||||
clientId: params.clientId,
|
||||
clientSecret: params.clientSecret,
|
||||
orgId: params.orgId,
|
||||
dataCenter: carriedDataCenter,
|
||||
}
|
||||
)
|
||||
updates.encryptedServiceAccountKey = secret.encryptedServiceAccountKey
|
||||
|
||||
@@ -16,6 +16,7 @@ export type ServiceAccountFieldId =
|
||||
| 'clientId'
|
||||
| 'clientSecret'
|
||||
| 'orgId'
|
||||
| 'dataCenter'
|
||||
|
||||
/**
|
||||
* Required create-body fields per service-account provider — the client-safe
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface ServiceAccountSecretFields {
|
||||
clientId?: string
|
||||
clientSecret?: string
|
||||
orgId?: string
|
||||
dataCenter?: string
|
||||
}
|
||||
|
||||
export interface ServiceAccountSecretResult {
|
||||
@@ -234,19 +235,24 @@ async function buildClientCredentialAccountSecret(
|
||||
const clientId = fields.clientId?.trim()
|
||||
const clientSecret = fields.clientSecret?.trim()
|
||||
const orgId = fields.orgId?.trim()
|
||||
const dataCenter = fields.dataCenter?.trim()
|
||||
if (!clientId || !clientSecret || !orgId) {
|
||||
const required = descriptor.fields.map((field) => field.id).join(', ')
|
||||
const required = descriptor.fields
|
||||
.filter((field) => !field.optional)
|
||||
.map((field) => field.id)
|
||||
.join(', ')
|
||||
throw new ServiceAccountSecretError(
|
||||
`${required} are required for ${descriptor.serviceLabel} service account credentials`
|
||||
)
|
||||
}
|
||||
const mint = await minter({ clientId, clientSecret, orgId })
|
||||
const mint = await minter({ clientId, clientSecret, orgId, dataCenter })
|
||||
const blob: ClientCredentialAccountSecretBlob = {
|
||||
type: CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE,
|
||||
providerId,
|
||||
clientId,
|
||||
clientSecret,
|
||||
orgId,
|
||||
...(dataCenter ? { dataCenter } : {}),
|
||||
...(mint.identity?.storedMetadata ? { metadata: mint.identity.storedMetadata } : {}),
|
||||
}
|
||||
const { encrypted } = await encryptSecret(JSON.stringify(blob))
|
||||
|
||||
@@ -70,6 +70,7 @@ const EXPECTED_COVERAGE: Record<string, string[]> = {
|
||||
'trello-service-account': ['trello'],
|
||||
'wealthbox-service-account': ['wealthbox'],
|
||||
'webflow-service-account': ['webflow'],
|
||||
'zoho-desk-service-account': ['zoho-desk'],
|
||||
'zoom-service-account': ['zoom'],
|
||||
}
|
||||
|
||||
|
||||
@@ -246,6 +246,7 @@ import {
|
||||
ZendeskIcon,
|
||||
ZepIcon,
|
||||
ZeroBounceIcon,
|
||||
ZohoDeskIcon,
|
||||
ZoomIcon,
|
||||
ZoomInfoIcon,
|
||||
} from '@/components/icons'
|
||||
@@ -511,6 +512,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
zendesk: ZendeskIcon,
|
||||
zep: ZepIcon,
|
||||
zerobounce: ZeroBounceIcon,
|
||||
zoho_desk: ZohoDeskIcon,
|
||||
zoom: ZoomIcon,
|
||||
zoominfo: ZoomInfoIcon,
|
||||
}
|
||||
|
||||
@@ -21812,6 +21812,72 @@
|
||||
"integrationType": "sales",
|
||||
"tags": ["enrichment", "sales-engagement"]
|
||||
},
|
||||
{
|
||||
"type": "zoho_desk",
|
||||
"slug": "zoho-desk",
|
||||
"name": "Zoho Desk",
|
||||
"description": "Manage Zoho Desk tickets, comments, threads, and contacts",
|
||||
"longDescription": "Read and update Zoho Desk tickets, manage comments and threads, look up contacts, and download attachments. Can also trigger workflows from Zoho Desk webhook events.",
|
||||
"bgColor": "#E42527",
|
||||
"iconName": "ZohoDeskIcon",
|
||||
"docsUrl": "https://docs.sim.ai/integrations/zoho_desk",
|
||||
"operations": [
|
||||
{
|
||||
"name": "List Tickets",
|
||||
"description": "List tickets from a Zoho Desk organization with optional filters. Returns a list projection: description, resolution, statusType and classification are only available from Get Ticket."
|
||||
},
|
||||
{
|
||||
"name": "Get Ticket",
|
||||
"description": "Retrieve a single Zoho Desk ticket by ID."
|
||||
},
|
||||
{
|
||||
"name": "Update Ticket",
|
||||
"description": "Update fields on an existing Zoho Desk ticket."
|
||||
},
|
||||
{
|
||||
"name": "List Comments",
|
||||
"description": "List comments on a Zoho Desk ticket."
|
||||
},
|
||||
{
|
||||
"name": "Add Comment",
|
||||
"description": "Add a comment to a Zoho Desk ticket."
|
||||
},
|
||||
{
|
||||
"name": "List Threads",
|
||||
"description": "List conversation threads on a Zoho Desk ticket, newest first (Zoho sorts by sendDateTime descending by default). Returns a list projection: message bodies (content, summary, to/cc/bcc) come back only from Get Thread."
|
||||
},
|
||||
{
|
||||
"name": "Get Thread",
|
||||
"description": "Retrieve the full content of a single Zoho Desk ticket thread."
|
||||
},
|
||||
{
|
||||
"name": "Get Contact",
|
||||
"description": "Retrieve a Zoho Desk contact by ID."
|
||||
},
|
||||
{
|
||||
"name": "Get Attachment",
|
||||
"description": "Download a Zoho Desk ticket attachment (from its href) as a file."
|
||||
},
|
||||
{
|
||||
"name": "List Organizations",
|
||||
"description": "List the Zoho Desk organizations (portals) the connected account can access."
|
||||
}
|
||||
],
|
||||
"operationCount": 10,
|
||||
"triggers": [
|
||||
{
|
||||
"id": "zoho_desk",
|
||||
"name": "Zoho Desk Event",
|
||||
"description": "Trigger a workflow when a Zoho Desk event occurs (ticket, comment, thread, contact, agent, task, or article changes)."
|
||||
}
|
||||
],
|
||||
"triggerCount": 1,
|
||||
"authType": "oauth",
|
||||
"oauthServiceId": "zoho-desk",
|
||||
"category": "tools",
|
||||
"integrationType": "support",
|
||||
"tags": ["customer-support", "ticketing", "automation"]
|
||||
},
|
||||
{
|
||||
"type": "zoom",
|
||||
"slug": "zoom",
|
||||
|
||||
@@ -45,6 +45,8 @@ beforeAll(() => {
|
||||
LINKEDIN_CLIENT_SECRET: 'linkedin_client_secret',
|
||||
SALESFORCE_CLIENT_ID: 'salesforce_client_id',
|
||||
SALESFORCE_CLIENT_SECRET: 'salesforce_client_secret',
|
||||
ZOHO_CLIENT_ID: 'zoho_client_id',
|
||||
ZOHO_CLIENT_SECRET: 'zoho_client_secret',
|
||||
SHOPIFY_CLIENT_ID: 'shopify_client_id',
|
||||
SHOPIFY_CLIENT_SECRET: 'shopify_client_secret',
|
||||
ZOOM_CLIENT_ID: 'zoom_client_id',
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
WebflowIcon,
|
||||
WordpressIcon,
|
||||
xIcon,
|
||||
ZohoDeskIcon,
|
||||
ZoomIcon,
|
||||
} from '@/components/icons'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
@@ -1098,6 +1099,48 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
},
|
||||
defaultService: 'salesforce',
|
||||
},
|
||||
'zoho-desk': {
|
||||
name: 'Zoho Desk',
|
||||
icon: ZohoDeskIcon,
|
||||
services: {
|
||||
'zoho-desk': {
|
||||
name: 'Zoho Desk',
|
||||
description:
|
||||
'Manage Zoho Desk tickets, comments, threads, and contacts. Connecting with OAuth requires a Zoho account in the US data center; a Self Client also supports the EU, IN, and AU data centers.',
|
||||
providerId: 'zoho-desk',
|
||||
serviceAccountProviderId: 'zoho-desk-service-account',
|
||||
icon: ZohoDeskIcon,
|
||||
baseProviderIcon: ZohoDeskIcon,
|
||||
// Kept to exactly what the tools and the webhook trigger exercise:
|
||||
// tickets (incl. threads/comments), contacts (get_contact), basic
|
||||
// (list_organizations), agents (the `assigneeId` picker lists agents),
|
||||
// webhook create/delete (the trigger provisions and tears down its own
|
||||
// subscription), and profile (OAuth getUserInfo).
|
||||
// Desk.search.READ, Desk.webhooks.READ and Desk.webhooks.UPDATE were
|
||||
// requested but unused - no tool searches, and the provider never lists
|
||||
// or edits a subscription.
|
||||
scopes: [
|
||||
// READ + UPDATE rather than tickets.ALL: no tool creates or deletes a
|
||||
// ticket, and ALL additionally grants ticket DELETE. Threads, comments
|
||||
// and attachments live under the tickets module and are covered by
|
||||
// these two. NOTE: Zoho publishes no scope line for the attachment
|
||||
// content sub-path - verify attachment download against a live account
|
||||
// before merge and widen here if it returns SCOPE_MISMATCH.
|
||||
'Desk.tickets.READ',
|
||||
'Desk.tickets.UPDATE',
|
||||
'Desk.contacts.READ',
|
||||
// READ only: the agent picker for `assigneeId` lists agents, and no
|
||||
// tool creates, edits or deletes one.
|
||||
'Desk.agents.READ',
|
||||
'Desk.basic.READ',
|
||||
'Desk.webhooks.CREATE',
|
||||
'Desk.webhooks.DELETE',
|
||||
'aaaserver.profile.READ',
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultService: 'zoho-desk',
|
||||
},
|
||||
zoom: {
|
||||
name: 'Zoom',
|
||||
icon: ZoomIcon,
|
||||
@@ -1608,6 +1651,21 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
|
||||
supportsRefreshTokenRotation: false,
|
||||
}
|
||||
}
|
||||
case 'zoho-desk': {
|
||||
// Zoho's refresh_token grant returns a new access token but no new refresh
|
||||
// token, so rotation stays off (the existing refresh token is preserved).
|
||||
// The refresh must target the accounts server; a US/multi-DC-enabled client
|
||||
// uses accounts.zoho.com. Data residency for API calls is honored separately
|
||||
// via the persisted Desk base URL derived from the token response api_domain.
|
||||
const { clientId, clientSecret } = getCredentials(env.ZOHO_CLIENT_ID, env.ZOHO_CLIENT_SECRET)
|
||||
return {
|
||||
tokenEndpoint: 'https://accounts.zoho.com/oauth/v2/token',
|
||||
clientId,
|
||||
clientSecret,
|
||||
useBasicAuth: false,
|
||||
supportsRefreshTokenRotation: false,
|
||||
}
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported provider: ${provider}`)
|
||||
}
|
||||
@@ -1850,7 +1908,12 @@ export async function refreshOAuthToken(
|
||||
const expiresIn = data.expires_in || data.expiresIn || 3600
|
||||
|
||||
if (!accessToken) {
|
||||
logger.warn('No access token found in refresh response', { providerId, response: data })
|
||||
// Log only the shape, never `data` itself - on a partial success it can
|
||||
// carry live tokens.
|
||||
logger.warn('No access token found in refresh response', {
|
||||
providerId,
|
||||
responseKeys: Object.keys(data ?? {}),
|
||||
})
|
||||
return { ok: false, message: 'No access token in refresh response' }
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ export type OAuthProvider =
|
||||
| 'spotify'
|
||||
| 'calcom'
|
||||
| 'docusign'
|
||||
| 'zoho-desk'
|
||||
|
||||
export type OAuthService =
|
||||
| 'google'
|
||||
@@ -141,6 +142,7 @@ export type OAuthService =
|
||||
| 'docusign'
|
||||
| 'github'
|
||||
| 'monday'
|
||||
| 'zoho-desk'
|
||||
|
||||
export interface OAuthProviderConfig {
|
||||
name: string
|
||||
|
||||
@@ -11,6 +11,15 @@ import type {
|
||||
* Used by the OAuth Required Modal and available for any UI that needs to display scope info.
|
||||
*/
|
||||
export const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
||||
// Zoho Desk scopes
|
||||
'Desk.tickets.READ': 'View tickets, threads, comments, and attachments',
|
||||
'Desk.tickets.UPDATE': 'Update tickets and add comments',
|
||||
'Desk.contacts.READ': 'View contacts',
|
||||
'Desk.agents.READ': 'View agents',
|
||||
'Desk.basic.READ': 'View basic account and organization data',
|
||||
'Desk.webhooks.CREATE': 'Create webhooks',
|
||||
'Desk.webhooks.DELETE': 'Delete webhooks',
|
||||
'aaaserver.profile.READ': 'View your Zoho profile',
|
||||
// Google scopes
|
||||
'https://www.googleapis.com/auth/gmail.send': 'Send emails',
|
||||
'https://www.googleapis.com/auth/gmail.labels': 'View and manage email labels',
|
||||
|
||||
@@ -683,7 +683,10 @@ export async function prepareStableTriggerWebhooksForDeploy({
|
||||
success: false,
|
||||
error: {
|
||||
message: getErrorMessage(error, 'Failed to prepare webhook registrations'),
|
||||
status: 500,
|
||||
// Propagate a provider-attached status (e.g. Zoho's 4xx edition/validation
|
||||
// failures) so the deploy outbox fails terminally instead of retrying,
|
||||
// matching the legacy save path's status-aware mapping below.
|
||||
status: (error as { status?: number })?.status ?? 500,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -940,7 +943,11 @@ export async function saveTriggerWebhooksForDeploy({
|
||||
(cleanupFailure as Error)?.message ||
|
||||
(error as Error)?.message ||
|
||||
'Failed to create external subscription',
|
||||
status: 500,
|
||||
// Propagate a 4xx from the provider handler (e.g. a permanent Zoho
|
||||
// config/permission/invalid-data failure) so the outbox classifies it
|
||||
// as non-retryable; anything else (network, provider 5xx) stays 500 and
|
||||
// retryable. cleanupFailure never overrides the root cause's status.
|
||||
status: (error as { status?: number })?.status ?? 500,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,10 @@ const pendingWebhookVerificationRegistrationMatchers: Record<
|
||||
grain: () => true,
|
||||
generic: (registration) => registration.metadata?.verifyTestEvents === true,
|
||||
salesforce: () => true,
|
||||
// Zoho Desk validates the notification URL with a create-time probe that must
|
||||
// return 200 before it will register the subscription (chicken-and-egg: the
|
||||
// webhook row is inactive until the create succeeds).
|
||||
zoho_desk: () => true,
|
||||
}
|
||||
|
||||
const pendingWebhookVerificationProbeMatchers: Record<
|
||||
@@ -68,6 +72,14 @@ const pendingWebhookVerificationProbeMatchers: Record<
|
||||
method === 'GET' ||
|
||||
method === 'HEAD' ||
|
||||
(method === 'POST' && (!body || Object.keys(body).length === 0)),
|
||||
// Zoho Desk sends a GET reachability probe at subscription-create time.
|
||||
// Zoho sends a GET reachability probe at subscription-create time and, if that
|
||||
// does not return 200, falls back to a POST probe before failing the create.
|
||||
// Match the empty-bodied POST too, as grain/generic/salesforce do.
|
||||
zoho_desk: ({ method, body }) =>
|
||||
method === 'GET' ||
|
||||
method === 'HEAD' ||
|
||||
(method === 'POST' && (!body || Object.keys(body).length === 0)),
|
||||
}
|
||||
|
||||
function getRedisKey(path: string): string {
|
||||
|
||||
@@ -37,6 +37,10 @@ const SYSTEM_MANAGED_FIELDS = new Set([
|
||||
'setupCompleted',
|
||||
'subscriptionExpiration',
|
||||
'userId',
|
||||
// Zoho Desk provider-managed: the persisted data-center Desk base, set by
|
||||
// createSubscription (not a user trigger field), so it must not count as a
|
||||
// config change that forces delete/recreate.
|
||||
'apiDomain',
|
||||
])
|
||||
|
||||
/**
|
||||
|
||||
@@ -58,6 +58,7 @@ import { vercelHandler } from '@/lib/webhooks/providers/vercel'
|
||||
import { webflowHandler } from '@/lib/webhooks/providers/webflow'
|
||||
import { whatsappHandler } from '@/lib/webhooks/providers/whatsapp'
|
||||
import { zendeskHandler } from '@/lib/webhooks/providers/zendesk'
|
||||
import { zohoDeskHandler } from '@/lib/webhooks/providers/zoho-desk'
|
||||
import { zoomHandler } from '@/lib/webhooks/providers/zoom'
|
||||
|
||||
const logger = createLogger('WebhookProviderRegistry')
|
||||
@@ -122,6 +123,7 @@ const PROVIDER_HANDLERS: Record<string, WebhookProviderHandler> = {
|
||||
webflow: webflowHandler,
|
||||
whatsapp: whatsappHandler,
|
||||
zendesk: zendeskHandler,
|
||||
zoho_desk: zohoDeskHandler,
|
||||
zoom: zoomHandler,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/app/api/auth/oauth/utils', () => ({
|
||||
refreshAccessTokenIfNeeded: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/webhooks/provider-subscription-utils', () => ({
|
||||
getCredentialOwner: vi.fn(),
|
||||
getNotificationUrl: vi.fn(() => 'https://example.com/api/webhooks/trigger/path'),
|
||||
}))
|
||||
|
||||
import {
|
||||
matchesPendingWebhookVerificationProbe,
|
||||
requiresPendingWebhookVerification,
|
||||
} from '@/lib/webhooks/pending-verification'
|
||||
import { getCredentialOwner } from '@/lib/webhooks/provider-subscription-utils'
|
||||
import { mapZohoWebhookError, zohoDeskHandler } from '@/lib/webhooks/providers/zoho-desk'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
function errorStatus(err: unknown): number | undefined {
|
||||
return (err as { status?: number })?.status
|
||||
}
|
||||
|
||||
function makeAuthContext(headers: Record<string, string>, providerConfig: Record<string, unknown>) {
|
||||
return {
|
||||
webhook: {},
|
||||
workflow: {},
|
||||
request: { headers: new Headers(headers) } as unknown as Request,
|
||||
rawBody: '',
|
||||
requestId: 'test',
|
||||
providerConfig,
|
||||
}
|
||||
}
|
||||
|
||||
describe('zohoDeskHandler', () => {
|
||||
it('acknowledges ingress via the durable queue (5s deadline)', () => {
|
||||
expect(zohoDeskHandler.executionMode).toBe('queue')
|
||||
})
|
||||
|
||||
describe('verifyAuth', () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(getCredentialOwner).mockReset()
|
||||
})
|
||||
|
||||
it('rejects requests without the X-ZDesk-JWT header', async () => {
|
||||
const result = await zohoDeskHandler.verifyAuth?.(
|
||||
// biome-ignore lint/suspicious/noExplicitAny: minimal context for the header-only path
|
||||
makeAuthContext({}, { orgId: '1', webhookId: '2' }) as any
|
||||
)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.status).toBe(401)
|
||||
})
|
||||
|
||||
it('falls back to the credential Desk domain when the webhook row has no apiDomain', async () => {
|
||||
vi.mocked(getCredentialOwner).mockResolvedValue({
|
||||
accountId: 'acct-1',
|
||||
userId: 'u1',
|
||||
// biome-ignore lint/suspicious/noExplicitAny: partial owner shape is enough for this path
|
||||
} as any)
|
||||
await zohoDeskHandler.verifyAuth?.(
|
||||
makeAuthContext(
|
||||
{ 'x-zdesk-jwt': 'not-a-real-jwt' },
|
||||
{ orgId: '1', externalId: '2', credentialId: 'cred-1' }
|
||||
// biome-ignore lint/suspicious/noExplicitAny: minimal context for the fallback path
|
||||
) as any
|
||||
)
|
||||
expect(getCredentialOwner).toHaveBeenCalledWith('cred-1', 'test')
|
||||
})
|
||||
|
||||
it('uses the persisted apiDomain without a credential lookup (fast path)', async () => {
|
||||
await zohoDeskHandler.verifyAuth?.(
|
||||
makeAuthContext(
|
||||
{ 'x-zdesk-jwt': 'not-a-real-jwt' },
|
||||
{ orgId: '1', externalId: '2', credentialId: 'cred-1', apiDomain: 'https://desk.zoho.eu' }
|
||||
// biome-ignore lint/suspicious/noExplicitAny: minimal context for the fast path
|
||||
) as any
|
||||
)
|
||||
expect(getCredentialOwner).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSubscription', () => {
|
||||
async function captureCreateError(providerConfig: Record<string, unknown>): Promise<unknown> {
|
||||
try {
|
||||
await zohoDeskHandler.createSubscription?.({
|
||||
webhook: { providerConfig },
|
||||
workflow: {},
|
||||
userId: 'user-1',
|
||||
requestId: 'test',
|
||||
// biome-ignore lint/suspicious/noExplicitAny: request is unused on these guard paths
|
||||
request: {} as any,
|
||||
})
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
it('fails terminally (400) when the organization ID is missing', async () => {
|
||||
const error = await captureCreateError({ eventType: 'Ticket_Add' })
|
||||
expect((error as Error)?.message).toMatch(/Organization ID/i)
|
||||
expect(errorStatus(error)).toBe(400)
|
||||
})
|
||||
|
||||
it('fails terminally (400) when the event type is missing', async () => {
|
||||
const error = await captureCreateError({ orgId: '700123' })
|
||||
expect((error as Error)?.message).toMatch(/event type/i)
|
||||
expect(errorStatus(error)).toBe(400)
|
||||
})
|
||||
|
||||
it('accepts the manual organization field when the canonical orgId is absent', async () => {
|
||||
// Reaching the event-type guard proves the organization guard passed, i.e.
|
||||
// `manualOrgId` resolved. See resolveConfigOrgId: the deploy-time canonical
|
||||
// collapse normally writes `orgId`, but drops it when the pair is pinned to
|
||||
// basic mode while only the manual field carries a value.
|
||||
const error = await captureCreateError({ manualOrgId: '700123' })
|
||||
expect((error as Error)?.message).toMatch(/event type/i)
|
||||
expect(errorStatus(error)).toBe(400)
|
||||
})
|
||||
|
||||
it('prefers the collapsed canonical orgId over the manual field', async () => {
|
||||
const error = await captureCreateError({ orgId: '', manualOrgId: ' ' })
|
||||
expect((error as Error)?.message).toMatch(/Organization ID/i)
|
||||
expect(errorStatus(error)).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatInput', () => {
|
||||
it('maps a Zoho Desk event array to the trigger outputs', async () => {
|
||||
const result = await zohoDeskHandler.formatInput?.({
|
||||
webhook: {},
|
||||
workflow: { id: 'wf', userId: 'user' },
|
||||
body: [
|
||||
{
|
||||
eventType: 'Ticket_Add',
|
||||
eventTime: '1700000000000',
|
||||
orgId: '700123',
|
||||
payload: { id: 'ticket-1' },
|
||||
prevState: null,
|
||||
},
|
||||
],
|
||||
headers: {},
|
||||
requestId: 'test',
|
||||
})
|
||||
expect(result?.input).toMatchObject({
|
||||
eventType: 'Ticket_Add',
|
||||
eventTime: '1700000000000',
|
||||
orgId: '700123',
|
||||
payload: { id: 'ticket-1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('passes through a non-array body unchanged', async () => {
|
||||
const body = { unexpected: true }
|
||||
const result = await zohoDeskHandler.formatInput?.({
|
||||
webhook: {},
|
||||
workflow: { id: 'wf', userId: 'user' },
|
||||
body,
|
||||
headers: {},
|
||||
requestId: 'test',
|
||||
})
|
||||
expect(result?.input).toBe(body)
|
||||
})
|
||||
|
||||
it('emits a normalized null shape for an empty array instead of leaking []', async () => {
|
||||
const result = await zohoDeskHandler.formatInput?.({
|
||||
webhook: {},
|
||||
workflow: { id: 'wf', userId: 'user' },
|
||||
body: [],
|
||||
headers: {},
|
||||
requestId: 'test',
|
||||
})
|
||||
expect(result?.input).toEqual({
|
||||
eventType: null,
|
||||
eventTime: null,
|
||||
orgId: null,
|
||||
payload: null,
|
||||
prevState: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('derives a plain-text contentText for html comment/thread payloads', async () => {
|
||||
const result = await zohoDeskHandler.formatInput?.({
|
||||
webhook: {},
|
||||
workflow: { id: 'wf', userId: 'user' },
|
||||
body: [
|
||||
{
|
||||
eventType: 'Ticket_Comment_Add',
|
||||
eventTime: '1700000000000',
|
||||
orgId: '700123',
|
||||
payload: {
|
||||
id: 'comment-1',
|
||||
content: '<div style="direction: ltr;"><div>testing</div></div>',
|
||||
contentType: 'html',
|
||||
},
|
||||
prevState: { id: 'comment-1', content: '<div>before</div>', contentType: 'html' },
|
||||
},
|
||||
],
|
||||
headers: {},
|
||||
requestId: 'test',
|
||||
})
|
||||
const input = result?.input as {
|
||||
payload: Record<string, unknown>
|
||||
prevState: Record<string, unknown>
|
||||
}
|
||||
expect(input.payload.content).toBe('<div style="direction: ltr;"><div>testing</div></div>')
|
||||
expect(input.payload.contentType).toBe('html')
|
||||
expect(input.payload.contentText).toBe('testing')
|
||||
// prevState is enriched symmetrically so before/after comparisons match shapes.
|
||||
expect(input.prevState.content).toBe('<div>before</div>')
|
||||
expect(input.prevState.contentText).toBe('before')
|
||||
})
|
||||
|
||||
it('mirrors plainText content into contentText', async () => {
|
||||
const result = await zohoDeskHandler.formatInput?.({
|
||||
webhook: {},
|
||||
workflow: { id: 'wf', userId: 'user' },
|
||||
body: [
|
||||
{
|
||||
eventType: 'Ticket_Comment_Add',
|
||||
eventTime: '1700000000000',
|
||||
orgId: '700123',
|
||||
payload: { id: 'comment-2', content: 'just text', contentType: 'plainText' },
|
||||
prevState: null,
|
||||
},
|
||||
],
|
||||
headers: {},
|
||||
requestId: 'test',
|
||||
})
|
||||
const payload = (result?.input as { payload: Record<string, unknown> }).payload
|
||||
expect(payload.contentText).toBe('just text')
|
||||
expect(payload.content).toBe('just text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSubscription request body', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.mocked(getCredentialOwner).mockReset()
|
||||
vi.mocked(refreshAccessTokenIfNeeded).mockReset()
|
||||
})
|
||||
|
||||
it('omits ignoreSourceId (Zoho rejects an arbitrary UUID) and returns no such id', async () => {
|
||||
vi.mocked(getCredentialOwner).mockResolvedValue({
|
||||
accountId: 'acc-1',
|
||||
userId: 'user-1',
|
||||
// biome-ignore lint/suspicious/noExplicitAny: partial owner shape for the test
|
||||
} as any)
|
||||
vi.mocked(refreshAccessTokenIfNeeded).mockResolvedValue('zoho-token')
|
||||
|
||||
let sentBody: Record<string, unknown> = {}
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
||||
sentBody = JSON.parse(String((init as RequestInit).body))
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ id: 'wh-123' }),
|
||||
} as unknown as Response
|
||||
})
|
||||
|
||||
const result = await zohoDeskHandler.createSubscription?.({
|
||||
webhook: {
|
||||
id: 'w1',
|
||||
path: 'p1',
|
||||
providerConfig: { credentialId: 'cred-1', orgId: '700123', eventType: 'Ticket_Add' },
|
||||
},
|
||||
workflow: {},
|
||||
userId: 'user-1',
|
||||
requestId: 'test',
|
||||
// biome-ignore lint/suspicious/noExplicitAny: request is unused on this path
|
||||
request: {} as any,
|
||||
})
|
||||
|
||||
expect(sentBody).not.toHaveProperty('ignoreSourceId')
|
||||
expect(sentBody.subscriptions).toHaveProperty('Ticket_Add')
|
||||
expect(sentBody.isEnabled).toBe(true)
|
||||
expect(result?.providerConfigUpdates).toMatchObject({ externalId: 'wh-123' })
|
||||
expect(result?.providerConfigUpdates).not.toHaveProperty('ignoreSourceId')
|
||||
})
|
||||
|
||||
/** Drives createSubscription and returns the JSON body sent to Zoho. */
|
||||
async function captureSentBody(
|
||||
providerConfig: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
vi.mocked(getCredentialOwner).mockResolvedValue({
|
||||
accountId: 'acc-1',
|
||||
userId: 'user-1',
|
||||
// biome-ignore lint/suspicious/noExplicitAny: partial owner shape for the test
|
||||
} as any)
|
||||
vi.mocked(refreshAccessTokenIfNeeded).mockResolvedValue('zoho-token')
|
||||
|
||||
let sentBody: Record<string, unknown> = {}
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
||||
sentBody = JSON.parse(String((init as RequestInit).body))
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ id: 'wh-123' }),
|
||||
} as unknown as Response
|
||||
})
|
||||
|
||||
await zohoDeskHandler.createSubscription?.({
|
||||
webhook: {
|
||||
id: 'w1',
|
||||
path: 'p1',
|
||||
providerConfig: { credentialId: 'cred-1', orgId: '700123', ...providerConfig },
|
||||
},
|
||||
workflow: {},
|
||||
userId: 'user-1',
|
||||
requestId: 'test',
|
||||
// biome-ignore lint/suspicious/noExplicitAny: request is unused on this path
|
||||
request: {} as any,
|
||||
})
|
||||
return sentBody
|
||||
}
|
||||
|
||||
// Zoho documents includePrevState on the Ticket/Contact/Agent/Task/Article
|
||||
// update events but NOT on Ticket_Comment_Update, which lists only
|
||||
// departmentIds. An `endsWith('_Update')` rule sent an undocumented filter
|
||||
// key on that one event.
|
||||
it.each(['Ticket_Update', 'Contact_Update', 'Agent_Update', 'Task_Update', 'Article_Update'])(
|
||||
'sets includePrevState for %s',
|
||||
async (eventType) => {
|
||||
const body = await captureSentBody({ eventType })
|
||||
expect((body.subscriptions as Record<string, unknown>)[eventType]).toMatchObject({
|
||||
includePrevState: true,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('does NOT set includePrevState for Ticket_Comment_Update', async () => {
|
||||
// With a department filter the object exists, so this proves the key is
|
||||
// absent rather than the whole filter being null for an unrelated reason.
|
||||
const withDepts = await captureSentBody({
|
||||
eventType: 'Ticket_Comment_Update',
|
||||
triggerDepartmentIds: '111',
|
||||
})
|
||||
expect((withDepts.subscriptions as Record<string, unknown>).Ticket_Comment_Update).toEqual({
|
||||
departmentIds: ['111'],
|
||||
})
|
||||
|
||||
// And with no filters at all it collapses to null, not `{includePrevState:true}`.
|
||||
const bare = await captureSentBody({ eventType: 'Ticket_Comment_Update' })
|
||||
expect((bare.subscriptions as Record<string, unknown>).Ticket_Comment_Update).toBeNull()
|
||||
})
|
||||
|
||||
// Zoho: events outside the ticket/task family "do not support filters.
|
||||
// Therefore, pass the value as null in the API request."
|
||||
it('drops departmentIds for an event whose filter does not accept it', async () => {
|
||||
const body = await captureSentBody({
|
||||
eventType: 'Contact_Add',
|
||||
triggerDepartmentIds: '111,222',
|
||||
})
|
||||
expect((body.subscriptions as Record<string, unknown>).Contact_Add).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps departmentIds for an event whose filter accepts it', async () => {
|
||||
const body = await captureSentBody({
|
||||
eventType: 'Ticket_Add',
|
||||
triggerDepartmentIds: '111,222',
|
||||
})
|
||||
expect((body.subscriptions as Record<string, unknown>).Ticket_Add).toEqual({
|
||||
departmentIds: ['111', '222'],
|
||||
})
|
||||
})
|
||||
|
||||
it('sends null, not an empty object, when an event has no filters', async () => {
|
||||
const body = await captureSentBody({ eventType: 'Ticket_Delete' })
|
||||
expect((body.subscriptions as Record<string, unknown>).Ticket_Delete).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapZohoWebhookError', () => {
|
||||
it('surfaces INVALID_DATA field errors and is non-retryable (4xx)', () => {
|
||||
const err = mapZohoWebhookError(
|
||||
422,
|
||||
JSON.stringify({
|
||||
errorCode: 'INVALID_DATA',
|
||||
errors: [
|
||||
{
|
||||
fieldName: '/ignoreSourceId',
|
||||
errorMessage:
|
||||
"The value passed for field '/ignoreSourceId' does not match the allowed values.",
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
expect(err.message).toContain('INVALID_DATA')
|
||||
expect(err.message).toContain('/ignoreSourceId')
|
||||
expect(err.message).not.toContain('Professional edition')
|
||||
expect(errorStatus(err)).toBe(422)
|
||||
})
|
||||
|
||||
it('surfaces UNPROCESSABLE_ENTITY (URL validation) verbatim, non-retryable', () => {
|
||||
const err = mapZohoWebhookError(
|
||||
422,
|
||||
JSON.stringify({
|
||||
errorCode: 'UNPROCESSABLE_ENTITY',
|
||||
message:
|
||||
'Validation failed for the condition : The endpoint failed to respond with status code 200',
|
||||
})
|
||||
)
|
||||
expect(err.message).toContain('endpoint failed to respond with status code 200')
|
||||
expect(err.message).not.toContain('Professional edition')
|
||||
expect(errorStatus(err)).toBe(422)
|
||||
})
|
||||
|
||||
it('only claims the edition/permission cause when Zoho indicates it', () => {
|
||||
const err = mapZohoWebhookError(
|
||||
403,
|
||||
JSON.stringify({ errorCode: 'PERMISSION_DENIED', message: 'You do not have permission' })
|
||||
)
|
||||
expect(err.message).toContain('permission')
|
||||
expect(err.message).toContain('Professional edition')
|
||||
expect(errorStatus(err)).toBe(403)
|
||||
})
|
||||
|
||||
it('does not add the edition hint to a 403 whose body is unrelated to edition/permission', () => {
|
||||
const err = mapZohoWebhookError(
|
||||
403,
|
||||
JSON.stringify({ errorCode: 'INVALID_OAUTH', message: 'Invalid OAuth token' })
|
||||
)
|
||||
expect(err.message).toContain('INVALID_OAUTH')
|
||||
expect(err.message).toContain('Invalid OAuth token')
|
||||
expect(err.message).not.toContain('Professional edition')
|
||||
expect(errorStatus(err)).toBe(403)
|
||||
})
|
||||
|
||||
it('keeps provider 5xx retryable', () => {
|
||||
const err = mapZohoWebhookError(500, JSON.stringify({ errorCode: 'INTERNAL_ERROR' }))
|
||||
expect(errorStatus(err)).toBe(503)
|
||||
})
|
||||
})
|
||||
|
||||
describe('create-time URL verification probe', () => {
|
||||
it('opts Zoho Desk into pending webhook verification', () => {
|
||||
expect(requiresPendingWebhookVerification('zoho_desk')).toBe(true)
|
||||
})
|
||||
|
||||
it('answers Zoho GET/HEAD probes but not real POST deliveries', () => {
|
||||
const entry = { provider: 'zoho_desk', path: 'p1', expiresAt: Date.now() + 60_000 }
|
||||
expect(
|
||||
matchesPendingWebhookVerificationProbe(entry, { method: 'GET', body: undefined })
|
||||
).toBe(true)
|
||||
expect(
|
||||
matchesPendingWebhookVerificationProbe(entry, { method: 'HEAD', body: undefined })
|
||||
).toBe(true)
|
||||
expect(
|
||||
matchesPendingWebhookVerificationProbe(entry, { method: 'POST', body: { eventType: 'x' } })
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,522 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { truncate } from '@sim/utils/string'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import * as jose from 'jose'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getCredentialOwner, getNotificationUrl } from '@/lib/webhooks/provider-subscription-utils'
|
||||
import type {
|
||||
AuthContext,
|
||||
DeleteSubscriptionContext,
|
||||
FormatInputContext,
|
||||
FormatInputResult,
|
||||
SubscriptionContext,
|
||||
SubscriptionResult,
|
||||
WebhookProviderHandler,
|
||||
} from '@/lib/webhooks/providers/types'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { isZohoHost } from '@/tools/zoho_desk/host-allowlist'
|
||||
import { withDerivedContentText } from '@/tools/zoho_desk/utils'
|
||||
|
||||
const logger = createLogger('WebhookProvider:ZohoDesk')
|
||||
|
||||
const DEFAULT_ZOHO_DESK_BASE = 'https://desk.zoho.com'
|
||||
// Stop at a comma or whitespace: better-auth persists Zoho's scopes comma-joined
|
||||
// (no spaces), so a greedy `\S+` would swallow the whole scope list into the host.
|
||||
const ZOHO_DESK_BASE_URL_REGEX = /__zoho_domain__:([^\s,]+)/
|
||||
|
||||
/**
|
||||
* Remote JWKS sets are cached per Desk data-center host. `createRemoteJWKSet`
|
||||
* caches keys and coalesces refetches internally, so a module-scoped cache keeps
|
||||
* verification within Zoho's 5-second delivery deadline.
|
||||
*/
|
||||
const jwksCache = new Map<string, ReturnType<typeof jose.createRemoteJWKSet>>()
|
||||
|
||||
/**
|
||||
* Events whose subscription filter accepts `departmentIds`. Zoho documents the
|
||||
* rest as taking no filter at all.
|
||||
*/
|
||||
const DEPARTMENT_FILTERABLE_EVENTS = new Set([
|
||||
'Ticket_Add',
|
||||
'Ticket_Update',
|
||||
'Ticket_Comment_Add',
|
||||
'Ticket_Comment_Update',
|
||||
'Ticket_Thread_Add',
|
||||
'Task_Add',
|
||||
'Task_Update',
|
||||
])
|
||||
|
||||
/**
|
||||
* Events whose filter accepts `includePrevState`. Enumerated rather than derived
|
||||
* from an `_Update` suffix: Zoho documents the attribute on Ticket/Contact/Agent/
|
||||
* Task/Article update events but NOT on `Ticket_Comment_Update`, which lists only
|
||||
* `departmentIds`. Sending an undocumented filter key on a live create is the
|
||||
* same class of risk as an undocumented query param.
|
||||
*/
|
||||
const PREV_STATE_EVENTS = new Set([
|
||||
'Ticket_Update',
|
||||
'Contact_Update',
|
||||
'Agent_Update',
|
||||
'Task_Update',
|
||||
'Article_Update',
|
||||
])
|
||||
|
||||
/**
|
||||
* Bound on distinct Desk hosts held in {@link jwksCache}. The host is derived
|
||||
* from `providerConfig.apiDomain`, which `SYSTEM_MANAGED_FIELDS` protects from
|
||||
* *diffing* but not from being written by a workspace member. `safeZohoDeskBase`
|
||||
* already clamps it to a Zoho apex so no key material is ever fetched off-Zoho,
|
||||
* but any `*.zoho.com` label still passes - so without a cap, repeated writes
|
||||
* plus webhook hits could grow one JWKS instance (and its key cache) per label.
|
||||
* Zoho has a handful of data centers; anything beyond this is not legitimate
|
||||
* traffic, so evicting oldest-first is safe.
|
||||
*/
|
||||
const JWKS_CACHE_MAX_ENTRIES = 16
|
||||
|
||||
function getJwks(deskHost: string): ReturnType<typeof jose.createRemoteJWKSet> {
|
||||
const set = jwksCache.get(deskHost)
|
||||
if (set) return set
|
||||
|
||||
if (jwksCache.size >= JWKS_CACHE_MAX_ENTRIES) {
|
||||
const oldest = jwksCache.keys().next()
|
||||
if (!oldest.done) jwksCache.delete(oldest.value)
|
||||
}
|
||||
// Zoho fails a delivery that is not answered within 5 seconds and publishes no
|
||||
// retry, so a cold-start JWKS fetch must not consume the whole budget -
|
||||
// jose's default timeoutDuration is 5000ms, exactly the deadline.
|
||||
const created = jose.createRemoteJWKSet(new URL(`https://${deskHost}/.well-known/jwks.json`), {
|
||||
timeoutDuration: 1500,
|
||||
})
|
||||
jwksCache.set(deskHost, created)
|
||||
return created
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchor a Desk base URL to the Zoho apex allowlist before it is used to build a
|
||||
* token-carrying request or a JWKS fetch. `providerConfig.apiDomain` is only
|
||||
* written by `createSubscription`, but `SYSTEM_MANAGED_FIELDS` governs config
|
||||
* *diffing*, not writability - it does not strip a caller-supplied `apiDomain`
|
||||
* from an incoming providerConfig. Falling back to the US base on anything
|
||||
* unrecognized keeps a hostile value from ever receiving the OAuth token or
|
||||
* standing in as the JWKS issuer.
|
||||
*/
|
||||
function safeZohoDeskBase(candidate: unknown): string {
|
||||
if (typeof candidate !== 'string' || !candidate) return DEFAULT_ZOHO_DESK_BASE
|
||||
try {
|
||||
const url = new URL(candidate)
|
||||
if (url.protocol !== 'https:' || !isZohoHost(url.hostname)) return DEFAULT_ZOHO_DESK_BASE
|
||||
return candidate.replace(/\/+$/, '')
|
||||
} catch {
|
||||
return DEFAULT_ZOHO_DESK_BASE
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the persisted data-center Desk base URL from the credential's scope marker. */
|
||||
async function resolveZohoDeskApiDomain(accountId: string): Promise<string> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({ scope: account.scope })
|
||||
.from(account)
|
||||
.where(eq(account.id, accountId))
|
||||
.limit(1)
|
||||
const scope = rows[0]?.scope
|
||||
const match = typeof scope === 'string' ? scope.match(ZOHO_DESK_BASE_URL_REGEX) : null
|
||||
return safeZohoDeskBase(match?.[1])
|
||||
} catch (error) {
|
||||
logger.warn('Failed to resolve Zoho Desk api domain from credential', {
|
||||
message: toError(error).message,
|
||||
})
|
||||
return DEFAULT_ZOHO_DESK_BASE
|
||||
}
|
||||
}
|
||||
|
||||
function splitCsv(value: unknown): string[] {
|
||||
if (typeof value !== 'string') return []
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the organization id out of a persisted `providerConfig`.
|
||||
*
|
||||
* The trigger exposes the organization as a canonical basic/advanced pair:
|
||||
* `orgId` (the selector) and `manualOrgId` (free text). `buildProviderConfig`
|
||||
* collapses that pair at deploy time and writes the ACTIVE member's value under
|
||||
* the canonical key `orgId`, so that is the authoritative read and is checked
|
||||
* first.
|
||||
*
|
||||
* `manualOrgId` is the explicit fallback because the collapse is strict about
|
||||
* the resolved mode: when `block.data.canonicalModes` pins the group to `basic`
|
||||
* while only the manual field carries a value, the collapse deletes the
|
||||
* canonical key even though the deploy-time required-field check passes (the
|
||||
* canonical group counts as satisfied by the manual member). Without this
|
||||
* fallback that combination would deploy and then fail here with "Organization
|
||||
* ID is required".
|
||||
*/
|
||||
function resolveConfigOrgId(config: Record<string, unknown>): string | undefined {
|
||||
if (typeof config.orgId === 'string' && config.orgId.trim()) return config.orgId.trim()
|
||||
if (typeof config.manualOrgId === 'string' && config.manualOrgId.trim()) {
|
||||
return config.manualOrgId.trim()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Error carrying an HTTP status so the deploy outbox can classify retryability. */
|
||||
function statusError(message: string, status: number): Error {
|
||||
const err = new Error(message) as Error & { status: number }
|
||||
err.status = status
|
||||
return err
|
||||
}
|
||||
|
||||
/** Zoho errorCode / message patterns that genuinely indicate a permission or edition denial. */
|
||||
const ZOHO_EDITION_PERMISSION_PATTERN =
|
||||
/permission|not\s+(allowed|permitted|supported)|edition|upgrade|feature/i
|
||||
|
||||
/**
|
||||
* Map a Zoho Desk webhook-creation failure to an actionable error that surfaces
|
||||
* Zoho's real errorCode / message / field errors instead of a catch-all. The
|
||||
* returned error carries an HTTP `status`: permanent client failures (4xx -
|
||||
* invalid data, permission, unprocessable) keep their 4xx so the deploy outbox
|
||||
* fails the deploy terminally (NonRetryableDeploymentError) with the true
|
||||
* reason; rate limits and provider 5xx map to 5xx so they stay retryable.
|
||||
*/
|
||||
export function mapZohoWebhookError(status: number, bodyText: string): Error {
|
||||
let errorCode: string | undefined
|
||||
let message: string | undefined
|
||||
let fieldErrors: string[] = []
|
||||
try {
|
||||
const parsed = JSON.parse(bodyText) as {
|
||||
errorCode?: unknown
|
||||
message?: unknown
|
||||
errors?: Array<{ fieldName?: unknown; errorMessage?: unknown }>
|
||||
}
|
||||
if (typeof parsed.errorCode === 'string') errorCode = parsed.errorCode
|
||||
if (typeof parsed.message === 'string') message = parsed.message
|
||||
if (Array.isArray(parsed.errors)) {
|
||||
fieldErrors = parsed.errors
|
||||
.map((e) =>
|
||||
typeof e?.errorMessage === 'string'
|
||||
? typeof e.fieldName === 'string'
|
||||
? `${e.fieldName}: ${e.errorMessage}`
|
||||
: e.errorMessage
|
||||
: undefined
|
||||
)
|
||||
.filter((v): v is string => Boolean(v))
|
||||
}
|
||||
} catch {
|
||||
// non-JSON body
|
||||
}
|
||||
|
||||
const detail =
|
||||
fieldErrors.length > 0
|
||||
? fieldErrors.join('; ')
|
||||
: message || truncate(bodyText, 200) || `HTTP ${status}`
|
||||
const codePrefix = errorCode ? `${errorCode}: ` : ''
|
||||
let realMessage = `Zoho Desk webhook creation failed (HTTP ${status}) - ${codePrefix}${detail}`
|
||||
|
||||
// Only claim the edition/permission cause when Zoho's own errorCode or message
|
||||
// says so. A bare 403 can equally mean a wrong org, a missing scope, or a bad
|
||||
// token, and those must not get the misleading "requires Professional" suffix.
|
||||
const indicatesEditionOrPermission =
|
||||
(errorCode ? ZOHO_EDITION_PERMISSION_PATTERN.test(errorCode) : false) ||
|
||||
ZOHO_EDITION_PERMISSION_PATTERN.test(detail)
|
||||
if (indicatesEditionOrPermission) {
|
||||
realMessage +=
|
||||
' (Zoho Desk webhooks require a Professional edition or higher and the Desk.webhooks.CREATE scope.)'
|
||||
}
|
||||
|
||||
// Rate limits and provider-side 5xx are transient -> keep retryable.
|
||||
if (status === 429 || status >= 500) {
|
||||
return statusError(realMessage, 503)
|
||||
}
|
||||
// Other client errors cannot succeed on retry -> carry the 4xx status.
|
||||
return statusError(realMessage, status >= 400 && status < 500 ? status : 422)
|
||||
}
|
||||
|
||||
export const zohoDeskHandler: WebhookProviderHandler = {
|
||||
// Zoho requires a 200 within 5 seconds, so acknowledge ingress before running
|
||||
// the workflow through the durable queue rather than inline.
|
||||
executionMode: 'queue',
|
||||
|
||||
async createSubscription({
|
||||
webhook: webhookRecord,
|
||||
userId,
|
||||
requestId,
|
||||
}: SubscriptionContext): Promise<SubscriptionResult | undefined> {
|
||||
const config = ((webhookRecord as Record<string, unknown>).providerConfig ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
const credentialId = typeof config.credentialId === 'string' ? config.credentialId : undefined
|
||||
const orgId = resolveConfigOrgId(config)
|
||||
const eventType = typeof config.eventType === 'string' ? config.eventType : undefined
|
||||
|
||||
// Missing configuration is permanent - carry a 4xx so the deploy outbox fails
|
||||
// terminally (NonRetryableDeploymentError) instead of retrying a create that
|
||||
// can never succeed without the user fixing the trigger config.
|
||||
if (!orgId) {
|
||||
throw statusError(
|
||||
'Zoho Desk Organization ID is required to create the webhook subscription.',
|
||||
400
|
||||
)
|
||||
}
|
||||
if (!eventType) {
|
||||
throw statusError(
|
||||
'A Zoho Desk event type is required to create the webhook subscription.',
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
const owner = credentialId ? await getCredentialOwner(credentialId, requestId) : null
|
||||
const accessToken = owner
|
||||
? await refreshAccessTokenIfNeeded(owner.accountId, owner.userId, requestId)
|
||||
: null
|
||||
if (!accessToken || !owner) {
|
||||
throw statusError(
|
||||
'Zoho Desk account connection required. Please connect your Zoho Desk account in the trigger configuration and try again.',
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// Already allowlist-anchored by resolveZohoDeskApiDomain -> safeZohoDeskBase.
|
||||
const apiDomain = await resolveZohoDeskApiDomain(owner.accountId)
|
||||
const notificationUrl = getNotificationUrl(webhookRecord)
|
||||
|
||||
const filter: Record<string, unknown> = {}
|
||||
// Zoho supports `departmentIds` only on the ticket/task-family events; for
|
||||
// Contact_*, Agent_*, Article_* and Task_Delete its docs say "This event does
|
||||
// not support filters. Therefore, pass the value as null in the API request."
|
||||
// Sending it anyway is at best a silent no-op and at worst INVALID_DATA.
|
||||
if (DEPARTMENT_FILTERABLE_EVENTS.has(eventType)) {
|
||||
const departmentIds = splitCsv(config.triggerDepartmentIds)
|
||||
if (departmentIds.length > 0) filter.departmentIds = departmentIds
|
||||
}
|
||||
// `includePrevState` defaults to false, so without it Zoho never sends
|
||||
// `prevState` and the trigger's declared output is permanently null for the
|
||||
// update events that do support it.
|
||||
if (PREV_STATE_EVENTS.has(eventType)) {
|
||||
filter.includePrevState = true
|
||||
}
|
||||
if (eventType === 'Ticket_Update') {
|
||||
const fields = splitCsv(config.fields).slice(0, 5)
|
||||
if (fields.length > 0) filter.fields = fields
|
||||
}
|
||||
if (eventType === 'Ticket_Thread_Add') {
|
||||
const direction = typeof config.direction === 'string' ? config.direction : 'both'
|
||||
if (direction === 'in' || direction === 'out') filter.direction = direction
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiDomain}/api/v1/webhooks`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Zoho-oauthtoken ${accessToken}`,
|
||||
orgId,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// ignoreSourceId is omitted. Zoho documents it as taking a UUID, but the
|
||||
// live API rejected a well-formed v4 UUID with INVALID_DATA - the doc and
|
||||
// the implementation disagree, and the field only suppresses echo of our
|
||||
// own writes, which Sim does not need.
|
||||
body: JSON.stringify({
|
||||
url: notificationUrl,
|
||||
name: `sim-${webhookRecord.id}`.slice(0, 50),
|
||||
// Zoho's own examples pass `null` (not `{}`) for an event with no
|
||||
// filters - `"Contact_Add" : null`. Match the documented form.
|
||||
subscriptions: { [eventType]: Object.keys(filter).length > 0 ? filter : null },
|
||||
isEnabled: true,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
|
||||
const bodyText = await response.text()
|
||||
if (!response.ok) {
|
||||
logger.error(`[${requestId}] Failed to create Zoho Desk webhook`, {
|
||||
status: response.status,
|
||||
body: truncate(bodyText, 500),
|
||||
})
|
||||
throw mapZohoWebhookError(response.status, bodyText)
|
||||
}
|
||||
|
||||
let created: Record<string, unknown> = {}
|
||||
try {
|
||||
created = JSON.parse(bodyText)
|
||||
} catch {
|
||||
// Zoho returns JSON on success; tolerate an empty body.
|
||||
}
|
||||
const idValue = created.id
|
||||
const externalId =
|
||||
typeof idValue === 'string' ? idValue : typeof idValue === 'number' ? String(idValue) : ''
|
||||
// Never persist a subscription without its id: the id is the JWT `aud` claim
|
||||
// verifyAuth checks, so an empty one would force verification to fail open.
|
||||
if (!externalId) {
|
||||
// Zoho reported success but gave no id: a webhook may have been created and
|
||||
// is unidentifiable, so retrying risks duplicates. Fail terminally (4xx).
|
||||
throw statusError('Zoho Desk webhook creation succeeded but returned no webhook id', 422)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Created Zoho Desk webhook`, { externalId })
|
||||
return {
|
||||
// externalId (the JWT `aud` claim) and apiDomain are provider-managed and
|
||||
// are SYSTEM_MANAGED_FIELDS, so they are excluded from the redeploy config
|
||||
// diff to avoid delete/recreate churn on every deploy.
|
||||
providerConfigUpdates: { externalId, apiDomain },
|
||||
}
|
||||
},
|
||||
|
||||
async deleteSubscription({
|
||||
webhook: webhookRecord,
|
||||
requestId,
|
||||
strict,
|
||||
}: DeleteSubscriptionContext): Promise<void> {
|
||||
const config = ((webhookRecord as Record<string, unknown>).providerConfig ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
const externalId = typeof config.externalId === 'string' ? config.externalId : undefined
|
||||
const orgId = resolveConfigOrgId(config)
|
||||
const credentialId = typeof config.credentialId === 'string' ? config.credentialId : undefined
|
||||
|
||||
if (!externalId || !orgId) {
|
||||
if (strict) throw new Error('Missing Zoho Desk webhook identifiers for deletion')
|
||||
return
|
||||
}
|
||||
|
||||
const owner = credentialId ? await getCredentialOwner(credentialId, requestId) : null
|
||||
const accessToken = owner
|
||||
? await refreshAccessTokenIfNeeded(owner.accountId, owner.userId, requestId)
|
||||
: null
|
||||
if (!accessToken || !owner) {
|
||||
if (strict) throw new Error('Missing Zoho Desk token for webhook deletion')
|
||||
return
|
||||
}
|
||||
|
||||
const apiDomain =
|
||||
typeof config.apiDomain === 'string' && config.apiDomain
|
||||
? safeZohoDeskBase(config.apiDomain)
|
||||
: await resolveZohoDeskApiDomain(owner.accountId)
|
||||
|
||||
const response = await fetch(`${apiDomain}/api/v1/webhooks/${externalId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Zoho-oauthtoken ${accessToken}`,
|
||||
orgId,
|
||||
},
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
|
||||
if (!response.ok && response.status !== 404) {
|
||||
logger.warn(`[${requestId}] Failed to delete Zoho Desk webhook`, { status: response.status })
|
||||
if (strict) throw new Error(`Zoho Desk webhook delete failed: ${response.status}`)
|
||||
}
|
||||
},
|
||||
|
||||
async verifyAuth({
|
||||
request,
|
||||
requestId,
|
||||
providerConfig,
|
||||
}: AuthContext): Promise<NextResponse | null> {
|
||||
const token = request.headers.get('x-zdesk-jwt')
|
||||
if (!token) {
|
||||
logger.warn(`[${requestId}] Zoho Desk webhook missing X-ZDesk-JWT header`)
|
||||
return new NextResponse('Unauthorized - Missing Zoho Desk JWT', { status: 401 })
|
||||
}
|
||||
|
||||
// Same canonical-then-manual resolution as create/delete, so the JWT issuer
|
||||
// claim is bound to the organization the subscription was actually created
|
||||
// against no matter which side of the pair supplied it.
|
||||
const orgId = resolveConfigOrgId(providerConfig) ?? ''
|
||||
// `webhookId` was persisted on older rows; `externalId` is the canonical id.
|
||||
const webhookId =
|
||||
(typeof providerConfig.externalId === 'string' && providerConfig.externalId) ||
|
||||
(typeof providerConfig.webhookId === 'string' && providerConfig.webhookId) ||
|
||||
''
|
||||
// Fail closed: without both identifiers we cannot bind the JWT to this
|
||||
// subscription, so accepting any org-JWKS RS256 token matching only the
|
||||
// issuer would be a verification bypass. Reject instead of skipping a claim.
|
||||
if (!orgId || !webhookId) {
|
||||
logger.warn(`[${requestId}] Zoho Desk webhook missing orgId/webhookId; rejecting`)
|
||||
return new NextResponse('Unauthorized - webhook not fully provisioned', { status: 401 })
|
||||
}
|
||||
|
||||
// Prefer the apiDomain persisted on the webhook row (fast path, no DB hit on
|
||||
// the 5s-deadline verification path). Only when it is absent - older rows, or
|
||||
// a config that never captured it - fall back to the Desk base stored on the
|
||||
// OAuth credential (`__zoho_domain__` scope marker), mirroring
|
||||
// deleteSubscription, so a non-US org verifies against its own JWKS rather
|
||||
// than defaulting to the US host and rejecting legitimate events.
|
||||
let apiDomain =
|
||||
typeof providerConfig.apiDomain === 'string' && providerConfig.apiDomain
|
||||
? safeZohoDeskBase(providerConfig.apiDomain)
|
||||
: ''
|
||||
if (!apiDomain) {
|
||||
const credentialId =
|
||||
typeof providerConfig.credentialId === 'string' ? providerConfig.credentialId : undefined
|
||||
const owner = credentialId ? await getCredentialOwner(credentialId, requestId) : null
|
||||
apiDomain = owner ? await resolveZohoDeskApiDomain(owner.accountId) : DEFAULT_ZOHO_DESK_BASE
|
||||
}
|
||||
|
||||
let deskHost: string
|
||||
try {
|
||||
deskHost = new URL(apiDomain).host
|
||||
} catch {
|
||||
deskHost = 'desk.zoho.com'
|
||||
}
|
||||
|
||||
try {
|
||||
await jose.jwtVerify(token, getJwks(deskHost), {
|
||||
algorithms: ['RS256'],
|
||||
issuer: `orgId:${orgId}`,
|
||||
audience: `webhookId:${webhookId}`,
|
||||
})
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.warn(`[${requestId}] Zoho Desk JWT verification failed`, {
|
||||
message: toError(error).message,
|
||||
})
|
||||
return new NextResponse('Unauthorized - Invalid Zoho Desk JWT', { status: 401 })
|
||||
}
|
||||
},
|
||||
|
||||
async formatInput({ body, requestId }: FormatInputContext): Promise<FormatInputResult> {
|
||||
// Zoho Desk delivers an array of events: [{ payload, prevState, eventTime, eventType, orgId }].
|
||||
// Anything that is not the expected array shape is passed through unchanged.
|
||||
if (!Array.isArray(body)) {
|
||||
return { input: body }
|
||||
}
|
||||
// Zoho fires one event per notification (single-element array). Log rather
|
||||
// than silently drop if that ever changes, so batched deliveries are visible.
|
||||
if (body.length > 1) {
|
||||
logger.warn(
|
||||
`[${requestId}] Zoho Desk delivered ${body.length} events in one payload; processing the first only`
|
||||
)
|
||||
}
|
||||
const event = body[0]
|
||||
if (!event || typeof event !== 'object') {
|
||||
// Empty or malformed array (e.g. Zoho posts `[]`): emit the normalized
|
||||
// trigger shape with null fields so downstream steps still see
|
||||
// eventType/payload/etc. rather than a raw array leaking through.
|
||||
return {
|
||||
input: { eventType: null, eventTime: null, orgId: null, payload: null, prevState: null },
|
||||
}
|
||||
}
|
||||
const record = event as Record<string, unknown>
|
||||
// Comment / thread event payloads carry a raw `content` + `contentType`
|
||||
// ('html' | 'plainText') pair; augment both `payload` and `prevState` with a
|
||||
// derived plain-text `contentText` (HTML stripped) alongside the untouched raw
|
||||
// content, so before/after comparisons see a consistent shape. Values without
|
||||
// a content pair (ticket / contact events) pass through unchanged.
|
||||
return {
|
||||
input: {
|
||||
eventType: record.eventType ?? null,
|
||||
eventTime: record.eventTime ?? null,
|
||||
orgId: record.orgId ?? null,
|
||||
payload: record.payload != null ? withDerivedContentText(record.payload) : null,
|
||||
prevState: record.prevState != null ? withDerivedContentText(record.prevState) : null,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export const SELECTOR_CONTEXT_FIELDS = new Set<keyof SelectorContext>([
|
||||
'awsRegion',
|
||||
'logGroupName',
|
||||
'tableId',
|
||||
'orgId',
|
||||
])
|
||||
|
||||
/**
|
||||
|
||||
@@ -60,7 +60,12 @@ function shouldSerializeSubBlock(
|
||||
if (isToolInputOnlySubBlock(subBlockConfig)) return false
|
||||
if (isSubBlockHidden(subBlockConfig)) return false
|
||||
|
||||
if (subBlockConfig.mode === 'trigger') {
|
||||
// `trigger-advanced` is a trigger-mode field too - the advanced twin of a
|
||||
// `trigger` selector - so it must be excluded from tool-mode serialization for
|
||||
// the same reason. Without this it stays a live, validated tool param: a
|
||||
// trigger's `required` manual field then blocks running an unrelated operation
|
||||
// that does not even render it, and the error names a hidden field.
|
||||
if (subBlockConfig.mode === 'trigger' || subBlockConfig.mode === 'trigger-advanced') {
|
||||
if (!isTriggerContext && !isTriggerCategory) return false
|
||||
} else if (isTriggerContext && !isTriggerCategory) {
|
||||
return false
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1454,6 +1454,9 @@ export async function executeTool(
|
||||
if (data.instanceUrl) {
|
||||
contextParams.instanceUrl = data.instanceUrl
|
||||
}
|
||||
if (data.apiDomain && !contextParams.apiDomain) {
|
||||
contextParams.apiDomain = data.apiDomain
|
||||
}
|
||||
if (data.cloudId && !contextParams.cloudId) {
|
||||
contextParams.cloudId = data.cloudId
|
||||
}
|
||||
|
||||
@@ -4713,6 +4713,18 @@ import {
|
||||
zepGetUserTool,
|
||||
} from '@/tools/zep'
|
||||
import { zerobounceGetCreditsTool, zerobounceVerifyEmailTool } from '@/tools/zerobounce'
|
||||
import {
|
||||
zohoDeskAddCommentTool,
|
||||
zohoDeskGetAttachmentTool,
|
||||
zohoDeskGetContactTool,
|
||||
zohoDeskGetThreadTool,
|
||||
zohoDeskGetTicketTool,
|
||||
zohoDeskListCommentsTool,
|
||||
zohoDeskListOrganizationsTool,
|
||||
zohoDeskListThreadsTool,
|
||||
zohoDeskListTicketsTool,
|
||||
zohoDeskUpdateTicketTool,
|
||||
} from '@/tools/zoho_desk'
|
||||
import {
|
||||
zoomCreateMeetingTool,
|
||||
zoomDeleteMeetingTool,
|
||||
@@ -8921,6 +8933,16 @@ export const tools: Record<string, ToolConfig> = {
|
||||
zendesk_delete_organization: zendeskDeleteOrganizationTool,
|
||||
zendesk_search: zendeskSearchTool,
|
||||
zendesk_search_count: zendeskSearchCountTool,
|
||||
zoho_desk_list_tickets: zohoDeskListTicketsTool,
|
||||
zoho_desk_get_ticket: zohoDeskGetTicketTool,
|
||||
zoho_desk_update_ticket: zohoDeskUpdateTicketTool,
|
||||
zoho_desk_list_comments: zohoDeskListCommentsTool,
|
||||
zoho_desk_add_comment: zohoDeskAddCommentTool,
|
||||
zoho_desk_list_threads: zohoDeskListThreadsTool,
|
||||
zoho_desk_get_thread: zohoDeskGetThreadTool,
|
||||
zoho_desk_get_contact: zohoDeskGetContactTool,
|
||||
zoho_desk_list_organizations: zohoDeskListOrganizationsTool,
|
||||
zoho_desk_get_attachment: zohoDeskGetAttachmentTool,
|
||||
intercom_create_contact: intercomCreateContactTool,
|
||||
intercom_create_contact_v2: intercomCreateContactV2Tool,
|
||||
intercom_get_contact: intercomGetContactTool,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { ZohoDeskAddCommentParams, ZohoDeskResponse } from '@/tools/zoho_desk/types'
|
||||
import { ZOHO_DESK_COMMENT_PROPERTIES } from '@/tools/zoho_desk/types'
|
||||
import {
|
||||
buildZohoDeskHeaders,
|
||||
getZohoDeskApiBase,
|
||||
getZohoDeskErrorMessage,
|
||||
requireZohoDeskId,
|
||||
withDerivedContentText,
|
||||
} from '@/tools/zoho_desk/utils'
|
||||
|
||||
export const zohoDeskAddCommentTool: ToolConfig<ZohoDeskAddCommentParams, ZohoDeskResponse> = {
|
||||
id: 'zoho_desk_add_comment',
|
||||
name: 'Zoho Desk Add Comment',
|
||||
description: 'Add a comment to a Zoho Desk ticket.',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: { required: true, provider: 'zoho-desk' },
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Zoho Desk OAuth access token',
|
||||
},
|
||||
apiDomain: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Zoho Desk data-center REST base URL',
|
||||
},
|
||||
orgId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Zoho Desk organization ID',
|
||||
},
|
||||
ticketId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Ticket ID',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Comment content',
|
||||
},
|
||||
contentType: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
"Content type: plainText or html. Defaults to plainText so agent-written text posts literally; pass 'html' to send markup (Zoho's own API default is html).",
|
||||
},
|
||||
isPublic: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether the comment is public',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) =>
|
||||
`${getZohoDeskApiBase(params)}/tickets/${encodeURIComponent(requireZohoDeskId(params.ticketId, 'Ticket ID'))}/comments`,
|
||||
method: 'POST',
|
||||
headers: (params) => buildZohoDeskHeaders(params),
|
||||
body: (params) => ({
|
||||
content: params.content,
|
||||
contentType: params.contentType || 'plainText',
|
||||
isPublic: params.isPublic ?? false,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
getZohoDeskErrorMessage(data, `Failed to add comment (HTTP ${response.status})`)
|
||||
)
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: { comment: withDerivedContentText(data) },
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
comment: {
|
||||
type: 'object',
|
||||
description: 'The created comment',
|
||||
properties: ZOHO_DESK_COMMENT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { ZohoDeskGetAttachmentParams, ZohoDeskResponse } from '@/tools/zoho_desk/types'
|
||||
|
||||
export const zohoDeskGetAttachmentTool: ToolConfig<ZohoDeskGetAttachmentParams, ZohoDeskResponse> =
|
||||
{
|
||||
id: 'zoho_desk_get_attachment',
|
||||
name: 'Zoho Desk Get Attachment',
|
||||
description: 'Download a Zoho Desk ticket attachment (from its href) as a file.',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: { required: true, provider: 'zoho-desk' },
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Zoho Desk OAuth access token',
|
||||
},
|
||||
apiDomain: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Zoho Desk data-center REST base URL',
|
||||
},
|
||||
orgId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Zoho Desk organization ID',
|
||||
},
|
||||
href: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Attachment download href (from a thread or comment attachment)',
|
||||
},
|
||||
fileName: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Optional file name for the downloaded file',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/zoho_desk/attachment',
|
||||
method: 'POST',
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
body: (params) => ({
|
||||
accessToken: params.accessToken,
|
||||
apiDomain: params.apiDomain,
|
||||
orgId: params.orgId,
|
||||
href: params.href,
|
||||
fileName: params.fileName,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok || !data?.success) {
|
||||
throw new Error(data?.error || `Failed to download attachment (HTTP ${response.status})`)
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: { file: data.output?.file },
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
file: { type: 'file', description: 'The downloaded attachment file' },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { ZohoDeskGetContactParams, ZohoDeskResponse } from '@/tools/zoho_desk/types'
|
||||
import { ZOHO_DESK_CONTACT_PROPERTIES } from '@/tools/zoho_desk/types'
|
||||
import {
|
||||
buildZohoDeskHeaders,
|
||||
getZohoDeskApiBase,
|
||||
getZohoDeskErrorMessage,
|
||||
requireZohoDeskId,
|
||||
} from '@/tools/zoho_desk/utils'
|
||||
|
||||
export const zohoDeskGetContactTool: ToolConfig<ZohoDeskGetContactParams, ZohoDeskResponse> = {
|
||||
id: 'zoho_desk_get_contact',
|
||||
name: 'Zoho Desk Get Contact',
|
||||
description: 'Retrieve a Zoho Desk contact by ID.',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: { required: true, provider: 'zoho-desk' },
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Zoho Desk OAuth access token',
|
||||
},
|
||||
apiDomain: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Zoho Desk data-center REST base URL',
|
||||
},
|
||||
orgId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Zoho Desk organization ID',
|
||||
},
|
||||
contactId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Contact ID to retrieve',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) =>
|
||||
`${getZohoDeskApiBase(params)}/contacts/${encodeURIComponent(requireZohoDeskId(params.contactId, 'Contact ID'))}`,
|
||||
method: 'GET',
|
||||
headers: (params) => buildZohoDeskHeaders(params),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
getZohoDeskErrorMessage(data, `Failed to get contact (HTTP ${response.status})`)
|
||||
)
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: { contact: data },
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
contact: {
|
||||
type: 'object',
|
||||
description: 'The contact',
|
||||
properties: ZOHO_DESK_CONTACT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { ZohoDeskGetThreadParams, ZohoDeskResponse } from '@/tools/zoho_desk/types'
|
||||
import { ZOHO_DESK_THREAD_PROPERTIES } from '@/tools/zoho_desk/types'
|
||||
import {
|
||||
buildZohoDeskHeaders,
|
||||
getZohoDeskApiBase,
|
||||
getZohoDeskErrorMessage,
|
||||
requireZohoDeskId,
|
||||
withDerivedContentText,
|
||||
} from '@/tools/zoho_desk/utils'
|
||||
|
||||
export const zohoDeskGetThreadTool: ToolConfig<ZohoDeskGetThreadParams, ZohoDeskResponse> = {
|
||||
id: 'zoho_desk_get_thread',
|
||||
name: 'Zoho Desk Get Thread',
|
||||
description: 'Retrieve the full content of a single Zoho Desk ticket thread.',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: { required: true, provider: 'zoho-desk' },
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Zoho Desk OAuth access token',
|
||||
},
|
||||
apiDomain: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Zoho Desk data-center REST base URL',
|
||||
},
|
||||
orgId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Zoho Desk organization ID',
|
||||
},
|
||||
ticketId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Ticket ID',
|
||||
},
|
||||
threadId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Thread ID',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) =>
|
||||
`${getZohoDeskApiBase(params)}/tickets/${encodeURIComponent(requireZohoDeskId(params.ticketId, 'Ticket ID'))}/threads/${encodeURIComponent(requireZohoDeskId(params.threadId, 'Thread ID'))}`,
|
||||
method: 'GET',
|
||||
headers: (params) => buildZohoDeskHeaders(params),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
getZohoDeskErrorMessage(data, `Failed to get thread (HTTP ${response.status})`)
|
||||
)
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: { thread: withDerivedContentText(data) },
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
thread: { type: 'object', description: 'The thread', properties: ZOHO_DESK_THREAD_PROPERTIES },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { ZohoDeskGetTicketParams, ZohoDeskResponse } from '@/tools/zoho_desk/types'
|
||||
import { ZOHO_DESK_TICKET_PROPERTIES } from '@/tools/zoho_desk/types'
|
||||
import {
|
||||
buildZohoDeskHeaders,
|
||||
getZohoDeskApiBase,
|
||||
getZohoDeskErrorMessage,
|
||||
requireZohoDeskId,
|
||||
withDerivedContentText,
|
||||
} from '@/tools/zoho_desk/utils'
|
||||
|
||||
export const zohoDeskGetTicketTool: ToolConfig<ZohoDeskGetTicketParams, ZohoDeskResponse> = {
|
||||
id: 'zoho_desk_get_ticket',
|
||||
name: 'Zoho Desk Get Ticket',
|
||||
description: 'Retrieve a single Zoho Desk ticket by ID.',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: { required: true, provider: 'zoho-desk' },
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Zoho Desk OAuth access token',
|
||||
},
|
||||
apiDomain: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Zoho Desk data-center REST base URL',
|
||||
},
|
||||
orgId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Zoho Desk organization ID',
|
||||
},
|
||||
ticketId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Ticket ID to retrieve',
|
||||
},
|
||||
include: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Comma-separated related data to embed. Allowed: contacts, products, assignee, departments, contract, isRead, team, skills',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params.include) query.set('include', params.include)
|
||||
const qs = query.toString()
|
||||
return `${getZohoDeskApiBase(params)}/tickets/${encodeURIComponent(requireZohoDeskId(params.ticketId, 'Ticket ID'))}${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => buildZohoDeskHeaders(params),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
getZohoDeskErrorMessage(data, `Failed to get ticket (HTTP ${response.status})`)
|
||||
)
|
||||
}
|
||||
// Adds the derived plain-text `descriptionText` alongside the raw
|
||||
// description without mutating it. Absent or plain-text descriptions are
|
||||
// mirrored unchanged.
|
||||
return {
|
||||
success: true,
|
||||
output: { ticket: withDerivedContentText(data) },
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
ticket: { type: 'object', description: 'The ticket', properties: ZOHO_DESK_TICKET_PROPERTIES },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Zoho-owned apex domains across data centers. `apiDomain` and attachment `href`
|
||||
* values are user/LLM-influenced, so any outbound request that carries the OAuth
|
||||
* token - and the data-center base persisted at token exchange - must anchor its
|
||||
* host to one of these with a strict suffix match. A naive `contains "zoho."` or
|
||||
* `desk.zoho.*` check would accept an attacker domain like `zoho.attacker.com` or
|
||||
* `desk.zoho.com.attacker.com` and leak the token to it.
|
||||
*
|
||||
* Kept in its own dependency-free module so the auth token-exchange path can
|
||||
* validate hosts without pulling in the heavier tool utilities (e.g. html-to-text).
|
||||
*/
|
||||
const ZOHO_ALLOWED_APEX_DOMAINS = [
|
||||
'zoho.com',
|
||||
'zoho.eu',
|
||||
'zoho.in',
|
||||
'zoho.com.au',
|
||||
'zoho.jp',
|
||||
'zoho.ca',
|
||||
'zoho.sa',
|
||||
'zoho.com.cn',
|
||||
'zoho.uk',
|
||||
'zohoapis.com',
|
||||
'zohoapis.eu',
|
||||
'zohoapis.in',
|
||||
'zohoapis.com.au',
|
||||
'zohoapis.jp',
|
||||
'zohoapis.ca',
|
||||
'zohoapis.sa',
|
||||
'zohoapis.com.cn',
|
||||
'zohoapis.uk',
|
||||
]
|
||||
|
||||
/** Zoho Desk REST host for the US data center, used whenever no data center is known. */
|
||||
export const DEFAULT_ZOHO_DESK_BASE = 'https://desk.zoho.com'
|
||||
|
||||
/** True only when the hostname is exactly a Zoho apex or a subdomain of one. */
|
||||
export function isZohoHost(hostname: string): boolean {
|
||||
const host = hostname.toLowerCase()
|
||||
return ZOHO_ALLOWED_APEX_DOMAINS.some((apex) => host === apex || host.endsWith(`.${apex}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the Zoho Desk REST base URL from a token response `api_domain`
|
||||
* (e.g. `https://www.zohoapis.eu` -> `https://desk.zoho.eu`). Zoho returns the
|
||||
* data-center-scoped `api_domain` on the `www.zohoapis.*` host, but the Desk
|
||||
* REST API lives on `desk.zoho.*` in the same data center. Deriving the Desk
|
||||
* base (instead of assuming `desk.zoho.com`) honors data residency.
|
||||
*
|
||||
* Shared by the OAuth token exchange, which persists the result on the
|
||||
* credential, and by the client-credentials service-account minter, which
|
||||
* returns it alongside the minted token - the two must not drift.
|
||||
*
|
||||
* @returns the derived Desk base, or `undefined` when `apiDomain` is absent,
|
||||
* unparseable, or not a Zoho host. Callers that must distinguish "Zoho told us
|
||||
* a data center" from "we fell back" use this instead of
|
||||
* {@link deriveZohoDeskBaseFromApiDomain}, so an untrusted `api_domain` can
|
||||
* never masquerade as an authoritative US answer.
|
||||
*/
|
||||
export function tryDeriveZohoDeskBaseFromApiDomain(apiDomain?: string): string | undefined {
|
||||
if (!apiDomain) return undefined
|
||||
try {
|
||||
const host = new URL(apiDomain).host.toLowerCase()
|
||||
// Gate on the strict Zoho apex allowlist before trusting the host: a loose
|
||||
// `desk.zoho.*` pattern would accept a lookalike like `desk.zoho.com.attacker.com`
|
||||
// and persist it as the credential's REST base, later leaking the OAuth token.
|
||||
if (!isZohoHost(host)) return undefined
|
||||
// Map the data-center TLD from the (now trusted) host onto the Desk REST host
|
||||
// in the same data center - works for both www.zohoapis.<tld> and desk.zoho.<tld>.
|
||||
const match = host.match(/zoho(?:apis)?\.([a-z.]+)$/)
|
||||
return match?.[1] ? `https://desk.zoho.${match[1]}` : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the Zoho Desk REST base URL from a token response `api_domain`
|
||||
* (e.g. `https://www.zohoapis.eu` -> `https://desk.zoho.eu`). Zoho returns the
|
||||
* data-center-scoped `api_domain` on the `www.zohoapis.*` host, but the Desk
|
||||
* REST API lives on `desk.zoho.*` in the same data center. Deriving the Desk
|
||||
* base (instead of assuming `desk.zoho.com`) honors data residency.
|
||||
*
|
||||
* Shared by the OAuth token exchange, which persists the result on the
|
||||
* credential, and by the client-credentials service-account minter, which
|
||||
* returns it alongside the minted token - the two must not drift.
|
||||
*
|
||||
* @returns the derived Desk base, or {@link DEFAULT_ZOHO_DESK_BASE} when
|
||||
* `apiDomain` is absent, unparseable, or not a Zoho host.
|
||||
*/
|
||||
export function deriveZohoDeskBaseFromApiDomain(apiDomain?: string): string {
|
||||
return tryDeriveZohoDeskBaseFromApiDomain(apiDomain) ?? DEFAULT_ZOHO_DESK_BASE
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker under which the OAuth token exchange persists the data-center-scoped
|
||||
* Desk REST base inside the credential's stored scope string.
|
||||
*
|
||||
* The trailing pattern stops at a comma or whitespace: better-auth persists
|
||||
* Zoho's scopes comma-joined (no spaces), so a greedy `\S+` would swallow the
|
||||
* whole scope list into the host. The Desk base URL itself never contains a
|
||||
* comma or a space.
|
||||
*/
|
||||
const ZOHO_DESK_BASE_URL_MARKER = /__zoho_domain__:([^\s,]+)/
|
||||
|
||||
/**
|
||||
* Read the persisted data-center Desk base out of an OAuth credential's scope
|
||||
* string, returning it only when it is a token-safe https Zoho host. Callers
|
||||
* that get `undefined` fall back to {@link DEFAULT_ZOHO_DESK_BASE} rather than
|
||||
* letting an unrecognized value receive the OAuth token.
|
||||
*/
|
||||
export function extractZohoDeskBaseFromScope(scope: string | null | undefined): string | undefined {
|
||||
if (typeof scope !== 'string') return undefined
|
||||
const candidate = scope.match(ZOHO_DESK_BASE_URL_MARKER)?.[1]
|
||||
if (!candidate) return undefined
|
||||
try {
|
||||
const url = new URL(candidate)
|
||||
if (url.protocol !== 'https:' || !isZohoHost(url.hostname)) return undefined
|
||||
return candidate.replace(/\/+$/, '')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a URL is a token-safe Zoho target: it must be `https:` and its host
|
||||
* must be a Zoho apex or subdomain. Returns the parsed URL, or throws (the caller
|
||||
* maps that to a 400) - used by every route that sends the OAuth token to a host
|
||||
* derived from user/LLM-influenced input.
|
||||
*/
|
||||
export function assertZohoUrl(rawUrl: string): URL {
|
||||
const url = new URL(rawUrl)
|
||||
if (url.protocol !== 'https:' || !isZohoHost(url.hostname)) {
|
||||
throw new Error('URL must be an https Zoho host')
|
||||
}
|
||||
return url
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { zohoDeskAddCommentTool } from './add_comment'
|
||||
export { zohoDeskGetAttachmentTool } from './get_attachment'
|
||||
export { zohoDeskGetContactTool } from './get_contact'
|
||||
export { zohoDeskGetThreadTool } from './get_thread'
|
||||
export { zohoDeskGetTicketTool } from './get_ticket'
|
||||
export { zohoDeskListCommentsTool } from './list_comments'
|
||||
export { zohoDeskListOrganizationsTool } from './list_organizations'
|
||||
export { zohoDeskListThreadsTool } from './list_threads'
|
||||
export { zohoDeskListTicketsTool } from './list_tickets'
|
||||
export { zohoDeskUpdateTicketTool } from './update_ticket'
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { ZohoDeskListCommentsParams, ZohoDeskResponse } from '@/tools/zoho_desk/types'
|
||||
import { ZOHO_DESK_COMMENT_PROPERTIES } from '@/tools/zoho_desk/types'
|
||||
import {
|
||||
buildZohoDeskHeaders,
|
||||
getZohoDeskApiBase,
|
||||
getZohoDeskErrorMessage,
|
||||
requireZohoDeskId,
|
||||
withDerivedContentText,
|
||||
} from '@/tools/zoho_desk/utils'
|
||||
|
||||
export const zohoDeskListCommentsTool: ToolConfig<ZohoDeskListCommentsParams, ZohoDeskResponse> = {
|
||||
id: 'zoho_desk_list_comments',
|
||||
name: 'Zoho Desk List Comments',
|
||||
description: 'List comments on a Zoho Desk ticket.',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: { required: true, provider: 'zoho-desk' },
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Zoho Desk OAuth access token',
|
||||
},
|
||||
apiDomain: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Zoho Desk data-center REST base URL',
|
||||
},
|
||||
orgId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Zoho Desk organization ID',
|
||||
},
|
||||
ticketId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Ticket ID',
|
||||
},
|
||||
from: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Pagination start index (0-based)',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Number of comments to return (1-100, default 50)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params.from !== undefined) query.set('from', String(params.from))
|
||||
if (params.limit !== undefined) query.set('limit', String(params.limit))
|
||||
const qs = query.toString()
|
||||
return `${getZohoDeskApiBase(params)}/tickets/${encodeURIComponent(requireZohoDeskId(params.ticketId, 'Ticket ID'))}/comments${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => buildZohoDeskHeaders(params),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
getZohoDeskErrorMessage(data, `Failed to list comments (HTTP ${response.status})`)
|
||||
)
|
||||
}
|
||||
const comments = (Array.isArray(data.data) ? data.data : []).map(withDerivedContentText)
|
||||
return {
|
||||
success: true,
|
||||
output: { comments, count: comments.length },
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
comments: {
|
||||
type: 'array',
|
||||
description: 'List of comments',
|
||||
items: { type: 'object', properties: ZOHO_DESK_COMMENT_PROPERTIES },
|
||||
},
|
||||
count: { type: 'number', description: 'Number of comments returned' },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { ZohoDeskListOrganizationsParams, ZohoDeskResponse } from '@/tools/zoho_desk/types'
|
||||
import { getZohoDeskApiBase, getZohoDeskErrorMessage } from '@/tools/zoho_desk/utils'
|
||||
|
||||
export const zohoDeskListOrganizationsTool: ToolConfig<
|
||||
ZohoDeskListOrganizationsParams,
|
||||
ZohoDeskResponse
|
||||
> = {
|
||||
id: 'zoho_desk_list_organizations',
|
||||
name: 'Zoho Desk List Organizations',
|
||||
description: 'List the Zoho Desk organizations (portals) the connected account can access.',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: { required: true, provider: 'zoho-desk' },
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Zoho Desk OAuth access token',
|
||||
},
|
||||
apiDomain: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Zoho Desk data-center REST base URL',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
// The organizations endpoint is the only Desk call that does not require the
|
||||
// orgId header, so it can be listed before an organization is selected.
|
||||
// No query params: Zoho documents none for /organizations and its sample is
|
||||
// a bare GET. Passing an unsupported `limit` risks a 422 on the one call
|
||||
// that bootstraps every other operation. Zoho's default page size applies.
|
||||
url: (params) => `${getZohoDeskApiBase(params)}/organizations`,
|
||||
method: 'GET',
|
||||
headers: (params) => {
|
||||
if (!params.accessToken) throw new Error('Zoho Desk access token is required')
|
||||
return {
|
||||
Authorization: `Zoho-oauthtoken ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
getZohoDeskErrorMessage(data, `Failed to list organizations (HTTP ${response.status})`)
|
||||
)
|
||||
}
|
||||
const organizations = Array.isArray(data.data) ? data.data : []
|
||||
return {
|
||||
success: true,
|
||||
output: { organizations, count: organizations.length },
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
organizations: {
|
||||
type: 'array',
|
||||
description: 'Accessible organizations',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Organization ID' },
|
||||
companyName: { type: 'string', description: 'Company name', optional: true },
|
||||
portalName: { type: 'string', description: 'Portal name', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
count: { type: 'number', description: 'Number of organizations returned' },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { ZohoDeskListThreadsParams, ZohoDeskResponse } from '@/tools/zoho_desk/types'
|
||||
import { ZOHO_DESK_THREAD_PROPERTIES } from '@/tools/zoho_desk/types'
|
||||
import {
|
||||
buildZohoDeskHeaders,
|
||||
getZohoDeskApiBase,
|
||||
getZohoDeskErrorMessage,
|
||||
requireZohoDeskId,
|
||||
withDerivedContentText,
|
||||
} from '@/tools/zoho_desk/utils'
|
||||
|
||||
export const zohoDeskListThreadsTool: ToolConfig<ZohoDeskListThreadsParams, ZohoDeskResponse> = {
|
||||
id: 'zoho_desk_list_threads',
|
||||
name: 'Zoho Desk List Threads',
|
||||
description:
|
||||
'List conversation threads on a Zoho Desk ticket, newest first (Zoho sorts by sendDateTime descending by default). Returns a list projection: message bodies (content, summary, to/cc/bcc) come back only from Get Thread.',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: { required: true, provider: 'zoho-desk' },
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Zoho Desk OAuth access token',
|
||||
},
|
||||
apiDomain: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Zoho Desk data-center REST base URL',
|
||||
},
|
||||
orgId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Zoho Desk organization ID',
|
||||
},
|
||||
ticketId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Ticket ID',
|
||||
},
|
||||
from: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Pagination start index (0-based)',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Number of threads to return (1-200, default 100)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params.from !== undefined) query.set('from', String(params.from))
|
||||
if (params.limit !== undefined) query.set('limit', String(params.limit))
|
||||
const qs = query.toString()
|
||||
return `${getZohoDeskApiBase(params)}/tickets/${encodeURIComponent(requireZohoDeskId(params.ticketId, 'Ticket ID'))}/threads${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => buildZohoDeskHeaders(params),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
getZohoDeskErrorMessage(data, `Failed to list threads (HTTP ${response.status})`)
|
||||
)
|
||||
}
|
||||
const threads = (Array.isArray(data.data) ? data.data : []).map(withDerivedContentText)
|
||||
return {
|
||||
success: true,
|
||||
output: { threads, count: threads.length },
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
threads: {
|
||||
type: 'array',
|
||||
description: 'List of threads',
|
||||
items: { type: 'object', properties: ZOHO_DESK_THREAD_PROPERTIES },
|
||||
},
|
||||
count: { type: 'number', description: 'Number of threads returned' },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { ZohoDeskListTicketsParams, ZohoDeskResponse } from '@/tools/zoho_desk/types'
|
||||
import { ZOHO_DESK_TICKET_PROPERTIES } from '@/tools/zoho_desk/types'
|
||||
import {
|
||||
buildZohoDeskHeaders,
|
||||
getZohoDeskApiBase,
|
||||
getZohoDeskErrorMessage,
|
||||
} from '@/tools/zoho_desk/utils'
|
||||
|
||||
export const zohoDeskListTicketsTool: ToolConfig<ZohoDeskListTicketsParams, ZohoDeskResponse> = {
|
||||
id: 'zoho_desk_list_tickets',
|
||||
name: 'Zoho Desk List Tickets',
|
||||
description:
|
||||
'List tickets from a Zoho Desk organization with optional filters. Returns a list projection: description, resolution, statusType and classification are only available from Get Ticket.',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: { required: true, provider: 'zoho-desk' },
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Zoho Desk OAuth access token',
|
||||
},
|
||||
apiDomain: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Zoho Desk data-center REST base URL',
|
||||
},
|
||||
orgId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Zoho Desk organization ID',
|
||||
},
|
||||
from: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Pagination start index (0-based, max 4999)',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Number of tickets to return (1-100, default 10)',
|
||||
},
|
||||
departmentIds: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter by department ID (comma-separated for multiple)',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Filter by status, including custom statuses. Comma-separate to match multiple (e.g. "Open,On Hold")',
|
||||
},
|
||||
priority: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter by priority. Comma-separate to match multiple (e.g. "High,Urgent")',
|
||||
},
|
||||
sortBy: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Sort field: createdTime, customerResponseTime, or responseDueDate. Prefix with - for descending.',
|
||||
},
|
||||
include: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Comma-separated related data to embed. Allowed: contacts, products, departments, team, isRead, assignee',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params.from !== undefined) query.set('from', String(params.from))
|
||||
if (params.limit !== undefined) query.set('limit', String(params.limit))
|
||||
// Zoho names this query param `departmentIds` (plural). A singular
|
||||
// `departmentId` is silently ignored, returning every department's tickets.
|
||||
if (params.departmentIds) query.set('departmentIds', params.departmentIds)
|
||||
if (params.status) query.set('status', params.status)
|
||||
if (params.priority) query.set('priority', params.priority)
|
||||
if (params.sortBy) query.set('sortBy', params.sortBy)
|
||||
if (params.include) query.set('include', params.include)
|
||||
const qs = query.toString()
|
||||
return `${getZohoDeskApiBase(params)}/tickets${qs ? `?${qs}` : ''}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => buildZohoDeskHeaders(params),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
getZohoDeskErrorMessage(data, `Failed to list tickets (HTTP ${response.status})`)
|
||||
)
|
||||
}
|
||||
const tickets = Array.isArray(data.data) ? data.data : []
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
tickets,
|
||||
count: tickets.length,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
tickets: {
|
||||
type: 'array',
|
||||
description: 'List of tickets',
|
||||
items: { type: 'object', properties: ZOHO_DESK_TICKET_PROPERTIES },
|
||||
},
|
||||
count: { type: 'number', description: 'Number of tickets returned' },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import type { ToolOutputProperty, ToolResponse } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* Params shared by every Zoho Desk tool. `accessToken` and `apiDomain` are
|
||||
* injected server-side from the stored OAuth credential; `orgId` identifies the
|
||||
* Zoho Desk organization (portal) and is sent as a header on every request.
|
||||
*/
|
||||
export interface ZohoDeskBaseParams {
|
||||
accessToken: string
|
||||
apiDomain?: string
|
||||
orgId: string
|
||||
}
|
||||
|
||||
export interface ZohoDeskListTicketsParams extends ZohoDeskBaseParams {
|
||||
from?: number
|
||||
limit?: number
|
||||
/** Comma-separated department IDs. Zoho names this query param plural. */
|
||||
departmentIds?: string
|
||||
status?: string
|
||||
priority?: string
|
||||
sortBy?: string
|
||||
include?: string
|
||||
}
|
||||
|
||||
export interface ZohoDeskGetTicketParams extends ZohoDeskBaseParams {
|
||||
ticketId: string
|
||||
include?: string
|
||||
}
|
||||
|
||||
export interface ZohoDeskUpdateTicketParams extends ZohoDeskBaseParams {
|
||||
ticketId: string
|
||||
subject?: string
|
||||
status?: string
|
||||
priority?: string
|
||||
assigneeId?: string
|
||||
departmentId?: string
|
||||
category?: string
|
||||
subCategory?: string
|
||||
dueDate?: string
|
||||
description?: string
|
||||
resolution?: string
|
||||
classification?: string
|
||||
customFields?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ZohoDeskListCommentsParams extends ZohoDeskBaseParams {
|
||||
ticketId: string
|
||||
from?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface ZohoDeskAddCommentParams extends ZohoDeskBaseParams {
|
||||
ticketId: string
|
||||
content: string
|
||||
contentType?: string
|
||||
isPublic?: boolean
|
||||
}
|
||||
|
||||
export interface ZohoDeskListThreadsParams extends ZohoDeskBaseParams {
|
||||
ticketId: string
|
||||
from?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface ZohoDeskGetThreadParams extends ZohoDeskBaseParams {
|
||||
ticketId: string
|
||||
threadId: string
|
||||
}
|
||||
|
||||
export interface ZohoDeskGetContactParams extends ZohoDeskBaseParams {
|
||||
contactId: string
|
||||
}
|
||||
|
||||
export type ZohoDeskListOrganizationsParams = Pick<ZohoDeskBaseParams, 'accessToken' | 'apiDomain'>
|
||||
|
||||
export interface ZohoDeskGetAttachmentParams extends ZohoDeskBaseParams {
|
||||
href: string
|
||||
fileName?: string
|
||||
}
|
||||
|
||||
export interface ZohoDeskResponse extends ToolResponse {
|
||||
output: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Attachment metadata surfaced on comment/thread outputs (not the file bytes). */
|
||||
export const ZOHO_DESK_ATTACHMENT_PROPERTIES: Record<string, ToolOutputProperty> = {
|
||||
id: { type: 'string', description: 'Attachment ID' },
|
||||
name: { type: 'string', description: 'File name', optional: true },
|
||||
// Zoho documents this as KB, but serializes it as a string and its samples are
|
||||
// not self-consistent about the unit — report it as Zoho gives it rather than
|
||||
// asserting a unit we would be guessing at.
|
||||
size: { type: 'string', description: 'File size as reported by Zoho', optional: true },
|
||||
href: { type: 'string', description: 'Download href', optional: true },
|
||||
}
|
||||
|
||||
export const ZOHO_DESK_TICKET_PROPERTIES: Record<string, ToolOutputProperty> = {
|
||||
id: { type: 'string', description: 'Ticket ID' },
|
||||
ticketNumber: { type: 'string', description: 'Human-readable ticket number', optional: true },
|
||||
subject: { type: 'string', description: 'Ticket subject', optional: true },
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'Ticket description (raw; may be HTML)',
|
||||
optional: true,
|
||||
nullable: true,
|
||||
},
|
||||
descriptionText: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Plain-text rendering of the description: HTML stripped when the body contains markup, otherwise the description verbatim',
|
||||
optional: true,
|
||||
nullable: true,
|
||||
},
|
||||
status: { type: 'string', description: 'Ticket status', optional: true },
|
||||
statusType: {
|
||||
type: 'string',
|
||||
description: 'Status category (Open/Closed/On Hold)',
|
||||
optional: true,
|
||||
},
|
||||
priority: { type: 'string', description: 'Ticket priority', optional: true, nullable: true },
|
||||
category: { type: 'string', description: 'Ticket category', optional: true, nullable: true },
|
||||
subCategory: {
|
||||
type: 'string',
|
||||
description: 'Ticket sub-category',
|
||||
optional: true,
|
||||
nullable: true,
|
||||
},
|
||||
classification: {
|
||||
type: 'string',
|
||||
description: 'Ticket classification',
|
||||
optional: true,
|
||||
nullable: true,
|
||||
},
|
||||
channel: { type: 'string', description: 'Origin channel', optional: true },
|
||||
departmentId: { type: 'string', description: 'Department ID', optional: true },
|
||||
contactId: { type: 'string', description: 'Contact ID', optional: true, nullable: true },
|
||||
accountId: { type: 'string', description: 'Account ID', optional: true, nullable: true },
|
||||
assigneeId: { type: 'string', description: 'Assignee ID', optional: true, nullable: true },
|
||||
email: { type: 'string', description: 'Contact email', optional: true, nullable: true },
|
||||
phone: { type: 'string', description: 'Contact phone', optional: true, nullable: true },
|
||||
dueDate: { type: 'string', description: 'Due date', optional: true, nullable: true },
|
||||
responseDueDate: {
|
||||
type: 'string',
|
||||
description: 'Response due date',
|
||||
optional: true,
|
||||
nullable: true,
|
||||
},
|
||||
createdTime: { type: 'string', description: 'Created timestamp', optional: true },
|
||||
modifiedTime: { type: 'string', description: 'Last modified timestamp', optional: true },
|
||||
closedTime: { type: 'string', description: 'Closed timestamp', optional: true, nullable: true },
|
||||
resolution: { type: 'string', description: 'Resolution text', optional: true, nullable: true },
|
||||
threadCount: { type: 'string', description: 'Number of threads', optional: true },
|
||||
commentCount: { type: 'string', description: 'Number of comments', optional: true },
|
||||
webUrl: { type: 'string', description: 'Web URL to the ticket', optional: true },
|
||||
isEscalated: { type: 'boolean', description: 'Whether the ticket is escalated', optional: true },
|
||||
isOverDue: { type: 'boolean', description: 'Whether the ticket is overdue', optional: true },
|
||||
isSpam: { type: 'boolean', description: 'Whether the ticket is marked spam', optional: true },
|
||||
cf: {
|
||||
type: 'json',
|
||||
description: 'Custom field values, keyed by custom field API name',
|
||||
optional: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const ZOHO_DESK_COMMENT_PROPERTIES: Record<string, ToolOutputProperty> = {
|
||||
id: { type: 'string', description: 'Comment ID' },
|
||||
content: { type: 'string', description: 'Comment content (raw; may be HTML)', optional: true },
|
||||
contentType: { type: 'string', description: 'Content type (plainText/html)', optional: true },
|
||||
contentText: {
|
||||
type: 'string',
|
||||
description: 'Plain-text rendering of content (HTML stripped when contentType is html)',
|
||||
optional: true,
|
||||
},
|
||||
isPublic: { type: 'boolean', description: 'Whether the comment is public', optional: true },
|
||||
commenterId: { type: 'string', description: 'Commenter ID', optional: true },
|
||||
commenter: {
|
||||
type: 'object',
|
||||
description: 'Who wrote the comment',
|
||||
optional: true,
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Display name', optional: true },
|
||||
firstName: { type: 'string', description: 'First name', optional: true },
|
||||
lastName: { type: 'string', description: 'Last name', optional: true },
|
||||
email: { type: 'string', description: 'Email address', optional: true },
|
||||
type: { type: 'string', description: 'Commenter type (AGENT/END_USER)', optional: true },
|
||||
roleName: { type: 'string', description: 'Role name', optional: true },
|
||||
photoURL: { type: 'string', description: 'Avatar URL', optional: true, nullable: true },
|
||||
},
|
||||
},
|
||||
commentedTime: { type: 'string', description: 'Commented timestamp', optional: true },
|
||||
modifiedTime: {
|
||||
type: 'string',
|
||||
description: 'Modified timestamp',
|
||||
optional: true,
|
||||
nullable: true,
|
||||
},
|
||||
attachments: {
|
||||
type: 'array',
|
||||
description: 'Comment attachments',
|
||||
optional: true,
|
||||
items: { type: 'object', properties: ZOHO_DESK_ATTACHMENT_PROPERTIES },
|
||||
},
|
||||
}
|
||||
|
||||
export const ZOHO_DESK_THREAD_PROPERTIES: Record<string, ToolOutputProperty> = {
|
||||
id: { type: 'string', description: 'Thread ID' },
|
||||
channel: { type: 'string', description: 'Thread channel', optional: true },
|
||||
direction: { type: 'string', description: 'Direction (in/out)', optional: true },
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'Thread content (raw; may be HTML)',
|
||||
optional: true,
|
||||
nullable: true,
|
||||
},
|
||||
contentType: { type: 'string', description: 'Content type', optional: true },
|
||||
contentText: {
|
||||
type: 'string',
|
||||
description: 'Plain-text rendering of content (HTML stripped when contentType is html)',
|
||||
optional: true,
|
||||
nullable: true,
|
||||
},
|
||||
summary: { type: 'string', description: 'Thread summary', optional: true, nullable: true },
|
||||
responderId: { type: 'string', description: 'Responder ID', optional: true, nullable: true },
|
||||
createdTime: { type: 'string', description: 'Created timestamp', optional: true },
|
||||
hasAttach: { type: 'boolean', description: 'Whether the thread has attachments', optional: true },
|
||||
attachmentCount: { type: 'string', description: 'Number of attachments', optional: true },
|
||||
fromEmailAddress: {
|
||||
type: 'string',
|
||||
description: 'From email address',
|
||||
optional: true,
|
||||
nullable: true,
|
||||
},
|
||||
to: { type: 'string', description: 'To email address', optional: true, nullable: true },
|
||||
cc: { type: 'string', description: 'CC email address', optional: true, nullable: true },
|
||||
bcc: { type: 'string', description: 'BCC email address', optional: true, nullable: true },
|
||||
replyTo: {
|
||||
type: 'string',
|
||||
description: 'Reply-to email address',
|
||||
optional: true,
|
||||
nullable: true,
|
||||
},
|
||||
isForward: { type: 'boolean', description: 'Whether the thread is a forward', optional: true },
|
||||
isContentTruncated: {
|
||||
type: 'boolean',
|
||||
description: 'Whether Zoho truncated the thread content; fetch fullContentURL for the rest',
|
||||
optional: true,
|
||||
},
|
||||
fullContentURL: {
|
||||
type: 'string',
|
||||
description: 'URL returning the untruncated thread content',
|
||||
optional: true,
|
||||
nullable: true,
|
||||
},
|
||||
plainText: {
|
||||
type: 'string',
|
||||
description: "Zoho's own plain-text rendering of the thread, when it supplies one",
|
||||
optional: true,
|
||||
nullable: true,
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
description: 'Delivery status of an outgoing thread (SUCCESS/FAILED/DRAFT)',
|
||||
optional: true,
|
||||
},
|
||||
isDescriptionThread: {
|
||||
type: 'boolean',
|
||||
description: "Whether this thread is the ticket's original description",
|
||||
optional: true,
|
||||
},
|
||||
visibility: { type: 'string', description: 'Thread visibility (e.g. public)', optional: true },
|
||||
canReply: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the thread can be replied to',
|
||||
optional: true,
|
||||
},
|
||||
author: {
|
||||
type: 'object',
|
||||
description: 'Who sent the thread',
|
||||
optional: true,
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Display name', optional: true },
|
||||
firstName: { type: 'string', description: 'First name', optional: true },
|
||||
lastName: { type: 'string', description: 'Last name', optional: true },
|
||||
email: { type: 'string', description: 'Email address', optional: true },
|
||||
type: { type: 'string', description: 'Author type (AGENT/END_USER)', optional: true },
|
||||
photoURL: { type: 'string', description: 'Avatar URL', optional: true, nullable: true },
|
||||
},
|
||||
},
|
||||
attachments: {
|
||||
type: 'array',
|
||||
description: 'Thread attachments',
|
||||
optional: true,
|
||||
items: { type: 'object', properties: ZOHO_DESK_ATTACHMENT_PROPERTIES },
|
||||
},
|
||||
}
|
||||
|
||||
export const ZOHO_DESK_CONTACT_PROPERTIES: Record<string, ToolOutputProperty> = {
|
||||
id: { type: 'string', description: 'Contact ID' },
|
||||
firstName: { type: 'string', description: 'First name', optional: true, nullable: true },
|
||||
lastName: { type: 'string', description: 'Last name', optional: true },
|
||||
email: { type: 'string', description: 'Primary email', optional: true, nullable: true },
|
||||
secondaryEmail: {
|
||||
type: 'string',
|
||||
description: 'Secondary email',
|
||||
optional: true,
|
||||
nullable: true,
|
||||
},
|
||||
phone: { type: 'string', description: 'Phone number', optional: true, nullable: true },
|
||||
mobile: { type: 'string', description: 'Mobile number', optional: true, nullable: true },
|
||||
accountId: {
|
||||
type: 'string',
|
||||
description: 'Associated account ID',
|
||||
optional: true,
|
||||
nullable: true,
|
||||
},
|
||||
ownerId: { type: 'string', description: 'Owner ID', optional: true, nullable: true },
|
||||
type: { type: 'string', description: 'Contact type', optional: true, nullable: true },
|
||||
title: { type: 'string', description: 'Job title', optional: true, nullable: true },
|
||||
street: { type: 'string', description: 'Street', optional: true, nullable: true },
|
||||
city: { type: 'string', description: 'City', optional: true, nullable: true },
|
||||
state: { type: 'string', description: 'State', optional: true, nullable: true },
|
||||
country: { type: 'string', description: 'Country', optional: true, nullable: true },
|
||||
zip: { type: 'string', description: 'ZIP / postal code', optional: true, nullable: true },
|
||||
description: { type: 'string', description: 'Description', optional: true, nullable: true },
|
||||
cf: {
|
||||
type: 'json',
|
||||
description: 'Custom field values, keyed by custom field API name',
|
||||
optional: true,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { zohoDeskUpdateTicketTool } from '@/tools/zoho_desk/update_ticket'
|
||||
|
||||
describe('zohoDeskUpdateTicketTool request body', () => {
|
||||
const base = { accessToken: 'tok', orgId: '700', ticketId: '123' }
|
||||
const buildBody = zohoDeskUpdateTicketTool.request.body as (p: Record<string, unknown>) => unknown
|
||||
|
||||
it('throws when no updatable fields are provided', () => {
|
||||
expect(() => buildBody(base)).toThrow(/no fields to update/i)
|
||||
})
|
||||
|
||||
it('builds a body containing only the provided fields', () => {
|
||||
expect(buildBody({ ...base, status: 'Closed' })).toEqual({ status: 'Closed' })
|
||||
expect(buildBody({ ...base, priority: 'High', subject: 'Hi' })).toEqual({
|
||||
priority: 'High',
|
||||
subject: 'Hi',
|
||||
})
|
||||
})
|
||||
|
||||
// The workflow serializer initializes every untouched subBlock to `null` (not
|
||||
// `undefined`) and writes those nulls into tool params, so this - not the
|
||||
// fields-absent case above - is the shape the block actually produces. A
|
||||
// status-only edit must not post `subject: null` and blank the ticket.
|
||||
it('drops untouched fields that arrive as null from the serializer', () => {
|
||||
const serializedParams = {
|
||||
...base,
|
||||
subject: null,
|
||||
status: 'Closed',
|
||||
priority: null,
|
||||
assigneeId: null,
|
||||
departmentId: null,
|
||||
category: null,
|
||||
subCategory: null,
|
||||
dueDate: null,
|
||||
description: null,
|
||||
resolution: null,
|
||||
classification: null,
|
||||
customFields: null,
|
||||
}
|
||||
expect(buildBody(serializedParams)).toEqual({ status: 'Closed' })
|
||||
})
|
||||
|
||||
// Zoho documents "" as its clear-a-field idiom (its own PATCH sample carries
|
||||
// `"classification": ""`), so an emptied box must reach the API rather than be
|
||||
// collapsed into "leave unchanged" - otherwise no scalar field is clearable.
|
||||
it('forwards an empty string so a field can be cleared', () => {
|
||||
expect(buildBody({ ...base, status: 'Closed', classification: '' })).toEqual({
|
||||
status: 'Closed',
|
||||
classification: '',
|
||||
})
|
||||
})
|
||||
|
||||
// The empty-PATCH guard must be reachable from the real serializer shape, not
|
||||
// only from the synthetic fields-absent case.
|
||||
it('throws when every updatable field is null', () => {
|
||||
expect(() => buildBody({ ...base, subject: null, status: null, priority: null })).toThrow(
|
||||
/no fields to update/i
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { ZohoDeskResponse, ZohoDeskUpdateTicketParams } from '@/tools/zoho_desk/types'
|
||||
import { ZOHO_DESK_TICKET_PROPERTIES } from '@/tools/zoho_desk/types'
|
||||
import {
|
||||
buildZohoDeskHeaders,
|
||||
getZohoDeskApiBase,
|
||||
getZohoDeskErrorMessage,
|
||||
requireZohoDeskId,
|
||||
withDerivedContentText,
|
||||
} from '@/tools/zoho_desk/utils'
|
||||
|
||||
/**
|
||||
* Drop keys the caller did not set, so a PATCH only carries real edits.
|
||||
*
|
||||
* Deliberately NOT `filterUndefined`: that helper strips `undefined` only, but an
|
||||
* untouched subBlock does not arrive as `undefined` - the workflow serializer
|
||||
* initializes every subBlock value to `null` and writes those nulls straight into
|
||||
* tool params. A status-only update therefore reached Zoho as
|
||||
* `{"subject": null, "status": "Closed"}`, which either fails the PATCH or blanks
|
||||
* the ticket's subject.
|
||||
*
|
||||
* An empty string is NOT treated as unset. Zoho documents `""` as its idiom for
|
||||
* clearing a field - its own PATCH sample carries `"classification": ""` and
|
||||
* `"productId": ""` - so collapsing `''` into "leave unchanged" would make
|
||||
* clearing `classification`, `category`, `subCategory`, `resolution` or
|
||||
* `description` impossible through this tool. `null` (never touched) and `''`
|
||||
* (deliberately emptied) are different intents and are kept distinct.
|
||||
*/
|
||||
function omitUnset(fields: Record<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (value === undefined || value === null) continue
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export const zohoDeskUpdateTicketTool: ToolConfig<ZohoDeskUpdateTicketParams, ZohoDeskResponse> = {
|
||||
id: 'zoho_desk_update_ticket',
|
||||
name: 'Zoho Desk Update Ticket',
|
||||
description: 'Update fields on an existing Zoho Desk ticket.',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: { required: true, provider: 'zoho-desk' },
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'Zoho Desk OAuth access token',
|
||||
},
|
||||
apiDomain: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description: 'Zoho Desk data-center REST base URL',
|
||||
},
|
||||
orgId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Zoho Desk organization ID',
|
||||
},
|
||||
ticketId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Ticket ID to update',
|
||||
},
|
||||
subject: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Ticket subject',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Ticket status (e.g. Open, Closed)',
|
||||
},
|
||||
priority: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Ticket priority (e.g. High)',
|
||||
},
|
||||
assigneeId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Assignee (agent) ID',
|
||||
},
|
||||
departmentId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Department ID',
|
||||
},
|
||||
category: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Ticket category',
|
||||
},
|
||||
subCategory: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Ticket sub-category',
|
||||
},
|
||||
dueDate: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Due date (ISO 8601)',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Ticket description',
|
||||
},
|
||||
resolution: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Resolution notes recorded on the ticket',
|
||||
},
|
||||
classification: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Ticket classification: Problem, Request, Question, or Others',
|
||||
},
|
||||
customFields: {
|
||||
type: 'json',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Custom field values as a JSON object, keyed by custom field API name',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) =>
|
||||
`${getZohoDeskApiBase(params)}/tickets/${encodeURIComponent(requireZohoDeskId(params.ticketId, 'Ticket ID'))}`,
|
||||
method: 'PATCH',
|
||||
headers: (params) => buildZohoDeskHeaders(params),
|
||||
body: (params) => {
|
||||
const body = omitUnset({
|
||||
subject: params.subject,
|
||||
status: params.status,
|
||||
priority: params.priority,
|
||||
assigneeId: params.assigneeId,
|
||||
departmentId: params.departmentId,
|
||||
category: params.category,
|
||||
subCategory: params.subCategory,
|
||||
dueDate: params.dueDate,
|
||||
description: params.description,
|
||||
resolution: params.resolution,
|
||||
classification: params.classification,
|
||||
// Zoho's ticket PATCH names the custom-field object `cf`. `customFields`
|
||||
// exists only as a deprecated alias on other Desk resources (e.g. events)
|
||||
// and on the separate validate-field-updates endpoint - sending it here
|
||||
// is silently ignored, so the update reports success without applying.
|
||||
cf: params.customFields,
|
||||
})
|
||||
// Zoho rejects an empty PATCH; fail early with an actionable message
|
||||
// instead of surfacing an opaque Zoho error for a no-op update.
|
||||
if (Object.keys(body).length === 0) {
|
||||
throw new Error(
|
||||
'No fields to update. Provide at least one field to change (e.g. status, priority, or subject).'
|
||||
)
|
||||
}
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
getZohoDeskErrorMessage(data, `Failed to update ticket (HTTP ${response.status})`)
|
||||
)
|
||||
}
|
||||
// The PATCH response is the updated ticket, so derive `descriptionText` the
|
||||
// same way get_ticket does - the shared output map declares it.
|
||||
return {
|
||||
success: true,
|
||||
output: { ticket: withDerivedContentText(data) },
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
ticket: {
|
||||
type: 'object',
|
||||
description: 'The updated ticket',
|
||||
properties: ZOHO_DESK_TICKET_PROPERTIES,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assertZohoUrl, isZohoHost } from '@/tools/zoho_desk/host-allowlist'
|
||||
import {
|
||||
buildZohoDeskHeaders,
|
||||
convertZohoHtmlToText,
|
||||
deriveAttachmentName,
|
||||
deriveZohoContentText,
|
||||
getZohoDeskApiBase,
|
||||
getZohoDeskErrorMessage,
|
||||
resolveZohoAttachmentUrl,
|
||||
withDerivedContentText,
|
||||
} from '@/tools/zoho_desk/utils'
|
||||
|
||||
describe('zoho desk tool utils', () => {
|
||||
describe('getZohoDeskApiBase', () => {
|
||||
it('uses the persisted data-center Desk base', () => {
|
||||
expect(getZohoDeskApiBase({ apiDomain: 'https://desk.zoho.eu' })).toBe(
|
||||
'https://desk.zoho.eu/api/v1'
|
||||
)
|
||||
})
|
||||
|
||||
it('strips trailing slashes', () => {
|
||||
expect(getZohoDeskApiBase({ apiDomain: 'https://desk.zoho.in/' })).toBe(
|
||||
'https://desk.zoho.in/api/v1'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the US host when no api domain is provided', () => {
|
||||
expect(getZohoDeskApiBase({})).toBe('https://desk.zoho.com/api/v1')
|
||||
})
|
||||
|
||||
// apiDomain reaches this helper from injected context, so a lookalike or
|
||||
// plaintext host must never receive the OAuth token.
|
||||
it('falls back to the US base for non-Zoho, lookalike, or non-https hosts', () => {
|
||||
expect(getZohoDeskApiBase({ apiDomain: 'https://desk.zoho.com.attacker.com' })).toBe(
|
||||
'https://desk.zoho.com/api/v1'
|
||||
)
|
||||
expect(getZohoDeskApiBase({ apiDomain: 'https://attacker.com' })).toBe(
|
||||
'https://desk.zoho.com/api/v1'
|
||||
)
|
||||
expect(getZohoDeskApiBase({ apiDomain: 'http://desk.zoho.eu' })).toBe(
|
||||
'https://desk.zoho.com/api/v1'
|
||||
)
|
||||
expect(getZohoDeskApiBase({ apiDomain: 'not-a-url' })).toBe('https://desk.zoho.com/api/v1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildZohoDeskHeaders', () => {
|
||||
it('builds Zoho-oauthtoken auth + orgId headers', () => {
|
||||
const headers = buildZohoDeskHeaders({ accessToken: 'abc', orgId: '700123' })
|
||||
expect(headers.Authorization).toBe('Zoho-oauthtoken abc')
|
||||
expect(headers.orgId).toBe('700123')
|
||||
expect(headers['Content-Type']).toBe('application/json')
|
||||
})
|
||||
|
||||
it('throws when the access token is missing', () => {
|
||||
expect(() => buildZohoDeskHeaders({ accessToken: '', orgId: '1' })).toThrow(/access token/i)
|
||||
})
|
||||
|
||||
it('throws when the orgId is missing', () => {
|
||||
expect(() => buildZohoDeskHeaders({ accessToken: 'x', orgId: '' })).toThrow(/organization/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getZohoDeskErrorMessage', () => {
|
||||
it('prefers the message field', () => {
|
||||
expect(getZohoDeskErrorMessage({ message: 'Bad request' }, 'fallback')).toBe('Bad request')
|
||||
})
|
||||
|
||||
it('falls back to errorCode then the fallback', () => {
|
||||
expect(getZohoDeskErrorMessage({ errorCode: 'INVALID_DATA' }, 'fallback')).toBe(
|
||||
'INVALID_DATA'
|
||||
)
|
||||
expect(getZohoDeskErrorMessage(null, 'fallback')).toBe('fallback')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveAttachmentName', () => {
|
||||
it('prefers an explicit file name', () => {
|
||||
expect(
|
||||
deriveAttachmentName('mine.pdf', 'attachment; filename="other.pdf"', '/a/b/content')
|
||||
).toBe('mine.pdf')
|
||||
})
|
||||
|
||||
it('uses the Content-Disposition filename', () => {
|
||||
expect(
|
||||
deriveAttachmentName(null, 'attachment; filename="report.pdf"', '/tickets/1/attachments/2')
|
||||
).toBe('report.pdf')
|
||||
})
|
||||
|
||||
it('decodes an RFC 5987 (UTF-8) Content-Disposition filename', () => {
|
||||
expect(
|
||||
deriveAttachmentName(null, "attachment; filename*=UTF-8''r%C3%A9sum%C3%A9.pdf", '/x')
|
||||
).toBe('résumé.pdf')
|
||||
})
|
||||
|
||||
it('falls back to a URL segment that looks like a file name', () => {
|
||||
expect(deriveAttachmentName(null, null, '/files/photo.png')).toBe('photo.png')
|
||||
})
|
||||
|
||||
it('ignores a generic /content endpoint and returns a plain fallback', () => {
|
||||
expect(deriveAttachmentName(null, null, '/tickets/1/attachments/2/content')).toBe(
|
||||
'attachment'
|
||||
)
|
||||
expect(deriveAttachmentName('', '', '/tickets/1/attachments/2')).toBe('attachment')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isZohoHost', () => {
|
||||
it('accepts Zoho apex hosts and their subdomains across data centers', () => {
|
||||
expect(isZohoHost('desk.zoho.com')).toBe(true)
|
||||
expect(isZohoHost('desk.zoho.eu')).toBe(true)
|
||||
expect(isZohoHost('zohoapis.com.au')).toBe(true)
|
||||
expect(isZohoHost('DESK.ZOHO.IN')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects lookalike and attacker hosts', () => {
|
||||
expect(isZohoHost('zoho.attacker.com')).toBe(false)
|
||||
expect(isZohoHost('desk.zoho.com.attacker.com')).toBe(false)
|
||||
expect(isZohoHost('notzoho.com')).toBe(false)
|
||||
expect(isZohoHost('evil.com')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertZohoUrl', () => {
|
||||
it('returns the URL for an https Zoho host', () => {
|
||||
expect(assertZohoUrl('https://desk.zoho.eu/api/v1/organizations').host).toBe('desk.zoho.eu')
|
||||
})
|
||||
|
||||
it('throws for a non-Zoho host or non-https scheme', () => {
|
||||
expect(() => assertZohoUrl('https://attacker.com/api/v1/organizations')).toThrow()
|
||||
expect(() => assertZohoUrl('http://desk.zoho.com/api/v1/organizations')).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveZohoAttachmentUrl', () => {
|
||||
const apiBase = 'https://desk.zoho.com/api/v1'
|
||||
|
||||
it('uses an absolute http(s) href as-is', () => {
|
||||
expect(
|
||||
resolveZohoAttachmentUrl('https://desk.zoho.eu/api/v1/tickets/1/x/content', apiBase).href
|
||||
).toBe('https://desk.zoho.eu/api/v1/tickets/1/x/content')
|
||||
})
|
||||
|
||||
it('does not duplicate /api/v1 when the relative href already includes it', () => {
|
||||
expect(
|
||||
resolveZohoAttachmentUrl('/api/v1/tickets/1/attachments/2/content', apiBase).href
|
||||
).toBe('https://desk.zoho.com/api/v1/tickets/1/attachments/2/content')
|
||||
expect(resolveZohoAttachmentUrl('api/v1/tickets/1/attachments/2/content', apiBase).href).toBe(
|
||||
'https://desk.zoho.com/api/v1/tickets/1/attachments/2/content'
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves a relative href without an api/v1 prefix against the api base', () => {
|
||||
expect(resolveZohoAttachmentUrl('/tickets/1/x/content', apiBase).href).toBe(
|
||||
'https://desk.zoho.com/api/v1/tickets/1/x/content'
|
||||
)
|
||||
expect(resolveZohoAttachmentUrl('tickets/1/x/content', apiBase).href).toBe(
|
||||
'https://desk.zoho.com/api/v1/tickets/1/x/content'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertZohoHtmlToText', () => {
|
||||
it('strips HTML tags to readable plain text', () => {
|
||||
const html = '<div style="direction: ltr; font-size: 13px;"><div>testing</div></div>'
|
||||
expect(convertZohoHtmlToText(html)).toBe('testing')
|
||||
})
|
||||
|
||||
it('returns an empty string for empty input', () => {
|
||||
expect(convertZohoHtmlToText('')).toBe('')
|
||||
})
|
||||
|
||||
it('keeps anchor text and drops image subtrees', () => {
|
||||
const html =
|
||||
'<p>See <a href="https://x.test">the docs</a></p><img src="https://x.test/a.png">'
|
||||
const text = convertZohoHtmlToText(html)
|
||||
expect(text).toContain('See the docs')
|
||||
expect(text).not.toContain('a.png')
|
||||
})
|
||||
|
||||
it('hides an anchor href that equals its link text', () => {
|
||||
expect(convertZohoHtmlToText('<a href="https://x.test">https://x.test</a>')).toBe(
|
||||
'https://x.test'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveZohoContentText', () => {
|
||||
it('strips HTML when contentType is html', () => {
|
||||
expect(deriveZohoContentText('<b>hi</b>', 'html')).toBe('hi')
|
||||
})
|
||||
|
||||
it('returns plainText content unchanged', () => {
|
||||
expect(deriveZohoContentText('<b>literal</b>', 'plainText')).toBe('<b>literal</b>')
|
||||
})
|
||||
|
||||
it('mirrors content when contentType is unrecognized or absent', () => {
|
||||
expect(deriveZohoContentText('raw', undefined)).toBe('raw')
|
||||
expect(deriveZohoContentText('raw', 'other')).toBe('raw')
|
||||
})
|
||||
|
||||
it('returns undefined when content is not a string', () => {
|
||||
expect(deriveZohoContentText(null, 'html')).toBeUndefined()
|
||||
expect(deriveZohoContentText(undefined, 'plainText')).toBeUndefined()
|
||||
})
|
||||
|
||||
// Zoho spells the HTML discriminator differently per resource: comments come
|
||||
// back as `html`, threads as the MIME form `text/html`. A strict `=== 'html'`
|
||||
// check silently passed thread markup through as "plain text".
|
||||
it('strips HTML for the text/html spelling Zoho uses on threads', () => {
|
||||
expect(deriveZohoContentText('<b>hi</b>', 'text/html')).toBe('hi')
|
||||
})
|
||||
|
||||
it('strips HTML for a parameterized or differently-cased content type', () => {
|
||||
expect(deriveZohoContentText('<b>hi</b>', 'text/html; charset=UTF-8')).toBe('hi')
|
||||
expect(deriveZohoContentText('<b>hi</b>', 'TEXT/HTML')).toBe('hi')
|
||||
expect(deriveZohoContentText('<b>hi</b>', 'HTML')).toBe('hi')
|
||||
})
|
||||
})
|
||||
|
||||
describe('withDerivedContentText', () => {
|
||||
it('adds a stripped contentText alongside the untouched raw HTML content', () => {
|
||||
const comment = { id: '1', content: '<div>testing</div>', contentType: 'html' }
|
||||
const result = withDerivedContentText(comment) as Record<string, unknown>
|
||||
expect(result.content).toBe('<div>testing</div>')
|
||||
expect(result.contentType).toBe('html')
|
||||
expect(result.contentText).toBe('testing')
|
||||
})
|
||||
|
||||
it('mirrors content into contentText for plainText resources', () => {
|
||||
const comment = { id: '2', content: 'plain note', contentType: 'plainText' }
|
||||
const result = withDerivedContentText(comment) as Record<string, unknown>
|
||||
expect(result.contentText).toBe('plain note')
|
||||
expect(result.content).toBe('plain note')
|
||||
})
|
||||
|
||||
it('leaves resources without string content unchanged (no contentText key)', () => {
|
||||
const thread = { id: '3', content: null, contentType: 'html' }
|
||||
const result = withDerivedContentText(thread) as Record<string, unknown>
|
||||
expect('contentText' in result).toBe(false)
|
||||
const ticketPayload = { id: '4', subject: 'no content pair' }
|
||||
expect(withDerivedContentText(ticketPayload)).toEqual(ticketPayload)
|
||||
})
|
||||
|
||||
it('passes through non-object values', () => {
|
||||
expect(withDerivedContentText(null)).toBeNull()
|
||||
expect(withDerivedContentText('str')).toBe('str')
|
||||
})
|
||||
|
||||
// Zoho ships ticket descriptions as HTML with NO content-type key — this is
|
||||
// the exact shape from Zoho's own Ticket_Add webhook sample. Gating the strip
|
||||
// on a `descriptionContentType` that never arrives made descriptionText a
|
||||
// byte-identical copy of the markup, so this case must stay unfabricated: no
|
||||
// descriptionContentType key anywhere in the fixture.
|
||||
it('strips HTML from a ticket description that carries no content-type key', () => {
|
||||
const ticket = { id: '5', subject: 'Delay', description: '<div>order is late</div>' }
|
||||
const result = withDerivedContentText(ticket) as Record<string, unknown>
|
||||
expect(result.description).toBe('<div>order is late</div>')
|
||||
expect(result.descriptionText).toBe('order is late')
|
||||
expect('contentText' in result).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves a plain-text ticket description readable', () => {
|
||||
const result = withDerivedContentText({
|
||||
id: '7',
|
||||
description: 'order is late',
|
||||
}) as Record<string, unknown>
|
||||
expect(result.descriptionText).toBe('order is late')
|
||||
})
|
||||
|
||||
// Zoho ships BOTH shapes on `description` with no discriminator: HTML on the
|
||||
// webhook payload, plain text in the REST samples. Converting unconditionally
|
||||
// decodes entities and deletes tag-shaped text that was never markup, so a
|
||||
// plain body must pass through byte-for-byte.
|
||||
it('does not mangle plain text that merely looks tag-ish or has entities', () => {
|
||||
const cases = [
|
||||
'a < b > c',
|
||||
'compare a&b; then ship',
|
||||
'use the <not a tag notation',
|
||||
'SELECT * FROM t WHERE x < 5 AND y > 2',
|
||||
]
|
||||
for (const description of cases) {
|
||||
const result = withDerivedContentText({ description }) as Record<string, unknown>
|
||||
expect(result.descriptionText).toBe(description)
|
||||
}
|
||||
})
|
||||
|
||||
// A bare angle-bracket pair is not markup. These are realistic support-ticket
|
||||
// bodies, and running them through html-to-text deletes everything between
|
||||
// the brackets ("if x<y then z>0" became "if x0").
|
||||
it('preserves plain text containing angle-bracket pairs', () => {
|
||||
const cases = [
|
||||
'if x<y then z>0',
|
||||
'replace <username> with the real name',
|
||||
'SELECT * FROM t WHERE a<b AND c>d',
|
||||
]
|
||||
for (const description of cases) {
|
||||
const result = withDerivedContentText({ description }) as Record<string, unknown>
|
||||
expect(result.descriptionText).toBe(description)
|
||||
}
|
||||
})
|
||||
|
||||
it('strips hex-entity encoded bodies too', () => {
|
||||
const result = withDerivedContentText({
|
||||
description: 'Sam's order failed',
|
||||
}) as Record<string, unknown>
|
||||
expect(result.descriptionText).toBe("Sam's order failed")
|
||||
})
|
||||
|
||||
it('still strips a genuinely HTML description', () => {
|
||||
const result = withDerivedContentText({
|
||||
description: '<div>order is <b>late</b></div>',
|
||||
}) as Record<string, unknown>
|
||||
expect(result.descriptionText).toBe('order is late')
|
||||
})
|
||||
|
||||
it('still honors an explicit descriptionContentType if Zoho ever sends one', () => {
|
||||
const result = withDerivedContentText({
|
||||
description: '<b>literal</b>',
|
||||
descriptionContentType: 'plainText',
|
||||
}) as Record<string, unknown>
|
||||
expect(result.descriptionText).toBe('<b>literal</b>')
|
||||
})
|
||||
|
||||
it('derives both fields when a resource carries content and description', () => {
|
||||
const result = withDerivedContentText({
|
||||
content: '<b>c</b>',
|
||||
contentType: 'text/html',
|
||||
description: '<b>d</b>',
|
||||
}) as Record<string, unknown>
|
||||
expect(result.contentText).toBe('c')
|
||||
expect(result.descriptionText).toBe('d')
|
||||
})
|
||||
|
||||
it('omits descriptionText when there is no string description', () => {
|
||||
const result = withDerivedContentText({ id: '6', description: null }) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
expect('descriptionText' in result).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,216 @@
|
||||
import { htmlToText } from 'html-to-text'
|
||||
import { DEFAULT_ZOHO_DESK_BASE, isZohoHost } from '@/tools/zoho_desk/host-allowlist'
|
||||
import type { ZohoDeskBaseParams } from '@/tools/zoho_desk/types'
|
||||
|
||||
/**
|
||||
* Convert Zoho Desk rich-text HTML (comment / thread / ticket bodies) to
|
||||
* readable plain text. Mirrors the per-integration `html-to-text` configuration
|
||||
* used elsewhere in the codebase (Outlook, Gmail, Confluence): anchors collapse
|
||||
* to their text, and img / script / style subtrees are dropped. Configured
|
||||
* locally because each integration's markup differs; there is no shared helper.
|
||||
*/
|
||||
export function convertZohoHtmlToText(html: string): string {
|
||||
if (!html) return ''
|
||||
return htmlToText(html, {
|
||||
wordwrap: false,
|
||||
selectors: [
|
||||
{ selector: 'a', options: { hideLinkHrefIfSameAsText: true, noAnchorUrl: true } },
|
||||
{ selector: 'img', format: 'skip' },
|
||||
{ selector: 'script', format: 'skip' },
|
||||
{ selector: 'style', format: 'skip' },
|
||||
],
|
||||
preserveNewlines: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a plain-text rendering of a Zoho Desk content value.
|
||||
*
|
||||
* Zoho is not consistent about how it spells the HTML discriminator: comment
|
||||
* bodies come back as `contentType: 'html'` while thread bodies use the MIME
|
||||
* form `contentType: 'text/html'`. A strict `=== 'html'` check therefore passes
|
||||
* thread HTML straight through as "plain text". Match either spelling (and any
|
||||
* other `text/html;charset=...` variant) case-insensitively.
|
||||
*
|
||||
* A plain-text (or unrecognized) content value is returned unchanged so callers
|
||||
* can mirror it into a parallel text field. Returns `undefined` when there is no
|
||||
* string content to derive from.
|
||||
*/
|
||||
export function deriveZohoContentText(content: unknown, contentType: unknown): string | undefined {
|
||||
if (typeof content !== 'string') return undefined
|
||||
if (typeof contentType !== 'string') return content
|
||||
const normalized = contentType.trim().toLowerCase()
|
||||
const isHtml = normalized === 'html' || normalized.startsWith('text/html')
|
||||
return isHtml ? convertZohoHtmlToText(content) : content
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a string carries HTML markup worth stripping - an element tag or a
|
||||
* character entity. Used where Zoho gives no content-type discriminator, so a
|
||||
* genuinely plain body is never run through html-to-text (which would decode
|
||||
* entities and delete tag-shaped text that was never markup).
|
||||
*/
|
||||
function looksLikeHtml(value: string): boolean {
|
||||
// Requires a real element - a paired <tag>...</tag>, a self-closing <tag/>, or
|
||||
// an HTML comment/doctype. A bare `<`...`>` pair is NOT enough: plain support
|
||||
// text like "if x<y then z>0" or "replace <username> with the real name" would
|
||||
// otherwise be run through html-to-text and silently lose everything between
|
||||
// the brackets. Entity form covers named, decimal and hex references.
|
||||
return (
|
||||
/<([a-z][a-z0-9]*)\b[^>]*>[\s\S]*<\/\1\s*>/i.test(value) ||
|
||||
/<[a-z][a-z0-9]*\b[^>]*\/>/i.test(value) ||
|
||||
/<!(?:--|doctype)/i.test(value) ||
|
||||
/&(?:[a-z]+|#\d+|#x[0-9a-f]+);/i.test(value)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a shallow copy of a Zoho Desk resource (comment / thread / event
|
||||
* payload) augmented with a derived `contentText` field alongside the raw
|
||||
* `content` + `contentType`. The raw HTML is never mutated or replaced - some
|
||||
* consumers want the markup. Resources without a string `content` (or a
|
||||
* non-object value) are returned unchanged.
|
||||
*/
|
||||
export function withDerivedContentText(resource: unknown): unknown {
|
||||
if (!resource || typeof resource !== 'object' || Array.isArray(resource)) return resource
|
||||
const record = resource as Record<string, unknown>
|
||||
|
||||
const contentText = deriveZohoContentText(record.content, record.contentType)
|
||||
// Ticket resources carry their body on `description`, not `content`, and Zoho
|
||||
// ships NO content-type discriminator for it: the Ticket_Add webhook sample
|
||||
// has `"description": "<div>Description</div>"` with no `descriptionContentType`
|
||||
// key, and the ticket GET/PATCH field lists have no content-type sibling.
|
||||
//
|
||||
// But the shape is not consistently HTML either - Zoho's own REST samples show
|
||||
// plain descriptions ("Hi. There is a sudden delay in the processing of the
|
||||
// orders."), and the webhook path runs this over contact/account/department
|
||||
// payloads whose `description` Zoho documents as plain text. Converting
|
||||
// unconditionally is therefore lossy on the plain case: html-to-text decodes
|
||||
// entities (`&` -> `&`) and deletes anything tag-shaped (`a < b > c`, an
|
||||
// XML snippet). Sniff for markup instead and pass anything without it through
|
||||
// untouched, so neither shape is mangled. An explicit descriptionContentType
|
||||
// still wins if Zoho ever starts sending one.
|
||||
const descriptionText =
|
||||
typeof record.description === 'string'
|
||||
? typeof record.descriptionContentType === 'string'
|
||||
? deriveZohoContentText(record.description, record.descriptionContentType)
|
||||
: looksLikeHtml(record.description)
|
||||
? convertZohoHtmlToText(record.description)
|
||||
: record.description
|
||||
: undefined
|
||||
|
||||
if (contentText === undefined && descriptionText === undefined) return record
|
||||
return {
|
||||
...record,
|
||||
...(contentText !== undefined ? { contentText } : {}),
|
||||
...(descriptionText !== undefined ? { descriptionText } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Zoho Desk REST API base (`{deskBase}/api/v1`). `apiDomain` is the
|
||||
* data-center-scoped Desk base persisted from the OAuth token response, so calls
|
||||
* always reach the correct data center instead of assuming `desk.zoho.com`.
|
||||
*/
|
||||
export function getZohoDeskApiBase(params: Pick<ZohoDeskBaseParams, 'apiDomain'>): string {
|
||||
const candidate = (params.apiDomain || DEFAULT_ZOHO_DESK_BASE).replace(/\/+$/, '')
|
||||
// Anchor to the Zoho apex allowlist before this host receives the OAuth token.
|
||||
// `apiDomain` is injected server-side from the credential and is hidden from
|
||||
// the LLM tool schema, but the injection in tools/index.ts lets a pre-existing
|
||||
// context value win over the credential-derived one - so validate rather than
|
||||
// trust precedence, and fall back to the US base on anything unrecognized.
|
||||
try {
|
||||
const url = new URL(candidate)
|
||||
if (url.protocol === 'https:' && isZohoHost(url.hostname)) return `${candidate}/api/v1`
|
||||
} catch {
|
||||
// fall through to the default base
|
||||
}
|
||||
return `${DEFAULT_ZOHO_DESK_BASE}/api/v1`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an attachment `href` into an absolute download URL. Absolute hrefs are
|
||||
* used as-is; a relative href is resolved against the Desk API base (`apiBase`,
|
||||
* which ends in `/api/v1`). A leading slash and an already-present `api/v1/`
|
||||
* prefix are stripped first so a Zoho href like `/api/v1/tickets/1/.../content`
|
||||
* does not produce a duplicated `/api/v1/api/v1/...` path. Throws on an
|
||||
* unparseable result (the caller maps that to a 400).
|
||||
*/
|
||||
export function resolveZohoAttachmentUrl(href: string, apiBase: string): URL {
|
||||
if (/^https?:\/\//i.test(href)) return new URL(href)
|
||||
const path = href.replace(/^\/+/, '').replace(/^api\/v1\//i, '')
|
||||
return new URL(`${apiBase.replace(/\/+$/, '')}/${path}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim an identifier destined for a URL path segment, rejecting a missing or
|
||||
* whitespace-only value. A pasted trailing space would otherwise be encoded as
|
||||
* `%20` and 404 against Zoho with no indication of the real cause.
|
||||
*/
|
||||
export function requireZohoDeskId(value: string | undefined, label: string): string {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed) throw new Error(`${label} is required.`)
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/** Build the auth + org headers required on every Zoho Desk API call. */
|
||||
export function buildZohoDeskHeaders(
|
||||
params: Pick<ZohoDeskBaseParams, 'accessToken' | 'orgId'>
|
||||
): Record<string, string> {
|
||||
if (!params.accessToken) throw new Error('Zoho Desk access token is required')
|
||||
if (!params.orgId) throw new Error('Zoho Desk organization ID is required')
|
||||
return {
|
||||
Authorization: `Zoho-oauthtoken ${params.accessToken}`,
|
||||
orgId: String(params.orgId),
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a sensible name for a downloaded attachment so files aren't all stored
|
||||
* as a generic default: an explicit override wins, else the Content-Disposition
|
||||
* filename, else the last path segment of the download URL when it looks like a
|
||||
* real file name (has an extension and isn't a generic `.../content` endpoint),
|
||||
* else a plain fallback.
|
||||
*/
|
||||
export function deriveAttachmentName(
|
||||
explicit: string | null | undefined,
|
||||
contentDisposition: string | null | undefined,
|
||||
pathname: string
|
||||
): string {
|
||||
const trimmedExplicit = explicit?.trim()
|
||||
if (trimmedExplicit) return trimmedExplicit
|
||||
|
||||
const dispositionMatch = contentDisposition
|
||||
? /filename\*?=(?:UTF-8'')?["']?([^"';]+)/i.exec(contentDisposition)?.[1]
|
||||
: undefined
|
||||
if (dispositionMatch) {
|
||||
try {
|
||||
return decodeURIComponent(dispositionMatch)
|
||||
} catch {
|
||||
return dispositionMatch
|
||||
}
|
||||
}
|
||||
|
||||
let lastSegment = ''
|
||||
try {
|
||||
lastSegment = decodeURIComponent(pathname.split('/').filter(Boolean).pop() ?? '')
|
||||
} catch {
|
||||
lastSegment = pathname.split('/').filter(Boolean).pop() ?? ''
|
||||
}
|
||||
if (lastSegment?.includes('.') && lastSegment.toLowerCase() !== 'content') {
|
||||
return lastSegment
|
||||
}
|
||||
|
||||
return 'attachment'
|
||||
}
|
||||
|
||||
/** Extract a human-readable error message from a Zoho Desk error response body. */
|
||||
export function getZohoDeskErrorMessage(data: unknown, fallback: string): string {
|
||||
if (data && typeof data === 'object') {
|
||||
const record = data as Record<string, unknown>
|
||||
if (typeof record.message === 'string' && record.message.trim()) return record.message
|
||||
if (typeof record.errorCode === 'string' && record.errorCode.trim()) return record.errorCode
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -461,6 +461,7 @@ import {
|
||||
zendeskTicketStatusChangedTrigger,
|
||||
zendeskWebhookTrigger,
|
||||
} from '@/triggers/zendesk'
|
||||
import { zohoDeskWebhookTrigger } from '@/triggers/zoho_desk'
|
||||
import {
|
||||
zoomMeetingEndedTrigger,
|
||||
zoomMeetingStartedTrigger,
|
||||
@@ -867,4 +868,5 @@ export const TRIGGER_REGISTRY: TriggerRegistry = {
|
||||
sentry_metric_alert: sentryMetricAlertTrigger,
|
||||
twilio_sms_received: twilioSmsReceivedTrigger,
|
||||
twilio_sms_status: twilioSmsStatusTrigger,
|
||||
zoho_desk: zohoDeskWebhookTrigger,
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { zohoDeskWebhookTrigger } from './webhook'
|
||||
@@ -0,0 +1,197 @@
|
||||
import { ZohoDeskIcon } from '@/components/icons'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
|
||||
/**
|
||||
* Zoho Desk event types that support a programmatic webhook subscription.
|
||||
* Restricted to events documented in Zoho's webhook subscriptions schema.
|
||||
*/
|
||||
const ZOHO_DESK_EVENT_OPTIONS = [
|
||||
{ label: 'Ticket Created', id: 'Ticket_Add' },
|
||||
{ label: 'Ticket Updated', id: 'Ticket_Update' },
|
||||
{ label: 'Ticket Deleted', id: 'Ticket_Delete' },
|
||||
{ label: 'Ticket Comment Added', id: 'Ticket_Comment_Add' },
|
||||
{ label: 'Ticket Comment Updated', id: 'Ticket_Comment_Update' },
|
||||
{ label: 'Ticket Thread Added', id: 'Ticket_Thread_Add' },
|
||||
{ label: 'Contact Created', id: 'Contact_Add' },
|
||||
{ label: 'Contact Updated', id: 'Contact_Update' },
|
||||
{ label: 'Contact Deleted', id: 'Contact_Delete' },
|
||||
{ label: 'Agent Created', id: 'Agent_Add' },
|
||||
{ label: 'Agent Updated', id: 'Agent_Update' },
|
||||
{ label: 'Agent Deleted', id: 'Agent_Delete' },
|
||||
{ label: 'Task Created', id: 'Task_Add' },
|
||||
{ label: 'Task Updated', id: 'Task_Update' },
|
||||
{ label: 'Task Deleted', id: 'Task_Delete' },
|
||||
{ label: 'Article Created', id: 'Article_Add' },
|
||||
{ label: 'Article Updated', id: 'Article_Update' },
|
||||
{ label: 'Article Deleted', id: 'Article_Delete' },
|
||||
]
|
||||
|
||||
const ZOHO_DESK_SETUP_INSTRUCTIONS = [
|
||||
'Connect your Zoho Desk account above with OAuth — the trigger provisions its webhook through an OAuth connection and cannot use a Self Client. Webhooks require a Zoho Desk edition of Professional or higher (Free and Standard cannot create webhooks). The OAuth connection supports accounts in the US data center (accounts.zoho.com) only, so organizations in other regions cannot use this trigger yet.',
|
||||
'Select your Organization from the dropdown (populated automatically from your connected Zoho Desk account).',
|
||||
'Choose the event to subscribe to. For "Ticket Updated" you can optionally list up to 5 ticket field API names to watch; previous field values are included in the payload. For "Ticket Thread Added" you can filter by direction (incoming/outgoing).',
|
||||
'Optionally restrict events to specific departments by entering comma-separated department IDs.',
|
||||
'Click Save above. Sim creates the webhook subscription in Zoho Desk for you and tears it down automatically when the workflow is undeployed.',
|
||||
]
|
||||
.map(
|
||||
(instruction, index) => `<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
export const zohoDeskWebhookTrigger: TriggerConfig = {
|
||||
id: 'zoho_desk',
|
||||
name: 'Zoho Desk Event',
|
||||
provider: 'zoho_desk',
|
||||
description:
|
||||
'Trigger a workflow when a Zoho Desk event occurs (ticket, comment, thread, contact, agent, task, or article changes).',
|
||||
version: '1.0.0',
|
||||
icon: ZohoDeskIcon,
|
||||
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'triggerCredentials',
|
||||
title: 'Zoho Desk Account',
|
||||
type: 'oauth-input',
|
||||
description:
|
||||
'This trigger creates and manages a webhook subscription in your Zoho Desk account.',
|
||||
serviceId: 'zoho-desk',
|
||||
requiredScopes: getScopesForService('zoho-desk'),
|
||||
required: true,
|
||||
mode: 'trigger',
|
||||
// Maps the trigger's credential onto the `oauthCredential` canonical id so
|
||||
// the organization selector below resolves it into its SelectorContext.
|
||||
canonicalParamId: 'oauthCredential',
|
||||
},
|
||||
{
|
||||
// Intentionally the SAME id as the block's tool-mode `orgId` (unlike
|
||||
// `triggerDepartmentIds` below). An organization is the portal the whole
|
||||
// block talks to and means exactly the same thing in either mode, so one
|
||||
// shared value is the correct behavior — switching the block between tool
|
||||
// and trigger mode keeps the portal the user already picked.
|
||||
//
|
||||
// The platform supports it: `buildCanonicalIndex` dedupes the repeated id
|
||||
// and refuses to let a `trigger`-mode subblock overwrite a basicId claimed
|
||||
// by a non-trigger one, and `isSubBlockVisibleForTriggerMode` renders only
|
||||
// the active mode's twin, so the field never appears twice.
|
||||
//
|
||||
// A distinct id here would be actively worse, not merely inconsistent: a
|
||||
// separate `triggerManualOrgId` would put two entries in this group's
|
||||
// `advancedIds`, and `getCanonicalValues` returns the first non-empty one
|
||||
// in array order — so a stale tool-mode `manualOrgId` could silently
|
||||
// supply the trigger's organization.
|
||||
id: 'orgId',
|
||||
title: 'Organization',
|
||||
type: 'project-selector',
|
||||
placeholder: 'Select an organization',
|
||||
description: 'The Zoho Desk organization (portal) to subscribe in.',
|
||||
canonicalParamId: 'orgId',
|
||||
serviceId: 'zoho-desk',
|
||||
selectorKey: 'zoho_desk.organizations',
|
||||
dependsOn: ['triggerCredentials'],
|
||||
required: true,
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
// Deliberately REUSES the block's `manualOrgId` id rather than introducing
|
||||
// a `triggerManualOrgId`. An organization id means the same thing in tool
|
||||
// and trigger mode, and `buildCanonicalIndex` dedupes a repeated advanced
|
||||
// id across the block/trigger spread — so sharing yields one advanced
|
||||
// member for the `orgId` canonical group. A distinct id would instead
|
||||
// produce advancedIds `['manualOrgId', 'triggerManualOrgId']`, and
|
||||
// `getCanonicalValues` takes the first non-empty member, which would let a
|
||||
// tool-mode value stand in for the trigger's organization.
|
||||
id: 'manualOrgId',
|
||||
title: 'Organization ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter organization ID',
|
||||
description: 'Type an organization ID instead of picking one from the list.',
|
||||
canonicalParamId: 'orgId',
|
||||
required: true,
|
||||
mode: 'trigger-advanced',
|
||||
},
|
||||
{
|
||||
id: 'eventType',
|
||||
title: 'Event',
|
||||
type: 'dropdown',
|
||||
options: ZOHO_DESK_EVENT_OPTIONS,
|
||||
value: () => 'Ticket_Add',
|
||||
required: true,
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
// NOTE: distinct from the block's tool-mode `departmentIds` filter.
|
||||
// `block.subBlocks` is keyed by id, so a shared id would let a value typed
|
||||
// as a list_tickets filter silently become the webhook's department filter
|
||||
// (and vice versa) when the block is switched between tool and trigger mode.
|
||||
id: 'triggerDepartmentIds',
|
||||
title: 'Department IDs (optional)',
|
||||
type: 'short-input',
|
||||
placeholder: 'Comma-separated department IDs',
|
||||
description: 'Restrict events to these departments. Leave empty for all departments.',
|
||||
condition: {
|
||||
field: 'eventType',
|
||||
value: [
|
||||
'Ticket_Add',
|
||||
'Ticket_Update',
|
||||
'Ticket_Comment_Add',
|
||||
'Ticket_Comment_Update',
|
||||
'Ticket_Thread_Add',
|
||||
'Task_Add',
|
||||
'Task_Update',
|
||||
],
|
||||
},
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'fields',
|
||||
title: 'Watched Fields (optional)',
|
||||
type: 'short-input',
|
||||
placeholder: 'Up to 5 comma-separated field API names',
|
||||
description:
|
||||
'For Ticket Updated: only fire when one of these fields changes (max 5). Previous values are included in the payload.',
|
||||
condition: { field: 'eventType', value: 'Ticket_Update' },
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'direction',
|
||||
title: 'Thread Direction',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Both', id: 'both' },
|
||||
{ label: 'Incoming', id: 'in' },
|
||||
{ label: 'Outgoing', id: 'out' },
|
||||
],
|
||||
value: () => 'both',
|
||||
condition: { field: 'eventType', value: 'Ticket_Thread_Add' },
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'triggerInstructions',
|
||||
title: 'Setup Instructions',
|
||||
hideFromPreview: true,
|
||||
type: 'text',
|
||||
defaultValue: ZOHO_DESK_SETUP_INSTRUCTIONS,
|
||||
mode: 'trigger',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: {
|
||||
eventType: { type: 'string', description: 'The Zoho Desk event type (e.g. Ticket_Add)' },
|
||||
eventTime: { type: 'string', description: 'Event time in milliseconds since epoch' },
|
||||
orgId: { type: 'string', description: 'Zoho Desk organization ID' },
|
||||
payload: {
|
||||
type: 'json',
|
||||
description:
|
||||
'The full resource that changed (ticket, comment, thread, etc.). Comment and thread events gain a derived plain-text `contentText` alongside the raw `content` + `contentType`; ticket events gain `descriptionText` alongside `description`.',
|
||||
},
|
||||
prevState: { type: 'json', description: 'Previous state of the resource (update events only)' },
|
||||
},
|
||||
|
||||
webhook: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
|
||||
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
|
||||
|
||||
const BASELINE = {
|
||||
totalRoutes: 1004,
|
||||
zodRoutes: 1004,
|
||||
totalRoutes: 1008,
|
||||
zodRoutes: 1008,
|
||||
nonZodRoutes: 0,
|
||||
} as const
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ const HANDWRITTEN_INTEGRATION_DOCS = new Set([
|
||||
'trello-service-account',
|
||||
'wealthbox-service-account',
|
||||
'webflow-service-account',
|
||||
'zoho-desk-service-account',
|
||||
'zoom-service-account',
|
||||
])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user