Skip to content

Commit 310a216

Browse files
committed
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.
1 parent e7d5783 commit 310a216

22 files changed

Lines changed: 599 additions & 120 deletions

File tree

apps/sim/app/api/tools/zoho_desk/attachment/route.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import { zohoDeskGetAttachmentContract } from '@/lib/api/contracts/tools/zoho-de
55
import { parseRequest } from '@/lib/api/server'
66
import { checkInternalAuth } from '@/lib/auth/hybrid'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8-
import { buildZohoDeskHeaders, getZohoDeskApiBase } from '@/tools/zoho_desk/utils'
8+
import {
9+
buildZohoDeskHeaders,
10+
deriveAttachmentName,
11+
getZohoDeskApiBase,
12+
} from '@/tools/zoho_desk/utils'
913

1014
export const dynamic = 'force-dynamic'
1115

@@ -98,10 +102,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
98102
)
99103
}
100104

101-
const contentDisposition = response.headers.get('content-disposition') || ''
102-
const dispositionName = /filename\*?=(?:UTF-8'')?["']?([^"';]+)/i.exec(contentDisposition)?.[1]
103-
const filename =
104-
fileName || (dispositionName ? decodeURIComponent(dispositionName) : 'attachment')
105+
// ToolFileData (consumed by FileToolProcessor) keys the file name as `name`.
106+
const name = deriveAttachmentName(
107+
fileName,
108+
response.headers.get('content-disposition'),
109+
downloadUrl.pathname
110+
)
105111
const mimeType = response.headers.get('content-type') || 'application/octet-stream'
106112

107113
return NextResponse.json({
@@ -110,7 +116,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
110116
file: {
111117
data: Buffer.from(arrayBuffer).toString('base64'),
112118
mimeType,
113-
filename,
119+
name,
114120
},
115121
},
116122
})
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { requestJson } from '@/lib/api/client/request'
2+
import { zohoDeskListOrganizationsContract } from '@/lib/api/contracts/tools/zoho-desk'
3+
import { fetchOAuthToken } from '@/hooks/selectors/helpers'
4+
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
5+
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
6+
7+
/**
8+
* Populate a Zoho Desk organization selector from the connected credential.
9+
*
10+
* Shared by the Zoho Desk block (tool operations, credential in `credential`/
11+
* `manualCredential`) and its trigger (credential in `triggerCredentials`). It
12+
* lives in its own module so the trigger can reuse it without importing the
13+
* block file, which would create a block <-> trigger import cycle.
14+
*/
15+
export async function fetchZohoDeskOrganizationOptions(
16+
blockId: string
17+
): Promise<Array<{ label: string; id: string }>> {
18+
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
19+
if (!activeWorkflowId) return []
20+
const blockValues = useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[blockId]
21+
const credentialId =
22+
(blockValues?.credential as string) ||
23+
(blockValues?.manualCredential as string) ||
24+
(blockValues?.triggerCredentials as string)
25+
if (!credentialId) return []
26+
const bundle = await fetchOAuthToken(credentialId, activeWorkflowId)
27+
if (!bundle) return []
28+
const data = await requestJson(zohoDeskListOrganizationsContract, {
29+
body: { accessToken: bundle.accessToken, apiDomain: bundle.apiDomain ?? null },
30+
})
31+
return data.organizations.map((org) => ({
32+
label: org.companyName ? `${org.companyName} (${org.id})` : org.id,
33+
id: org.id,
34+
}))
35+
}

apps/sim/blocks/blocks/zoho-desk.ts

Lines changed: 10 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
11
import { ZohoDeskIcon } from '@/components/icons'
2-
import { requestJson } from '@/lib/api/client/request'
3-
import { zohoDeskListOrganizationsContract } from '@/lib/api/contracts/tools/zoho-desk'
42
import { getScopesForService } from '@/lib/oauth/utils'
3+
import { fetchZohoDeskOrganizationOptions } from '@/blocks/blocks/zoho-desk-org-options'
54
import type { BlockConfig, BlockMeta } from '@/blocks/types'
65
import { AuthMode, IntegrationType } from '@/blocks/types'
7-
import { fetchOAuthToken } from '@/hooks/selectors/helpers'
8-
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
9-
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
106
import type { ZohoDeskResponse } from '@/tools/zoho_desk/types'
117
import { getTrigger } from '@/triggers'
128

@@ -23,27 +19,6 @@ const OPERATIONS_NEEDING_ORG = [
2319
'get_attachment',
2420
]
2521

26-
/** Populate the organization selector from the connected Zoho Desk credential. */
27-
async function fetchZohoDeskOrganizationOptions(
28-
blockId: string
29-
): Promise<Array<{ label: string; id: string }>> {
30-
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
31-
if (!activeWorkflowId) return []
32-
const blockValues = useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[blockId]
33-
const credentialId =
34-
(blockValues?.credential as string) || (blockValues?.manualCredential as string)
35-
if (!credentialId) return []
36-
const bundle = await fetchOAuthToken(credentialId, activeWorkflowId)
37-
if (!bundle) return []
38-
const data = await requestJson(zohoDeskListOrganizationsContract, {
39-
body: { accessToken: bundle.accessToken, apiDomain: bundle.apiDomain ?? null },
40-
})
41-
return data.organizations.map((org) => ({
42-
label: org.companyName ? `${org.companyName} (${org.id})` : org.id,
43-
id: org.id,
44-
}))
45-
}
46-
4722
export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
4823
type: 'zoho_desk',
4924
name: 'Zoho Desk',
@@ -181,14 +156,6 @@ export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
181156
defaultValue: false,
182157
condition: { field: 'operation', value: 'add_comment' },
183158
},
184-
{
185-
id: 'ignoreSourceId',
186-
title: 'Ignore Source ID',
187-
type: 'short-input',
188-
mode: 'advanced',
189-
placeholder: 'Loop-guard source ID (from a Zoho Desk trigger)',
190-
condition: { field: 'operation', value: ['add_comment', 'update_ticket'] },
191-
},
192159
// Update ticket
193160
{
194161
id: 'subject',
@@ -346,9 +313,17 @@ export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
346313
// Pull raw pagination out of the spread so invalid values never reach the
347314
// tool; only re-add them when Number() yields a finite value (a non-numeric
348315
// typo would otherwise become NaN and produce an invalid Zoho query param).
349-
const { oauthCredential, from: rawFrom, limit: rawLimit, ...rest } = params
316+
const { oauthCredential, from: rawFrom, limit: rawLimit, contentType, ...rest } = params
350317
const result: Record<string, unknown> = { ...rest, oauthCredential }
351318

319+
// contentType is the comment's content type; its default would otherwise
320+
// serialize for every operation (e.g. get_attachment, which has no such
321+
// param). Only forward it for add_comment so the UI can't imply an option
322+
// that has no effect elsewhere.
323+
if (params.operation === 'add_comment' && typeof contentType === 'string' && contentType) {
324+
result.contentType = contentType
325+
}
326+
352327
if (rawFrom !== undefined && rawFrom !== '') {
353328
const from = Number(rawFrom)
354329
if (Number.isFinite(from)) result.from = from
@@ -381,7 +356,6 @@ export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
381356
content: { type: 'string', description: 'Comment content' },
382357
contentType: { type: 'string', description: 'Comment content type (plainText/html)' },
383358
isPublic: { type: 'boolean', description: 'Whether a comment is public' },
384-
ignoreSourceId: { type: 'string', description: 'Loop-guard source ID for writes' },
385359
subject: { type: 'string', description: 'Ticket subject' },
386360
status: { type: 'string', description: 'Ticket status' },
387361
priority: { type: 'string', description: 'Ticket priority' },

apps/sim/lib/api/contracts/tools/zoho-desk.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ export const zohoDeskGetAttachmentBodySchema = z.object({
4242
const zohoDeskFileSchema = z.object({
4343
data: z.string(),
4444
mimeType: z.string(),
45-
filename: z.string(),
45+
// FileToolProcessor (ToolFileData) reads the file name from `name`.
46+
name: z.string(),
4647
})
4748

4849
export const zohoDeskGetAttachmentResponseSchema = z.object({

apps/sim/lib/webhooks/deploy.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -940,7 +940,11 @@ export async function saveTriggerWebhooksForDeploy({
940940
(cleanupFailure as Error)?.message ||
941941
(error as Error)?.message ||
942942
'Failed to create external subscription',
943-
status: 500,
943+
// Propagate a 4xx from the provider handler (e.g. a permanent Zoho
944+
// config/permission/invalid-data failure) so the outbox classifies it
945+
// as non-retryable; anything else (network, provider 5xx) stays 500 and
946+
// retryable. cleanupFailure never overrides the root cause's status.
947+
status: (error as { status?: number })?.status ?? 500,
944948
},
945949
}
946950
}

apps/sim/lib/webhooks/pending-verification.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ const pendingWebhookVerificationRegistrationMatchers: Record<
4949
grain: () => true,
5050
generic: (registration) => registration.metadata?.verifyTestEvents === true,
5151
salesforce: () => true,
52+
// Zoho Desk validates the notification URL with a create-time probe that must
53+
// return 200 before it will register the subscription (chicken-and-egg: the
54+
// webhook row is inactive until the create succeeds).
55+
zoho_desk: () => true,
5256
}
5357

5458
const pendingWebhookVerificationProbeMatchers: Record<
@@ -68,6 +72,8 @@ const pendingWebhookVerificationProbeMatchers: Record<
6872
method === 'GET' ||
6973
method === 'HEAD' ||
7074
(method === 'POST' && (!body || Object.keys(body).length === 0)),
75+
// Zoho Desk sends a GET reachability probe at subscription-create time.
76+
zoho_desk: ({ method }) => method === 'GET' || method === 'HEAD',
7177
}
7278

7379
function getRedisKey(path: string): string {

apps/sim/lib/webhooks/provider-subscriptions.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ const SYSTEM_MANAGED_FIELDS = new Set([
3737
'setupCompleted',
3838
'subscriptionExpiration',
3939
'userId',
40+
// Zoho Desk provider-managed: the persisted data-center Desk base, set by
41+
// createSubscription (not a user trigger field), so it must not count as a
42+
// config change that forces delete/recreate.
43+
'apiDomain',
4044
])
4145

4246
/**

0 commit comments

Comments
 (0)