Skip to content

Commit e8c4ff7

Browse files
committed
feat(clickup): webhook triggers with auto-managed subscriptions + hierarchy selectors
1 parent 2ec72b6 commit e8c4ff7

49 files changed

Lines changed: 2704 additions & 58 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/content/docs/en/integrations/clickup.mdx

Lines changed: 464 additions & 1 deletion
Large diffs are not rendered by default.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { clickupFoldersSelectorContract } from '@/lib/api/contracts/selectors'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
6+
import { generateRequestId } from '@/lib/core/utils/request'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
9+
import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared'
10+
11+
const logger = createLogger('ClickUpFoldersAPI')
12+
13+
export const dynamic = 'force-dynamic'
14+
15+
interface ClickUpNamedResource {
16+
id: string | number
17+
name?: string
18+
}
19+
20+
export const POST = withRouteHandler(async (request: NextRequest) => {
21+
const requestId = generateRequestId()
22+
const parsed = await parseRequest(clickupFoldersSelectorContract, request, {})
23+
if (!parsed.success) return parsed.response
24+
const { credential, workflowId, spaceId } = parsed.data.body
25+
26+
const authz = await authorizeCredentialUse(request, {
27+
credentialId: credential,
28+
workflowId,
29+
})
30+
if (!authz.ok || !authz.credentialOwnerUserId) {
31+
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
32+
}
33+
34+
const accessToken = await refreshAccessTokenIfNeeded(
35+
credential,
36+
authz.credentialOwnerUserId,
37+
requestId
38+
)
39+
if (!accessToken) {
40+
logger.error('Failed to get access token', {
41+
credentialId: credential,
42+
userId: authz.credentialOwnerUserId,
43+
})
44+
return NextResponse.json(
45+
{ error: 'Could not retrieve access token', authRequired: true },
46+
{ status: 401 }
47+
)
48+
}
49+
50+
const response = await fetch(
51+
`${CLICKUP_API_BASE_URL}/space/${encodeURIComponent(spaceId)}/folder`,
52+
{
53+
headers: {
54+
Authorization: clickupAuthorizationHeader(accessToken),
55+
Accept: 'application/json',
56+
},
57+
}
58+
)
59+
60+
if (!response.ok) {
61+
const errorData = await response.json().catch(() => ({}))
62+
logger.error('Failed to fetch ClickUp folders', {
63+
status: response.status,
64+
error: errorData,
65+
})
66+
return NextResponse.json(
67+
{ error: 'Failed to fetch ClickUp folders' },
68+
{ status: response.status }
69+
)
70+
}
71+
72+
const data = (await response.json().catch(() => ({}))) as {
73+
folders?: ClickUpNamedResource[]
74+
}
75+
const folders = (Array.isArray(data.folders) ? data.folders : []).map((item) => ({
76+
id: String(item.id),
77+
name: item.name || `Folder ${item.id}`,
78+
}))
79+
80+
return NextResponse.json({ folders })
81+
})
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { clickupListsSelectorContract } from '@/lib/api/contracts/selectors'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
6+
import { generateRequestId } from '@/lib/core/utils/request'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
9+
import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared'
10+
11+
const logger = createLogger('ClickUpListsAPI')
12+
13+
export const dynamic = 'force-dynamic'
14+
15+
interface ClickUpNamedResource {
16+
id: string | number
17+
name?: string
18+
}
19+
20+
export const POST = withRouteHandler(async (request: NextRequest) => {
21+
const requestId = generateRequestId()
22+
const parsed = await parseRequest(clickupListsSelectorContract, request, {})
23+
if (!parsed.success) return parsed.response
24+
const { credential, workflowId, folderId, spaceId } = parsed.data.body
25+
26+
const authz = await authorizeCredentialUse(request, {
27+
credentialId: credential,
28+
workflowId,
29+
})
30+
if (!authz.ok || !authz.credentialOwnerUserId) {
31+
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
32+
}
33+
34+
const accessToken = await refreshAccessTokenIfNeeded(
35+
credential,
36+
authz.credentialOwnerUserId,
37+
requestId
38+
)
39+
if (!accessToken) {
40+
logger.error('Failed to get access token', {
41+
credentialId: credential,
42+
userId: authz.credentialOwnerUserId,
43+
})
44+
return NextResponse.json(
45+
{ error: 'Could not retrieve access token', authRequired: true },
46+
{ status: 401 }
47+
)
48+
}
49+
50+
const response = await fetch(
51+
folderId?.trim()
52+
? `${CLICKUP_API_BASE_URL}/folder/${encodeURIComponent(folderId.trim())}/list`
53+
: `${CLICKUP_API_BASE_URL}/space/${encodeURIComponent((spaceId ?? '').trim())}/list`,
54+
{
55+
headers: {
56+
Authorization: clickupAuthorizationHeader(accessToken),
57+
Accept: 'application/json',
58+
},
59+
}
60+
)
61+
62+
if (!response.ok) {
63+
const errorData = await response.json().catch(() => ({}))
64+
logger.error('Failed to fetch ClickUp lists', {
65+
status: response.status,
66+
error: errorData,
67+
})
68+
return NextResponse.json(
69+
{ error: 'Failed to fetch ClickUp lists' },
70+
{ status: response.status }
71+
)
72+
}
73+
74+
const data = (await response.json().catch(() => ({}))) as {
75+
lists?: ClickUpNamedResource[]
76+
}
77+
const lists = (Array.isArray(data.lists) ? data.lists : []).map((item) => ({
78+
id: String(item.id),
79+
name: item.name || `List ${item.id}`,
80+
}))
81+
82+
return NextResponse.json({ lists })
83+
})
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { clickupSpacesSelectorContract } from '@/lib/api/contracts/selectors'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
6+
import { generateRequestId } from '@/lib/core/utils/request'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
9+
import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared'
10+
11+
const logger = createLogger('ClickUpSpacesAPI')
12+
13+
export const dynamic = 'force-dynamic'
14+
15+
interface ClickUpNamedResource {
16+
id: string | number
17+
name?: string
18+
}
19+
20+
export const POST = withRouteHandler(async (request: NextRequest) => {
21+
const requestId = generateRequestId()
22+
const parsed = await parseRequest(clickupSpacesSelectorContract, request, {})
23+
if (!parsed.success) return parsed.response
24+
const { credential, workflowId, teamId } = parsed.data.body
25+
26+
const authz = await authorizeCredentialUse(request, {
27+
credentialId: credential,
28+
workflowId,
29+
})
30+
if (!authz.ok || !authz.credentialOwnerUserId) {
31+
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
32+
}
33+
34+
const accessToken = await refreshAccessTokenIfNeeded(
35+
credential,
36+
authz.credentialOwnerUserId,
37+
requestId
38+
)
39+
if (!accessToken) {
40+
logger.error('Failed to get access token', {
41+
credentialId: credential,
42+
userId: authz.credentialOwnerUserId,
43+
})
44+
return NextResponse.json(
45+
{ error: 'Could not retrieve access token', authRequired: true },
46+
{ status: 401 }
47+
)
48+
}
49+
50+
const response = await fetch(`${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(teamId)}/space`, {
51+
headers: {
52+
Authorization: clickupAuthorizationHeader(accessToken),
53+
Accept: 'application/json',
54+
},
55+
})
56+
57+
if (!response.ok) {
58+
const errorData = await response.json().catch(() => ({}))
59+
logger.error('Failed to fetch ClickUp spaces', {
60+
status: response.status,
61+
error: errorData,
62+
})
63+
return NextResponse.json(
64+
{ error: 'Failed to fetch ClickUp spaces' },
65+
{ status: response.status }
66+
)
67+
}
68+
69+
const data = (await response.json().catch(() => ({}))) as {
70+
spaces?: ClickUpNamedResource[]
71+
}
72+
const spaces = (Array.isArray(data.spaces) ? data.spaces : []).map((item) => ({
73+
id: String(item.id),
74+
name: item.name || `Space ${item.id}`,
75+
}))
76+
77+
return NextResponse.json({ spaces })
78+
})
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { clickupWorkspacesSelectorContract } from '@/lib/api/contracts/selectors'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
6+
import { generateRequestId } from '@/lib/core/utils/request'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
9+
import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared'
10+
11+
const logger = createLogger('ClickUpWorkspacesAPI')
12+
13+
export const dynamic = 'force-dynamic'
14+
15+
interface ClickUpTeam {
16+
id: string | number
17+
name?: string
18+
}
19+
20+
export const POST = withRouteHandler(async (request: NextRequest) => {
21+
const requestId = generateRequestId()
22+
const parsed = await parseRequest(clickupWorkspacesSelectorContract, request, {})
23+
if (!parsed.success) return parsed.response
24+
const { credential, workflowId } = parsed.data.body
25+
26+
const authz = await authorizeCredentialUse(request, {
27+
credentialId: credential,
28+
workflowId,
29+
})
30+
if (!authz.ok || !authz.credentialOwnerUserId) {
31+
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
32+
}
33+
34+
const accessToken = await refreshAccessTokenIfNeeded(
35+
credential,
36+
authz.credentialOwnerUserId,
37+
requestId
38+
)
39+
if (!accessToken) {
40+
logger.error('Failed to get access token', {
41+
credentialId: credential,
42+
userId: authz.credentialOwnerUserId,
43+
})
44+
return NextResponse.json(
45+
{ error: 'Could not retrieve access token', authRequired: true },
46+
{ status: 401 }
47+
)
48+
}
49+
50+
const response = await fetch(`${CLICKUP_API_BASE_URL}/team`, {
51+
headers: {
52+
Authorization: clickupAuthorizationHeader(accessToken),
53+
Accept: 'application/json',
54+
},
55+
})
56+
57+
if (!response.ok) {
58+
const errorData = await response.json().catch(() => ({}))
59+
logger.error('Failed to fetch ClickUp workspaces', {
60+
status: response.status,
61+
error: errorData,
62+
})
63+
return NextResponse.json(
64+
{ error: 'Failed to fetch ClickUp workspaces' },
65+
{ status: response.status }
66+
)
67+
}
68+
69+
const data = (await response.json().catch(() => ({}))) as { teams?: ClickUpTeam[] }
70+
const workspaces = (Array.isArray(data.teams) ? data.teams : []).map((team) => ({
71+
id: String(team.id),
72+
name: team.name || `Workspace ${team.id}`,
73+
}))
74+
75+
return NextResponse.json({ workspaces })
76+
})

0 commit comments

Comments
 (0)