From cedfff1f366eecae93b73df53596ae64b81fe5b1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 16 Jul 2026 01:36:31 -0700 Subject: [PATCH 1/8] feat(clickup): webhook triggers with auto-managed subscriptions + hierarchy selectors --- .../content/docs/en/integrations/clickup.mdx | 465 +++++++++++++++++- .../app/api/tools/clickup/folders/route.ts | 81 +++ apps/sim/app/api/tools/clickup/lists/route.ts | 83 ++++ .../sim/app/api/tools/clickup/spaces/route.ts | 78 +++ .../app/api/tools/clickup/workspaces/route.ts | 76 +++ apps/sim/blocks/blocks/clickup.ts | 225 +++++++-- .../selectors/providers/clickup/selectors.ts | 128 +++++ apps/sim/hooks/selectors/registry.ts | 2 + apps/sim/hooks/selectors/types.ts | 6 + .../lib/api/contracts/selectors/clickup.ts | 73 +++ apps/sim/lib/api/contracts/selectors/index.ts | 11 + apps/sim/lib/integrations/integrations.json | 156 +++++- apps/sim/lib/webhooks/providers/clickup.ts | 356 ++++++++++++++ apps/sim/lib/webhooks/providers/registry.ts | 2 + apps/sim/lib/workflows/subblocks/context.ts | 2 + apps/sim/triggers/clickup/folder_created.ts | 19 + apps/sim/triggers/clickup/folder_deleted.ts | 19 + apps/sim/triggers/clickup/folder_updated.ts | 19 + apps/sim/triggers/clickup/goal_created.ts | 19 + apps/sim/triggers/clickup/goal_deleted.ts | 19 + apps/sim/triggers/clickup/goal_updated.ts | 19 + apps/sim/triggers/clickup/index.ts | 29 ++ .../triggers/clickup/key_result_created.ts | 19 + .../triggers/clickup/key_result_deleted.ts | 19 + .../triggers/clickup/key_result_updated.ts | 19 + apps/sim/triggers/clickup/list_created.ts | 19 + apps/sim/triggers/clickup/list_deleted.ts | 19 + apps/sim/triggers/clickup/list_updated.ts | 19 + apps/sim/triggers/clickup/space_created.ts | 19 + apps/sim/triggers/clickup/space_deleted.ts | 19 + apps/sim/triggers/clickup/space_updated.ts | 19 + apps/sim/triggers/clickup/subblocks.ts | 113 +++++ .../triggers/clickup/task_assignee_updated.ts | 19 + .../triggers/clickup/task_comment_posted.ts | 19 + .../triggers/clickup/task_comment_updated.ts | 19 + apps/sim/triggers/clickup/task_created.ts | 30 ++ apps/sim/triggers/clickup/task_deleted.ts | 19 + .../triggers/clickup/task_due_date_updated.ts | 19 + apps/sim/triggers/clickup/task_moved.ts | 19 + .../triggers/clickup/task_priority_updated.ts | 19 + .../triggers/clickup/task_status_updated.ts | 19 + apps/sim/triggers/clickup/task_tag_updated.ts | 19 + .../clickup/task_time_estimate_updated.ts | 19 + .../clickup/task_time_tracked_updated.ts | 19 + apps/sim/triggers/clickup/task_updated.ts | 19 + apps/sim/triggers/clickup/utils.ts | 250 ++++++++++ apps/sim/triggers/clickup/webhook.ts | 19 + apps/sim/triggers/registry.ts | 60 +++ scripts/check-api-validation-contracts.ts | 4 +- 49 files changed, 2704 insertions(+), 58 deletions(-) create mode 100644 apps/sim/app/api/tools/clickup/folders/route.ts create mode 100644 apps/sim/app/api/tools/clickup/lists/route.ts create mode 100644 apps/sim/app/api/tools/clickup/spaces/route.ts create mode 100644 apps/sim/app/api/tools/clickup/workspaces/route.ts create mode 100644 apps/sim/hooks/selectors/providers/clickup/selectors.ts create mode 100644 apps/sim/lib/api/contracts/selectors/clickup.ts create mode 100644 apps/sim/lib/webhooks/providers/clickup.ts create mode 100644 apps/sim/triggers/clickup/folder_created.ts create mode 100644 apps/sim/triggers/clickup/folder_deleted.ts create mode 100644 apps/sim/triggers/clickup/folder_updated.ts create mode 100644 apps/sim/triggers/clickup/goal_created.ts create mode 100644 apps/sim/triggers/clickup/goal_deleted.ts create mode 100644 apps/sim/triggers/clickup/goal_updated.ts create mode 100644 apps/sim/triggers/clickup/index.ts create mode 100644 apps/sim/triggers/clickup/key_result_created.ts create mode 100644 apps/sim/triggers/clickup/key_result_deleted.ts create mode 100644 apps/sim/triggers/clickup/key_result_updated.ts create mode 100644 apps/sim/triggers/clickup/list_created.ts create mode 100644 apps/sim/triggers/clickup/list_deleted.ts create mode 100644 apps/sim/triggers/clickup/list_updated.ts create mode 100644 apps/sim/triggers/clickup/space_created.ts create mode 100644 apps/sim/triggers/clickup/space_deleted.ts create mode 100644 apps/sim/triggers/clickup/space_updated.ts create mode 100644 apps/sim/triggers/clickup/subblocks.ts create mode 100644 apps/sim/triggers/clickup/task_assignee_updated.ts create mode 100644 apps/sim/triggers/clickup/task_comment_posted.ts create mode 100644 apps/sim/triggers/clickup/task_comment_updated.ts create mode 100644 apps/sim/triggers/clickup/task_created.ts create mode 100644 apps/sim/triggers/clickup/task_deleted.ts create mode 100644 apps/sim/triggers/clickup/task_due_date_updated.ts create mode 100644 apps/sim/triggers/clickup/task_moved.ts create mode 100644 apps/sim/triggers/clickup/task_priority_updated.ts create mode 100644 apps/sim/triggers/clickup/task_status_updated.ts create mode 100644 apps/sim/triggers/clickup/task_tag_updated.ts create mode 100644 apps/sim/triggers/clickup/task_time_estimate_updated.ts create mode 100644 apps/sim/triggers/clickup/task_time_tracked_updated.ts create mode 100644 apps/sim/triggers/clickup/task_updated.ts create mode 100644 apps/sim/triggers/clickup/utils.ts create mode 100644 apps/sim/triggers/clickup/webhook.ts diff --git a/apps/docs/content/docs/en/integrations/clickup.mdx b/apps/docs/content/docs/en/integrations/clickup.mdx index 4fc022846f3..f41856f75d6 100644 --- a/apps/docs/content/docs/en/integrations/clickup.mdx +++ b/apps/docs/content/docs/en/integrations/clickup.mdx @@ -12,7 +12,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" ## Usage Instructions -Integrate ClickUp into the workflow. Create, read, update, and delete tasks, manage comments, tags, folders, and lists, upload attachments, and look up workspaces, members, and custom fields. +Integrate ClickUp into the workflow. Create, read, update, and delete tasks, manage comments, tags, folders, and lists, upload attachments, and look up workspaces, members, and custom fields. Can also trigger workflows on ClickUp events like task, list, folder, space, and goal changes. @@ -786,3 +786,466 @@ Get the currently running time entry in a ClickUp workspace (null when no timer | `timeEntry` | json | The running time entry \(duration is negative while running\); null when no timer is running | + +## Triggers + +A **Trigger** is a block that starts a workflow when an event happens in this service. + +### ClickUp Folder Created + +Trigger workflow when a folder is created in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `folderId` | string | ID of the affected folder | + + +--- + +### ClickUp Folder Deleted + +Trigger workflow when a folder is deleted in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `folderId` | string | ID of the affected folder | + + +--- + +### ClickUp Folder Updated + +Trigger workflow when a folder is updated in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `folderId` | string | ID of the affected folder | + + +--- + +### ClickUp Goal Created + +Trigger workflow when a goal is created in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | + + +--- + +### ClickUp Goal Deleted + +Trigger workflow when a goal is deleted in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | + + +--- + +### ClickUp Goal Updated + +Trigger workflow when a goal is updated in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | + + +--- + +### ClickUp Key Result Created + +Trigger workflow when a key result is created in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | + + +--- + +### ClickUp Key Result Deleted + +Trigger workflow when a key result is deleted in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | + + +--- + +### ClickUp Key Result Updated + +Trigger workflow when a key result is updated in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | + + +--- + +### ClickUp List Created + +Trigger workflow when a list is created in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `listId` | string | ID of the affected list | + + +--- + +### ClickUp List Deleted + +Trigger workflow when a list is deleted in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `listId` | string | ID of the affected list | + + +--- + +### ClickUp List Updated + +Trigger workflow when a list is updated in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `listId` | string | ID of the affected list | + + +--- + +### ClickUp Space Created + +Trigger workflow when a space is created in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `spaceId` | string | ID of the affected space | + + +--- + +### ClickUp Space Deleted + +Trigger workflow when a space is deleted in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `spaceId` | string | ID of the affected space | + + +--- + +### ClickUp Space Updated + +Trigger workflow when a space is updated in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `spaceId` | string | ID of the affected space | + + +--- + +### ClickUp Task Assignee Updated + +Trigger workflow when the assignees of a task change in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Task Comment Posted + +Trigger workflow when a comment is posted on a task in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Task Comment Updated + +Trigger workflow when a task comment is updated in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Task Created + +Trigger workflow when a task is created in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Task Deleted + +Trigger workflow when a task is deleted in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Task Due Date Updated + +Trigger workflow when the due date of a task changes in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Task Moved + +Trigger workflow when a task is moved to a different list in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Task Priority Updated + +Trigger workflow when the priority of a task changes in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Task Status Updated + +Trigger workflow when the status of a task changes in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Task Tag Updated + +Trigger workflow when the tags of a task change in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Task Time Estimate Updated + +Trigger workflow when the time estimate of a task changes in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Task Time Tracked Updated + +Trigger workflow when the tracked time of a task changes in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Task Updated + +Trigger workflow when a task is updated in ClickUp + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task | + + +--- + +### ClickUp Webhook + +Trigger workflow on any ClickUp event (subscribes to all events) + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | +| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `payload` | json | Full raw ClickUp webhook payload | +| `taskId` | string | ID of the affected task \(task events only\) | +| `listId` | string | ID of the affected list \(list events only\) | +| `folderId` | string | ID of the affected folder \(folder events only\) | +| `spaceId` | string | ID of the affected space \(space events only\) | + diff --git a/apps/sim/app/api/tools/clickup/folders/route.ts b/apps/sim/app/api/tools/clickup/folders/route.ts new file mode 100644 index 00000000000..0de8b46a375 --- /dev/null +++ b/apps/sim/app/api/tools/clickup/folders/route.ts @@ -0,0 +1,81 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { clickupFoldersSelectorContract } from '@/lib/api/contracts/selectors' +import { parseRequest } from '@/lib/api/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared' + +const logger = createLogger('ClickUpFoldersAPI') + +export const dynamic = 'force-dynamic' + +interface ClickUpNamedResource { + id: string | number + name?: string +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + const parsed = await parseRequest(clickupFoldersSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId, spaceId } = parsed.data.body + + const authz = await authorizeCredentialUse(request, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch( + `${CLICKUP_API_BASE_URL}/space/${encodeURIComponent(spaceId)}/folder`, + { + headers: { + Authorization: clickupAuthorizationHeader(accessToken), + Accept: 'application/json', + }, + } + ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch ClickUp folders', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch ClickUp folders' }, + { status: response.status } + ) + } + + const data = (await response.json().catch(() => ({}))) as { + folders?: ClickUpNamedResource[] + } + const folders = (Array.isArray(data.folders) ? data.folders : []).map((item) => ({ + id: String(item.id), + name: item.name || `Folder ${item.id}`, + })) + + return NextResponse.json({ folders }) +}) diff --git a/apps/sim/app/api/tools/clickup/lists/route.ts b/apps/sim/app/api/tools/clickup/lists/route.ts new file mode 100644 index 00000000000..d4945bda65f --- /dev/null +++ b/apps/sim/app/api/tools/clickup/lists/route.ts @@ -0,0 +1,83 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { clickupListsSelectorContract } from '@/lib/api/contracts/selectors' +import { parseRequest } from '@/lib/api/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared' + +const logger = createLogger('ClickUpListsAPI') + +export const dynamic = 'force-dynamic' + +interface ClickUpNamedResource { + id: string | number + name?: string +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + const parsed = await parseRequest(clickupListsSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId, folderId, spaceId } = parsed.data.body + + const authz = await authorizeCredentialUse(request, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch( + folderId?.trim() + ? `${CLICKUP_API_BASE_URL}/folder/${encodeURIComponent(folderId.trim())}/list` + : `${CLICKUP_API_BASE_URL}/space/${encodeURIComponent((spaceId ?? '').trim())}/list`, + { + headers: { + Authorization: clickupAuthorizationHeader(accessToken), + Accept: 'application/json', + }, + } + ) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch ClickUp lists', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch ClickUp lists' }, + { status: response.status } + ) + } + + const data = (await response.json().catch(() => ({}))) as { + lists?: ClickUpNamedResource[] + } + const lists = (Array.isArray(data.lists) ? data.lists : []).map((item) => ({ + id: String(item.id), + name: item.name || `List ${item.id}`, + })) + + return NextResponse.json({ lists }) +}) diff --git a/apps/sim/app/api/tools/clickup/spaces/route.ts b/apps/sim/app/api/tools/clickup/spaces/route.ts new file mode 100644 index 00000000000..ac2d046a47e --- /dev/null +++ b/apps/sim/app/api/tools/clickup/spaces/route.ts @@ -0,0 +1,78 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { clickupSpacesSelectorContract } from '@/lib/api/contracts/selectors' +import { parseRequest } from '@/lib/api/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared' + +const logger = createLogger('ClickUpSpacesAPI') + +export const dynamic = 'force-dynamic' + +interface ClickUpNamedResource { + id: string | number + name?: string +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + const parsed = await parseRequest(clickupSpacesSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId, teamId } = parsed.data.body + + const authz = await authorizeCredentialUse(request, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch(`${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(teamId)}/space`, { + headers: { + Authorization: clickupAuthorizationHeader(accessToken), + Accept: 'application/json', + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch ClickUp spaces', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch ClickUp spaces' }, + { status: response.status } + ) + } + + const data = (await response.json().catch(() => ({}))) as { + spaces?: ClickUpNamedResource[] + } + const spaces = (Array.isArray(data.spaces) ? data.spaces : []).map((item) => ({ + id: String(item.id), + name: item.name || `Space ${item.id}`, + })) + + return NextResponse.json({ spaces }) +}) diff --git a/apps/sim/app/api/tools/clickup/workspaces/route.ts b/apps/sim/app/api/tools/clickup/workspaces/route.ts new file mode 100644 index 00000000000..6dcbe4b1659 --- /dev/null +++ b/apps/sim/app/api/tools/clickup/workspaces/route.ts @@ -0,0 +1,76 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { clickupWorkspacesSelectorContract } from '@/lib/api/contracts/selectors' +import { parseRequest } from '@/lib/api/server' +import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared' + +const logger = createLogger('ClickUpWorkspacesAPI') + +export const dynamic = 'force-dynamic' + +interface ClickUpTeam { + id: string | number + name?: string +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + const parsed = await parseRequest(clickupWorkspacesSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId } = parsed.data.body + + const authz = await authorizeCredentialUse(request, { + credentialId: credential, + workflowId, + }) + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } + + const response = await fetch(`${CLICKUP_API_BASE_URL}/team`, { + headers: { + Authorization: clickupAuthorizationHeader(accessToken), + Accept: 'application/json', + }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch ClickUp workspaces', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch ClickUp workspaces' }, + { status: response.status } + ) + } + + const data = (await response.json().catch(() => ({}))) as { teams?: ClickUpTeam[] } + const workspaces = (Array.isArray(data.teams) ? data.teams : []).map((team) => ({ + id: String(team.id), + name: team.name || `Workspace ${team.id}`, + })) + + return NextResponse.json({ workspaces }) +}) diff --git a/apps/sim/blocks/blocks/clickup.ts b/apps/sim/blocks/blocks/clickup.ts index 9a0281b18e6..be5c0155102 100644 --- a/apps/sim/blocks/blocks/clickup.ts +++ b/apps/sim/blocks/blocks/clickup.ts @@ -4,6 +4,7 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' import type { ClickUpResponse } from '@/tools/clickup/types' +import { getTrigger } from '@/triggers' const TIMESTAMP_WAND_PROMPT = `Generate a Unix timestamp in milliseconds based on the user's description. Examples: @@ -57,13 +58,35 @@ function billableFromAction(action: unknown): boolean | undefined { return action === 'billable' ? true : action === 'non_billable' ? false : undefined } +const CLICKUP_TEAM_OPS = [ + 'search_tasks', + 'get_spaces', + 'get_time_entries', + 'create_time_entry', + 'update_time_entry', + 'delete_time_entry', + 'start_timer', + 'stop_timer', + 'get_running_timer', +] +const CLICKUP_SPACE_OPS = ['get_folders', 'create_folder', 'get_space_tags'] +const CLICKUP_LIST_PARENT_OPS = ['get_lists', 'create_list'] +const CLICKUP_LIST_OPS = ['create_task', 'get_tasks', 'get_list_members', 'get_custom_fields'] +const CLICKUP_HIERARCHY_OPS = [ + ...CLICKUP_TEAM_OPS, + ...CLICKUP_SPACE_OPS, + ...CLICKUP_LIST_PARENT_OPS, + ...CLICKUP_LIST_OPS, +] + export const ClickUpBlock: BlockConfig = { type: 'clickup', name: 'ClickUp', description: 'Interact with ClickUp', authMode: AuthMode.OAuth, + triggerAllowed: true, longDescription: - 'Integrate ClickUp into the workflow. Create, read, update, and delete tasks, manage comments, tags, folders, and lists, upload attachments, and look up workspaces, members, and custom fields.', + 'Integrate ClickUp into the workflow. Create, read, update, and delete tasks, manage comments, tags, folders, and lists, upload attachments, and look up workspaces, members, and custom fields. Can also trigger workflows on ClickUp events like task, list, folder, space, and goal changes.', docsLink: 'https://docs.sim.ai/integrations/clickup', category: 'tools', integrationType: IntegrationType.Productivity, @@ -137,36 +160,58 @@ export const ClickUpBlock: BlockConfig = { required: true, }, { - id: 'workspaceId', - title: 'Workspace ID', + id: 'workspaceSelector', + title: 'Workspace', + type: 'project-selector', + canonicalParamId: 'teamId', + serviceId: 'clickup', + selectorKey: 'clickup.workspaces', + placeholder: 'Select a workspace', + dependsOn: ['credential'], + mode: 'basic', + condition: { field: 'operation', value: CLICKUP_HIERARCHY_OPS }, + required: { field: 'operation', value: CLICKUP_TEAM_OPS }, + }, + { + id: 'manualWorkspaceId', + title: 'Workspace (Team) ID', type: 'short-input', - required: true, + canonicalParamId: 'teamId', placeholder: 'Enter workspace (team) ID', + dependsOn: ['credential'], + mode: 'advanced', + condition: { field: 'operation', value: CLICKUP_HIERARCHY_OPS }, + required: { field: 'operation', value: CLICKUP_TEAM_OPS }, + }, + { + id: 'spaceSelector', + title: 'Space', + type: 'project-selector', + canonicalParamId: 'spaceId', + serviceId: 'clickup', + selectorKey: 'clickup.spaces', + placeholder: 'Select a space', + dependsOn: ['credential', 'workspaceSelector'], + mode: 'basic', condition: { field: 'operation', - value: [ - 'search_tasks', - 'get_spaces', - 'get_time_entries', - 'create_time_entry', - 'update_time_entry', - 'delete_time_entry', - 'start_timer', - 'stop_timer', - 'get_running_timer', - ], + value: [...CLICKUP_SPACE_OPS, ...CLICKUP_LIST_PARENT_OPS, ...CLICKUP_LIST_OPS], }, + required: { field: 'operation', value: CLICKUP_SPACE_OPS }, }, { - id: 'spaceId', + id: 'manualSpaceId', title: 'Space ID', type: 'short-input', - required: true, + canonicalParamId: 'spaceId', placeholder: 'Enter space ID', + dependsOn: ['credential'], + mode: 'advanced', condition: { field: 'operation', - value: ['get_folders', 'create_folder', 'get_space_tags'], + value: [...CLICKUP_SPACE_OPS, ...CLICKUP_LIST_PARENT_OPS, ...CLICKUP_LIST_OPS], }, + required: { field: 'operation', value: CLICKUP_SPACE_OPS }, }, { id: 'listParent', @@ -183,39 +228,57 @@ export const ClickUpBlock: BlockConfig = { }, }, { - id: 'folderId', - title: 'Folder ID', - type: 'short-input', - required: true, - placeholder: 'Enter folder ID', + id: 'folderSelector', + title: 'Folder', + type: 'project-selector', + canonicalParamId: 'folderId', + serviceId: 'clickup', + selectorKey: 'clickup.folders', + placeholder: 'Select a folder', + description: 'Leave empty for folderless lists that live directly in the space', + dependsOn: ['credential', 'spaceSelector'], + mode: 'basic', condition: { field: 'operation', - value: ['get_lists', 'create_list'], - and: { field: 'listParent', value: 'folder' }, + value: [...CLICKUP_LIST_PARENT_OPS, ...CLICKUP_LIST_OPS], }, }, { - id: 'listSpaceId', - title: 'Space ID', + id: 'manualFolderId', + title: 'Folder ID', type: 'short-input', - required: true, - placeholder: 'Enter space ID', + canonicalParamId: 'folderId', + placeholder: 'Enter folder ID', + dependsOn: ['credential'], + mode: 'advanced', condition: { field: 'operation', - value: ['get_lists', 'create_list'], - and: { field: 'listParent', value: 'space' }, + value: [...CLICKUP_LIST_PARENT_OPS, ...CLICKUP_LIST_OPS], }, }, { - id: 'listId', + id: 'listSelector', + title: 'List', + type: 'project-selector', + canonicalParamId: 'listId', + serviceId: 'clickup', + selectorKey: 'clickup.lists', + placeholder: 'Select a list', + dependsOn: { all: ['credential'], any: ['spaceSelector', 'folderSelector'] }, + mode: 'basic', + condition: { field: 'operation', value: CLICKUP_LIST_OPS }, + required: { field: 'operation', value: CLICKUP_LIST_OPS }, + }, + { + id: 'manualListId', title: 'List ID', type: 'short-input', - required: true, + canonicalParamId: 'listId', placeholder: 'Enter list ID', - condition: { - field: 'operation', - value: ['create_task', 'get_tasks', 'get_list_members', 'get_custom_fields'], - }, + dependsOn: ['credential'], + mode: 'advanced', + condition: { field: 'operation', value: CLICKUP_LIST_OPS }, + required: { field: 'operation', value: CLICKUP_LIST_OPS }, }, { id: 'taskId', @@ -1062,6 +1125,35 @@ Return ONLY the value (plain string, number, or JSON) - no explanations, no extr value: ['upload_attachment'], }, }, + ...getTrigger('clickup_task_created').subBlocks, + ...getTrigger('clickup_task_updated').subBlocks, + ...getTrigger('clickup_task_deleted').subBlocks, + ...getTrigger('clickup_task_status_updated').subBlocks, + ...getTrigger('clickup_task_priority_updated').subBlocks, + ...getTrigger('clickup_task_assignee_updated').subBlocks, + ...getTrigger('clickup_task_due_date_updated').subBlocks, + ...getTrigger('clickup_task_tag_updated').subBlocks, + ...getTrigger('clickup_task_moved').subBlocks, + ...getTrigger('clickup_task_comment_posted').subBlocks, + ...getTrigger('clickup_task_comment_updated').subBlocks, + ...getTrigger('clickup_task_time_estimate_updated').subBlocks, + ...getTrigger('clickup_task_time_tracked_updated').subBlocks, + ...getTrigger('clickup_list_created').subBlocks, + ...getTrigger('clickup_list_updated').subBlocks, + ...getTrigger('clickup_list_deleted').subBlocks, + ...getTrigger('clickup_folder_created').subBlocks, + ...getTrigger('clickup_folder_updated').subBlocks, + ...getTrigger('clickup_folder_deleted').subBlocks, + ...getTrigger('clickup_space_created').subBlocks, + ...getTrigger('clickup_space_updated').subBlocks, + ...getTrigger('clickup_space_deleted').subBlocks, + ...getTrigger('clickup_goal_created').subBlocks, + ...getTrigger('clickup_goal_updated').subBlocks, + ...getTrigger('clickup_goal_deleted').subBlocks, + ...getTrigger('clickup_key_result_created').subBlocks, + ...getTrigger('clickup_key_result_updated').subBlocks, + ...getTrigger('clickup_key_result_deleted').subBlocks, + ...getTrigger('clickup_webhook').subBlocks, ], tools: { access: [ @@ -1206,7 +1298,7 @@ Return ONLY the value (plain string, number, or JSON) - no explanations, no extr case 'search_tasks': return { ...baseParams, - workspaceId: params.workspaceId, + workspaceId: params.teamId, page: optionalNumber(params.page), orderBy: params.orderBy && params.orderBy !== 'none' ? params.orderBy : undefined, reverse: params.reverse ? true : undefined, @@ -1300,7 +1392,7 @@ Return ONLY the value (plain string, number, or JSON) - no explanations, no extr case 'get_spaces': return { ...baseParams, - workspaceId: params.workspaceId, + workspaceId: params.teamId, archived: params.archived ? true : undefined, } case 'get_folders': @@ -1313,7 +1405,7 @@ Return ONLY the value (plain string, number, or JSON) - no explanations, no extr return { ...baseParams, folderId: params.listParent === 'space' ? undefined : params.folderId || undefined, - spaceId: params.listParent === 'space' ? params.listSpaceId || undefined : undefined, + spaceId: params.listParent === 'space' ? params.spaceId || undefined : undefined, archived: params.archived ? true : undefined, } case 'create_folder': @@ -1326,7 +1418,7 @@ Return ONLY the value (plain string, number, or JSON) - no explanations, no extr return { ...baseParams, folderId: params.listParent === 'space' ? undefined : params.folderId || undefined, - spaceId: params.listParent === 'space' ? params.listSpaceId || undefined : undefined, + spaceId: params.listParent === 'space' ? params.spaceId || undefined : undefined, name: params.name, content: params.content || undefined, markdownContent: params.markdownContent || undefined, @@ -1393,7 +1485,7 @@ Return ONLY the value (plain string, number, or JSON) - no explanations, no extr case 'get_time_entries': return { ...baseParams, - workspaceId: params.workspaceId, + workspaceId: params.teamId, startDate: optionalNumber(params.timeStartDate), endDate: optionalNumber(params.timeEndDate), assignee: params.timerAssignee || undefined, @@ -1415,7 +1507,7 @@ Return ONLY the value (plain string, number, or JSON) - no explanations, no extr case 'create_time_entry': return { ...baseParams, - workspaceId: params.workspaceId, + workspaceId: params.teamId, start: optionalNumber(params.entryStart), duration: optionalNumber(params.entryDuration), description: params.entryDescription || undefined, @@ -1426,7 +1518,7 @@ Return ONLY the value (plain string, number, or JSON) - no explanations, no extr case 'update_time_entry': return { ...baseParams, - workspaceId: params.workspaceId, + workspaceId: params.teamId, timerId: params.timerId, description: params.entryDescription || undefined, start: optionalNumber(params.entryStart), @@ -1438,13 +1530,13 @@ Return ONLY the value (plain string, number, or JSON) - no explanations, no extr case 'delete_time_entry': return { ...baseParams, - workspaceId: params.workspaceId, + workspaceId: params.teamId, timerId: params.timerId, } case 'start_timer': return { ...baseParams, - workspaceId: params.workspaceId, + workspaceId: params.teamId, taskId: params.timerTaskId || undefined, description: params.entryDescription || undefined, billable: billableFromAction(params.billableAction), @@ -1453,12 +1545,12 @@ Return ONLY the value (plain string, number, or JSON) - no explanations, no extr case 'stop_timer': return { ...baseParams, - workspaceId: params.workspaceId, + workspaceId: params.teamId, } case 'get_running_timer': return { ...baseParams, - workspaceId: params.workspaceId, + workspaceId: params.teamId, assignee: optionalNumber(params.entryAssignee), } default: @@ -1470,14 +1562,13 @@ Return ONLY the value (plain string, number, or JSON) - no explanations, no extr inputs: { operation: { type: 'string', description: 'Operation to perform' }, oauthCredential: { type: 'string', description: 'ClickUp OAuth credential' }, - workspaceId: { type: 'string', description: 'Workspace (team) ID' }, + teamId: { type: 'string', description: 'Workspace (team) ID' }, spaceId: { type: 'string', description: 'Space ID' }, listParent: { type: 'string', description: 'Where lists live for list operations (folder or space)', }, folderId: { type: 'string', description: 'Folder ID' }, - listSpaceId: { type: 'string', description: 'Space ID for folderless list operations' }, listId: { type: 'string', description: 'List ID' }, taskId: { type: 'string', description: 'Task ID' }, commentId: { type: 'string', description: 'Comment ID' }, @@ -1596,6 +1687,40 @@ Return ONLY the value (plain string, number, or JSON) - no explanations, no extr timeEntry: { type: 'json', description: 'Time entry details' }, timeEntries: { type: 'json', description: 'Array of time entries' }, }, + triggers: { + enabled: true, + available: [ + 'clickup_task_created', + 'clickup_task_updated', + 'clickup_task_deleted', + 'clickup_task_status_updated', + 'clickup_task_priority_updated', + 'clickup_task_assignee_updated', + 'clickup_task_due_date_updated', + 'clickup_task_tag_updated', + 'clickup_task_moved', + 'clickup_task_comment_posted', + 'clickup_task_comment_updated', + 'clickup_task_time_estimate_updated', + 'clickup_task_time_tracked_updated', + 'clickup_list_created', + 'clickup_list_updated', + 'clickup_list_deleted', + 'clickup_folder_created', + 'clickup_folder_updated', + 'clickup_folder_deleted', + 'clickup_space_created', + 'clickup_space_updated', + 'clickup_space_deleted', + 'clickup_goal_created', + 'clickup_goal_updated', + 'clickup_goal_deleted', + 'clickup_key_result_created', + 'clickup_key_result_updated', + 'clickup_key_result_deleted', + 'clickup_webhook', + ], + }, } export const ClickUpBlockMeta = { diff --git a/apps/sim/hooks/selectors/providers/clickup/selectors.ts b/apps/sim/hooks/selectors/providers/clickup/selectors.ts new file mode 100644 index 00000000000..15376272364 --- /dev/null +++ b/apps/sim/hooks/selectors/providers/clickup/selectors.ts @@ -0,0 +1,128 @@ +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 clickupSelectors = { + 'clickup.workspaces': { + key: 'clickup.workspaces', + contracts: [selectorContracts.clickupWorkspacesSelectorContract], + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'clickup.workspaces', + context.oauthCredential ?? 'none', + ], + enabled: ({ context }) => Boolean(context.oauthCredential), + fetchList: async ({ context, signal }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'clickup.workspaces') + const data = await requestJson(selectorContracts.clickupWorkspacesSelectorContract, { + body: { credential: credentialId, workflowId: context.workflowId }, + signal, + }) + return (data.workspaces || []).map((workspace) => ({ + id: workspace.id, + label: workspace.name, + })) + }, + }, + 'clickup.spaces': { + key: 'clickup.spaces', + contracts: [selectorContracts.clickupSpacesSelectorContract], + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'clickup.spaces', + context.oauthCredential ?? 'none', + context.teamId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.oauthCredential && context.teamId), + fetchList: async ({ context, signal }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'clickup.spaces') + if (!context.teamId) { + throw new Error('Missing workspace (team) ID for clickup.spaces selector') + } + const data = await requestJson(selectorContracts.clickupSpacesSelectorContract, { + body: { + credential: credentialId, + teamId: context.teamId, + workflowId: context.workflowId, + }, + signal, + }) + return (data.spaces || []).map((space) => ({ + id: space.id, + label: space.name, + })) + }, + }, + 'clickup.folders': { + key: 'clickup.folders', + contracts: [selectorContracts.clickupFoldersSelectorContract], + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'clickup.folders', + context.oauthCredential ?? 'none', + context.spaceId ?? 'none', + ], + enabled: ({ context }) => Boolean(context.oauthCredential && context.spaceId), + fetchList: async ({ context, signal }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'clickup.folders') + if (!context.spaceId) { + throw new Error('Missing space ID for clickup.folders selector') + } + const data = await requestJson(selectorContracts.clickupFoldersSelectorContract, { + body: { + credential: credentialId, + spaceId: context.spaceId, + workflowId: context.workflowId, + }, + signal, + }) + return (data.folders || []).map((folder) => ({ + id: folder.id, + label: folder.name, + })) + }, + }, + 'clickup.lists': { + key: 'clickup.lists', + contracts: [selectorContracts.clickupListsSelectorContract], + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'clickup.lists', + context.oauthCredential ?? 'none', + context.spaceId ?? 'none', + context.folderId ?? 'none', + ], + enabled: ({ context }) => + Boolean(context.oauthCredential && (context.folderId || context.spaceId)), + fetchList: async ({ context, signal }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'clickup.lists') + if (!context.folderId && !context.spaceId) { + throw new Error('Missing folder or space ID for clickup.lists selector') + } + const data = await requestJson(selectorContracts.clickupListsSelectorContract, { + body: { + credential: credentialId, + folderId: context.folderId, + spaceId: context.spaceId, + workflowId: context.workflowId, + }, + signal, + }) + return (data.lists || []).map((list) => ({ + id: list.id, + label: list.name, + })) + }, + }, +} satisfies Record< + Extract< + SelectorKey, + 'clickup.workspaces' | 'clickup.spaces' | 'clickup.folders' | 'clickup.lists' + >, + SelectorDefinition +> diff --git a/apps/sim/hooks/selectors/registry.ts b/apps/sim/hooks/selectors/registry.ts index e3af0f2b179..45e29e029b2 100644 --- a/apps/sim/hooks/selectors/registry.ts +++ b/apps/sim/hooks/selectors/registry.ts @@ -3,6 +3,7 @@ import { asanaSelectors } from '@/hooks/selectors/providers/asana/selectors' import { attioSelectors } from '@/hooks/selectors/providers/attio/selectors' import { bigquerySelectors } from '@/hooks/selectors/providers/bigquery/selectors' import { calcomSelectors } from '@/hooks/selectors/providers/calcom/selectors' +import { clickupSelectors } from '@/hooks/selectors/providers/clickup/selectors' import { cloudwatchSelectors } from '@/hooks/selectors/providers/cloudwatch/selectors' import { confluenceSelectors } from '@/hooks/selectors/providers/confluence/selectors' import { googleSelectors } from '@/hooks/selectors/providers/google/selectors' @@ -50,6 +51,7 @@ export const selectorRegistry = { ...linearSelectors, ...knowledgeSelectors, ...webflowSelectors, + ...clickupSelectors, ...cloudwatchSelectors, ...simSelectors, } satisfies Record diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts index 0b26fdd130f..1c4e9fe89ce 100644 --- a/apps/sim/hooks/selectors/types.ts +++ b/apps/sim/hooks/selectors/types.ts @@ -12,6 +12,10 @@ export type SelectorKey = | 'bigquery.tables' | 'calcom.eventTypes' | 'calcom.schedules' + | 'clickup.workspaces' + | 'clickup.spaces' + | 'clickup.folders' + | 'clickup.lists' | 'confluence.spaces' | 'google.tasks.lists' | 'jsm.requestTypes' @@ -87,6 +91,8 @@ export interface SelectorContext { serviceDeskId?: string impersonateUserEmail?: string boardId?: string + spaceId?: string + folderId?: string awsAccessKeyId?: string awsSecretAccessKey?: string awsRegion?: string diff --git a/apps/sim/lib/api/contracts/selectors/clickup.ts b/apps/sim/lib/api/contracts/selectors/clickup.ts new file mode 100644 index 00000000000..9c2577f95f1 --- /dev/null +++ b/apps/sim/lib/api/contracts/selectors/clickup.ts @@ -0,0 +1,73 @@ +import { z } from 'zod' +import { + credentialWorkflowBodySchema, + definePostSelector, + idNameSchema, + optionalString, +} from '@/lib/api/contracts/selectors/shared' +import type { ContractJsonResponse } from '@/lib/api/contracts/types' + +export const clickupWorkspacesBodySchema = credentialWorkflowBodySchema + +export const clickupSpacesBodySchema = credentialWorkflowBodySchema.extend({ + teamId: z.string().min(1, 'Workspace (team) ID is required'), +}) + +export const clickupFoldersBodySchema = credentialWorkflowBodySchema.extend({ + spaceId: z.string().min(1, 'Space ID is required'), +}) + +/** + * ClickUp lists live either inside a folder or directly in a space + * (folderless). The route dispatches on whichever ID is provided, preferring + * the folder when both are present. + */ +export const clickupListsBodySchema = credentialWorkflowBodySchema + .extend({ + folderId: optionalString, + spaceId: optionalString, + }) + .superRefine((body, ctx) => { + if (!body.folderId?.trim() && !body.spaceId?.trim()) { + ctx.addIssue({ + code: 'custom', + path: ['folderId'], + message: 'Either folderId or spaceId is required', + }) + } + }) + +export const clickupWorkspacesSelectorContract = definePostSelector( + '/api/tools/clickup/workspaces', + clickupWorkspacesBodySchema, + z.object({ workspaces: z.array(idNameSchema) }) +) + +export const clickupSpacesSelectorContract = definePostSelector( + '/api/tools/clickup/spaces', + clickupSpacesBodySchema, + z.object({ spaces: z.array(idNameSchema) }) +) + +export const clickupFoldersSelectorContract = definePostSelector( + '/api/tools/clickup/folders', + clickupFoldersBodySchema, + z.object({ folders: z.array(idNameSchema) }) +) + +export const clickupListsSelectorContract = definePostSelector( + '/api/tools/clickup/lists', + clickupListsBodySchema, + z.object({ lists: z.array(idNameSchema) }) +) + +export type ClickupWorkspacesSelectorResponse = ContractJsonResponse< + typeof clickupWorkspacesSelectorContract +> +export type ClickupSpacesSelectorResponse = ContractJsonResponse< + typeof clickupSpacesSelectorContract +> +export type ClickupFoldersSelectorResponse = ContractJsonResponse< + typeof clickupFoldersSelectorContract +> +export type ClickupListsSelectorResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/selectors/index.ts b/apps/sim/lib/api/contracts/selectors/index.ts index 8bab27ce372..7c4ac0d5005 100644 --- a/apps/sim/lib/api/contracts/selectors/index.ts +++ b/apps/sim/lib/api/contracts/selectors/index.ts @@ -15,6 +15,12 @@ import { calcomEventTypesSelectorContract, calcomSchedulesSelectorContract, } from '@/lib/api/contracts/selectors/calcom' +import { + clickupFoldersSelectorContract, + clickupListsSelectorContract, + clickupSpacesSelectorContract, + clickupWorkspacesSelectorContract, +} from '@/lib/api/contracts/selectors/clickup' import { cloudwatchLogGroupsSelectorContract, cloudwatchLogStreamsSelectorContract, @@ -107,6 +113,7 @@ export * from '@/lib/api/contracts/selectors/asana' export * from '@/lib/api/contracts/selectors/attio' export * from '@/lib/api/contracts/selectors/bigquery' export * from '@/lib/api/contracts/selectors/calcom' +export * from '@/lib/api/contracts/selectors/clickup' export * from '@/lib/api/contracts/selectors/cloudwatch' export * from '@/lib/api/contracts/selectors/confluence' export * from '@/lib/api/contracts/selectors/google' @@ -137,6 +144,10 @@ export const selectorContractsByPath = { '/api/tools/google_bigquery/tables': bigQueryTablesSelectorContract, '/api/tools/calcom/event-types': calcomEventTypesSelectorContract, '/api/tools/calcom/schedules': calcomSchedulesSelectorContract, + '/api/tools/clickup/workspaces': clickupWorkspacesSelectorContract, + '/api/tools/clickup/spaces': clickupSpacesSelectorContract, + '/api/tools/clickup/folders': clickupFoldersSelectorContract, + '/api/tools/clickup/lists': clickupListsSelectorContract, '/api/tools/confluence/selector-spaces': confluenceSpacesSelectorContract, '/api/tools/jsm/selector-servicedesks': jsmServiceDesksSelectorContract, '/api/tools/jsm/selector-requesttypes': jsmRequestTypesSelectorContract, diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 0b597e2e7e2..de2f74c9522 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -3244,7 +3244,7 @@ "slug": "clickup", "name": "ClickUp", "description": "Interact with ClickUp", - "longDescription": "Integrate ClickUp into the workflow. Create, read, update, and delete tasks, manage comments, tags, folders, and lists, upload attachments, and look up workspaces, members, and custom fields.", + "longDescription": "Integrate ClickUp into the workflow. Create, read, update, and delete tasks, manage comments, tags, folders, and lists, upload attachments, and look up workspaces, members, and custom fields. Can also trigger workflows on ClickUp events like task, list, folder, space, and goal changes.", "bgColor": "#FFFFFF", "iconName": "ClickUpIcon", "docsUrl": "https://docs.sim.ai/integrations/clickup", @@ -3343,7 +3343,7 @@ }, { "name": "Update Checklist Item", - "description": "Update a checklist item on a ClickUp task \u2014 rename, assign, resolve, or nest it" + "description": "Update a checklist item on a ClickUp task — rename, assign, resolve, or nest it" }, { "name": "Delete Checklist Item", @@ -3359,7 +3359,7 @@ }, { "name": "Update Time Entry", - "description": "Update a time entry in a ClickUp workspace \u2014 description, start/end times, task, or billable state" + "description": "Update a time entry in a ClickUp workspace — description, start/end times, task, or billable state" }, { "name": "Delete Time Entry", @@ -3403,8 +3403,154 @@ } ], "operationCount": 38, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "clickup_task_created", + "name": "ClickUp Task Created", + "description": "Trigger workflow when a task is created in ClickUp" + }, + { + "id": "clickup_task_updated", + "name": "ClickUp Task Updated", + "description": "Trigger workflow when a task is updated in ClickUp" + }, + { + "id": "clickup_task_deleted", + "name": "ClickUp Task Deleted", + "description": "Trigger workflow when a task is deleted in ClickUp" + }, + { + "id": "clickup_task_status_updated", + "name": "ClickUp Task Status Updated", + "description": "Trigger workflow when the status of a task changes in ClickUp" + }, + { + "id": "clickup_task_priority_updated", + "name": "ClickUp Task Priority Updated", + "description": "Trigger workflow when the priority of a task changes in ClickUp" + }, + { + "id": "clickup_task_assignee_updated", + "name": "ClickUp Task Assignee Updated", + "description": "Trigger workflow when the assignees of a task change in ClickUp" + }, + { + "id": "clickup_task_due_date_updated", + "name": "ClickUp Task Due Date Updated", + "description": "Trigger workflow when the due date of a task changes in ClickUp" + }, + { + "id": "clickup_task_tag_updated", + "name": "ClickUp Task Tag Updated", + "description": "Trigger workflow when the tags of a task change in ClickUp" + }, + { + "id": "clickup_task_moved", + "name": "ClickUp Task Moved", + "description": "Trigger workflow when a task is moved to a different list in ClickUp" + }, + { + "id": "clickup_task_comment_posted", + "name": "ClickUp Task Comment Posted", + "description": "Trigger workflow when a comment is posted on a task in ClickUp" + }, + { + "id": "clickup_task_comment_updated", + "name": "ClickUp Task Comment Updated", + "description": "Trigger workflow when a task comment is updated in ClickUp" + }, + { + "id": "clickup_task_time_estimate_updated", + "name": "ClickUp Task Time Estimate Updated", + "description": "Trigger workflow when the time estimate of a task changes in ClickUp" + }, + { + "id": "clickup_task_time_tracked_updated", + "name": "ClickUp Task Time Tracked Updated", + "description": "Trigger workflow when the tracked time of a task changes in ClickUp" + }, + { + "id": "clickup_list_created", + "name": "ClickUp List Created", + "description": "Trigger workflow when a list is created in ClickUp" + }, + { + "id": "clickup_list_updated", + "name": "ClickUp List Updated", + "description": "Trigger workflow when a list is updated in ClickUp" + }, + { + "id": "clickup_list_deleted", + "name": "ClickUp List Deleted", + "description": "Trigger workflow when a list is deleted in ClickUp" + }, + { + "id": "clickup_folder_created", + "name": "ClickUp Folder Created", + "description": "Trigger workflow when a folder is created in ClickUp" + }, + { + "id": "clickup_folder_updated", + "name": "ClickUp Folder Updated", + "description": "Trigger workflow when a folder is updated in ClickUp" + }, + { + "id": "clickup_folder_deleted", + "name": "ClickUp Folder Deleted", + "description": "Trigger workflow when a folder is deleted in ClickUp" + }, + { + "id": "clickup_space_created", + "name": "ClickUp Space Created", + "description": "Trigger workflow when a space is created in ClickUp" + }, + { + "id": "clickup_space_updated", + "name": "ClickUp Space Updated", + "description": "Trigger workflow when a space is updated in ClickUp" + }, + { + "id": "clickup_space_deleted", + "name": "ClickUp Space Deleted", + "description": "Trigger workflow when a space is deleted in ClickUp" + }, + { + "id": "clickup_goal_created", + "name": "ClickUp Goal Created", + "description": "Trigger workflow when a goal is created in ClickUp" + }, + { + "id": "clickup_goal_updated", + "name": "ClickUp Goal Updated", + "description": "Trigger workflow when a goal is updated in ClickUp" + }, + { + "id": "clickup_goal_deleted", + "name": "ClickUp Goal Deleted", + "description": "Trigger workflow when a goal is deleted in ClickUp" + }, + { + "id": "clickup_key_result_created", + "name": "ClickUp Key Result Created", + "description": "Trigger workflow when a key result is created in ClickUp" + }, + { + "id": "clickup_key_result_updated", + "name": "ClickUp Key Result Updated", + "description": "Trigger workflow when a key result is updated in ClickUp" + }, + { + "id": "clickup_key_result_deleted", + "name": "ClickUp Key Result Deleted", + "description": "Trigger workflow when a key result is deleted in ClickUp" + }, + { + "id": "clickup_webhook", + "name": "ClickUp Webhook", + "description": "Trigger workflow on any ClickUp event (subscribes to all events)" + } + ], + "triggerCount": 29, "authType": "oauth", "oauthServiceId": "clickup", "category": "tools", diff --git a/apps/sim/lib/webhooks/providers/clickup.ts b/apps/sim/lib/webhooks/providers/clickup.ts new file mode 100644 index 00000000000..5a1ac4069eb --- /dev/null +++ b/apps/sim/lib/webhooks/providers/clickup.ts @@ -0,0 +1,356 @@ +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' +import { toError } from '@sim/utils/errors' +import { NextResponse } from 'next/server' +import { + getCredentialOwner, + getNotificationUrl, + getProviderConfig, +} from '@/lib/webhooks/provider-subscription-utils' +import type { + DeleteSubscriptionContext, + EventMatchContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared' + +const logger = createLogger('WebhookProvider:ClickUp') + +function validateClickUpSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + return false + } + const computedHash = hmacSha256Hex(body, secret) + return safeCompare(computedHash, signature) + } catch (error) { + logger.error('Error validating ClickUp signature:', error) + return false + } +} + +/** + * Resolves the OAuth access token for the credential stored in the webhook's + * provider config. Throws a user-facing error when the credential is missing + * or the token cannot be retrieved. + */ +async function resolveClickUpAccessToken( + credentialId: string | undefined, + requestId: string +): Promise { + if (!credentialId) { + throw new Error( + 'ClickUp account connection required. Please connect your ClickUp account in the trigger configuration and try again.' + ) + } + + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded(credentialOwner.accountId, credentialOwner.userId, requestId) + : null + + if (!accessToken) { + throw new Error( + 'ClickUp account connection required. Please connect your ClickUp account in the trigger configuration and try again.' + ) + } + + return accessToken +} + +/** + * Parses an optional numeric location filter (space, folder, list). ClickUp + * expects these as integers in the create-webhook body. + */ +function parseOptionalNumericId(value: unknown, label: string): number | undefined { + if (typeof value !== 'string' && typeof value !== 'number') return undefined + const raw = String(value).trim() + if (!raw) return undefined + const parsed = Number(raw) + if (!Number.isFinite(parsed)) { + throw new Error(`ClickUp ${label} must be numeric. Received: ${raw}`) + } + return parsed +} + +function parseOptionalStringId(value: unknown): string | undefined { + if (typeof value !== 'string' && typeof value !== 'number') return undefined + const raw = String(value).trim() + return raw || undefined +} + +async function deleteClickUpWebhook(accessToken: string, externalId: string): Promise { + return fetch(`${CLICKUP_API_BASE_URL}/webhook/${externalId}`, { + method: 'DELETE', + headers: { Authorization: clickupAuthorizationHeader(accessToken) }, + }) +} + +export const clickupHandler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-Signature', + validateFn: validateClickUpSignature, + providerLabel: 'ClickUp', + requireSecret: true, + }), + + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + const obj = body as Record + + if (triggerId && triggerId !== 'clickup_webhook') { + const { isClickUpEventMatch, getClickUpEventType } = await import('@/triggers/clickup/utils') + if (!isClickUpEventMatch(triggerId, obj)) { + logger.debug( + `[${requestId}] ClickUp event mismatch for trigger ${triggerId}. Event: ${getClickUpEventType(obj)}. Skipping execution.`, + { + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + } + ) + return NextResponse.json({ + status: 'skipped', + reason: 'event_type_mismatch', + }) + } + } + + return true + }, + + extractIdempotencyId(body: unknown) { + const obj = body as Record + const event = obj.event + if (typeof event !== 'string') return null + + const historyItems = Array.isArray(obj.history_items) ? obj.history_items : [] + const firstItem = historyItems[0] as Record | undefined + const historyId = firstItem?.id + if (!historyId) return null + + const resourceId = obj.task_id ?? obj.list_id ?? obj.folder_id ?? obj.space_id ?? '' + return `clickup:${event}:${resourceId}:${historyId}` + }, + + async createSubscription({ + webhook: webhookRecord, + requestId, + }: SubscriptionContext): Promise { + try { + const config = getProviderConfig(webhookRecord) + const triggerId = config.triggerId as string | undefined + const credentialId = config.credentialId as string | undefined + + const accessToken = await resolveClickUpAccessToken(credentialId, requestId) + + const workspaceId = parseOptionalStringId(config.triggerWorkspaceId) + if (!workspaceId) { + throw new Error( + 'ClickUp workspace is required. Please select a workspace in the trigger configuration and try again.' + ) + } + + let events: string[] + if (triggerId === 'clickup_webhook') { + events = ['*'] + } else { + const { CLICKUP_TRIGGER_EVENT_MAP } = await import('@/triggers/clickup/utils') + const mappedEvents = CLICKUP_TRIGGER_EVENT_MAP[triggerId as string] + if (!mappedEvents || mappedEvents.length === 0) { + throw new Error(`Unknown ClickUp trigger type: ${triggerId}`) + } + events = mappedEvents + } + + const requestBody: Record = { + endpoint: getNotificationUrl(webhookRecord), + events, + } + + const spaceId = parseOptionalNumericId(config.triggerSpaceId, 'Space ID') + const folderId = parseOptionalNumericId(config.triggerFolderId, 'Folder ID') + const listId = parseOptionalNumericId(config.triggerListId, 'List ID') + const taskId = parseOptionalStringId(config.triggerTaskId) + if (spaceId !== undefined) requestBody.space_id = spaceId + if (folderId !== undefined) requestBody.folder_id = folderId + if (listId !== undefined) requestBody.list_id = listId + if (taskId !== undefined) requestBody.task_id = taskId + + const clickupResponse = await fetch( + `${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(workspaceId)}/webhook`, + { + method: 'POST', + headers: { + Authorization: clickupAuthorizationHeader(accessToken), + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + } + ) + + if (!clickupResponse.ok) { + const errorBody = (await clickupResponse.json().catch(() => ({}))) as { err?: string } + logger.error( + `[${requestId}] Failed to create webhook in ClickUp for webhook ${webhookRecord.id}. Status: ${clickupResponse.status}`, + { response: errorBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in ClickUp' + if (clickupResponse.status === 401) { + userFriendlyMessage = + 'ClickUp authentication failed. Please reconnect your ClickUp account.' + } else if (clickupResponse.status === 403) { + userFriendlyMessage = + 'ClickUp access denied. Please ensure your account can manage webhooks in this workspace.' + } else if (errorBody.err) { + userFriendlyMessage = `Failed to create webhook subscription in ClickUp: ${errorBody.err}` + } + + throw new Error(userFriendlyMessage) + } + + const responseBody = (await clickupResponse.json().catch(() => ({}))) as { + id?: string + webhook?: { id?: string; secret?: string } + } + const externalId = responseBody.id ?? responseBody.webhook?.id + const secret = responseBody.webhook?.secret + + if (!externalId) { + logger.error( + `[${requestId}] ClickUp webhook created but no webhook id returned for webhook ${webhookRecord.id}`, + { response: responseBody } + ) + throw new Error('ClickUp webhook creation succeeded but no webhook ID was returned') + } + + if (!secret) { + logger.error( + `[${requestId}] ClickUp webhook created but no secret returned for webhook ${webhookRecord.id}. Rolling back.`, + { response: responseBody } + ) + await deleteClickUpWebhook(accessToken, externalId).catch(() => undefined) + throw new Error('ClickUp webhook creation succeeded but no signing secret was returned') + } + + logger.info( + `[${requestId}] Successfully created webhook in ClickUp for webhook ${webhookRecord.id}.`, + { + clickupWebhookId: externalId, + workspaceId, + events, + } + ) + + return { providerConfigUpdates: { externalId, webhookSecret: secret } } + } catch (error: unknown) { + const message = toError(error).message + logger.error( + `[${requestId}] Exception during ClickUp webhook creation for webhook ${webhookRecord.id}.`, + { message } + ) + throw error + } + }, + + async deleteSubscription({ + webhook: webhookRecord, + requestId, + strict, + }: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(webhookRecord) + const externalId = config.externalId as string | undefined + const credentialId = config.credentialId as string | undefined + + if (!externalId) { + logger.warn( + `[${requestId}] Missing externalId for ClickUp webhook deletion ${webhookRecord.id}, skipping cleanup` + ) + if (strict) throw new Error('Missing ClickUp externalId for webhook deletion') + return + } + + if (!credentialId) { + logger.warn( + `[${requestId}] Missing credentialId for ClickUp webhook deletion ${webhookRecord.id}, skipping cleanup` + ) + if (strict) throw new Error('Missing ClickUp credentialId for webhook deletion') + return + } + + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + + if (!accessToken) { + const message = `[${requestId}] Could not retrieve ClickUp access token. Cannot delete webhook.` + logger.warn(message, { webhookId: webhookRecord.id }) + if (strict) throw new Error(message) + return + } + + const clickupResponse = await deleteClickUpWebhook(accessToken, externalId) + + if (!clickupResponse.ok && clickupResponse.status !== 404) { + const responseBody = await clickupResponse.json().catch(() => ({})) + logger.warn( + `[${requestId}] Failed to delete ClickUp webhook (non-fatal): ${clickupResponse.status}`, + { response: responseBody } + ) + if (strict) throw new Error(`Failed to delete ClickUp webhook: ${clickupResponse.status}`) + } else { + logger.info(`[${requestId}] Successfully deleted ClickUp webhook ${externalId}`) + } + } catch (error) { + logger.warn(`[${requestId}] Error deleting ClickUp webhook (non-fatal)`, error) + if (strict) throw error + } + }, + + async formatInput({ body, webhook }: FormatInputContext): Promise { + const { + extractClickUpTaskData, + extractClickUpListData, + extractClickUpFolderData, + extractClickUpSpaceData, + extractClickUpGoalData, + extractClickUpGenericData, + } = await import('@/triggers/clickup/utils') + + const b = body as Record + const providerConfig = (webhook.providerConfig as Record) || {} + const triggerId = providerConfig.triggerId as string | undefined + + if (triggerId?.startsWith('clickup_task_')) { + return { input: extractClickUpTaskData(b) } + } + if (triggerId?.startsWith('clickup_list_')) { + return { input: extractClickUpListData(b) } + } + if (triggerId?.startsWith('clickup_folder_')) { + return { input: extractClickUpFolderData(b) } + } + if (triggerId?.startsWith('clickup_space_')) { + return { input: extractClickUpSpaceData(b) } + } + if (triggerId?.startsWith('clickup_goal_') || triggerId?.startsWith('clickup_key_result_')) { + return { input: extractClickUpGoalData(b) } + } + return { input: extractClickUpGenericData(b) } + }, +} diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index e6417472c1f..ac89b403fa8 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -8,6 +8,7 @@ import { calcomHandler } from '@/lib/webhooks/providers/calcom' import { calendlyHandler } from '@/lib/webhooks/providers/calendly' import { circlebackHandler } from '@/lib/webhooks/providers/circleback' import { clerkHandler } from '@/lib/webhooks/providers/clerk' +import { clickupHandler } from '@/lib/webhooks/providers/clickup' import { confluenceHandler } from '@/lib/webhooks/providers/confluence' import { emailBisonHandler } from '@/lib/webhooks/providers/emailbison' import { fathomHandler } from '@/lib/webhooks/providers/fathom' @@ -70,6 +71,7 @@ const PROVIDER_HANDLERS: Record = { calcom: calcomHandler, circleback: circlebackHandler, clerk: clerkHandler, + clickup: clickupHandler, confluence: confluenceHandler, emailbison: emailBisonHandler, fireflies: firefliesHandler, diff --git a/apps/sim/lib/workflows/subblocks/context.ts b/apps/sim/lib/workflows/subblocks/context.ts index 4b71917e460..1f779b16c00 100644 --- a/apps/sim/lib/workflows/subblocks/context.ts +++ b/apps/sim/lib/workflows/subblocks/context.ts @@ -29,6 +29,8 @@ export const SELECTOR_CONTEXT_FIELDS = new Set([ 'serviceDeskId', 'impersonateUserEmail', 'boardId', + 'spaceId', + 'folderId', 'awsAccessKeyId', 'awsSecretAccessKey', 'awsRegion', diff --git a/apps/sim/triggers/clickup/folder_created.ts b/apps/sim/triggers/clickup/folder_created.ts new file mode 100644 index 00000000000..2f653a014ef --- /dev/null +++ b/apps/sim/triggers/clickup/folder_created.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpFolderOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupFolderCreatedTrigger: TriggerConfig = { + id: 'clickup_folder_created', + name: 'ClickUp Folder Created', + provider: 'clickup', + description: 'Trigger workflow when a folder is created in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_folder_created'), + outputs: buildClickUpFolderOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/folder_deleted.ts b/apps/sim/triggers/clickup/folder_deleted.ts new file mode 100644 index 00000000000..bf3b761db09 --- /dev/null +++ b/apps/sim/triggers/clickup/folder_deleted.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpFolderOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupFolderDeletedTrigger: TriggerConfig = { + id: 'clickup_folder_deleted', + name: 'ClickUp Folder Deleted', + provider: 'clickup', + description: 'Trigger workflow when a folder is deleted in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_folder_deleted'), + outputs: buildClickUpFolderOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/folder_updated.ts b/apps/sim/triggers/clickup/folder_updated.ts new file mode 100644 index 00000000000..6dbbf68821b --- /dev/null +++ b/apps/sim/triggers/clickup/folder_updated.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpFolderOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupFolderUpdatedTrigger: TriggerConfig = { + id: 'clickup_folder_updated', + name: 'ClickUp Folder Updated', + provider: 'clickup', + description: 'Trigger workflow when a folder is updated in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_folder_updated'), + outputs: buildClickUpFolderOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/goal_created.ts b/apps/sim/triggers/clickup/goal_created.ts new file mode 100644 index 00000000000..1d5f2ac941a --- /dev/null +++ b/apps/sim/triggers/clickup/goal_created.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpGoalOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupGoalCreatedTrigger: TriggerConfig = { + id: 'clickup_goal_created', + name: 'ClickUp Goal Created', + provider: 'clickup', + description: 'Trigger workflow when a goal is created in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_goal_created'), + outputs: buildClickUpGoalOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/goal_deleted.ts b/apps/sim/triggers/clickup/goal_deleted.ts new file mode 100644 index 00000000000..dcfa386d8fd --- /dev/null +++ b/apps/sim/triggers/clickup/goal_deleted.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpGoalOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupGoalDeletedTrigger: TriggerConfig = { + id: 'clickup_goal_deleted', + name: 'ClickUp Goal Deleted', + provider: 'clickup', + description: 'Trigger workflow when a goal is deleted in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_goal_deleted'), + outputs: buildClickUpGoalOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/goal_updated.ts b/apps/sim/triggers/clickup/goal_updated.ts new file mode 100644 index 00000000000..6136fd6ecd3 --- /dev/null +++ b/apps/sim/triggers/clickup/goal_updated.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpGoalOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupGoalUpdatedTrigger: TriggerConfig = { + id: 'clickup_goal_updated', + name: 'ClickUp Goal Updated', + provider: 'clickup', + description: 'Trigger workflow when a goal is updated in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_goal_updated'), + outputs: buildClickUpGoalOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/index.ts b/apps/sim/triggers/clickup/index.ts new file mode 100644 index 00000000000..84114afbdfa --- /dev/null +++ b/apps/sim/triggers/clickup/index.ts @@ -0,0 +1,29 @@ +export { clickupFolderCreatedTrigger } from './folder_created' +export { clickupFolderDeletedTrigger } from './folder_deleted' +export { clickupFolderUpdatedTrigger } from './folder_updated' +export { clickupGoalCreatedTrigger } from './goal_created' +export { clickupGoalDeletedTrigger } from './goal_deleted' +export { clickupGoalUpdatedTrigger } from './goal_updated' +export { clickupKeyResultCreatedTrigger } from './key_result_created' +export { clickupKeyResultDeletedTrigger } from './key_result_deleted' +export { clickupKeyResultUpdatedTrigger } from './key_result_updated' +export { clickupListCreatedTrigger } from './list_created' +export { clickupListDeletedTrigger } from './list_deleted' +export { clickupListUpdatedTrigger } from './list_updated' +export { clickupSpaceCreatedTrigger } from './space_created' +export { clickupSpaceDeletedTrigger } from './space_deleted' +export { clickupSpaceUpdatedTrigger } from './space_updated' +export { clickupTaskAssigneeUpdatedTrigger } from './task_assignee_updated' +export { clickupTaskCommentPostedTrigger } from './task_comment_posted' +export { clickupTaskCommentUpdatedTrigger } from './task_comment_updated' +export { clickupTaskCreatedTrigger } from './task_created' +export { clickupTaskDeletedTrigger } from './task_deleted' +export { clickupTaskDueDateUpdatedTrigger } from './task_due_date_updated' +export { clickupTaskMovedTrigger } from './task_moved' +export { clickupTaskPriorityUpdatedTrigger } from './task_priority_updated' +export { clickupTaskStatusUpdatedTrigger } from './task_status_updated' +export { clickupTaskTagUpdatedTrigger } from './task_tag_updated' +export { clickupTaskTimeEstimateUpdatedTrigger } from './task_time_estimate_updated' +export { clickupTaskTimeTrackedUpdatedTrigger } from './task_time_tracked_updated' +export { clickupTaskUpdatedTrigger } from './task_updated' +export { clickupWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/clickup/key_result_created.ts b/apps/sim/triggers/clickup/key_result_created.ts new file mode 100644 index 00000000000..4efb5fb0419 --- /dev/null +++ b/apps/sim/triggers/clickup/key_result_created.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpGoalOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupKeyResultCreatedTrigger: TriggerConfig = { + id: 'clickup_key_result_created', + name: 'ClickUp Key Result Created', + provider: 'clickup', + description: 'Trigger workflow when a key result is created in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_key_result_created'), + outputs: buildClickUpGoalOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/key_result_deleted.ts b/apps/sim/triggers/clickup/key_result_deleted.ts new file mode 100644 index 00000000000..0c38ef998fb --- /dev/null +++ b/apps/sim/triggers/clickup/key_result_deleted.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpGoalOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupKeyResultDeletedTrigger: TriggerConfig = { + id: 'clickup_key_result_deleted', + name: 'ClickUp Key Result Deleted', + provider: 'clickup', + description: 'Trigger workflow when a key result is deleted in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_key_result_deleted'), + outputs: buildClickUpGoalOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/key_result_updated.ts b/apps/sim/triggers/clickup/key_result_updated.ts new file mode 100644 index 00000000000..328912bcb4b --- /dev/null +++ b/apps/sim/triggers/clickup/key_result_updated.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpGoalOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupKeyResultUpdatedTrigger: TriggerConfig = { + id: 'clickup_key_result_updated', + name: 'ClickUp Key Result Updated', + provider: 'clickup', + description: 'Trigger workflow when a key result is updated in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_key_result_updated'), + outputs: buildClickUpGoalOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/list_created.ts b/apps/sim/triggers/clickup/list_created.ts new file mode 100644 index 00000000000..171b0cfd619 --- /dev/null +++ b/apps/sim/triggers/clickup/list_created.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpListOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupListCreatedTrigger: TriggerConfig = { + id: 'clickup_list_created', + name: 'ClickUp List Created', + provider: 'clickup', + description: 'Trigger workflow when a list is created in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_list_created'), + outputs: buildClickUpListOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/list_deleted.ts b/apps/sim/triggers/clickup/list_deleted.ts new file mode 100644 index 00000000000..5b9ded1fbc7 --- /dev/null +++ b/apps/sim/triggers/clickup/list_deleted.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpListOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupListDeletedTrigger: TriggerConfig = { + id: 'clickup_list_deleted', + name: 'ClickUp List Deleted', + provider: 'clickup', + description: 'Trigger workflow when a list is deleted in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_list_deleted'), + outputs: buildClickUpListOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/list_updated.ts b/apps/sim/triggers/clickup/list_updated.ts new file mode 100644 index 00000000000..e032ee01bf7 --- /dev/null +++ b/apps/sim/triggers/clickup/list_updated.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpListOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupListUpdatedTrigger: TriggerConfig = { + id: 'clickup_list_updated', + name: 'ClickUp List Updated', + provider: 'clickup', + description: 'Trigger workflow when a list is updated in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_list_updated'), + outputs: buildClickUpListOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/space_created.ts b/apps/sim/triggers/clickup/space_created.ts new file mode 100644 index 00000000000..29e4f106265 --- /dev/null +++ b/apps/sim/triggers/clickup/space_created.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpSpaceOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupSpaceCreatedTrigger: TriggerConfig = { + id: 'clickup_space_created', + name: 'ClickUp Space Created', + provider: 'clickup', + description: 'Trigger workflow when a space is created in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_space_created'), + outputs: buildClickUpSpaceOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/space_deleted.ts b/apps/sim/triggers/clickup/space_deleted.ts new file mode 100644 index 00000000000..f52c99d7e51 --- /dev/null +++ b/apps/sim/triggers/clickup/space_deleted.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpSpaceOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupSpaceDeletedTrigger: TriggerConfig = { + id: 'clickup_space_deleted', + name: 'ClickUp Space Deleted', + provider: 'clickup', + description: 'Trigger workflow when a space is deleted in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_space_deleted'), + outputs: buildClickUpSpaceOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/space_updated.ts b/apps/sim/triggers/clickup/space_updated.ts new file mode 100644 index 00000000000..a19eaa179a2 --- /dev/null +++ b/apps/sim/triggers/clickup/space_updated.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpSpaceOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupSpaceUpdatedTrigger: TriggerConfig = { + id: 'clickup_space_updated', + name: 'ClickUp Space Updated', + provider: 'clickup', + description: 'Trigger workflow when a space is updated in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_space_updated'), + outputs: buildClickUpSpaceOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/subblocks.ts b/apps/sim/triggers/clickup/subblocks.ts new file mode 100644 index 00000000000..2bebbf9244f --- /dev/null +++ b/apps/sim/triggers/clickup/subblocks.ts @@ -0,0 +1,113 @@ +import { createLogger } from '@sim/logger' +import { requestJson } from '@/lib/api/client/request' +import { clickupWorkspacesSelectorContract } from '@/lib/api/contracts/selectors/clickup' +import type { SubBlockConfig } from '@/blocks/types' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { clickupSetupInstructions } from '@/triggers/clickup/utils' + +const logger = createLogger('ClickUpTriggerSubBlocks') + +async function fetchWorkspaceOptions( + blockId: string +): Promise> { + const credentialId = useSubBlockStore.getState().getValue(blockId, 'triggerCredentials') as + | string + | null + if (!credentialId) { + throw new Error('No ClickUp credential selected') + } + try { + const data = await requestJson(clickupWorkspacesSelectorContract, { + body: { credential: credentialId }, + }) + return (data.workspaces ?? []).map((workspace) => ({ + id: workspace.id, + label: workspace.name, + })) + } catch (error) { + logger.error('Error fetching ClickUp workspaces:', error) + throw error + } +} + +/** + * Builds the shared subBlocks for a ClickUp trigger: OAuth credentials, the + * workspace selector the webhook is registered in, optional location scoping + * (space, folder, list, task), and setup instructions. Used by the primary + * trigger (after its dropdown) and all secondary triggers. + */ +export function buildClickUpTriggerSubBlocks(triggerId: string): SubBlockConfig[] { + return [ + { + id: 'triggerCredentials', + title: 'ClickUp Account', + type: 'oauth-input', + serviceId: 'clickup', + requiredScopes: [], + mode: 'trigger', + required: true, + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + { + id: 'triggerWorkspaceId', + title: 'Workspace', + type: 'dropdown', + placeholder: 'Select a workspace', + description: 'The ClickUp Workspace the webhook is registered in', + required: true, + options: [], + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + fetchOptions: fetchWorkspaceOptions, + fetchOptionById: async (blockId: string, optionId: string) => { + const workspaces = await fetchWorkspaceOptions(blockId) + return workspaces.find((workspace) => workspace.id === optionId) ?? null + }, + }, + { + id: 'triggerSpaceId', + title: 'Space ID (Optional)', + type: 'short-input', + placeholder: 'Leave empty for the entire workspace', + description: 'Only receive events from this space', + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + { + id: 'triggerFolderId', + title: 'Folder ID (Optional)', + type: 'short-input', + placeholder: 'Leave empty for the entire workspace', + description: 'Only receive events from this folder', + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + { + id: 'triggerListId', + title: 'List ID (Optional)', + type: 'short-input', + placeholder: 'Leave empty for the entire workspace', + description: 'Only receive events from this list', + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + { + id: 'triggerTaskId', + title: 'Task ID (Optional)', + type: 'short-input', + placeholder: 'Leave empty for the entire workspace', + description: 'Only receive events for this task', + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + hideFromPreview: true, + type: 'text', + defaultValue: clickupSetupInstructions(), + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + ] +} diff --git a/apps/sim/triggers/clickup/task_assignee_updated.ts b/apps/sim/triggers/clickup/task_assignee_updated.ts new file mode 100644 index 00000000000..23466b0d74b --- /dev/null +++ b/apps/sim/triggers/clickup/task_assignee_updated.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskAssigneeUpdatedTrigger: TriggerConfig = { + id: 'clickup_task_assignee_updated', + name: 'ClickUp Task Assignee Updated', + provider: 'clickup', + description: 'Trigger workflow when the assignees of a task change in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_task_assignee_updated'), + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/task_comment_posted.ts b/apps/sim/triggers/clickup/task_comment_posted.ts new file mode 100644 index 00000000000..af3021a419f --- /dev/null +++ b/apps/sim/triggers/clickup/task_comment_posted.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskCommentPostedTrigger: TriggerConfig = { + id: 'clickup_task_comment_posted', + name: 'ClickUp Task Comment Posted', + provider: 'clickup', + description: 'Trigger workflow when a comment is posted on a task in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_task_comment_posted'), + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/task_comment_updated.ts b/apps/sim/triggers/clickup/task_comment_updated.ts new file mode 100644 index 00000000000..13844b512a0 --- /dev/null +++ b/apps/sim/triggers/clickup/task_comment_updated.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskCommentUpdatedTrigger: TriggerConfig = { + id: 'clickup_task_comment_updated', + name: 'ClickUp Task Comment Updated', + provider: 'clickup', + description: 'Trigger workflow when a task comment is updated in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_task_comment_updated'), + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/task_created.ts b/apps/sim/triggers/clickup/task_created.ts new file mode 100644 index 00000000000..acae43ed846 --- /dev/null +++ b/apps/sim/triggers/clickup/task_created.ts @@ -0,0 +1,30 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs, clickupTriggerOptions } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskCreatedTrigger: TriggerConfig = { + id: 'clickup_task_created', + name: 'ClickUp Task Created', + provider: 'clickup', + description: 'Trigger workflow when a task is created in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: [ + { + id: 'selectedTriggerId', + title: 'Trigger Type', + type: 'dropdown', + mode: 'trigger', + options: clickupTriggerOptions, + value: () => 'clickup_task_created', + required: true, + }, + ...buildClickUpTriggerSubBlocks('clickup_task_created'), + ], + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/task_deleted.ts b/apps/sim/triggers/clickup/task_deleted.ts new file mode 100644 index 00000000000..cd60f56f5a4 --- /dev/null +++ b/apps/sim/triggers/clickup/task_deleted.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskDeletedTrigger: TriggerConfig = { + id: 'clickup_task_deleted', + name: 'ClickUp Task Deleted', + provider: 'clickup', + description: 'Trigger workflow when a task is deleted in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_task_deleted'), + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/task_due_date_updated.ts b/apps/sim/triggers/clickup/task_due_date_updated.ts new file mode 100644 index 00000000000..4c220536e95 --- /dev/null +++ b/apps/sim/triggers/clickup/task_due_date_updated.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskDueDateUpdatedTrigger: TriggerConfig = { + id: 'clickup_task_due_date_updated', + name: 'ClickUp Task Due Date Updated', + provider: 'clickup', + description: 'Trigger workflow when the due date of a task changes in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_task_due_date_updated'), + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/task_moved.ts b/apps/sim/triggers/clickup/task_moved.ts new file mode 100644 index 00000000000..2989d4cf03a --- /dev/null +++ b/apps/sim/triggers/clickup/task_moved.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskMovedTrigger: TriggerConfig = { + id: 'clickup_task_moved', + name: 'ClickUp Task Moved', + provider: 'clickup', + description: 'Trigger workflow when a task is moved to a different list in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_task_moved'), + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/task_priority_updated.ts b/apps/sim/triggers/clickup/task_priority_updated.ts new file mode 100644 index 00000000000..10b4480ab3b --- /dev/null +++ b/apps/sim/triggers/clickup/task_priority_updated.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskPriorityUpdatedTrigger: TriggerConfig = { + id: 'clickup_task_priority_updated', + name: 'ClickUp Task Priority Updated', + provider: 'clickup', + description: 'Trigger workflow when the priority of a task changes in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_task_priority_updated'), + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/task_status_updated.ts b/apps/sim/triggers/clickup/task_status_updated.ts new file mode 100644 index 00000000000..658ad90b52a --- /dev/null +++ b/apps/sim/triggers/clickup/task_status_updated.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskStatusUpdatedTrigger: TriggerConfig = { + id: 'clickup_task_status_updated', + name: 'ClickUp Task Status Updated', + provider: 'clickup', + description: 'Trigger workflow when the status of a task changes in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_task_status_updated'), + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/task_tag_updated.ts b/apps/sim/triggers/clickup/task_tag_updated.ts new file mode 100644 index 00000000000..97397617ef5 --- /dev/null +++ b/apps/sim/triggers/clickup/task_tag_updated.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskTagUpdatedTrigger: TriggerConfig = { + id: 'clickup_task_tag_updated', + name: 'ClickUp Task Tag Updated', + provider: 'clickup', + description: 'Trigger workflow when the tags of a task change in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_task_tag_updated'), + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/task_time_estimate_updated.ts b/apps/sim/triggers/clickup/task_time_estimate_updated.ts new file mode 100644 index 00000000000..e795946c2d4 --- /dev/null +++ b/apps/sim/triggers/clickup/task_time_estimate_updated.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskTimeEstimateUpdatedTrigger: TriggerConfig = { + id: 'clickup_task_time_estimate_updated', + name: 'ClickUp Task Time Estimate Updated', + provider: 'clickup', + description: 'Trigger workflow when the time estimate of a task changes in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_task_time_estimate_updated'), + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/task_time_tracked_updated.ts b/apps/sim/triggers/clickup/task_time_tracked_updated.ts new file mode 100644 index 00000000000..11414bdbcae --- /dev/null +++ b/apps/sim/triggers/clickup/task_time_tracked_updated.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskTimeTrackedUpdatedTrigger: TriggerConfig = { + id: 'clickup_task_time_tracked_updated', + name: 'ClickUp Task Time Tracked Updated', + provider: 'clickup', + description: 'Trigger workflow when the tracked time of a task changes in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_task_time_tracked_updated'), + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/task_updated.ts b/apps/sim/triggers/clickup/task_updated.ts new file mode 100644 index 00000000000..ad2fca1caa9 --- /dev/null +++ b/apps/sim/triggers/clickup/task_updated.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpTaskOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupTaskUpdatedTrigger: TriggerConfig = { + id: 'clickup_task_updated', + name: 'ClickUp Task Updated', + provider: 'clickup', + description: 'Trigger workflow when a task is updated in ClickUp', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_task_updated'), + outputs: buildClickUpTaskOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/clickup/utils.ts b/apps/sim/triggers/clickup/utils.ts new file mode 100644 index 00000000000..e23dd0fd542 --- /dev/null +++ b/apps/sim/triggers/clickup/utils.ts @@ -0,0 +1,250 @@ +import type { TriggerOutput } from '@/triggers/types' + +export const clickupTriggerOptions = [ + { label: 'Task Created', id: 'clickup_task_created' }, + { label: 'Task Updated', id: 'clickup_task_updated' }, + { label: 'Task Deleted', id: 'clickup_task_deleted' }, + { label: 'Task Status Updated', id: 'clickup_task_status_updated' }, + { label: 'Task Priority Updated', id: 'clickup_task_priority_updated' }, + { label: 'Task Assignee Updated', id: 'clickup_task_assignee_updated' }, + { label: 'Task Due Date Updated', id: 'clickup_task_due_date_updated' }, + { label: 'Task Tag Updated', id: 'clickup_task_tag_updated' }, + { label: 'Task Moved', id: 'clickup_task_moved' }, + { label: 'Task Comment Posted', id: 'clickup_task_comment_posted' }, + { label: 'Task Comment Updated', id: 'clickup_task_comment_updated' }, + { label: 'Task Time Estimate Updated', id: 'clickup_task_time_estimate_updated' }, + { label: 'Task Time Tracked Updated', id: 'clickup_task_time_tracked_updated' }, + { label: 'List Created', id: 'clickup_list_created' }, + { label: 'List Updated', id: 'clickup_list_updated' }, + { label: 'List Deleted', id: 'clickup_list_deleted' }, + { label: 'Folder Created', id: 'clickup_folder_created' }, + { label: 'Folder Updated', id: 'clickup_folder_updated' }, + { label: 'Folder Deleted', id: 'clickup_folder_deleted' }, + { label: 'Space Created', id: 'clickup_space_created' }, + { label: 'Space Updated', id: 'clickup_space_updated' }, + { label: 'Space Deleted', id: 'clickup_space_deleted' }, + { label: 'Goal Created', id: 'clickup_goal_created' }, + { label: 'Goal Updated', id: 'clickup_goal_updated' }, + { label: 'Goal Deleted', id: 'clickup_goal_deleted' }, + { label: 'Key Result Created', id: 'clickup_key_result_created' }, + { label: 'Key Result Updated', id: 'clickup_key_result_updated' }, + { label: 'Key Result Deleted', id: 'clickup_key_result_deleted' }, + { label: 'All Events (Generic Webhook)', id: 'clickup_webhook' }, +] + +/** + * Builds the setup instructions shown in the trigger configuration panel. + * ClickUp webhooks are fully managed by Sim: created on deploy, deleted on + * undeploy, and verified via the per-webhook HMAC secret. + */ +export function clickupSetupInstructions(): string { + const instructions = [ + 'Note: Webhooks are automatically created in ClickUp when you deploy this workflow, and deleted when you undeploy. See the ClickUp webhook documentation for details.', + 'Connect your ClickUp account using the credential selector above.', + 'Select the Workspace the webhook should be registered in.', + 'Optionally scope the webhook to a specific space, folder, list, or task.', + 'Deploy the workflow — a webhook will be created automatically in your ClickUp workspace.', + ] + + return instructions + .map( + (instruction, index) => + `
${index === 0 ? instruction : `${index}. ${instruction}`}
` + ) + .join('') +} + +/** + * Maps Sim trigger IDs to the exact ClickUp webhook event names. + * The catch-all `clickup_webhook` trigger is handled separately (it + * subscribes with the `*` wildcard). + */ +export const CLICKUP_TRIGGER_EVENT_MAP: Record = { + clickup_task_created: ['taskCreated'], + clickup_task_updated: ['taskUpdated'], + clickup_task_deleted: ['taskDeleted'], + clickup_task_status_updated: ['taskStatusUpdated'], + clickup_task_priority_updated: ['taskPriorityUpdated'], + clickup_task_assignee_updated: ['taskAssigneeUpdated'], + clickup_task_due_date_updated: ['taskDueDateUpdated'], + clickup_task_tag_updated: ['taskTagUpdated'], + clickup_task_moved: ['taskMoved'], + clickup_task_comment_posted: ['taskCommentPosted'], + clickup_task_comment_updated: ['taskCommentUpdated'], + clickup_task_time_estimate_updated: ['taskTimeEstimateUpdated'], + clickup_task_time_tracked_updated: ['taskTimeTrackedUpdated'], + clickup_list_created: ['listCreated'], + clickup_list_updated: ['listUpdated'], + clickup_list_deleted: ['listDeleted'], + clickup_folder_created: ['folderCreated'], + clickup_folder_updated: ['folderUpdated'], + clickup_folder_deleted: ['folderDeleted'], + clickup_space_created: ['spaceCreated'], + clickup_space_updated: ['spaceUpdated'], + clickup_space_deleted: ['spaceDeleted'], + clickup_goal_created: ['goalCreated'], + clickup_goal_updated: ['goalUpdated'], + clickup_goal_deleted: ['goalDeleted'], + clickup_key_result_created: ['keyResultCreated'], + clickup_key_result_updated: ['keyResultUpdated'], + clickup_key_result_deleted: ['keyResultDeleted'], +} + +/** + * Extracts the event name from a ClickUp webhook payload. + * ClickUp payloads are flat: `{ event, webhook_id, task_id | list_id | ..., history_items }`. + */ +export function getClickUpEventType(body: Record): string | undefined { + return typeof body.event === 'string' ? body.event : undefined +} + +/** + * Checks whether a ClickUp webhook payload matches a trigger. + */ +export function isClickUpEventMatch(triggerId: string, body: Record): boolean { + if (triggerId === 'clickup_webhook') { + return true + } + + const eventType = getClickUpEventType(body) + if (!eventType) { + return false + } + + const acceptedEvents = CLICKUP_TRIGGER_EVENT_MAP[triggerId] + return acceptedEvents ? acceptedEvents.includes(eventType) : false +} + +function buildBaseOutputs(): Record { + return { + eventType: { type: 'string', description: 'The ClickUp event name (e.g. taskCreated)' }, + historyItems: { + type: 'json', + description: 'History items describing what changed (id, type, date, user, before, after)', + }, + payload: { type: 'json', description: 'Full raw ClickUp webhook payload' }, + } +} + +/** Outputs for task-family triggers (task, comment, time events). */ +export function buildClickUpTaskOutputs(): Record { + return { + ...buildBaseOutputs(), + taskId: { type: 'string', description: 'ID of the affected task' }, + } +} + +/** Outputs for list triggers. */ +export function buildClickUpListOutputs(): Record { + return { + ...buildBaseOutputs(), + listId: { type: 'string', description: 'ID of the affected list' }, + } +} + +/** Outputs for folder triggers. */ +export function buildClickUpFolderOutputs(): Record { + return { + ...buildBaseOutputs(), + folderId: { type: 'string', description: 'ID of the affected folder' }, + } +} + +/** Outputs for space triggers. */ +export function buildClickUpSpaceOutputs(): Record { + return { + ...buildBaseOutputs(), + spaceId: { type: 'string', description: 'ID of the affected space' }, + } +} + +/** + * Outputs for goal and key result triggers. ClickUp does not document a + * dedicated resource-ID field for these payloads, so only the documented + * common fields are exposed; the full body is available via `payload`. + */ +export function buildClickUpGoalOutputs(): Record { + return buildBaseOutputs() +} + +/** Outputs for the generic catch-all webhook trigger. */ +export function buildClickUpGenericOutputs(): Record { + return { + ...buildBaseOutputs(), + taskId: { type: 'string', description: 'ID of the affected task (task events only)' }, + listId: { type: 'string', description: 'ID of the affected list (list events only)' }, + folderId: { type: 'string', description: 'ID of the affected folder (folder events only)' }, + spaceId: { type: 'string', description: 'ID of the affected space (space events only)' }, + } +} + +function extractBaseFields(body: Record): Record { + return { + eventType: body.event ?? null, + historyItems: Array.isArray(body.history_items) ? body.history_items : [], + payload: body, + } +} + +/** Extracts formatted data from a ClickUp task-family event payload. */ +export function extractClickUpTaskData(body: Record): Record { + const base = extractBaseFields(body) + return { + eventType: base.eventType, + taskId: body.task_id ?? null, + historyItems: base.historyItems, + payload: base.payload, + } +} + +/** Extracts formatted data from a ClickUp list event payload. */ +export function extractClickUpListData(body: Record): Record { + const base = extractBaseFields(body) + return { + eventType: base.eventType, + listId: body.list_id ?? null, + historyItems: base.historyItems, + payload: base.payload, + } +} + +/** Extracts formatted data from a ClickUp folder event payload. */ +export function extractClickUpFolderData(body: Record): Record { + const base = extractBaseFields(body) + return { + eventType: base.eventType, + folderId: body.folder_id ?? null, + historyItems: base.historyItems, + payload: base.payload, + } +} + +/** Extracts formatted data from a ClickUp space event payload. */ +export function extractClickUpSpaceData(body: Record): Record { + const base = extractBaseFields(body) + return { + eventType: base.eventType, + spaceId: body.space_id ?? null, + historyItems: base.historyItems, + payload: base.payload, + } +} + +/** Extracts formatted data from a ClickUp goal or key result event payload. */ +export function extractClickUpGoalData(body: Record): Record { + return extractBaseFields(body) +} + +/** Extracts formatted data from any ClickUp event payload (catch-all trigger). */ +export function extractClickUpGenericData(body: Record): Record { + const base = extractBaseFields(body) + return { + eventType: base.eventType, + taskId: body.task_id ?? null, + listId: body.list_id ?? null, + folderId: body.folder_id ?? null, + spaceId: body.space_id ?? null, + historyItems: base.historyItems, + payload: base.payload, + } +} diff --git a/apps/sim/triggers/clickup/webhook.ts b/apps/sim/triggers/clickup/webhook.ts new file mode 100644 index 00000000000..4c03f24f4ee --- /dev/null +++ b/apps/sim/triggers/clickup/webhook.ts @@ -0,0 +1,19 @@ +import { ClickUpIcon } from '@/components/icons' +import { buildClickUpTriggerSubBlocks } from '@/triggers/clickup/subblocks' +import { buildClickUpGenericOutputs } from '@/triggers/clickup/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const clickupWebhookTrigger: TriggerConfig = { + id: 'clickup_webhook', + name: 'ClickUp Webhook', + provider: 'clickup', + description: 'Trigger workflow on any ClickUp event (subscribes to all events)', + version: '1.0.0', + icon: ClickUpIcon, + subBlocks: buildClickUpTriggerSubBlocks('clickup_webhook'), + outputs: buildClickUpGenericOutputs(), + webhook: { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Signature': 'hmac-sha256-signature' }, + }, +} diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index de118f173c8..720e16884a3 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -74,6 +74,37 @@ import { clerkUserUpdatedTrigger, clerkWebhookTrigger, } from '@/triggers/clerk' +import { + clickupFolderCreatedTrigger, + clickupFolderDeletedTrigger, + clickupFolderUpdatedTrigger, + clickupGoalCreatedTrigger, + clickupGoalDeletedTrigger, + clickupGoalUpdatedTrigger, + clickupKeyResultCreatedTrigger, + clickupKeyResultDeletedTrigger, + clickupKeyResultUpdatedTrigger, + clickupListCreatedTrigger, + clickupListDeletedTrigger, + clickupListUpdatedTrigger, + clickupSpaceCreatedTrigger, + clickupSpaceDeletedTrigger, + clickupSpaceUpdatedTrigger, + clickupTaskAssigneeUpdatedTrigger, + clickupTaskCommentPostedTrigger, + clickupTaskCommentUpdatedTrigger, + clickupTaskCreatedTrigger, + clickupTaskDeletedTrigger, + clickupTaskDueDateUpdatedTrigger, + clickupTaskMovedTrigger, + clickupTaskPriorityUpdatedTrigger, + clickupTaskStatusUpdatedTrigger, + clickupTaskTagUpdatedTrigger, + clickupTaskTimeEstimateUpdatedTrigger, + clickupTaskTimeTrackedUpdatedTrigger, + clickupTaskUpdatedTrigger, + clickupWebhookTrigger, +} from '@/triggers/clickup' import { confluenceAttachmentCreatedTrigger, confluenceAttachmentRemovedTrigger, @@ -489,6 +520,35 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { calcom_meeting_ended: calcomMeetingEndedTrigger, calcom_recording_ready: calcomRecordingReadyTrigger, calcom_webhook: calcomWebhookTrigger, + clickup_task_created: clickupTaskCreatedTrigger, + clickup_task_updated: clickupTaskUpdatedTrigger, + clickup_task_deleted: clickupTaskDeletedTrigger, + clickup_task_status_updated: clickupTaskStatusUpdatedTrigger, + clickup_task_priority_updated: clickupTaskPriorityUpdatedTrigger, + clickup_task_assignee_updated: clickupTaskAssigneeUpdatedTrigger, + clickup_task_due_date_updated: clickupTaskDueDateUpdatedTrigger, + clickup_task_tag_updated: clickupTaskTagUpdatedTrigger, + clickup_task_moved: clickupTaskMovedTrigger, + clickup_task_comment_posted: clickupTaskCommentPostedTrigger, + clickup_task_comment_updated: clickupTaskCommentUpdatedTrigger, + clickup_task_time_estimate_updated: clickupTaskTimeEstimateUpdatedTrigger, + clickup_task_time_tracked_updated: clickupTaskTimeTrackedUpdatedTrigger, + clickup_list_created: clickupListCreatedTrigger, + clickup_list_updated: clickupListUpdatedTrigger, + clickup_list_deleted: clickupListDeletedTrigger, + clickup_folder_created: clickupFolderCreatedTrigger, + clickup_folder_updated: clickupFolderUpdatedTrigger, + clickup_folder_deleted: clickupFolderDeletedTrigger, + clickup_space_created: clickupSpaceCreatedTrigger, + clickup_space_updated: clickupSpaceUpdatedTrigger, + clickup_space_deleted: clickupSpaceDeletedTrigger, + clickup_goal_created: clickupGoalCreatedTrigger, + clickup_goal_updated: clickupGoalUpdatedTrigger, + clickup_goal_deleted: clickupGoalDeletedTrigger, + clickup_key_result_created: clickupKeyResultCreatedTrigger, + clickup_key_result_updated: clickupKeyResultUpdatedTrigger, + clickup_key_result_deleted: clickupKeyResultDeletedTrigger, + clickup_webhook: clickupWebhookTrigger, confluence_webhook: confluenceWebhookTrigger, confluence_page_created: confluencePageCreatedTrigger, confluence_page_updated: confluencePageUpdatedTrigger, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 61afe1084db..9a3be6d4d6a 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -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: 958, - zodRoutes: 958, + totalRoutes: 962, + zodRoutes: 962, nonZodRoutes: 0, } as const From ba2393778c74c58437a20039d95ff29a60246183 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 16 Jul 2026 01:49:23 -0700 Subject: [PATCH 2/8] feat(clickup): KB connector (Docs v3), selector-route hardening, subblock migrations, registry-check regex fix --- .../app/api/tools/clickup/folders/route.ts | 112 ++++--- apps/sim/app/api/tools/clickup/lists/route.ts | 126 ++++--- .../sim/app/api/tools/clickup/spaces/route.ts | 111 ++++--- .../app/api/tools/clickup/workspaces/route.ts | 99 +++--- apps/sim/connectors/clickup/clickup.ts | 312 ++++++++++++++++++ apps/sim/connectors/clickup/index.ts | 1 + apps/sim/connectors/clickup/meta.ts | 70 ++++ apps/sim/connectors/registry.server.ts | 2 + apps/sim/connectors/registry.ts | 2 + apps/sim/lib/webhooks/providers/clickup.ts | 11 +- .../migrations/subblock-migrations.ts | 7 + apps/sim/scripts/check-block-registry.ts | 4 +- apps/sim/triggers/clickup/utils.ts | 3 +- 13 files changed, 657 insertions(+), 203 deletions(-) create mode 100644 apps/sim/connectors/clickup/clickup.ts create mode 100644 apps/sim/connectors/clickup/index.ts create mode 100644 apps/sim/connectors/clickup/meta.ts diff --git a/apps/sim/app/api/tools/clickup/folders/route.ts b/apps/sim/app/api/tools/clickup/folders/route.ts index 0de8b46a375..6613be9189e 100644 --- a/apps/sim/app/api/tools/clickup/folders/route.ts +++ b/apps/sim/app/api/tools/clickup/folders/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { clickupFoldersSelectorContract } from '@/lib/api/contracts/selectors' import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -18,64 +19,75 @@ interface ClickUpNamedResource { } export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - const parsed = await parseRequest(clickupFoldersSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId, spaceId } = parsed.data.body + try { + const requestId = generateRequestId() + const parsed = await parseRequest(clickupFoldersSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId, spaceId } = parsed.data.body - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } + const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId') + if (!spaceIdValidation.isValid) { + logger.error('Invalid spaceId', { error: spaceIdValidation.error }) + return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 }) + } - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { + const authz = await authorizeCredentialUse(request, { credentialId: credential, - userId: authz.credentialOwnerUserId, + workflowId, }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } - const response = await fetch( - `${CLICKUP_API_BASE_URL}/space/${encodeURIComponent(spaceId)}/folder`, - { - headers: { - Authorization: clickupAuthorizationHeader(accessToken), - Accept: 'application/json', - }, + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) } - ) - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch ClickUp folders', { - status: response.status, - error: errorData, - }) - return NextResponse.json( - { error: 'Failed to fetch ClickUp folders' }, - { status: response.status } + const response = await fetch( + `${CLICKUP_API_BASE_URL}/space/${encodeURIComponent(spaceId)}/folder`, + { + headers: { + Authorization: clickupAuthorizationHeader(accessToken), + Accept: 'application/json', + }, + } ) - } - const data = (await response.json().catch(() => ({}))) as { - folders?: ClickUpNamedResource[] - } - const folders = (Array.isArray(data.folders) ? data.folders : []).map((item) => ({ - id: String(item.id), - name: item.name || `Folder ${item.id}`, - })) + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch ClickUp folders', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch ClickUp folders' }, + { status: response.status } + ) + } - return NextResponse.json({ folders }) + const data = (await response.json().catch(() => ({}))) as { + folders?: ClickUpNamedResource[] + } + const folders = (Array.isArray(data.folders) ? data.folders : []).map((item) => ({ + id: String(item.id), + name: item.name || `Folder ${item.id}`, + })) + + return NextResponse.json({ folders }) + } catch (error) { + logger.error('Error fetching ClickUp folders', error) + return NextResponse.json({ error: 'Failed to fetch ClickUp folders' }, { status: 500 }) + } }) diff --git a/apps/sim/app/api/tools/clickup/lists/route.ts b/apps/sim/app/api/tools/clickup/lists/route.ts index d4945bda65f..a07712b36e4 100644 --- a/apps/sim/app/api/tools/clickup/lists/route.ts +++ b/apps/sim/app/api/tools/clickup/lists/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { clickupListsSelectorContract } from '@/lib/api/contracts/selectors' import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -18,66 +19,87 @@ interface ClickUpNamedResource { } export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - const parsed = await parseRequest(clickupListsSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId, folderId, spaceId } = parsed.data.body + try { + const requestId = generateRequestId() + const parsed = await parseRequest(clickupListsSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId, folderId, spaceId } = parsed.data.body - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } + if (folderId?.trim()) { + const folderIdValidation = validateAlphanumericId(folderId.trim(), 'folderId') + if (!folderIdValidation.isValid) { + logger.error('Invalid folderId', { error: folderIdValidation.error }) + return NextResponse.json({ error: folderIdValidation.error }, { status: 400 }) + } + } + + if (spaceId?.trim()) { + const spaceIdValidation = validateAlphanumericId(spaceId.trim(), 'spaceId') + if (!spaceIdValidation.isValid) { + logger.error('Invalid spaceId', { error: spaceIdValidation.error }) + return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 }) + } + } - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { + const authz = await authorizeCredentialUse(request, { credentialId: credential, - userId: authz.credentialOwnerUserId, + workflowId, }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } - const response = await fetch( - folderId?.trim() - ? `${CLICKUP_API_BASE_URL}/folder/${encodeURIComponent(folderId.trim())}/list` - : `${CLICKUP_API_BASE_URL}/space/${encodeURIComponent((spaceId ?? '').trim())}/list`, - { - headers: { - Authorization: clickupAuthorizationHeader(accessToken), - Accept: 'application/json', - }, + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) } - ) - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch ClickUp lists', { - status: response.status, - error: errorData, - }) - return NextResponse.json( - { error: 'Failed to fetch ClickUp lists' }, - { status: response.status } + const response = await fetch( + folderId?.trim() + ? `${CLICKUP_API_BASE_URL}/folder/${encodeURIComponent(folderId.trim())}/list` + : `${CLICKUP_API_BASE_URL}/space/${encodeURIComponent((spaceId ?? '').trim())}/list`, + { + headers: { + Authorization: clickupAuthorizationHeader(accessToken), + Accept: 'application/json', + }, + } ) - } - const data = (await response.json().catch(() => ({}))) as { - lists?: ClickUpNamedResource[] - } - const lists = (Array.isArray(data.lists) ? data.lists : []).map((item) => ({ - id: String(item.id), - name: item.name || `List ${item.id}`, - })) + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch ClickUp lists', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch ClickUp lists' }, + { status: response.status } + ) + } - return NextResponse.json({ lists }) + const data = (await response.json().catch(() => ({}))) as { + lists?: ClickUpNamedResource[] + } + const lists = (Array.isArray(data.lists) ? data.lists : []).map((item) => ({ + id: String(item.id), + name: item.name || `List ${item.id}`, + })) + + return NextResponse.json({ lists }) + } catch (error) { + logger.error('Error fetching ClickUp lists', error) + return NextResponse.json({ error: 'Failed to fetch ClickUp lists' }, { status: 500 }) + } }) diff --git a/apps/sim/app/api/tools/clickup/spaces/route.ts b/apps/sim/app/api/tools/clickup/spaces/route.ts index ac2d046a47e..197e5245ca4 100644 --- a/apps/sim/app/api/tools/clickup/spaces/route.ts +++ b/apps/sim/app/api/tools/clickup/spaces/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { clickupSpacesSelectorContract } from '@/lib/api/contracts/selectors' import { parseRequest } from '@/lib/api/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -18,61 +19,75 @@ interface ClickUpNamedResource { } export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - const parsed = await parseRequest(clickupSpacesSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId, teamId } = parsed.data.body + try { + const requestId = generateRequestId() + const parsed = await parseRequest(clickupSpacesSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId, teamId } = parsed.data.body - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } + const teamIdValidation = validateAlphanumericId(teamId, 'teamId') + if (!teamIdValidation.isValid) { + logger.error('Invalid teamId', { error: teamIdValidation.error }) + return NextResponse.json({ error: teamIdValidation.error }, { status: 400 }) + } - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { + const authz = await authorizeCredentialUse(request, { credentialId: credential, - userId: authz.credentialOwnerUserId, + workflowId, }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } - const response = await fetch(`${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(teamId)}/space`, { - headers: { - Authorization: clickupAuthorizationHeader(accessToken), - Accept: 'application/json', - }, - }) + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch ClickUp spaces', { - status: response.status, - error: errorData, - }) - return NextResponse.json( - { error: 'Failed to fetch ClickUp spaces' }, - { status: response.status } + const response = await fetch( + `${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(teamId)}/space`, + { + headers: { + Authorization: clickupAuthorizationHeader(accessToken), + Accept: 'application/json', + }, + } ) - } - const data = (await response.json().catch(() => ({}))) as { - spaces?: ClickUpNamedResource[] - } - const spaces = (Array.isArray(data.spaces) ? data.spaces : []).map((item) => ({ - id: String(item.id), - name: item.name || `Space ${item.id}`, - })) + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch ClickUp spaces', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch ClickUp spaces' }, + { status: response.status } + ) + } - return NextResponse.json({ spaces }) + const data = (await response.json().catch(() => ({}))) as { + spaces?: ClickUpNamedResource[] + } + const spaces = (Array.isArray(data.spaces) ? data.spaces : []).map((item) => ({ + id: String(item.id), + name: item.name || `Space ${item.id}`, + })) + + return NextResponse.json({ spaces }) + } catch (error) { + logger.error('Error fetching ClickUp spaces', error) + return NextResponse.json({ error: 'Failed to fetch ClickUp spaces' }, { status: 500 }) + } }) diff --git a/apps/sim/app/api/tools/clickup/workspaces/route.ts b/apps/sim/app/api/tools/clickup/workspaces/route.ts index 6dcbe4b1659..2ff80e7f2dd 100644 --- a/apps/sim/app/api/tools/clickup/workspaces/route.ts +++ b/apps/sim/app/api/tools/clickup/workspaces/route.ts @@ -18,59 +18,64 @@ interface ClickUpTeam { } export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - const parsed = await parseRequest(clickupWorkspacesSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId } = parsed.data.body + try { + const requestId = generateRequestId() + const parsed = await parseRequest(clickupWorkspacesSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId } = parsed.data.body - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { + const authz = await authorizeCredentialUse(request, { credentialId: credential, - userId: authz.credentialOwnerUserId, + workflowId, }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } + if (!authz.ok || !authz.credentialOwnerUserId) { + return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) + } - const response = await fetch(`${CLICKUP_API_BASE_URL}/team`, { - headers: { - Authorization: clickupAuthorizationHeader(accessToken), - Accept: 'application/json', - }, - }) + const accessToken = await refreshAccessTokenIfNeeded( + credential, + authz.credentialOwnerUserId, + requestId + ) + if (!accessToken) { + logger.error('Failed to get access token', { + credentialId: credential, + userId: authz.credentialOwnerUserId, + }) + return NextResponse.json( + { error: 'Could not retrieve access token', authRequired: true }, + { status: 401 } + ) + } - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch ClickUp workspaces', { - status: response.status, - error: errorData, + const response = await fetch(`${CLICKUP_API_BASE_URL}/team`, { + headers: { + Authorization: clickupAuthorizationHeader(accessToken), + Accept: 'application/json', + }, }) - return NextResponse.json( - { error: 'Failed to fetch ClickUp workspaces' }, - { status: response.status } - ) - } - const data = (await response.json().catch(() => ({}))) as { teams?: ClickUpTeam[] } - const workspaces = (Array.isArray(data.teams) ? data.teams : []).map((team) => ({ - id: String(team.id), - name: team.name || `Workspace ${team.id}`, - })) + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + logger.error('Failed to fetch ClickUp workspaces', { + status: response.status, + error: errorData, + }) + return NextResponse.json( + { error: 'Failed to fetch ClickUp workspaces' }, + { status: response.status } + ) + } - return NextResponse.json({ workspaces }) + const data = (await response.json().catch(() => ({}))) as { teams?: ClickUpTeam[] } + const workspaces = (Array.isArray(data.teams) ? data.teams : []).map((team) => ({ + id: String(team.id), + name: team.name || `Workspace ${team.id}`, + })) + + return NextResponse.json({ workspaces }) + } catch (error) { + logger.error('Error fetching ClickUp workspaces', error) + return NextResponse.json({ error: 'Failed to fetch ClickUp workspaces' }, { status: 500 }) + } }) diff --git a/apps/sim/connectors/clickup/clickup.ts b/apps/sim/connectors/clickup/clickup.ts new file mode 100644 index 00000000000..7bb320a4ba1 --- /dev/null +++ b/apps/sim/connectors/clickup/clickup.ts @@ -0,0 +1,312 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' +import { clickupConnectorMeta } from '@/connectors/clickup/meta' +import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' +import { parseTagDate } from '@/connectors/utils' +import { clickupAuthorizationHeader, extractClickUpErrorMessage } from '@/tools/clickup/shared' + +const logger = createLogger('ClickUpConnector') + +const CLICKUP_API_V3_BASE_URL = 'https://api.clickup.com/api/v3' + +/** Maximum page size accepted by the Search Docs endpoint. */ +const LIST_PAGE_SIZE = 100 + +/** Minimum page size accepted by the Search Docs endpoint (used for validation probes). */ +const VALIDATE_PAGE_SIZE = 10 + +/** + * Core Doc fields from the v3 Search Docs / Fetch Doc responses + * (`PublicDocsDocCoreDto` in ClickUp's OpenAPI spec). + */ +interface ClickUpDoc { + id: string + name: string + dateCreated?: number + dateUpdated?: number + isPublic?: boolean + deleted: boolean + archived: boolean +} + +/** + * Page fields from the v3 Fetch Pages response (`PublicDocsPageV3Dto`). + * Pages nest recursively via `pages`. + */ +interface ClickUpDocPage { + name: string + content: string + deleted?: boolean + archived?: boolean + pages: ClickUpDocPage[] +} + +function buildHeaders(accessToken: string): Record { + return { + Accept: 'application/json', + Authorization: clickupAuthorizationHeader(accessToken), + } +} + +function getOptionalNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +/** + * Parses a raw Doc object from the Search Docs / Fetch Doc responses. + * Returns null when the payload is missing the documented required fields. + */ +function parseDoc(value: unknown): ClickUpDoc | null { + if (!isRecordLike(value)) return null + const id = typeof value.id === 'string' ? value.id : undefined + if (!id) return null + + return { + id, + name: typeof value.name === 'string' && value.name.trim() ? value.name : 'Untitled', + dateCreated: getOptionalNumber(value.date_created), + dateUpdated: getOptionalNumber(value.date_updated), + isPublic: typeof value.public === 'boolean' ? value.public : undefined, + deleted: value.deleted === true, + archived: value.archived === true, + } +} + +/** + * Parses a raw page object from the Fetch Pages response, keeping nested subpages. + */ +function parsePage(value: unknown): ClickUpDocPage | null { + if (!isRecordLike(value)) return null + const rawSubpages = Array.isArray(value.pages) ? value.pages : [] + + return { + name: typeof value.name === 'string' ? value.name : '', + content: typeof value.content === 'string' ? value.content : '', + deleted: value.deleted === true, + archived: value.archived === true, + pages: rawSubpages + .map((subpage) => parsePage(subpage)) + .filter((subpage): subpage is ClickUpDocPage => subpage !== null), + } +} + +/** + * Flattens a Doc's page tree depth-first into markdown sections, skipping + * deleted and archived pages (their subpages are skipped with them). + */ +function flattenPages(pages: ClickUpDocPage[], sections: string[]): void { + for (const page of pages) { + if (page.deleted || page.archived) continue + const parts: string[] = [] + if (page.name.trim()) parts.push(`# ${page.name.trim()}`) + if (page.content.trim()) parts.push(page.content.trim()) + if (parts.length > 0) sections.push(parts.join('\n\n')) + flattenPages(page.pages, sections) + } +} + +/** + * Produces a lightweight metadata stub for a Doc. The contentHash is derived + * from the Doc's `date_updated` so it is identical whether built from the + * Search Docs listing or the Fetch Doc response. + */ +function docToStub(doc: ClickUpDoc, workspaceId: string): ExternalDocument { + return { + externalId: doc.id, + title: doc.name, + content: '', + contentDeferred: true, + mimeType: 'text/plain', + sourceUrl: `https://app.clickup.com/${workspaceId}/v/dc/${doc.id}`, + contentHash: `clickup:${doc.id}:${doc.dateUpdated ?? ''}`, + metadata: { + created: doc.dateCreated != null ? new Date(doc.dateCreated).toISOString() : undefined, + lastUpdated: doc.dateUpdated != null ? new Date(doc.dateUpdated).toISOString() : undefined, + public: doc.isPublic, + }, + } +} + +/** + * Builds the Search Docs URL for a workspace, optionally filtered to the Docs + * whose parent container is the given Space. + */ +function buildSearchDocsUrl( + workspaceId: string, + options: { spaceId?: string; limit: number; cursor?: string } +): string { + const params = new URLSearchParams() + params.append('limit', String(options.limit)) + if (options.cursor) params.append('cursor', options.cursor) + if (options.spaceId) { + params.append('parent_id', options.spaceId) + params.append('parent_type', 'SPACE') + } + return `${CLICKUP_API_V3_BASE_URL}/workspaces/${encodeURIComponent(workspaceId)}/docs?${params.toString()}` +} + +function getRequiredWorkspaceId(sourceConfig: Record): string { + const workspaceId = typeof sourceConfig.teamId === 'string' ? sourceConfig.teamId.trim() : '' + if (!workspaceId) { + throw new Error('ClickUp workspace ID is required') + } + return workspaceId +} + +export const clickupConnector: ConnectorConfig = { + ...clickupConnectorMeta, + + listDocuments: async ( + accessToken: string, + sourceConfig: Record, + cursor?: string, + syncContext?: Record + ): Promise => { + const workspaceId = getRequiredWorkspaceId(sourceConfig) + const spaceId = + typeof sourceConfig.spaceId === 'string' && sourceConfig.spaceId.trim() + ? sourceConfig.spaceId.trim() + : undefined + const maxDocs = sourceConfig.maxDocs ? Number(sourceConfig.maxDocs) : 0 + + const url = buildSearchDocsUrl(workspaceId, { spaceId, limit: LIST_PAGE_SIZE, cursor }) + const response = await fetchWithRetry(url, { + method: 'GET', + headers: buildHeaders(accessToken), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to list ClickUp Docs', { status: response.status, error: errorText }) + throw new Error(`Failed to list ClickUp Docs: ${response.status}`) + } + + const data = (await response.json()) as Record + const rawDocs = Array.isArray(data.docs) ? data.docs : [] + + const documents: ExternalDocument[] = [] + for (const rawDoc of rawDocs) { + const doc = parseDoc(rawDoc) + if (!doc || doc.deleted || doc.archived) continue + documents.push(docToStub(doc, workspaceId)) + } + + const totalFetched = ((syncContext?.totalDocsFetched as number) ?? 0) + documents.length + if (syncContext) syncContext.totalDocsFetched = totalFetched + const hitLimit = maxDocs > 0 && totalFetched >= maxDocs + if (hitLimit && syncContext) syncContext.listingCapped = true + + const nextCursor = + typeof data.next_cursor === 'string' && data.next_cursor ? data.next_cursor : undefined + + return { + documents, + nextCursor: hitLimit ? undefined : nextCursor, + hasMore: hitLimit ? false : Boolean(nextCursor), + } + }, + + getDocument: async ( + accessToken: string, + sourceConfig: Record, + externalId: string + ): Promise => { + const workspaceId = getRequiredWorkspaceId(sourceConfig) + const headers = buildHeaders(accessToken) + const docUrl = `${CLICKUP_API_V3_BASE_URL}/workspaces/${encodeURIComponent(workspaceId)}/docs/${encodeURIComponent(externalId)}` + + const docResponse = await fetchWithRetry(docUrl, { method: 'GET', headers }) + if (docResponse.status === 404) return null + if (!docResponse.ok) { + throw new Error(`Failed to fetch ClickUp Doc ${externalId}: ${docResponse.status}`) + } + + const doc = parseDoc(await docResponse.json()) + if (!doc || doc.deleted || doc.archived) return null + + const pagesParams = new URLSearchParams({ max_page_depth: '-1', content_format: 'text/md' }) + const pagesUrl = `${docUrl}/pages?${pagesParams.toString()}` + const pagesResponse = await fetchWithRetry(pagesUrl, { method: 'GET', headers }) + if (!pagesResponse.ok) { + throw new Error( + `Failed to fetch pages for ClickUp Doc ${externalId}: ${pagesResponse.status}` + ) + } + + const rawPages = await pagesResponse.json() + const pages = (Array.isArray(rawPages) ? rawPages : []) + .map((page) => parsePage(page)) + .filter((page): page is ClickUpDocPage => page !== null) + + const sections: string[] = [] + flattenPages(pages, sections) + const content = sections.join('\n\n') + if (!content.trim()) { + logger.warn(`ClickUp Doc has no indexable page content: ${externalId}`) + return null + } + + return { + ...docToStub(doc, workspaceId), + content, + contentDeferred: false, + } + }, + + validateConfig: async ( + accessToken: string, + sourceConfig: Record + ): Promise<{ valid: boolean; error?: string }> => { + const workspaceId = typeof sourceConfig.teamId === 'string' ? sourceConfig.teamId.trim() : '' + if (!workspaceId) { + return { valid: false, error: 'Workspace is required' } + } + + const maxDocs = sourceConfig.maxDocs as string | undefined + if (maxDocs && (Number.isNaN(Number(maxDocs)) || Number(maxDocs) <= 0)) { + return { valid: false, error: 'Max docs must be a positive number' } + } + + const spaceId = + typeof sourceConfig.spaceId === 'string' && sourceConfig.spaceId.trim() + ? sourceConfig.spaceId.trim() + : undefined + + try { + const url = buildSearchDocsUrl(workspaceId, { spaceId, limit: VALIDATE_PAGE_SIZE }) + const response = await fetchWithRetry( + url, + { method: 'GET', headers: buildHeaders(accessToken) }, + VALIDATE_RETRY_OPTIONS + ) + + if (!response.ok) { + const data = await response.json().catch(() => null) + return { + valid: false, + error: extractClickUpErrorMessage(response, data, 'Failed to access ClickUp Docs'), + } + } + + return { valid: true } + } catch (error) { + return { valid: false, error: toError(error).message || 'Failed to validate configuration' } + } + }, + + mapTags: (metadata: Record): Record => { + const result: Record = {} + + const created = parseTagDate(metadata.created) + if (created) result.created = created + + const lastUpdated = parseTagDate(metadata.lastUpdated) + if (lastUpdated) result.lastUpdated = lastUpdated + + if (typeof metadata.public === 'boolean') result.public = metadata.public + + return result + }, +} diff --git a/apps/sim/connectors/clickup/index.ts b/apps/sim/connectors/clickup/index.ts new file mode 100644 index 00000000000..cd7641afde6 --- /dev/null +++ b/apps/sim/connectors/clickup/index.ts @@ -0,0 +1 @@ +export { clickupConnector } from '@/connectors/clickup/clickup' diff --git a/apps/sim/connectors/clickup/meta.ts b/apps/sim/connectors/clickup/meta.ts new file mode 100644 index 00000000000..5879b5cf2ab --- /dev/null +++ b/apps/sim/connectors/clickup/meta.ts @@ -0,0 +1,70 @@ +import { ClickUpIcon } from '@/components/icons' +import type { ConnectorMeta } from '@/connectors/types' + +export const clickupConnectorMeta: ConnectorMeta = { + id: 'clickup', + name: 'ClickUp', + description: 'Sync Docs from a ClickUp Workspace', + version: '1.0.0', + icon: ClickUpIcon, + + auth: { + mode: 'oauth', + provider: 'clickup', + }, + + configFields: [ + { + id: 'workspaceSelector', + title: 'Workspace', + type: 'selector', + selectorKey: 'clickup.workspaces', + canonicalParamId: 'teamId', + mode: 'basic', + placeholder: 'Select a workspace', + required: true, + }, + { + id: 'teamId', + title: 'Workspace ID', + type: 'short-input', + canonicalParamId: 'teamId', + mode: 'advanced', + placeholder: 'e.g. 9012345678', + required: true, + }, + { + id: 'spaceSelector', + title: 'Space', + type: 'selector', + selectorKey: 'clickup.spaces', + canonicalParamId: 'spaceId', + mode: 'basic', + dependsOn: ['workspaceSelector'], + placeholder: 'Select a space (optional)', + required: false, + }, + { + id: 'spaceId', + title: 'Space ID', + type: 'short-input', + canonicalParamId: 'spaceId', + mode: 'advanced', + placeholder: 'e.g. 90123456789', + required: false, + }, + { + id: 'maxDocs', + title: 'Max Docs', + type: 'short-input', + required: false, + placeholder: 'e.g. 500 (default: unlimited)', + }, + ], + + tagDefinitions: [ + { id: 'created', displayName: 'Created', fieldType: 'date' }, + { id: 'lastUpdated', displayName: 'Last Updated', fieldType: 'date' }, + { id: 'public', displayName: 'Public', fieldType: 'boolean' }, + ], +} diff --git a/apps/sim/connectors/registry.server.ts b/apps/sim/connectors/registry.server.ts index bf28bafcab7..ba870e2af41 100644 --- a/apps/sim/connectors/registry.server.ts +++ b/apps/sim/connectors/registry.server.ts @@ -2,6 +2,7 @@ import { airtableConnector } from '@/connectors/airtable' import { asanaConnector } from '@/connectors/asana' import { ashbyConnector } from '@/connectors/ashby' import { azureDevopsConnector } from '@/connectors/azure-devops' +import { clickupConnector } from '@/connectors/clickup' import { confluenceConnector } from '@/connectors/confluence' import { discordConnector } from '@/connectors/discord' import { docusignConnector } from '@/connectors/docusign' @@ -63,6 +64,7 @@ export const CONNECTOR_REGISTRY: ConnectorRegistry = { asana: asanaConnector, ashby: ashbyConnector, azure_devops: azureDevopsConnector, + clickup: clickupConnector, confluence: confluenceConnector, discord: discordConnector, docusign: docusignConnector, diff --git a/apps/sim/connectors/registry.ts b/apps/sim/connectors/registry.ts index 0af117761e0..b1fd50e9736 100644 --- a/apps/sim/connectors/registry.ts +++ b/apps/sim/connectors/registry.ts @@ -2,6 +2,7 @@ import { airtableConnectorMeta } from '@/connectors/airtable/meta' import { asanaConnectorMeta } from '@/connectors/asana/meta' import { ashbyConnectorMeta } from '@/connectors/ashby/meta' import { azureDevopsConnectorMeta } from '@/connectors/azure-devops/meta' +import { clickupConnectorMeta } from '@/connectors/clickup/meta' import { confluenceConnectorMeta } from '@/connectors/confluence/meta' import { discordConnectorMeta } from '@/connectors/discord/meta' import { docusignConnectorMeta } from '@/connectors/docusign/meta' @@ -63,6 +64,7 @@ export const CONNECTOR_META_REGISTRY: ConnectorMetaRegistry = { asana: asanaConnectorMeta, ashby: ashbyConnectorMeta, azure_devops: azureDevopsConnectorMeta, + clickup: clickupConnectorMeta, confluence: confluenceConnectorMeta, discord: discordConnectorMeta, docusign: docusignConnectorMeta, diff --git a/apps/sim/lib/webhooks/providers/clickup.ts b/apps/sim/lib/webhooks/providers/clickup.ts index 5a1ac4069eb..6f468978cb7 100644 --- a/apps/sim/lib/webhooks/providers/clickup.ts +++ b/apps/sim/lib/webhooks/providers/clickup.ts @@ -127,18 +127,21 @@ export const clickupHandler: WebhookProviderHandler = { return true }, + /** + * ClickUp documents `{{webhook_id}}:{{history_item_id}}` as the recommended + * idempotency key; history item IDs are unique per delivered event. + */ extractIdempotencyId(body: unknown) { const obj = body as Record - const event = obj.event - if (typeof event !== 'string') return null + const webhookId = obj.webhook_id + if (typeof webhookId !== 'string' || !webhookId) return null const historyItems = Array.isArray(obj.history_items) ? obj.history_items : [] const firstItem = historyItems[0] as Record | undefined const historyId = firstItem?.id if (!historyId) return null - const resourceId = obj.task_id ?? obj.list_id ?? obj.folder_id ?? obj.space_id ?? '' - return `clickup:${event}:${resourceId}:${historyId}` + return `clickup:${webhookId}:${historyId}` }, async createSubscription({ diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.ts index a97d73e4e56..9966ff3c58c 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.ts @@ -48,6 +48,13 @@ export const SUBBLOCK_ID_MIGRATIONS: Record> = { expandSurveyFormDefinitions: '_removed_expandSurveyFormDefinitions', filterCandidateId: '_removed_filterCandidateId', }, + clickup: { + workspaceId: 'workspaceSelector', + spaceId: 'spaceSelector', + listSpaceId: 'spaceSelector', + folderId: 'folderSelector', + listId: 'listSelector', + }, apollo: { contact_ids_bulk: 'contacts', account_ids_bulk: 'accounts', diff --git a/apps/sim/scripts/check-block-registry.ts b/apps/sim/scripts/check-block-registry.ts index 13089512f14..6a8421e41a7 100644 --- a/apps/sim/scripts/check-block-registry.ts +++ b/apps/sim/scripts/check-block-registry.ts @@ -128,7 +128,9 @@ function getPreviousIds(): PreviousIdsResult { continue } - const typeMatch = content.match(/BlockConfig\s*=\s*\{[\s\S]*?type:\s*['"]([^'"]+)['"]/) + const typeMatch = content.match( + /BlockConfig(?:<[^>]*>)?\s*=\s*\{[\s\S]*?type:\s*['"]([^'"]+)['"]/ + ) if (!typeMatch) continue const blockType = typeMatch[1] diff --git a/apps/sim/triggers/clickup/utils.ts b/apps/sim/triggers/clickup/utils.ts index e23dd0fd542..2af811babeb 100644 --- a/apps/sim/triggers/clickup/utils.ts +++ b/apps/sim/triggers/clickup/utils.ts @@ -120,7 +120,8 @@ function buildBaseOutputs(): Record { eventType: { type: 'string', description: 'The ClickUp event name (e.g. taskCreated)' }, historyItems: { type: 'json', - description: 'History items describing what changed (id, type, date, user, before, after)', + description: + 'History items describing what changed (id, type, date, source, user, before, after)', }, payload: { type: 'json', description: 'Full raw ClickUp webhook payload' }, } From 8f30b76ac8751844cf46051f2bb22a0f7d04676f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 16 Jul 2026 01:53:46 -0700 Subject: [PATCH 3/8] test(clickup): webhook provider handler tests + redact create-response secret in error logs --- .../lib/webhooks/providers/clickup.test.ts | 384 ++++++++++++++++++ apps/sim/lib/webhooks/providers/clickup.ts | 13 +- 2 files changed, 395 insertions(+), 2 deletions(-) create mode 100644 apps/sim/lib/webhooks/providers/clickup.test.ts diff --git a/apps/sim/lib/webhooks/providers/clickup.test.ts b/apps/sim/lib/webhooks/providers/clickup.test.ts new file mode 100644 index 00000000000..4ea510558a7 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/clickup.test.ts @@ -0,0 +1,384 @@ +/** + * @vitest-environment node + */ +import { hmacSha256Hex } from '@sim/security/hmac' +import { NextRequest, NextResponse } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetCredentialOwner, mockRefreshAccessTokenIfNeeded } = vi.hoisted(() => ({ + mockGetCredentialOwner: vi.fn(), + mockRefreshAccessTokenIfNeeded: vi.fn(), +})) + +vi.mock('@/lib/webhooks/provider-subscription-utils', () => ({ + getProviderConfig: (webhook: { providerConfig?: Record }) => + webhook.providerConfig || {}, + getNotificationUrl: () => 'https://app.example.com/api/webhooks/trigger/clickup-path', + getCredentialOwner: mockGetCredentialOwner, +})) + +vi.mock('@/app/api/auth/oauth/utils', () => ({ + refreshAccessTokenIfNeeded: mockRefreshAccessTokenIfNeeded, +})) + +import { clickupHandler } from '@/lib/webhooks/providers/clickup' + +const fetchMock = vi.fn() + +function reqWithHeaders(headers: Record): NextRequest { + return new NextRequest('http://localhost/test', { headers }) +} + +function jsonResponse(status: number, body: Record) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function createContext(providerConfig: Record) { + return { + webhook: { id: 'webhook-row-1', path: 'clickup-path', providerConfig }, + workflow: {}, + userId: 'user-1', + requestId: 'req-1', + } as never +} + +describe('ClickUp webhook provider', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', fetchMock) + mockGetCredentialOwner.mockResolvedValue({ userId: 'user-1', accountId: 'account-1' }) + mockRefreshAccessTokenIfNeeded.mockResolvedValue('oauth-token') + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + describe('verifyAuth', () => { + it('fails closed when no webhookSecret is configured', async () => { + const res = await clickupHandler.verifyAuth!({ + request: reqWithHeaders({}), + rawBody: '{}', + requestId: 't1', + providerConfig: {}, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('rejects when the X-Signature header is missing', async () => { + const res = await clickupHandler.verifyAuth!({ + request: reqWithHeaders({}), + rawBody: '{}', + requestId: 't2', + providerConfig: { webhookSecret: 'secret' }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('rejects an invalid signature', async () => { + const res = await clickupHandler.verifyAuth!({ + request: reqWithHeaders({ 'X-Signature': 'deadbeef' }), + rawBody: '{"event":"taskCreated"}', + requestId: 't3', + providerConfig: { webhookSecret: 'secret' }, + webhook: {}, + workflow: {}, + }) + expect(res?.status).toBe(401) + }) + + it('accepts a valid HMAC-SHA256 hex signature over the raw body', async () => { + const rawBody = '{"event":"taskCreated","task_id":"abc"}' + const signature = hmacSha256Hex(rawBody, 'secret') + const res = await clickupHandler.verifyAuth!({ + request: reqWithHeaders({ 'X-Signature': signature }), + rawBody, + requestId: 't4', + providerConfig: { webhookSecret: 'secret' }, + webhook: {}, + workflow: {}, + }) + expect(res).toBeNull() + }) + }) + + describe('matchEvent', () => { + it('passes when the event matches the trigger', async () => { + const result = await clickupHandler.matchEvent!({ + webhook: { id: 'w1' }, + workflow: { id: 'wf1' }, + body: { event: 'taskCreated' }, + request: reqWithHeaders({}), + requestId: 't5', + providerConfig: { triggerId: 'clickup_task_created' }, + }) + expect(result).toBe(true) + }) + + it('skips with a response when the event does not match', async () => { + const result = await clickupHandler.matchEvent!({ + webhook: { id: 'w1' }, + workflow: { id: 'wf1' }, + body: { event: 'taskDeleted' }, + request: reqWithHeaders({}), + requestId: 't6', + providerConfig: { triggerId: 'clickup_task_created' }, + }) + expect(result).toBeInstanceOf(NextResponse) + }) + + it('passes all events through for the catch-all trigger', async () => { + const result = await clickupHandler.matchEvent!({ + webhook: { id: 'w1' }, + workflow: { id: 'wf1' }, + body: { event: 'goalCreated' }, + request: reqWithHeaders({}), + requestId: 't7', + providerConfig: { triggerId: 'clickup_webhook' }, + }) + expect(result).toBe(true) + }) + }) + + describe('extractIdempotencyId', () => { + it('derives the documented webhook_id:history_item_id key', () => { + const body = { + event: 'taskCreated', + webhook_id: 'wh-1', + task_id: 'abc', + history_items: [{ id: 'hist-1' }], + } + expect(clickupHandler.extractIdempotencyId!(body)).toBe('clickup:wh-1:hist-1') + expect(clickupHandler.extractIdempotencyId!({ ...body })).toBe('clickup:wh-1:hist-1') + }) + + it('returns null without history items or webhook_id', () => { + expect( + clickupHandler.extractIdempotencyId!({ event: 'taskCreated', webhook_id: 'wh-1' }) + ).toBeNull() + expect( + clickupHandler.extractIdempotencyId!({ + event: 'taskCreated', + history_items: [{ id: 'h1' }], + }) + ).toBeNull() + }) + }) + + describe('formatInput', () => { + const baseBody = { + event: 'taskCreated', + webhook_id: 'wh-1', + task_id: 'abc', + history_items: [{ id: 'h1' }], + } + + function formatCtx(triggerId: string, body: Record) { + return { + webhook: { providerConfig: { triggerId } }, + workflow: { id: 'wf1', userId: 'user-1' }, + body, + headers: {}, + requestId: 't8', + } as never + } + + it('maps task events to the task output keys', async () => { + const { input } = await clickupHandler.formatInput!( + formatCtx('clickup_task_created', baseBody) + ) + expect(Object.keys(input as Record).sort()).toEqual([ + 'eventType', + 'historyItems', + 'payload', + 'taskId', + ]) + expect((input as Record).taskId).toBe('abc') + }) + + it('maps list events to the list output keys', async () => { + const body = { event: 'listCreated', webhook_id: 'wh-1', list_id: '162641285' } + const { input } = await clickupHandler.formatInput!(formatCtx('clickup_list_created', body)) + expect((input as Record).listId).toBe('162641285') + expect((input as Record).eventType).toBe('listCreated') + }) + + it('maps goal events to the documented base keys only', async () => { + const body = { event: 'goalCreated', webhook_id: 'wh-1' } + const { input } = await clickupHandler.formatInput!(formatCtx('clickup_goal_created', body)) + expect(Object.keys(input as Record).sort()).toEqual([ + 'eventType', + 'historyItems', + 'payload', + ]) + }) + + it('maps the catch-all trigger to the generic output keys', async () => { + const { input } = await clickupHandler.formatInput!(formatCtx('clickup_webhook', baseBody)) + expect(Object.keys(input as Record).sort()).toEqual([ + 'eventType', + 'folderId', + 'historyItems', + 'listId', + 'payload', + 'spaceId', + 'taskId', + ]) + }) + }) + + describe('createSubscription', () => { + const validConfig = { + triggerId: 'clickup_task_created', + credentialId: 'cred-1', + triggerWorkspaceId: '108', + } + + it('creates the webhook and returns externalId + webhookSecret', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { id: 'ext-1', webhook: { id: 'ext-1', secret: 'shh' } }) + ) + + const result = await clickupHandler.createSubscription!(createContext(validConfig)) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('https://api.clickup.com/api/v2/team/108/webhook') + expect(init.headers.Authorization).toBe('Bearer oauth-token') + expect(JSON.parse(init.body)).toEqual({ + endpoint: 'https://app.example.com/api/webhooks/trigger/clickup-path', + events: ['taskCreated'], + }) + expect(result?.providerConfigUpdates).toEqual({ externalId: 'ext-1', webhookSecret: 'shh' }) + }) + + it('subscribes with the wildcard for the catch-all trigger and coerces location filters', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { id: 'ext-2', webhook: { secret: 'shh' } }) + ) + + await clickupHandler.createSubscription!( + createContext({ + triggerId: 'clickup_webhook', + credentialId: 'cred-1', + triggerWorkspaceId: '108', + triggerSpaceId: '1234', + triggerListId: '9876', + triggerTaskId: 'abc1234', + }) + ) + + const [, init] = fetchMock.mock.calls[0] + expect(JSON.parse(init.body)).toEqual({ + endpoint: 'https://app.example.com/api/webhooks/trigger/clickup-path', + events: ['*'], + space_id: 1234, + list_id: 9876, + task_id: 'abc1234', + }) + }) + + it('throws a friendly error when the workspace is missing', async () => { + await expect( + clickupHandler.createSubscription!( + createContext({ triggerId: 'clickup_task_created', credentialId: 'cred-1' }) + ) + ).rejects.toThrow(/workspace is required/i) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('throws a friendly error on 401 from ClickUp', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(401, { err: 'Token invalid' })) + await expect( + clickupHandler.createSubscription!(createContext(validConfig)) + ).rejects.toThrow(/authentication failed/i) + }) + + it('rolls back the created webhook and throws when no secret is returned', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, { id: 'ext-3', webhook: { id: 'ext-3' } })) + fetchMock.mockResolvedValueOnce(jsonResponse(200, {})) + + await expect( + clickupHandler.createSubscription!(createContext(validConfig)) + ).rejects.toThrow(/no signing secret/i) + + expect(fetchMock).toHaveBeenCalledTimes(2) + const [deleteUrl, deleteInit] = fetchMock.mock.calls[1] + expect(deleteUrl).toBe('https://api.clickup.com/api/v2/webhook/ext-3') + expect(deleteInit.method).toBe('DELETE') + }) + + it('rejects non-numeric location filters before calling ClickUp', async () => { + await expect( + clickupHandler.createSubscription!( + createContext({ ...validConfig, triggerSpaceId: 'not-a-number' }) + ) + ).rejects.toThrow(/Space ID must be numeric/) + expect(fetchMock).not.toHaveBeenCalled() + }) + }) + + describe('deleteSubscription', () => { + it('deletes the external webhook', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, {})) + await clickupHandler.deleteSubscription!({ + webhook: { + id: 'webhook-row-1', + providerConfig: { externalId: 'ext-1', credentialId: 'cred-1' }, + }, + workflow: {}, + requestId: 'req-1', + }) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('https://api.clickup.com/api/v2/webhook/ext-1') + expect(init.method).toBe('DELETE') + }) + + it('tolerates 404 and never throws when not strict', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(404, {})) + await expect( + clickupHandler.deleteSubscription!({ + webhook: { + id: 'webhook-row-1', + providerConfig: { externalId: 'ext-1', credentialId: 'cred-1' }, + }, + workflow: {}, + requestId: 'req-1', + }) + ).resolves.toBeUndefined() + }) + + it('throws on failure only when strict', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(500, {})) + await expect( + clickupHandler.deleteSubscription!({ + webhook: { + id: 'webhook-row-1', + providerConfig: { externalId: 'ext-1', credentialId: 'cred-1' }, + }, + workflow: {}, + requestId: 'req-1', + strict: true, + }) + ).rejects.toThrow(/Failed to delete ClickUp webhook/) + }) + + it('skips gracefully when externalId is missing', async () => { + await expect( + clickupHandler.deleteSubscription!({ + webhook: { id: 'webhook-row-1', providerConfig: { credentialId: 'cred-1' } }, + workflow: {}, + requestId: 'req-1', + }) + ).resolves.toBeUndefined() + expect(fetchMock).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/sim/lib/webhooks/providers/clickup.ts b/apps/sim/lib/webhooks/providers/clickup.ts index 6f468978cb7..ac3a8c153c6 100644 --- a/apps/sim/lib/webhooks/providers/clickup.ts +++ b/apps/sim/lib/webhooks/providers/clickup.ts @@ -227,11 +227,20 @@ export const clickupHandler: WebhookProviderHandler = { } const externalId = responseBody.id ?? responseBody.webhook?.id const secret = responseBody.webhook?.secret + const redactedResponse = { + ...responseBody, + webhook: responseBody.webhook + ? { + ...responseBody.webhook, + secret: responseBody.webhook.secret ? '[redacted]' : undefined, + } + : undefined, + } if (!externalId) { logger.error( `[${requestId}] ClickUp webhook created but no webhook id returned for webhook ${webhookRecord.id}`, - { response: responseBody } + { response: redactedResponse } ) throw new Error('ClickUp webhook creation succeeded but no webhook ID was returned') } @@ -239,7 +248,7 @@ export const clickupHandler: WebhookProviderHandler = { if (!secret) { logger.error( `[${requestId}] ClickUp webhook created but no secret returned for webhook ${webhookRecord.id}. Rolling back.`, - { response: responseBody } + { response: redactedResponse } ) await deleteClickUpWebhook(accessToken, externalId).catch(() => undefined) throw new Error('ClickUp webhook creation succeeded but no signing secret was returned') From be4cedc0573f732ca6947ab2bfc3ec47a25d1833 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 16 Jul 2026 01:59:32 -0700 Subject: [PATCH 4/8] fix(clickup-connector): trim final page to maxDocs cap with precise listingCapped semantics --- apps/sim/connectors/clickup/clickup.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/sim/connectors/clickup/clickup.ts b/apps/sim/connectors/clickup/clickup.ts index 7bb320a4ba1..491d9ace1ea 100644 --- a/apps/sim/connectors/clickup/clickup.ts +++ b/apps/sim/connectors/clickup/clickup.ts @@ -186,20 +186,26 @@ export const clickupConnector: ConnectorConfig = { const data = (await response.json()) as Record const rawDocs = Array.isArray(data.docs) ? data.docs : [] - const documents: ExternalDocument[] = [] + const previouslyFetched = (syncContext?.totalDocsFetched as number) ?? 0 + const remaining = + maxDocs > 0 ? Math.max(0, maxDocs - previouslyFetched) : Number.POSITIVE_INFINITY + + const pageDocuments: ExternalDocument[] = [] for (const rawDoc of rawDocs) { const doc = parseDoc(rawDoc) if (!doc || doc.deleted || doc.archived) continue - documents.push(docToStub(doc, workspaceId)) + pageDocuments.push(docToStub(doc, workspaceId)) } + const documents = pageDocuments.slice(0, remaining) + const trimmedByCap = documents.length < pageDocuments.length - const totalFetched = ((syncContext?.totalDocsFetched as number) ?? 0) + documents.length + const totalFetched = previouslyFetched + documents.length if (syncContext) syncContext.totalDocsFetched = totalFetched - const hitLimit = maxDocs > 0 && totalFetched >= maxDocs - if (hitLimit && syncContext) syncContext.listingCapped = true - const nextCursor = typeof data.next_cursor === 'string' && data.next_cursor ? data.next_cursor : undefined + const hitLimit = maxDocs > 0 && totalFetched >= maxDocs + const capTruncatedListing = hitLimit && (trimmedByCap || Boolean(nextCursor)) + if (capTruncatedListing && syncContext) syncContext.listingCapped = true return { documents, From 1c161cbda8eadbcbaa0bc22c998ae284091236c1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 16 Jul 2026 02:00:07 -0700 Subject: [PATCH 5/8] chore(clickup): lint formatting --- apps/sim/lib/webhooks/providers/clickup.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/clickup.test.ts b/apps/sim/lib/webhooks/providers/clickup.test.ts index 4ea510558a7..f8b6d1ff344 100644 --- a/apps/sim/lib/webhooks/providers/clickup.test.ts +++ b/apps/sim/lib/webhooks/providers/clickup.test.ts @@ -296,18 +296,18 @@ describe('ClickUp webhook provider', () => { it('throws a friendly error on 401 from ClickUp', async () => { fetchMock.mockResolvedValueOnce(jsonResponse(401, { err: 'Token invalid' })) - await expect( - clickupHandler.createSubscription!(createContext(validConfig)) - ).rejects.toThrow(/authentication failed/i) + await expect(clickupHandler.createSubscription!(createContext(validConfig))).rejects.toThrow( + /authentication failed/i + ) }) it('rolls back the created webhook and throws when no secret is returned', async () => { fetchMock.mockResolvedValueOnce(jsonResponse(200, { id: 'ext-3', webhook: { id: 'ext-3' } })) fetchMock.mockResolvedValueOnce(jsonResponse(200, {})) - await expect( - clickupHandler.createSubscription!(createContext(validConfig)) - ).rejects.toThrow(/no signing secret/i) + await expect(clickupHandler.createSubscription!(createContext(validConfig))).rejects.toThrow( + /no signing secret/i + ) expect(fetchMock).toHaveBeenCalledTimes(2) const [deleteUrl, deleteInit] = fetchMock.mock.calls[1] From fb95d46483931d7af001adc0aa33882ec3215aad Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 16 Jul 2026 02:10:26 -0700 Subject: [PATCH 6/8] fix(clickup): restore list-op location requiredness, split listSpaceId migration target, surface failed webhook rollback --- .../content/docs/en/integrations/clickup.mdx | 58 +++++++++---------- apps/sim/blocks/blocks/clickup.ts | 54 +++++++++++++++-- .../selectors/providers/clickup/selectors.ts | 10 ++-- apps/sim/hooks/selectors/types.ts | 1 + apps/sim/lib/webhooks/providers/clickup.ts | 11 +++- .../migrations/subblock-migrations.ts | 2 +- apps/sim/lib/workflows/subblocks/context.ts | 1 + 7 files changed, 97 insertions(+), 40 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/clickup.mdx b/apps/docs/content/docs/en/integrations/clickup.mdx index f41856f75d6..5a2221b8556 100644 --- a/apps/docs/content/docs/en/integrations/clickup.mdx +++ b/apps/docs/content/docs/en/integrations/clickup.mdx @@ -800,7 +800,7 @@ Trigger workflow when a folder is created in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | | `folderId` | string | ID of the affected folder | @@ -816,7 +816,7 @@ Trigger workflow when a folder is deleted in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | | `folderId` | string | ID of the affected folder | @@ -832,7 +832,7 @@ Trigger workflow when a folder is updated in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | | `folderId` | string | ID of the affected folder | @@ -848,7 +848,7 @@ Trigger workflow when a goal is created in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | @@ -863,7 +863,7 @@ Trigger workflow when a goal is deleted in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | @@ -878,7 +878,7 @@ Trigger workflow when a goal is updated in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | @@ -893,7 +893,7 @@ Trigger workflow when a key result is created in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | @@ -908,7 +908,7 @@ Trigger workflow when a key result is deleted in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | @@ -923,7 +923,7 @@ Trigger workflow when a key result is updated in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | @@ -938,7 +938,7 @@ Trigger workflow when a list is created in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | | `listId` | string | ID of the affected list | @@ -954,7 +954,7 @@ Trigger workflow when a list is deleted in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | | `listId` | string | ID of the affected list | @@ -970,7 +970,7 @@ Trigger workflow when a list is updated in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | | `listId` | string | ID of the affected list | @@ -986,7 +986,7 @@ Trigger workflow when a space is created in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | | `spaceId` | string | ID of the affected space | @@ -1002,7 +1002,7 @@ Trigger workflow when a space is deleted in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | | `spaceId` | string | ID of the affected space | @@ -1018,7 +1018,7 @@ Trigger workflow when a space is updated in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | | `spaceId` | string | ID of the affected space | @@ -1034,7 +1034,7 @@ Trigger workflow when the assignees of a task change in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | | `taskId` | string | ID of the affected task | @@ -1050,7 +1050,7 @@ Trigger workflow when a comment is posted on a task in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | | `taskId` | string | ID of the affected task | @@ -1066,7 +1066,7 @@ Trigger workflow when a task comment is updated in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | | `taskId` | string | ID of the affected task | @@ -1082,7 +1082,7 @@ Trigger workflow when a task is created in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | | `taskId` | string | ID of the affected task | @@ -1098,7 +1098,7 @@ Trigger workflow when a task is deleted in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | | `taskId` | string | ID of the affected task | @@ -1114,7 +1114,7 @@ Trigger workflow when the due date of a task changes in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | | `taskId` | string | ID of the affected task | @@ -1130,7 +1130,7 @@ Trigger workflow when a task is moved to a different list in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | | `taskId` | string | ID of the affected task | @@ -1146,7 +1146,7 @@ Trigger workflow when the priority of a task changes in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | | `taskId` | string | ID of the affected task | @@ -1162,7 +1162,7 @@ Trigger workflow when the status of a task changes in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | | `taskId` | string | ID of the affected task | @@ -1178,7 +1178,7 @@ Trigger workflow when the tags of a task change in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | | `taskId` | string | ID of the affected task | @@ -1194,7 +1194,7 @@ Trigger workflow when the time estimate of a task changes in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | | `taskId` | string | ID of the affected task | @@ -1210,7 +1210,7 @@ Trigger workflow when the tracked time of a task changes in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | | `taskId` | string | ID of the affected task | @@ -1226,7 +1226,7 @@ Trigger workflow when a task is updated in ClickUp | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | | `taskId` | string | ID of the affected task | @@ -1242,7 +1242,7 @@ Trigger workflow on any ClickUp event (subscribes to all events) | Parameter | Type | Description | | --------- | ---- | ----------- | | `eventType` | string | The ClickUp event name \(e.g. taskCreated\) | -| `historyItems` | json | History items describing what changed \(id, type, date, user, before, after\) | +| `historyItems` | json | History items describing what changed \(id, type, date, source, user, before, after\) | | `payload` | json | Full raw ClickUp webhook payload | | `taskId` | string | ID of the affected task \(task events only\) | | `listId` | string | ID of the affected list \(list events only\) | diff --git a/apps/sim/blocks/blocks/clickup.ts b/apps/sim/blocks/blocks/clickup.ts index be5c0155102..3fb97671f03 100644 --- a/apps/sim/blocks/blocks/clickup.ts +++ b/apps/sim/blocks/blocks/clickup.ts @@ -195,7 +195,7 @@ export const ClickUpBlock: BlockConfig = { mode: 'basic', condition: { field: 'operation', - value: [...CLICKUP_SPACE_OPS, ...CLICKUP_LIST_PARENT_OPS, ...CLICKUP_LIST_OPS], + value: [...CLICKUP_SPACE_OPS, ...CLICKUP_LIST_OPS], }, required: { field: 'operation', value: CLICKUP_SPACE_OPS }, }, @@ -209,7 +209,7 @@ export const ClickUpBlock: BlockConfig = { mode: 'advanced', condition: { field: 'operation', - value: [...CLICKUP_SPACE_OPS, ...CLICKUP_LIST_PARENT_OPS, ...CLICKUP_LIST_OPS], + value: [...CLICKUP_SPACE_OPS, ...CLICKUP_LIST_OPS], }, required: { field: 'operation', value: CLICKUP_SPACE_OPS }, }, @@ -227,6 +227,39 @@ export const ClickUpBlock: BlockConfig = { value: ['get_lists', 'create_list'], }, }, + { + id: 'listSpaceSelector', + title: 'Space', + type: 'project-selector', + canonicalParamId: 'listSpaceId', + serviceId: 'clickup', + selectorKey: 'clickup.spaces', + placeholder: 'Select a space', + description: 'Required for folderless lists; used to browse folders in folder mode', + dependsOn: ['credential', 'workspaceSelector'], + mode: 'basic', + condition: { field: 'operation', value: CLICKUP_LIST_PARENT_OPS }, + required: { + field: 'operation', + value: CLICKUP_LIST_PARENT_OPS, + and: { field: 'listParent', value: 'space' }, + }, + }, + { + id: 'manualListSpaceId', + title: 'Space ID', + type: 'short-input', + canonicalParamId: 'listSpaceId', + placeholder: 'Enter space ID', + dependsOn: ['credential'], + mode: 'advanced', + condition: { field: 'operation', value: CLICKUP_LIST_PARENT_OPS }, + required: { + field: 'operation', + value: CLICKUP_LIST_PARENT_OPS, + and: { field: 'listParent', value: 'space' }, + }, + }, { id: 'folderSelector', title: 'Folder', @@ -236,12 +269,17 @@ export const ClickUpBlock: BlockConfig = { selectorKey: 'clickup.folders', placeholder: 'Select a folder', description: 'Leave empty for folderless lists that live directly in the space', - dependsOn: ['credential', 'spaceSelector'], + dependsOn: { all: ['credential'], any: ['spaceSelector', 'listSpaceSelector'] }, mode: 'basic', condition: { field: 'operation', value: [...CLICKUP_LIST_PARENT_OPS, ...CLICKUP_LIST_OPS], }, + required: { + field: 'operation', + value: CLICKUP_LIST_PARENT_OPS, + and: { field: 'listParent', value: 'folder' }, + }, }, { id: 'manualFolderId', @@ -255,6 +293,11 @@ export const ClickUpBlock: BlockConfig = { field: 'operation', value: [...CLICKUP_LIST_PARENT_OPS, ...CLICKUP_LIST_OPS], }, + required: { + field: 'operation', + value: CLICKUP_LIST_PARENT_OPS, + and: { field: 'listParent', value: 'folder' }, + }, }, { id: 'listSelector', @@ -1405,7 +1448,7 @@ Return ONLY the value (plain string, number, or JSON) - no explanations, no extr return { ...baseParams, folderId: params.listParent === 'space' ? undefined : params.folderId || undefined, - spaceId: params.listParent === 'space' ? params.spaceId || undefined : undefined, + spaceId: params.listParent === 'space' ? params.listSpaceId || undefined : undefined, archived: params.archived ? true : undefined, } case 'create_folder': @@ -1418,7 +1461,7 @@ Return ONLY the value (plain string, number, or JSON) - no explanations, no extr return { ...baseParams, folderId: params.listParent === 'space' ? undefined : params.folderId || undefined, - spaceId: params.listParent === 'space' ? params.spaceId || undefined : undefined, + spaceId: params.listParent === 'space' ? params.listSpaceId || undefined : undefined, name: params.name, content: params.content || undefined, markdownContent: params.markdownContent || undefined, @@ -1564,6 +1607,7 @@ Return ONLY the value (plain string, number, or JSON) - no explanations, no extr oauthCredential: { type: 'string', description: 'ClickUp OAuth credential' }, teamId: { type: 'string', description: 'Workspace (team) ID' }, spaceId: { type: 'string', description: 'Space ID' }, + listSpaceId: { type: 'string', description: 'Space ID for folderless list operations' }, listParent: { type: 'string', description: 'Where lists live for list operations (folder or space)', diff --git a/apps/sim/hooks/selectors/providers/clickup/selectors.ts b/apps/sim/hooks/selectors/providers/clickup/selectors.ts index 15376272364..2cf7ca40aa9 100644 --- a/apps/sim/hooks/selectors/providers/clickup/selectors.ts +++ b/apps/sim/hooks/selectors/providers/clickup/selectors.ts @@ -64,18 +64,20 @@ export const clickupSelectors = { 'selectors', 'clickup.folders', context.oauthCredential ?? 'none', - context.spaceId ?? 'none', + context.spaceId ?? context.listSpaceId ?? 'none', ], - enabled: ({ context }) => Boolean(context.oauthCredential && context.spaceId), + enabled: ({ context }) => + Boolean(context.oauthCredential && (context.spaceId || context.listSpaceId)), fetchList: async ({ context, signal }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'clickup.folders') - if (!context.spaceId) { + const spaceId = context.spaceId || context.listSpaceId + if (!spaceId) { throw new Error('Missing space ID for clickup.folders selector') } const data = await requestJson(selectorContracts.clickupFoldersSelectorContract, { body: { credential: credentialId, - spaceId: context.spaceId, + spaceId, workflowId: context.workflowId, }, signal, diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts index 1c4e9fe89ce..8a6eec1ebb3 100644 --- a/apps/sim/hooks/selectors/types.ts +++ b/apps/sim/hooks/selectors/types.ts @@ -92,6 +92,7 @@ export interface SelectorContext { impersonateUserEmail?: string boardId?: string spaceId?: string + listSpaceId?: string folderId?: string awsAccessKeyId?: string awsSecretAccessKey?: string diff --git a/apps/sim/lib/webhooks/providers/clickup.ts b/apps/sim/lib/webhooks/providers/clickup.ts index ac3a8c153c6..dff0f8b32f6 100644 --- a/apps/sim/lib/webhooks/providers/clickup.ts +++ b/apps/sim/lib/webhooks/providers/clickup.ts @@ -250,7 +250,16 @@ export const clickupHandler: WebhookProviderHandler = { `[${requestId}] ClickUp webhook created but no secret returned for webhook ${webhookRecord.id}. Rolling back.`, { response: redactedResponse } ) - await deleteClickUpWebhook(accessToken, externalId).catch(() => undefined) + const rollback = await deleteClickUpWebhook(accessToken, externalId).catch(() => undefined) + if (!rollback || (!rollback.ok && rollback.status !== 404)) { + logger.error( + `[${requestId}] Failed to roll back ClickUp webhook ${externalId} after missing secret`, + { clickupWebhookId: externalId, status: rollback?.status } + ) + throw new Error( + `ClickUp webhook creation succeeded but no signing secret was returned, and rolling back the webhook failed. Delete webhook ${externalId} manually in ClickUp before retrying.` + ) + } throw new Error('ClickUp webhook creation succeeded but no signing secret was returned') } diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.ts index 9966ff3c58c..db8a1bea308 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.ts @@ -51,7 +51,7 @@ export const SUBBLOCK_ID_MIGRATIONS: Record> = { clickup: { workspaceId: 'workspaceSelector', spaceId: 'spaceSelector', - listSpaceId: 'spaceSelector', + listSpaceId: 'listSpaceSelector', folderId: 'folderSelector', listId: 'listSelector', }, diff --git a/apps/sim/lib/workflows/subblocks/context.ts b/apps/sim/lib/workflows/subblocks/context.ts index 1f779b16c00..0d94267af50 100644 --- a/apps/sim/lib/workflows/subblocks/context.ts +++ b/apps/sim/lib/workflows/subblocks/context.ts @@ -30,6 +30,7 @@ export const SELECTOR_CONTEXT_FIELDS = new Set([ 'impersonateUserEmail', 'boardId', 'spaceId', + 'listSpaceId', 'folderId', 'awsAccessKeyId', 'awsSecretAccessKey', From bd51be8aa389ea2a8142adea0bcd20c2c7845592 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 16 Jul 2026 02:18:53 -0700 Subject: [PATCH 7/8] fix(clickup): clickup.lists selector accepts listSpaceId context like clickup.folders --- .../hooks/selectors/providers/clickup/selectors.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/sim/hooks/selectors/providers/clickup/selectors.ts b/apps/sim/hooks/selectors/providers/clickup/selectors.ts index 2cf7ca40aa9..87cf96ec094 100644 --- a/apps/sim/hooks/selectors/providers/clickup/selectors.ts +++ b/apps/sim/hooks/selectors/providers/clickup/selectors.ts @@ -96,21 +96,24 @@ export const clickupSelectors = { 'selectors', 'clickup.lists', context.oauthCredential ?? 'none', - context.spaceId ?? 'none', + context.spaceId ?? context.listSpaceId ?? 'none', context.folderId ?? 'none', ], enabled: ({ context }) => - Boolean(context.oauthCredential && (context.folderId || context.spaceId)), + Boolean( + context.oauthCredential && (context.folderId || context.spaceId || context.listSpaceId) + ), fetchList: async ({ context, signal }: SelectorQueryArgs) => { const credentialId = ensureCredential(context, 'clickup.lists') - if (!context.folderId && !context.spaceId) { + const spaceId = context.spaceId || context.listSpaceId + if (!context.folderId && !spaceId) { throw new Error('Missing folder or space ID for clickup.lists selector') } const data = await requestJson(selectorContracts.clickupListsSelectorContract, { body: { credential: credentialId, folderId: context.folderId, - spaceId: context.spaceId, + spaceId, workflowId: context.workflowId, }, signal, From 872601804072f55eb550de57316889cf6f4fadfa Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 16 Jul 2026 10:12:32 -0700 Subject: [PATCH 8/8] fix(clickup): integer-only maxDocs and location filters, depth-aware doc headings, most-specific-location hints --- apps/sim/connectors/clickup/clickup.ts | 13 +++++++------ apps/sim/lib/webhooks/providers/clickup.test.ts | 9 +++++++-- apps/sim/lib/webhooks/providers/clickup.ts | 4 ++-- apps/sim/triggers/clickup/subblocks.ts | 12 ++++++++---- 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/apps/sim/connectors/clickup/clickup.ts b/apps/sim/connectors/clickup/clickup.ts index 491d9ace1ea..7d42855face 100644 --- a/apps/sim/connectors/clickup/clickup.ts +++ b/apps/sim/connectors/clickup/clickup.ts @@ -96,14 +96,15 @@ function parsePage(value: unknown): ClickUpDocPage | null { * Flattens a Doc's page tree depth-first into markdown sections, skipping * deleted and archived pages (their subpages are skipped with them). */ -function flattenPages(pages: ClickUpDocPage[], sections: string[]): void { +function flattenPages(pages: ClickUpDocPage[], sections: string[], depth = 0): void { + const heading = '#'.repeat(Math.min(depth + 1, 6)) for (const page of pages) { if (page.deleted || page.archived) continue const parts: string[] = [] - if (page.name.trim()) parts.push(`# ${page.name.trim()}`) + if (page.name.trim()) parts.push(`${heading} ${page.name.trim()}`) if (page.content.trim()) parts.push(page.content.trim()) if (parts.length > 0) sections.push(parts.join('\n\n')) - flattenPages(page.pages, sections) + flattenPages(page.pages, sections, depth + 1) } } @@ -169,7 +170,7 @@ export const clickupConnector: ConnectorConfig = { typeof sourceConfig.spaceId === 'string' && sourceConfig.spaceId.trim() ? sourceConfig.spaceId.trim() : undefined - const maxDocs = sourceConfig.maxDocs ? Number(sourceConfig.maxDocs) : 0 + const maxDocs = sourceConfig.maxDocs ? Math.floor(Number(sourceConfig.maxDocs)) : 0 const url = buildSearchDocsUrl(workspaceId, { spaceId, limit: LIST_PAGE_SIZE, cursor }) const response = await fetchWithRetry(url, { @@ -271,8 +272,8 @@ export const clickupConnector: ConnectorConfig = { } const maxDocs = sourceConfig.maxDocs as string | undefined - if (maxDocs && (Number.isNaN(Number(maxDocs)) || Number(maxDocs) <= 0)) { - return { valid: false, error: 'Max docs must be a positive number' } + if (maxDocs && (!Number.isInteger(Number(maxDocs)) || Number(maxDocs) <= 0)) { + return { valid: false, error: 'Max docs must be a positive whole number' } } const spaceId = diff --git a/apps/sim/lib/webhooks/providers/clickup.test.ts b/apps/sim/lib/webhooks/providers/clickup.test.ts index f8b6d1ff344..00247cd12e4 100644 --- a/apps/sim/lib/webhooks/providers/clickup.test.ts +++ b/apps/sim/lib/webhooks/providers/clickup.test.ts @@ -315,12 +315,17 @@ describe('ClickUp webhook provider', () => { expect(deleteInit.method).toBe('DELETE') }) - it('rejects non-numeric location filters before calling ClickUp', async () => { + it('rejects non-integer location filters before calling ClickUp', async () => { await expect( clickupHandler.createSubscription!( createContext({ ...validConfig, triggerSpaceId: 'not-a-number' }) ) - ).rejects.toThrow(/Space ID must be numeric/) + ).rejects.toThrow(/Space ID must be a whole number/) + await expect( + clickupHandler.createSubscription!( + createContext({ ...validConfig, triggerSpaceId: '12.5' }) + ) + ).rejects.toThrow(/Space ID must be a whole number/) expect(fetchMock).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/webhooks/providers/clickup.ts b/apps/sim/lib/webhooks/providers/clickup.ts index dff0f8b32f6..63fa5c420ac 100644 --- a/apps/sim/lib/webhooks/providers/clickup.ts +++ b/apps/sim/lib/webhooks/providers/clickup.ts @@ -74,8 +74,8 @@ function parseOptionalNumericId(value: unknown, label: string): number | undefin const raw = String(value).trim() if (!raw) return undefined const parsed = Number(raw) - if (!Number.isFinite(parsed)) { - throw new Error(`ClickUp ${label} must be numeric. Received: ${raw}`) + if (!Number.isInteger(parsed)) { + throw new Error(`ClickUp ${label} must be a whole number. Received: ${raw}`) } return parsed } diff --git a/apps/sim/triggers/clickup/subblocks.ts b/apps/sim/triggers/clickup/subblocks.ts index 2bebbf9244f..c9ec57d91e1 100644 --- a/apps/sim/triggers/clickup/subblocks.ts +++ b/apps/sim/triggers/clickup/subblocks.ts @@ -69,7 +69,8 @@ export function buildClickUpTriggerSubBlocks(triggerId: string): SubBlockConfig[ title: 'Space ID (Optional)', type: 'short-input', placeholder: 'Leave empty for the entire workspace', - description: 'Only receive events from this space', + description: + 'Only receive events from this space. ClickUp applies the most specific location when several are set', mode: 'trigger', condition: { field: 'selectedTriggerId', value: triggerId }, }, @@ -78,7 +79,8 @@ export function buildClickUpTriggerSubBlocks(triggerId: string): SubBlockConfig[ title: 'Folder ID (Optional)', type: 'short-input', placeholder: 'Leave empty for the entire workspace', - description: 'Only receive events from this folder', + description: + 'Only receive events from this folder. ClickUp applies the most specific location when several are set', mode: 'trigger', condition: { field: 'selectedTriggerId', value: triggerId }, }, @@ -87,7 +89,8 @@ export function buildClickUpTriggerSubBlocks(triggerId: string): SubBlockConfig[ title: 'List ID (Optional)', type: 'short-input', placeholder: 'Leave empty for the entire workspace', - description: 'Only receive events from this list', + description: + 'Only receive events from this list. ClickUp applies the most specific location when several are set', mode: 'trigger', condition: { field: 'selectedTriggerId', value: triggerId }, }, @@ -96,7 +99,8 @@ export function buildClickUpTriggerSubBlocks(triggerId: string): SubBlockConfig[ title: 'Task ID (Optional)', type: 'short-input', placeholder: 'Leave empty for the entire workspace', - description: 'Only receive events for this task', + description: + 'Only receive events for this task. ClickUp applies the most specific location when several are set', mode: 'trigger', condition: { field: 'selectedTriggerId', value: triggerId }, },