diff --git a/apps/docs/content/docs/en/integrations/clickup.mdx b/apps/docs/content/docs/en/integrations/clickup.mdx index 4fc022846f3..5a2221b8556 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, source, 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, source, 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, source, 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, source, 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, source, 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, source, 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, source, 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, source, 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, source, 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, source, 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, source, 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, source, 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, source, 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, source, 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, source, 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, source, 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, source, 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, source, 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, source, 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, source, 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, source, 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, source, 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, source, 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, source, 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, source, 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, source, 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, source, 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, source, 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, 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\) | +| `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..6613be9189e --- /dev/null +++ b/apps/sim/app/api/tools/clickup/folders/route.ts @@ -0,0 +1,93 @@ +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 { 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' +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) => { + try { + const requestId = generateRequestId() + const parsed = await parseRequest(clickupFoldersSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId, spaceId } = parsed.data.body + + const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId') + if (!spaceIdValidation.isValid) { + logger.error('Invalid spaceId', { error: spaceIdValidation.error }) + return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 }) + } + + 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 }) + } 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 new file mode 100644 index 00000000000..a07712b36e4 --- /dev/null +++ b/apps/sim/app/api/tools/clickup/lists/route.ts @@ -0,0 +1,105 @@ +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 { 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' +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) => { + try { + const requestId = generateRequestId() + const parsed = await parseRequest(clickupListsSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId, folderId, spaceId } = parsed.data.body + + 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 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 }) + } 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 new file mode 100644 index 00000000000..197e5245ca4 --- /dev/null +++ b/apps/sim/app/api/tools/clickup/spaces/route.ts @@ -0,0 +1,93 @@ +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 { 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' +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) => { + try { + const requestId = generateRequestId() + const parsed = await parseRequest(clickupSpacesSelectorContract, request, {}) + if (!parsed.success) return parsed.response + const { credential, workflowId, teamId } = parsed.data.body + + const teamIdValidation = validateAlphanumericId(teamId, 'teamId') + if (!teamIdValidation.isValid) { + logger.error('Invalid teamId', { error: teamIdValidation.error }) + return NextResponse.json({ error: teamIdValidation.error }, { status: 400 }) + } + + 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 }) + } 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 new file mode 100644 index 00000000000..2ff80e7f2dd --- /dev/null +++ b/apps/sim/app/api/tools/clickup/workspaces/route.ts @@ -0,0 +1,81 @@ +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) => { + 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', { + 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 }) + } 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/blocks/blocks/clickup.ts b/apps/sim/blocks/blocks/clickup.ts index 9a0281b18e6..3fb97671f03 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_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_OPS], }, + required: { field: 'operation', value: CLICKUP_SPACE_OPS }, }, { id: 'listParent', @@ -183,39 +228,100 @@ export const ClickUpBlock: BlockConfig = { }, }, { - id: 'folderId', - title: 'Folder ID', - type: 'short-input', - required: true, - placeholder: 'Enter folder ID', - condition: { + 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: ['get_lists', 'create_list'], - and: { field: 'listParent', value: 'folder' }, + value: CLICKUP_LIST_PARENT_OPS, + and: { field: 'listParent', value: 'space' }, }, }, { - id: 'listSpaceId', + id: 'manualListSpaceId', title: 'Space ID', type: 'short-input', - required: true, + canonicalParamId: 'listSpaceId', placeholder: 'Enter space ID', - condition: { + dependsOn: ['credential'], + mode: 'advanced', + condition: { field: 'operation', value: CLICKUP_LIST_PARENT_OPS }, + required: { field: 'operation', - value: ['get_lists', 'create_list'], + value: CLICKUP_LIST_PARENT_OPS, and: { field: 'listParent', value: 'space' }, }, }, { - id: 'listId', - title: 'List 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: { 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', + title: 'Folder ID', type: 'short-input', - required: true, - placeholder: 'Enter list ID', + canonicalParamId: 'folderId', + placeholder: 'Enter folder ID', + dependsOn: ['credential'], + mode: 'advanced', condition: { field: 'operation', - value: ['create_task', 'get_tasks', 'get_list_members', 'get_custom_fields'], + value: [...CLICKUP_LIST_PARENT_OPS, ...CLICKUP_LIST_OPS], }, + required: { + field: 'operation', + value: CLICKUP_LIST_PARENT_OPS, + and: { field: 'listParent', value: 'folder' }, + }, + }, + { + 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', + canonicalParamId: 'listId', + placeholder: 'Enter list ID', + dependsOn: ['credential'], + mode: 'advanced', + condition: { field: 'operation', value: CLICKUP_LIST_OPS }, + required: { field: 'operation', value: CLICKUP_LIST_OPS }, }, { id: 'taskId', @@ -1062,6 +1168,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 +1341,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 +1435,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': @@ -1393,7 +1528,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 +1550,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 +1561,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 +1573,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 +1588,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 +1605,14 @@ 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' }, + listSpaceId: { type: 'string', description: 'Space ID for folderless list operations' }, 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 +1731,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/connectors/clickup/clickup.ts b/apps/sim/connectors/clickup/clickup.ts new file mode 100644 index 00000000000..7d42855face --- /dev/null +++ b/apps/sim/connectors/clickup/clickup.ts @@ -0,0 +1,319 @@ +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[], 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(`${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, depth + 1) + } +} + +/** + * 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 ? Math.floor(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 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 + pageDocuments.push(docToStub(doc, workspaceId)) + } + const documents = pageDocuments.slice(0, remaining) + const trimmedByCap = documents.length < pageDocuments.length + + const totalFetched = previouslyFetched + documents.length + if (syncContext) syncContext.totalDocsFetched = totalFetched + 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, + 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.isInteger(Number(maxDocs)) || Number(maxDocs) <= 0)) { + return { valid: false, error: 'Max docs must be a positive whole 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/hooks/selectors/providers/clickup/selectors.ts b/apps/sim/hooks/selectors/providers/clickup/selectors.ts new file mode 100644 index 00000000000..87cf96ec094 --- /dev/null +++ b/apps/sim/hooks/selectors/providers/clickup/selectors.ts @@ -0,0 +1,133 @@ +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 ?? context.listSpaceId ?? 'none', + ], + enabled: ({ context }) => + Boolean(context.oauthCredential && (context.spaceId || context.listSpaceId)), + fetchList: async ({ context, signal }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'clickup.folders') + 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, + 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 ?? context.listSpaceId ?? 'none', + context.folderId ?? 'none', + ], + enabled: ({ context }) => + Boolean( + context.oauthCredential && (context.folderId || context.spaceId || context.listSpaceId) + ), + fetchList: async ({ context, signal }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'clickup.lists') + 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, + 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..8a6eec1ebb3 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,9 @@ export interface SelectorContext { serviceDeskId?: string impersonateUserEmail?: string boardId?: string + spaceId?: string + listSpaceId?: 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.test.ts b/apps/sim/lib/webhooks/providers/clickup.test.ts new file mode 100644 index 00000000000..00247cd12e4 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/clickup.test.ts @@ -0,0 +1,389 @@ +/** + * @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-integer location filters before calling ClickUp', async () => { + await expect( + clickupHandler.createSubscription!( + createContext({ ...validConfig, triggerSpaceId: 'not-a-number' }) + ) + ).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() + }) + }) + + 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 new file mode 100644 index 00000000000..63fa5c420ac --- /dev/null +++ b/apps/sim/lib/webhooks/providers/clickup.ts @@ -0,0 +1,377 @@ +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.isInteger(parsed)) { + throw new Error(`ClickUp ${label} must be a whole number. 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 + }, + + /** + * 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 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 + + return `clickup:${webhookId}:${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 + 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: redactedResponse } + ) + 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: redactedResponse } + ) + 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') + } + + 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/migrations/subblock-migrations.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.ts index a97d73e4e56..db8a1bea308 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: 'listSpaceSelector', + folderId: 'folderSelector', + listId: 'listSelector', + }, apollo: { contact_ids_bulk: 'contacts', account_ids_bulk: 'accounts', diff --git a/apps/sim/lib/workflows/subblocks/context.ts b/apps/sim/lib/workflows/subblocks/context.ts index 4b71917e460..0d94267af50 100644 --- a/apps/sim/lib/workflows/subblocks/context.ts +++ b/apps/sim/lib/workflows/subblocks/context.ts @@ -29,6 +29,9 @@ export const SELECTOR_CONTEXT_FIELDS = new Set([ 'serviceDeskId', 'impersonateUserEmail', 'boardId', + 'spaceId', + 'listSpaceId', + 'folderId', 'awsAccessKeyId', 'awsSecretAccessKey', 'awsRegion', 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/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..c9ec57d91e1 --- /dev/null +++ b/apps/sim/triggers/clickup/subblocks.ts @@ -0,0 +1,117 @@ +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. ClickUp applies the most specific location when several are set', + 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. ClickUp applies the most specific location when several are set', + 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. ClickUp applies the most specific location when several are set', + 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. ClickUp applies the most specific location when several are set', + 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..2af811babeb --- /dev/null +++ b/apps/sim/triggers/clickup/utils.ts @@ -0,0 +1,251 @@ +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, source, 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