Skip to content
465 changes: 464 additions & 1 deletion apps/docs/content/docs/en/integrations/clickup.mdx

Large diffs are not rendered by default.

93 changes: 93 additions & 0 deletions apps/sim/app/api/tools/clickup/folders/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
})
105 changes: 105 additions & 0 deletions apps/sim/app/api/tools/clickup/lists/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
})
93 changes: 93 additions & 0 deletions apps/sim/app/api/tools/clickup/spaces/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
})
81 changes: 81 additions & 0 deletions apps/sim/app/api/tools/clickup/workspaces/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
})
Loading
Loading