Skip to content

Commit b362b58

Browse files
committed
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.
1 parent 8a431e6 commit b362b58

14 files changed

Lines changed: 350 additions & 9 deletions

File tree

apps/docs/content/docs/en/integrations/zoho-desk-service-account.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ Sim requests exactly the scopes its Zoho Desk tools and trigger exercise:
8787
Desk.tickets.READ
8888
Desk.tickets.UPDATE
8989
Desk.contacts.READ
90+
Desk.agents.READ
9091
Desk.basic.READ
9192
Desk.webhooks.CREATE
9293
Desk.webhooks.DELETE
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { zohoDeskAgentsSelectorContract } from '@/lib/api/contracts/selectors'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
7+
import { generateRequestId } from '@/lib/core/utils/request'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { resolveZohoDeskSelectorCredential } from '@/app/api/tools/zoho_desk/selector-credential'
10+
import { assertZohoUrl } from '@/tools/zoho_desk/host-allowlist'
11+
import { buildZohoDeskHeaders, getZohoDeskErrorMessage } from '@/tools/zoho_desk/utils'
12+
13+
export const dynamic = 'force-dynamic'
14+
15+
const logger = createLogger('ZohoDeskAgentsAPI')
16+
17+
/**
18+
* `GET /api/v1/agents` is index-paginated exactly like `/departments`: `from` is
19+
* a 0-based offset and `limit` caps at 200 (default 10). A short page means the
20+
* list is exhausted. The page cap bounds the drain so a provider that keeps
21+
* returning full pages cannot loop forever.
22+
*/
23+
const AGENT_PAGE_SIZE = 200
24+
const MAX_AGENT_PAGES = 20
25+
26+
/**
27+
* Only active agents can own a ticket, so a disabled or deleted agent in the
28+
* picker would only produce an assignment the API rejects.
29+
*/
30+
const AGENT_STATUS = 'ACTIVE'
31+
32+
interface ZohoAgent {
33+
id?: string | number
34+
name?: string
35+
firstName?: string
36+
lastName?: string
37+
emailId?: string
38+
}
39+
40+
/**
41+
* Zoho returns `name` for most agents but leaves it (and `firstName`) empty on
42+
* some rows, so fall back through the name parts and finally the email before
43+
* showing a bare numeric id the user cannot recognize.
44+
*/
45+
function getAgentLabel(agent: ZohoAgent): string {
46+
if (agent.name?.trim()) return agent.name.trim()
47+
const fullName = [agent.firstName, agent.lastName]
48+
.map((part) => part?.trim())
49+
.filter(Boolean)
50+
.join(' ')
51+
if (fullName) return fullName
52+
return agent.emailId?.trim() || String(agent.id)
53+
}
54+
55+
/** Backs the `zoho_desk.agents` selector. */
56+
export const POST = withRouteHandler(async (request: NextRequest) => {
57+
const requestId = generateRequestId()
58+
59+
const parsed = await parseRequest(zohoDeskAgentsSelectorContract, request, {})
60+
if (!parsed.success) return parsed.response
61+
const { credential, workflowId, orgId } = parsed.data.body
62+
63+
const resolved = await resolveZohoDeskSelectorCredential(request, {
64+
credentialId: credential,
65+
workflowId,
66+
requestId,
67+
})
68+
if (!resolved.ok) return resolved.response
69+
const { accessToken, apiBase } = resolved.credential
70+
71+
const headers = buildZohoDeskHeaders({ accessToken, orgId })
72+
const agents: Array<{ id: string; name: string }> = []
73+
74+
try {
75+
for (let page = 0; page < MAX_AGENT_PAGES; page++) {
76+
let agentsUrl: URL
77+
try {
78+
agentsUrl = assertZohoUrl(`${apiBase}/agents`)
79+
} catch {
80+
return NextResponse.json(
81+
{ error: 'Credential resolved to a non-Zoho host' },
82+
{ status: 400 }
83+
)
84+
}
85+
agentsUrl.searchParams.set('from', String(page * AGENT_PAGE_SIZE))
86+
agentsUrl.searchParams.set('limit', String(AGENT_PAGE_SIZE))
87+
agentsUrl.searchParams.set('status', AGENT_STATUS)
88+
89+
// Same rationale as the organizations/departments/attachment routes: pin
90+
// the resolved IP, block private/reserved hops, and drop the token if a
91+
// Zoho-side redirect leaves the original origin.
92+
const response = await secureFetchWithValidation(agentsUrl.toString(), {
93+
method: 'GET',
94+
headers,
95+
timeout: 15_000,
96+
stripAuthOnRedirect: true,
97+
})
98+
99+
const body: { data?: unknown } = await response
100+
.json()
101+
.then((json) => (json && typeof json === 'object' ? (json as { data?: unknown }) : {}))
102+
.catch(() => ({}))
103+
104+
// Zoho answers 204 with no body once the offset runs past the last agent,
105+
// which is a successful end-of-list, not an error.
106+
if (response.status === 204) break
107+
108+
if (!response.ok) {
109+
const message = getZohoDeskErrorMessage(
110+
body,
111+
`Failed to list agents (HTTP ${response.status})`
112+
)
113+
logger.warn('Failed to list Zoho Desk agents', { status: response.status, message })
114+
return NextResponse.json(
115+
{ error: message },
116+
{ status: response.status >= 400 && response.status < 500 ? response.status : 502 }
117+
)
118+
}
119+
120+
const pageItems = Array.isArray(body.data) ? (body.data as ZohoAgent[]) : []
121+
for (const agent of pageItems) {
122+
if (agent.id === undefined || agent.id === null) continue
123+
agents.push({ id: String(agent.id), name: getAgentLabel(agent) })
124+
}
125+
126+
if (pageItems.length < AGENT_PAGE_SIZE) break
127+
if (page === MAX_AGENT_PAGES - 1) {
128+
logger.warn('Zoho Desk agents listing hit the page cap; list may be incomplete', {
129+
pages: MAX_AGENT_PAGES,
130+
})
131+
}
132+
}
133+
134+
return NextResponse.json({ agents })
135+
} catch (error) {
136+
const message = getErrorMessage(error, 'Failed to list agents')
137+
logger.error('Error listing Zoho Desk agents', { error: message })
138+
return NextResponse.json({ error: message }, { status: 502 })
139+
}
140+
})

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,11 +192,24 @@ export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
192192
},
193193
{
194194
id: 'assigneeId',
195+
title: 'Assignee',
196+
type: 'project-selector',
197+
canonicalParamId: 'assigneeId',
198+
serviceId: 'zoho-desk',
199+
selectorKey: 'zoho_desk.agents',
200+
placeholder: 'Assign the ticket to this agent',
201+
dependsOn: ['credential', 'orgId'],
202+
mode: 'basic',
203+
condition: { field: 'operation', value: 'update_ticket' },
204+
},
205+
{
206+
id: 'manualAssigneeId',
195207
title: 'Assignee ID',
196208
type: 'short-input',
209+
canonicalParamId: 'assigneeId',
197210
placeholder: 'Agent ID',
198-
condition: { field: 'operation', value: 'update_ticket' },
199211
mode: 'advanced',
212+
condition: { field: 'operation', value: 'update_ticket' },
200213
},
201214
{
202215
id: 'description',

apps/sim/hooks/selectors/providers/zoho-desk/selectors.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { SelectorQueryArgs } from '@/hooks/selectors/types'
1212

1313
const organizations = getSelectorDefinition('zoho_desk.organizations')
1414
const departments = getSelectorDefinition('zoho_desk.departments')
15+
const agents = getSelectorDefinition('zoho_desk.agents')
1516

1617
const orgArgs = (overrides: Partial<SelectorQueryArgs['context']> = {}): SelectorQueryArgs => ({
1718
key: 'zoho_desk.organizations',
@@ -23,6 +24,11 @@ const deptArgs = (overrides: Partial<SelectorQueryArgs['context']> = {}): Select
2324
context: { oauthCredential: 'cred-1', workflowId: 'wf-1', orgId: 'org-9', ...overrides },
2425
})
2526

27+
const agentArgs = (overrides: Partial<SelectorQueryArgs['context']> = {}): SelectorQueryArgs => ({
28+
key: 'zoho_desk.agents',
29+
context: { oauthCredential: 'cred-1', workflowId: 'wf-1', orgId: 'org-9', ...overrides },
30+
})
31+
2632
describe('zoho_desk.organizations selector', () => {
2733
beforeEach(() => vi.clearAllMocks())
2834

@@ -119,3 +125,64 @@ describe('zoho_desk.departments selector', () => {
119125
expect(mockRequestJson).not.toHaveBeenCalled()
120126
})
121127
})
128+
129+
describe('zoho_desk.agents selector', () => {
130+
beforeEach(() => vi.clearAllMocks())
131+
132+
it('stays disabled until both the credential and the organization are set', () => {
133+
expect(agents.enabled?.(agentArgs())).toBe(true)
134+
expect(agents.enabled?.(agentArgs({ orgId: undefined }))).toBe(false)
135+
expect(agents.enabled?.(agentArgs({ oauthCredential: undefined }))).toBe(false)
136+
})
137+
138+
it('keys the query by credential and organization so switching portals refetches', () => {
139+
expect(agents.getQueryKey(agentArgs())).toEqual([
140+
'selectors',
141+
'zoho_desk.agents',
142+
'cred-1',
143+
'org-9',
144+
])
145+
expect(agents.getQueryKey(agentArgs({ orgId: undefined }))).toEqual([
146+
'selectors',
147+
'zoho_desk.agents',
148+
'cred-1',
149+
'none',
150+
])
151+
})
152+
153+
it('forwards the organization id and maps agents to options', async () => {
154+
mockRequestJson.mockResolvedValue({
155+
agents: [
156+
{ id: '1892000000056007', name: 'zyl case' },
157+
{ id: '1892000000042001', name: 'jade' },
158+
],
159+
})
160+
161+
const options = await agents.fetchList?.(agentArgs())
162+
163+
expect(mockRequestJson).toHaveBeenCalledWith(
164+
expect.objectContaining({ path: '/api/tools/zoho_desk/agents' }),
165+
expect.objectContaining({
166+
body: { credential: 'cred-1', orgId: 'org-9', workflowId: 'wf-1' },
167+
})
168+
)
169+
expect(options).toEqual([
170+
{ id: '1892000000056007', label: 'zyl case' },
171+
{ id: '1892000000042001', label: 'jade' },
172+
])
173+
})
174+
175+
it('throws when the organization is missing rather than calling the route unscoped', async () => {
176+
await expect(agents.fetchList?.(agentArgs({ orgId: undefined }))).rejects.toThrow(
177+
/Missing organization ID/
178+
)
179+
expect(mockRequestJson).not.toHaveBeenCalled()
180+
})
181+
182+
it('throws when the credential is missing rather than calling the route', async () => {
183+
await expect(agents.fetchList?.(agentArgs({ oauthCredential: undefined }))).rejects.toThrow(
184+
/Missing credential/
185+
)
186+
expect(mockRequestJson).not.toHaveBeenCalled()
187+
})
188+
})

apps/sim/hooks/selectors/providers/zoho-desk/selectors.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,39 @@ export const zohoDeskSelectors = {
5858
}))
5959
},
6060
},
61+
'zoho_desk.agents': {
62+
key: 'zoho_desk.agents',
63+
contracts: [selectorContracts.zohoDeskAgentsSelectorContract],
64+
staleTime: SELECTOR_STALE,
65+
getQueryKey: ({ context }: SelectorQueryArgs) => [
66+
'selectors',
67+
'zoho_desk.agents',
68+
context.oauthCredential ?? 'none',
69+
context.orgId ?? 'none',
70+
],
71+
// Same `orgId` header scoping as departments: the organization must be
72+
// chosen before agents can be listed.
73+
enabled: ({ context }) => Boolean(context.oauthCredential && context.orgId),
74+
fetchList: async ({ context, signal }: SelectorQueryArgs) => {
75+
const credentialId = ensureCredential(context, 'zoho_desk.agents')
76+
if (!context.orgId) {
77+
throw new Error('Missing organization ID for zoho_desk.agents selector')
78+
}
79+
const data = await requestJson(selectorContracts.zohoDeskAgentsSelectorContract, {
80+
body: {
81+
credential: credentialId,
82+
orgId: context.orgId,
83+
workflowId: context.workflowId,
84+
},
85+
signal,
86+
})
87+
return (data.agents || []).map((agent) => ({
88+
id: agent.id,
89+
label: agent.name,
90+
}))
91+
},
92+
},
6193
} satisfies Record<
62-
Extract<SelectorKey, 'zoho_desk.organizations' | 'zoho_desk.departments'>,
94+
Extract<SelectorKey, 'zoho_desk.organizations' | 'zoho_desk.departments' | 'zoho_desk.agents'>,
6395
SelectorDefinition
6496
>

apps/sim/hooks/selectors/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export type SelectorKey =
2828
| 'trello.boards'
2929
| 'zoho_desk.organizations'
3030
| 'zoho_desk.departments'
31+
| 'zoho_desk.agents'
3132
| 'zoom.meetings'
3233
| 'slack.channels'
3334
| 'slack.users'

apps/sim/lib/api/contracts/selectors/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ import {
108108
webflowSitesSelectorContract,
109109
} from '@/lib/api/contracts/selectors/webflow'
110110
import {
111+
zohoDeskAgentsSelectorContract,
111112
zohoDeskDepartmentsSelectorContract,
112113
zohoDeskOrganizationsSelectorContract,
113114
} from '@/lib/api/contracts/selectors/zoho-desk'
@@ -169,6 +170,7 @@ export const selectorContractsByPath = {
169170
'/api/tools/trello/boards': trelloBoardsSelectorContract,
170171
'/api/tools/zoho_desk/organizations': zohoDeskOrganizationsSelectorContract,
171172
'/api/tools/zoho_desk/departments': zohoDeskDepartmentsSelectorContract,
173+
'/api/tools/zoho_desk/agents': zohoDeskAgentsSelectorContract,
172174
'/api/tools/zoom/meetings': zoomMeetingsSelectorContract,
173175
'/api/tools/slack/channels': slackChannelsSelectorContract,
174176
'/api/tools/slack/users': slackUsersSelectorContract,

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,18 @@ export const zohoDeskDepartmentsSelectorContract = definePostSelector(
2525
z.object({ departments: z.array(idNameSchema) })
2626
)
2727

28+
export const zohoDeskAgentsSelectorContract = definePostSelector(
29+
'/api/tools/zoho_desk/agents',
30+
credentialWorkflowBodySchema.extend({ orgId: zohoDeskOrgIdSchema }),
31+
z.object({ agents: z.array(idNameSchema) })
32+
)
33+
2834
export type ZohoDeskOrganizationsSelectorResponse = ContractJsonResponse<
2935
typeof zohoDeskOrganizationsSelectorContract
3036
>
3137
export type ZohoDeskDepartmentsSelectorResponse = ContractJsonResponse<
3238
typeof zohoDeskDepartmentsSelectorContract
3339
>
40+
export type ZohoDeskAgentsSelectorResponse = ContractJsonResponse<
41+
typeof zohoDeskAgentsSelectorContract
42+
>

apps/sim/lib/oauth/oauth.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1113,8 +1113,9 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
11131113
baseProviderIcon: ZohoDeskIcon,
11141114
// Kept to exactly what the tools and the webhook trigger exercise:
11151115
// tickets (incl. threads/comments), contacts (get_contact), basic
1116-
// (list_organizations), webhook create/delete (the trigger provisions and
1117-
// tears down its own subscription), and profile (OAuth getUserInfo).
1116+
// (list_organizations), agents (the `assigneeId` picker lists agents),
1117+
// webhook create/delete (the trigger provisions and tears down its own
1118+
// subscription), and profile (OAuth getUserInfo).
11181119
// Desk.search.READ, Desk.webhooks.READ and Desk.webhooks.UPDATE were
11191120
// requested but unused - no tool searches, and the provider never lists
11201121
// or edits a subscription.
@@ -1128,6 +1129,9 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
11281129
'Desk.tickets.READ',
11291130
'Desk.tickets.UPDATE',
11301131
'Desk.contacts.READ',
1132+
// READ only: the agent picker for `assigneeId` lists agents, and no
1133+
// tool creates, edits or deletes one.
1134+
'Desk.agents.READ',
11311135
'Desk.basic.READ',
11321136
'Desk.webhooks.CREATE',
11331137
'Desk.webhooks.DELETE',

apps/sim/lib/oauth/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const SCOPE_DESCRIPTIONS: Record<string, string> = {
1515
'Desk.tickets.READ': 'View tickets, threads, comments, and attachments',
1616
'Desk.tickets.UPDATE': 'Update tickets and add comments',
1717
'Desk.contacts.READ': 'View contacts',
18+
'Desk.agents.READ': 'View agents',
1819
'Desk.basic.READ': 'View basic account and organization data',
1920
'Desk.webhooks.CREATE': 'Create webhooks',
2021
'Desk.webhooks.DELETE': 'Delete webhooks',

0 commit comments

Comments
 (0)